diff --git a/docs/cli/update.md b/docs/cli/update.md index 2b4b73d65..839ff5690 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac ```bash clawdbot update +clawdbot update --channel beta +clawdbot update --tag beta clawdbot update --restart clawdbot update --json clawdbot --update @@ -23,9 +25,13 @@ clawdbot --update ## Options - `--restart`: restart the Gateway daemon after a successful update. +- `--channel `: set the update channel for npm installs (persisted in config). +- `--tag `: override the npm dist-tag or version for this update only. - `--json`: print machine-readable `UpdateRunResult` JSON. - `--timeout `: per-step timeout (default is 1200s). +Note: downgrades require confirmation because older versions can break configuration. + ## What it does (git checkout) High-level: diff --git a/docs/install/updating.md b/docs/install/updating.md index 4cda132ef..476fea5b2 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -50,6 +50,20 @@ pnpm add -g clawdbot@latest ``` We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs). +To stay on the beta channel for CLI updates: + +```bash +clawdbot update --channel beta +``` + +Switch back to stable later: + +```bash +clawdbot update --channel stable +``` + +Use `--tag ` for a one-off install tag/version. + Then: ```bash @@ -75,7 +89,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 (global install)” instead. +If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead. ## Update (Control UI / RPC) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9b8e6b680..dfc233a98 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateRunResult } from "../infra/update-runner.js"; @@ -7,6 +10,25 @@ vi.mock("../infra/update-runner.js", () => ({ runGatewayUpdate: vi.fn(), })); +vi.mock("../infra/clawdbot-root.js", () => ({ + resolveClawdbotPackageRoot: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: vi.fn(), + writeConfigFile: vi.fn(), +})); + +vi.mock("../infra/update-check.js", async () => { + const actual = await vi.importActual( + "../infra/update-check.js", + ); + return { + ...actual, + fetchNpmTagVersion: vi.fn(), + }; +}); + // Mock doctor (heavy module; should not run in unit tests) vi.mock("../commands/doctor.js", () => ({ doctorCommand: vi.fn(), @@ -26,6 +48,41 @@ vi.mock("../runtime.js", () => ({ })); describe("update-cli", () => { + const baseSnapshot = { + valid: true, + config: {}, + issues: [], + } as const; + + const setTty = (value: boolean | undefined) => { + Object.defineProperty(process.stdin, "isTTY", { + value, + configurable: true, + }); + }; + + const setStdoutTty = (value: boolean | undefined) => { + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); + }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); + const { readConfigFileSnapshot } = await import("../config/config.js"); + const { fetchNpmTagVersion } = await import("../infra/update-check.js"); + vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd()); + vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); + vi.mocked(fetchNpmTagVersion).mockResolvedValue({ + tag: "latest", + version: "9999.0.0", + }); + setTty(false); + setStdoutTty(false); + }); + it("exports updateCommand and registerUpdateCli", async () => { const { updateCommand, registerUpdateCli } = await import("./update-cli.js"); expect(typeof updateCommand).toBe("function"); @@ -63,6 +120,62 @@ describe("update-cli", () => { expect(defaultRuntime.log).toHaveBeenCalled(); }); + it("defaults to stable channel when unset", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }); + + await updateCommand({}); + + const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; + expect(call?.tag).toBe("latest"); + }); + + it("uses stored beta channel when configured", async () => { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + config: { update: { channel: "beta" } }, + }); + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }); + + await updateCommand({}); + + const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; + expect(call?.tag).toBe("beta"); + }); + + it("honors --tag override", async () => { + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }); + + await updateCommand({ tag: "next" }); + + const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; + expect(call?.tag).toBe("next"); + }); + it("updateCommand outputs JSON when --json is set", async () => { const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { defaultRuntime } = await import("../runtime.js"); @@ -168,4 +281,67 @@ describe("update-cli", () => { expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout")); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); + + it("persists update channel when --channel is set", async () => { + const { writeConfigFile } = await import("../config/config.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateCommand } = await import("./update-cli.js"); + + const mockResult: UpdateRunResult = { + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + }; + + vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + + await updateCommand({ channel: "beta" }); + + expect(writeConfigFile).toHaveBeenCalled(); + const call = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as { + update?: { channel?: string }; + }; + expect(call?.update?.channel).toBe("beta"); + }); + + it("requires confirmation on downgrade when non-interactive", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); + try { + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "clawdbot", version: "2.0.0" }), + "utf-8", + ); + + const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); + const { fetchNpmTagVersion } = await import("../infra/update-check.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { defaultRuntime } = await import("../runtime.js"); + const { updateCommand } = await import("./update-cli.js"); + + vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir); + vi.mocked(fetchNpmTagVersion).mockResolvedValue({ + tag: "latest", + version: "0.0.1", + }); + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + steps: [], + durationMs: 100, + }); + vi.mocked(defaultRuntime.error).mockClear(); + vi.mocked(defaultRuntime.exit).mockClear(); + + await updateCommand({}); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Downgrade confirmation required."), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 0fff3099d..bde4bb052 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,7 +1,12 @@ -import { spinner } from "@clack/prompts"; +import { confirm, isCancel, spinner } from "@clack/prompts"; +import fs from "node:fs/promises"; +import path from "node:path"; import type { Command } from "commander"; +import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; +import { compareSemverStrings, fetchNpmTagVersion } from "../infra/update-check.js"; +import { parseSemver } from "../infra/runtime-guard.js"; import { runGatewayUpdate, type UpdateRunResult, @@ -10,11 +15,14 @@ import { } from "../infra/update-runner.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; +import { stylePromptMessage } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; export type UpdateCommandOptions = { json?: boolean; restart?: boolean; + channel?: string; + tag?: string; timeout?: string; }; @@ -31,6 +39,61 @@ const STEP_LABELS: Record = { "global update": "Updating via package manager", }; +type UpdateChannel = "stable" | "beta"; + +const DEFAULT_UPDATE_CHANNEL: UpdateChannel = "stable"; + +function normalizeChannel(value?: string | null): UpdateChannel | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "stable" || normalized === "beta") return normalized; + return null; +} + +function normalizeTag(value?: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed; +} + +function channelToTag(channel: UpdateChannel): string { + return channel === "beta" ? "beta" : "latest"; +} + +function normalizeVersionTag(tag: string): string | null { + const trimmed = tag.trim(); + if (!trimmed) return null; + const cleaned = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed; + return parseSemver(cleaned) ? cleaned : null; +} + +async function readPackageVersion(root: string): Promise { + try { + const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { version?: string }; + return typeof parsed.version === "string" ? parsed.version : null; + } catch { + return null; + } +} + +async function resolveTargetVersion(tag: string, timeoutMs?: number): Promise { + const direct = normalizeVersionTag(tag); + if (direct) return direct; + const res = await fetchNpmTagVersion({ tag, timeoutMs }); + return res.version ?? null; +} + +async function isGitCheckout(root: string): Promise { + try { + await fs.stat(path.join(root, ".git")); + return true; + } catch { + return false; + } +} + function getStepLabel(step: UpdateStepInfo): string { return STEP_LABELS[step.name] ?? step.name; } @@ -164,13 +227,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - const showProgress = !opts.json && process.stdout.isTTY; - - if (!opts.json) { - defaultRuntime.log(theme.heading("Updating Clawdbot...")); - defaultRuntime.log(""); - } - const root = (await resolveClawdbotPackageRoot({ moduleUrl: import.meta.url, @@ -178,6 +234,91 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { cwd: process.cwd(), })) ?? process.cwd(); + const configSnapshot = await readConfigFileSnapshot(); + const storedChannel = configSnapshot.valid + ? normalizeChannel(configSnapshot.config.update?.channel) + : null; + + const requestedChannel = normalizeChannel(opts.channel); + if (opts.channel && !requestedChannel) { + defaultRuntime.error(`--channel must be "stable" or "beta" (got "${opts.channel}")`); + defaultRuntime.exit(1); + return; + } + if (opts.channel && !configSnapshot.valid) { + const issues = configSnapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`); + defaultRuntime.error( + ["Config is invalid; cannot set update channel.", ...issues].join("\n"), + ); + defaultRuntime.exit(1); + return; + } + + const channel = requestedChannel ?? storedChannel ?? DEFAULT_UPDATE_CHANNEL; + const tag = normalizeTag(opts.tag) ?? channelToTag(channel); + + const gitCheckout = await isGitCheckout(root); + if (!gitCheckout) { + const currentVersion = await readPackageVersion(root); + const targetVersion = await resolveTargetVersion(tag, timeoutMs); + const cmp = + currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null; + const needsConfirm = + currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0)); + + if (needsConfirm) { + if (!process.stdin.isTTY || opts.json) { + defaultRuntime.error( + [ + "Downgrade confirmation required.", + "Downgrading can break configuration. Re-run in a TTY to confirm.", + ].join("\n"), + ); + defaultRuntime.exit(1); + return; + } + + const targetLabel = targetVersion ?? `${tag} (unknown)`; + const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`; + const ok = await confirm({ + message: stylePromptMessage(message), + initialValue: false, + }); + if (isCancel(ok) || ok === false) { + if (!opts.json) { + defaultRuntime.log(theme.muted("Update cancelled.")); + } + defaultRuntime.exit(0); + return; + } + } + } else if ((opts.channel || opts.tag) && !opts.json) { + defaultRuntime.log( + theme.muted("Note: --channel/--tag apply to npm installs only; git updates ignore them."), + ); + } + + if (requestedChannel && configSnapshot.valid) { + const next = { + ...configSnapshot.config, + update: { + ...configSnapshot.config.update, + channel: requestedChannel, + }, + }; + await writeConfigFile(next); + if (!opts.json) { + defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); + } + } + + const showProgress = !opts.json && process.stdout.isTTY; + + if (!opts.json) { + defaultRuntime.log(theme.heading("Updating Clawdbot...")); + defaultRuntime.log(""); + } + const { progress, stop } = createUpdateProgress(showProgress); const result = await runGatewayUpdate({ @@ -185,6 +326,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { argv1: process.argv[1], timeoutMs, progress, + tag, }); stop(); @@ -270,6 +412,8 @@ export function registerUpdateCli(program: Command) { .description("Update Clawdbot to the latest version") .option("--json", "Output result as JSON", false) .option("--restart", "Restart the gateway daemon after a successful update", false) + .option("--channel ", "Persist update channel (npm installs only)") + .option("--tag ", "Override npm dist-tag or version for this update") .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") .addHelpText( "after", @@ -277,6 +421,8 @@ export function registerUpdateCli(program: Command) { ` Examples: clawdbot update # Update a source checkout (git) + clawdbot update --channel beta # Switch to the beta channel (npm installs) + clawdbot update --tag beta # One-off update to a dist-tag or version clawdbot update --restart # Update and restart the daemon clawdbot update --json # Output result as JSON clawdbot --update # Shorthand for clawdbot update @@ -284,6 +430,7 @@ Examples: Notes: - For git installs: fetches, rebases, installs deps, builds, and runs doctor - For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md) + - Downgrades require confirmation (can break configuration) - Skips update if the working directory has uncommitted changes ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`, @@ -293,6 +440,8 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda await updateCommand({ json: Boolean(opts.json), restart: Boolean(opts.restart), + channel: opts.channel as string | undefined, + tag: opts.tag as string | undefined, timeout: opts.timeout as string | undefined, }); } catch (err) { diff --git a/src/config/schema.ts b/src/config/schema.ts index 88ee6c29f..7055fc996 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -46,6 +46,7 @@ export type ChannelUiMetadata = { const GROUP_LABELS: Record = { wizard: "Wizard", + update: "Update", logging: "Logging", gateway: "Gateway", agents: "Agents", @@ -71,6 +72,7 @@ const GROUP_LABELS: Record = { const GROUP_ORDER: Record = { wizard: 20, + update: 25, gateway: 30, agents: 40, tools: 50, @@ -95,6 +97,7 @@ const GROUP_ORDER: Record = { }; const FIELD_LABELS: Record = { + "update.channel": "Update Channel", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -273,6 +276,7 @@ const FIELD_LABELS: Record = { }; const FIELD_HELP: Record = { + "update.channel": 'Update channel for npm installs ("stable" or "beta").', "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index 8312e63a4..84555504f 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -49,6 +49,10 @@ export type ClawdbotConfig = { lastRunMode?: "local" | "remote"; }; logging?: LoggingConfig; + update?: { + /** Update channel for npm installs ("stable" or "beta"). */ + channel?: "stable" | "beta"; + }; browser?: BrowserConfig; ui?: { /** Accent color for Clawdbot UI chrome (hex). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8b13da1ed..081ba32ea 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -61,6 +61,11 @@ export const ClawdbotSchema = z redactPatterns: z.array(z.string()).optional(), }) .optional(), + update: z + .object({ + channel: z.union([z.literal("stable"), z.literal("beta")]).optional(), + }) + .optional(), browser: z .object({ enabled: z.boolean().optional(), diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 5d57fdafe..ba5519545 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -30,6 +30,12 @@ export type RegistryStatus = { error?: string; }; +export type NpmTagStatus = { + tag: string; + version: string | null; + error?: string; +}; + export type UpdateCheckResult = { root: string | null; installKind: "git" | "package" | "unknown"; @@ -263,17 +269,32 @@ async function fetchWithTimeout(url: string, timeoutMs: number): Promise { + const res = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params?.timeoutMs }); + return { + latestVersion: res.version, + error: res.error, + }; +} + +export async function fetchNpmTagVersion(params: { + tag: string; + timeoutMs?: number; +}): Promise { const timeoutMs = params?.timeoutMs ?? 3500; + const tag = params.tag; try { - const res = await fetchWithTimeout("https://registry.npmjs.org/clawdbot/latest", timeoutMs); + const res = await fetchWithTimeout( + `https://registry.npmjs.org/clawdbot/${encodeURIComponent(tag)}`, + timeoutMs, + ); if (!res.ok) { - return { latestVersion: null, error: `HTTP ${res.status}` }; + return { tag, version: null, error: `HTTP ${res.status}` }; } const json = (await res.json()) as { version?: unknown }; - const latestVersion = typeof json?.version === "string" ? json.version : null; - return { latestVersion }; + const version = typeof json?.version === "string" ? json.version : null; + return { tag, version }; } catch (err) { - return { latestVersion: null, error: String(err) }; + return { tag, version: null, error: String(err) }; } } diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 0724cab4d..4df7adc0c 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -159,6 +159,54 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call === "npm i -g clawdbot@latest")).toBe(true); }); + it("updates global npm installs with tag override", 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@beta") { + 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, + tag: "beta", + }); + + 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@beta")).toBe(true); + }); + it("updates global bun installs when detected", async () => { const oldBunInstall = process.env.BUN_INSTALL; const bunInstall = path.join(tempDir, "bun-install"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 3e407abb4..6ede48bc4 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -52,6 +52,7 @@ export type UpdateStepProgress = { type UpdateRunnerOptions = { cwd?: string; argv1?: string; + tag?: string; timeoutMs?: number; runCommand?: CommandRunner; progress?: UpdateStepProgress; @@ -267,10 +268,17 @@ 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"]; +function normalizeTag(tag?: string) { + const trimmed = tag?.trim(); + if (!trimmed) return "latest"; + 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]; } // Total number of visible steps in a successful git update flow @@ -472,7 +480,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const updateStep = await runStep({ runCommand, name: "global update", - argv: globalUpdateArgs(globalManager), + argv: globalUpdateArgs(globalManager, opts.tag), cwd: pkgRoot, timeoutMs, progress,