mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:20:42 +00:00
fix: guard cli bootstrap imports
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user