diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fdc8c338a..b47be0814 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -447,6 +447,7 @@ describe("update-cli", () => { it("requires confirmation on downgrade when non-interactive", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); try { + setTty(false); await fs.writeFile( path.join(tempDir, "package.json"), JSON.stringify({ name: "clawdbot", version: "2.0.0" }), @@ -483,4 +484,45 @@ describe("update-cli", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("allows downgrade with --yes in non-interactive mode", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); + try { + setTty(false); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "clawdbot", version: "2.0.0" }), + "utf-8", + ); + + const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); + const { resolveNpmChannelTag } = await import("../infra/update-check.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { defaultRuntime } = await import("../runtime.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "0.0.1", + }); + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + steps: [], + durationMs: 100, + }); + vi.mocked(defaultRuntime.error).mockClear(); + vi.mocked(defaultRuntime.exit).mockClear(); + + await updateCommand({ yes: true }); + + expect(defaultRuntime.error).not.toHaveBeenCalledWith( + expect.stringContaining("Downgrade confirmation required."), + ); + expect(runGatewayUpdate).toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 3560c0511..bd17a30db 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -45,6 +45,7 @@ export type UpdateCommandOptions = { channel?: string; tag?: string; timeout?: string; + yes?: boolean; }; export type UpdateStatusOptions = { json?: boolean; @@ -427,7 +428,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const needsConfirm = currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0)); - if (needsConfirm) { + if (needsConfirm && !opts.yes) { if (!process.stdin.isTTY || opts.json) { defaultRuntime.error( [ @@ -667,10 +668,15 @@ export function registerUpdateCli(program: Command) { .option("--channel ", "Persist update channel (git + npm)") .option("--tag ", "Override npm dist-tag or version for this update") .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") + .option("--yes", "Skip confirmation prompts (non-interactive)", false) .addHelpText( "after", () => ` +What this does: + - Git checkouts: fetches, rebases, installs deps, builds, and runs doctor + - npm installs: updates via detected package manager + Examples: clawdbot update # Update a source checkout (git) clawdbot update --channel beta # Switch to beta channel (git + npm) @@ -678,10 +684,11 @@ Examples: clawdbot update --tag beta # One-off update to a dist-tag or version clawdbot update --restart # Update and restart the daemon clawdbot update --json # Output result as JSON + clawdbot update --yes # Non-interactive (accept downgrade prompts) clawdbot --update # Shorthand for clawdbot update Notes: - - For git installs: fetches, rebases, installs deps, builds, and runs doctor + - Switch channels with --channel stable|beta|dev - For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md) - Downgrades require confirmation (can break configuration) - Skips update if the working directory has uncommitted changes @@ -696,6 +703,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda channel: opts.channel as string | undefined, tag: opts.tag as string | undefined, timeout: opts.timeout as string | undefined, + yes: Boolean(opts.yes), }); } catch (err) { defaultRuntime.error(String(err));