diff --git a/scripts/install.sh b/scripts/install.sh index 97fd5607f2a..3973f48cf23 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -179,11 +179,13 @@ bootstrap_gum_temp() { gum_tmpdir="$(mktemp -d)" TMPFILES+=("$gum_tmpdir") + ui_info "Preparing spinner support" if ! download_file "${base}/${asset}" "$gum_tmpdir/$asset"; then GUM_REASON="download failed" return 1 fi + ui_info "Verifying spinner support download" if ! download_file "${base}/checksums.txt" "$gum_tmpdir/checksums.txt"; then GUM_REASON="checksum unavailable or failed" return 1 @@ -444,6 +446,7 @@ run_quiet_step() { local log log="$(mktempfile)" + local showed_progress=false if [[ -n "$GUM" ]] && gum_is_tty && ! is_shell_function "${1:-}"; then local cmd_quoted="" @@ -453,12 +456,20 @@ run_quiet_step() { if run_with_spinner "$title" bash -c "${cmd_quoted}>${log_quoted} 2>&1"; then return 0 fi + showed_progress=true else + # Keep users informed even when gum spinner cannot run (for example shell functions). + ui_info "${title}" + showed_progress=true if "$@" >"$log" 2>&1; then return 0 fi fi + if [[ "$showed_progress" == "false" ]]; then + ui_info "${title}" + fi + ui_error "${title} failed — re-run with --verbose for details" if [[ -s "$log" ]]; then tail -n 80 "$log" >&2 || true @@ -1432,7 +1443,7 @@ install_node() { if command -v apt-get &> /dev/null; then local tmp tmp="$(mktempfile)" - download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp" + run_quiet_step "Downloading NodeSource setup script" download_file "https://deb.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp" if is_root; then run_quiet_step "Configuring NodeSource repository" bash "$tmp" run_quiet_step "Installing Node.js" apt-get install -y -qq nodejs @@ -1443,7 +1454,7 @@ install_node() { elif command -v dnf &> /dev/null; then local tmp tmp="$(mktempfile)" - download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp" + run_quiet_step "Downloading NodeSource setup script" download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp" if is_root; then run_quiet_step "Configuring NodeSource repository" bash "$tmp" run_quiet_step "Installing Node.js" dnf install -y -q nodejs @@ -1454,7 +1465,7 @@ install_node() { elif command -v yum &> /dev/null; then local tmp tmp="$(mktempfile)" - download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp" + run_quiet_step "Downloading NodeSource setup script" download_file "https://rpm.nodesource.com/setup_${NODE_DEFAULT_MAJOR}.x" "$tmp" if is_root; then run_quiet_step "Configuring NodeSource repository" bash "$tmp" run_quiet_step "Installing Node.js" yum install -y -q nodejs @@ -2265,6 +2276,8 @@ main() { return 0 fi + # bootstrap_gum_temp may perform network downloads before any spinner is available. + echo -e "${INFO}Preparing installer interface...${NC}" bootstrap_gum_temp || true print_installer_banner print_gum_status diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index e0d5a5a43ec..529a08727fd 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -15,6 +15,7 @@ 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, @@ -248,7 +249,25 @@ export async function runCli(argv: string[] = process.argv) { return; } const { runCrestodian } = await import("../crestodian/crestodian.js"); - await runCrestodian(); + 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; } @@ -268,95 +287,116 @@ export async function runCli(argv: string[] = process.argv) { return; } - // 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 startupProgress = createCliProgress({ + label: "Loading OpenClaw CLI…", + indeterminate: true, + delayMs: 0, + fallback: "none", }); - - 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); + let startupProgressStopped = false; + const stopStartupProgress = () => { + if (startupProgressStopped) { + return; } - const { registerSubCliByName } = await import("./program/register.subclis.js"); - await registerSubCliByName(program, primary); - } + startupProgressStopped = true; + startupProgress.done(); + }; - 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); + 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); + } } } } - } - try { - await program.parseAsync(parseArgv); - } catch (error) { - if (!(error instanceof CommanderError)) { - throw error; + stopStartupProgress(); + + try { + await program.parseAsync(parseArgv); + } catch (error) { + if (!(error instanceof CommanderError)) { + throw error; + } + process.exitCode = error.exitCode; } - process.exitCode = error.exitCode; + } finally { + stopStartupProgress(); } } finally { await closeCliMemoryManagers(); diff --git a/src/crestodian/crestodian.test.ts b/src/crestodian/crestodian.test.ts index 8fe5a5f89f2..e4ed04aa246 100644 --- a/src/crestodian/crestodian.test.ts +++ b/src/crestodian/crestodian.test.ts @@ -54,11 +54,13 @@ describe("runCrestodian", () => { vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); const { runtime, lines } = createCrestodianTestRuntime(); const runGatewayRestart = vi.fn(async () => {}); + const onReady = vi.fn(); await runCrestodian( { message: "the local bridge looks sleepy, poke it", deps: { runGatewayRestart }, + onReady, planWithAssistant: async () => ({ reply: "I can queue a Gateway restart.", command: "restart gateway", @@ -69,6 +71,7 @@ describe("runCrestodian", () => { ); expect(runGatewayRestart).not.toHaveBeenCalled(); + expect(onReady).not.toHaveBeenCalled(); expect(lines.join("\n")).toContain("[crestodian] planner: openai/gpt-5.5"); expect(lines.join("\n")).toContain("[crestodian] interpreted: restart gateway"); expect(lines.join("\n")).toContain("Plan: restart the Gateway. Say yes to apply."); @@ -80,16 +83,19 @@ describe("runCrestodian", () => { vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); const { runtime, lines } = createCrestodianTestRuntime(); const planner = vi.fn(async () => ({ command: "restart gateway" })); + const onReady = vi.fn(); await runCrestodian( { message: "models", planWithAssistant: planner, + onReady, }, runtime, ); expect(planner).not.toHaveBeenCalled(); + expect(onReady).not.toHaveBeenCalled(); expect(lines.join("\n")).toContain("Default model:"); }); @@ -99,12 +105,14 @@ describe("runCrestodian", () => { vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); const { runtime, lines } = createCrestodianTestRuntime(); const runInteractiveTui = vi.fn(async () => {}); + const onReady = vi.fn(); await runCrestodian( { input: { isTTY: true } as unknown as NodeJS.ReadableStream, output: { isTTY: true } as unknown as NodeJS.WritableStream, runInteractiveTui, + onReady, }, runtime, ); @@ -113,6 +121,7 @@ describe("runCrestodian", () => { expect.objectContaining({ runInteractiveTui }), runtime, ); + expect(onReady).toHaveBeenCalledTimes(1); expect(lines.join("\n")).not.toContain("Say: status"); }); }); diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index 9562ebd17e3..7e3778f71e9 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -1,5 +1,6 @@ import { stdin as defaultStdin, stdout as defaultStdout } from "node:process"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +import { withProgress } from "../cli/progress.js"; import type { CrestodianAssistantPlanner } from "./assistant.js"; import { resolveCrestodianOperation } from "./dialogue.js"; import { @@ -19,6 +20,7 @@ export type RunCrestodianOptions = { yes?: boolean; json?: boolean; interactive?: boolean; + onReady?: () => void; deps?: CrestodianCommandDeps; planWithAssistant?: CrestodianAssistantPlanner; input?: NodeJS.ReadableStream; @@ -49,7 +51,15 @@ export async function runCrestodian( } if (opts.message?.trim()) { - const overview = await loadCrestodianOverview(); + const overview = await withProgress( + { + label: "Loading Crestodian overview…", + indeterminate: true, + delayMs: 0, + fallback: "none", + }, + async () => await loadCrestodianOverview(), + ); runtime.log(formatCrestodianOverview(overview)); runtime.log(""); await runOneShot(opts.message, runtime, opts); @@ -68,5 +78,6 @@ export async function runCrestodian( const runInteractiveTui = opts.runInteractiveTui ?? (await import("./tui-backend.js")).runCrestodianTui; + opts.onReady?.(); await runInteractiveTui(opts, runtime); }