Merge pull request #701 from bjesuiter/fix/update-progress-logs

feat(update): add progress spinner during update steps
This commit is contained in:
Peter Steinberger
2026-01-11 02:03:27 +00:00
committed by GitHub
4 changed files with 258 additions and 99 deletions

View File

@@ -7,6 +7,7 @@
- CLI/Status: improve Tailscale reporting in `status --all` and harden parsing of noisy `tailscale status --json` output. - 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). - 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. - 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 ## 2026.1.11-4

View File

@@ -90,7 +90,11 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
applyState(); applyState();
}; };
if (delayMs === 0) {
start();
} else {
timer = setTimeout(start, delayMs); timer = setTimeout(start, delayMs);
}
const setLabel = (next: string) => { const setLabel = (next: string) => {
label = next; label = next;

View File

@@ -1,9 +1,12 @@
import { spinner } from "@clack/prompts";
import type { Command } from "commander"; import type { Command } from "commander";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { import {
runGatewayUpdate, runGatewayUpdate,
type UpdateRunResult, type UpdateRunResult,
type UpdateStepInfo,
type UpdateStepProgress,
} from "../infra/update-runner.js"; } from "../infra/update-runner.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
@@ -15,6 +18,75 @@ export type UpdateCommandOptions = {
timeout?: string; timeout?: string;
}; };
const STEP_LABELS: Record<string, string> = {
"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<typeof spinner> | 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 { function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
const seconds = (ms / 1000).toFixed(1); const seconds = (ms / 1000).toFixed(1);
@@ -27,7 +99,11 @@ function formatStepStatus(exitCode: number | null): string {
return theme.error("\u2717"); return theme.error("\u2717");
} }
function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) { type PrintResultOptions = UpdateCommandOptions & {
hideSteps?: boolean;
};
function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
if (opts.json) { if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
@@ -44,7 +120,6 @@ function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) {
defaultRuntime.log( defaultRuntime.log(
`${theme.heading("Update Result:")} ${statusColor(result.status.toUpperCase())}`, `${theme.heading("Update Result:")} ${statusColor(result.status.toUpperCase())}`,
); );
defaultRuntime.log(` Mode: ${theme.muted(result.mode)}`);
if (result.root) { if (result.root) {
defaultRuntime.log(` Root: ${theme.muted(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)}`); defaultRuntime.log(` After: ${theme.muted(after)}`);
} }
if (result.steps.length > 0) { if (!opts.hideSteps && result.steps.length > 0) {
defaultRuntime.log(""); defaultRuntime.log("");
defaultRuntime.log(theme.heading("Steps:")); defaultRuntime.log(theme.heading("Steps:"));
for (const step of result.steps) { for (const step of result.steps) {
@@ -70,7 +145,6 @@ function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) {
const duration = theme.muted(`(${formatDuration(step.durationMs)})`); const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
defaultRuntime.log(` ${status} ${step.name} ${duration}`); defaultRuntime.log(` ${status} ${step.name} ${duration}`);
// Show stderr for failed steps
if (step.exitCode !== 0 && step.stderrTail) { if (step.exitCode !== 0 && step.stderrTail) {
const lines = step.stderrTail.split("\n").slice(0, 5); const lines = step.stderrTail.split("\n").slice(0, 5);
for (const line of lines) { for (const line of lines) {
@@ -99,6 +173,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return; return;
} }
const showProgress = !opts.json && process.stdout.isTTY;
if (!opts.json) { if (!opts.json) {
defaultRuntime.log(theme.heading("Updating Clawdbot...")); defaultRuntime.log(theme.heading("Updating Clawdbot..."));
defaultRuntime.log(""); defaultRuntime.log("");
@@ -111,13 +187,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
cwd: process.cwd(), cwd: process.cwd(),
})) ?? process.cwd(); })) ?? process.cwd();
const { progress, stop } = createUpdateProgress(showProgress);
const result = await runGatewayUpdate({ const result = await runGatewayUpdate({
cwd: root, cwd: root,
argv1: process.argv[1], argv1: process.argv[1],
timeoutMs, timeoutMs,
progress,
}); });
printResult(result, opts); stop();
printResult(result, { ...opts, hideSteps: showProgress });
if (result.status === "error") { if (result.status === "error") {
defaultRuntime.exit(1); defaultRuntime.exit(1);

View File

@@ -30,11 +30,30 @@ type CommandRunner = (
options: CommandOptions, options: CommandOptions,
) => Promise<{ stdout: string; stderr: string; code: number | null }>; ) => 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 = { type UpdateRunnerOptions = {
cwd?: string; cwd?: string;
argv1?: string; argv1?: string;
timeoutMs?: number; timeoutMs?: number;
runCommand?: CommandRunner; runCommand?: CommandRunner;
progress?: UpdateStepProgress;
}; };
const DEFAULT_TIMEOUT_MS = 20 * 60_000; const DEFAULT_TIMEOUT_MS = 20 * 60_000;
@@ -142,20 +161,57 @@ async function detectPackageManager(root: string) {
return "npm"; return "npm";
} }
async function runStep( type RunStepOptions = {
runCommand: CommandRunner, runCommand: CommandRunner;
name: string, name: string;
argv: string[], argv: string[];
cwd: string, cwd: string;
timeoutMs: number, timeoutMs: number;
env?: NodeJS.ProcessEnv, env?: NodeJS.ProcessEnv;
): Promise<UpdateStepResult> { progress?: UpdateStepProgress;
stepIndex: number;
totalSteps: number;
};
async function runStep(opts: RunStepOptions): Promise<UpdateStepResult> {
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 started = Date.now();
const result = await runCommand(argv, { cwd, timeoutMs, env }); const result = await runCommand(argv, { cwd, timeoutMs, env });
const durationMs = Date.now() - started; const durationMs = Date.now() - started;
const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS);
progress?.onStepComplete?.({
...stepInfo,
durationMs,
exitCode: result.code,
stderrTail,
});
return { return {
name, name,
command: argv.join(" "), command,
cwd, cwd,
durationMs, durationMs,
exitCode: result.code, exitCode: result.code,
@@ -181,6 +237,9 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
return ["npm", "install"]; return ["npm", "install"];
} }
// Total number of visible steps in a successful git update flow
const GIT_UPDATE_TOTAL_STEPS = 9;
export async function runGatewayUpdate( export async function runGatewayUpdate(
opts: UpdateRunnerOptions = {}, opts: UpdateRunnerOptions = {},
): Promise<UpdateRunResult> { ): Promise<UpdateRunResult> {
@@ -192,9 +251,33 @@ export async function runGatewayUpdate(
return { stdout: res.stdout, stderr: res.stderr, code: res.code }; return { stdout: res.stdout, stderr: res.stderr, code: res.code };
}); });
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const progress = opts.progress;
const steps: UpdateStepResult[] = []; const steps: UpdateStepResult[] = [];
const candidates = buildStartDirs(opts); 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); const pkgRoot = await findPackageRoot(candidates);
let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs);
@@ -214,40 +297,39 @@ export async function runGatewayUpdate(
} }
if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) { if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) {
const beforeSha = ( // Get current SHA (not a visible step, no progress)
await runStep( const beforeShaResult = await runCommand(
runCommand,
"git rev-parse HEAD",
["git", "-C", gitRoot, "rev-parse", "HEAD"], ["git", "-C", gitRoot, "rev-parse", "HEAD"],
gitRoot, { cwd: gitRoot, timeoutMs },
timeoutMs, );
) const beforeSha = beforeShaResult.stdout.trim() || null;
).stdoutTail?.trim();
const beforeVersion = await readPackageVersion(gitRoot); const beforeVersion = await readPackageVersion(gitRoot);
const statusStep = await runStep( const statusCheck = await runStep(
runCommand, step(
"git status", "clean check",
["git", "-C", gitRoot, "status", "--porcelain"], ["git", "-C", gitRoot, "status", "--porcelain"],
gitRoot, gitRoot,
timeoutMs, ),
); );
steps.push(statusStep); steps.push(statusCheck);
if ((statusStep.stdoutTail ?? "").trim()) { const hasUncommittedChanges =
statusCheck.stdoutTail && statusCheck.stdoutTail.trim().length > 0;
if (hasUncommittedChanges) {
return { return {
status: "skipped", status: "skipped",
mode: "git", mode: "git",
root: gitRoot, root: gitRoot,
reason: "dirty", reason: "dirty",
before: { sha: beforeSha ?? null, version: beforeVersion }, before: { sha: beforeSha, version: beforeVersion },
steps, steps,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}; };
} }
const upstreamStep = await runStep( const upstreamStep = await runStep(
runCommand, step(
"git upstream", "upstream check",
[ [
"git", "git",
"-C", "-C",
@@ -258,7 +340,7 @@ export async function runGatewayUpdate(
"@{upstream}", "@{upstream}",
], ],
gitRoot, gitRoot,
timeoutMs, ),
); );
steps.push(upstreamStep); steps.push(upstreamStep);
if (upstreamStep.exitCode !== 0) { if (upstreamStep.exitCode !== 0) {
@@ -267,97 +349,88 @@ export async function runGatewayUpdate(
mode: "git", mode: "git",
root: gitRoot, root: gitRoot,
reason: "no-upstream", reason: "no-upstream",
before: { sha: beforeSha ?? null, version: beforeVersion }, before: { sha: beforeSha, version: beforeVersion },
steps, steps,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}; };
} }
steps.push( const fetchStep = await runStep(
await runStep( step(
runCommand,
"git fetch", "git fetch",
["git", "-C", gitRoot, "fetch", "--all", "--prune"], ["git", "-C", gitRoot, "fetch", "--all", "--prune"],
gitRoot, gitRoot,
timeoutMs,
), ),
); );
steps.push(fetchStep);
const rebaseStep = await runStep( const rebaseStep = await runStep(
runCommand, step(
"git rebase", "git rebase",
["git", "-C", gitRoot, "rebase", "@{upstream}"], ["git", "-C", gitRoot, "rebase", "@{upstream}"],
gitRoot, gitRoot,
timeoutMs, ),
); );
steps.push(rebaseStep); steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) { if (rebaseStep.exitCode !== 0) {
steps.push( const abortResult = await runCommand(
await runStep(
runCommand,
"git rebase --abort",
["git", "-C", gitRoot, "rebase", "--abort"], ["git", "-C", gitRoot, "rebase", "--abort"],
gitRoot, { cwd: gitRoot, timeoutMs },
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 { return {
status: "error", status: "error",
mode: "git", mode: "git",
root: gitRoot, root: gitRoot,
reason: "rebase-failed", reason: "rebase-failed",
before: { sha: beforeSha ?? null, version: beforeVersion }, before: { sha: beforeSha, version: beforeVersion },
steps, steps,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}; };
} }
const manager = await detectPackageManager(gitRoot); const manager = await detectPackageManager(gitRoot);
steps.push(
await runStep( const depsStep = await runStep(
runCommand, step("deps install", managerInstallArgs(manager), gitRoot),
"deps install",
managerInstallArgs(manager),
gitRoot,
timeoutMs,
),
); );
steps.push( steps.push(depsStep);
await runStep(
runCommand, const buildStep = await runStep(
"build", step("build", managerScriptArgs(manager, "build"), gitRoot),
managerScriptArgs(manager, "build"),
gitRoot,
timeoutMs,
),
); );
steps.push( steps.push(buildStep);
await runStep(
runCommand, const uiBuildStep = await runStep(
"ui:build", step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot),
managerScriptArgs(manager, "ui:build"),
gitRoot,
timeoutMs,
),
); );
steps.push( steps.push(uiBuildStep);
await runStep(
runCommand, const doctorStep = await runStep(
step(
"clawdbot doctor", "clawdbot doctor",
managerScriptArgs(manager, "clawdbot", ["doctor"]), managerScriptArgs(manager, "clawdbot", ["doctor"]),
gitRoot, gitRoot,
timeoutMs,
{ CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, { 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( const afterShaStep = await runStep(
runCommand, step(
"git rev-parse HEAD (after)", "git rev-parse HEAD (after)",
["git", "-C", gitRoot, "rev-parse", "HEAD"], ["git", "-C", gitRoot, "rev-parse", "HEAD"],
gitRoot, gitRoot,
timeoutMs, ),
); );
steps.push(afterShaStep); steps.push(afterShaStep);
const afterVersion = await readPackageVersion(gitRoot); const afterVersion = await readPackageVersion(gitRoot);
@@ -367,7 +440,7 @@ export async function runGatewayUpdate(
mode: "git", mode: "git",
root: gitRoot, root: gitRoot,
reason: failedStep ? failedStep.name : undefined, reason: failedStep ? failedStep.name : undefined,
before: { sha: beforeSha ?? null, version: beforeVersion }, before: { sha: beforeSha, version: beforeVersion },
after: { after: {
sha: afterShaStep.stdoutTail?.trim() ?? null, sha: afterShaStep.stdoutTail?.trim() ?? null,
version: afterVersion, version: afterVersion,