mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
perf: speed up secrets and nodes help startup (#84818)
Merged via squash.
Prepared head SHA: d65ae1bd58
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+23
-6
@@ -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();
|
||||
|
||||
@@ -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<typeof createHash>, files: string[]) {
|
||||
function updateHashFromFiles(
|
||||
hash: ReturnType<typeof createHash>,
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RootHelpRenderOptions | null>>(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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+30
-13
@@ -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) {
|
||||
|
||||
+20
-11
@@ -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<SecretsApplyPlan> {
|
||||
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,
|
||||
|
||||
+192
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+78
-1
@@ -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<RootHelpRenderOptions | null>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
if (await tryHandleRootHelpFastPath(argv)) {
|
||||
return;
|
||||
}
|
||||
if (await tryHandlePrecomputedCommandHelpFastPath(argv)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { runCli } = await gatewayEntryStartupTrace.measure(
|
||||
"run-main-import",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, string>([
|
||||
["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<void> => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user