feat(update): add progress spinner during update steps
This commit is contained in:
committed by
Peter Steinberger
parent
920436da65
commit
88c404bcfc
@@ -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<string, string> = {
|
||||
"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<void> {
|
||||
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<void> {
|
||||
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") {
|
||||
|
||||
@@ -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<UpdateStepResult> {
|
||||
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<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 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<UpdateRunResult> {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user