feat(update): add progress spinner during update steps

This commit is contained in:
Benjamin Jesuiter
2026-01-11 00:48:39 +01:00
committed by Peter Steinberger
parent 920436da65
commit 88c404bcfc
2 changed files with 202 additions and 88 deletions

View File

@@ -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,