Files
moltbot/src/cli/update-cli/update-command.ts

2308 lines
74 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { confirm, isCancel } from "@clack/prompts";
import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
} from "../../commands/doctor-completion.js";
import { doctorCommand } from "../../commands/doctor.js";
import {
ConfigMutationConflictError,
readConfigFileSnapshot,
replaceConfigFile,
resolveGatewayPort,
} from "../../config/config.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import {
readGatewayServiceState,
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import {
channelToNpmTag,
DEFAULT_GIT_CHANNEL,
DEFAULT_PACKAGE_CHANNEL,
normalizeUpdateChannel,
} from "../../infra/update-channels.js";
import {
compareSemverStrings,
fetchNpmPackageTargetStatus,
resolveNpmChannelTag,
checkUpdateStatus,
} from "../../infra/update-check.js";
import {
canResolveRegistryVersionForPackageTarget,
createGlobalInstallEnv,
cleanupGlobalRenameDirs,
globalInstallArgs,
resolveGlobalInstallTarget,
resolveGlobalInstallSpec,
} from "../../infra/update-global.js";
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
import {
loadInstalledPluginIndexInstallRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../../plugins/installed-plugin-index-records.js";
import {
syncPluginsForUpdateChannel,
updateNpmInstalledPlugins,
type PluginUpdateIntegrityDriftParams,
type PluginUpdateOutcome,
} from "../../plugins/update.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { stylePromptMessage } from "../../terminal/prompt-style.js";
import { theme } from "../../terminal/theme.js";
import { resolveUserPath } from "../../utils.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import { installCompletion } from "../completion-runtime.js";
import { runDaemonInstall, runDaemonRestart } from "../daemon-cli.js";
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
import {
renderRestartDiagnostics,
terminateStaleGatewayPids,
waitForGatewayHealthyRestart,
type GatewayRestartSnapshot,
} from "../daemon-cli/restart-health.js";
import { commitPluginInstallRecordsWithConfig } from "../plugins-install-record-commit.js";
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
import { refreshPluginRegistryAfterConfigMutation } from "../plugins-registry-refresh.js";
import { createUpdateProgress, printResult } from "./progress.js";
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
import {
DEFAULT_PACKAGE_NAME,
createGlobalCommandRunner,
ensureGitCheckout,
normalizeTag,
parseTimeoutMsOrExit,
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 SERVICE_REFRESH_TIMEOUT_MS = 60_000;
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
const SERVICE_REFRESH_PATH_ENV_KEYS = [
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
] as const;
const POST_INSTALL_DOCTOR_SERVICE_ENV_KEYS = [
...SERVICE_REFRESH_PATH_ENV_KEYS,
"OPENCLAW_PROFILE",
] as const;
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).",
];
type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
function pickUpdateQuip(): string {
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
}
function isPackageManagerUpdateMode(mode: UpdateRunResult["mode"]): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
return (
record.source === "npm" ||
record.source === "clawhub" ||
record.source === "git" ||
record.source === "marketplace"
);
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
const env = params.env ?? process.env;
const missing: MissingPluginInstallPayload[] = [];
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (!isTrackedPackageInstallRecord(record)) {
continue;
}
const rawInstallPath = normalizeOptionalString(record.installPath);
if (!rawInstallPath) {
missing.push({ pluginId, reason: "missing-install-path" });
continue;
}
const installPath = resolveUserPath(rawInstallPath, env);
if (!(await pathExists(installPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
continue;
}
const packageJsonPath = path.join(installPath, "package.json");
if (!(await pathExists(packageJsonPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
}
return missing;
}
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
if (entry.reason === "missing-install-path") {
return "installPath is missing";
}
if (entry.reason === "missing-package-json") {
return `package.json is missing under ${entry.installPath}`;
}
return `package directory is missing: ${entry.installPath}`;
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
serviceLoaded: boolean;
}): boolean {
if (isPackageManagerUpdateMode(params.updateMode)) {
return params.serviceInstalled;
}
return params.serviceLoaded;
}
export function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type PostUpdateLaunchAgentRecoveryDeps = {
platform?: NodeJS.Platform;
readState?: typeof readGatewayServiceState;
recover?: typeof recoverInstalledLaunchAgent;
};
export async function recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateLaunchAgentRecoveryDeps;
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
const platform = params.deps?.platform ?? process.platform;
if (platform !== "darwin") {
return { attempted: false, recovered: false };
}
const service = params.service ?? resolveGatewayService();
const readState = params.deps?.readState ?? readGatewayServiceState;
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
const state = await readState(service, { env: params.env }).catch(() => null);
if (state?.loaded) {
return { attempted: false, recovered: false };
}
if (state && !state.installed && !state.runtime?.missingSupervision) {
return { attempted: false, recovered: false };
}
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
() => null,
);
if (!recovered) {
return {
attempted: true,
recovered: false,
detail:
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
};
}
return {
attempted: true,
recovered: true,
message: recovered.message,
};
}
type PostUpdateGatewayHealthRecoveryDeps = {
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
waitForHealthy?: typeof waitForGatewayHealthyRestart;
};
export async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateGatewayHealthRecoveryDeps;
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}> {
if (params.health.healthy) {
return { health: params.health, launchAgentRecovery: null };
}
const recoverLaunchAgent =
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
const launchAgentRecovery = await recoverLaunchAgent({
service: params.service,
env: params.env,
});
if (!launchAgentRecovery.recovered) {
return { health: params.health, launchAgentRecovery };
}
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
const health = await waitForHealthy({
service: params.service,
port: params.port,
expectedVersion: params.expectedVersion,
env: params.env,
});
return { health, launchAgentRecovery };
}
function formatPostUpdateGatewayRecoveryInstructions(result: UpdateRunResult): string[] {
const lines = [
`Recovery: run \`${replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME)}\`; if macOS reports the LaunchAgent is installed but not loaded, run \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\` from the logged-in user session, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway status --deep"), CLI_NAME)}\`.`,
];
const beforeVersion = normalizeOptionalString(result.before?.version);
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
lines.push(
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
);
}
return lines;
}
type PrePackageServiceStop = {
stopped: boolean;
inspected: boolean;
runtimeInspected: boolean;
running: boolean;
blockMessage?: string;
serviceEnv?: NodeJS.ProcessEnv;
};
function formatGatewayAncestryBlockMessage(pid: number): string {
return `openclaw update detected it is running inside the gateway process tree.
Gateway PID ${pid} is an ancestor of this process, so this updater cannot safely stop or restart the gateway that owns it.
Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`;
}
function isGatewayAncestorPid(pid: unknown): pid is number {
return typeof pid === "number" && pid > 0 && getSelfAndAncestorPidsSync().has(pid);
}
function gatewayAncestryBlockMessage(pid: unknown): string | undefined {
return isGatewayAncestorPid(pid) ? formatGatewayAncestryBlockMessage(pid) : undefined;
}
function gatewayRuntimeAncestryBlockMessage(
runtime: { pid?: unknown } | null | undefined,
): string | undefined {
return gatewayAncestryBlockMessage(runtime?.pid);
}
async function maybeStopManagedServiceBeforePackageUpdate(params: {
shouldRestart: boolean;
jsonMode: boolean;
}): Promise<PrePackageServiceStop> {
let service: ReturnType<typeof resolveGatewayService>;
let serviceState: Awaited<ReturnType<typeof readGatewayServiceState>>;
try {
service = resolveGatewayService();
serviceState = await readGatewayServiceState(service, { env: process.env });
} catch {
return { stopped: false, inspected: false, runtimeInspected: false, running: false };
}
const runtimeStatus = serviceState.runtime?.status;
const runtimeInspected = runtimeStatus === "running" || runtimeStatus === "stopped";
if (!serviceState.installed) {
return {
stopped: false,
inspected: true,
runtimeInspected,
running: serviceState.running,
serviceEnv: serviceState.env,
};
}
if (!params.shouldRestart) {
if (!params.jsonMode && serviceState.running) {
defaultRuntime.log(
theme.warn(
"--no-restart is set while the managed gateway service is running; the package update will not stop or restart that process.",
),
);
}
return {
stopped: false,
inspected: true,
runtimeInspected,
running: serviceState.running,
serviceEnv: serviceState.env,
};
}
if (!runtimeInspected) {
return {
stopped: false,
inspected: true,
runtimeInspected: false,
running: false,
serviceEnv: serviceState.env,
};
}
if (!serviceState.running) {
return {
stopped: false,
inspected: true,
runtimeInspected: true,
running: false,
serviceEnv: serviceState.env,
};
}
const blockMessage = gatewayRuntimeAncestryBlockMessage(serviceState.runtime);
if (blockMessage) {
return {
stopped: false,
inspected: true,
runtimeInspected: true,
running: true,
blockMessage,
serviceEnv: serviceState.env,
};
}
if (!params.jsonMode) {
defaultRuntime.log(theme.muted("Stopping managed gateway service before package update..."));
}
await service.stop({ env: serviceState.env, stdout: process.stdout });
return {
stopped: true,
inspected: true,
runtimeInspected: true,
running: true,
serviceEnv: serviceState.env,
};
}
async function maybeRestartServiceAfterFailedPackageUpdate(params: {
prePackageServiceStop: PrePackageServiceStop | undefined;
jsonMode: boolean;
}): Promise<void> {
if (!params.prePackageServiceStop?.stopped || !params.prePackageServiceStop.serviceEnv) {
return;
}
try {
await resolveGatewayService().restart({
env: params.prePackageServiceStop.serviceEnv,
stdout: process.stdout,
});
if (!params.jsonMode) {
defaultRuntime.log(theme.muted("Restarted managed gateway service after failed update."));
}
} catch (err) {
const message = `Failed to restart managed gateway service after failed update: ${String(err)}`;
if (params.jsonMode) {
defaultRuntime.error(message);
} else {
defaultRuntime.log(theme.warn(message));
}
}
}
function isRunningInsideGatewayService(
env: Record<string, string | undefined> = process.env,
): boolean {
if (env.OPENCLAW_SERVICE_MARKER?.trim() !== GATEWAY_SERVICE_MARKER) {
return false;
}
const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim();
return !serviceKind || serviceKind === GATEWAY_SERVICE_KIND;
}
function shouldBlockPackageUpdateFromGatewayServiceEnv(params: {
prePackageServiceStop: PrePackageServiceStop | undefined;
}): boolean {
if (!isRunningInsideGatewayService()) {
return false;
}
const stopState = params.prePackageServiceStop;
if (!stopState?.inspected) {
return true;
}
if (stopState.stopped) {
return false;
}
if (!stopState.runtimeInspected) {
return true;
}
return stopState.running;
}
function formatCommandFailure(stdout: string, stderr: string): string {
const detail = (stderr || stdout).trim();
if (!detail) {
return "command returned a non-zero exit code";
}
return detail.split("\n").slice(-3).join("\n");
}
function tryResolveInvocationCwd(): string | undefined {
try {
return process.cwd();
} catch {
return undefined;
}
}
async function resolvePackageRuntimePreflightError(params: {
tag: string;
timeoutMs?: number;
}): Promise<string | null> {
if (!canResolveRegistryVersionForPackageTarget(params.tag)) {
return null;
}
const target = params.tag.trim();
if (!target) {
return null;
}
const status = await fetchNpmPackageTargetStatus({
target,
timeoutMs: params.timeoutMs,
});
if (status.error) {
return null;
}
const satisfies = nodeVersionSatisfiesEngine(process.versions.node ?? null, status.nodeEngine);
if (satisfies !== false) {
return null;
}
const targetLabel = status.version ?? target;
return [
`Node ${process.versions.node ?? "unknown"} is too old for openclaw@${targetLabel}.`,
`The requested package requires ${status.nodeEngine}.`,
"Upgrade Node to 22.14+ or Node 24, then rerun `openclaw update`.",
"Bare `npm i -g openclaw` can silently install an older compatible release.",
"After upgrading Node, use `npm i -g openclaw@latest`.",
].join("\n");
}
function resolveServiceRefreshEnv(
env: NodeJS.ProcessEnv,
invocationCwd?: string,
): NodeJS.ProcessEnv {
const resolvedEnv: NodeJS.ProcessEnv = { ...env };
for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) {
const rawValue = resolvedEnv[key]?.trim();
if (!rawValue) {
continue;
}
if (rawValue.startsWith("~") || path.isAbsolute(rawValue) || path.win32.isAbsolute(rawValue)) {
resolvedEnv[key] = rawValue;
continue;
}
if (!invocationCwd) {
resolvedEnv[key] = rawValue;
continue;
}
resolvedEnv[key] = path.resolve(invocationCwd, rawValue);
}
return resolvedEnv;
}
function disableUpdatedPackageCompileCacheEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...env,
NODE_DISABLE_COMPILE_CACHE: "1",
};
}
function stripGatewayServiceMarkerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const resolvedEnv = { ...env };
delete resolvedEnv.OPENCLAW_SERVICE_MARKER;
delete resolvedEnv.OPENCLAW_SERVICE_KIND;
return resolvedEnv;
}
function resolveUpdatedInstallCommandEnv(
env: NodeJS.ProcessEnv,
invocationCwd?: string,
): NodeJS.ProcessEnv {
return disableUpdatedPackageCompileCacheEnv(resolveServiceRefreshEnv(env, invocationCwd));
}
export function resolvePostInstallDoctorEnv(params?: {
baseEnv?: NodeJS.ProcessEnv;
serviceEnv?: NodeJS.ProcessEnv;
invocationCwd?: string;
}): NodeJS.ProcessEnv {
const resolvedEnv = disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env);
if (!params?.serviceEnv) {
return resolvedEnv;
}
const serviceEnv = resolveServiceRefreshEnv(params.serviceEnv, params.invocationCwd);
for (const key of POST_INSTALL_DOCTOR_SERVICE_ENV_KEYS) {
const value = serviceEnv[key]?.trim();
if (value) {
resolvedEnv[key] = serviceEnv[key];
}
}
return resolvedEnv;
}
export function resolveUpdatedGatewayRestartPort(params: {
config?: OpenClawConfig;
processEnv?: NodeJS.ProcessEnv;
serviceEnv?: NodeJS.ProcessEnv;
}): number {
return resolveGatewayPort(params.config, params.serviceEnv ?? params.processEnv ?? process.env);
}
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.writeJson(preview);
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;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const args = ["gateway", "install", "--force"];
if (params.jsonMode) {
args.push("--json");
}
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (entrypoint) {
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
return;
}
throw new Error(
`updated install refresh failed (${entrypoint}): ${formatCommandFailure(res.stdout, res.stderr)}`,
);
}
if (isPackageManagerUpdateMode(params.result.mode)) {
throw new Error(
`updated install entrypoint not found under ${params.result.root ?? "unknown"}`,
);
}
await runDaemonInstall({ force: true, json: params.jsonMode || undefined });
}
async function runUpdatedInstallGatewayRestart(params: {
result: UpdateRunResult;
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (!entrypoint) {
throw new Error(
`updated install entrypoint not found under ${params.result.root ?? "unknown"}`,
);
}
const args = ["gateway", "restart"];
if (params.jsonMode) {
args.push("--json");
}
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
return true;
}
throw new Error(
`updated install restart failed (${entrypoint}): ${formatCommandFailure(res.stdout, res.stderr)}`,
);
}
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"];
jsonMode: boolean;
managedServiceEnv?: NodeJS.ProcessEnv;
invocationCwd?: string;
}): Promise<UpdateRunResult> {
const manager = await resolveGlobalManager({
root: params.root,
installKind: params.installKind,
timeoutMs: params.timeoutMs,
});
const installEnv = await createGlobalInstallEnv();
const runCommand = createGlobalCommandRunner();
const installTarget = await resolveGlobalInstallTarget({
manager,
runCommand,
timeoutMs: params.timeoutMs,
pkgRoot: params.root,
});
const pkgRoot = installTarget.packageRoot;
const packageName =
(pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(params.root)) ??
DEFAULT_PACKAGE_NAME;
const installSpec = resolveGlobalInstallSpec({
packageName,
tag: params.tag,
env: installEnv,
});
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
if (pkgRoot) {
await cleanupGlobalRenameDirs({
globalRoot: path.dirname(pkgRoot),
packageName,
});
}
const diskWarning = createLowDiskSpaceWarning({
targetPath: pkgRoot ? path.dirname(pkgRoot) : params.root,
purpose: "global package update",
});
if (diskWarning) {
if (params.jsonMode) {
defaultRuntime.error(`Warning: ${diskWarning}`);
} else {
defaultRuntime.log(theme.warn(diskWarning));
}
}
const packageUpdate = await runGlobalPackageUpdateSteps({
installTarget,
installSpec,
packageName,
packageRoot: pkgRoot,
runCommand,
timeoutMs: params.timeoutMs,
...(installEnv === undefined ? {} : { env: installEnv }),
runStep: (stepParams) =>
runUpdateStep({
...stepParams,
progress: params.progress,
}),
postVerifyStep: async (verifiedPackageRoot) => {
const entryPath = await resolveGatewayInstallEntrypoint(verifiedPackageRoot);
if (entryPath) {
return await runUpdateStep({
name: `${CLI_NAME} doctor`,
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"],
env: {
...resolvePostInstallDoctorEnv({
serviceEnv: params.managedServiceEnv,
invocationCwd: params.invocationCwd,
}),
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
},
timeoutMs: params.timeoutMs,
progress: params.progress,
});
}
return null;
},
});
return {
status: packageUpdate.failedStep ? "error" : "ok",
mode: manager,
root: packageUpdate.verifiedPackageRoot ?? params.root,
reason: packageUpdate.failedStep ? packageUpdate.failedStep.name : undefined,
before: { version: beforeVersion },
after: { version: packageUpdate.afterVersion ?? beforeVersion },
steps: packageUpdate.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;
devTargetRef?: string;
}): Promise<UpdateRunResult> {
const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root;
const effectiveTimeout = params.timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS;
const installEnv = await createGlobalInstallEnv();
const cloneStep = params.switchToGit
? await ensureGitCheckout({
dir: updateRoot,
env: installEnv,
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,
devTargetRef: params.devTargetRef,
});
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 runCommand = createGlobalCommandRunner();
const installTarget = await resolveGlobalInstallTarget({
manager,
runCommand,
timeoutMs: effectiveTimeout,
pkgRoot: params.root,
});
const installStep = await runUpdateStep({
name: "global install",
argv: globalInstallArgs(installTarget, updateRoot),
cwd: updateRoot,
env: installEnv,
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;
timeoutMs: number;
}): Promise<PostCorePluginUpdateResult> {
if (!params.configSnapshot.valid) {
if (!params.opts.json) {
defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid."));
}
return {
status: "skipped",
reason: "invalid-config",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [],
},
integrityDrifts: [],
};
}
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 pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const syncResult = await syncPluginsForUpdateChannel({
config: withPluginInstallRecords(params.configSnapshot.sourceConfig, pluginInstallRecords),
channel: params.channel,
workspaceDir: params.root,
externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({
workspaceDir: params.root,
}),
logger: pluginLogger,
});
let pluginConfig = syncResult.config;
const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = [];
const pluginUpdateOutcomes: PluginUpdateOutcome[] = [];
let pluginsChanged = syncResult.changed;
let npmPluginsChanged = false;
const onPluginIntegrityDrift = async (drift: PluginUpdateIntegrityDriftParams) => {
integrityDrifts.push({
pluginId: drift.pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
...(drift.resolvedSpec ? { resolvedSpec: drift.resolvedSpec } : {}),
...(drift.resolvedVersion ? { resolvedVersion: drift.resolvedVersion } : {}),
action: "aborted",
});
if (!params.opts.json) {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}` +
"\nPlugin update aborted. Reinstall the plugin only if you trust the new artifact.",
),
);
}
return false;
};
const repairMissingPayloads = async (
records: Record<string, PluginInstallRecord>,
): Promise<readonly string[]> => {
const missing = await collectMissingPluginInstallPayloads({ records });
if (missing.length === 0) {
return [];
}
const missingIds = missing.map((entry) => entry.pluginId);
if (!params.opts.json) {
defaultRuntime.log(
theme.warn(
`Recovering missing plugin install payloads: ${missing
.map((entry) => `${entry.pluginId} (${formatMissingPluginPayloadReason(entry)})`)
.join(", ")}.`,
),
);
}
const repairResult = await updateNpmInstalledPlugins({
config: pluginConfig,
pluginIds: missingIds,
timeoutMs: params.timeoutMs,
updateChannel: params.channel,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
});
pluginConfig = repairResult.config;
pluginsChanged ||= repairResult.changed;
npmPluginsChanged ||= repairResult.changed;
pluginUpdateOutcomes.push(...repairResult.outcomes);
return missingIds;
};
const repairedMissingPayloadIds = await repairMissingPayloads(
pluginConfig.plugins?.installs ?? {},
);
const npmResult = await updateNpmInstalledPlugins({
config: pluginConfig,
timeoutMs: params.timeoutMs,
updateChannel: params.channel,
skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]),
skipDisabledPlugins: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
});
pluginConfig = npmResult.config;
pluginsChanged ||= npmResult.changed;
npmPluginsChanged ||= npmResult.changed;
pluginUpdateOutcomes.push(...npmResult.outcomes);
const remainingMissingPayloads = await collectMissingPluginInstallPayloads({
records: pluginConfig.plugins?.installs ?? {},
});
pluginUpdateOutcomes.push(
...remainingMissingPayloads.map(
(entry): PluginUpdateOutcome => ({
pluginId: entry.pluginId,
status: "error",
message: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
}),
),
);
if (pluginsChanged) {
const nextInstallRecords = pluginConfig.plugins?.installs ?? {};
const nextConfig = withoutPluginInstallRecords(pluginConfig);
await commitPluginInstallRecordsWithConfig({
previousInstallRecords: pluginInstallRecords,
nextInstallRecords,
nextConfig,
baseHash: params.configSnapshot.hash,
});
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
reason: "source-changed",
workspaceDir: params.root,
installRecords: nextInstallRecords,
logger: pluginLogger,
});
}
if (params.opts.json) {
return {
status:
syncResult.summary.errors.length > 0 ||
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
changed: pluginsChanged,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
switchedToNpm: syncResult.summary.switchedToNpm,
warnings: syncResult.summary.warnings,
errors: syncResult.summary.errors,
},
npm: {
changed: npmPluginsChanged,
outcomes: pluginUpdateOutcomes,
},
integrityDrifts,
};
}
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 = pluginUpdateOutcomes.filter((entry) => entry.status === "updated").length;
const unchanged = pluginUpdateOutcomes.filter((entry) => entry.status === "unchanged").length;
const failed = pluginUpdateOutcomes.filter((entry) => entry.status === "error").length;
const skipped = pluginUpdateOutcomes.filter((entry) => entry.status === "skipped").length;
if (pluginUpdateOutcomes.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 pluginUpdateOutcomes) {
if (outcome.status !== "error") {
continue;
}
defaultRuntime.log(theme.error(outcome.message));
}
return {
status:
syncResult.summary.errors.length > 0 ||
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
changed: pluginsChanged,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
switchedToNpm: syncResult.summary.switchedToNpm,
warnings: syncResult.summary.warnings,
errors: syncResult.summary.errors,
},
npm: {
changed: npmPluginsChanged,
outcomes: pluginUpdateOutcomes,
},
integrityDrifts,
};
}
async function maybeRestartService(params: {
shouldRestart: boolean;
result: UpdateRunResult;
opts: UpdateCommandOptions;
refreshServiceEnv: boolean;
serviceEnv?: NodeJS.ProcessEnv;
gatewayPort: number;
restartScriptPath?: string | null;
invocationCwd?: string;
}): Promise<boolean> {
const verifyRestartedGateway = async (expectedGatewayVersion: string | undefined) => {
const restartAfterStaleCleanup = async () => {
if (params.refreshServiceEnv && isPackageManagerUpdateMode(params.result.mode)) {
await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
return;
}
if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
await runDaemonRestart();
}
};
const service = resolveGatewayService();
let health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
});
if (!health.healthy && health.staleGatewayPids.length > 0) {
if (!params.opts.json) {
defaultRuntime.log(
theme.warn(
`Found stale gateway process(es) after restart: ${health.staleGatewayPids.join(", ")}. Cleaning up...`,
),
);
}
await terminateStaleGatewayPids(health.staleGatewayPids);
await restartAfterStaleCleanup();
health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
});
}
const recoveryVerification = await recoverLaunchAgentAndRecheckGatewayHealth({
health,
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
});
health = recoveryVerification.health;
const launchAgentRecovery = recoveryVerification.launchAgentRecovery;
if (launchAgentRecovery?.attempted) {
if (!params.opts.json) {
defaultRuntime.log(
launchAgentRecovery.recovered
? theme.warn(launchAgentRecovery.message)
: theme.warn(launchAgentRecovery.detail),
);
} else {
defaultRuntime.error(
launchAgentRecovery.recovered ? launchAgentRecovery.message : launchAgentRecovery.detail,
);
}
}
if (health.healthy) {
return true;
}
const diagnosticLines = [
"Gateway did not become healthy after restart.",
...renderRestartDiagnostics(health),
...(launchAgentRecovery?.attempted
? [
launchAgentRecovery.recovered
? `LaunchAgent recovery: ${launchAgentRecovery.message}`
: `LaunchAgent recovery failed: ${launchAgentRecovery.detail}`,
]
: []),
`Restart log: ${resolveGatewayRestartLogPath(params.serviceEnv ?? process.env)}`,
`Run \`${replaceCliName(formatCliCommand("openclaw gateway status --deep"), CLI_NAME)}\` for details.`,
...formatPostUpdateGatewayRecoveryInstructions(params.result),
];
if (params.opts.json) {
defaultRuntime.error(diagnosticLines.join("\n"));
} else {
defaultRuntime.log(theme.warn(diagnosticLines[0] ?? "Gateway did not become healthy."));
for (const line of diagnosticLines.slice(1)) {
defaultRuntime.log(theme.muted(line));
}
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return false;
}
return !(health.versionMismatch || health.activatedPluginErrors?.length);
};
if (params.shouldRestart) {
if (!params.opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Restarting service..."));
}
try {
const expectedGatewayVersion = isPackageManagerUpdateMode(params.result.mode)
? normalizeOptionalString(params.result.after?.version)
: undefined;
const isPackageUpdate = isPackageManagerUpdateMode(params.result.mode);
let restarted = false;
let restartInitiated = false;
if (params.refreshServiceEnv) {
try {
await refreshGatewayServiceEnv({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
} catch (err) {
// Always log the refresh failure so callers can detect it (issue #56772).
// Previously this was silently suppressed in --json mode, hiding the root
// cause and preventing auto-update callers from detecting the failure.
const message = `Failed to refresh gateway service environment from updated install: ${String(err)}`;
if (params.opts.json) {
defaultRuntime.error(message);
} else {
defaultRuntime.log(theme.warn(message));
}
if (isPackageUpdate) {
return false;
}
}
}
if (params.restartScriptPath) {
await runRestartScript(params.restartScriptPath);
restartInitiated = true;
} else if (params.refreshServiceEnv && isPackageUpdate) {
restarted = await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
} else if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
restarted = await runDaemonRestart();
} else if (!params.opts.json) {
defaultRuntime.log(theme.muted("No installed gateway service found; skipped restart."));
}
const shouldVerifyRestart =
restartInitiated || (restarted && expectedGatewayVersion !== undefined);
if (shouldVerifyRestart) {
const restartHealthy = await verifyRestartedGateway(expectedGatewayVersion);
if (!restartHealthy) {
if (!params.opts.json) {
defaultRuntime.log("");
}
return false;
}
if (!params.opts.json && restartInitiated) {
defaultRuntime.log(theme.success("Daemon restart completed."));
defaultRuntime.log("");
}
}
if (!params.opts.json && restarted) {
defaultRuntime.log(theme.success("Daemon restarted successfully."));
defaultRuntime.log("");
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
try {
const interactiveDoctor =
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;
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
}
}
} 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)}`,
),
);
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return false;
}
}
return true;
}
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.`,
),
);
}
}
return true;
}
async function runPostCorePluginUpdate(params: {
root: string;
channel: "stable" | "beta" | "dev";
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
timeoutMs: number;
}): Promise<PostCorePluginUpdateResult> {
return await updatePluginsAfterCoreUpdate({
root: params.root,
channel: params.channel,
configSnapshot: params.configSnapshot,
opts: params.opts,
timeoutMs: params.timeoutMs,
});
}
async function persistRequestedUpdateChannel(params: {
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
requestedChannel: "stable" | "beta" | "dev" | null;
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
if (!params.requestedChannel || !params.configSnapshot.valid) {
return params.configSnapshot;
}
const storedChannel = normalizeUpdateChannel(params.configSnapshot.config.update?.channel);
if (params.requestedChannel === storedChannel) {
return params.configSnapshot;
}
const next = {
...params.configSnapshot.sourceConfig,
update: {
...params.configSnapshot.sourceConfig.update,
channel: params.requestedChannel,
},
};
try {
await replaceConfigFile({
nextConfig: next,
baseHash: params.configSnapshot.hash,
});
return createUpdatedChannelSnapshot(params.configSnapshot, next);
} catch (error) {
if (!(error instanceof ConfigMutationConflictError)) {
throw error;
}
}
const refreshed = await readConfigFileSnapshot();
if (!refreshed.valid) {
return refreshed;
}
const refreshedChannel = normalizeUpdateChannel(refreshed.config.update?.channel);
if (refreshedChannel === params.requestedChannel) {
return refreshed;
}
const refreshedNext = {
...refreshed.sourceConfig,
update: {
...refreshed.sourceConfig.update,
channel: params.requestedChannel,
},
};
await replaceConfigFile({
nextConfig: refreshedNext,
baseHash: refreshed.hash,
});
return createUpdatedChannelSnapshot(refreshed, refreshedNext);
}
function createUpdatedChannelSnapshot(
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
next: OpenClawConfig,
): Awaited<ReturnType<typeof readConfigFileSnapshot>> {
if (!snapshot.valid) {
return snapshot;
}
return {
...snapshot,
hash: undefined,
parsed: next,
sourceConfig: asResolvedSourceConfig(next),
resolved: asResolvedSourceConfig(next),
runtimeConfig: asRuntimeConfig(next),
config: asRuntimeConfig(next),
};
}
async function writePostCorePluginUpdateResultFile(
filePath: string | undefined,
result: PostCorePluginUpdateResult,
): Promise<void> {
if (!filePath) {
return;
}
await fs.writeFile(filePath, `${JSON.stringify(result)}\n`, "utf-8");
}
async function readPostCorePluginUpdateResultFile(
filePath: string,
): Promise<PostCorePluginUpdateResult | undefined> {
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as PostCorePluginUpdateResult;
if (
parsed &&
typeof parsed === "object" &&
(parsed.status === "ok" || parsed.status === "skipped" || parsed.status === "error")
) {
return parsed;
}
} catch {
return undefined;
}
return undefined;
}
function stopPostCoreUpdateChild(child: ChildProcess): void {
if (process.platform === "win32" && child.pid) {
try {
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
killer.once("error", () => {
child.kill();
});
return;
} catch {
child.kill();
return;
}
}
child.kill();
}
async function continuePostCoreUpdateInFreshProcess(params: {
root: string;
channel: "stable" | "beta" | "dev";
requestedChannel: "stable" | "beta" | "dev" | null;
opts: UpdateCommandOptions;
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
if (!entryPath) {
return { resumed: false };
}
const argv = [entryPath, "update"];
if (params.opts.json) {
argv.push("--json");
}
if (params.opts.restart === false) {
argv.push("--no-restart");
}
if (params.opts.yes) {
argv.push("--yes");
}
if (params.opts.timeout) {
argv.push("--timeout", params.opts.timeout);
}
const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-"));
const resultPath = path.join(resultDir, "plugins.json");
try {
const child = spawn(resolveNodeRunner(), argv, {
stdio: "inherit",
env: {
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
...(params.requestedChannel
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
: {}),
[POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath,
},
});
const childResult = await new Promise<
| { kind: "exit"; exitCode: number }
| { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult }
>((resolve, reject) => {
let settled = false;
const finish = (
result:
| { kind: "exit"; exitCode: number }
| { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult },
) => {
if (settled) {
return;
}
settled = true;
clearInterval(resultPoll);
resolve(result);
};
const resultPoll = setInterval(() => {
void readPostCorePluginUpdateResultFile(resultPath)
.then((pluginUpdate) => {
if (!pluginUpdate) {
return;
}
stopPostCoreUpdateChild(child);
finish({ kind: "plugin-update", pluginUpdate });
})
.catch(() => undefined);
}, POST_CORE_UPDATE_RESULT_POLL_MS);
child.once("error", (error) => {
if (settled) {
return;
}
settled = true;
clearInterval(resultPoll);
reject(error);
});
child.once("exit", (code, signal) => {
if (settled) {
return;
}
if (signal) {
settled = true;
clearInterval(resultPoll);
reject(new Error(`post-update process terminated by signal ${signal}`));
return;
}
finish({ kind: "exit", exitCode: code ?? 1 });
});
});
const pluginUpdate =
childResult.kind === "plugin-update"
? childResult.pluginUpdate
: await readPostCorePluginUpdateResultFile(resultPath);
const exitCode = childResult.kind === "exit" ? childResult.exitCode : 0;
if (exitCode !== 0) {
if (pluginUpdate) {
return { resumed: true, pluginUpdate };
}
defaultRuntime.exit(exitCode);
throw new Error(`post-update process exited with code ${exitCode}`);
}
return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) };
} finally {
await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined);
}
}
function shouldResumePostCoreUpdateInFreshProcess(params: {
result: UpdateRunResult;
downgradeRisk: boolean;
}): boolean {
if (params.downgradeRisk) {
return false;
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return true;
}
if (params.result.mode !== "git") {
return false;
}
const beforeSha = normalizeOptionalString(params.result.before?.sha);
const afterSha = normalizeOptionalString(params.result.after?.sha);
if (beforeSha && afterSha && beforeSha !== afterSha) {
return true;
}
const beforeVersion = normalizeOptionalString(params.result.before?.version);
const afterVersion = normalizeOptionalString(params.result.after?.version);
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
}
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
suppressDeprecations();
const invocationCwd = tryResolveInvocationCwd();
const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1";
const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim();
const postCoreRequestedChannelInput =
process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? "";
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
const shouldRestart = opts.restart !== false;
if (timeoutMs === null) {
return;
}
const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS;
const root = await resolveUpdateRoot();
if (postCoreUpdateResume) {
if (
postCoreUpdateChannel !== "stable" &&
postCoreUpdateChannel !== "beta" &&
postCoreUpdateChannel !== "dev"
) {
defaultRuntime.error("Missing post-core update channel context.");
defaultRuntime.exit(1);
return;
}
const postCoreRequestedChannel = postCoreRequestedChannelInput
? normalizeUpdateChannel(postCoreRequestedChannelInput)
: null;
if (postCoreRequestedChannelInput && !postCoreRequestedChannel) {
defaultRuntime.error("Invalid post-core requested update channel context.");
defaultRuntime.exit(1);
return;
}
const postCoreConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot: await readConfigFileSnapshot(),
requestedChannel: postCoreRequestedChannel,
});
const pluginUpdate = await runPostCorePluginUpdate({
root,
channel: postCoreUpdateChannel,
configSnapshot: postCoreConfigSnapshot,
opts,
timeoutMs: updateStepTimeoutMs,
});
if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
await writePostCorePluginUpdateResultFile(
process.env[POST_CORE_UPDATE_RESULT_PATH_ENV],
pluginUpdate,
);
}
if (opts.json) {
if (!process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
const result: UpdateRunResult = {
status: pluginUpdate.status === "error" ? "error" : "ok",
mode: "unknown",
root,
steps: [],
durationMs: 0,
postUpdate: { plugins: pluginUpdate },
};
defaultRuntime.writeJson(result);
}
}
if (pluginUpdate.status === "error") {
defaultRuntime.exit(1);
return;
}
defaultRuntime.exit(0);
return;
}
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 = formatConfigIssueLines(configSnapshot.issues, "-");
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 devTargetRef =
channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined;
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;
let packageInstallSpec: string | null = null;
let packageAlreadyCurrent = false;
if (updateInstallKind !== "git") {
currentVersion = switchToPackage ? null : await readPackageVersion(root);
if (explicitTag) {
targetVersion = await resolveTargetVersion(tag, timeoutMs);
} else {
targetVersion = 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;
packageAlreadyCurrent =
updateInstallKind === "package" &&
!switchToPackage &&
currentVersion != null &&
targetVersion != null &&
currentVersion === targetVersion &&
(requestedChannel === null || requestedChannel === storedChannel);
downgradeRisk =
canResolveRegistryVersionForPackageTarget(tag) &&
!fallbackToLatest &&
currentVersion != null &&
(targetVersion == null || (cmp != null && cmp > 0));
packageInstallSpec = resolveGlobalInstallSpec({
packageName: DEFAULT_PACKAGE_NAME,
tag,
env: process.env,
});
}
if (opts.dryRun) {
let mode: UpdateRunResult["mode"] = "unknown";
if (updateInstallKind === "git") {
mode = "git";
} else if (updateInstallKind === "package") {
mode = await resolveGlobalManager({
root,
installKind,
timeoutMs: updateStepTimeoutMs,
});
}
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 if (packageAlreadyCurrent) {
actions.push(
`Refresh package install with spec ${packageInstallSpec ?? tag}; current version already matches ${targetVersion}`,
);
} else {
actions.push(`Run global package manager update with spec ${packageInstallSpec ?? 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).");
}
if (explicitTag && !canResolveRegistryVersionForPackageTarget(tag)) {
notes.push("Non-registry package specs skip npm version lookup and downgrade previews.");
}
printDryRunPreview(
{
dryRun: true,
root,
installKind,
mode,
updateInstallKind,
switchToGit,
switchToPackage,
restart: shouldRestart,
requestedChannel,
storedChannel,
effectiveChannel: channel,
tag: packageInstallSpec ?? 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."),
);
}
if (updateInstallKind === "package") {
const runtimePreflightError = await resolvePackageRuntimePreflightError({
tag,
timeoutMs,
});
if (runtimePreflightError) {
defaultRuntime.error(runtimePreflightError);
defaultRuntime.exit(1);
return;
}
}
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();
let prePackageServiceStop: PrePackageServiceStop | undefined;
if (updateInstallKind === "package") {
try {
prePackageServiceStop = await maybeStopManagedServiceBeforePackageUpdate({
shouldRestart,
jsonMode: Boolean(opts.json),
});
} catch (err) {
stop();
defaultRuntime.error(`Failed to stop managed gateway service before update: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
if (prePackageServiceStop?.blockMessage) {
stop();
defaultRuntime.error(prePackageServiceStop.blockMessage);
defaultRuntime.exit(1);
return;
}
if (shouldBlockPackageUpdateFromGatewayServiceEnv({ prePackageServiceStop })) {
stop();
defaultRuntime.error(
[
"Package updates cannot run from inside the gateway service process.",
"That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
`Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`,
].join("\n"),
);
defaultRuntime.exit(1);
return;
}
}
let result: UpdateRunResult;
try {
result =
updateInstallKind === "package"
? await runPackageInstallUpdate({
root,
installKind,
tag,
timeoutMs: updateStepTimeoutMs,
startedAt,
progress,
jsonMode: Boolean(opts.json),
managedServiceEnv: prePackageServiceStop?.serviceEnv,
invocationCwd,
})
: await runGitUpdate({
root,
switchToGit,
installKind,
timeoutMs,
startedAt,
progress,
channel,
tag,
showProgress,
opts,
stop,
devTargetRef,
});
} catch (err) {
stop();
await maybeRestartServiceAfterFailedPackageUpdate({
prePackageServiceStop,
jsonMode: Boolean(opts.json),
});
throw err;
}
stop();
if (!opts.json || result.status !== "ok") {
printResult(result, { ...opts, hideSteps: showProgress });
}
if (result.status === "error") {
await maybeRestartServiceAfterFailedPackageUpdate({
prePackageServiceStop,
jsonMode: Boolean(opts.json),
});
defaultRuntime.exit(1);
return;
}
if (result.status === "skipped") {
await maybeRestartServiceAfterFailedPackageUpdate({
prePackageServiceStop,
jsonMode: Boolean(opts.json),
});
if (result.reason === "dirty") {
defaultRuntime.error(theme.error("Update blocked: local files are edited in this checkout."));
defaultRuntime.log(
theme.warn(
"Git-based updates need a clean working tree before they can switch commits, fetch, or rebase.",
),
);
defaultRuntime.log(
theme.muted("Commit, stash, or discard the local changes, then rerun `openclaw update`."),
);
}
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;
}
const shouldResumePostCoreInFreshProcess = shouldResumePostCoreUpdateInFreshProcess({
result,
downgradeRisk,
});
let postUpdateConfigSnapshot = configSnapshot;
if (!shouldResumePostCoreInFreshProcess) {
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
if (
requestedChannel &&
configSnapshot.valid &&
requestedChannel !== storedChannel &&
!shouldResumePostCoreInFreshProcess &&
!opts.json
) {
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
} else if (
requestedChannel &&
configSnapshot.valid &&
requestedChannel !== storedChannel &&
shouldResumePostCoreInFreshProcess &&
!opts.json
) {
defaultRuntime.log(theme.muted(`Update channel will be set to ${requestedChannel}.`));
}
const postUpdateRoot = result.root ?? root;
let postCorePluginUpdate: PostCorePluginUpdateResult | undefined;
let pluginsUpdatedInFreshProcess = false;
if (shouldResumePostCoreInFreshProcess) {
const freshProcessResult = await continuePostCoreUpdateInFreshProcess({
root: postUpdateRoot,
channel,
requestedChannel,
opts,
});
pluginsUpdatedInFreshProcess = freshProcessResult.resumed;
postCorePluginUpdate = freshProcessResult.pluginUpdate;
}
if (!pluginsUpdatedInFreshProcess) {
if (shouldResumePostCoreInFreshProcess) {
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
configSnapshot,
requestedChannel,
});
}
postCorePluginUpdate = await runPostCorePluginUpdate({
root: postUpdateRoot,
channel,
configSnapshot: postUpdateConfigSnapshot,
opts,
timeoutMs: updateStepTimeoutMs,
});
}
const resultWithPostUpdate: UpdateRunResult = postCorePluginUpdate
? {
...result,
status: postCorePluginUpdate.status === "error" ? "error" : result.status,
...(postCorePluginUpdate.status === "error" ? { reason: "post-update-plugins" } : {}),
postUpdate: {
...result.postUpdate,
plugins: postCorePluginUpdate,
},
}
: result;
if (postCorePluginUpdate?.status === "error") {
if (opts.json) {
defaultRuntime.writeJson(resultWithPostUpdate);
} else {
defaultRuntime.error(theme.error("Update failed during plugin post-update sync."));
}
await maybeRestartServiceAfterFailedPackageUpdate({
prePackageServiceStop,
jsonMode: Boolean(opts.json),
});
defaultRuntime.exit(1);
return;
}
let restartScriptPath: string | null = null;
let refreshGatewayServiceEnv = false;
let gatewayServiceEnv: NodeJS.ProcessEnv | undefined;
let gatewayPort = resolveUpdatedGatewayRestartPort({
config: postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined,
processEnv: process.env,
});
if (shouldRestart) {
try {
const serviceState = await readGatewayServiceState(resolveGatewayService(), {
env: process.env,
});
if (
shouldPrepareUpdatedInstallRestart({
updateMode: resultWithPostUpdate.mode,
serviceInstalled: serviceState.installed,
serviceLoaded: serviceState.loaded,
})
) {
gatewayServiceEnv = serviceState.env;
gatewayPort = resolveUpdatedGatewayRestartPort({
config: postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined,
processEnv: process.env,
serviceEnv: gatewayServiceEnv,
});
restartScriptPath = await prepareRestartScript(serviceState.env, gatewayPort);
refreshGatewayServiceEnv = true;
}
} catch {
// Ignore errors during pre-check; fallback to standard restart
}
}
await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json));
await tryInstallShellCompletion({
jsonMode: Boolean(opts.json),
skipPrompt: Boolean(opts.yes),
});
const restartOk = await maybeRestartService({
shouldRestart,
result: resultWithPostUpdate,
opts,
refreshServiceEnv: refreshGatewayServiceEnv,
serviceEnv: gatewayServiceEnv,
gatewayPort,
restartScriptPath,
invocationCwd,
});
if (!restartOk) {
defaultRuntime.exit(1);
return;
}
if (!opts.json) {
defaultRuntime.log(theme.muted(pickUpdateQuip()));
} else {
defaultRuntime.writeJson(resultWithPostUpdate);
}
}