diff --git a/CHANGELOG.md b/CHANGELOG.md index ba17acf99499..b6c9ad82bf8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog. - CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn. - CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn. +- CLI/perf: keep `secrets --help` and `nodes --help` on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn. ## 2026.5.20 diff --git a/openclaw.mjs b/openclaw.mjs index e5e6662ed2cc..cbe178c21ebc 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -328,8 +328,21 @@ const buildMissingEntryErrorMessage = async () => { const isBareRootHelpInvocation = (argv) => argv.length === 3 && (argv[2] === "--help" || argv[2] === "-h"); -const isBrowserHelpInvocation = (argv) => - argv.length === 4 && argv[2] === "browser" && (argv[3] === "--help" || argv[3] === "-h"); +const resolvePrecomputedCommandHelp = (argv) => { + if (argv.length !== 4 || (argv[3] !== "--help" && argv[3] !== "-h")) { + return null; + } + if (argv[2] === "browser") { + return { command: "browser", metadataKey: "browserHelpText" }; + } + if (argv[2] === "secrets") { + return { command: "secrets", metadataKey: "secretsHelpText" }; + } + if (argv[2] === "nodes") { + return { command: "nodes", metadataKey: "nodesHelpText" }; + } + return null; +}; const isHelpFastPathDisabled = () => process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1"; @@ -440,11 +453,15 @@ const tryOutputBareRootHelp = async () => { return false; }; -const tryOutputBrowserHelp = () => { - if (!isBrowserHelpInvocation(process.argv)) { +const tryOutputPrecomputedCommandHelp = () => { + const commandHelp = resolvePrecomputedCommandHelp(process.argv); + if (!commandHelp) { return false; } - const precomputed = loadPrecomputedHelpText("browserHelpText"); + if (commandHelp.command === "nodes" && shouldDeferRootHelpToRuntimeEntry()) { + return false; + } + const precomputed = loadPrecomputedHelpText(commandHelp.metadataKey); if (!precomputed) { return false; } @@ -455,7 +472,7 @@ const tryOutputBrowserHelp = () => { if (!waitingForCompileCacheRespawn) { if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { // OK - } else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { + } else if (!isHelpFastPathDisabled() && tryOutputPrecomputedCommandHelp()) { // OK } else { await installProcessWarningFilter(); diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index 36b3ff1c0e01..1adbff1e07d2 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -27,6 +27,7 @@ const outputPath = path.join(distDir, "cli-startup-metadata.json"); const extensionsDir = path.join(rootDir, "extensions"); const ROOT_HELP_RENDER_TIMEOUT_MS = 120_000; const BROWSER_HELP_RENDER_TIMEOUT_MS = 120_000; +const COMMAND_HELP_RENDER_TIMEOUT_MS = 120_000; const CORE_CHANNEL_ORDER = [ "telegram", "whatsapp", @@ -71,26 +72,74 @@ function resolveRootHelpBundleIdentity( }; } -function updateHashFromFiles(hash: ReturnType, files: string[]) { +function updateHashFromFiles( + hash: ReturnType, + files: string[], + sourceRootDir: string = rootDir, +): void { for (const file of files.toSorted()) { - hash.update(`${path.relative(rootDir, file)}\0`); + hash.update(`${path.relative(sourceRootDir, file)}\0`); hash.update(readFileSync(file)); hash.update("\0"); } } -function resolveBrowserHelpSourceSignature(): string { +function resolveBrowserHelpSourceSignature(sourceRootDir: string = rootDir): string { const hash = createHash("sha1"); - const browserCliDir = path.join(rootDir, "extensions/browser/src/cli"); + const browserCliDir = path.join(sourceRootDir, "extensions/browser/src/cli"); const browserCliFiles = readdirSync(browserCliDir) .filter((entry) => entry.endsWith(".ts")) .map((entry) => path.join(browserCliDir, entry)); - updateHashFromFiles(hash, browserCliFiles); - updateHashFromFiles(hash, [ - path.join(rootDir, "src/cli/program/help.ts"), - path.join(rootDir, "src/cli/program/context.ts"), - path.join(rootDir, "src/cli/banner.ts"), - ]); + updateHashFromFiles(hash, browserCliFiles, sourceRootDir); + updateHashFromFiles( + hash, + [ + path.join(sourceRootDir, "src/cli/program/help.ts"), + path.join(sourceRootDir, "src/cli/program/context.ts"), + path.join(sourceRootDir, "src/cli/banner.ts"), + ], + sourceRootDir, + ); + return hash.digest("hex"); +} + +function resolveSecretsHelpSourceSignature(sourceRootDir: string = rootDir): string { + const hash = createHash("sha1"); + updateHashFromFiles( + hash, + [ + path.join(sourceRootDir, "src/cli/secrets-cli.ts"), + path.join(sourceRootDir, "src/cli/program/help.ts"), + path.join(sourceRootDir, "src/cli/program/context.ts"), + path.join(sourceRootDir, "src/cli/banner.ts"), + ], + sourceRootDir, + ); + return hash.digest("hex"); +} + +function resolveNodesHelpSourceSignature(sourceRootDir: string = rootDir): string { + const hash = createHash("sha1"); + const nodesCliDir = path.join(sourceRootDir, "src/cli/nodes-cli"); + const nodesCliFiles = readdirSync(nodesCliDir) + .filter((entry) => entry.endsWith(".ts") && !entry.endsWith(".test.ts")) + .map((entry) => path.join(nodesCliDir, entry)); + updateHashFromFiles(hash, nodesCliFiles, sourceRootDir); + updateHashFromFiles( + hash, + [ + path.join(sourceRootDir, "extensions/canvas/cli-metadata.ts"), + path.join(sourceRootDir, "extensions/canvas/index.ts"), + path.join(sourceRootDir, "extensions/canvas/src/a2ui-jsonl.ts"), + path.join(sourceRootDir, "extensions/canvas/src/cli-helpers.ts"), + path.join(sourceRootDir, "extensions/canvas/src/cli.ts"), + path.join(sourceRootDir, "src/cli/program/help.ts"), + path.join(sourceRootDir, "src/cli/program/context.ts"), + path.join(sourceRootDir, "src/cli/banner.ts"), + path.join(sourceRootDir, "src/plugins/register-plugin-cli-command-groups.ts"), + ], + sourceRootDir, + ); return hash.digest("hex"); } @@ -312,20 +361,68 @@ function renderSourceBrowserHelpText( return result.stdout ?? ""; } +function renderSourceCommandHelpText( + command: "nodes" | "secrets", + renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), +): string { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "openclaw.mjs", command, "--help"], + { + cwd: rootDir, + encoding: "utf8", + env: { + ...renderContext.env, + OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH: "1", + }, + timeout: COMMAND_HELP_RENDER_TIMEOUT_MS, + }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + throw new Error( + `Failed to render source ${command} help` + + (stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""), + ); + } + return result.stdout ?? ""; +} + +function renderSourceSecretsHelpText( + renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), +): string { + return renderSourceCommandHelpText("secrets", renderContext); +} + +function renderSourceNodesHelpText( + renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), +): string { + return renderSourceCommandHelpText("nodes", renderContext); +} + export async function writeCliStartupMetadata(options?: { distDir?: string; outputPath?: string; extensionsDir?: string; + sourceRootDir?: string; renderBundledRootHelpText?: typeof renderBundledRootHelpText; renderSourceRootHelpText?: typeof renderSourceRootHelpText; renderSourceBrowserHelpText?: typeof renderSourceBrowserHelpText; + renderSourceSecretsHelpText?: typeof renderSourceSecretsHelpText; + renderSourceNodesHelpText?: typeof renderSourceNodesHelpText; }): Promise { const resolvedDistDir = options?.distDir ?? distDir; const resolvedOutputPath = options?.outputPath ?? outputPath; const resolvedExtensionsDir = options?.extensionsDir ?? extensionsDir; + const resolvedSourceRootDir = options?.sourceRootDir ?? rootDir; const channelCatalog = readBundledChannelCatalog(resolvedExtensionsDir); const bundleIdentity = resolveRootHelpBundleIdentity(resolvedDistDir); - const browserHelpSourceSignature = resolveBrowserHelpSourceSignature(); + const browserHelpSourceSignature = resolveBrowserHelpSourceSignature(resolvedSourceRootDir); + const secretsHelpSourceSignature = resolveSecretsHelpSourceSignature(resolvedSourceRootDir); + const nodesHelpSourceSignature = resolveNodesHelpSourceSignature(resolvedSourceRootDir); const bundledPluginsDir = path.join(resolvedDistDir, "extensions"); const renderContext = createIsolatedRootHelpRenderContext( existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir, @@ -336,16 +433,26 @@ export async function writeCliStartupMetadata(options?: { const existing = JSON.parse(readFileSync(resolvedOutputPath, "utf8")) as { rootHelpBundleSignature?: unknown; browserHelpSourceSignature?: unknown; + secretsHelpSourceSignature?: unknown; + nodesHelpSourceSignature?: unknown; channelCatalogSignature?: unknown; browserHelpText?: unknown; + secretsHelpText?: unknown; + nodesHelpText?: unknown; }; if ( bundleIdentity && existing.rootHelpBundleSignature === bundleIdentity.signature && existing.browserHelpSourceSignature === browserHelpSourceSignature && + existing.secretsHelpSourceSignature === secretsHelpSourceSignature && + existing.nodesHelpSourceSignature === nodesHelpSourceSignature && existing.channelCatalogSignature === channelCatalog.signature && typeof existing.browserHelpText === "string" && - existing.browserHelpText.length > 0 + existing.browserHelpText.length > 0 && + typeof existing.secretsHelpText === "string" && + existing.secretsHelpText.length > 0 && + typeof existing.nodesHelpText === "string" && + existing.nodesHelpText.length > 0 ) { return; } @@ -365,6 +472,12 @@ export async function writeCliStartupMetadata(options?: { const browserHelpText = (options?.renderSourceBrowserHelpText ?? renderSourceBrowserHelpText)( renderContext, ); + const secretsHelpText = (options?.renderSourceSecretsHelpText ?? renderSourceSecretsHelpText)( + renderContext, + ); + const nodesHelpText = (options?.renderSourceNodesHelpText ?? renderSourceNodesHelpText)( + renderContext, + ); mkdirSync(resolvedDistDir, { recursive: true }); writeFileSync( @@ -376,7 +489,11 @@ export async function writeCliStartupMetadata(options?: { channelCatalogSignature: channelCatalog.signature, rootHelpBundleSignature: bundleIdentity?.signature ?? null, browserHelpSourceSignature, + secretsHelpSourceSignature, + nodesHelpSourceSignature, browserHelpText, + secretsHelpText, + nodesHelpText, rootHelpText, }, null, diff --git a/src/cli/help-cold-imports.test.ts b/src/cli/help-cold-imports.test.ts index 79582d921117..afaad21e0458 100644 --- a/src/cli/help-cold-imports.test.ts +++ b/src/cli/help-cold-imports.test.ts @@ -184,6 +184,42 @@ vi.mock("../commands/agents.commands.list.js", () => { return { agentsListCommand: vi.fn(async () => {}) }; }); +vi.mock("@clack/prompts", () => { + loaded.mark("clack-prompts"); + return { + confirm: vi.fn(async () => true), + }; +}); + +vi.mock("../secrets/apply.js", () => { + loaded.mark("secrets-apply-runtime"); + return { + runSecretsApply: vi.fn(async () => ({})), + }; +}); + +vi.mock("../secrets/audit.js", () => { + loaded.mark("secrets-audit-runtime"); + return { + resolveSecretsAuditExitCode: vi.fn(() => 0), + runSecretsAudit: vi.fn(async () => ({})), + }; +}); + +vi.mock("../secrets/configure.js", () => { + loaded.mark("secrets-configure-runtime"); + return { + runSecretsConfigureInteractive: vi.fn(async () => ({})), + }; +}); + +vi.mock("../secrets/plan.js", () => { + loaded.mark("secrets-plan-runtime"); + return { + isSecretsApplyPlan: vi.fn(() => true), + }; +}); + function makeProgram(): Command { const program = new Command(); program.name("openclaw"); @@ -297,4 +333,18 @@ describe("subcommand help cold imports", () => { expect(loaded.modules).not.toContain("agents-identity-command"); expect(loaded.modules).not.toContain("agents-list-command"); }); + + it("keeps secrets help out of secrets action modules", async () => { + const { registerSecretsCli } = await import("./secrets-cli.js"); + const program = makeProgram(); + + registerSecretsCli(program); + await expectHelpExit(program, ["secrets", "--help"]); + + expect(loaded.modules).not.toContain("clack-prompts"); + expect(loaded.modules).not.toContain("secrets-apply-runtime"); + expect(loaded.modules).not.toContain("secrets-audit-runtime"); + expect(loaded.modules).not.toContain("secrets-configure-runtime"); + expect(loaded.modules).not.toContain("secrets-plan-runtime"); + }); }); diff --git a/src/cli/root-help-metadata.ts b/src/cli/root-help-metadata.ts index 1c13e325b882..8b3709b20475 100644 --- a/src/cli/root-help-metadata.ts +++ b/src/cli/root-help-metadata.ts @@ -2,9 +2,17 @@ import { readCliStartupMetadata } from "./startup-metadata.js"; let precomputedRootHelpText: string | null | undefined; let precomputedBrowserHelpText: string | null | undefined; +let precomputedSecretsHelpText: string | null | undefined; +let precomputedNodesHelpText: string | null | undefined; + +type PrecomputedHelpTextKey = + | "rootHelpText" + | "browserHelpText" + | "secretsHelpText" + | "nodesHelpText"; function loadPrecomputedHelpText( - key: "rootHelpText" | "browserHelpText", + key: PrecomputedHelpTextKey, cache: string | null | undefined, setCache: (value: string | null) => void, ): string | null { @@ -39,6 +47,18 @@ export function loadPrecomputedBrowserHelpText(): string | null { }); } +export function loadPrecomputedSecretsHelpText(): string | null { + return loadPrecomputedHelpText("secretsHelpText", precomputedSecretsHelpText, (value) => { + precomputedSecretsHelpText = value; + }); +} + +export function loadPrecomputedNodesHelpText(): string | null { + return loadPrecomputedHelpText("nodesHelpText", precomputedNodesHelpText, (value) => { + precomputedNodesHelpText = value; + }); +} + export function outputPrecomputedRootHelpText(): boolean { const rootHelpText = loadPrecomputedRootHelpText(); if (!rootHelpText) { @@ -57,10 +77,30 @@ export function outputPrecomputedBrowserHelpText(): boolean { return true; } +export function outputPrecomputedSecretsHelpText(): boolean { + const secretsHelpText = loadPrecomputedSecretsHelpText(); + if (!secretsHelpText) { + return false; + } + process.stdout.write(secretsHelpText); + return true; +} + +export function outputPrecomputedNodesHelpText(): boolean { + const nodesHelpText = loadPrecomputedNodesHelpText(); + if (!nodesHelpText) { + return false; + } + process.stdout.write(nodesHelpText); + return true; +} + export const testing = { resetPrecomputedRootHelpTextForTests(): void { precomputedRootHelpText = undefined; precomputedBrowserHelpText = undefined; + precomputedSecretsHelpText = undefined; + precomputedNodesHelpText = undefined; }, }; export { testing as __testing }; diff --git a/src/cli/run-main-policy.ts b/src/cli/run-main-policy.ts index a1d8a9a06937..a1902d98b871 100644 --- a/src/cli/run-main-policy.ts +++ b/src/cli/run-main-policy.ts @@ -11,6 +11,7 @@ import { normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; +import { hasFlag } from "./argv.js"; import { resolveCliCommandPathPolicy, resolveCliNetworkProxyPolicy, @@ -85,7 +86,37 @@ export function shouldUseBrowserHelpFastPath( return ( invocation.commandPath.length === 1 && invocation.commandPath[0] === "browser" && - invocation.hasHelpOrVersion + (hasFlag(argv, "--help") || hasFlag(argv, "-h")) + ); +} + +export function shouldUseSecretsHelpFastPath( + argv: string[], + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1") { + return false; + } + const invocation = resolveCliArgvInvocation(argv); + return ( + invocation.commandPath.length === 1 && + invocation.commandPath[0] === "secrets" && + (hasFlag(argv, "--help") || hasFlag(argv, "-h")) + ); +} + +export function shouldUseNodesHelpFastPath( + argv: string[], + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1") { + return false; + } + const invocation = resolveCliArgvInvocation(argv); + return ( + invocation.commandPath.length === 1 && + invocation.commandPath[0] === "nodes" && + (hasFlag(argv, "--help") || hasFlag(argv, "-h")) ); } diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 7538732ec005..f263f83a0997 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -19,6 +19,8 @@ const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn()); const outputRootHelpMock = vi.hoisted(() => vi.fn()); const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false)); const outputPrecomputedBrowserHelpTextMock = vi.hoisted(() => vi.fn(() => false)); +const outputPrecomputedSecretsHelpTextMock = vi.hoisted(() => vi.fn(() => false)); +const outputPrecomputedNodesHelpTextMock = vi.hoisted(() => vi.fn(() => false)); const loadRootHelpRenderOptionsForConfigSensitivePluginsMock = vi.hoisted(() => vi.fn<() => Promise>(async () => null), ); @@ -170,7 +172,9 @@ vi.mock("./program/root-help.js", () => ({ vi.mock("./root-help-metadata.js", () => ({ outputPrecomputedBrowserHelpText: outputPrecomputedBrowserHelpTextMock, + outputPrecomputedNodesHelpText: outputPrecomputedNodesHelpTextMock, outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock, + outputPrecomputedSecretsHelpText: outputPrecomputedSecretsHelpTextMock, })); vi.mock("./root-help-live-config.js", () => ({ @@ -255,7 +259,9 @@ describe("runCli exit behavior", () => { hasMemoryRuntimeMock.mockReturnValue(false); listAgentHarnessIdsMock.mockReturnValue([]); outputPrecomputedBrowserHelpTextMock.mockReturnValue(false); + outputPrecomputedNodesHelpTextMock.mockReturnValue(false); outputPrecomputedRootHelpTextMock.mockReturnValue(false); + outputPrecomputedSecretsHelpTextMock.mockReturnValue(false); loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValue(null); tryOutputSetupOnboardConfigureHelpMock.mockResolvedValue(true); hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(false); @@ -412,6 +418,47 @@ describe("runCli exit behavior", () => { exitSpy.mockRestore(); }); + it("renders secrets help from startup metadata without building the full program", async () => { + outputPrecomputedSecretsHelpTextMock.mockReturnValueOnce(true); + + await runCli(["node", "openclaw", "secrets", "--help"]); + + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(outputPrecomputedSecretsHelpTextMock).toHaveBeenCalledTimes(1); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(registerSubCliByNameMock).not.toHaveBeenCalled(); + }); + + it("renders nodes help from startup metadata without building the full program", async () => { + outputPrecomputedNodesHelpTextMock.mockReturnValueOnce(true); + + await runCli(["node", "openclaw", "nodes", "--help"]); + + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(outputPrecomputedNodesHelpTextMock).toHaveBeenCalledTimes(1); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(registerSubCliByNameMock).not.toHaveBeenCalled(); + }); + + it("defers nodes help startup metadata when plugin config can change command metadata", async () => { + const argv = ["node", "openclaw", "nodes", "--help"]; + const parseAsync = vi.fn().mockResolvedValueOnce(undefined); + const program = { + commands: [{ name: () => "nodes", aliases: () => [] }], + parseAsync, + }; + loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValueOnce({ env: {} }); + outputPrecomputedNodesHelpTextMock.mockReturnValueOnce(true); + buildProgramMock.mockReturnValueOnce(program); + + await runCli(argv); + + expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1); + expect(outputPrecomputedNodesHelpTextMock).not.toHaveBeenCalled(); + expect(registerSubCliByNameMock.mock.calls).toEqual([[program, "nodes", argv]]); + expect(parseAsync).toHaveBeenCalledWith(argv); + }); + it("keeps root help on the precomputed path without proxy bootstrap", async () => { outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index b1a75588893a..9bb95b15284b 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -8,7 +8,9 @@ import { shouldStartCrestodianForModernOnboard, shouldStartProxyForCli, shouldUseBrowserHelpFastPath, + shouldUseNodesHelpFastPath, shouldUseRootHelpFastPath, + shouldUseSecretsHelpFastPath, shouldUseSetupOnboardConfigureHelpFastPath, } from "./run-main-policy.js"; import { isGatewayRunFastPathArgv } from "./run-main.js"; @@ -210,6 +212,25 @@ describe("shouldUseBrowserHelpFastPath", () => { false, ); expect(shouldUseBrowserHelpFastPath(["node", "openclaw", "status", "--help"])).toBe(false); + expect(shouldUseBrowserHelpFastPath(["node", "openclaw", "browser", "--version"])).toBe(false); + }); +}); + +describe("parent command help fast paths", () => { + it("use fast paths for secrets and nodes parent help only", () => { + expect(shouldUseSecretsHelpFastPath(["node", "openclaw", "secrets", "--help"])).toBe(true); + expect(shouldUseSecretsHelpFastPath(["node", "openclaw", "secrets", "-h"])).toBe(true); + expect(shouldUseSecretsHelpFastPath(["node", "openclaw", "secrets", "--version"])).toBe(false); + expect(shouldUseSecretsHelpFastPath(["node", "openclaw", "secrets", "audit", "--help"])).toBe( + false, + ); + + expect(shouldUseNodesHelpFastPath(["node", "openclaw", "nodes", "--help"])).toBe(true); + expect(shouldUseNodesHelpFastPath(["node", "openclaw", "nodes", "-h"])).toBe(true); + expect(shouldUseNodesHelpFastPath(["node", "openclaw", "nodes", "--version"])).toBe(false); + expect(shouldUseNodesHelpFastPath(["node", "openclaw", "nodes", "invoke", "--help"])).toBe( + false, + ); }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 76fbf945b1a7..edbc60589b1d 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -22,10 +22,7 @@ import { consumeGatewayFastPathRootOptionToken, consumeGatewayRunOptionToken, } from "./gateway-run-argv.js"; -import { - hasJsonOutputFlag, - withConsoleLogsRoutedToStderrForJson, -} from "./json-output-mode.js"; +import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; import { getCoreCliCommandNames } from "./program/core-command-descriptors.js"; import { getSubCliEntries } from "./program/subcli-descriptors.js"; @@ -37,7 +34,9 @@ import { shouldStartCrestodianForModernOnboard, shouldStartProxyForCli, shouldUseBrowserHelpFastPath, + shouldUseNodesHelpFastPath, shouldUseRootHelpFastPath, + shouldUseSecretsHelpFastPath, shouldUseSetupOnboardConfigureHelpFastPath, } from "./run-main-policy.js"; import { normalizeWindowsArgv } from "./windows-argv.js"; @@ -49,7 +48,9 @@ export { shouldStartCrestodianForModernOnboard, shouldStartProxyForCli, shouldUseBrowserHelpFastPath, + shouldUseNodesHelpFastPath, shouldUseRootHelpFastPath, + shouldUseSecretsHelpFastPath, shouldUseSetupOnboardConfigureHelpFastPath, } from "./run-main-policy.js"; @@ -559,6 +560,27 @@ export async function runCli(argv: string[] = process.argv) { } } + if (shouldUseSecretsHelpFastPath(normalizedArgv)) { + const { outputPrecomputedSecretsHelpText } = await import("./root-help-metadata.js"); + if (outputPrecomputedSecretsHelpText()) { + return; + } + } + + if (shouldUseNodesHelpFastPath(normalizedArgv)) { + const { loadRootHelpRenderOptionsForConfigSensitivePlugins } = + await import("./root-help-live-config.js"); + const liveRootHelpOptions = await loadRootHelpRenderOptionsForConfigSensitivePlugins( + process.env, + ); + if (!liveRootHelpOptions) { + const { outputPrecomputedNodesHelpText } = await import("./root-help-metadata.js"); + if (outputPrecomputedNodesHelpText()) { + return; + } + } + } + const shouldRunBareRootCrestodian = shouldStartCrestodianForBareRoot(normalizedArgv); const shouldRunModernOnboardCrestodian = shouldStartCrestodianForModernOnboard(normalizedArgv); if (shouldRunBareRootCrestodian || shouldRunModernOnboardCrestodian) { @@ -743,15 +765,10 @@ export async function runCli(argv: string[] = process.argv) { const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js"); return await withConsoleLogsRoutedToStderrForJson(parseArgv, () => - registerPluginCliCommandsFromValidatedConfig( - program, - undefined, - undefined, - { - mode: "lazy", - primary, - }, - ), + registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, { + mode: "lazy", + primary, + }), ); }); if (config) { diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index 4baefe8b9263..c354225e55f7 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -1,13 +1,8 @@ -import fs from "node:fs"; -import { confirm } from "@clack/prompts"; import type { Command } from "commander"; import { danger } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; -import { runSecretsApply } from "../secrets/apply.js"; -import { resolveSecretsAuditExitCode, runSecretsAudit } from "../secrets/audit.js"; -import { runSecretsConfigureInteractive } from "../secrets/configure.js"; -import { isSecretsApplyPlan, type SecretsApplyPlan } from "../secrets/plan.js"; +import type { SecretsApplyPlan } from "../secrets/plan.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; @@ -37,8 +32,12 @@ type SecretsApplyOptions = { json?: boolean; }; -function readPlanFile(pathname: string): SecretsApplyPlan { - const raw = fs.readFileSync(pathname, "utf8"); +async function readPlanFile(pathname: string): Promise { + const [{ readFileSync }, { isSecretsApplyPlan }] = await Promise.all([ + import("node:fs"), + import("../secrets/plan.js"), + ]); + const raw = readFileSync(pathname, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!isSecretsApplyPlan(parsed)) { throw new Error( @@ -48,7 +47,7 @@ function readPlanFile(pathname: string): SecretsApplyPlan { return parsed; } -export function registerSecretsCli(program: Command) { +export function registerSecretsCli(program: Command): void { const secrets = program .command("secrets") .description("Secrets runtime controls") @@ -106,6 +105,8 @@ export function registerSecretsCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts: SecretsAuditOptions) => { try { + const { resolveSecretsAuditExitCode, runSecretsAudit } = + await import("../secrets/audit.js"); const report = await runSecretsAudit({ allowExec: Boolean(opts.allowExec), }); @@ -169,6 +170,7 @@ export function registerSecretsCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts: SecretsConfigureOptions) => { try { + const { runSecretsConfigureInteractive } = await import("../secrets/configure.js"); const configured = await runSecretsConfigureInteractive({ providersOnly: Boolean(opts.providersOnly), skipProviderSetup: Boolean(opts.skipProviderSetup), @@ -176,7 +178,8 @@ export function registerSecretsCli(program: Command) { allowExecInPreflight: Boolean(opts.allowExec), }); if (opts.planOut) { - fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8"); + const { writeFileSync } = await import("node:fs"); + writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8"); } if (opts.json) { defaultRuntime.writeJson({ @@ -212,6 +215,7 @@ export function registerSecretsCli(program: Command) { let shouldApply = Boolean(opts.apply); if (!shouldApply && !opts.json) { + const { confirm } = await import("@clack/prompts"); const approved = await confirm({ message: "Apply this plan now?", initialValue: true, @@ -223,6 +227,7 @@ export function registerSecretsCli(program: Command) { if (shouldApply) { const needsIrreversiblePrompt = Boolean(opts.apply); if (needsIrreversiblePrompt && !opts.yes && !opts.json) { + const { confirm } = await import("@clack/prompts"); const confirmed = await confirm({ message: "This migration is one-way for migrated plaintext values. Continue with apply?", @@ -233,6 +238,7 @@ export function registerSecretsCli(program: Command) { return; } } + const { runSecretsApply } = await import("../secrets/apply.js"); const result = await runSecretsApply({ plan: configured.plan, write: true, @@ -267,7 +273,10 @@ export function registerSecretsCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts: SecretsApplyOptions) => { try { - const plan = readPlanFile(opts.from); + const [{ runSecretsApply }, plan] = await Promise.all([ + import("../secrets/apply.js"), + readPlanFile(opts.from), + ]); const result = await runSecretsApply({ plan, write: !opts.dryRun, diff --git a/src/entry.test.ts b/src/entry.test.ts index bc2561ef7132..9094eeb541f3 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { tryHandleRootHelpFastPath } from "./entry.js"; +import { tryHandlePrecomputedCommandHelpFastPath, tryHandleRootHelpFastPath } from "./entry.js"; describe("entry root help fast path", () => { it("prefers precomputed root help text when available", async () => { @@ -97,3 +97,194 @@ describe("entry root help fast path", () => { expect(outputRootHelpCalls).toBe(0); }); }); + +describe("entry precomputed command help fast path", () => { + it("renders browser help from startup metadata without importing the full program", async () => { + let outputPrecomputedBrowserHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "browser", "--help"], + { + env: {}, + outputPrecomputedBrowserHelpText: () => { + outputPrecomputedBrowserHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(true); + expect(outputPrecomputedBrowserHelpTextCalls).toBe(1); + }); + + it("renders secrets help from startup metadata without importing the full program", async () => { + let outputPrecomputedSecretsHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "secrets", "--help"], + { + env: {}, + outputPrecomputedSecretsHelpText: () => { + outputPrecomputedSecretsHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(true); + expect(outputPrecomputedSecretsHelpTextCalls).toBe(1); + }); + + it("renders nodes help from startup metadata without importing the full program", async () => { + let outputPrecomputedNodesHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "nodes", "--help"], + { + env: {}, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => null, + outputPrecomputedNodesHelpText: () => { + outputPrecomputedNodesHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(true); + expect(outputPrecomputedNodesHelpTextCalls).toBe(1); + }); + + it("defers nodes help when plugin config can change command metadata", async () => { + let outputPrecomputedNodesHelpTextCalls = 0; + let liveConfigChecks = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "nodes", "--help"], + { + env: {}, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => { + liveConfigChecks += 1; + return { env: {} }; + }, + outputPrecomputedNodesHelpText: () => { + outputPrecomputedNodesHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(false); + expect(liveConfigChecks).toBe(1); + expect(outputPrecomputedNodesHelpTextCalls).toBe(0); + }); + + it("falls through when startup metadata is unavailable", async () => { + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "secrets", "--help"], + { + env: {}, + outputPrecomputedSecretsHelpText: () => false, + }, + ); + + expect(handled).toBe(false); + }); + + it("ignores nested subcommand help invocations", async () => { + let outputPrecomputedNodesHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "nodes", "invoke", "--help"], + { + env: {}, + outputPrecomputedNodesHelpText: () => { + outputPrecomputedNodesHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(false); + expect(outputPrecomputedNodesHelpTextCalls).toBe(0); + }); + + it("ignores command version invocations", async () => { + let outputPrecomputedNodesHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "nodes", "--version"], + { + env: {}, + outputPrecomputedNodesHelpText: () => { + outputPrecomputedNodesHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(false); + expect(outputPrecomputedNodesHelpTextCalls).toBe(0); + }); + + it("respects the startup help fast path kill switch", async () => { + let outputPrecomputedSecretsHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "secrets", "--help"], + { + env: { OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH: "1" }, + outputPrecomputedSecretsHelpText: () => { + outputPrecomputedSecretsHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(false); + expect(outputPrecomputedSecretsHelpTextCalls).toBe(0); + }); + + it("respects the process env startup help fast path kill switch", async () => { + let outputPrecomputedSecretsHelpTextCalls = 0; + const original = process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH; + process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH = "1"; + try { + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "secrets", "--help"], + { + outputPrecomputedSecretsHelpText: () => { + outputPrecomputedSecretsHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(false); + expect(outputPrecomputedSecretsHelpTextCalls).toBe(0); + } finally { + if (original === undefined) { + delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH; + } else { + process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH = original; + } + } + }); + + it("skips the host command help fast path when a container target is active", async () => { + let outputPrecomputedSecretsHelpTextCalls = 0; + + const handled = await tryHandlePrecomputedCommandHelpFastPath( + ["node", "openclaw", "--container", "demo", "secrets", "--help"], + { + env: {}, + outputPrecomputedSecretsHelpText: () => { + outputPrecomputedSecretsHelpTextCalls += 1; + return true; + }, + }, + ); + + expect(handled).toBe(false); + expect(outputPrecomputedSecretsHelpTextCalls).toBe(0); + }); +}); diff --git a/src/entry.ts b/src/entry.ts index 31c20d54ce73..c4006d42e78b 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from "node:process"; import { fileURLToPath } from "node:url"; -import { isRootHelpInvocation } from "./cli/argv.js"; +import { getCommandPathWithRootOptions, hasFlag, isRootHelpInvocation } from "./cli/argv.js"; import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import type { RootHelpRenderOptions } from "./cli/program/root-help.js"; @@ -23,6 +23,9 @@ const ENTRY_WRAPPER_PAIRS = [ { wrapperBasename: "openclaw.js", entryBasename: "entry.js" }, ] as const; +type PrecomputedCommandHelpName = "browser" | "secrets" | "nodes"; +type OutputPrecomputedHelpText = () => boolean; + function shouldForceReadOnlyAuthStore(argv: string[]): boolean { const tokens = argv.slice(2).filter((token) => token.length > 0 && !token.startsWith("-")); for (let index = 0; index < tokens.length - 1; index += 1) { @@ -205,10 +208,84 @@ export async function tryHandleRootHelpFastPath( } } +function resolvePrecomputedCommandHelpName(argv: string[]): PrecomputedCommandHelpName | null { + if (!hasFlag(argv, "--help") && !hasFlag(argv, "-h")) { + return null; + } + const commandPath = getCommandPathWithRootOptions(argv, 2); + if (commandPath.length !== 1) { + return null; + } + const [commandName] = commandPath; + if (commandName === "browser" || commandName === "secrets" || commandName === "nodes") { + return commandName; + } + return null; +} + +export async function tryHandlePrecomputedCommandHelpFastPath( + argv: string[], + deps: { + outputPrecomputedBrowserHelpText?: OutputPrecomputedHelpText; + outputPrecomputedSecretsHelpText?: OutputPrecomputedHelpText; + outputPrecomputedNodesHelpText?: OutputPrecomputedHelpText; + loadRootHelpRenderOptionsForConfigSensitivePlugins?: ( + env?: NodeJS.ProcessEnv, + ) => Promise; + env?: NodeJS.ProcessEnv; + } = {}, +): Promise { + const env = deps.env ?? process.env; + if (env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1") { + return false; + } + if (resolveCliContainerTarget(argv, env)) { + return false; + } + const commandName = resolvePrecomputedCommandHelpName(argv); + if (!commandName) { + return false; + } + + try { + if (commandName === "nodes") { + const loadRootHelpRenderOptionsForConfigSensitivePlugins = + deps.loadRootHelpRenderOptionsForConfigSensitivePlugins ?? + (await import("./cli/root-help-live-config.js")) + .loadRootHelpRenderOptionsForConfigSensitivePlugins; + const liveRootHelpOptions = await loadRootHelpRenderOptionsForConfigSensitivePlugins(env); + if (liveRootHelpOptions) { + return false; + } + } + if (commandName === "browser") { + const outputPrecomputedBrowserHelpText = + deps.outputPrecomputedBrowserHelpText ?? + (await import("./cli/root-help-metadata.js")).outputPrecomputedBrowserHelpText; + return outputPrecomputedBrowserHelpText(); + } + if (commandName === "secrets") { + const outputPrecomputedSecretsHelpText = + deps.outputPrecomputedSecretsHelpText ?? + (await import("./cli/root-help-metadata.js")).outputPrecomputedSecretsHelpText; + return outputPrecomputedSecretsHelpText(); + } + const outputPrecomputedNodesHelpText = + deps.outputPrecomputedNodesHelpText ?? + (await import("./cli/root-help-metadata.js")).outputPrecomputedNodesHelpText; + return outputPrecomputedNodesHelpText(); + } catch { + return false; + } +} + async function runMainOrRootHelp(argv: string[]): Promise { if (await tryHandleRootHelpFastPath(argv)) { return; } + if (await tryHandlePrecomputedCommandHelpFastPath(argv)) { + return; + } try { const { runCli } = await gatewayEntryStartupTrace.measure( "run-main-import", diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 99cbcebd7248..04d82eba9f60 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -221,6 +221,32 @@ describe("openclaw launcher", () => { expect(result.stdout).toBe("PRECOMPUTED help\n"); }); + it.each([ + { command: "browser", metadataKey: "browserHelpText" }, + { command: "secrets", metadataKey: "secretsHelpText" }, + { command: "nodes", metadataKey: "nodesHelpText" }, + ])("uses precomputed $command help before loading the runtime entry", async (params) => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ [params.metadataKey]: `PRECOMPUTED ${params.command} help\n` }), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [path.join(fixtureRoot, "openclaw.mjs"), params.command, "--help"], + { + cwd: fixtureRoot, + env: launcherEnv(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(`PRECOMPUTED ${params.command} help\n`); + }); + it("defers root help to the runtime entry when plugin config can change help", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); const configPath = path.join(fixtureRoot, "openclaw.json"); @@ -251,6 +277,40 @@ describe("openclaw launcher", () => { expect(result.stdout).not.toContain("PRECOMPUTED"); }); + it("defers nodes help to the runtime entry when plugin config can change help", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const configPath = path.join(fixtureRoot, "openclaw.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ nodesHelpText: "PRECOMPUTED nodes help\n" }), + "utf8", + ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "process.stdout.write('RUNTIME ENTRY\\n');\n", + "utf8", + ); + await fs.writeFile( + configPath, + JSON.stringify({ plugins: { entries: { canvas: { enabled: false } } } }), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [path.join(fixtureRoot, "openclaw.mjs"), "nodes", "--help"], + { + cwd: fixtureRoot, + env: launcherEnv({ OPENCLAW_CONFIG_PATH: configPath }), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("RUNTIME ENTRY\n"); + expect(result.stdout).not.toContain("PRECOMPUTED"); + }); + it("checks the OPENCLAW_HOME default config path before using precomputed root help", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); const openclawHome = path.join(fixtureRoot, "home"); diff --git a/test/scripts/write-cli-startup-metadata.test.ts b/test/scripts/write-cli-startup-metadata.test.ts index 54b8d862f733..97056b8703f8 100644 --- a/test/scripts/write-cli-startup-metadata.test.ts +++ b/test/scripts/write-cli-startup-metadata.test.ts @@ -4,6 +4,35 @@ import { describe, expect, it } from "vitest"; import { writeCliStartupMetadata } from "../../scripts/write-cli-startup-metadata.ts"; import { createScriptTestHarness } from "./test-helpers.js"; +function writeFixtureFile(rootDir: string, relativePath: string, contents: string): void { + const filePath = path.join(rootDir, relativePath); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, contents, "utf8"); +} + +function writeStartupMetadataSourceSignatureFixture(rootDir: string): void { + const fixtures = new Map([ + ["extensions/browser/src/cli/browser-cli.ts", "export const browserHelp = 'browser';\n"], + ["extensions/canvas/cli-metadata.ts", "export const canvasMetadata = 'canvas';\n"], + ["extensions/canvas/index.ts", "export const canvasEntry = 'canvas';\n"], + ["extensions/canvas/src/a2ui-jsonl.ts", "export const a2uiJsonl = 'canvas';\n"], + ["extensions/canvas/src/cli-helpers.ts", "export const canvasHelpers = 'canvas';\n"], + ["extensions/canvas/src/cli.ts", "export const canvasCliHelp = 'canvas';\n"], + ["src/cli/banner.ts", "export const banner = 'openclaw';\n"], + ["src/cli/nodes-cli/register.ts", "export const nodesHelp = 'nodes';\n"], + ["src/cli/program/context.ts", "export const context = 'context';\n"], + ["src/cli/program/help.ts", "export const help = 'help';\n"], + [ + "src/plugins/register-plugin-cli-command-groups.ts", + "export const pluginCommandGroups = 'plugins';\n", + ], + ["src/cli/secrets-cli.ts", "export const secretsHelp = 'secrets';\n"], + ]); + for (const [relativePath, contents] of fixtures) { + writeFixtureFile(rootDir, relativePath, contents); + } +} + describe("write-cli-startup-metadata", () => { const { createTempDir } = createScriptTestHarness(); @@ -38,17 +67,70 @@ describe("write-cli-startup-metadata", () => { }, renderSourceRootHelpText: () => "Usage: openclaw\n", renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", + renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", + renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", }); const written = JSON.parse(readFileSync(outputPath, "utf8")) as { browserHelpText: string; channelOptions: string[]; + nodesHelpText: string; rootHelpText: string; + secretsHelpText: string; }; expect(written.channelOptions).toContain("matrix"); expect(written.browserHelpText).toContain("Usage:"); expect(written.browserHelpText).toContain("openclaw browser"); + expect(written.secretsHelpText).toContain("Usage:"); + expect(written.secretsHelpText).toContain("openclaw secrets"); + expect(written.nodesHelpText).toContain("Usage:"); + expect(written.nodesHelpText).toContain("openclaw nodes"); expect(written.rootHelpText).toContain("Usage:"); expect(written.rootHelpText).toContain("openclaw"); }); + + it("regenerates nodes help when bundled canvas CLI help sources change", async () => { + const tempRoot = createTempDir("openclaw-startup-metadata-signature-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + let nodesRenderCount = 0; + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + const writeMetadata = async (): Promise => { + await writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => "Usage: openclaw\n", + renderSourceBrowserHelpText: () => "Usage: openclaw browser\n", + renderSourceSecretsHelpText: () => "Usage: openclaw secrets\n", + renderSourceNodesHelpText: () => { + nodesRenderCount += 1; + return `Usage: openclaw nodes ${nodesRenderCount}\n`; + }, + }); + }; + + await writeMetadata(); + await writeMetadata(); + expect(nodesRenderCount).toBe(1); + + writeFixtureFile( + tempRoot, + "extensions/canvas/src/cli.ts", + "export const canvasCliHelp = 'canvas changed help';\n", + ); + + await writeMetadata(); + + const written = JSON.parse(readFileSync(outputPath, "utf8")) as { + nodesHelpText: string; + }; + expect(nodesRenderCount).toBe(2); + expect(written.nodesHelpText).toContain("openclaw nodes 2"); + }); });