From 777fb6b7bb2911991100b14a9096e8c4827d048d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Jan 2026 18:18:10 +0000 Subject: [PATCH] CLI: add clawdbot update command and --update flag --- src/cli/program.ts | 2 + src/cli/run-main.ts | 7 ++ src/cli/update-cli.test.ts | 148 ++++++++++++++++++++++++++ src/cli/update-cli.ts | 205 +++++++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 src/cli/update-cli.test.ts create mode 100644 src/cli/update-cli.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index 1ca4430f0..4d78da35b 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -53,6 +53,7 @@ import { registerProvidersCli } from "./providers-cli.js"; import { registerSandboxCli } from "./sandbox-cli.js"; import { registerSkillsCli } from "./skills-cli.js"; import { registerTuiCli } from "./tui-cli.js"; +import { registerUpdateCli } from "./update-cli.js"; export { forceFreePort }; @@ -1132,6 +1133,7 @@ Examples: registerPairingCli(program); registerProvidersCli(program); registerSkillsCli(program); + registerUpdateCli(program); program .command("status") diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index b9a4fa533..e52bf0c6a 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -8,6 +8,7 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; +import { updateCommand } from "./update-cli.js"; export async function runCli(argv: string[] = process.argv) { loadDotEnv({ quiet: true }); @@ -20,6 +21,12 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); + // Handle --update flag before full program parsing + if (argv.includes("--update")) { + await updateCommand({}); + return; + } + const { buildProgram } = await import("./program.js"); const program = buildProgram(); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts new file mode 100644 index 000000000..cf555a6fd --- /dev/null +++ b/src/cli/update-cli.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { UpdateRunResult } from "../infra/update-runner.js"; + +// Mock the update-runner module +vi.mock("../infra/update-runner.js", () => ({ + runGatewayUpdate: vi.fn(), +})); + +// Mock the daemon-cli module +vi.mock("./daemon-cli.js", () => ({ + runDaemonRestart: vi.fn(), +})); + +// Mock the runtime +vi.mock("../runtime.js", () => ({ + defaultRuntime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, +})); + +describe("update-cli", () => { + it("exports updateCommand and registerUpdateCli", async () => { + const { updateCommand, registerUpdateCli } = await import( + "./update-cli.js" + ); + expect(typeof updateCommand).toBe("function"); + expect(typeof registerUpdateCli).toBe("function"); + }); + + it("updateCommand runs update and outputs result", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { defaultRuntime } = await import("../runtime.js"); + const { updateCommand } = await import("./update-cli.js"); + + const mockResult: UpdateRunResult = { + status: "ok", + mode: "git", + root: "/test/path", + before: { sha: "abc123", version: "1.0.0" }, + after: { sha: "def456", version: "1.0.1" }, + steps: [ + { + name: "git fetch", + command: "git fetch", + cwd: "/test/path", + durationMs: 100, + exitCode: 0, + }, + ], + durationMs: 500, + }; + + vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + + await updateCommand({ json: false }); + + expect(runGatewayUpdate).toHaveBeenCalled(); + expect(defaultRuntime.log).toHaveBeenCalled(); + }); + + it("updateCommand outputs JSON when --json is set", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { defaultRuntime } = await import("../runtime.js"); + const { updateCommand } = await import("./update-cli.js"); + + const mockResult: UpdateRunResult = { + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }; + + vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(defaultRuntime.log).mockClear(); + + await updateCommand({ json: true }); + + const logCalls = vi.mocked(defaultRuntime.log).mock.calls; + const jsonOutput = logCalls.find((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + }); + + it("updateCommand exits with error on failure", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { defaultRuntime } = await import("../runtime.js"); + const { updateCommand } = await import("./update-cli.js"); + + const mockResult: UpdateRunResult = { + status: "error", + mode: "git", + reason: "rebase-failed", + steps: [], + durationMs: 100, + }; + + vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(defaultRuntime.exit).mockClear(); + + await updateCommand({}); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("updateCommand restarts daemon when --restart is set", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { runDaemonRestart } = await import("./daemon-cli.js"); + const { updateCommand } = await import("./update-cli.js"); + + const mockResult: UpdateRunResult = { + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }; + + vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runDaemonRestart).mockResolvedValue(); + + await updateCommand({ restart: true }); + + expect(runDaemonRestart).toHaveBeenCalled(); + }); + + it("updateCommand validates timeout option", async () => { + const { defaultRuntime } = await import("../runtime.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(defaultRuntime.error).mockClear(); + vi.mocked(defaultRuntime.exit).mockClear(); + + await updateCommand({ timeout: "invalid" }); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("timeout"), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts new file mode 100644 index 000000000..9ef5a1e3e --- /dev/null +++ b/src/cli/update-cli.ts @@ -0,0 +1,205 @@ +import type { Command } from "commander"; + +import { + runGatewayUpdate, + type UpdateRunResult, +} from "../infra/update-runner.js"; +import { defaultRuntime } from "../runtime.js"; +import { theme } from "../terminal/theme.js"; +import { runDaemonRestart } from "./daemon-cli.js"; + +export type UpdateCommandOptions = { + json?: boolean; + restart?: boolean; + timeout?: string; +}; + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = (ms / 1000).toFixed(1); + return `${seconds}s`; +} + +function formatStepStatus(exitCode: number | null): string { + if (exitCode === 0) return theme.success("\u2713"); + if (exitCode === null) return theme.warn("?"); + return theme.error("\u2717"); +} + +function printResult(result: UpdateRunResult, opts: UpdateCommandOptions) { + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + + const statusColor = + result.status === "ok" + ? theme.success + : result.status === "skipped" + ? theme.warn + : theme.error; + + defaultRuntime.log(""); + defaultRuntime.log( + `${theme.heading("Update Result:")} ${statusColor(result.status.toUpperCase())}`, + ); + defaultRuntime.log(` Mode: ${theme.muted(result.mode)}`); + if (result.root) { + defaultRuntime.log(` Root: ${theme.muted(result.root)}`); + } + if (result.reason) { + defaultRuntime.log(` Reason: ${theme.muted(result.reason)}`); + } + + if (result.before?.version || result.before?.sha) { + const before = + result.before.version ?? result.before.sha?.slice(0, 8) ?? ""; + defaultRuntime.log(` Before: ${theme.muted(before)}`); + } + if (result.after?.version || result.after?.sha) { + const after = result.after.version ?? result.after.sha?.slice(0, 8) ?? ""; + defaultRuntime.log(` After: ${theme.muted(after)}`); + } + + if (result.steps.length > 0) { + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Steps:")); + for (const step of result.steps) { + const status = formatStepStatus(step.exitCode); + const duration = theme.muted(`(${formatDuration(step.durationMs)})`); + defaultRuntime.log(` ${status} ${step.name} ${duration}`); + + // Show stderr for failed steps + if (step.exitCode !== 0 && step.stderrTail) { + const lines = step.stderrTail.split("\n").slice(0, 5); + for (const line of lines) { + if (line.trim()) { + defaultRuntime.log(` ${theme.error(line)}`); + } + } + } + } + } + + defaultRuntime.log(""); + defaultRuntime.log( + `Total time: ${theme.muted(formatDuration(result.durationMs))}`, + ); +} + +export async function updateCommand(opts: UpdateCommandOptions): Promise { + const timeoutMs = opts.timeout + ? Number.parseInt(opts.timeout, 10) * 1000 + : undefined; + + if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) { + defaultRuntime.error("--timeout must be a positive integer (seconds)"); + defaultRuntime.exit(1); + return; + } + + if (!opts.json) { + defaultRuntime.log(theme.heading("Updating Clawdbot...")); + defaultRuntime.log(""); + } + + const result = await runGatewayUpdate({ + cwd: process.cwd(), + argv1: process.argv[1], + timeoutMs, + }); + + printResult(result, opts); + + if (result.status === "error") { + defaultRuntime.exit(1); + return; + } + + if (result.status === "skipped") { + if (result.reason === "dirty") { + defaultRuntime.log( + theme.warn( + "Skipped: working directory has uncommitted changes. Commit or stash them first.", + ), + ); + } + defaultRuntime.exit(0); + return; + } + + // Restart daemon if requested + if (opts.restart) { + if (!opts.json) { + defaultRuntime.log(""); + defaultRuntime.log(theme.heading("Restarting daemon...")); + } + try { + await runDaemonRestart(); + if (!opts.json) { + defaultRuntime.log(theme.success("Daemon restarted successfully.")); + } + } catch (err) { + if (!opts.json) { + defaultRuntime.log( + theme.warn(`Daemon restart failed: ${String(err)}`), + ); + defaultRuntime.log( + theme.muted( + "You may need to restart the daemon manually: clawdbot daemon restart", + ), + ); + } + } + } else if (!opts.json) { + defaultRuntime.log(""); + defaultRuntime.log( + theme.muted( + "Tip: Run `clawdbot daemon restart` to apply updates to a running gateway.", + ), + ); + } +} + +export function registerUpdateCli(program: Command) { + program + .command("update") + .description("Update Clawdbot to the latest version") + .option("--json", "Output result as JSON", false) + .option( + "--restart", + "Restart the gateway daemon after a successful update", + false, + ) + .option( + "--timeout ", + "Timeout for each update step in seconds (default: 1200)", + ) + .addHelpText( + "after", + ` +Examples: + clawdbot update # Update from git or package manager + clawdbot update --restart # Update and restart the daemon + clawdbot update --json # Output result as JSON + clawdbot --update # Shorthand for clawdbot update + +Notes: + - For git installs: fetches, rebases, installs deps, builds, and runs doctor + - For npm installs: runs package manager update command + - Skips update if the working directory has uncommitted changes +`, + ) + .action(async (opts) => { + try { + await updateCommand({ + json: Boolean(opts.json), + restart: Boolean(opts.restart), + timeout: opts.timeout as string | undefined, + }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +}