feat(doctor): offer update first

This commit is contained in:
Peter Steinberger
2026-01-10 21:14:30 +01:00
parent d772ff06c8
commit 3389231ecb
8 changed files with 242 additions and 12 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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. Its intentionally boring: repair + migrate + warn.
Note: if youre 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

View File

@@ -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();

View File

@@ -141,7 +141,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
);
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")}`,

View File

@@ -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<string, unknown>,
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<string, unknown>,
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({

View File

@@ -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();

View File

@@ -148,9 +148,10 @@ async function runStep(
argv: string[],
cwd: string,
timeoutMs: number,
env?: NodeJS.ProcessEnv,
): Promise<UpdateStepResult> {
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" },
),
);