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

@@ -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) {