mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor(cli): split update command modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
156
src/cli/update-cli/progress.ts
Normal file
156
src/cli/update-cli/progress.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { spinner } from "@clack/prompts";
|
||||
import type {
|
||||
UpdateRunResult,
|
||||
UpdateStepInfo,
|
||||
UpdateStepProgress,
|
||||
} from "../../infra/update-runner.js";
|
||||
import type { UpdateCommandOptions } from "./shared.js";
|
||||
import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
|
||||
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 target commit",
|
||||
"git rev-parse @{upstream}": "Resolving upstream commit",
|
||||
"git rev-list": "Enumerating candidate commits",
|
||||
"git clone": "Cloning git checkout",
|
||||
"preflight worktree": "Preparing preflight worktree",
|
||||
"preflight cleanup": "Cleaning preflight worktree",
|
||||
"deps install": "Installing dependencies",
|
||||
build: "Building",
|
||||
"ui:build": "Building UI assets",
|
||||
"ui:build (post-doctor repair)": "Restoring missing UI assets",
|
||||
"ui assets verify": "Validating UI assets",
|
||||
"openclaw doctor entry": "Checking doctor entrypoint",
|
||||
"openclaw doctor": "Running doctor checks",
|
||||
"git rev-parse HEAD (after)": "Verifying update",
|
||||
"global update": "Updating via package manager",
|
||||
"global install": "Installing global package",
|
||||
};
|
||||
|
||||
function getStepLabel(step: UpdateStepInfo): string {
|
||||
return STEP_LABELS[step.name] ?? step.name;
|
||||
}
|
||||
|
||||
export type ProgressController = {
|
||||
progress: UpdateStepProgress;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export 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(`(${formatDurationPrecise(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 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;
|
||||
};
|
||||
|
||||
export function printResult(result: UpdateRunResult, opts: PrintResultOptions): void {
|
||||
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(`(${formatDurationPrecise(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(formatDurationPrecise(result.durationMs))}`);
|
||||
}
|
||||
289
src/cli/update-cli/shared.ts
Normal file
289
src/cli/update-cli/shared.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
|
||||
import { trimLogTail } from "../../infra/restart-sentinel.js";
|
||||
import { parseSemver } from "../../infra/runtime-guard.js";
|
||||
import { fetchNpmTagVersion } from "../../infra/update-check.js";
|
||||
import {
|
||||
detectGlobalInstallManagerByPresence,
|
||||
detectGlobalInstallManagerForRoot,
|
||||
type GlobalInstallManager,
|
||||
} from "../../infra/update-global.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { pathExists } from "../../utils.js";
|
||||
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
restart?: boolean;
|
||||
channel?: string;
|
||||
tag?: string;
|
||||
timeout?: string;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateStatusOptions = {
|
||||
json?: boolean;
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
export type UpdateWizardOptions = {
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
const OPENCLAW_REPO_URL = "https://github.com/openclaw/openclaw.git";
|
||||
const MAX_LOG_CHARS = 8000;
|
||||
|
||||
export const DEFAULT_PACKAGE_NAME = "openclaw";
|
||||
const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
|
||||
|
||||
export function normalizeTag(value?: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith("openclaw@")) {
|
||||
return trimmed.slice("openclaw@".length);
|
||||
}
|
||||
if (trimmed.startsWith(`${DEFAULT_PACKAGE_NAME}@`)) {
|
||||
return trimmed.slice(`${DEFAULT_PACKAGE_NAME}@`.length);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export async function isGitCheckout(root: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path.join(root, ".git"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPackageName(root: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
const name = parsed?.name?.trim();
|
||||
return name ? name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCorePackage(root: string): Promise<boolean> {
|
||||
const name = await readPackageName(root);
|
||||
return Boolean(name && CORE_PACKAGE_NAMES.has(name));
|
||||
}
|
||||
|
||||
export async function isEmptyDir(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
const entries = await fs.readdir(targetPath);
|
||||
return entries.length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitInstallDir(): string {
|
||||
const override = process.env.OPENCLAW_GIT_DIR?.trim();
|
||||
if (override) {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return resolveDefaultGitDir();
|
||||
}
|
||||
|
||||
function resolveDefaultGitDir(): string {
|
||||
return resolveStateDir(process.env, os.homedir);
|
||||
}
|
||||
|
||||
export function resolveNodeRunner(): string {
|
||||
const base = path.basename(process.execPath).toLowerCase();
|
||||
if (base === "node" || base === "node.exe") {
|
||||
return process.execPath;
|
||||
}
|
||||
return "node";
|
||||
}
|
||||
|
||||
export async function resolveUpdateRoot(): Promise<string> {
|
||||
return (
|
||||
(await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
})) ?? process.cwd()
|
||||
);
|
||||
}
|
||||
|
||||
export async function runUpdateStep(params: {
|
||||
name: string;
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
timeoutMs: number;
|
||||
progress?: UpdateStepProgress;
|
||||
}): Promise<UpdateStepResult> {
|
||||
const command = params.argv.join(" ");
|
||||
params.progress?.onStepStart?.({
|
||||
name: params.name,
|
||||
command,
|
||||
index: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const started = Date.now();
|
||||
const res = await runCommandWithTimeout(params.argv, {
|
||||
cwd: params.cwd,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const durationMs = Date.now() - started;
|
||||
const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS);
|
||||
|
||||
params.progress?.onStepComplete?.({
|
||||
name: params.name,
|
||||
command,
|
||||
index: 0,
|
||||
total: 0,
|
||||
durationMs,
|
||||
exitCode: res.code,
|
||||
stderrTail,
|
||||
});
|
||||
|
||||
return {
|
||||
name: params.name,
|
||||
command,
|
||||
cwd: params.cwd ?? process.cwd(),
|
||||
durationMs,
|
||||
exitCode: res.code,
|
||||
stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS),
|
||||
stderrTail,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureGitCheckout(params: {
|
||||
dir: string;
|
||||
timeoutMs: number;
|
||||
progress?: UpdateStepProgress;
|
||||
}): Promise<UpdateStepResult | null> {
|
||||
const dirExists = await pathExists(params.dir);
|
||||
if (!dirExists) {
|
||||
return await runUpdateStep({
|
||||
name: "git clone",
|
||||
argv: ["git", "clone", OPENCLAW_REPO_URL, params.dir],
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isGitCheckout(params.dir))) {
|
||||
const empty = await isEmptyDir(params.dir);
|
||||
if (!empty) {
|
||||
throw new Error(
|
||||
`OPENCLAW_GIT_DIR points at a non-git directory: ${params.dir}. Set OPENCLAW_GIT_DIR to an empty folder or an openclaw checkout.`,
|
||||
);
|
||||
}
|
||||
|
||||
return await runUpdateStep({
|
||||
name: "git clone",
|
||||
argv: ["git", "clone", OPENCLAW_REPO_URL, params.dir],
|
||||
cwd: params.dir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isCorePackage(params.dir))) {
|
||||
throw new Error(`OPENCLAW_GIT_DIR does not look like a core checkout: ${params.dir}.`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveGlobalManager(params: {
|
||||
root: string;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
timeoutMs: number;
|
||||
}): Promise<GlobalInstallManager> {
|
||||
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
};
|
||||
|
||||
if (params.installKind === "package") {
|
||||
const detected = await detectGlobalInstallManagerForRoot(
|
||||
runCommand,
|
||||
params.root,
|
||||
params.timeoutMs,
|
||||
);
|
||||
if (detected) {
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
|
||||
const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs);
|
||||
return byPresence ?? "npm";
|
||||
}
|
||||
|
||||
export async function tryWriteCompletionCache(root: string, jsonMode: boolean): Promise<void> {
|
||||
const binPath = path.join(root, "openclaw.mjs");
|
||||
if (!(await pathExists(binPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync(resolveNodeRunner(), [binPath, "completion", "--write-state"], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (!jsonMode) {
|
||||
defaultRuntime.log(theme.warn(`Completion cache update failed: ${String(result.error)}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status !== 0 && !jsonMode) {
|
||||
const stderr = (result.stderr ?? "").toString().trim();
|
||||
const detail = stderr ? ` (${stderr})` : "";
|
||||
defaultRuntime.log(theme.warn(`Completion cache update failed${detail}.`));
|
||||
}
|
||||
}
|
||||
135
src/cli/update-cli/status.ts
Normal file
135
src/cli/update-cli/status.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
formatUpdateAvailableHint,
|
||||
formatUpdateOneLiner,
|
||||
resolveUpdateAvailability,
|
||||
} from "../../commands/status.update.js";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import {
|
||||
formatUpdateChannelLabel,
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
} from "../../infra/update-channels.js";
|
||||
import { checkUpdateStatus } from "../../infra/update-check.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { resolveUpdateRoot, type UpdateStatusOptions } from "./shared.js";
|
||||
|
||||
function formatGitStatusLine(params: {
|
||||
branch: string | null;
|
||||
tag: string | null;
|
||||
sha: string | null;
|
||||
}): string {
|
||||
const shortSha = params.sha ? params.sha.slice(0, 8) : null;
|
||||
const branch = params.branch && params.branch !== "HEAD" ? params.branch : null;
|
||||
const tag = params.tag;
|
||||
const parts = [
|
||||
branch ?? (tag ? "detached" : "git"),
|
||||
tag ? `tag ${tag}` : null,
|
||||
shortSha ? `@ ${shortSha}` : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export async function updateStatusCommand(opts: UpdateStatusOptions): 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 root = await resolveUpdateRoot();
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
const configChannel = configSnapshot.valid
|
||||
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
||||
: null;
|
||||
|
||||
const update = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: true,
|
||||
includeRegistry: true,
|
||||
});
|
||||
|
||||
const channelInfo = resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
installKind: update.installKind,
|
||||
git: update.git ? { tag: update.git.tag, branch: update.git.branch } : undefined,
|
||||
});
|
||||
const channelLabel = formatUpdateChannelLabel({
|
||||
channel: channelInfo.channel,
|
||||
source: channelInfo.source,
|
||||
gitTag: update.git?.tag ?? null,
|
||||
gitBranch: update.git?.branch ?? null,
|
||||
});
|
||||
|
||||
const gitLabel =
|
||||
update.installKind === "git"
|
||||
? formatGitStatusLine({
|
||||
branch: update.git?.branch ?? null,
|
||||
tag: update.git?.tag ?? null,
|
||||
sha: update.git?.sha ?? null,
|
||||
})
|
||||
: null;
|
||||
|
||||
const updateAvailability = resolveUpdateAvailability(update);
|
||||
const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, "");
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
update,
|
||||
channel: {
|
||||
value: channelInfo.channel,
|
||||
source: channelInfo.source,
|
||||
label: channelLabel,
|
||||
config: configChannel,
|
||||
},
|
||||
availability: updateAvailability,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const installLabel =
|
||||
update.installKind === "git"
|
||||
? `git (${update.root ?? "unknown"})`
|
||||
: update.installKind === "package"
|
||||
? update.packageManager
|
||||
: "unknown";
|
||||
|
||||
const rows = [
|
||||
{ Item: "Install", Value: installLabel },
|
||||
{ Item: "Channel", Value: channelLabel },
|
||||
...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []),
|
||||
{
|
||||
Item: "Update",
|
||||
Value: updateAvailability.available ? theme.warn(`available · ${updateLine}`) : updateLine,
|
||||
},
|
||||
];
|
||||
|
||||
defaultRuntime.log(theme.heading("OpenClaw update status"));
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
defaultRuntime.log("");
|
||||
|
||||
const updateHint = formatUpdateAvailableHint(update);
|
||||
if (updateHint) {
|
||||
defaultRuntime.log(theme.warn(updateHint));
|
||||
}
|
||||
}
|
||||
646
src/cli/update-cli/update-command.ts
Normal file
646
src/cli/update-cli/update-command.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
import { confirm, isCancel } from "@clack/prompts";
|
||||
import path from "node:path";
|
||||
import {
|
||||
checkShellCompletionStatus,
|
||||
ensureCompletionCacheExists,
|
||||
} from "../../commands/doctor-completion.js";
|
||||
import { doctorCommand } from "../../commands/doctor.js";
|
||||
import { readConfigFileSnapshot, writeConfigFile } from "../../config/config.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEFAULT_GIT_CHANNEL,
|
||||
DEFAULT_PACKAGE_CHANNEL,
|
||||
normalizeUpdateChannel,
|
||||
} from "../../infra/update-channels.js";
|
||||
import {
|
||||
compareSemverStrings,
|
||||
resolveNpmChannelTag,
|
||||
checkUpdateStatus,
|
||||
} from "../../infra/update-check.js";
|
||||
import {
|
||||
cleanupGlobalRenameDirs,
|
||||
globalInstallArgs,
|
||||
resolveGlobalPackageRoot,
|
||||
} from "../../infra/update-global.js";
|
||||
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../../plugins/update.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { stylePromptMessage } from "../../terminal/prompt-style.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { pathExists } from "../../utils.js";
|
||||
import { replaceCliName, resolveCliName } from "../cli-name.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { installCompletion } from "../completion-cli.js";
|
||||
import { runDaemonRestart } from "../daemon-cli.js";
|
||||
import { createUpdateProgress, printResult } from "./progress.js";
|
||||
import {
|
||||
DEFAULT_PACKAGE_NAME,
|
||||
ensureGitCheckout,
|
||||
normalizeTag,
|
||||
readPackageName,
|
||||
readPackageVersion,
|
||||
resolveGitInstallDir,
|
||||
resolveGlobalManager,
|
||||
resolveNodeRunner,
|
||||
resolveTargetVersion,
|
||||
resolveUpdateRoot,
|
||||
runUpdateStep,
|
||||
tryWriteCompletionCache,
|
||||
type UpdateCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { suppressDeprecations } from "./suppress-deprecations.js";
|
||||
|
||||
const CLI_NAME = resolveCliName();
|
||||
|
||||
const UPDATE_QUIPS = [
|
||||
"Leveled up! New skills unlocked. You're welcome.",
|
||||
"Fresh code, same lobster. Miss me?",
|
||||
"Back and better. Did you even notice I was gone?",
|
||||
"Update complete. I learned some new tricks while I was out.",
|
||||
"Upgraded! Now with 23% more sass.",
|
||||
"I've evolved. Try to keep up.",
|
||||
"New version, who dis? Oh right, still me but shinier.",
|
||||
"Patched, polished, and ready to pinch. Let's go.",
|
||||
"The lobster has molted. Harder shell, sharper claws.",
|
||||
"Update done! Check the changelog or just trust me, it's good.",
|
||||
"Reborn from the boiling waters of npm. Stronger now.",
|
||||
"I went away and came back smarter. You should try it sometime.",
|
||||
"Update complete. The bugs feared me, so they left.",
|
||||
"New version installed. Old version sends its regards.",
|
||||
"Firmware fresh. Brain wrinkles: increased.",
|
||||
"I've seen things you wouldn't believe. Anyway, I'm updated.",
|
||||
"Back online. The changelog is long but our friendship is longer.",
|
||||
"Upgraded! Peter fixed stuff. Blame him if it breaks.",
|
||||
"Molting complete. Please don't look at my soft shell phase.",
|
||||
"Version bump! Same chaos energy, fewer crashes (probably).",
|
||||
];
|
||||
|
||||
function pickUpdateQuip(): string {
|
||||
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
|
||||
}
|
||||
|
||||
async function tryInstallShellCompletion(opts: {
|
||||
jsonMode: boolean;
|
||||
skipPrompt: boolean;
|
||||
}): Promise<void> {
|
||||
if (opts.jsonMode || !process.stdin.isTTY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await checkShellCompletionStatus(CLI_NAME);
|
||||
|
||||
if (status.usesSlowPattern) {
|
||||
defaultRuntime.log(theme.muted("Upgrading shell completion to cached version..."));
|
||||
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
|
||||
if (cacheGenerated) {
|
||||
await installCompletion(status.shell, true, CLI_NAME);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.profileInstalled && !status.cacheExists) {
|
||||
defaultRuntime.log(theme.muted("Regenerating shell completion cache..."));
|
||||
await ensureCompletionCacheExists(CLI_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.profileInstalled) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Shell completion"));
|
||||
|
||||
const shouldInstall = await confirm({
|
||||
message: stylePromptMessage(`Enable ${status.shell} shell completion for ${CLI_NAME}?`),
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (isCancel(shouldInstall) || !shouldInstall) {
|
||||
if (!opts.skipPrompt) {
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
`Skipped. Run \`${replaceCliName(formatCliCommand("openclaw completion --install"), CLI_NAME)}\` later to enable.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
|
||||
if (!cacheGenerated) {
|
||||
defaultRuntime.log(theme.warn("Failed to generate completion cache."));
|
||||
return;
|
||||
}
|
||||
|
||||
await installCompletion(status.shell, opts.skipPrompt, CLI_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPackageInstallUpdate(params: {
|
||||
root: string;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
tag: string;
|
||||
timeoutMs: number;
|
||||
startedAt: number;
|
||||
progress: ReturnType<typeof createUpdateProgress>["progress"];
|
||||
}): Promise<UpdateRunResult> {
|
||||
const manager = await resolveGlobalManager({
|
||||
root: params.root,
|
||||
installKind: params.installKind,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
};
|
||||
|
||||
const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, params.timeoutMs);
|
||||
const packageName =
|
||||
(pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(params.root)) ??
|
||||
DEFAULT_PACKAGE_NAME;
|
||||
|
||||
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
|
||||
if (pkgRoot) {
|
||||
await cleanupGlobalRenameDirs({
|
||||
globalRoot: path.dirname(pkgRoot),
|
||||
packageName,
|
||||
});
|
||||
}
|
||||
|
||||
const updateStep = await runUpdateStep({
|
||||
name: "global update",
|
||||
argv: globalInstallArgs(manager, `${packageName}@${params.tag}`),
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
|
||||
const steps = [updateStep];
|
||||
let afterVersion = beforeVersion;
|
||||
|
||||
if (pkgRoot) {
|
||||
afterVersion = await readPackageVersion(pkgRoot);
|
||||
const entryPath = path.join(pkgRoot, "dist", "entry.js");
|
||||
if (await pathExists(entryPath)) {
|
||||
const doctorStep = await runUpdateStep({
|
||||
name: `${CLI_NAME} doctor`,
|
||||
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
steps.push(doctorStep);
|
||||
}
|
||||
}
|
||||
|
||||
const failedStep = steps.find((step) => step.exitCode !== 0);
|
||||
return {
|
||||
status: failedStep ? "error" : "ok",
|
||||
mode: manager,
|
||||
root: pkgRoot ?? params.root,
|
||||
reason: failedStep ? failedStep.name : undefined,
|
||||
before: { version: beforeVersion },
|
||||
after: { version: afterVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - params.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function runGitUpdate(params: {
|
||||
root: string;
|
||||
switchToGit: boolean;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
timeoutMs: number | undefined;
|
||||
startedAt: number;
|
||||
progress: ReturnType<typeof createUpdateProgress>["progress"];
|
||||
channel: "stable" | "beta" | "dev";
|
||||
tag: string;
|
||||
showProgress: boolean;
|
||||
opts: UpdateCommandOptions;
|
||||
stop: () => void;
|
||||
}): Promise<UpdateRunResult> {
|
||||
const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root;
|
||||
const effectiveTimeout = params.timeoutMs ?? 20 * 60_000;
|
||||
|
||||
const cloneStep = params.switchToGit
|
||||
? await ensureGitCheckout({
|
||||
dir: updateRoot,
|
||||
timeoutMs: effectiveTimeout,
|
||||
progress: params.progress,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (cloneStep && cloneStep.exitCode !== 0) {
|
||||
const result: UpdateRunResult = {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: updateRoot,
|
||||
reason: cloneStep.name,
|
||||
steps: [cloneStep],
|
||||
durationMs: Date.now() - params.startedAt,
|
||||
};
|
||||
params.stop();
|
||||
printResult(result, { ...params.opts, hideSteps: params.showProgress });
|
||||
defaultRuntime.exit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
const updateResult = await runGatewayUpdate({
|
||||
cwd: updateRoot,
|
||||
argv1: params.switchToGit ? undefined : process.argv[1],
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
channel: params.channel,
|
||||
tag: params.tag,
|
||||
});
|
||||
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
|
||||
|
||||
if (params.switchToGit && updateResult.status === "ok") {
|
||||
const manager = await resolveGlobalManager({
|
||||
root: params.root,
|
||||
installKind: params.installKind,
|
||||
timeoutMs: effectiveTimeout,
|
||||
});
|
||||
const installStep = await runUpdateStep({
|
||||
name: "global install",
|
||||
argv: globalInstallArgs(manager, updateRoot),
|
||||
cwd: updateRoot,
|
||||
timeoutMs: effectiveTimeout,
|
||||
progress: params.progress,
|
||||
});
|
||||
steps.push(installStep);
|
||||
|
||||
const failedStep = installStep.exitCode !== 0 ? installStep : null;
|
||||
return {
|
||||
...updateResult,
|
||||
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
|
||||
steps,
|
||||
durationMs: Date.now() - params.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...updateResult,
|
||||
steps,
|
||||
durationMs: Date.now() - params.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePluginsAfterCoreUpdate(params: {
|
||||
root: string;
|
||||
channel: "stable" | "beta" | "dev";
|
||||
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
|
||||
opts: UpdateCommandOptions;
|
||||
}): Promise<void> {
|
||||
if (!params.configSnapshot.valid) {
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginLogger = params.opts.json
|
||||
? {}
|
||||
: {
|
||||
info: (msg: string) => defaultRuntime.log(msg),
|
||||
warn: (msg: string) => defaultRuntime.log(theme.warn(msg)),
|
||||
error: (msg: string) => defaultRuntime.log(theme.error(msg)),
|
||||
};
|
||||
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Updating plugins..."));
|
||||
}
|
||||
|
||||
const syncResult = await syncPluginsForUpdateChannel({
|
||||
config: params.configSnapshot.config,
|
||||
channel: params.channel,
|
||||
workspaceDir: params.root,
|
||||
logger: pluginLogger,
|
||||
});
|
||||
let pluginConfig = syncResult.config;
|
||||
|
||||
const npmResult = await updateNpmInstalledPlugins({
|
||||
config: pluginConfig,
|
||||
skipIds: new Set(syncResult.summary.switchedToNpm),
|
||||
logger: pluginLogger,
|
||||
});
|
||||
pluginConfig = npmResult.config;
|
||||
|
||||
if (syncResult.changed || npmResult.changed) {
|
||||
await writeConfigFile(pluginConfig);
|
||||
}
|
||||
|
||||
if (params.opts.json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summarizeList = (list: string[]) => {
|
||||
if (list.length <= 6) {
|
||||
return list.join(", ");
|
||||
}
|
||||
return `${list.slice(0, 6).join(", ")} +${list.length - 6} more`;
|
||||
};
|
||||
|
||||
if (syncResult.summary.switchedToBundled.length > 0) {
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
`Switched to bundled plugins: ${summarizeList(syncResult.summary.switchedToBundled)}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (syncResult.summary.switchedToNpm.length > 0) {
|
||||
defaultRuntime.log(
|
||||
theme.muted(`Restored npm plugins: ${summarizeList(syncResult.summary.switchedToNpm)}.`),
|
||||
);
|
||||
}
|
||||
for (const warning of syncResult.summary.warnings) {
|
||||
defaultRuntime.log(theme.warn(warning));
|
||||
}
|
||||
for (const error of syncResult.summary.errors) {
|
||||
defaultRuntime.log(theme.error(error));
|
||||
}
|
||||
|
||||
const updated = npmResult.outcomes.filter((entry) => entry.status === "updated").length;
|
||||
const unchanged = npmResult.outcomes.filter((entry) => entry.status === "unchanged").length;
|
||||
const failed = npmResult.outcomes.filter((entry) => entry.status === "error").length;
|
||||
const skipped = npmResult.outcomes.filter((entry) => entry.status === "skipped").length;
|
||||
|
||||
if (npmResult.outcomes.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No plugin updates needed."));
|
||||
} else {
|
||||
const parts = [`${updated} updated`, `${unchanged} unchanged`];
|
||||
if (failed > 0) {
|
||||
parts.push(`${failed} failed`);
|
||||
}
|
||||
if (skipped > 0) {
|
||||
parts.push(`${skipped} skipped`);
|
||||
}
|
||||
defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`));
|
||||
}
|
||||
|
||||
for (const outcome of npmResult.outcomes) {
|
||||
if (outcome.status !== "error") {
|
||||
continue;
|
||||
}
|
||||
defaultRuntime.log(theme.error(outcome.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRestartService(params: {
|
||||
shouldRestart: boolean;
|
||||
result: UpdateRunResult;
|
||||
opts: UpdateCommandOptions;
|
||||
}): Promise<void> {
|
||||
if (params.shouldRestart) {
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Restarting service..."));
|
||||
}
|
||||
|
||||
try {
|
||||
const restarted = await runDaemonRestart();
|
||||
if (!params.opts.json && restarted) {
|
||||
defaultRuntime.log(theme.success("Daemon restarted successfully."));
|
||||
defaultRuntime.log("");
|
||||
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
|
||||
try {
|
||||
const interactiveDoctor =
|
||||
Boolean(process.stdin.isTTY) && !params.opts.json && params.opts.yes !== true;
|
||||
await doctorCommand(defaultRuntime, {
|
||||
nonInteractive: !interactiveDoctor,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||
} finally {
|
||||
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log(theme.warn(`Daemon restart failed: ${String(err)}`));
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
`You may need to restart the service manually: ${replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log("");
|
||||
if (params.result.mode === "npm" || params.result.mode === "pnpm") {
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
`Tip: Run \`${replaceCliName(formatCliCommand("openclaw doctor"), CLI_NAME)}\`, then \`${replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME)}\` to apply updates to a running gateway.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
`Tip: Run \`${replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME)}\` to apply updates to a running gateway.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
suppressDeprecations();
|
||||
|
||||
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
||||
const shouldRestart = opts.restart !== false;
|
||||
|
||||
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
||||
defaultRuntime.error("--timeout must be a positive integer (seconds)");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await resolveUpdateRoot();
|
||||
const updateStatus = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: false,
|
||||
includeRegistry: false,
|
||||
});
|
||||
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
const storedChannel = configSnapshot.valid
|
||||
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
||||
: null;
|
||||
|
||||
const requestedChannel = normalizeUpdateChannel(opts.channel);
|
||||
if (opts.channel && !requestedChannel) {
|
||||
defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (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 installKind = updateStatus.installKind;
|
||||
const switchToGit = requestedChannel === "dev" && installKind !== "git";
|
||||
const switchToPackage =
|
||||
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
|
||||
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
|
||||
const defaultChannel =
|
||||
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
||||
|
||||
const explicitTag = normalizeTag(opts.tag);
|
||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||
|
||||
if (updateInstallKind !== "git") {
|
||||
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
let fallbackToLatest = false;
|
||||
const targetVersion = explicitTag
|
||||
? await resolveTargetVersion(tag, timeoutMs)
|
||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
tag = resolved.tag;
|
||||
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
|
||||
return resolved.version;
|
||||
});
|
||||
const cmp =
|
||||
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
|
||||
const needsConfirm =
|
||||
!fallbackToLatest &&
|
||||
currentVersion != null &&
|
||||
(targetVersion == null || (cmp != null && cmp > 0));
|
||||
|
||||
if (needsConfirm && !opts.yes) {
|
||||
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) {
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
}
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (opts.tag && !opts.json) {
|
||||
defaultRuntime.log(
|
||||
theme.muted("Note: --tag applies to npm installs only; git updates ignore it."),
|
||||
);
|
||||
}
|
||||
|
||||
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 OpenClaw..."));
|
||||
defaultRuntime.log("");
|
||||
}
|
||||
|
||||
const { progress, stop } = createUpdateProgress(showProgress);
|
||||
const startedAt = Date.now();
|
||||
|
||||
const result = switchToPackage
|
||||
? await runPackageInstallUpdate({
|
||||
root,
|
||||
installKind,
|
||||
tag,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
startedAt,
|
||||
progress,
|
||||
})
|
||||
: await runGitUpdate({
|
||||
root,
|
||||
switchToGit,
|
||||
installKind,
|
||||
timeoutMs,
|
||||
startedAt,
|
||||
progress,
|
||||
channel,
|
||||
tag,
|
||||
showProgress,
|
||||
opts,
|
||||
stop,
|
||||
});
|
||||
|
||||
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 OpenClaw install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run \`${replaceCliName(formatCliCommand("openclaw doctor"), CLI_NAME)}\` and \`${replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME)}\`.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
`Examples: \`${replaceCliName("npm i -g openclaw@latest", CLI_NAME)}\` or \`${replaceCliName("pnpm add -g openclaw@latest", CLI_NAME)}\``,
|
||||
),
|
||||
);
|
||||
}
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
await updatePluginsAfterCoreUpdate({
|
||||
root,
|
||||
channel,
|
||||
configSnapshot,
|
||||
opts,
|
||||
});
|
||||
|
||||
await tryWriteCompletionCache(root, Boolean(opts.json));
|
||||
await tryInstallShellCompletion({
|
||||
jsonMode: Boolean(opts.json),
|
||||
skipPrompt: Boolean(opts.yes),
|
||||
});
|
||||
|
||||
await maybeRestartService({
|
||||
shouldRestart,
|
||||
result,
|
||||
opts,
|
||||
});
|
||||
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted(pickUpdateQuip()));
|
||||
}
|
||||
}
|
||||
160
src/cli/update-cli/wizard.ts
Normal file
160
src/cli/update-cli/wizard.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { confirm, isCancel, select } from "@clack/prompts";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import {
|
||||
formatUpdateChannelLabel,
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
} from "../../infra/update-channels.js";
|
||||
import { checkUpdateStatus } from "../../infra/update-check.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { pathExists } from "../../utils.js";
|
||||
import {
|
||||
isEmptyDir,
|
||||
isGitCheckout,
|
||||
resolveGitInstallDir,
|
||||
resolveUpdateRoot,
|
||||
type UpdateWizardOptions,
|
||||
} from "./shared.js";
|
||||
import { updateCommand } from "./update-command.js";
|
||||
|
||||
const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
|
||||
select({
|
||||
...params,
|
||||
message: stylePromptMessage(params.message),
|
||||
options: params.options.map((opt) =>
|
||||
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
|
||||
),
|
||||
});
|
||||
|
||||
export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
defaultRuntime.error(
|
||||
"Update wizard requires a TTY. Use `openclaw update --channel <stable|beta|dev>` instead.",
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
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 root = await resolveUpdateRoot();
|
||||
const [updateStatus, configSnapshot] = await Promise.all([
|
||||
checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: false,
|
||||
includeRegistry: false,
|
||||
}),
|
||||
readConfigFileSnapshot(),
|
||||
]);
|
||||
|
||||
const configChannel = configSnapshot.valid
|
||||
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
||||
: null;
|
||||
const channelInfo = resolveEffectiveUpdateChannel({
|
||||
configChannel,
|
||||
installKind: updateStatus.installKind,
|
||||
git: updateStatus.git
|
||||
? { tag: updateStatus.git.tag, branch: updateStatus.git.branch }
|
||||
: undefined,
|
||||
});
|
||||
const channelLabel = formatUpdateChannelLabel({
|
||||
channel: channelInfo.channel,
|
||||
source: channelInfo.source,
|
||||
gitTag: updateStatus.git?.tag ?? null,
|
||||
gitBranch: updateStatus.git?.branch ?? null,
|
||||
});
|
||||
|
||||
const pickedChannel = await selectStyled({
|
||||
message: "Update channel",
|
||||
options: [
|
||||
{
|
||||
value: "keep",
|
||||
label: `Keep current (${channelInfo.channel})`,
|
||||
hint: channelLabel,
|
||||
},
|
||||
{
|
||||
value: "stable",
|
||||
label: "Stable",
|
||||
hint: "Tagged releases (npm latest)",
|
||||
},
|
||||
{
|
||||
value: "beta",
|
||||
label: "Beta",
|
||||
hint: "Prereleases (npm beta)",
|
||||
},
|
||||
{
|
||||
value: "dev",
|
||||
label: "Dev",
|
||||
hint: "Git main",
|
||||
},
|
||||
],
|
||||
initialValue: "keep",
|
||||
});
|
||||
|
||||
if (isCancel(pickedChannel)) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedChannel = pickedChannel === "keep" ? null : pickedChannel;
|
||||
|
||||
if (requestedChannel === "dev" && updateStatus.installKind !== "git") {
|
||||
const gitDir = resolveGitInstallDir();
|
||||
const hasGit = await isGitCheckout(gitDir);
|
||||
if (!hasGit) {
|
||||
const dirExists = await pathExists(gitDir);
|
||||
if (dirExists) {
|
||||
const empty = await isEmptyDir(gitDir);
|
||||
if (!empty) {
|
||||
defaultRuntime.error(
|
||||
`OPENCLAW_GIT_DIR points at a non-git directory: ${gitDir}. Set OPENCLAW_GIT_DIR to an empty folder or an openclaw checkout.`,
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage(
|
||||
`Create a git checkout at ${gitDir}? (override via OPENCLAW_GIT_DIR)`,
|
||||
),
|
||||
initialValue: true,
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restart = await confirm({
|
||||
message: stylePromptMessage("Restart the gateway service after update?"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (isCancel(restart)) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCommand({
|
||||
channel: requestedChannel ?? undefined,
|
||||
restart: Boolean(restart),
|
||||
timeout: opts.timeout,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user