Skip to content

Commit 2e2cbdd

Browse files
rjchien728claude
andauthored
fix(onboard): crash at channel selection on globally installed CLI (openclaw#66736)
* fix(channels): resolve bundled channel catalog from dist/extensions/ in published installs * refactor(channels): delegate bundled channel catalog loader to resolveBundledPluginsDir --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd3e6e1 commit 2e2cbdd

2 files changed

Lines changed: 167 additions & 16 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js";
5+
6+
// Delegate to the plugin-dir resolver for candidate-order policy; mock it here
7+
// so these tests focus on the loader's responsibility (parse package.jsons in
8+
// the returned dir, fall back to dist/channel-catalog.json when empty). The
9+
// precedence policy (source vs dist-runtime vs dist, VITEST/tsx source-first,
10+
// isSourceCheckoutRoot detection, etc.) is exercised in
11+
// src/plugins/bundled-dir.test.ts and is intentionally not re-tested here.
12+
vi.mock("../plugins/bundled-dir.js", () => ({
13+
resolveBundledPluginsDir: vi.fn(),
14+
}));
15+
16+
// The channel-catalog.json fallback still walks package roots via
17+
// resolveOpenClawPackageRootSync. Isolate from the real repo by mocking
18+
// moduleUrl/argv1 resolution to null and deriving only from the tmp cwd.
19+
vi.mock("../infra/openclaw-root.js", () => ({
20+
resolveOpenClawPackageRootSync: (opts: { cwd?: string; argv1?: string; moduleUrl?: string }) =>
21+
opts.cwd ?? null,
22+
resolveOpenClawPackageRoot: async (opts: { cwd?: string; argv1?: string; moduleUrl?: string }) =>
23+
opts.cwd ?? null,
24+
}));
25+
26+
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
27+
import { listBundledChannelCatalogEntries } from "./bundled-channel-catalog-read.js";
28+
29+
const tempDirs: string[] = [];
30+
31+
afterEach(() => {
32+
cleanupTempDirs(tempDirs);
33+
vi.restoreAllMocks();
34+
vi.mocked(resolveBundledPluginsDir).mockReset();
35+
});
36+
37+
function seedRoot(prefix: string): string {
38+
const root = makeTempRepoRoot(tempDirs, prefix);
39+
writeJsonFile(path.join(root, "package.json"), { name: "openclaw" });
40+
vi.spyOn(process, "cwd").mockReturnValue(root);
41+
return root;
42+
}
43+
44+
function seedChannelPkg(
45+
pkgJsonPath: string,
46+
opts: { id: string; docsPath: string; label?: string; blurb?: string },
47+
): void {
48+
writeJsonFile(pkgJsonPath, {
49+
name: `@openclaw/${opts.id}`,
50+
openclaw: {
51+
channel: {
52+
id: opts.id,
53+
label: opts.label ?? opts.id,
54+
docsPath: opts.docsPath,
55+
blurb: opts.blurb ?? "test blurb",
56+
},
57+
},
58+
});
59+
}
60+
61+
describe("listBundledChannelCatalogEntries", () => {
62+
it("reads bundled channel metadata from the extensions dir returned by resolveBundledPluginsDir", () => {
63+
// Regression gate for the onboard crash on globally installed CLI: in a
64+
// published install, resolveBundledPluginsDir returns <pkgRoot>/dist/extensions.
65+
// Verify the loader iterates that tree and surfaces bundled channels such as
66+
// telegram, which are not in dist/channel-catalog.json (filtered to
67+
// release.publishToNpm === true) and therefore invisible to the fallback.
68+
const root = seedRoot("bcr-resolved-");
69+
const extensionsRoot = path.join(root, "dist", "extensions");
70+
seedChannelPkg(path.join(extensionsRoot, "telegram", "package.json"), {
71+
id: "telegram",
72+
docsPath: "/channels/telegram",
73+
label: "Telegram",
74+
});
75+
seedChannelPkg(path.join(extensionsRoot, "imessage", "package.json"), {
76+
id: "imessage",
77+
docsPath: "/channels/imessage",
78+
});
79+
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
80+
81+
const entries = listBundledChannelCatalogEntries();
82+
83+
const ids = entries.map((entry) => entry.id).toSorted();
84+
expect(ids).toEqual(["imessage", "telegram"]);
85+
const telegram = entries.find((entry) => entry.id === "telegram");
86+
expect(telegram?.channel.docsPath).toBe("/channels/telegram");
87+
expect(telegram?.channel.label).toBe("Telegram");
88+
});
89+
90+
it("falls back to dist/channel-catalog.json when the resolver returns undefined", () => {
91+
// OPENCLAW_DISABLE_BUNDLED_PLUGINS, missing bundled tree, or an unresolvable
92+
// package root all surface as undefined from resolveBundledPluginsDir. In
93+
// that case the loader should consult the shipped channel-catalog.json
94+
// rather than report zero bundled channels.
95+
const root = seedRoot("bcr-fallback-undefined-");
96+
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
97+
entries: [
98+
{
99+
name: "@openclaw/fallback",
100+
openclaw: {
101+
channel: {
102+
id: "fallback-channel",
103+
label: "Fallback",
104+
docsPath: "/channels/fallback",
105+
blurb: "fallback blurb",
106+
},
107+
},
108+
},
109+
],
110+
});
111+
vi.mocked(resolveBundledPluginsDir).mockReturnValue(undefined);
112+
113+
const entries = listBundledChannelCatalogEntries();
114+
expect(entries.map((entry) => entry.id)).toContain("fallback-channel");
115+
});
116+
117+
it("falls back to dist/channel-catalog.json when the resolved dir has no plugin package.jsons", () => {
118+
// A stale staged dir or an OPENCLAW_BUNDLED_PLUGINS_DIR override pointing at
119+
// an empty tree should not hide the shipped catalog entries. The loader's
120+
// own readdir returns nothing, bundledEntries is empty, and control falls
121+
// through to readOfficialCatalogFileSync.
122+
const root = seedRoot("bcr-fallback-empty-");
123+
const extensionsRoot = path.join(root, "dist", "extensions");
124+
fs.mkdirSync(extensionsRoot, { recursive: true });
125+
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
126+
entries: [
127+
{
128+
name: "@openclaw/fallback",
129+
openclaw: {
130+
channel: {
131+
id: "fallback-channel",
132+
label: "Fallback",
133+
docsPath: "/channels/fallback",
134+
blurb: "fallback blurb",
135+
},
136+
},
137+
},
138+
],
139+
});
140+
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
141+
142+
const entries = listBundledChannelCatalogEntries();
143+
expect(entries.map((entry) => entry.id)).toContain("fallback-channel");
144+
});
145+
});

