feat: add update channel support
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user