feat: add update channel support

This commit is contained in:
Peter Steinberger
2026-01-17 11:40:02 +00:00
parent ed5c5629f6
commit a9f21b3d3a
10 changed files with 455 additions and 20 deletions

View File

@@ -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 <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration.
## What it does (git checkout)
High-level:

View File

@@ -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 <dist-tag|version>` 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 cant detect the install, use “Update (global install)” instead.
## Update (Control UI / RPC)

View File

@@ -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<typeof import("../infra/update-check.js")>(
"../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 });
}
});
});

View File

@@ -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<string, string> = {
"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<string | null> {
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<string | null> {
const direct = normalizeVersionTag(tag);
if (direct) return direct;
const res = await fetchNpmTagVersion({ tag, timeoutMs });
return res.version ?? null;
}
async function isGitCheckout(root: string): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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 <stable|beta>", "Persist update channel (npm installs only)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "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) {

View File

@@ -46,6 +46,7 @@ export type ChannelUiMetadata = {
const GROUP_LABELS: Record<string, string> = {
wizard: "Wizard",
update: "Update",
logging: "Logging",
gateway: "Gateway",
agents: "Agents",
@@ -71,6 +72,7 @@ const GROUP_LABELS: Record<string, string> = {
const GROUP_ORDER: Record<string, number> = {
wizard: 20,
update: 25,
gateway: 30,
agents: 40,
tools: 50,
@@ -95,6 +97,7 @@ const GROUP_ORDER: Record<string, number> = {
};
const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
};
const FIELD_HELP: Record<string, string> = {
"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.",

View File

@@ -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). */

View File

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

View File

@@ -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<Respons
export async function fetchNpmLatestVersion(params?: {
timeoutMs?: number;
}): Promise<RegistryStatus> {
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<NpmTagStatus> {
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) };
}
}

View File

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

View File

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