feat(doctor): offer update first
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")}`,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user