feat(update): add core auto-updater and dry-run preview
This commit is contained in:
@@ -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