From 88c404bcfc665145b20e308c9595d82909440a50 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 00:48:39 +0100 Subject: [PATCH 01/11] 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, From 8f9aa3e8c5b587fc8a4d1b6898acf0955b520baa Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 00:49:48 +0100 Subject: [PATCH 02/11] fix(progress): start spinner immediately when delayMs is 0 --- src/cli/progress.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/progress.ts b/src/cli/progress.ts index 871ae0992..a55604966 100644 --- a/src/cli/progress.ts +++ b/src/cli/progress.ts @@ -90,7 +90,11 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter { applyState(); }; - timer = setTimeout(start, delayMs); + if (delayMs === 0) { + start(); + } else { + timer = setTimeout(start, delayMs); + } const setLabel = (next: string) => { label = next; From 6e0c1cb0515d70d32dab1b22dc48d319ef91e8d0 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 00:55:04 +0100 Subject: [PATCH 03/11] fix(update): show each step with spinner as it runs --- src/cli/update-cli.ts | 54 ++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index a4aee24c1..89b467897 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,3 +1,4 @@ +import { spinner } from "@clack/prompts"; import type { Command } from "commander"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; @@ -10,7 +11,6 @@ import { 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; @@ -38,24 +38,50 @@ function getStepLabel(step: UpdateStepInfo): string { return `${friendlyLabel} (${commandHint})`; } -function createUpdateProgress(enabled: boolean): { +type ProgressController = { progress: UpdateStepProgress; - reporter: ProgressReporter; -} { - const reporter = createCliProgress({ - label: "Preparing update...", - indeterminate: true, - enabled, - delayMs: 0, - }); + stop: () => void; +}; + +function createUpdateProgress(enabled: boolean): ProgressController { + if (!enabled) { + return { + progress: {}, + stop: () => {}, + }; + } + + let currentSpinner: ReturnType | null = null; const progress: UpdateStepProgress = { onStepStart: (step) => { - reporter.setLabel(getStepLabel(step)); + currentSpinner = spinner(); + currentSpinner.start(theme.accent(getStepLabel(step))); + }, + onStepComplete: (step) => { + if (!currentSpinner) return; + + const label = getStepLabel(step); + const duration = theme.muted(`(${formatDuration(step.durationMs)})`); + + if (step.exitCode === 0) { + currentSpinner.stop(`${theme.success("\u2713")} ${label} ${duration}`); + } else { + currentSpinner.stop(`${theme.error("\u2717")} ${label} ${duration}`); + } + currentSpinner = null; }, }; - return { progress, reporter }; + return { + progress, + stop: () => { + if (currentSpinner) { + currentSpinner.stop(); + currentSpinner = null; + } + }, + }; } function formatDuration(ms: number): string { @@ -156,7 +182,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { cwd: process.cwd(), })) ?? process.cwd(); - const { progress, reporter } = createUpdateProgress(showProgress); + const { progress, stop } = createUpdateProgress(showProgress); const result = await runGatewayUpdate({ cwd: root, @@ -165,7 +191,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { progress, }); - reporter.done(); + stop(); printResult(result, opts); From cc8e6e00a0afcde7f46a4fc5031dc090831dcf9a Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 00:58:01 +0100 Subject: [PATCH 04/11] fix(update): hide steps in summary when shown live, fix command hint --- src/cli/update-cli.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 89b467897..a84ff6b7b 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -32,10 +32,7 @@ const STEP_LABELS: Record = { 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})`; + return `${friendlyLabel} (${step.name})`; } type ProgressController = { @@ -96,7 +93,11 @@ function formatStepStatus(exitCode: number | null): string { return theme.error("\u2717"); } -function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) { +type PrintResultOptions = UpdateCommandOptions & { + hideSteps?: boolean; +}; + +function printResult(result: UpdateRunResult, opts: PrintResultOptions) { if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; @@ -113,13 +114,9 @@ function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) { defaultRuntime.log( `${theme.heading("Update Result:")} ${statusColor(result.status.toUpperCase())}`, ); - defaultRuntime.log(` Mode: ${theme.muted(result.mode)}`); if (result.root) { defaultRuntime.log(` Root: ${theme.muted(result.root)}`); } - if (result.reason) { - defaultRuntime.log(` Reason: ${theme.muted(result.reason)}`); - } if (result.before?.version || result.before?.sha) { const before = @@ -131,7 +128,7 @@ function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) { defaultRuntime.log(` After: ${theme.muted(after)}`); } - if (result.steps.length > 0) { + if (!opts.hideSteps && result.steps.length > 0) { defaultRuntime.log(""); defaultRuntime.log(theme.heading("Steps:")); for (const step of result.steps) { @@ -139,7 +136,6 @@ function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) { const duration = theme.muted(`(${formatDuration(step.durationMs)})`); defaultRuntime.log(` ${status} ${step.name} ${duration}`); - // Show stderr for failed steps if (step.exitCode !== 0 && step.stderrTail) { const lines = step.stderrTail.split("\n").slice(0, 5); for (const line of lines) { @@ -193,7 +189,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { stop(); - printResult(result, opts); + printResult(result, { ...opts, hideSteps: showProgress }); if (result.status === "error") { defaultRuntime.exit(1); From 6a2b8328df309ce41b8e95037183d7d3b849911f Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 00:59:17 +0100 Subject: [PATCH 05/11] fix(update): restore reason in summary --- src/cli/update-cli.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index a84ff6b7b..81ecf22f3 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -117,6 +117,9 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) { if (result.root) { defaultRuntime.log(` Root: ${theme.muted(result.root)}`); } + if (result.reason) { + defaultRuntime.log(` Reason: ${theme.muted(result.reason)}`); + } if (result.before?.version || result.before?.sha) { const before = From 35d42be8282f2490cef20363820c7c77c1b24c9b Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 01:06:34 +0100 Subject: [PATCH 06/11] fix(update): show skipped status with warning indicator for dirty repo --- src/cli/update-cli.ts | 13 +++-- src/infra/update-runner.ts | 107 +++++++++++++++++++++++-------------- 2 files changed, 76 insertions(+), 44 deletions(-) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 81ecf22f3..8326c7a46 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -61,11 +61,14 @@ function createUpdateProgress(enabled: boolean): ProgressController { const label = getStepLabel(step); const duration = theme.muted(`(${formatDuration(step.durationMs)})`); - if (step.exitCode === 0) { - currentSpinner.stop(`${theme.success("\u2713")} ${label} ${duration}`); - } else { - currentSpinner.stop(`${theme.error("\u2717")} ${label} ${duration}`); - } + const icon = + step.status === "success" + ? theme.success("\u2713") + : step.status === "skipped" + ? theme.warn("\u25CB") + : theme.error("\u2717"); + + currentSpinner.stop(`${icon} ${label} ${duration}`); currentSpinner = null; }, }; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 8ab45b962..7c6b9881a 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -37,11 +37,17 @@ export type UpdateStepInfo = { total: number; }; +export type StepStatus = "success" | "error" | "skipped"; + +export type UpdateStepCompletion = UpdateStepInfo & { + durationMs: number; + exitCode: number | null; + status: StepStatus; +}; + export type UpdateStepProgress = { onStepStart?: (step: UpdateStepInfo) => void; - onStepComplete?: ( - step: UpdateStepInfo & { durationMs: number; exitCode: number | null }, - ) => void; + onStepComplete?: (step: UpdateStepCompletion) => void; }; type UpdateRunnerOptions = { @@ -169,7 +175,11 @@ type RunStepOptions = { totalSteps: number; }; -async function runStep(opts: RunStepOptions): Promise { +type StepResultWithReport = UpdateStepResult & { + reportComplete: (status: StepStatus) => void; +}; + +async function runStep(opts: RunStepOptions): Promise { const { runCommand, name, @@ -196,11 +206,14 @@ async function runStep(opts: RunStepOptions): Promise { const result = await runCommand(argv, { cwd, timeoutMs, env }); const durationMs = Date.now() - started; - progress?.onStepComplete?.({ - ...stepInfo, - durationMs, - exitCode: result.code, - }); + const reportComplete = (status: StepStatus) => { + progress?.onStepComplete?.({ + ...stepInfo, + durationMs, + exitCode: result.code, + status, + }); + }; return { name, @@ -210,6 +223,7 @@ async function runStep(opts: RunStepOptions): Promise { exitCode: result.code, stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS), + reportComplete, }; } @@ -305,8 +319,10 @@ export async function runGatewayUpdate( gitRoot, ), ); + const isDirty = (statusStep.stdoutTail ?? "").trim().length > 0; + statusStep.reportComplete(isDirty ? "skipped" : "success"); steps.push(statusStep); - if ((statusStep.stdoutTail ?? "").trim()) { + if (isDirty) { return { status: "skipped", mode: "git", @@ -333,8 +349,10 @@ export async function runGatewayUpdate( gitRoot, ), ); + const hasUpstream = upstreamStep.exitCode === 0; + upstreamStep.reportComplete(hasUpstream ? "success" : "skipped"); steps.push(upstreamStep); - if (upstreamStep.exitCode !== 0) { + if (!hasUpstream) { return { status: "skipped", mode: "git", @@ -346,15 +364,15 @@ export async function runGatewayUpdate( }; } - steps.push( - await runStep( - step( - "git fetch", - ["git", "-C", gitRoot, "fetch", "--all", "--prune"], - gitRoot, - ), + const fetchStep = await runStep( + step( + "git fetch", + ["git", "-C", gitRoot, "fetch", "--all", "--prune"], + gitRoot, ), ); + fetchStep.reportComplete(fetchStep.exitCode === 0 ? "success" : "error"); + steps.push(fetchStep); const rebaseStep = await runStep( step( @@ -363,9 +381,9 @@ export async function runGatewayUpdate( gitRoot, ), ); + rebaseStep.reportComplete(rebaseStep.exitCode === 0 ? "success" : "error"); steps.push(rebaseStep); if (rebaseStep.exitCode !== 0) { - // Abort rebase (error recovery, not counted in total) const abortResult = await runCommand( ["git", "-C", gitRoot, "rebase", "--abort"], { cwd: gitRoot, timeoutMs }, @@ -391,29 +409,37 @@ export async function runGatewayUpdate( } const manager = await detectPackageManager(gitRoot); - steps.push( - await runStep(step("deps install", managerInstallArgs(manager), gitRoot)), + + const depsStep = await runStep( + step("deps install", managerInstallArgs(manager), gitRoot), ); - steps.push( - await runStep( - step("build", managerScriptArgs(manager, "build"), gitRoot), - ), - ); - steps.push( - await runStep( - step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot), - ), - ); - steps.push( - await runStep( - step( - "clawdbot doctor", - managerScriptArgs(manager, "clawdbot", ["doctor"]), - gitRoot, - { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, - ), + depsStep.reportComplete(depsStep.exitCode === 0 ? "success" : "error"); + steps.push(depsStep); + + const buildStep = await runStep( + step("build", managerScriptArgs(manager, "build"), gitRoot), + ); + buildStep.reportComplete(buildStep.exitCode === 0 ? "success" : "error"); + steps.push(buildStep); + + const uiBuildStep = await runStep( + step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot), + ); + uiBuildStep.reportComplete( + uiBuildStep.exitCode === 0 ? "success" : "error", + ); + steps.push(uiBuildStep); + + const doctorStep = await runStep( + step( + "clawdbot doctor", + managerScriptArgs(manager, "clawdbot", ["doctor"]), + gitRoot, + { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, ), ); + doctorStep.reportComplete(doctorStep.exitCode === 0 ? "success" : "error"); + steps.push(doctorStep); const failedStep = steps.find((s) => s.exitCode !== 0); const afterShaStep = await runStep( @@ -423,6 +449,9 @@ export async function runGatewayUpdate( gitRoot, ), ); + afterShaStep.reportComplete( + afterShaStep.exitCode === 0 ? "success" : "error", + ); steps.push(afterShaStep); const afterVersion = await readPackageVersion(gitRoot); From 4102e2f1b8daaaef9c1dc38fd09d5c6f19ba28af Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 01:14:59 +0100 Subject: [PATCH 07/11] refactor(update): simplify progress with proper exit codes --- src/cli/update-cli.ts | 11 +++----- src/infra/update-runner.ts | 52 ++++++++++---------------------------- 2 files changed, 16 insertions(+), 47 deletions(-) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 8326c7a46..febda8a07 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -19,8 +19,8 @@ export type UpdateCommandOptions = { }; const STEP_LABELS: Record = { - "git status": "Checking for uncommitted changes", - "git upstream": "Checking upstream branch", + "clean check": "Working directory is clean", + "upstream check": "Upstream branch exists", "git fetch": "Fetching latest changes", "git rebase": "Rebasing onto upstream", "deps install": "Installing dependencies", @@ -60,13 +60,8 @@ function createUpdateProgress(enabled: boolean): ProgressController { const label = getStepLabel(step); const duration = theme.muted(`(${formatDuration(step.durationMs)})`); - const icon = - step.status === "success" - ? theme.success("\u2713") - : step.status === "skipped" - ? theme.warn("\u25CB") - : theme.error("\u2717"); + step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717"); currentSpinner.stop(`${icon} ${label} ${duration}`); currentSpinner = null; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 7c6b9881a..bc1138fda 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -37,12 +37,9 @@ export type UpdateStepInfo = { total: number; }; -export type StepStatus = "success" | "error" | "skipped"; - export type UpdateStepCompletion = UpdateStepInfo & { durationMs: number; exitCode: number | null; - status: StepStatus; }; export type UpdateStepProgress = { @@ -175,11 +172,7 @@ type RunStepOptions = { totalSteps: number; }; -type StepResultWithReport = UpdateStepResult & { - reportComplete: (status: StepStatus) => void; -}; - -async function runStep(opts: RunStepOptions): Promise { +async function runStep(opts: RunStepOptions): Promise { const { runCommand, name, @@ -206,14 +199,11 @@ async function runStep(opts: RunStepOptions): Promise { const result = await runCommand(argv, { cwd, timeoutMs, env }); const durationMs = Date.now() - started; - const reportComplete = (status: StepStatus) => { - progress?.onStepComplete?.({ - ...stepInfo, - durationMs, - exitCode: result.code, - status, - }); - }; + progress?.onStepComplete?.({ + ...stepInfo, + durationMs, + exitCode: result.code, + }); return { name, @@ -223,7 +213,6 @@ async function runStep(opts: RunStepOptions): Promise { exitCode: result.code, stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS), - reportComplete, }; } @@ -312,17 +301,15 @@ export async function runGatewayUpdate( const beforeSha = beforeShaResult.stdout.trim() || null; const beforeVersion = await readPackageVersion(gitRoot); - const statusStep = await runStep( + const cleanCheck = await runStep( step( - "git status", - ["git", "-C", gitRoot, "status", "--porcelain"], + "clean check", + ["sh", "-c", `test -z "$(git -C '${gitRoot}' status --porcelain)"`], gitRoot, ), ); - const isDirty = (statusStep.stdoutTail ?? "").trim().length > 0; - statusStep.reportComplete(isDirty ? "skipped" : "success"); - steps.push(statusStep); - if (isDirty) { + steps.push(cleanCheck); + if (cleanCheck.exitCode !== 0) { return { status: "skipped", mode: "git", @@ -336,7 +323,7 @@ export async function runGatewayUpdate( const upstreamStep = await runStep( step( - "git upstream", + "upstream check", [ "git", "-C", @@ -349,10 +336,8 @@ export async function runGatewayUpdate( gitRoot, ), ); - const hasUpstream = upstreamStep.exitCode === 0; - upstreamStep.reportComplete(hasUpstream ? "success" : "skipped"); steps.push(upstreamStep); - if (!hasUpstream) { + if (upstreamStep.exitCode !== 0) { return { status: "skipped", mode: "git", @@ -371,7 +356,6 @@ export async function runGatewayUpdate( gitRoot, ), ); - fetchStep.reportComplete(fetchStep.exitCode === 0 ? "success" : "error"); steps.push(fetchStep); const rebaseStep = await runStep( @@ -381,7 +365,6 @@ export async function runGatewayUpdate( gitRoot, ), ); - rebaseStep.reportComplete(rebaseStep.exitCode === 0 ? "success" : "error"); steps.push(rebaseStep); if (rebaseStep.exitCode !== 0) { const abortResult = await runCommand( @@ -413,21 +396,16 @@ export async function runGatewayUpdate( const depsStep = await runStep( step("deps install", managerInstallArgs(manager), gitRoot), ); - depsStep.reportComplete(depsStep.exitCode === 0 ? "success" : "error"); steps.push(depsStep); const buildStep = await runStep( step("build", managerScriptArgs(manager, "build"), gitRoot), ); - buildStep.reportComplete(buildStep.exitCode === 0 ? "success" : "error"); steps.push(buildStep); const uiBuildStep = await runStep( step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot), ); - uiBuildStep.reportComplete( - uiBuildStep.exitCode === 0 ? "success" : "error", - ); steps.push(uiBuildStep); const doctorStep = await runStep( @@ -438,7 +416,6 @@ export async function runGatewayUpdate( { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, ), ); - doctorStep.reportComplete(doctorStep.exitCode === 0 ? "success" : "error"); steps.push(doctorStep); const failedStep = steps.find((s) => s.exitCode !== 0); @@ -449,9 +426,6 @@ export async function runGatewayUpdate( gitRoot, ), ); - afterShaStep.reportComplete( - afterShaStep.exitCode === 0 ? "success" : "error", - ); steps.push(afterShaStep); const afterVersion = await readPackageVersion(gitRoot); From 3f27b23d5acfef04b972e249aee6788209b47d3d Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 01:21:39 +0100 Subject: [PATCH 08/11] fix(update): remove command hint from step labels --- src/cli/update-cli.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index febda8a07..8b3eb6eb9 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -31,8 +31,7 @@ const STEP_LABELS: Record = { }; function getStepLabel(step: UpdateStepInfo): string { - const friendlyLabel = STEP_LABELS[step.name] ?? step.name; - return `${friendlyLabel} (${step.name})`; + return STEP_LABELS[step.name] ?? step.name; } type ProgressController = { From 6cb55eaaa71ed4b7dff6d13047ab8cd8a7ff808b Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 01:26:14 +0100 Subject: [PATCH 09/11] feat(update): show stderr for failed steps --- src/cli/update-cli.ts | 9 +++++++++ src/infra/update-runner.ts | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 8b3eb6eb9..ddd1433ce 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -64,6 +64,15 @@ function createUpdateProgress(enabled: boolean): ProgressController { currentSpinner.stop(`${icon} ${label} ${duration}`); currentSpinner = null; + + if (step.exitCode !== 0 && step.stderrTail) { + const lines = step.stderrTail.split("\n").slice(-10); + for (const line of lines) { + if (line.trim()) { + defaultRuntime.log(` ${theme.error(line)}`); + } + } + } }, }; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index bc1138fda..0e53a006a 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -40,6 +40,7 @@ export type UpdateStepInfo = { export type UpdateStepCompletion = UpdateStepInfo & { durationMs: number; exitCode: number | null; + stderrTail?: string | null; }; export type UpdateStepProgress = { @@ -199,10 +200,13 @@ async function runStep(opts: RunStepOptions): Promise { const result = await runCommand(argv, { cwd, timeoutMs, env }); const durationMs = Date.now() - started; + const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS); + progress?.onStepComplete?.({ ...stepInfo, durationMs, exitCode: result.code, + stderrTail, }); return { From f3bd6e4957908b8e36d32c2417dd067a7e9fe594 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 11 Jan 2026 02:02:39 +0100 Subject: [PATCH 10/11] fix(update): use git status --porcelain for dirty check cross-platform --- src/infra/update-runner.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 0e53a006a..49e3e2802 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -305,15 +305,17 @@ export async function runGatewayUpdate( const beforeSha = beforeShaResult.stdout.trim() || null; const beforeVersion = await readPackageVersion(gitRoot); - const cleanCheck = await runStep( + const statusCheck = await runStep( step( - "clean check", - ["sh", "-c", `test -z "$(git -C '${gitRoot}' status --porcelain)"`], + "Running git status", + ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot, ), ); - steps.push(cleanCheck); - if (cleanCheck.exitCode !== 0) { + steps.push(statusCheck); + const hasUncommittedChanges = + statusCheck.stdoutTail && statusCheck.stdoutTail.trim().length > 0; + if (hasUncommittedChanges) { return { status: "skipped", mode: "git", From 5ec3663748e69dff4ee0f115590ab2c792336f95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 03:03:09 +0100 Subject: [PATCH 11/11] fix: guard update spinner output (#701) (thanks @bjesuiter) --- CHANGELOG.md | 1 + src/cli/update-cli.ts | 2 +- src/infra/update-runner.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2fa19799..190d71e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - CLI/Status: improve Tailscale reporting in `status --all` and harden parsing of noisy `tailscale status --json` output. - CLI/Status: make `status --all` scan progress determinate (OSC progress + spinner). - Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage. +- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter. ## 2026.1.11-4 diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index ddd1433ce..905eeeb9e 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -173,7 +173,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - const showProgress = !opts.json && process.stderr.isTTY; + const showProgress = !opts.json && process.stdout.isTTY; if (!opts.json) { defaultRuntime.log(theme.heading("Updating Clawdbot...")); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 49e3e2802..46f2e6019 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -307,7 +307,7 @@ export async function runGatewayUpdate( const statusCheck = await runStep( step( - "Running git status", + "clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot, ),