mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 09:50:42 +00:00
* Add startup progress indicators * Narrow startup progress scope * Revert startup spinner delay to immediate feedback * Improve install.sh progress feedback for quiet steps * Show progress for installer download phases
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
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 { createCliProgress } from "./progress.js";
|
|
import { maybeWarnAboutDebugProxyCoverage } from "../proxy-capture/coverage.js";
|
|
import {
|
|
finalizeDebugProxyCapture,
|
|
initializeDebugProxyCapture,
|
|
} from "../proxy-capture/runtime.js";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalLowercaseString,
|
|
normalizeOptionalString,
|
|
} from "../shared/string-coerce.js";
|
|
import { resolveCliArgvInvocation } from "./argv-invocation.js";
|
|
import {
|
|
shouldRegisterPrimaryCommandOnly,
|
|
shouldSkipPluginCommandRegistration,
|
|
} from "./command-registration-policy.js";
|
|
import { shouldEnsureCliPathForCommandPath } from "./command-startup-policy.js";
|
|
import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js";
|
|
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
|
import { tryRouteCli } from "./route.js";
|
|
import { normalizeWindowsArgv } from "./windows-argv.js";
|
|
|
|
async function closeCliMemoryManagers(): Promise<void> {
|
|
if (!hasMemoryRuntime()) {
|
|
return;
|
|
}
|
|
try {
|
|
const { closeActiveMemorySearchManagers } = await import("../plugins/memory-runtime.js");
|
|
await closeActiveMemorySearchManagers();
|
|
} catch {
|
|
// Best-effort teardown for short-lived CLI processes.
|
|
}
|
|
}
|
|
|
|
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
|
|
const index = argv.indexOf("--update");
|
|
if (index === -1) {
|
|
return argv;
|
|
}
|
|
|
|
const next = [...argv];
|
|
next.splice(index, 1, "update");
|
|
return next;
|
|
}
|
|
|
|
export function shouldEnsureCliPath(argv: string[]): boolean {
|
|
const invocation = resolveCliArgvInvocation(argv);
|
|
if (invocation.hasHelpOrVersion || shouldStartCrestodianForBareRoot(argv)) {
|
|
return false;
|
|
}
|
|
return shouldEnsureCliPathForCommandPath(invocation.commandPath);
|
|
}
|
|
|
|
export function shouldUseRootHelpFastPath(argv: string[]): boolean {
|
|
return (
|
|
process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH !== "1" &&
|
|
resolveCliArgvInvocation(argv).isRootHelpInvocation
|
|
);
|
|
}
|
|
|
|
export function shouldUseBrowserHelpFastPath(argv: string[]): boolean {
|
|
if (process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1") {
|
|
return false;
|
|
}
|
|
const invocation = resolveCliArgvInvocation(argv);
|
|
return (
|
|
invocation.commandPath.length === 1 &&
|
|
invocation.commandPath[0] === "browser" &&
|
|
invocation.hasHelpOrVersion
|
|
);
|
|
}
|
|
|
|
export function shouldStartCrestodianForBareRoot(argv: string[]): boolean {
|
|
const invocation = resolveCliArgvInvocation(argv);
|
|
return invocation.commandPath.length === 0 && !invocation.hasHelpOrVersion;
|
|
}
|
|
|
|
export function shouldStartCrestodianForModernOnboard(argv: string[]): boolean {
|
|
const invocation = resolveCliArgvInvocation(argv);
|
|
return (
|
|
invocation.commandPath[0] === "onboard" &&
|
|
argv.includes("--modern") &&
|
|
!invocation.hasHelpOrVersion
|
|
);
|
|
}
|
|
|
|
export function resolveMissingPluginCommandMessage(
|
|
pluginId: string,
|
|
config?: OpenClawConfig,
|
|
options?: { registry?: PluginManifestCommandAliasRegistry },
|
|
): string | null {
|
|
const normalizedPluginId = normalizeLowercaseStringOrEmpty(pluginId);
|
|
if (!normalizedPluginId) {
|
|
return null;
|
|
}
|
|
const allow =
|
|
Array.isArray(config?.plugins?.allow) && config.plugins.allow.length > 0
|
|
? config.plugins.allow
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => normalizeOptionalLowercaseString(entry))
|
|
.filter(Boolean)
|
|
: [];
|
|
const commandAlias = resolveManifestCommandAliasOwner({
|
|
command: normalizedPluginId,
|
|
config,
|
|
registry: options?.registry,
|
|
});
|
|
const parentPluginId = commandAlias?.pluginId;
|
|
if (parentPluginId) {
|
|
if (allow.length > 0 && !allow.includes(parentPluginId)) {
|
|
return (
|
|
`"${normalizedPluginId}" is not a plugin; it is a command provided by the ` +
|
|
`"${parentPluginId}" plugin. Add "${parentPluginId}" to \`plugins.allow\` ` +
|
|
`instead of "${normalizedPluginId}".`
|
|
);
|
|
}
|
|
if (config?.plugins?.entries?.[parentPluginId]?.enabled === false) {
|
|
return (
|
|
`The \`openclaw ${normalizedPluginId}\` command is unavailable because ` +
|
|
`\`plugins.entries.${parentPluginId}.enabled=false\`. Re-enable that entry if you want ` +
|
|
"the bundled plugin command surface."
|
|
);
|
|
}
|
|
if (commandAlias.kind === "runtime-slash") {
|
|
const cliHint = commandAlias.cliCommand
|
|
? `Use \`openclaw ${commandAlias.cliCommand}\` for related CLI operations, or `
|
|
: "Use ";
|
|
return (
|
|
`"${normalizedPluginId}" is a runtime slash command (/${normalizedPluginId}), not a CLI command. ` +
|
|
`It is provided by the "${parentPluginId}" plugin. ` +
|
|
`${cliHint}\`/${normalizedPluginId}\` in a chat session.`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (allow.length > 0 && !allow.includes(normalizedPluginId)) {
|
|
if (parentPluginId && allow.includes(parentPluginId)) {
|
|
return null;
|
|
}
|
|
return (
|
|
`The \`openclaw ${normalizedPluginId}\` command is unavailable because ` +
|
|
`\`plugins.allow\` excludes "${normalizedPluginId}". Add "${normalizedPluginId}" to ` +
|
|
`\`plugins.allow\` if you want that bundled plugin CLI surface.`
|
|
);
|
|
}
|
|
if (config?.plugins?.entries?.[normalizedPluginId]?.enabled === false) {
|
|
return (
|
|
`The \`openclaw ${normalizedPluginId}\` command is unavailable because ` +
|
|
`\`plugins.entries.${normalizedPluginId}.enabled=false\`. Re-enable that entry if you want ` +
|
|
"the bundled plugin CLI surface."
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
if (existsSync(path.join(process.cwd(), ".env"))) {
|
|
return true;
|
|
}
|
|
return existsSync(path.join(resolveStateDir(env), ".env"));
|
|
}
|
|
|
|
export async function runCli(argv: string[] = process.argv) {
|
|
const originalArgv = normalizeWindowsArgv(argv);
|
|
const parsedContainer = parseCliContainerArgs(originalArgv);
|
|
if (!parsedContainer.ok) {
|
|
throw new Error(parsedContainer.error);
|
|
}
|
|
const parsedProfile = parseCliProfileArgs(parsedContainer.argv);
|
|
if (!parsedProfile.ok) {
|
|
throw new Error(parsedProfile.error);
|
|
}
|
|
if (parsedProfile.profile) {
|
|
applyCliProfileEnv({ profile: parsedProfile.profile });
|
|
}
|
|
const containerTargetName =
|
|
parsedContainer.container ?? normalizeOptionalString(process.env.OPENCLAW_CONTAINER) ?? null;
|
|
if (containerTargetName && parsedProfile.profile) {
|
|
throw new Error("--container cannot be combined with --profile/--dev");
|
|
}
|
|
|
|
const containerTarget = maybeRunCliInContainer(originalArgv);
|
|
if (containerTarget.handled) {
|
|
if (containerTarget.exitCode !== 0) {
|
|
process.exitCode = containerTarget.exitCode;
|
|
}
|
|
return;
|
|
}
|
|
let normalizedArgv = parsedProfile.argv;
|
|
|
|
if (shouldLoadCliDotEnv()) {
|
|
const { loadCliDotEnv } = await import("./dotenv.js");
|
|
loadCliDotEnv({ quiet: true });
|
|
}
|
|
normalizeEnv();
|
|
initializeDebugProxyCapture("cli");
|
|
process.once("exit", () => {
|
|
finalizeDebugProxyCapture();
|
|
});
|
|
ensureGlobalUndiciEnvProxyDispatcher();
|
|
maybeWarnAboutDebugProxyCoverage();
|
|
if (shouldEnsureCliPath(normalizedArgv)) {
|
|
ensureOpenClawCliOnPath();
|
|
}
|
|
|
|
// Enforce the minimum supported runtime before doing any work.
|
|
assertSupportedRuntime();
|
|
|
|
try {
|
|
if (shouldUseRootHelpFastPath(normalizedArgv)) {
|
|
const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js");
|
|
if (!outputPrecomputedRootHelpText()) {
|
|
const { outputRootHelp } = await import("./program/root-help.js");
|
|
await outputRootHelp();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (shouldUseBrowserHelpFastPath(normalizedArgv)) {
|
|
const { outputPrecomputedBrowserHelpText } = await import("./root-help-metadata.js");
|
|
if (outputPrecomputedBrowserHelpText()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (shouldStartCrestodianForBareRoot(normalizedArgv)) {
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
console.error(
|
|
'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const { runCrestodian } = await import("../crestodian/crestodian.js");
|
|
const progress = createCliProgress({
|
|
label: "Starting Crestodian…",
|
|
indeterminate: true,
|
|
delayMs: 0,
|
|
fallback: "none",
|
|
});
|
|
let progressStopped = false;
|
|
const stopProgress = () => {
|
|
if (progressStopped) {
|
|
return;
|
|
}
|
|
progressStopped = true;
|
|
progress.done();
|
|
};
|
|
try {
|
|
await runCrestodian({ onReady: stopProgress });
|
|
} finally {
|
|
stopProgress();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (shouldStartCrestodianForModernOnboard(normalizedArgv)) {
|
|
const { runCrestodian } = await import("../crestodian/crestodian.js");
|
|
const nonInteractive = normalizedArgv.includes("--non-interactive");
|
|
await runCrestodian({
|
|
message: nonInteractive ? "overview" : undefined,
|
|
yes: false,
|
|
json: normalizedArgv.includes("--json"),
|
|
interactive: !nonInteractive,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (await tryRouteCli(normalizedArgv)) {
|
|
return;
|
|
}
|
|
|
|
const startupProgress = createCliProgress({
|
|
label: "Loading OpenClaw CLI…",
|
|
indeterminate: true,
|
|
delayMs: 0,
|
|
fallback: "none",
|
|
});
|
|
let startupProgressStopped = false;
|
|
const stopStartupProgress = () => {
|
|
if (startupProgressStopped) {
|
|
return;
|
|
}
|
|
startupProgressStopped = true;
|
|
startupProgress.done();
|
|
};
|
|
|
|
try {
|
|
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
|
enableConsoleCapture();
|
|
|
|
const [
|
|
{ buildProgram },
|
|
{ runFatalErrorHooks },
|
|
{ installUnhandledRejectionHandler },
|
|
{ restoreTerminalState },
|
|
] = await Promise.all([
|
|
import("./program.js"),
|
|
import("../infra/fatal-error-hooks.js"),
|
|
import("../infra/unhandled-rejections.js"),
|
|
import("../terminal/restore.js"),
|
|
]);
|
|
const program = buildProgram();
|
|
|
|
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
|
// These log the error and exit gracefully instead of crashing without trace.
|
|
installUnhandledRejectionHandler();
|
|
|
|
process.on("uncaughtException", (error) => {
|
|
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
|
|
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
|
|
console.error("[openclaw]", message);
|
|
}
|
|
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
|
|
process.exit(1);
|
|
});
|
|
|
|
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
|
|
const invocation = resolveCliArgvInvocation(parseArgv);
|
|
// Register the primary command (builtin or subcli) so help and command parsing
|
|
// are correct even with lazy command registration.
|
|
const { primary } = invocation;
|
|
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
|
|
const { getProgramContext } = await import("./program/program-context.js");
|
|
const ctx = getProgramContext(program);
|
|
if (ctx) {
|
|
const { registerCoreCliByName } = await import("./program/command-registry.js");
|
|
await registerCoreCliByName(program, ctx, primary, parseArgv);
|
|
}
|
|
const { registerSubCliByName } = await import("./program/register.subclis.js");
|
|
await registerSubCliByName(program, primary);
|
|
}
|
|
|
|
const hasBuiltinPrimary =
|
|
primary !== null &&
|
|
program.commands.some(
|
|
(command) => command.name() === primary || command.aliases().includes(primary),
|
|
);
|
|
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
|
|
argv: parseArgv,
|
|
primary,
|
|
hasBuiltinPrimary,
|
|
});
|
|
if (!shouldSkipPluginRegistration) {
|
|
// Register plugin CLI commands before parsing
|
|
const { registerPluginCliCommandsFromValidatedConfig } = await import("../plugins/cli.js");
|
|
const config = await registerPluginCliCommandsFromValidatedConfig(
|
|
program,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
mode: "lazy",
|
|
primary,
|
|
},
|
|
);
|
|
if (config) {
|
|
if (
|
|
primary &&
|
|
!program.commands.some(
|
|
(command) => command.name() === primary || command.aliases().includes(primary),
|
|
)
|
|
) {
|
|
const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config);
|
|
if (missingPluginCommandMessage) {
|
|
throw new Error(missingPluginCommandMessage);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
stopStartupProgress();
|
|
|
|
try {
|
|
await program.parseAsync(parseArgv);
|
|
} catch (error) {
|
|
if (!(error instanceof CommanderError)) {
|
|
throw error;
|
|
}
|
|
process.exitCode = error.exitCode;
|
|
}
|
|
} finally {
|
|
stopStartupProgress();
|
|
}
|
|
} finally {
|
|
await closeCliMemoryManagers();
|
|
}
|
|
}
|
|
|
|
export function isCliMainModule(): boolean {
|
|
return isMainModule({ currentFile: fileURLToPath(import.meta.url) });
|
|
}
|