From a51ed8a5ddc74f43cb3c69e4f99d77ee88c3e10e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 11:45:37 +0000 Subject: [PATCH] fix(cli): auto-update global installs --- CHANGELOG.md | 1 + src/cli/update-cli.ts | 21 ++++-- src/commands/doctor-update.ts | 5 +- src/infra/update-runner.test.ts | 110 +++++++++++++++++++++++++++++++- src/infra/update-runner.ts | 79 +++++++++++++++++++++++ 5 files changed, 203 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0b93563..5b1acfca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ ### Fixes - WhatsApp: default response prefix only for self-chat, using identity name when set. +- Fix: make `clawdbot update` auto-update global installs when installed via a package manager. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. - Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen. diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 0c2567020..435875c0d 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -28,6 +28,7 @@ const STEP_LABELS: Record = { "ui:build": "Building UI", "clawdbot doctor": "Running doctor checks", "git rev-parse HEAD (after)": "Verifying update", + "global update": "Updating via package manager", }; function getStepLabel(step: UpdateStepInfo): string { @@ -206,12 +207,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (result.reason === "not-git-install") { defaultRuntime.log( theme.warn( - "Skipped: this Clawdbot install isn't a git checkout. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.", + "Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.", ), ); defaultRuntime.log( theme.muted( - "Examples: `npm i -g clawdbot@latest`, `pnpm add -g clawdbot@latest`, or `bun add -g clawdbot@latest`", + "Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`", ), ); } @@ -251,9 +252,17 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } else if (!opts.json) { defaultRuntime.log(""); - defaultRuntime.log( - theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."), - ); + if (result.mode === "npm" || result.mode === "pnpm") { + defaultRuntime.log( + theme.muted( + "Tip: Run `clawdbot doctor`, then `clawdbot daemon restart` to apply updates to a running gateway.", + ), + ); + } else { + defaultRuntime.log( + theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."), + ); + } } } @@ -276,7 +285,7 @@ Examples: Notes: - For git installs: fetches, rebases, installs deps, builds, and runs doctor - - For global installs: use npm/pnpm/bun to reinstall (see docs/install/updating.md) + - For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md) - Skips update if the working directory has uncommitted changes ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`, diff --git a/src/commands/doctor-update.ts b/src/commands/doctor-update.ts index 7462bb7de..d180dbc40 100644 --- a/src/commands/doctor-update.ts +++ b/src/commands/doctor-update.ts @@ -70,10 +70,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: { 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", + "Run `clawdbot update` to update via your package manager (npm/pnpm), then rerun doctor.", ].join("\n"), "Update", ); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 1d7877016..0724cab4d 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -96,6 +96,8 @@ describe("runGatewayUpdate", () => { await fs.writeFile(path.join(tempDir, "pnpm-lock.yaml"), "", "utf-8"); const { runner, calls } = createRunner({ [`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 }, + "npm root -g": { code: 1 }, + "pnpm root -g": { code: 1 }, }); const result = await runGatewayUpdate({ @@ -106,9 +108,111 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("skipped"); expect(result.reason).toBe("not-git-install"); - expect(calls.some((call) => call.startsWith("pnpm "))).toBe(false); - expect(calls.some((call) => call.startsWith("npm "))).toBe(false); - expect(calls.some((call) => call.startsWith("bun "))).toBe(false); + expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false); + expect(calls.some((call) => call.startsWith("npm i -g"))).toBe(false); + }); + + it("updates global npm installs when detected", async () => { + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "clawdbot"); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "clawdbot", version: "1.0.0" }), + "utf-8", + ); + + const calls: string[] = []; + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + calls.push(key); + if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "not a git repository", code: 128 }; + } + if (key === "npm root -g") { + return { stdout: nodeModules, stderr: "", code: 0 }; + } + if (key === "npm i -g clawdbot@latest") { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "clawdbot", version: "2.0.0" }), + "utf-8", + ); + return { stdout: "ok", stderr: "", code: 0 }; + } + if (key === "pnpm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runGatewayUpdate({ + cwd: pkgRoot, + runCommand: async (argv, _options) => runCommand(argv), + timeoutMs: 5000, + }); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("npm"); + expect(result.before?.version).toBe("1.0.0"); + expect(result.after?.version).toBe("2.0.0"); + expect(calls.some((call) => call === "npm i -g clawdbot@latest")).toBe(true); + }); + + it("updates global bun installs when detected", async () => { + const oldBunInstall = process.env.BUN_INSTALL; + const bunInstall = path.join(tempDir, "bun-install"); + process.env.BUN_INSTALL = bunInstall; + + try { + const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules"); + const pkgRoot = path.join(bunGlobalRoot, "clawdbot"); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "clawdbot", version: "1.0.0" }), + "utf-8", + ); + + const calls: string[] = []; + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + calls.push(key); + if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "not a git repository", code: 128 }; + } + if (key === "npm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + if (key === "pnpm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + if (key === "bun add -g clawdbot@latest") { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "clawdbot", version: "2.0.0" }), + "utf-8", + ); + return { stdout: "ok", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runGatewayUpdate({ + cwd: pkgRoot, + runCommand: async (argv, _options) => runCommand(argv), + timeoutMs: 5000, + }); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("bun"); + expect(result.before?.version).toBe("1.0.0"); + expect(result.after?.version).toBe("2.0.0"); + expect(calls.some((call) => call === "bun add -g clawdbot@latest")).toBe(true); + } finally { + if (oldBunInstall === undefined) delete process.env.BUN_INSTALL; + else process.env.BUN_INSTALL = oldBunInstall; + } }); it("rejects git roots that are not a clawdbot checkout", async () => { diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index de21ea39e..3e407abb4 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import fs from "node:fs/promises"; import path from "node:path"; @@ -158,6 +159,52 @@ async function detectPackageManager(root: string) { return "npm"; } +async function tryRealpath(value: string): Promise { + try { + return await fs.realpath(value); + } catch { + return path.resolve(value); + } +} + +async function detectGlobalInstallManager( + runCommand: CommandRunner, + pkgRoot: string, + timeoutMs: number, +): Promise<"npm" | "pnpm" | "bun" | null> { + const pkgReal = await tryRealpath(pkgRoot); + + const candidates: Array<{ + manager: "npm" | "pnpm"; + argv: string[]; + }> = [ + { manager: "npm", argv: ["npm", "root", "-g"] }, + { manager: "pnpm", argv: ["pnpm", "root", "-g"] }, + ]; + + for (const { manager, argv } of candidates) { + const res = await runCommand(argv, { timeoutMs }).catch(() => null); + if (!res) continue; + if (res.code !== 0) continue; + const globalRoot = res.stdout.trim(); + if (!globalRoot) continue; + + const globalReal = await tryRealpath(globalRoot); + const expected = path.join(globalReal, "clawdbot"); + if (path.resolve(expected) === path.resolve(pkgReal)) return manager; + } + + // Bun doesn't have an officially stable "global root" command across versions, + // so we check the common global install path (best-effort). + const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun"); + const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules"); + const bunGlobalReal = await tryRealpath(bunGlobalRoot); + const bunExpected = path.join(bunGlobalReal, "clawdbot"); + if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun"; + + return null; +} + type RunStepOptions = { runCommand: CommandRunner; name: string; @@ -220,6 +267,12 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") { return ["npm", "install"]; } +function globalUpdateArgs(manager: "pnpm" | "npm" | "bun") { + if (manager === "pnpm") return ["pnpm", "add", "-g", "clawdbot@latest"]; + if (manager === "bun") return ["bun", "add", "-g", "clawdbot@latest"]; + return ["npm", "i", "-g", "clawdbot@latest"]; +} + // Total number of visible steps in a successful git update flow const GIT_UPDATE_TOTAL_STEPS = 9; @@ -414,6 +467,32 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } const beforeVersion = await readPackageVersion(pkgRoot); + const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs); + if (globalManager) { + const updateStep = await runStep({ + runCommand, + name: "global update", + argv: globalUpdateArgs(globalManager), + cwd: pkgRoot, + timeoutMs, + progress, + stepIndex: 0, + totalSteps: 1, + }); + const steps = [updateStep]; + const afterVersion = await readPackageVersion(pkgRoot); + return { + status: updateStep.exitCode === 0 ? "ok" : "error", + mode: globalManager, + root: pkgRoot, + reason: updateStep.exitCode === 0 ? undefined : updateStep.name, + before: { version: beforeVersion }, + after: { version: afterVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + return { status: "skipped", mode: "unknown",