Files
clawdbot/src/infra/update-runner.ts
2026-01-17 11:40:05 +00:00

514 lines
14 KiB
TypeScript

import os from "node:os";
import fs from "node:fs/promises";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import { trimLogTail } from "./restart-sentinel.js";
export type UpdateStepResult = {
name: string;
command: string;
cwd: string;
durationMs: number;
exitCode: number | null;
stdoutTail?: string | null;
stderrTail?: string | null;
};
export type UpdateRunResult = {
status: "ok" | "error" | "skipped";
mode: "git" | "pnpm" | "bun" | "npm" | "unknown";
root?: string;
reason?: string;
before?: { sha?: string | null; version?: string | null };
after?: { sha?: string | null; version?: string | null };
steps: UpdateStepResult[];
durationMs: number;
};
type CommandRunner = (
argv: string[],
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;
tag?: string;
timeoutMs?: number;
runCommand?: CommandRunner;
progress?: UpdateStepProgress;
};
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000;
const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
return path.resolve(trimmed);
}
function resolveNodeModulesBinPackageRoot(argv1: string): string | null {
const normalized = path.resolve(argv1);
const parts = normalized.split(path.sep);
const binIndex = parts.lastIndexOf(".bin");
if (binIndex <= 0) return null;
if (parts[binIndex - 1] !== "node_modules") return null;
const binName = path.basename(normalized);
const nodeModulesDir = parts.slice(0, binIndex).join(path.sep);
return path.join(nodeModulesDir, binName);
}
function buildStartDirs(opts: UpdateRunnerOptions): string[] {
const dirs: string[] = [];
const cwd = normalizeDir(opts.cwd);
if (cwd) dirs.push(cwd);
const argv1 = normalizeDir(opts.argv1);
if (argv1) {
dirs.push(path.dirname(argv1));
const packageRoot = resolveNodeModulesBinPackageRoot(argv1);
if (packageRoot) dirs.push(packageRoot);
}
const proc = normalizeDir(process.cwd());
if (proc) dirs.push(proc);
return Array.from(new Set(dirs));
}
async function readPackageVersion(root: string) {
try {
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { version?: string };
return typeof parsed?.version === "string" ? parsed.version : null;
} catch {
return null;
}
}
async function resolveGitRoot(
runCommand: CommandRunner,
candidates: string[],
timeoutMs: number,
): Promise<string | null> {
for (const dir of candidates) {
const res = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], {
timeoutMs,
});
if (res.code === 0) {
const root = res.stdout.trim();
if (root) return root;
}
}
return null;
}
async function findPackageRoot(candidates: string[]) {
for (const dir of candidates) {
let current = dir;
for (let i = 0; i < 12; i += 1) {
const pkgPath = path.join(current, "package.json");
try {
const raw = await fs.readFile(pkgPath, "utf-8");
const parsed = JSON.parse(raw) as { name?: string };
if (parsed?.name === "clawdbot") return current;
} catch {
// ignore
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
}
return null;
}
async function detectPackageManager(root: string) {
try {
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { packageManager?: string };
const pm = parsed?.packageManager?.split("@")[0]?.trim();
if (pm === "pnpm" || pm === "bun" || pm === "npm") return pm;
} catch {
// ignore
}
const files = await fs.readdir(root).catch((): string[] => []);
if (files.includes("pnpm-lock.yaml")) return "pnpm";
if (files.includes("bun.lockb")) return "bun";
if (files.includes("package-lock.json")) return "npm";
return "npm";
}
async function tryRealpath(value: string): Promise<string> {
try {
return await fs.realpath(value);
} catch {
return path.resolve(value);
}
}
async function detectGlobalInstallManager(
runCommand: CommandRunner,
pkgRoot: string,
timeoutMs: number,
): Promise<"npm" | "pnpm" | "bun" | null> {
const pkgReal = await tryRealpath(pkgRoot);
const candidates: Array<{
manager: "npm" | "pnpm";
argv: string[];
}> = [
{ manager: "npm", argv: ["npm", "root", "-g"] },
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
];
for (const { manager, argv } of candidates) {
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
if (!res) continue;
if (res.code !== 0) continue;
const globalRoot = res.stdout.trim();
if (!globalRoot) continue;
const globalReal = await tryRealpath(globalRoot);
const expected = path.join(globalReal, "clawdbot");
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
}
// Bun doesn't have an officially stable "global root" command across versions,
// so we check the common global install path (best-effort).
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
const bunExpected = path.join(bunGlobalReal, "clawdbot");
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
return null;
}
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;
const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS);
progress?.onStepComplete?.({
...stepInfo,
durationMs,
exitCode: result.code,
stderrTail,
});
return {
name,
command,
cwd,
durationMs,
exitCode: result.code,
stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS),
stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS),
};
}
function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) {
if (manager === "pnpm") return ["pnpm", script, ...args];
if (manager === "bun") return ["bun", "run", script, ...args];
if (args.length > 0) return ["npm", "run", script, "--", ...args];
return ["npm", "run", script];
}
function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
if (manager === "pnpm") return ["pnpm", "install"];
if (manager === "bun") return ["bun", "install"];
return ["npm", "install"];
}
function normalizeTag(tag?: string) {
const trimmed = tag?.trim();
if (!trimmed) return "latest";
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
}
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
const spec = `clawdbot@${normalizeTag(tag)}`;
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
if (manager === "bun") return ["bun", "add", "-g", spec];
return ["npm", "i", "-g", spec];
}
// 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> {
const startedAt = Date.now();
const runCommand =
opts.runCommand ??
(async (argv, options) => {
const res = await runCommandWithTimeout(argv, options);
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);
if (gitRoot && pkgRoot && path.resolve(gitRoot) !== path.resolve(pkgRoot)) {
gitRoot = null;
}
if (gitRoot && !pkgRoot) {
return {
status: "error",
mode: "unknown",
root: gitRoot,
reason: "not-clawdbot-root",
steps: [],
durationMs: Date.now() - startedAt,
};
}
if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) {
// 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 statusCheck = await runStep(
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
);
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, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const upstreamStep = await runStep(
step(
"upstream check",
["git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
gitRoot,
),
);
steps.push(upstreamStep);
if (upstreamStep.exitCode !== 0) {
return {
status: "skipped",
mode: "git",
root: gitRoot,
reason: "no-upstream",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const fetchStep = await runStep(
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot),
);
steps.push(fetchStep);
const rebaseStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {
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, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const manager = await detectPackageManager(gitRoot);
const depsStep = await runStep(step("deps install", managerInstallArgs(manager), gitRoot));
steps.push(depsStep);
const buildStep = await runStep(step("build", managerScriptArgs(manager, "build"), gitRoot));
steps.push(buildStep);
const uiBuildStep = await runStep(
step("ui:build", managerScriptArgs(manager, "ui:build"), gitRoot),
);
steps.push(uiBuildStep);
const doctorStep = await runStep(
step(
"clawdbot doctor",
managerScriptArgs(manager, "clawdbot", ["doctor", "--non-interactive"]),
gitRoot,
{ CLAWDBOT_UPDATE_IN_PROGRESS: "1" },
),
);
steps.push(doctorStep);
const failedStep = steps.find((s) => s.exitCode !== 0);
const afterShaStep = await runStep(
step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),
);
steps.push(afterShaStep);
const afterVersion = await readPackageVersion(gitRoot);
return {
status: failedStep ? "error" : "ok",
mode: "git",
root: gitRoot,
reason: failedStep ? failedStep.name : undefined,
before: { sha: beforeSha, version: beforeVersion },
after: {
sha: afterShaStep.stdoutTail?.trim() ?? null,
version: afterVersion,
},
steps,
durationMs: Date.now() - startedAt,
};
}
if (!pkgRoot) {
return {
status: "error",
mode: "unknown",
reason: `no root (${START_DIRS.join(",")})`,
steps: [],
durationMs: Date.now() - startedAt,
};
}
const beforeVersion = await readPackageVersion(pkgRoot);
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
if (globalManager) {
const updateStep = await runStep({
runCommand,
name: "global update",
argv: globalUpdateArgs(globalManager, opts.tag),
cwd: pkgRoot,
timeoutMs,
progress,
stepIndex: 0,
totalSteps: 1,
});
const steps = [updateStep];
const afterVersion = await readPackageVersion(pkgRoot);
return {
status: updateStep.exitCode === 0 ? "ok" : "error",
mode: globalManager,
root: pkgRoot,
reason: updateStep.exitCode === 0 ? undefined : updateStep.name,
before: { version: beforeVersion },
after: { version: afterVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
return {
status: "skipped",
mode: "unknown",
root: pkgRoot,
reason: "not-git-install",
before: { version: beforeVersion },
steps: [],
durationMs: Date.now() - startedAt,
};
}