diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts index 9cbbf05101b1..7bfdc4288636 100644 --- a/src/cli/plugins-list-command.ts +++ b/src/cli/plugins-list-command.ts @@ -44,7 +44,8 @@ export async function runPluginsListCommand( runtime: RuntimeEnv = defaultRuntime, ): Promise { const { buildPluginRegistrySnapshotReport } = await import("../plugins/status-snapshot.js"); - const cfg = getRuntimeConfig(); + // The inventory projector owns plugin metadata validation from the installed index. + const cfg = getRuntimeConfig({ skipPluginValidation: true }); const report = buildPluginRegistrySnapshotReport({ config: cfg, ...(opts.json ? { logger: quietPluginJsonLogger } : {}), diff --git a/src/cli/plugins-list-route-cold-imports.test.ts b/src/cli/plugins-list-route-cold-imports.test.ts new file mode 100644 index 000000000000..15e6946fcbb1 --- /dev/null +++ b/src/cli/plugins-list-route-cold-imports.test.ts @@ -0,0 +1,96 @@ +// The default plugin inventory route must render manifest facts without executing plugin code. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + createColdPluginFixture, + isColdPluginRuntimeLoaded, +} from "../plugins/test-helpers/cold-plugin-fixtures.js"; + +const testState = vi.hoisted(() => ({ + config: {} as OpenClawConfig, + loadedModules: new Set(), + logs: [] as string[], +})); + +vi.mock("./command-execution-startup.js", () => ({ + applyCliExecutionStartupPresentation: vi.fn(async () => {}), + ensureCliExecutionBootstrap: vi.fn(async () => {}), + resolveCliExecutionStartupContext: vi.fn(() => ({ + startupPolicy: { loadPlugins: false, suppressDoctorStdout: true }, + })), +})); + +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => testState.config, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: { + error: vi.fn(), + exit: vi.fn(), + log: (...args: unknown[]) => testState.logs.push(args.map(String).join(" ")), + writeJson: vi.fn(), + writeStdout: vi.fn(), + }, + writeRuntimeJson: vi.fn(), +})); + +vi.mock("./plugins-cli.js", () => { + testState.loadedModules.add("plugins-cli"); + return { registerPluginsCli: vi.fn() }; +}); + +vi.mock("../plugins/loader-module-runtime.js", () => { + testState.loadedModules.add("plugin-module-runtime"); + return {}; +}); + +import { tryRouteCli } from "./route.js"; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugins-list-route-")); + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +it("renders the default list from metadata without loading plugin modules", async () => { + const pluginRoot = path.join(tempRoot, "route-demo"); + fs.mkdirSync(pluginRoot, { recursive: true }); + const fixture = createColdPluginFixture({ + rootDir: pluginRoot, + pluginId: "route-demo", + packageName: "@example/route-demo", + packageVersion: "4.2.0", + manifest: { + name: "Route Demo", + description: "Prepared inventory metadata", + }, + }); + const bundledRoot = path.join(tempRoot, "bundled"); + fs.mkdirSync(bundledRoot, { recursive: true }); + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", bundledRoot); + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1"); + vi.stubEnv("OPENCLAW_HOME", path.join(tempRoot, "home")); + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(tempRoot, "state")); + testState.config = { + plugins: { + load: { paths: [pluginRoot] }, + entries: { "route-demo": { enabled: true } }, + }, + }; + + await expect(tryRouteCli(["node", "openclaw", "plugins", "list"])).resolves.toBe(true); + + const output = testState.logs.join("\n"); + expect(output).toContain("Route Demo"); + expect(output).toContain("route-demo"); + expect(output).toContain("Prepared inventory metadata"); + expect(output).toContain("4.2.0"); + expect(isColdPluginRuntimeLoaded(fixture)).toBe(false); + expect(testState.loadedModules).not.toContain("plugin-module-runtime"); + expect(testState.loadedModules).not.toContain("plugins-cli"); +}); diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index 9049999fe30e..f1fedea17ec5 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -449,11 +449,8 @@ export function parseChannelsStatusRouteArgs(argv: string[]) { }; } -/** Parse JSON-only `openclaw plugins list` flags for plugin inventory output. */ +/** Parse `openclaw plugins list` flags for the metadata-only inventory path. */ export function parsePluginsListRouteArgs(argv: string[]) { - if (!hasFlag(argv, "--json")) { - return null; - } const positionals = getRoutedCommandPositionals(argv, { commandPath: ["plugins", "list"], booleanFlags: ["--json", "--enabled", "--verbose"], @@ -462,7 +459,7 @@ export function parsePluginsListRouteArgs(argv: string[]) { return null; } return { - json: true as const, + json: hasFlag(argv, "--json"), enabled: hasFlag(argv, "--enabled"), verbose: hasFlag(argv, "--verbose"), }; diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 800831f6fa53..38c51378bdc9 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -169,27 +169,41 @@ describe("program routes", () => { ); }); - it("routes plugins list JSON without importing the full plugins CLI", async () => { - const route = expectRoute(["plugins", "list"]); - expect(route.loadPlugins).toBeUndefined(); - expect(route.canRun?.(["node", "openclaw", "plugins", "list"])).toBe(false); + it.each([ + { label: "default", flags: [], options: { json: false, enabled: false, verbose: false } }, + { + label: "enabled", + flags: ["--enabled"], + options: { json: false, enabled: true, verbose: false }, + }, + { + label: "verbose", + flags: ["--verbose"], + options: { json: false, enabled: false, verbose: true }, + }, + { + label: "JSON", + flags: ["--json", "--enabled", "--verbose"], + options: { json: true, enabled: true, verbose: true }, + }, + ])( + "routes plugins list $label without importing the full plugins CLI", + async ({ flags, options }) => { + const route = expectRoute(["plugins", "list"]); + expect(route.loadPlugins).toBeUndefined(); + expect(route.canRun?.(["node", "openclaw", "plugins", "list", ...flags])).toBe(true); - await expect( - route.run(["node", "openclaw", "plugins", "list", "--json", "--enabled", "--verbose"]), - ).resolves.toBe(true); + await expect(route.run(["node", "openclaw", "plugins", "list", ...flags])).resolves.toBe( + true, + ); - expect(runPluginsListCommandMock).toHaveBeenCalledWith( - { json: true, enabled: true, verbose: true }, - defaultRuntime, - ); - expect(pluginsCliLoadedMock).not.toHaveBeenCalled(); - }); + expect(runPluginsListCommandMock).toHaveBeenCalledWith(options, defaultRuntime); + expect(pluginsCliLoadedMock).not.toHaveBeenCalled(); + }, + ); - it("returns false for plugins list JSON route with unsupported arguments", async () => { - await expectRunFalse( - ["plugins", "list"], - ["node", "openclaw", "plugins", "list", "--json", "--wat"], - ); + it("returns false for plugins list route with unsupported arguments", async () => { + await expectRunFalse(["plugins", "list"], ["node", "openclaw", "plugins", "list", "--wat"]); }); it("matches gateway status route without plugin preload", () => {