diff --git a/CHANGELOG.md b/CHANGELOG.md index 487269375..4baa46ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Signal: add typing indicators and DM read receipts via signal-cli. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. +- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update ### Breaking - **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. @@ -31,6 +32,7 @@ Docs: https://docs.clawd.bot - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. +- Exec: avoid defaulting to elevated mode when elevated is not allowed. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai. ## 2026.1.21 diff --git a/docs/cli/update.md b/docs/cli/update.md index 9ebe509b0..8dafbbd87 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -16,6 +16,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), updates hap ```bash clawdbot update clawdbot update status +clawdbot update wizard clawdbot update --channel beta clawdbot update --channel dev clawdbot update --tag beta @@ -48,6 +49,11 @@ Options: - `--json`: print machine-readable status JSON. - `--timeout `: timeout for checks (default is 3s). +## `update wizard` + +Interactive flow to pick an update channel and confirm whether to restart the Gateway +after updating. If you select `dev` without a git checkout, it offers to create one. + ## What it does When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index e8ae15a7d..2c7aca4f3 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -5,6 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateRunResult } from "../infra/update-runner.js"; +const confirm = vi.fn(); +const select = vi.fn(); +const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })); +const isCancel = (value: unknown) => value === "cancel"; + +vi.mock("@clack/prompts", () => ({ + confirm, + select, + isCancel, + spinner, +})); + // Mock the update-runner module vi.mock("../infra/update-runner.js", () => ({ runGatewayUpdate: vi.fn(), @@ -128,9 +140,11 @@ describe("update-cli", () => { }); it("exports updateCommand and registerUpdateCli", async () => { - const { updateCommand, registerUpdateCli } = await import("./update-cli.js"); + const { updateCommand, registerUpdateCli, updateWizardCommand } = + await import("./update-cli.js"); expect(typeof updateCommand).toBe("function"); expect(typeof registerUpdateCli).toBe("function"); + expect(typeof updateWizardCommand).toBe("function"); }, 20_000); it("updateCommand runs update and outputs result", async () => { @@ -585,4 +599,61 @@ describe("update-cli", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("updateWizardCommand requires a TTY", async () => { + const { defaultRuntime } = await import("../runtime.js"); + const { updateWizardCommand } = await import("./update-cli.js"); + + setTty(false); + vi.mocked(defaultRuntime.error).mockClear(); + vi.mocked(defaultRuntime.exit).mockClear(); + + await updateWizardCommand({}); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Update wizard requires a TTY"), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("updateWizardCommand offers dev checkout and forwards selections", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-wizard-")); + const previousGitDir = process.env.CLAWDBOT_GIT_DIR; + try { + setTty(true); + process.env.CLAWDBOT_GIT_DIR = tempDir; + + const { checkUpdateStatus } = await import("../infra/update-check.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateWizardCommand } = await import("./update-cli.js"); + + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/test/path", + installKind: "package", + packageManager: "npm", + deps: { + manager: "npm", + status: "ok", + lockfilePath: null, + markerPath: null, + }, + }); + select.mockResolvedValue("dev"); + confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }); + + await updateWizardCommand({}); + + const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; + expect(call?.channel).toBe("dev"); + } finally { + process.env.CLAWDBOT_GIT_DIR = previousGitDir; + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 37ac4fc1c..538d07c97 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,4 +1,4 @@ -import { confirm, isCancel, spinner } from "@clack/prompts"; +import { confirm, isCancel, select, spinner } from "@clack/prompts"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -39,7 +39,7 @@ import { trimLogTail } from "../infra/restart-sentinel.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { formatCliCommand } from "./command-format.js"; -import { stylePromptMessage } from "../terminal/prompt-style.js"; +import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; import { renderTable } from "../terminal/table.js"; import { formatHelpExamples } from "./help-format.js"; @@ -63,6 +63,9 @@ export type UpdateStatusOptions = { json?: boolean; timeout?: string; }; +export type UpdateWizardOptions = { + timeout?: string; +}; const STEP_LABELS: Record = { "clean check": "Working directory is clean", @@ -481,6 +484,15 @@ function formatStepStatus(exitCode: number | null): string { return theme.error("\u2717"); } +const selectStyled = (params: Parameters>[0]) => + select({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }); + type PrintResultOptions = UpdateCommandOptions & { hideSteps?: boolean; }; @@ -940,6 +952,142 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } +export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise { + if (!process.stdin.isTTY) { + defaultRuntime.error( + "Update wizard requires a TTY. Use `clawdbot update --channel ` instead.", + ); + defaultRuntime.exit(1); + return; + } + + 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; + } + + const root = + (await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + })) ?? process.cwd(); + + const [updateStatus, configSnapshot] = await Promise.all([ + checkUpdateStatus({ + root, + timeoutMs: timeoutMs ?? 3500, + fetchGit: false, + includeRegistry: false, + }), + readConfigFileSnapshot(), + ]); + + const configChannel = configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null; + const channelInfo = resolveEffectiveUpdateChannel({ + configChannel, + installKind: updateStatus.installKind, + git: updateStatus.git + ? { tag: updateStatus.git.tag, branch: updateStatus.git.branch } + : undefined, + }); + const channelLabel = formatUpdateChannelLabel({ + channel: channelInfo.channel, + source: channelInfo.source, + gitTag: updateStatus.git?.tag ?? null, + gitBranch: updateStatus.git?.branch ?? null, + }); + + const pickedChannel = await selectStyled({ + message: "Update channel", + options: [ + { + value: "keep", + label: `Keep current (${channelInfo.channel})`, + hint: channelLabel, + }, + { + value: "stable", + label: "Stable", + hint: "Tagged releases (npm latest)", + }, + { + value: "beta", + label: "Beta", + hint: "Prereleases (npm beta)", + }, + { + value: "dev", + label: "Dev", + hint: "Git main", + }, + ], + initialValue: "keep", + }); + + if (isCancel(pickedChannel)) { + defaultRuntime.log(theme.muted("Update cancelled.")); + defaultRuntime.exit(0); + return; + } + + const requestedChannel = pickedChannel === "keep" ? null : pickedChannel; + + if (requestedChannel === "dev" && updateStatus.installKind !== "git") { + const gitDir = resolveGitInstallDir(); + const hasGit = await isGitCheckout(gitDir); + if (!hasGit) { + const dirExists = await pathExists(gitDir); + if (dirExists) { + const empty = await isEmptyDir(gitDir); + if (!empty) { + defaultRuntime.error( + `CLAWDBOT_GIT_DIR points at a non-git directory: ${gitDir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`, + ); + defaultRuntime.exit(1); + return; + } + } + const ok = await confirm({ + message: stylePromptMessage( + `Create a git checkout at ${gitDir}? (override via CLAWDBOT_GIT_DIR)`, + ), + initialValue: true, + }); + if (isCancel(ok) || ok === false) { + defaultRuntime.log(theme.muted("Update cancelled.")); + defaultRuntime.exit(0); + return; + } + } + } + + const restart = await confirm({ + message: stylePromptMessage("Restart the gateway service after update?"), + initialValue: false, + }); + if (isCancel(restart)) { + defaultRuntime.log(theme.muted("Update cancelled.")); + defaultRuntime.exit(0); + return; + } + + try { + await updateCommand({ + channel: requestedChannel ?? undefined, + restart: Boolean(restart), + timeout: opts.timeout, + }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } +} + export function registerUpdateCli(program: Command) { const update = program .command("update") @@ -959,6 +1107,7 @@ export function registerUpdateCli(program: Command) { ["clawdbot update --restart", "Update and restart the service"], ["clawdbot update --json", "Output result as JSON"], ["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"], + ["clawdbot update wizard", "Interactive update wizard"], ["clawdbot --update", "Shorthand for clawdbot update"], ] as const; const fmtExamples = examples @@ -1005,6 +1154,23 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda } }); + update + .command("wizard") + .description("Interactive update wizard") + .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") + .addHelpText( + "after", + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}\n`, + ) + .action(async (opts) => { + try { + await updateWizardCommand({ timeout: opts.timeout as string | undefined }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + update .command("status") .description("Show update channel and version status")