From 88c404bcfc665145b20e308c9595d82909440a50 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 00:48:39 +0100 Subject: [PATCH] feat(update): add progress spinner during update steps --- src/cli/update-cli.ts | 50 ++++++++ src/infra/update-runner.ts | 240 +++++++++++++++++++++++-------------- 2 files changed, 202 insertions(+), 88 deletions(-) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index de4e88253..a4aee24c1 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -4,10 +4,13 @@ import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { runGatewayUpdate, type UpdateRunResult, + type UpdateStepInfo, + type UpdateStepProgress, } from "../infra/update-runner.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { createCliProgress, type ProgressReporter } from "./progress.js"; export type UpdateCommandOptions = { json?: boolean; @@ -15,6 +18,46 @@ export type UpdateCommandOptions = { timeout?: string; }; +const STEP_LABELS: Record = { + "git status": "Checking for uncommitted changes", + "git upstream": "Checking upstream branch", + "git fetch": "Fetching latest changes", + "git rebase": "Rebasing onto upstream", + "deps install": "Installing dependencies", + build: "Building", + "ui:build": "Building UI", + "clawdbot doctor": "Running doctor checks", + "git rev-parse HEAD (after)": "Verifying update", +}; + +function getStepLabel(step: UpdateStepInfo): string { + const friendlyLabel = STEP_LABELS[step.name] ?? step.name; + const commandHint = step.command.startsWith("git ") + ? step.command.split(" ").slice(0, 2).join(" ") + : step.command.split(" ")[0]; + return `${friendlyLabel} (${commandHint})`; +} + +function createUpdateProgress(enabled: boolean): { + progress: UpdateStepProgress; + reporter: ProgressReporter; +} { + const reporter = createCliProgress({ + label: "Preparing update...", + indeterminate: true, + enabled, + delayMs: 0, + }); + + const progress: UpdateStepProgress = { + onStepStart: (step) => { + reporter.setLabel(getStepLabel(step)); + }, + }; + + return { progress, reporter }; +} + function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = (ms / 1000).toFixed(1); @@ -99,6 +142,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } + const showProgress = !opts.json && process.stderr.isTTY; + if (!opts.json) { defaultRuntime.log(theme.heading("Updating Clawdbot...")); defaultRuntime.log(""); @@ -111,12 +156,17 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { cwd: process.cwd(), })) ?? process.cwd(); + const { progress, reporter } = createUpdateProgress(showProgress); + const result = await runGatewayUpdate({ cwd: root, argv1: process.argv[1], timeoutMs, + progress, }); + reporter.done(); + printResult(result, opts); if (result.status === "error") { diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index c118213ae..8ab45b962 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -30,11 +30,26 @@ type CommandRunner = ( options: CommandOptions, ) => Promise<{ stdout: string; stderr: string; code: number | null }>; +export type UpdateStepInfo = { + name: string; + command: string; + index: number; + total: number; +}; + +export type UpdateStepProgress = { + onStepStart?: (step: UpdateStepInfo) => void; + onStepComplete?: ( + step: UpdateStepInfo & { durationMs: number; exitCode: number | null }, + ) => void; +}; + type UpdateRunnerOptions = { cwd?: string; argv1?: string; timeoutMs?: number; runCommand?: CommandRunner; + progress?: UpdateStepProgress; }; const DEFAULT_TIMEOUT_MS = 20 * 60_000; @@ -142,20 +157,54 @@ async function detectPackageManager(root: string) { return "npm"; } -async function runStep( - runCommand: CommandRunner, - name: string, - argv: string[], - cwd: string, - timeoutMs: number, - env?: NodeJS.ProcessEnv, -): Promise { +type RunStepOptions = { + runCommand: CommandRunner; + name: string; + argv: string[]; + cwd: string; + timeoutMs: number; + env?: NodeJS.ProcessEnv; + progress?: UpdateStepProgress; + stepIndex: number; + totalSteps: number; +}; + +async function runStep(opts: RunStepOptions): Promise { + const { + runCommand, + name, + argv, + cwd, + timeoutMs, + env, + progress, + stepIndex, + totalSteps, + } = opts; + const command = argv.join(" "); + + const stepInfo: UpdateStepInfo = { + name, + command, + index: stepIndex, + total: totalSteps, + }; + + progress?.onStepStart?.(stepInfo); + const started = Date.now(); const result = await runCommand(argv, { cwd, timeoutMs, env }); const durationMs = Date.now() - started; + + progress?.onStepComplete?.({ + ...stepInfo, + durationMs, + exitCode: result.code, + }); + return { name, - command: argv.join(" "), + command, cwd, durationMs, exitCode: result.code, @@ -181,6 +230,9 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") { return ["npm", "install"]; } +// Total number of visible steps in a successful git update flow +const GIT_UPDATE_TOTAL_STEPS = 9; + export async function runGatewayUpdate( opts: UpdateRunnerOptions = {}, ): Promise { @@ -192,9 +244,33 @@ export async function runGatewayUpdate( return { stdout: res.stdout, stderr: res.stderr, code: res.code }; }); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const progress = opts.progress; const steps: UpdateStepResult[] = []; const candidates = buildStartDirs(opts); + let stepIndex = 0; + + const step = ( + name: string, + argv: string[], + cwd: string, + env?: NodeJS.ProcessEnv, + ): RunStepOptions => { + const currentIndex = stepIndex; + stepIndex += 1; + return { + runCommand, + name, + argv, + cwd, + timeoutMs, + env, + progress, + stepIndex: currentIndex, + totalSteps: GIT_UPDATE_TOTAL_STEPS, + }; + }; + const pkgRoot = await findPackageRoot(candidates); let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); @@ -214,23 +290,20 @@ export async function runGatewayUpdate( } if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) { - const beforeSha = ( - await runStep( - runCommand, - "git rev-parse HEAD", - ["git", "-C", gitRoot, "rev-parse", "HEAD"], - gitRoot, - timeoutMs, - ) - ).stdoutTail?.trim(); + // Get current SHA (not a visible step, no progress) + const beforeShaResult = await runCommand( + ["git", "-C", gitRoot, "rev-parse", "HEAD"], + { cwd: gitRoot, timeoutMs }, + ); + const beforeSha = beforeShaResult.stdout.trim() || null; const beforeVersion = await readPackageVersion(gitRoot); const statusStep = await runStep( - runCommand, - "git status", - ["git", "-C", gitRoot, "status", "--porcelain"], - gitRoot, - timeoutMs, + step( + "git status", + ["git", "-C", gitRoot, "status", "--porcelain"], + gitRoot, + ), ); steps.push(statusStep); if ((statusStep.stdoutTail ?? "").trim()) { @@ -239,26 +312,26 @@ export async function runGatewayUpdate( mode: "git", root: gitRoot, reason: "dirty", - before: { sha: beforeSha ?? null, version: beforeVersion }, + before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } const upstreamStep = await runStep( - runCommand, - "git upstream", - [ - "git", - "-C", + step( + "git upstream", + [ + "git", + "-C", + gitRoot, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], gitRoot, - "rev-parse", - "--abbrev-ref", - "--symbolic-full-name", - "@{upstream}", - ], - gitRoot, - timeoutMs, + ), ); steps.push(upstreamStep); if (upstreamStep.exitCode !== 0) { @@ -267,7 +340,7 @@ export async function runGatewayUpdate( mode: "git", root: gitRoot, reason: "no-upstream", - before: { sha: beforeSha ?? null, version: beforeVersion }, + before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; @@ -275,89 +348,80 @@ export async function runGatewayUpdate( steps.push( await runStep( - runCommand, - "git fetch", - ["git", "-C", gitRoot, "fetch", "--all", "--prune"], - gitRoot, - timeoutMs, + step( + "git fetch", + ["git", "-C", gitRoot, "fetch", "--all", "--prune"], + gitRoot, + ), ), ); const rebaseStep = await runStep( - runCommand, - "git rebase", - ["git", "-C", gitRoot, "rebase", "@{upstream}"], - gitRoot, - timeoutMs, + step( + "git rebase", + ["git", "-C", gitRoot, "rebase", "@{upstream}"], + gitRoot, + ), ); steps.push(rebaseStep); if (rebaseStep.exitCode !== 0) { - steps.push( - await runStep( - runCommand, - "git rebase --abort", - ["git", "-C", gitRoot, "rebase", "--abort"], - gitRoot, - timeoutMs, - ), + // Abort rebase (error recovery, not counted in total) + const abortResult = await runCommand( + ["git", "-C", gitRoot, "rebase", "--abort"], + { cwd: gitRoot, timeoutMs }, ); + steps.push({ + name: "git rebase --abort", + command: "git rebase --abort", + cwd: gitRoot, + durationMs: 0, + exitCode: abortResult.code, + stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), + }); return { status: "error", mode: "git", root: gitRoot, reason: "rebase-failed", - before: { sha: beforeSha ?? null, version: beforeVersion }, + before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } const manager = await detectPackageManager(gitRoot); + steps.push( + await runStep(step("deps install", managerInstallArgs(manager), gitRoot)), + ); steps.push( await runStep( - runCommand, - "deps install", - managerInstallArgs(manager), - gitRoot, - timeoutMs, + step("build", managerScriptArgs(manager, "build"), gitRoot), ), ); steps.push( await runStep( - runCommand, - "build", - managerScriptArgs(manager, "build"), - gitRoot, - timeoutMs, + step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot), ), ); steps.push( await runStep( - runCommand, - "ui:build", - managerScriptArgs(manager, "ui:build"), - gitRoot, - timeoutMs, - ), - ); - steps.push( - await runStep( - runCommand, - "clawdbot doctor", - managerScriptArgs(manager, "clawdbot", ["doctor"]), - gitRoot, - timeoutMs, - { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, + step( + "clawdbot doctor", + managerScriptArgs(manager, "clawdbot", ["doctor"]), + gitRoot, + { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, + ), ), ); - const failedStep = steps.find((step) => step.exitCode !== 0); + const failedStep = steps.find((s) => s.exitCode !== 0); const afterShaStep = await runStep( - runCommand, - "git rev-parse HEAD (after)", - ["git", "-C", gitRoot, "rev-parse", "HEAD"], - gitRoot, - timeoutMs, + step( + "git rev-parse HEAD (after)", + ["git", "-C", gitRoot, "rev-parse", "HEAD"], + gitRoot, + ), ); steps.push(afterShaStep); const afterVersion = await readPackageVersion(gitRoot); @@ -367,7 +431,7 @@ export async function runGatewayUpdate( mode: "git", root: gitRoot, reason: failedStep ? failedStep.name : undefined, - before: { sha: beforeSha ?? null, version: beforeVersion }, + before: { sha: beforeSha, version: beforeVersion }, after: { sha: afterShaStep.stdoutTail?.trim() ?? null, version: afterVersion,