src/channels/bundled-channel-catalog-read.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs";
22
import path from "node:path";
33
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
4+
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
45
import type { PluginPackageChannel } from "../plugins/manifest.js";
56
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
67

@@ -26,23 +27,28 @@ function listPackageRoots(): string[] {
2627
].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index);
2728
}
2829

29-
function listBundledExtensionPackageJsonPaths(): string[] {
30-
for (const packageRoot of listPackageRoots()) {
31-
const extensionsRoot = path.join(packageRoot, "extensions");
32-
if (!fs.existsSync(extensionsRoot)) {
33-
continue;
34-
}
35-
try {
36-
return fs
37-
.readdirSync(extensionsRoot, { withFileTypes: true })
38-
.filter((entry) => entry.isDirectory())
39-
.map((entry) => path.join(extensionsRoot, entry.name, "package.json"))
40-
.filter((entry) => fs.existsSync(entry));
41-
} catch {
42-
continue;
43-
}
30+
function listBundledExtensionPackageJsonPaths(env: NodeJS.ProcessEnv = process.env): string[] {
31+
// Delegate to the plugin loader's resolver so channel metadata stays in lock
32+
// step with whichever bundled plugin tree is actually loaded at runtime
33+
// (source extensions/ in dev/test, dist/extensions in published installs,
34+
// dist-runtime/extensions when paired with dist, etc.). See
35+
// src/plugins/bundled-dir.ts for the full candidate-order policy and
36+
// src/plugins/bundled-dir.test.ts for the precedence coverage. Reusing the
37+
// resolver also picks up OPENCLAW_BUNDLED_PLUGINS_DIR overrides and the
38+
// bun --compile sibling layout for free.
39+
const extensionsRoot = resolveBundledPluginsDir(env);
40+
if (!extensionsRoot) {
41+
return [];
42+
}
43+
try {
44+
return fs
45+
.readdirSync(extensionsRoot, { withFileTypes: true })
46+
.filter((entry) => entry.isDirectory())
47+
.map((entry) => path.join(extensionsRoot, entry.name, "package.json"))
48+
.filter((entry) => fs.existsSync(entry));
49+
} catch {
50+
return [];
4451
}
45-
return [];
4652
}
4753

4854
function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] {

0 commit comments

Comments
 (0)