fix: guard cli bootstrap imports

This commit is contained in:
Peter Steinberger
2026-04-27 11:24:18 +01:00
parent fa0d81ed13
commit a0aedea63d
12 changed files with 393 additions and 39 deletions

View File

@@ -20,6 +20,15 @@ const getProgramContextMock = vi.hoisted(() => vi.fn(() => null));
const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
const runCrestodianMock = vi.hoisted(() => vi.fn(async () => {}));
const progressDoneMock = vi.hoisted(() => vi.fn());
const createCliProgressMock = vi.hoisted(() =>
vi.fn(() => ({
done: progressDoneMock,
})),
);
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
@@ -96,12 +105,29 @@ vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: restoreTerminalStateMock,
}));
vi.mock("../infra/net/proxy-env.js", () => ({
hasEnvHttpProxyConfigured: hasEnvHttpProxyConfiguredMock,
}));
vi.mock("../infra/net/undici-global-dispatcher.js", () => ({
ensureGlobalUndiciEnvProxyDispatcher: ensureGlobalUndiciEnvProxyDispatcherMock,
}));
vi.mock("../crestodian/crestodian.js", () => ({
runCrestodian: runCrestodianMock,
}));
vi.mock("./progress.js", () => ({
createCliProgress: createCliProgressMock,
}));
describe("runCli exit behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
hasMemoryRuntimeMock.mockReturnValue(false);
outputPrecomputedBrowserHelpTextMock.mockReturnValue(false);
outputPrecomputedRootHelpTextMock.mockReturnValue(false);
hasEnvHttpProxyConfiguredMock.mockReturnValue(false);
getProgramContextMock.mockReturnValue(null);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
});
@@ -146,6 +172,17 @@ describe("runCli exit behavior", () => {
exitSpy.mockRestore();
});
it("keeps root help on the precomputed path without proxy bootstrap", async () => {
outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true);
await runCli(["node", "openclaw", "--help"]);
expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1);
expect(hasEnvHttpProxyConfiguredMock).not.toHaveBeenCalled();
expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled();
expect(runCrestodianMock).not.toHaveBeenCalled();
});
it("renders root help without building the full program", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`unexpected process.exit(${String(code)})`);
@@ -163,6 +200,52 @@ describe("runCli exit behavior", () => {
exitSpy.mockRestore();
});
it("bootstraps env proxy before bare Crestodian startup", async () => {
hasEnvHttpProxyConfiguredMock.mockReturnValue(true);
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
await runCli(["node", "openclaw"]);
} finally {
if (stdinTty) {
Object.defineProperty(process.stdin, "isTTY", stdinTty);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
if (stdoutTty) {
Object.defineProperty(process.stdout, "isTTY", stdoutTty);
} else {
delete (process.stdout as { isTTY?: boolean }).isTTY;
}
}
expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1);
expect(runCrestodianMock).toHaveBeenCalledWith({ onReady: expect.any(Function) });
expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan(
runCrestodianMock.mock.invocationCallOrder[0],
);
});
it("bootstraps env proxy before modern onboard Crestodian startup", async () => {
hasEnvHttpProxyConfiguredMock.mockReturnValue(true);
await runCli(["node", "openclaw", "onboard", "--modern", "--json"]);
expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1);
expect(runCrestodianMock).toHaveBeenCalledWith({
message: undefined,
yes: false,
json: true,
interactive: true,
});
expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan(
runCrestodianMock.mock.invocationCallOrder[0],
);
});
it("closes memory managers when a runtime was registered", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
hasMemoryRuntimeMock.mockReturnValue(true);

View File

@@ -2,24 +2,13 @@ import { existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { CommanderError } from "commander";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeEnv } from "../infra/env.js";
import { formatUncaughtError } from "../infra/errors.js";
import { isMainModule } from "../infra/is-main.js";
import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { enableConsoleCapture } from "../logging.js";
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
import { hasMemoryRuntime } from "../plugins/memory-state.js";
import { maybeWarnAboutDebugProxyCoverage } from "../proxy-capture/coverage.js";
import {
finalizeDebugProxyCapture,
initializeDebugProxyCapture,
} from "../proxy-capture/runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import {
@@ -28,8 +17,6 @@ import {
} from "./command-registration-policy.js";
import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { createCliProgress } from "./progress.js";
import { tryRouteCli } from "./route.js";
import {
resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy,
rewriteUpdateFlagArgv,
@@ -51,6 +38,7 @@ export {
} from "./run-main-policy.js";
async function closeCliMemoryManagers(): Promise<void> {
const { hasMemoryRuntime } = await import("../plugins/memory-state.js");
if (!hasMemoryRuntime()) {
return;
}
@@ -67,10 +55,11 @@ export function resolveMissingPluginCommandMessage(
config?: OpenClawConfig,
options?: { registry?: PluginManifestCommandAliasRegistry },
): string | null {
return resolveMissingPluginCommandMessageFromPolicy(pluginId, config, {
...(options?.registry ? { registry: options.registry } : {}),
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
});
return resolveMissingPluginCommandMessageFromPolicy(
pluginId,
config,
options?.registry ? { registry: options.registry } : undefined,
);
}
function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -80,6 +69,33 @@ function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
return existsSync(path.join(resolveStateDir(env), ".env"));
}
function isCommanderParseExit(error: unknown): error is { exitCode: number } {
if (!error || typeof error !== "object") {
return false;
}
const candidate = error as { code?: unknown; exitCode?: unknown };
return (
typeof candidate.exitCode === "number" &&
Number.isInteger(candidate.exitCode) &&
typeof candidate.code === "string" &&
candidate.code.startsWith("commander.")
);
}
async function ensureCliEnvProxyDispatcher(): Promise<void> {
try {
const { hasEnvHttpProxyConfigured } = await import("../infra/net/proxy-env.js");
if (!hasEnvHttpProxyConfigured("https")) {
return;
}
const { ensureGlobalUndiciEnvProxyDispatcher } =
await import("../infra/net/undici-global-dispatcher.js");
ensureGlobalUndiciEnvProxyDispatcher();
} catch {
// Best-effort proxy bootstrap; CLI startup should continue without it.
}
}
export async function runCli(argv: string[] = process.argv) {
const originalArgv = normalizeWindowsArgv(argv);
const parsedContainer = parseCliContainerArgs(originalArgv);
@@ -113,12 +129,6 @@ export async function runCli(argv: string[] = process.argv) {
loadCliDotEnv({ quiet: true });
}
normalizeEnv();
initializeDebugProxyCapture("cli");
process.once("exit", () => {
finalizeDebugProxyCapture();
});
ensureGlobalUndiciEnvProxyDispatcher();
maybeWarnAboutDebugProxyCoverage();
if (shouldEnsureCliPath(normalizedArgv)) {
ensureOpenClawCliOnPath();
}
@@ -143,7 +153,13 @@ export async function runCli(argv: string[] = process.argv) {
}
}
if (shouldStartCrestodianForBareRoot(normalizedArgv)) {
const shouldRunBareRootCrestodian = shouldStartCrestodianForBareRoot(normalizedArgv);
const shouldRunModernOnboardCrestodian = shouldStartCrestodianForModernOnboard(normalizedArgv);
if (shouldRunBareRootCrestodian || shouldRunModernOnboardCrestodian) {
await ensureCliEnvProxyDispatcher();
}
if (shouldRunBareRootCrestodian) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error(
'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.',
@@ -152,6 +168,7 @@ export async function runCli(argv: string[] = process.argv) {
return;
}
const { runCrestodian } = await import("../crestodian/crestodian.js");
const { createCliProgress } = await import("./progress.js");
const progress = createCliProgress({
label: "Starting Crestodian…",
indeterminate: true,
@@ -174,7 +191,7 @@ export async function runCli(argv: string[] = process.argv) {
return;
}
if (shouldStartCrestodianForModernOnboard(normalizedArgv)) {
if (shouldRunModernOnboardCrestodian) {
const { runCrestodian } = await import("../crestodian/crestodian.js");
const nonInteractive = normalizedArgv.includes("--non-interactive");
await runCrestodian({
@@ -186,10 +203,26 @@ export async function runCli(argv: string[] = process.argv) {
return;
}
const [
{ initializeDebugProxyCapture, finalizeDebugProxyCapture },
{ maybeWarnAboutDebugProxyCoverage },
] = await Promise.all([
import("../proxy-capture/runtime.js"),
import("../proxy-capture/coverage.js"),
]);
initializeDebugProxyCapture("cli");
process.once("exit", () => {
finalizeDebugProxyCapture();
});
await ensureCliEnvProxyDispatcher();
maybeWarnAboutDebugProxyCoverage();
const { tryRouteCli } = await import("./route.js");
if (await tryRouteCli(normalizedArgv)) {
return;
}
const { createCliProgress } = await import("./progress.js");
const startupProgress = createCliProgress({
label: "Loading OpenClaw CLI…",
indeterminate: true,
@@ -207,15 +240,18 @@ export async function runCli(argv: string[] = process.argv) {
try {
// Capture all console output into structured logs while keeping stdout/stderr behavior.
const { enableConsoleCapture } = await import("../logging.js");
enableConsoleCapture();
const [
{ buildProgram },
{ formatUncaughtError },
{ runFatalErrorHooks },
{ installUnhandledRejectionHandler, isUncaughtExceptionHandled },
{ restoreTerminalState },
] = await Promise.all([
import("./program.js"),
import("../infra/errors.js"),
import("../infra/fatal-error-hooks.js"),
import("../infra/unhandled-rejections.js"),
import("../terminal/restore.js"),
@@ -283,7 +319,15 @@ export async function runCli(argv: string[] = process.argv) {
(command) => command.name() === primary || command.aliases().includes(primary),
)
) {
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
const { resolveManifestCommandAliasOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy(
primary,
config,
{
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
},
);
if (missingPluginCommandMessage) {
throw new Error(missingPluginCommandMessage);
}
@@ -296,7 +340,7 @@ export async function runCli(argv: string[] = process.argv) {
try {
await program.parseAsync(parseArgv);
} catch (error) {
if (!(error instanceof CommanderError)) {
if (!isCommanderParseExit(error)) {
throw error;
}
process.exitCode = error.exitCode;