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 }>; type UpdateRunnerOptions = { cwd?: string; argv1?: string; timeoutMs?: number; runCommand?: CommandRunner; }; 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 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 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 { 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 runStep( runCommand: CommandRunner, name: string, argv: string[], cwd: string, timeoutMs: number, ): Promise { const started = Date.now(); const result = await runCommand(argv, { cwd, timeoutMs }); const durationMs = Date.now() - started; return { name, command: argv.join(" "), 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 managerUpdateArgs(manager: "pnpm" | "bun" | "npm") { if (manager === "pnpm") return ["pnpm", "update"]; if (manager === "bun") return ["bun", "update"]; return ["npm", "update"]; } export async function runGatewayUpdate( opts: UpdateRunnerOptions = {}, ): Promise { 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 steps: UpdateStepResult[] = []; const candidates = buildStartDirs(opts); const gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); if (gitRoot) { const beforeSha = ( await runStep( runCommand, "git rev-parse HEAD", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot, timeoutMs, ) ).stdoutTail?.trim(); const beforeVersion = await readPackageVersion(gitRoot); const statusStep = await runStep( runCommand, "git status", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot, timeoutMs, ); steps.push(statusStep); if ((statusStep.stdoutTail ?? "").trim()) { return { status: "skipped", mode: "git", root: gitRoot, reason: "dirty", before: { sha: beforeSha ?? null, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } const upstreamStep = await runStep( runCommand, "git upstream", [ "git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", ], gitRoot, timeoutMs, ); steps.push(upstreamStep); if (upstreamStep.exitCode !== 0) { return { status: "skipped", mode: "git", root: gitRoot, reason: "no-upstream", before: { sha: beforeSha ?? null, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } steps.push( await runStep( runCommand, "git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot, timeoutMs, ), ); const rebaseStep = await runStep( runCommand, "git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot, timeoutMs, ); steps.push(rebaseStep); if (rebaseStep.exitCode !== 0) { steps.push( await runStep( runCommand, "git rebase --abort", ["git", "-C", gitRoot, "rebase", "--abort"], gitRoot, timeoutMs, ), ); return { status: "error", mode: "git", root: gitRoot, reason: "rebase-failed", before: { sha: beforeSha ?? null, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } const manager = await detectPackageManager(gitRoot); steps.push( await runStep( runCommand, "deps install", managerInstallArgs(manager), gitRoot, timeoutMs, ), ); steps.push( await runStep( runCommand, "build", managerScriptArgs(manager, "build"), gitRoot, timeoutMs, ), ); 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, ), ); const failedStep = steps.find((step) => step.exitCode !== 0); const afterShaStep = await runStep( runCommand, "git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot, timeoutMs, ); 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 ?? null, version: beforeVersion }, after: { sha: afterShaStep.stdoutTail?.trim() ?? null, version: afterVersion, }, steps, durationMs: Date.now() - startedAt, }; } const pkgRoot = await findPackageRoot(candidates); if (!pkgRoot) { return { status: "error", mode: "unknown", reason: `no root (${START_DIRS.join(",")})`, steps: [], durationMs: Date.now() - startedAt, }; } const manager = await detectPackageManager(pkgRoot); steps.push( await runStep( runCommand, "deps update", managerUpdateArgs(manager), pkgRoot, timeoutMs, ), ); const failed = steps.find((step) => step.exitCode !== 0); return { status: failed ? "error" : "ok", mode: manager, root: pkgRoot, reason: failed ? failed.name : undefined, steps, durationMs: Date.now() - startedAt, }; }