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/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; diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index de4e88253..905eeeb9e 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,9 +1,12 @@ +import { spinner } from "@clack/prompts"; import type { Command } from "commander"; 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"; @@ -15,6 +18,75 @@ export type UpdateCommandOptions = { timeout?: string; }; +const STEP_LABELS: Record = { + "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", + build: "Building", + "ui:build": "Building UI", + "clawdbot doctor": "Running doctor checks", + "git rev-parse HEAD (after)": "Verifying update", +}; + +function getStepLabel(step: UpdateStepInfo): string { + return STEP_LABELS[step.name] ?? step.name; +} + +type ProgressController = { + progress: UpdateStepProgress; + stop: () => void; +}; + +function createUpdateProgress(enabled: boolean): ProgressController { + if (!enabled) { + return { + progress: {}, + stop: () => {}, + }; + } + + let currentSpinner: ReturnType | null = null; + + const progress: UpdateStepProgress = { + onStepStart: (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)})`); + const icon = + step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717"); + + 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)}`); + } + } + } + }, + }; + + return { + progress, + stop: () => { + if (currentSpinner) { + currentSpinner.stop(); + currentSpinner = null; + } + }, + }; +} + function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = (ms / 1000).toFixed(1); @@ -27,7 +99,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; @@ -44,7 +120,6 @@ 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)}`); } @@ -62,7 +137,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) { @@ -70,7 +145,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) { @@ -99,6 +173,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } + const showProgress = !opts.json && process.stdout.isTTY; + if (!opts.json) { defaultRuntime.log(theme.heading("Updating Clawdbot...")); defaultRuntime.log(""); @@ -111,13 +187,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { cwd: process.cwd(), })) ?? process.cwd(); + const { progress, stop } = createUpdateProgress(showProgress); + const result = await runGatewayUpdate({ cwd: root, argv1: process.argv[1], timeoutMs, + progress, }); - printResult(result, opts); + stop(); + + printResult(result, { ...opts, hideSteps: showProgress }); if (result.status === "error") { defaultRuntime.exit(1); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index c118213ae..46f2e6019 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -30,11 +30,30 @@ 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 UpdateStepCompletion = UpdateStepInfo & { + durationMs: number; + exitCode: number | null; + stderrTail?: string | null; +}; + +export type UpdateStepProgress = { + onStepStart?: (step: UpdateStepInfo) => void; + onStepComplete?: (step: UpdateStepCompletion) => void; +}; + type UpdateRunnerOptions = { cwd?: string; argv1?: string; timeoutMs?: number; runCommand?: CommandRunner; + progress?: UpdateStepProgress; }; const DEFAULT_TIMEOUT_MS = 20 * 60_000; @@ -142,20 +161,57 @@ 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; + + const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS); + + progress?.onStepComplete?.({ + ...stepInfo, + durationMs, + exitCode: result.code, + stderrTail, + }); + return { name, - command: argv.join(" "), + command, cwd, durationMs, exitCode: result.code, @@ -181,6 +237,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 +251,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,51 +297,50 @@ 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, + const statusCheck = await runStep( + step( + "clean check", + ["git", "-C", gitRoot, "status", "--porcelain"], + gitRoot, + ), ); - steps.push(statusStep); - if ((statusStep.stdoutTail ?? "").trim()) { + steps.push(statusCheck); + const hasUncommittedChanges = + statusCheck.stdoutTail && statusCheck.stdoutTail.trim().length > 0; + if (hasUncommittedChanges) { return { status: "skipped", 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( + "upstream check", + [ + "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,97 +349,88 @@ 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, }; } - steps.push( - await runStep( - runCommand, + const fetchStep = await runStep( + step( "git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot, - timeoutMs, ), ); + steps.push(fetchStep); 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, - ), + 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( - runCommand, - "deps install", - managerInstallArgs(manager), - gitRoot, - timeoutMs, - ), + + const depsStep = await runStep( + step("deps install", managerInstallArgs(manager), gitRoot), ); - steps.push( - await runStep( - runCommand, - "build", - managerScriptArgs(manager, "build"), - gitRoot, - timeoutMs, - ), + steps.push(depsStep); + + const buildStep = await runStep( + step("build", managerScriptArgs(manager, "build"), gitRoot), ); - steps.push( - await runStep( - runCommand, - "ui:build", - managerScriptArgs(manager, "ui:build"), - gitRoot, - timeoutMs, - ), + steps.push(buildStep); + + const uiBuildStep = await runStep( + step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot), ); - steps.push( - await runStep( - runCommand, + steps.push(uiBuildStep); + + const doctorStep = await runStep( + step( "clawdbot doctor", managerScriptArgs(manager, "clawdbot", ["doctor"]), gitRoot, - timeoutMs, { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, ), ); + steps.push(doctorStep); - 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 +440,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,