mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:21:37 +00:00
feat(update): add core auto-updater and dry-run preview
This commit is contained in:
@@ -374,6 +374,23 @@ describe("update-cli", () => {
|
||||
expect(defaultRuntime.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateCommand --dry-run previews without mutating", async () => {
|
||||
vi.mocked(defaultRuntime.log).mockClear();
|
||||
serviceLoaded.mockResolvedValue(true);
|
||||
|
||||
await updateCommand({ dryRun: true, channel: "beta" });
|
||||
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(runDaemonInstall).not.toHaveBeenCalled();
|
||||
expect(runRestartScript).not.toHaveBeenCalled();
|
||||
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||
|
||||
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
|
||||
expect(logs.join("\n")).toContain("Update dry-run");
|
||||
expect(logs.join("\n")).toContain("No changes were applied.");
|
||||
});
|
||||
|
||||
it("updateStatusCommand prints table output", async () => {
|
||||
await updateStatusCommand({ json: false });
|
||||
|
||||
@@ -704,6 +721,16 @@ describe("update-cli", () => {
|
||||
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
|
||||
});
|
||||
|
||||
it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => {
|
||||
await setupNonInteractiveDowngrade();
|
||||
vi.mocked(defaultRuntime.exit).mockClear();
|
||||
|
||||
await updateCommand({ dryRun: true });
|
||||
|
||||
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false);
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateWizardCommand requires a TTY", async () => {
|
||||
setTty(false);
|
||||
vi.mocked(defaultRuntime.error).mockClear();
|
||||
|
||||
@@ -37,6 +37,7 @@ export function registerUpdateCli(program: Command) {
|
||||
.description("Update OpenClaw and inspect update channel status")
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--no-restart", "Skip restarting the gateway service after a successful update")
|
||||
.option("--dry-run", "Preview update actions without making changes", false)
|
||||
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
|
||||
.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)")
|
||||
@@ -47,6 +48,7 @@ export function registerUpdateCli(program: Command) {
|
||||
["openclaw update --channel beta", "Switch to beta channel (git + npm)"],
|
||||
["openclaw update --channel dev", "Switch to dev channel (git + npm)"],
|
||||
["openclaw update --tag beta", "One-off update to a dist-tag or version"],
|
||||
["openclaw update --dry-run", "Preview actions without changing anything"],
|
||||
["openclaw update --no-restart", "Update without restarting the service"],
|
||||
["openclaw update --json", "Output result as JSON"],
|
||||
["openclaw update --yes", "Non-interactive (accept downgrade prompts)"],
|
||||
@@ -69,6 +71,7 @@ ${theme.heading("Switch channels:")}
|
||||
${theme.heading("Non-interactive:")}
|
||||
- Use --yes to accept downgrade prompts
|
||||
- Combine with --channel/--tag/--restart/--json/--timeout as needed
|
||||
- Use --dry-run to preview actions without writing config/installing/restarting
|
||||
|
||||
${theme.heading("Examples:")}
|
||||
${fmtExamples}
|
||||
@@ -86,6 +89,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
|
||||
await updateCommand({
|
||||
json: Boolean(opts.json),
|
||||
restart: Boolean(opts.restart),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
channel: opts.channel as string | undefined,
|
||||
tag: opts.tag as string | undefined,
|
||||
timeout: opts.timeout as string | undefined,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { pathExists } from "../../utils.js";
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
restart?: boolean;
|
||||
dryRun?: boolean;
|
||||
channel?: string;
|
||||
tag?: string;
|
||||
timeout?: string;
|
||||
|
||||
@@ -114,6 +114,65 @@ function formatCommandFailure(stdout: string, stderr: string): string {
|
||||
return detail.split("\n").slice(-3).join("\n");
|
||||
}
|
||||
|
||||
type UpdateDryRunPreview = {
|
||||
dryRun: true;
|
||||
root: string;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
mode: UpdateRunResult["mode"];
|
||||
updateInstallKind: "git" | "package" | "unknown";
|
||||
switchToGit: boolean;
|
||||
switchToPackage: boolean;
|
||||
restart: boolean;
|
||||
requestedChannel: "stable" | "beta" | "dev" | null;
|
||||
storedChannel: "stable" | "beta" | "dev" | null;
|
||||
effectiveChannel: "stable" | "beta" | "dev";
|
||||
tag: string;
|
||||
currentVersion: string | null;
|
||||
targetVersion: string | null;
|
||||
downgradeRisk: boolean;
|
||||
actions: string[];
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): void {
|
||||
if (jsonMode) {
|
||||
defaultRuntime.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log(theme.heading("Update dry-run"));
|
||||
defaultRuntime.log(theme.muted("No changes were applied."));
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(` Root: ${theme.muted(preview.root)}`);
|
||||
defaultRuntime.log(` Install kind: ${theme.muted(preview.installKind)}`);
|
||||
defaultRuntime.log(` Mode: ${theme.muted(preview.mode)}`);
|
||||
defaultRuntime.log(` Channel: ${theme.muted(preview.effectiveChannel)}`);
|
||||
defaultRuntime.log(` Tag/spec: ${theme.muted(preview.tag)}`);
|
||||
if (preview.currentVersion) {
|
||||
defaultRuntime.log(` Current version: ${theme.muted(preview.currentVersion)}`);
|
||||
}
|
||||
if (preview.targetVersion) {
|
||||
defaultRuntime.log(` Target version: ${theme.muted(preview.targetVersion)}`);
|
||||
}
|
||||
if (preview.downgradeRisk) {
|
||||
defaultRuntime.log(theme.warn(" Downgrade confirmation would be required in a real run."));
|
||||
}
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Planned actions:"));
|
||||
for (const action of preview.actions) {
|
||||
defaultRuntime.log(` - ${action}`);
|
||||
}
|
||||
|
||||
if (preview.notes.length > 0) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Notes:"));
|
||||
for (const note of preview.notes) {
|
||||
defaultRuntime.log(` - ${theme.muted(note)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGatewayServiceEnv(params: {
|
||||
result: UpdateRunResult;
|
||||
jsonMode: boolean;
|
||||
@@ -613,11 +672,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
|
||||
const explicitTag = normalizeTag(opts.tag);
|
||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||
let currentVersion: string | null = null;
|
||||
let targetVersion: string | null = null;
|
||||
let downgradeRisk = false;
|
||||
let fallbackToLatest = false;
|
||||
|
||||
if (updateInstallKind !== "git") {
|
||||
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
let fallbackToLatest = false;
|
||||
const targetVersion = explicitTag
|
||||
currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
targetVersion = explicitTag
|
||||
? await resolveTargetVersion(tag, timeoutMs)
|
||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
tag = resolved.tag;
|
||||
@@ -626,38 +688,106 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
});
|
||||
const cmp =
|
||||
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
|
||||
const needsConfirm =
|
||||
downgradeRisk =
|
||||
!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 (opts.dryRun) {
|
||||
let mode: UpdateRunResult["mode"] = "unknown";
|
||||
if (updateInstallKind === "git") {
|
||||
mode = "git";
|
||||
} else if (updateInstallKind === "package") {
|
||||
mode = await resolveGlobalManager({
|
||||
root,
|
||||
installKind,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||
}
|
||||
defaultRuntime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (opts.tag && !opts.json) {
|
||||
|
||||
const actions: string[] = [];
|
||||
if (requestedChannel && requestedChannel !== storedChannel) {
|
||||
actions.push(`Persist update.channel=${requestedChannel} in config`);
|
||||
}
|
||||
if (switchToGit) {
|
||||
actions.push("Switch install mode from package to git checkout (dev channel)");
|
||||
} else if (switchToPackage) {
|
||||
actions.push(`Switch install mode from git to package manager (${mode})`);
|
||||
} else if (updateInstallKind === "git") {
|
||||
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
|
||||
} else {
|
||||
actions.push(`Run global package manager update with spec openclaw@${tag}`);
|
||||
}
|
||||
actions.push("Run plugin update sync after core update");
|
||||
actions.push("Refresh shell completion cache (if needed)");
|
||||
actions.push(
|
||||
shouldRestart
|
||||
? "Restart gateway service and run doctor checks"
|
||||
: "Skip restart (because --no-restart is set)",
|
||||
);
|
||||
|
||||
const notes: string[] = [];
|
||||
if (opts.tag && updateInstallKind === "git") {
|
||||
notes.push("--tag applies to npm installs only; git updates ignore it.");
|
||||
}
|
||||
if (fallbackToLatest) {
|
||||
notes.push("Beta channel resolves to latest for this run (fallback).");
|
||||
}
|
||||
|
||||
printDryRunPreview(
|
||||
{
|
||||
dryRun: true,
|
||||
root,
|
||||
installKind,
|
||||
mode,
|
||||
updateInstallKind,
|
||||
switchToGit,
|
||||
switchToPackage,
|
||||
restart: shouldRestart,
|
||||
requestedChannel,
|
||||
storedChannel,
|
||||
effectiveChannel: channel,
|
||||
tag,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
downgradeRisk,
|
||||
actions,
|
||||
notes,
|
||||
},
|
||||
Boolean(opts.json),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (downgradeRisk && !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;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInstallKind === "git" && opts.tag && !opts.json) {
|
||||
defaultRuntime.log(
|
||||
theme.muted("Note: --tag applies to npm installs only; git updates ignore it."),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user