feat(update): add core auto-updater and dry-run preview

This commit is contained in:
Peter Steinberger
2026-02-22 17:11:24 +01:00
parent 13690d406a
commit f442a3539f
15 changed files with 673 additions and 45 deletions

View File

@@ -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."),
);