From 5dcd48544afd004dd700601fa1374f2377f7e72a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 06:00:50 +0000 Subject: [PATCH] feat: align update channel installs --- docs/cli/update.md | 15 +- docs/install/development-channels.md | 9 +- src/cli/update-cli.test.ts | 60 ++++++ src/cli/update-cli.ts | 292 +++++++++++++++++++++++++-- src/infra/update-global.ts | 109 ++++++++++ src/infra/update-runner.ts | 60 +----- 6 files changed, 473 insertions(+), 72 deletions(-) create mode 100644 src/infra/update-global.ts diff --git a/docs/cli/update.md b/docs/cli/update.md index d841e73df..12fa90e57 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -7,9 +7,9 @@ read_when: # `clawdbot update` -Safely update a **source checkout** (git install) of Clawdbot. +Safely update Clawdbot and switch between stable/beta/dev channels. -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** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating). ## Usage @@ -48,7 +48,16 @@ Options: - `--json`: print machine-readable status JSON. - `--timeout `: timeout for checks (default is 3s). -## What it does (git checkout) +## What it does + +When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the +install method aligned: + +- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`), + updates it, and installs the global CLI from that checkout. +- `stable`/`beta` → installs from npm using the matching dist-tag. + +## Git checkout flow Channels: diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index 41c110b4e..2ba0fe2ba 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -7,7 +7,7 @@ read_when: # Development channels -Last updated: 2026-01-20 +Last updated: 2026-01-21 Clawdbot ships three update channels: @@ -38,6 +38,13 @@ clawdbot update --channel dev This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`). +When you **explicitly** switch channels with `--channel`, Clawdbot also aligns +the install method: + +- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`), + updates it, and installs the global CLI from that checkout. +- `stable`/`beta` installs from npm using the matching dist-tag. + Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one. ## Plugins and channels diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index b47be0814..4399dadaa 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -31,6 +31,10 @@ vi.mock("../infra/update-check.js", async () => { }; }); +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: vi.fn(), +})); + // Mock doctor (heavy module; should not run in unit tests) vi.mock("../commands/doctor.js", () => ({ doctorCommand: vi.fn(), @@ -76,6 +80,7 @@ describe("update-cli", () => { const { readConfigFileSnapshot } = await import("../config/config.js"); const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } = await import("../infra/update-check.js"); + const { runCommandWithTimeout } = await import("../process/exec.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(fetchNpmTagVersion).mockResolvedValue({ @@ -111,6 +116,13 @@ describe("update-cli", () => { latestVersion: "1.2.3", }, }); + vi.mocked(runCommandWithTimeout).mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + }); setTty(false); setStdoutTty(false); }); @@ -202,9 +214,21 @@ describe("update-cli", () => { const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { checkUpdateStatus } = await import("../infra/update-check.js"); const { updateCommand } = await import("./update-cli.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: tempDir, + installKind: "package", + packageManager: "npm", + deps: { + manager: "npm", + status: "ok", + lockfilePath: null, + markerPath: null, + }, + }); vi.mocked(runGatewayUpdate).mockResolvedValue({ status: "ok", mode: "npm", @@ -258,12 +282,24 @@ describe("update-cli", () => { const { resolveNpmChannelTag } = await import("../infra/update-check.js"); const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { updateCommand } = await import("./update-cli.js"); + const { checkUpdateStatus } = await import("../infra/update-check.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, config: { update: { channel: "beta" } }, }); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: tempDir, + installKind: "package", + packageManager: "npm", + deps: { + manager: "npm", + status: "ok", + lockfilePath: null, + markerPath: null, + }, + }); vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.1.20-1", @@ -459,8 +495,20 @@ describe("update-cli", () => { const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { defaultRuntime } = await import("../runtime.js"); const { updateCommand } = await import("./update-cli.js"); + const { checkUpdateStatus } = await import("../infra/update-check.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: tempDir, + installKind: "package", + packageManager: "npm", + deps: { + manager: "npm", + status: "ok", + lockfilePath: null, + markerPath: null, + }, + }); vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "0.0.1", @@ -500,8 +548,20 @@ describe("update-cli", () => { const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { defaultRuntime } = await import("../runtime.js"); const { updateCommand } = await import("./update-cli.js"); + const { checkUpdateStatus } = await import("../infra/update-check.js"); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: tempDir, + installKind: "package", + packageManager: "npm", + deps: { + manager: "npm", + status: "ok", + lockfilePath: null, + markerPath: null, + }, + }); vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "0.0.1", diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 0eb297f32..418a616aa 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,5 +1,6 @@ import { confirm, isCancel, spinner } from "@clack/prompts"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; @@ -18,6 +19,13 @@ import { type UpdateStepInfo, type UpdateStepProgress, } from "../infra/update-runner.js"; +import { + detectGlobalInstallManagerByPresence, + detectGlobalInstallManagerForRoot, + globalInstallArgs, + resolveGlobalPackageRoot, + type GlobalInstallManager, +} from "../infra/update-global.js"; import { channelToNpmTag, DEFAULT_GIT_CHANNEL, @@ -26,6 +34,7 @@ import { normalizeUpdateChannel, resolveEffectiveUpdateChannel, } from "../infra/update-channels.js"; +import { trimLogTail } from "../infra/restart-sentinel.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { formatCliCommand } from "./command-format.js"; @@ -39,6 +48,7 @@ import { resolveUpdateAvailability, } from "../commands/status.update.js"; import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js"; +import { runCommandWithTimeout } from "../process/exec.js"; export type UpdateCommandOptions = { json?: boolean; @@ -58,12 +68,14 @@ const STEP_LABELS: Record = { "upstream check": "Upstream branch exists", "git fetch": "Fetching latest changes", "git rebase": "Rebasing onto upstream", + "git clone": "Cloning git checkout", "deps install": "Installing dependencies", build: "Building", "ui:build": "Building UI", "clawdbot doctor": "Running doctor checks", "git rev-parse HEAD (after)": "Verifying update", "global update": "Updating via package manager", + "global install": "Installing global package", }; const UPDATE_QUIPS = [ @@ -89,6 +101,10 @@ const UPDATE_QUIPS = [ "Version bump! Same chaos energy, fewer crashes (probably).", ]; +const MAX_LOG_CHARS = 8000; +const CLAWDBOT_REPO_URL = "https://github.com/clawdbot/clawdbot.git"; +const DEFAULT_GIT_DIR = path.join(os.homedir(), "clawdbot"); + function normalizeTag(value?: string | null): string | null { if (!value) return null; const trimmed = value.trim(); @@ -133,6 +149,146 @@ async function isGitCheckout(root: string): Promise { } } +async function isClawdbotPackage(root: string): Promise { + try { + const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { name?: string }; + return parsed?.name === "clawdbot"; + } catch { + return false; + } +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.stat(targetPath); + return true; + } catch { + return false; + } +} + +async function isEmptyDir(targetPath: string): Promise { + try { + const entries = await fs.readdir(targetPath); + return entries.length === 0; + } catch { + return false; + } +} + +function resolveGitInstallDir(): string { + const override = process.env.CLAWDBOT_GIT_DIR?.trim(); + if (override) return path.resolve(override); + return DEFAULT_GIT_DIR; +} + +function resolveNodeRunner(): string { + const base = path.basename(process.execPath).toLowerCase(); + if (base === "node" || base === "node.exe") return process.execPath; + return "node"; +} + +async function runUpdateStep(params: { + name: string; + argv: string[]; + cwd?: string; + timeoutMs: number; + progress?: UpdateStepProgress; +}): Promise { + const command = params.argv.join(" "); + params.progress?.onStepStart?.({ + name: params.name, + command, + index: 0, + total: 0, + }); + const started = Date.now(); + const res = await runCommandWithTimeout(params.argv, { + cwd: params.cwd, + timeoutMs: params.timeoutMs, + }); + const durationMs = Date.now() - started; + const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS); + params.progress?.onStepComplete?.({ + name: params.name, + command, + index: 0, + total: 0, + durationMs, + exitCode: res.code, + stderrTail, + }); + return { + name: params.name, + command, + cwd: params.cwd ?? process.cwd(), + durationMs, + exitCode: res.code, + stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS), + stderrTail, + }; +} + +async function ensureGitCheckout(params: { + dir: string; + timeoutMs: number; + progress?: UpdateStepProgress; +}): Promise { + const dirExists = await pathExists(params.dir); + if (!dirExists) { + return await runUpdateStep({ + name: "git clone", + argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir], + timeoutMs: params.timeoutMs, + progress: params.progress, + }); + } + + if (!(await isGitCheckout(params.dir))) { + const empty = await isEmptyDir(params.dir); + if (!empty) { + throw new Error( + `CLAWDBOT_GIT_DIR points at a non-git directory: ${params.dir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`, + ); + } + return await runUpdateStep({ + name: "git clone", + argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir], + cwd: params.dir, + timeoutMs: params.timeoutMs, + progress: params.progress, + }); + } + + if (!(await isClawdbotPackage(params.dir))) { + throw new Error(`CLAWDBOT_GIT_DIR does not look like a clawdbot checkout: ${params.dir}.`); + } + + return null; +} + +async function resolveGlobalManager(params: { + root: string; + installKind: "git" | "package" | "unknown"; + timeoutMs: number; +}): Promise { + const runCommand = async (argv: string[], options: { timeoutMs: number }) => { + const res = await runCommandWithTimeout(argv, options); + return { stdout: res.stdout, stderr: res.stderr, code: res.code }; + }; + if (params.installKind === "package") { + const detected = await detectGlobalInstallManagerForRoot( + runCommand, + params.root, + params.timeoutMs, + ); + if (detected) return detected; + } + const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs); + return byPresence ?? "npm"; +} + function formatGitStatusLine(params: { branch: string | null; tag: string | null; @@ -394,6 +550,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { cwd: process.cwd(), })) ?? process.cwd(); + const updateStatus = await checkUpdateStatus({ + root, + timeoutMs: timeoutMs ?? 3500, + fetchGit: false, + includeRegistry: false, + }); + const configSnapshot = await readConfigFileSnapshot(); let activeConfig = configSnapshot.valid ? configSnapshot.config : null; const storedChannel = configSnapshot.valid @@ -413,13 +576,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - const gitCheckout = await isGitCheckout(root); - const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL; + const installKind = updateStatus.installKind; + const switchToGit = requestedChannel === "dev" && installKind !== "git"; + const switchToPackage = + requestedChannel !== null && requestedChannel !== "dev" && installKind === "git"; + const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind; + const defaultChannel = + updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL; const channel = requestedChannel ?? storedChannel ?? defaultChannel; const explicitTag = normalizeTag(opts.tag); let tag = explicitTag ?? channelToNpmTag(channel); - if (!gitCheckout) { - const currentVersion = await readPackageVersion(root); + if (updateInstallKind !== "git") { + const currentVersion = switchToPackage ? null : await readPackageVersion(root); const targetVersion = explicitTag ? await resolveTargetVersion(tag, timeoutMs) : await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => { @@ -487,14 +655,114 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const { progress, stop } = createUpdateProgress(showProgress); - const result = await runGatewayUpdate({ - cwd: root, - argv1: process.argv[1], - timeoutMs, - progress, - channel, - tag, - }); + const startedAt = Date.now(); + let result: UpdateRunResult; + + if (switchToPackage) { + const manager = await resolveGlobalManager({ + root, + installKind, + timeoutMs: timeoutMs ?? 20 * 60_000, + }); + const runCommand = async (argv: string[], options: { timeoutMs: number }) => { + const res = await runCommandWithTimeout(argv, options); + return { stdout: res.stdout, stderr: res.stderr, code: res.code }; + }; + const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, timeoutMs ?? 20 * 60_000); + const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null; + const updateStep = await runUpdateStep({ + name: "global update", + argv: globalInstallArgs(manager, `clawdbot@${tag}`), + timeoutMs: timeoutMs ?? 20 * 60_000, + progress, + }); + const steps = [updateStep]; + let afterVersion = beforeVersion; + if (pkgRoot) { + afterVersion = await readPackageVersion(pkgRoot); + const entryPath = path.join(pkgRoot, "dist", "entry.js"); + if (await pathExists(entryPath)) { + const doctorStep = await runUpdateStep({ + name: "clawdbot doctor", + argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"], + timeoutMs: timeoutMs ?? 20 * 60_000, + progress, + }); + steps.push(doctorStep); + } + } + const failedStep = steps.find((step) => step.exitCode !== 0); + result = { + status: failedStep ? "error" : "ok", + mode: manager, + root: pkgRoot ?? root, + reason: failedStep ? failedStep.name : undefined, + before: { version: beforeVersion }, + after: { version: afterVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } else { + const updateRoot = switchToGit ? resolveGitInstallDir() : root; + const cloneStep = switchToGit + ? await ensureGitCheckout({ + dir: updateRoot, + timeoutMs: timeoutMs ?? 20 * 60_000, + progress, + }) + : null; + if (cloneStep && cloneStep.exitCode !== 0) { + result = { + status: "error", + mode: "git", + root: updateRoot, + reason: cloneStep.name, + steps: [cloneStep], + durationMs: Date.now() - startedAt, + }; + stop(); + printResult(result, { ...opts, hideSteps: showProgress }); + defaultRuntime.exit(1); + return; + } + const updateResult = await runGatewayUpdate({ + cwd: updateRoot, + argv1: switchToGit ? undefined : process.argv[1], + timeoutMs, + progress, + channel, + tag, + }); + const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps]; + if (switchToGit && updateResult.status === "ok") { + const manager = await resolveGlobalManager({ + root, + installKind, + timeoutMs: timeoutMs ?? 20 * 60_000, + }); + const installStep = await runUpdateStep({ + name: "global install", + argv: globalInstallArgs(manager, updateRoot), + cwd: updateRoot, + timeoutMs: timeoutMs ?? 20 * 60_000, + progress, + }); + steps.push(installStep); + const failedStep = [installStep].find((step) => step.exitCode !== 0); + result = { + ...updateResult, + status: updateResult.status === "ok" && !failedStep ? "ok" : "error", + steps, + durationMs: Date.now() - startedAt, + }; + } else { + result = { + ...updateResult, + steps, + durationMs: Date.now() - startedAt, + }; + } + } stop(); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts new file mode 100644 index 000000000..9253b633b --- /dev/null +++ b/src/infra/update-global.ts @@ -0,0 +1,109 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type GlobalInstallManager = "npm" | "pnpm" | "bun"; + +export type CommandRunner = ( + argv: string[], + options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv }, +) => Promise<{ stdout: string; stderr: string; code: number | null }>; + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function tryRealpath(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +function resolveBunGlobalRoot(): string { + const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun"); + return path.join(bunInstall, "install", "global", "node_modules"); +} + +export async function resolveGlobalRoot( + manager: GlobalInstallManager, + runCommand: CommandRunner, + timeoutMs: number, +): Promise { + if (manager === "bun") return resolveBunGlobalRoot(); + const argv = manager === "pnpm" ? ["pnpm", "root", "-g"] : ["npm", "root", "-g"]; + const res = await runCommand(argv, { timeoutMs }).catch(() => null); + if (!res || res.code !== 0) return null; + const root = res.stdout.trim(); + return root || null; +} + +export async function resolveGlobalPackageRoot( + manager: GlobalInstallManager, + runCommand: CommandRunner, + timeoutMs: number, +): Promise { + const root = await resolveGlobalRoot(manager, runCommand, timeoutMs); + if (!root) return null; + return path.join(root, "clawdbot"); +} + +export async function detectGlobalInstallManagerForRoot( + runCommand: CommandRunner, + pkgRoot: string, + timeoutMs: number, +): Promise { + 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 || 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; + } + + const bunGlobalRoot = resolveBunGlobalRoot(); + const bunGlobalReal = await tryRealpath(bunGlobalRoot); + const bunExpected = path.join(bunGlobalReal, "clawdbot"); + if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun"; + + return null; +} + +export async function detectGlobalInstallManagerByPresence( + runCommand: CommandRunner, + timeoutMs: number, +): Promise { + for (const manager of ["npm", "pnpm"] as const) { + const root = await resolveGlobalRoot(manager, runCommand, timeoutMs); + if (!root) continue; + if (await pathExists(path.join(root, "clawdbot"))) return manager; + } + + const bunRoot = resolveBunGlobalRoot(); + if (await pathExists(path.join(bunRoot, "clawdbot"))) return "bun"; + return null; +} + +export function globalInstallArgs(manager: GlobalInstallManager, spec: string): string[] { + if (manager === "pnpm") return ["pnpm", "add", "-g", spec]; + if (manager === "bun") return ["bun", "add", "-g", spec]; + return ["npm", "i", "-g", spec]; +} diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 324eccc01..994788ee2 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -1,10 +1,10 @@ -import os from "node:os"; import fs from "node:fs/promises"; import path from "node:path"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { compareSemverStrings } from "./update-check.js"; import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js"; +import { detectGlobalInstallManagerForRoot, globalInstallArgs } from "./update-global.js"; import { trimLogTail } from "./restart-sentinel.js"; export type UpdateStepResult = { @@ -210,52 +210,6 @@ 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; @@ -324,13 +278,6 @@ function normalizeTag(tag?: string) { return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed; } -function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) { - const spec = `clawdbot@${normalizeTag(tag)}`; - if (manager === "pnpm") return ["pnpm", "add", "-g", spec]; - if (manager === "bun") return ["bun", "add", "-g", spec]; - return ["npm", "i", "-g", spec]; -} - export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise { const startedAt = Date.now(); const runCommand = @@ -604,12 +551,13 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } const beforeVersion = await readPackageVersion(pkgRoot); - const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs); + const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs); if (globalManager) { + const spec = `clawdbot@${normalizeTag(opts.tag)}`; const updateStep = await runStep({ runCommand, name: "global update", - argv: globalUpdateArgs(globalManager, opts.tag), + argv: globalInstallArgs(globalManager, spec), cwd: pkgRoot, timeoutMs, progress,