diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts new file mode 100644 index 00000000000..7a651822341 --- /dev/null +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -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 /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"); + }); +}); diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 4e2d271bee2..6480ba3fd3c 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import type { PluginPackageChannel } from "../plugins/manifest.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); } -function listBundledExtensionPackageJsonPaths(): string[] { - for (const packageRoot of listPackageRoots()) { - const extensionsRoot = path.join(packageRoot, "extensions"); - if (!fs.existsSync(extensionsRoot)) { - 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 { - continue; - } +function listBundledExtensionPackageJsonPaths(env: NodeJS.ProcessEnv = process.env): string[] { + // Delegate to the plugin loader's resolver so channel metadata stays in lock + // step with whichever bundled plugin tree is actually loaded at runtime + // (source extensions/ in dev/test, dist/extensions in published installs, + // dist-runtime/extensions when paired with dist, etc.). See + // src/plugins/bundled-dir.ts for the full candidate-order policy and + // src/plugins/bundled-dir.test.ts for the precedence coverage. Reusing the + // resolver also picks up OPENCLAW_BUNDLED_PLUGINS_DIR overrides and the + // bun --compile sibling layout for free. + const extensionsRoot = resolveBundledPluginsDir(env); + if (!extensionsRoot) { + return []; + } + 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[] {