297 lines
8.8 KiB
TypeScript
297 lines
8.8 KiB
TypeScript
import { spinner } from "@clack/prompts";
|
|
import type { Command } from "commander";
|
|
|
|
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
|
import {
|
|
runGatewayUpdate,
|
|
type UpdateRunResult,
|
|
type UpdateStepInfo,
|
|
type UpdateStepProgress,
|
|
} from "../infra/update-runner.js";
|
|
import { defaultRuntime } from "../runtime.js";
|
|
import { formatDocsLink } from "../terminal/links.js";
|
|
import { theme } from "../terminal/theme.js";
|
|
|
|
export type UpdateCommandOptions = {
|
|
json?: boolean;
|
|
restart?: boolean;
|
|
timeout?: string;
|
|
};
|
|
|
|
const STEP_LABELS: Record<string, string> = {
|
|
"clean check": "Working directory is clean",
|
|
"upstream check": "Upstream branch exists",
|
|
"git fetch": "Fetching latest changes",
|
|
"git rebase": "Rebasing onto upstream",
|
|
"deps install": "Installing dependencies",
|
|
build: "Building",
|
|
"ui:build": "Building UI",
|
|
"clawdbot doctor": "Running doctor checks",
|
|
"git rev-parse HEAD (after)": "Verifying update",
|
|
};
|
|
|
|
function getStepLabel(step: UpdateStepInfo): string {
|
|
return STEP_LABELS[step.name] ?? step.name;
|
|
}
|
|
|
|
type ProgressController = {
|
|
progress: UpdateStepProgress;
|
|
stop: () => void;
|
|
};
|
|
|
|
function createUpdateProgress(enabled: boolean): ProgressController {
|
|
if (!enabled) {
|
|
return {
|
|
progress: {},
|
|
stop: () => {},
|
|
};
|
|
}
|
|
|
|
let currentSpinner: ReturnType<typeof spinner> | null = null;
|
|
|
|
const progress: UpdateStepProgress = {
|
|
onStepStart: (step) => {
|
|
currentSpinner = spinner();
|
|
currentSpinner.start(theme.accent(getStepLabel(step)));
|
|
},
|
|
onStepComplete: (step) => {
|
|
if (!currentSpinner) return;
|
|
|
|
const label = getStepLabel(step);
|
|
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
|
|
const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717");
|
|
|
|
currentSpinner.stop(`${icon} ${label} ${duration}`);
|
|
currentSpinner = null;
|
|
|
|
if (step.exitCode !== 0 && step.stderrTail) {
|
|
const lines = step.stderrTail.split("\n").slice(-10);
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
defaultRuntime.log(` ${theme.error(line)}`);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return {
|
|
progress,
|
|
stop: () => {
|
|
if (currentSpinner) {
|
|
currentSpinner.stop();
|
|
currentSpinner = null;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
const seconds = (ms / 1000).toFixed(1);
|
|
return `${seconds}s`;
|
|
}
|
|
|
|
function formatStepStatus(exitCode: number | null): string {
|
|
if (exitCode === 0) return theme.success("\u2713");
|
|
if (exitCode === null) return theme.warn("?");
|
|
return theme.error("\u2717");
|
|
}
|
|
|
|
type PrintResultOptions = UpdateCommandOptions & {
|
|
hideSteps?: boolean;
|
|
};
|
|
|
|
function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
|
|
if (opts.json) {
|
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
|
return;
|
|
}
|
|
|
|
const statusColor =
|
|
result.status === "ok" ? theme.success : result.status === "skipped" ? theme.warn : theme.error;
|
|
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(
|
|
`${theme.heading("Update Result:")} ${statusColor(result.status.toUpperCase())}`,
|
|
);
|
|
if (result.root) {
|
|
defaultRuntime.log(` Root: ${theme.muted(result.root)}`);
|
|
}
|
|
if (result.reason) {
|
|
defaultRuntime.log(` Reason: ${theme.muted(result.reason)}`);
|
|
}
|
|
|
|
if (result.before?.version || result.before?.sha) {
|
|
const before = result.before.version ?? result.before.sha?.slice(0, 8) ?? "";
|
|
defaultRuntime.log(` Before: ${theme.muted(before)}`);
|
|
}
|
|
if (result.after?.version || result.after?.sha) {
|
|
const after = result.after.version ?? result.after.sha?.slice(0, 8) ?? "";
|
|
defaultRuntime.log(` After: ${theme.muted(after)}`);
|
|
}
|
|
|
|
if (!opts.hideSteps && result.steps.length > 0) {
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(theme.heading("Steps:"));
|
|
for (const step of result.steps) {
|
|
const status = formatStepStatus(step.exitCode);
|
|
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
|
|
defaultRuntime.log(` ${status} ${step.name} ${duration}`);
|
|
|
|
if (step.exitCode !== 0 && step.stderrTail) {
|
|
const lines = step.stderrTail.split("\n").slice(0, 5);
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
defaultRuntime.log(` ${theme.error(line)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(`Total time: ${theme.muted(formatDuration(result.durationMs))}`);
|
|
}
|
|
|
|
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
|
|
|
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
|
defaultRuntime.error("--timeout must be a positive integer (seconds)");
|
|
defaultRuntime.exit(1);
|
|
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,
|
|
argv1: process.argv[1],
|
|
cwd: process.cwd(),
|
|
})) ?? process.cwd();
|
|
|
|
const { progress, stop } = createUpdateProgress(showProgress);
|
|
|
|
const result = await runGatewayUpdate({
|
|
cwd: root,
|
|
argv1: process.argv[1],
|
|
timeoutMs,
|
|
progress,
|
|
});
|
|
|
|
stop();
|
|
|
|
printResult(result, { ...opts, hideSteps: showProgress });
|
|
|
|
if (result.status === "error") {
|
|
defaultRuntime.exit(1);
|
|
return;
|
|
}
|
|
|
|
if (result.status === "skipped") {
|
|
if (result.reason === "dirty") {
|
|
defaultRuntime.log(
|
|
theme.warn(
|
|
"Skipped: working directory has uncommitted changes. Commit or stash them first.",
|
|
),
|
|
);
|
|
}
|
|
if (result.reason === "not-git-install") {
|
|
defaultRuntime.log(
|
|
theme.warn(
|
|
"Skipped: this Clawdbot install isn't a git checkout. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.",
|
|
),
|
|
);
|
|
defaultRuntime.log(
|
|
theme.muted(
|
|
"Examples: `npm i -g clawdbot@latest`, `pnpm add -g clawdbot@latest`, or `bun add -g clawdbot@latest`",
|
|
),
|
|
);
|
|
}
|
|
defaultRuntime.exit(0);
|
|
return;
|
|
}
|
|
|
|
// Restart daemon if requested
|
|
if (opts.restart) {
|
|
if (!opts.json) {
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(theme.heading("Restarting daemon..."));
|
|
}
|
|
try {
|
|
const { runDaemonRestart } = await import("./daemon-cli.js");
|
|
const restarted = await runDaemonRestart();
|
|
if (!opts.json && restarted) {
|
|
defaultRuntime.log(theme.success("Daemon restarted successfully."));
|
|
defaultRuntime.log("");
|
|
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
|
|
try {
|
|
const { doctorCommand } = await import("../commands/doctor.js");
|
|
await doctorCommand(defaultRuntime, { nonInteractive: true });
|
|
} catch (err) {
|
|
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
|
} finally {
|
|
delete process.env.CLAWDBOT_UPDATE_IN_PROGRESS;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!opts.json) {
|
|
defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`));
|
|
defaultRuntime.log(
|
|
theme.muted("You may need to restart the daemon manually: clawdbot daemon restart"),
|
|
);
|
|
}
|
|
}
|
|
} else if (!opts.json) {
|
|
defaultRuntime.log("");
|
|
defaultRuntime.log(
|
|
theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function registerUpdateCli(program: Command) {
|
|
program
|
|
.command("update")
|
|
.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("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
|
.addHelpText(
|
|
"after",
|
|
() =>
|
|
`
|
|
Examples:
|
|
clawdbot update # Update a source checkout (git)
|
|
clawdbot update --restart # Update and restart the daemon
|
|
clawdbot update --json # Output result as JSON
|
|
clawdbot --update # Shorthand for clawdbot update
|
|
|
|
Notes:
|
|
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
|
|
- For global installs: use npm/pnpm/bun to reinstall (see docs/install/updating.md)
|
|
- Skips update if the working directory has uncommitted changes
|
|
|
|
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
|
)
|
|
.action(async (opts) => {
|
|
try {
|
|
await updateCommand({
|
|
json: Boolean(opts.json),
|
|
restart: Boolean(opts.restart),
|
|
timeout: opts.timeout as string | undefined,
|
|
});
|
|
} catch (err) {
|
|
defaultRuntime.error(String(err));
|
|
defaultRuntime.exit(1);
|
|
}
|
|
});
|
|
}
|