From 3389231ecb26e53de4f536dc30229987fc579e9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 21:14:30 +0100 Subject: [PATCH] feat(doctor): offer update first --- CHANGELOG.md | 1 + docs/cli/update.md | 4 +- docs/install/updating.md | 26 +++- .../openai-responses.reasoning-replay.test.ts | 2 +- src/cli/update-cli.ts | 4 +- src/commands/doctor.test.ts | 129 ++++++++++++++++++ src/commands/doctor.ts | 84 ++++++++++++ src/infra/update-runner.ts | 4 +- 8 files changed, 242 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32d607669..4b32d9552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints. - Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs). - Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646) +- Doctor: offer to run `clawdbot update` first on git installs (keeps doctor output aligned with latest). - Doctor: avoid false legacy workspace warning when install dir is `~/clawdbot`. (#660) - iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons. - Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75. diff --git a/docs/cli/update.md b/docs/cli/update.md index 52a28abf5..0b37b6d06 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -9,7 +9,7 @@ read_when: Safely update a **source checkout** (git install) of Clawdbot. -If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating). +If you installed via **npm/pnpm/bun** (global install, no git metadata), use the package manager flow in [Updating](/install/updating). ## Usage @@ -42,6 +42,6 @@ High-level: ## See also +- `clawdbot doctor` (offers to run update first on git checkouts) - [Updating](/install/updating) - [CLI reference](/cli) - diff --git a/docs/install/updating.md b/docs/install/updating.md index 61db8509e..f0c8a6b6b 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -1,5 +1,5 @@ --- -summary: "Updating Clawdbot safely (npm or source), plus rollback strategy" +summary: "Updating Clawdbot safely (global install or source), plus rollback strategy" read_when: - Updating Clawdbot - Something breaks after an update @@ -11,14 +11,14 @@ Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: upda ## Before you update -- Know how you installed: **npm** (global) vs **from source** (git clone). +- Know how you installed: **global** (npm/pnpm/bun) vs **from source** (git clone). - Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd). - Snapshot your tailoring: - Config: `~/.clawdbot/clawdbot.json` - Credentials: `~/.clawdbot/credentials/` - Workspace: `~/clawd` -## Update (npm install) +## Update (global install) Global install (pick one): @@ -30,6 +30,10 @@ npm i -g clawdbot@latest pnpm add -g clawdbot@latest ``` +```bash +bun add -g clawdbot@latest +``` + Then: ```bash @@ -55,7 +59,7 @@ It runs a safe-ish update flow: - Fetches + rebases against the configured upstream. - Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`. -If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (npm install)” instead. +If you installed via **npm/pnpm/bun** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead. ## Update (Control UI / RPC) @@ -90,12 +94,14 @@ pnpm clawdbot health Notes: - `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`. - If you run directly from TypeScript (`pnpm clawdbot ...` / `bun run clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor. -- Switching between npm and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install. +- Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install. ## Always run: `clawdbot doctor` Doctor is the “safe update” command. It’s intentionally boring: repair + migrate + warn. +Note: if you’re on a **source install** (git checkout), `clawdbot doctor` will offer to run `clawdbot update` first. + Typical things it does: - Migrate deprecated config keys / legacy config file locations. - Audit DM policies and warn on risky “open” settings. @@ -127,7 +133,7 @@ Runbook + exact service labels: [Gateway runbook](/gateway) ## Rollback / pinning (when something breaks) -### Pin (npm) +### Pin (global install) Install a known-good version: @@ -135,6 +141,14 @@ Install a known-good version: npm i -g clawdbot@2026.1.9 ``` +```bash +pnpm add -g clawdbot@2026.1.9 +``` + +```bash +bun add -g clawdbot@2026.1.9 +``` + Then restart + re-run doctor: ```bash diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index eacc1de86..7fd2e13a8 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -52,7 +52,7 @@ function installFailingFetchCapture() { } describe("openai-responses reasoning replay", () => { - it("replays reasoning for tool-call-only turns", async () => { + it("does not replay reasoning for tool-call-only turns", async () => { const cap = installFailingFetchCapture(); try { const model = buildModel(); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 2fe5c3af8..8ae2872f5 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -141,7 +141,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { ); defaultRuntime.log( theme.muted( - "Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`", + "Examples: `npm i -g clawdbot@latest`, `pnpm add -g clawdbot@latest`, or `bun add -g clawdbot@latest`", ), ); } @@ -206,7 +206,7 @@ Examples: Notes: - For git installs: fetches, rebases, installs deps, builds, and runs doctor - - For npm installs: use npm/pnpm to reinstall (see docs/install/updating.md) + - For global installs: use npm/pnpm/bun to reinstall (see docs/install/updating.md) - Skips update if the working directory has uncommitted changes ${theme.muted("Docs:")} ${formatDocsLink("/updating", "docs.clawd.bot/updating")}`, diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index d649c810b..108feafb7 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let originalIsTTY: boolean | undefined; let originalStateDir: string | undefined; +let originalUpdateInProgress: string | undefined; let tempStateDir: string | undefined; function setStdinTty(value: boolean | undefined) { @@ -19,9 +20,66 @@ function setStdinTty(value: boolean | undefined) { } beforeEach(() => { + confirm.mockReset().mockResolvedValue(true); + select.mockReset().mockResolvedValue("node"); + note.mockClear(); + + readConfigFileSnapshot.mockReset(); + writeConfigFile.mockReset().mockResolvedValue(undefined); + resolveClawdbotPackageRoot.mockReset().mockResolvedValue(null); + runGatewayUpdate.mockReset().mockResolvedValue({ + status: "skipped", + mode: "unknown", + steps: [], + durationMs: 0, + }); + legacyReadConfigFileSnapshot.mockReset().mockResolvedValue({ + path: "/tmp/clawdis.json", + exists: false, + raw: null, + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }); + createConfigIO.mockReset().mockImplementation(() => ({ + readConfigFileSnapshot: legacyReadConfigFileSnapshot, + })); + runExec.mockReset().mockResolvedValue({ stdout: "", stderr: "" }); + runCommandWithTimeout.mockReset().mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + ensureAuthProfileStore + .mockReset() + .mockReturnValue({ version: 1, profiles: {} }); + migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({ + config: raw as Record, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], + })); + findLegacyGatewayServices.mockReset().mockResolvedValue([]); + uninstallLegacyGatewayServices.mockReset().mockResolvedValue([]); + findExtraGatewayServices.mockReset().mockResolvedValue([]); + renderGatewayServiceCleanupHints.mockReset().mockReturnValue(["cleanup"]); + resolveGatewayProgramArguments.mockReset().mockResolvedValue({ + programArguments: ["node", "cli", "gateway", "--port", "18789"], + }); + serviceInstall.mockReset().mockResolvedValue(undefined); + serviceIsLoaded.mockReset().mockResolvedValue(false); + serviceStop.mockReset().mockResolvedValue(undefined); + serviceRestart.mockReset().mockResolvedValue(undefined); + serviceUninstall.mockReset().mockResolvedValue(undefined); + callGateway.mockReset().mockRejectedValue(new Error("gateway closed")); + originalIsTTY = process.stdin.isTTY; setStdinTty(true); originalStateDir = process.env.CLAWDBOT_STATE_DIR; + originalUpdateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS; + process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1"; tempStateDir = fs.mkdtempSync( path.join(os.tmpdir(), "clawdbot-doctor-state-"), ); @@ -39,6 +97,11 @@ afterEach(() => { } else { process.env.CLAWDBOT_STATE_DIR = originalStateDir; } + if (originalUpdateInProgress === undefined) { + delete process.env.CLAWDBOT_UPDATE_IN_PROGRESS; + } else { + process.env.CLAWDBOT_UPDATE_IN_PROGRESS = originalUpdateInProgress; + } if (tempStateDir) { fs.rmSync(tempStateDir, { recursive: true, force: true }); tempStateDir = undefined; @@ -50,6 +113,13 @@ const confirm = vi.fn().mockResolvedValue(true); const select = vi.fn().mockResolvedValue("node"); const note = vi.fn(); const writeConfigFile = vi.fn().mockResolvedValue(undefined); +const resolveClawdbotPackageRoot = vi.fn().mockResolvedValue(null); +const runGatewayUpdate = vi.fn().mockResolvedValue({ + status: "skipped", + mode: "unknown", + steps: [], + durationMs: 0, +}); const migrateLegacyConfig = vi.fn((raw: unknown) => ({ config: raw as Record, changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], @@ -147,6 +217,14 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout, })); +vi.mock("../infra/clawdbot-root.js", () => ({ + resolveClawdbotPackageRoot, +})); + +vi.mock("../infra/update-runner.js", () => ({ + runGatewayUpdate, +})); + vi.mock("../agents/auth-profiles.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -330,6 +408,57 @@ describe("doctor", () => { expect(serviceInstall).toHaveBeenCalledTimes(1); }); + it("offers to update first for git checkouts", async () => { + delete process.env.CLAWDBOT_UPDATE_IN_PROGRESS; + + const root = "/tmp/clawdbot"; + resolveClawdbotPackageRoot.mockResolvedValueOnce(root); + runCommandWithTimeout.mockResolvedValueOnce({ + stdout: `${root}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + }); + runGatewayUpdate.mockResolvedValueOnce({ + status: "ok", + mode: "git", + root, + steps: [], + durationMs: 1, + }); + + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], + }); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime); + + expect(runGatewayUpdate).toHaveBeenCalledWith( + expect.objectContaining({ cwd: root }), + ); + expect(readConfigFileSnapshot).not.toHaveBeenCalled(); + expect( + note.mock.calls.some( + ([, title]) => typeof title === "string" && title === "Update result", + ), + ).toBe(true); + }); + it("migrates legacy config file", async () => { readConfigFileSnapshot .mockResolvedValueOnce({ diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 291605014..0fa771759 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -31,8 +31,11 @@ import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js"; +import { runGatewayUpdate } from "../infra/update-runner.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; @@ -95,6 +98,25 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } +async function detectClawdbotGitCheckout( + root: string, +): Promise<"git" | "not-git" | "unknown"> { + const res = await runCommandWithTimeout( + ["git", "-C", root, "rev-parse", "--show-toplevel"], + { timeoutMs: 5000 }, + ).catch(() => null); + if (!res) return "unknown"; + if (res.code !== 0) { + // Avoid noisy "Update via package manager" notes when git is missing/broken, + // but do show it when this is clearly not a git checkout. + if (res.stderr.toLowerCase().includes("not a git repository")) { + return "not-git"; + } + return "unknown"; + } + return res.stdout.trim() === root ? "git" : "not-git"; +} + export async function doctorCommand( runtime: RuntimeEnv = defaultRuntime, options: DoctorOptions = {}, @@ -103,6 +125,68 @@ export async function doctorCommand( printWizardHeader(runtime); intro("Clawdbot doctor"); + const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1"; + const canOfferUpdate = + !updateInProgress && + options.nonInteractive !== true && + options.yes !== true && + options.repair !== true && + Boolean(process.stdin.isTTY); + if (canOfferUpdate) { + const root = await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + if (root) { + const git = await detectClawdbotGitCheckout(root); + if (git === "git") { + const shouldUpdate = await prompter.confirm({ + message: "Update Clawdbot from git before running doctor?", + initialValue: true, + }); + if (shouldUpdate) { + note( + "Running update (fetch/rebase/build/ui:build/doctor)…", + "Update", + ); + const result = await runGatewayUpdate({ + cwd: root, + argv1: process.argv[1], + }); + note( + [ + `Status: ${result.status}`, + `Mode: ${result.mode}`, + result.root ? `Root: ${result.root}` : null, + result.reason ? `Reason: ${result.reason}` : null, + ] + .filter(Boolean) + .join("\n"), + "Update result", + ); + if (result.status === "ok") { + outro( + "Update completed (doctor already ran as part of the update).", + ); + return; + } + } + } else if (git === "not-git") { + note( + [ + "This install is not a git checkout.", + "Update via your package manager, then rerun doctor:", + "- npm i -g clawdbot@latest", + "- pnpm add -g clawdbot@latest", + "- bun add -g clawdbot@latest", + ].join("\n"), + "Update", + ); + } + } + } + await maybeMigrateLegacyConfigFile(runtime); const snapshot = await readConfigFileSnapshot(); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 5d3f8be7e..c118213ae 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -148,9 +148,10 @@ async function runStep( argv: string[], cwd: string, timeoutMs: number, + env?: NodeJS.ProcessEnv, ): Promise { const started = Date.now(); - const result = await runCommand(argv, { cwd, timeoutMs }); + const result = await runCommand(argv, { cwd, timeoutMs, env }); const durationMs = Date.now() - started; return { name, @@ -346,6 +347,7 @@ export async function runGatewayUpdate( managerScriptArgs(manager, "clawdbot", ["doctor"]), gitRoot, timeoutMs, + { CLAWDBOT_UPDATE_IN_PROGRESS: "1" }, ), );