mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:40:44 +00:00
fix(onboard): crash at channel selection on globally installed CLI (#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>
This commit is contained in:
145
src/channels/bundled-channel-catalog-read.test.ts
Normal file
145
src/channels/bundled-channel-catalog-read.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js";
|
||||||
|
|
||||||
|
// Delegate to the plugin-dir resolver for candidate-order policy; mock it here
|
||||||
|
// so these tests focus on the loader's responsibility (parse package.jsons in
|
||||||
|
// the returned dir, fall back to dist/channel-catalog.json when empty). The
|
||||||
|
// precedence policy (source vs dist-runtime vs dist, VITEST/tsx source-first,
|
||||||
|
// isSourceCheckoutRoot detection, etc.) is exercised in
|
||||||
|
// src/plugins/bundled-dir.test.ts and is intentionally not re-tested here.
|
||||||
|
vi.mock("../plugins/bundled-dir.js", () => ({
|
||||||
|
resolveBundledPluginsDir: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The channel-catalog.json fallback still walks package roots via
|
||||||
|
// resolveOpenClawPackageRootSync. Isolate from the real repo by mocking
|
||||||
|
// moduleUrl/argv1 resolution to null and deriving only from the tmp cwd.
|
||||||
|
vi.mock("../infra/openclaw-root.js", () => ({
|
||||||
|
resolveOpenClawPackageRootSync: (opts: { cwd?: string; argv1?: string; moduleUrl?: string }) =>
|
||||||
|
opts.cwd ?? null,
|
||||||
|
resolveOpenClawPackageRoot: async (opts: { cwd?: string; argv1?: string; moduleUrl?: string }) =>
|
||||||
|
opts.cwd ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
|
||||||
|
import { listBundledChannelCatalogEntries } from "./bundled-channel-catalog-read.js";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupTempDirs(tempDirs);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.mocked(resolveBundledPluginsDir).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedRoot(prefix: string): string {
|
||||||
|
const root = makeTempRepoRoot(tempDirs, prefix);
|
||||||
|
writeJsonFile(path.join(root, "package.json"), { name: "openclaw" });
|
||||||
|
vi.spyOn(process, "cwd").mockReturnValue(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedChannelPkg(
|
||||||
|
pkgJsonPath: string,
|
||||||
|
opts: { id: string; docsPath: string; label?: string; blurb?: string },
|
||||||
|
): void {
|
||||||
|
writeJsonFile(pkgJsonPath, {
|
||||||
|
name: `@openclaw/${opts.id}`,
|
||||||
|
openclaw: {
|
||||||
|
channel: {
|
||||||
|
id: opts.id,
|
||||||
|
label: opts.label ?? opts.id,
|
||||||
|
docsPath: opts.docsPath,
|
||||||
|
blurb: opts.blurb ?? "test blurb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("listBundledChannelCatalogEntries", () => {
|
||||||
|
it("reads bundled channel metadata from the extensions dir returned by resolveBundledPluginsDir", () => {
|
||||||
|
// Regression gate for the onboard crash on globally installed CLI: in a
|
||||||
|
// published install, resolveBundledPluginsDir returns <pkgRoot>/dist/extensions.
|
||||||
|
// Verify the loader iterates that tree and surfaces bundled channels such as
|
||||||
|
// telegram, which are not in dist/channel-catalog.json (filtered to
|
||||||
|
// release.publishToNpm === true) and therefore invisible to the fallback.
|
||||||
|
const root = seedRoot("bcr-resolved-");
|
||||||
|
const extensionsRoot = path.join(root, "dist", "extensions");
|
||||||
|
seedChannelPkg(path.join(extensionsRoot, "telegram", "package.json"), {
|
||||||
|
id: "telegram",
|
||||||
|
docsPath: "/channels/telegram",
|
||||||
|
label: "Telegram",
|
||||||
|
});
|
||||||
|
seedChannelPkg(path.join(extensionsRoot, "imessage", "package.json"), {
|
||||||
|
id: "imessage",
|
||||||
|
docsPath: "/channels/imessage",
|
||||||
|
});
|
||||||
|
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
|
||||||
|
|
||||||
|
const entries = listBundledChannelCatalogEntries();
|
||||||
|
|
||||||
|
const ids = entries.map((entry) => entry.id).toSorted();
|
||||||
|
expect(ids).toEqual(["imessage", "telegram"]);
|
||||||
|
const telegram = entries.find((entry) => entry.id === "telegram");
|
||||||
|
expect(telegram?.channel.docsPath).toBe("/channels/telegram");
|
||||||
|
expect(telegram?.channel.label).toBe("Telegram");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to dist/channel-catalog.json when the resolver returns undefined", () => {
|
||||||
|
// OPENCLAW_DISABLE_BUNDLED_PLUGINS, missing bundled tree, or an unresolvable
|
||||||
|
// package root all surface as undefined from resolveBundledPluginsDir. In
|
||||||
|
// that case the loader should consult the shipped channel-catalog.json
|
||||||
|
// rather than report zero bundled channels.
|
||||||
|
const root = seedRoot("bcr-fallback-undefined-");
|
||||||
|
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: "@openclaw/fallback",
|
||||||
|
openclaw: {
|
||||||
|
channel: {
|
||||||
|
id: "fallback-channel",
|
||||||
|
label: "Fallback",
|
||||||
|
docsPath: "/channels/fallback",
|
||||||
|
blurb: "fallback blurb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(resolveBundledPluginsDir).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const entries = listBundledChannelCatalogEntries();
|
||||||
|
expect(entries.map((entry) => entry.id)).toContain("fallback-channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to dist/channel-catalog.json when the resolved dir has no plugin package.jsons", () => {
|
||||||
|
// A stale staged dir or an OPENCLAW_BUNDLED_PLUGINS_DIR override pointing at
|
||||||
|
// an empty tree should not hide the shipped catalog entries. The loader's
|
||||||
|
// own readdir returns nothing, bundledEntries is empty, and control falls
|
||||||
|
// through to readOfficialCatalogFileSync.
|
||||||
|
const root = seedRoot("bcr-fallback-empty-");
|
||||||
|
const extensionsRoot = path.join(root, "dist", "extensions");
|
||||||
|
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||||
|
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: "@openclaw/fallback",
|
||||||
|
openclaw: {
|
||||||
|
channel: {
|
||||||
|
id: "fallback-channel",
|
||||||
|
label: "Fallback",
|
||||||
|
docsPath: "/channels/fallback",
|
||||||
|
blurb: "fallback blurb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot);
|
||||||
|
|
||||||
|
const entries = listBundledChannelCatalogEntries();
|
||||||
|
expect(entries.map((entry) => entry.id)).toContain("fallback-channel");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||||
|
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
|
||||||
import type { PluginPackageChannel } from "../plugins/manifest.js";
|
import type { PluginPackageChannel } from "../plugins/manifest.js";
|
||||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||||
|
|
||||||
@@ -26,23 +27,28 @@ function listPackageRoots(): string[] {
|
|||||||
].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index);
|
].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listBundledExtensionPackageJsonPaths(): string[] {
|
function listBundledExtensionPackageJsonPaths(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||||
for (const packageRoot of listPackageRoots()) {
|
// Delegate to the plugin loader's resolver so channel metadata stays in lock
|
||||||
const extensionsRoot = path.join(packageRoot, "extensions");
|
// step with whichever bundled plugin tree is actually loaded at runtime
|
||||||
if (!fs.existsSync(extensionsRoot)) {
|
// (source extensions/ in dev/test, dist/extensions in published installs,
|
||||||
continue;
|
// dist-runtime/extensions when paired with dist, etc.). See
|
||||||
}
|
// src/plugins/bundled-dir.ts for the full candidate-order policy and
|
||||||
try {
|
// src/plugins/bundled-dir.test.ts for the precedence coverage. Reusing the
|
||||||
return fs
|
// resolver also picks up OPENCLAW_BUNDLED_PLUGINS_DIR overrides and the
|
||||||
.readdirSync(extensionsRoot, { withFileTypes: true })
|
// bun --compile sibling layout for free.
|
||||||
.filter((entry) => entry.isDirectory())
|
const extensionsRoot = resolveBundledPluginsDir(env);
|
||||||
.map((entry) => path.join(extensionsRoot, entry.name, "package.json"))
|
if (!extensionsRoot) {
|
||||||
.filter((entry) => fs.existsSync(entry));
|
return [];
|
||||||
} catch {
|
}
|
||||||
continue;
|
try {
|
||||||
}
|
return fs
|
||||||
|
.readdirSync(extensionsRoot, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => path.join(extensionsRoot, entry.name, "package.json"))
|
||||||
|
.filter((entry) => fs.existsSync(entry));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] {
|
function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user