Files
openclaw/src/cli/update-cli/update-command-service.ts
Peter Steinberger a89f01abf4 perf(sqlite): fast schema gates, background integrity verification, and downgrade protection (#110271)
* perf(sqlite): gate schema version before integrity scans and quarantine terminal open failures

* feat(gateway): refuse incompatible database schemas at boot and verify integrity in the background

* feat(update): refuse installing builds that cannot open the current databases

* docs: add database schema reference with version history and downgrade guidance

* test(update): tolerate optional runner options in git-mutation mocks

* refactor(update): split npm package-target metadata out of update-check

* chore: model verifier internals for knip and regenerate docs map

* chore(i18n): resync stale iOS localization catalog

* chore: allowlist read-only preflight and verifier raw SQLite access
2026-07-18 05:49:39 +01:00

1508 lines
50 KiB
TypeScript

// Managed gateway service lifecycle before and after an update.
import fs from "node:fs/promises";
import path from "node:path";
import { Writable } from "node:stream";
import { confirm, isCancel } from "@clack/prompts";
import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
} from "../../commands/doctor-completion.js";
import { doctorCommand } from "../../commands/doctor.js";
import { UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV } from "../../commands/doctor/shared/update-phase.js";
import { resolveGatewayPort } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
GATEWAY_SERVICE_RUNTIME_PID_ENV,
} from "../../daemon/constants.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import {
resumeScheduledTaskAutoStartAfterUpdate,
suspendScheduledTaskAutoStartForUpdate,
} from "../../daemon/schtasks.js";
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import {
readGatewayServiceState,
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import { fetchNpmPackageTargetStatus } from "../../infra/update-check-package-target.js";
import { canResolveRegistryVersionForPackageTarget } from "../../infra/update-global.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.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 {
registerSignalExitBarrier,
registerSignalExitGate,
waitForSignalExitBarriers,
} from "../signal-exit-barrier.js";
import { runRestartScript } from "./restart-helper.js";
import { resolveNodeRunner, type UpdateCommandOptions } from "./shared.js";
import { createUpdateConfigSnapshot } from "./update-command-config.js";
const CLI_NAME = resolveCliName();
const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
const POST_REFRESH_ALREADY_HEALTHY_ATTEMPTS = 10;
const POST_REFRESH_ALREADY_HEALTHY_DELAY_MS = 500;
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 JSON_MODE_SERVICE_STDOUT = new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
export function isPackageManagerUpdateMode(
mode: UpdateRunResult["mode"],
): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
serviceLoaded: boolean;
serviceStoppedForUpdate?: boolean;
serviceMatchesMutationRoot?: boolean;
serviceMatchesUpdateRoot?: boolean;
}): boolean {
if (params.serviceMatchesMutationRoot === false) {
return false;
}
if (isPackageManagerUpdateMode(params.updateMode)) {
return params.serviceInstalled;
}
if (params.updateMode === "git" && params.serviceStoppedForUpdate) {
return params.serviceInstalled;
}
if (params.updateMode === "git") {
return params.serviceLoaded && params.serviceMatchesUpdateRoot === true;
}
return params.serviceLoaded;
}
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;
};
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;
};
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,
supervisorKeepsAlive: true,
});
return { health, launchAgentRecovery };
}
async function hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
// polling so an unloaded agent can still reach the existing recovery path promptly.
return await params.service.isLoaded({ env: params.env }).catch(() => false);
}
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
const installCommand = replaceCliName(
formatCliCommand("openclaw gateway install --force"),
CLI_NAME,
);
const statusCommand = replaceCliName(
formatCliCommand("openclaw gateway status --deep"),
CLI_NAME,
);
if (platform === "darwin") {
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
}
if (platform === "linux") {
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
if (platform === "win32") {
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform: NodeJS.Platform = process.platform,
): string[] {
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
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;
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandServiceTestApi")] =
{
formatPostUpdateGatewayRecoveryInstructions,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
hasLoadedLaunchdKeepAliveSupervisor,
shouldUseLegacyProcessRestartAfterUpdate,
};
}
export type PreManagedServiceStop = {
stopped: boolean;
inspected: boolean;
runtimeInspected: boolean;
running: boolean;
serviceMatchesMutationRoot?: boolean;
blockMessage?: string;
serviceEnv?: NodeJS.ProcessEnv;
windowsTaskAutoStartRecovery?: WindowsTaskAutoStartRecovery;
};
type WindowsTaskAutoStartRecovery = {
suspended: Promise<boolean>;
restore: () => Promise<void>;
complete: () => void;
interrupted: () => boolean;
};
export type UpdateCommandRecoveryState = {
windowsTaskAutoStartRecovery?: WindowsTaskAutoStartRecovery;
};
export class UpdateCommandAbort extends Error {
constructor() {
super("openclaw-update-abort");
this.name = "UpdateCommandAbort";
}
}
export function createAggregateErrorWithCause(
errors: unknown[],
message: string,
cause: unknown,
): AggregateError {
return new AggregateError(errors, message, { cause });
}
export type ManagedServiceRootRedirect = {
root: string;
previousRoot: string;
nodeRunner?: string;
};
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 parsePositivePid(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!/^\d+$/u.test(trimmed)) {
return null;
}
return parseStrictPositiveInteger(trimmed) ?? null;
}
function isInheritedGatewayRuntimePid(
pid: number,
env: Record<string, string | undefined> = process.env,
): boolean {
if (!isRunningInsideGatewayService(env)) {
return false;
}
return parsePositivePid(env[GATEWAY_SERVICE_RUNTIME_PID_ENV]) === pid;
}
function isGatewayAncestorPid(
pid: unknown,
env: Record<string, string | undefined> = process.env,
): pid is number {
const parsed = parsePositivePid(pid);
if (parsed === null) {
return false;
}
return isInheritedGatewayRuntimePid(parsed, env) || getSelfAndAncestorPidsSync().has(parsed);
}
function gatewayAncestryBlockMessage(pid: unknown): string | undefined {
return isGatewayAncestorPid(pid) ? formatGatewayAncestryBlockMessage(pid) : undefined;
}
function serviceControlStdoutForMode(jsonMode: boolean): NodeJS.WritableStream {
return jsonMode ? JSON_MODE_SERVICE_STDOUT : process.stdout;
}
function armWindowsTaskAutoStartRecovery(
serviceEnv: NodeJS.ProcessEnv,
): WindowsTaskAutoStartRecovery {
let restorePromise: Promise<void> | undefined;
let unregisterSignalExitBarrier = () => {};
let finishUpdate: (() => void) | undefined;
let interrupted = false;
const updateFinished = new Promise<void>((resolve) => {
finishUpdate = resolve;
});
const unregisterSignalExitGate = registerSignalExitGate(updateFinished);
// Task Scheduler persists the disabled bit beyond this process, so recover it
// before normal signal exits as well as from the update's ordinary paths.
const onSignal = (exitCode: number) => {
interrupted = true;
void waitForSignalExitBarriers()
.catch((err: unknown) => {
defaultRuntime.error(`Failed to complete update shutdown cleanup: ${String(err)}`);
})
.finally(() => {
process.exit(exitCode);
});
};
const onSigint = () => onSignal(130);
const onSigterm = () => onSignal(143);
const onSigbreak = () => onSignal(130);
const removeSignalHandlers = () => {
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
process.off("SIGBREAK", onSigbreak);
unregisterSignalExitBarrier();
};
const complete = () => {
finishUpdate?.();
finishUpdate = undefined;
unregisterSignalExitGate();
};
const restore = () => {
restorePromise ??= suspensionPromise
.then(async (suspended) => {
if (suspended) {
await resumeScheduledTaskAutoStartAfterUpdate(serviceEnv);
}
})
.finally(removeSignalHandlers);
return restorePromise;
};
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
process.on("SIGBREAK", onSigbreak);
unregisterSignalExitBarrier = registerSignalExitBarrier(restore);
// Arm recovery before starting the persistent state change. A signal arriving
// while schtasks is still returning waits for that result before restoring.
const suspensionPromise = suspendScheduledTaskAutoStartForUpdate(serviceEnv);
return { suspended: suspensionPromise, restore, complete, interrupted: () => interrupted };
}
async function abortWindowsTaskUpdateIfInterrupted(
recovery: WindowsTaskAutoStartRecovery,
): Promise<void> {
if (!recovery.interrupted()) {
return;
}
try {
await recovery.restore();
} finally {
recovery.complete();
}
throw new UpdateCommandAbort();
}
async function maybeSuspendWindowsTaskAutoStartForPackageUpdate(params: {
updateInstallKind: "git" | "package";
serviceEnv: NodeJS.ProcessEnv | undefined;
}): Promise<WindowsTaskAutoStartRecovery | undefined> {
if (
params.updateInstallKind !== "package" ||
process.platform !== "win32" ||
!params.serviceEnv
) {
return undefined;
}
const recovery = armWindowsTaskAutoStartRecovery(params.serviceEnv);
let suspended: boolean;
try {
suspended = await recovery.suspended;
} catch (err) {
await recovery.restore().catch(() => undefined);
recovery.complete();
throw err;
}
await abortWindowsTaskUpdateIfInterrupted(recovery);
if (!suspended) {
try {
await recovery.restore();
} finally {
recovery.complete();
}
return undefined;
}
return recovery;
}
export async function maybeResumeWindowsTaskAutoStartAfterPackageUpdate(
stopState: PreManagedServiceStop | undefined,
): Promise<void> {
if (!stopState?.windowsTaskAutoStartRecovery) {
return;
}
// The recovery exists only when this update disabled an enabled task. Clear it
// after use so later failure paths cannot repeat the state change.
await stopState.windowsTaskAutoStartRecovery.restore();
stopState.windowsTaskAutoStartRecovery = undefined;
}
export async function restoreWindowsTaskAutoStartOrExit(
stopState: PreManagedServiceStop | undefined,
): Promise<boolean> {
try {
await maybeResumeWindowsTaskAutoStartAfterPackageUpdate(stopState);
return true;
} catch (err) {
defaultRuntime.error(
`Failed to restore Windows Scheduled Task autostart after package update: ${String(err)}`,
);
defaultRuntime.exit(1);
return false;
}
}
export async function maybeStopManagedServiceBeforeMutableUpdate(params: {
updateInstallKind: "git" | "package";
root: string;
shouldRestart: boolean;
jsonMode: boolean;
}): Promise<PreManagedServiceStop> {
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,
};
}
const serviceMatchesMutationRoot = await gatewayServiceCommandUsesRoot({
root: params.root,
command: serviceState.command,
});
const serviceOwnership =
serviceMatchesMutationRoot === null ? {} : { serviceMatchesMutationRoot };
if (!params.shouldRestart) {
if (!params.jsonMode && serviceState.running) {
defaultRuntime.log(
theme.warn(
`--no-restart is set while the managed gateway service is running; the ${params.updateInstallKind} update will not stop or restart that process.`,
),
);
}
const windowsTaskAutoStartRecovery = isRunningInsideGatewayService()
? undefined
: await maybeSuspendWindowsTaskAutoStartForPackageUpdate({
updateInstallKind: params.updateInstallKind,
serviceEnv: serviceState.env,
});
return {
stopped: false,
inspected: true,
runtimeInspected,
running: serviceState.running,
...serviceOwnership,
serviceEnv: serviceState.env,
...(windowsTaskAutoStartRecovery ? { windowsTaskAutoStartRecovery } : {}),
};
}
if (!runtimeInspected) {
// An inherited gateway process cannot safely update and will be rejected below.
// Do not leave its task disabled while returning that rejection.
const windowsTaskAutoStartRecovery = isRunningInsideGatewayService()
? undefined
: await maybeSuspendWindowsTaskAutoStartForPackageUpdate({
updateInstallKind: params.updateInstallKind,
serviceEnv: serviceState.env,
});
return {
stopped: false,
inspected: true,
runtimeInspected: false,
running: false,
...serviceOwnership,
serviceEnv: serviceState.env,
...(windowsTaskAutoStartRecovery ? { windowsTaskAutoStartRecovery } : {}),
};
}
if (!serviceState.running) {
const windowsTaskAutoStartRecovery = await maybeSuspendWindowsTaskAutoStartForPackageUpdate({
updateInstallKind: params.updateInstallKind,
serviceEnv: serviceState.env,
});
return {
stopped: false,
inspected: true,
runtimeInspected: true,
running: false,
...serviceOwnership,
serviceEnv: serviceState.env,
...(windowsTaskAutoStartRecovery ? { windowsTaskAutoStartRecovery } : {}),
};
}
const blockMessage = gatewayAncestryBlockMessage(serviceState.runtime?.pid);
if (blockMessage) {
return {
stopped: false,
inspected: true,
runtimeInspected: true,
running: true,
...serviceOwnership,
blockMessage,
serviceEnv: serviceState.env,
};
}
if (serviceMatchesMutationRoot === false) {
if (!params.jsonMode) {
defaultRuntime.log(
theme.muted(
`Managed gateway service points at a different OpenClaw root; leaving it running during this ${params.updateInstallKind} update.`,
),
);
}
return {
stopped: false,
inspected: true,
runtimeInspected: true,
running: true,
...serviceOwnership,
serviceEnv: serviceState.env,
};
}
if (!params.jsonMode) {
defaultRuntime.log(
theme.muted(`Stopping managed gateway service before ${params.updateInstallKind} update...`),
);
}
const windowsTaskAutoStartRecovery = await maybeSuspendWindowsTaskAutoStartForPackageUpdate({
updateInstallKind: params.updateInstallKind,
serviceEnv: serviceState.env,
});
try {
await service.stop({
env: serviceState.env,
stdout: serviceControlStdoutForMode(params.jsonMode),
});
if (windowsTaskAutoStartRecovery) {
await abortWindowsTaskUpdateIfInterrupted(windowsTaskAutoStartRecovery);
}
} catch (err) {
if (err instanceof UpdateCommandAbort) {
throw err;
}
if (windowsTaskAutoStartRecovery) {
try {
await windowsTaskAutoStartRecovery.restore();
} catch (resumeErr) {
throw createAggregateErrorWithCause(
[err, resumeErr],
`Failed to stop the managed gateway (${String(err)}) and restore Windows Scheduled Task autostart (${String(resumeErr)})`,
err,
);
} finally {
windowsTaskAutoStartRecovery.complete();
}
if (windowsTaskAutoStartRecovery.interrupted()) {
throw new UpdateCommandAbort();
}
}
throw err;
}
return {
stopped: true,
inspected: true,
runtimeInspected: true,
running: true,
...serviceOwnership,
serviceEnv: serviceState.env,
...(windowsTaskAutoStartRecovery ? { windowsTaskAutoStartRecovery } : {}),
};
}
export async function maybeRestartServiceAfterFailedMutableUpdate(params: {
preManagedServiceStop: PreManagedServiceStop | undefined;
jsonMode: boolean;
}): Promise<void> {
if (!params.preManagedServiceStop?.stopped || !params.preManagedServiceStop.serviceEnv) {
return;
}
try {
await resolveGatewayService().restart({
env: params.preManagedServiceStop.serviceEnv,
stdout: serviceControlStdoutForMode(params.jsonMode),
});
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;
}
export function shouldBlockMutableUpdateFromGatewayServiceEnv(params: {
preManagedServiceStop: PreManagedServiceStop | undefined;
}): boolean {
if (!isRunningInsideGatewayService()) {
return false;
}
const stopState = params.preManagedServiceStop;
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");
}
export function tryResolveInvocationCwd(): string | undefined {
try {
return process.cwd();
} catch {
return undefined;
}
}
type PackageRuntimePreflight = {
nodeRunner?: string;
replacedNodeRunner?: string;
targetVersion?: string;
};
export async function resolvePackageRuntimePreflight(params: {
tag: string;
timeoutMs?: number;
nodeRunner?: string;
fallbackNodeRunner?: string;
spec?: string;
command?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<Result<PackageRuntimePreflight, string>> {
const nodeRunner = normalizeOptionalString(params.nodeRunner);
const unchanged = (): PackageRuntimePreflight => (nodeRunner ? { nodeRunner } : {});
if (!canResolveRegistryVersionForPackageTarget(params.tag)) {
return ok(unchanged());
}
if (params.spec && !canResolveRegistryVersionForPackageTarget(params.spec)) {
return ok(unchanged());
}
const target = params.tag.trim();
if (!target) {
return ok(unchanged());
}
const status = await fetchNpmPackageTargetStatus({
target,
spec: params.spec,
timeoutMs: params.timeoutMs,
command: params.command,
cwd: params.cwd,
env: params.env,
});
if (status.error) {
return ok(unchanged());
}
const runtime = await resolvePackageRuntimeForPreflight({
nodeRunner,
timeoutMs: params.timeoutMs,
});
const satisfies = nodeVersionSatisfiesEngine(runtime.version, status.nodeEngine);
const targetVersion = status.version ?? target;
if (satisfies === true) {
return ok({
...(nodeRunner ? { nodeRunner } : {}),
targetVersion,
});
}
const fallbackNodeRunner = normalizeOptionalString(params.fallbackNodeRunner);
if (nodeRunner && fallbackNodeRunner && fallbackNodeRunner !== nodeRunner) {
const fallbackRuntime = await resolvePackageRuntimeForPreflight({
nodeRunner: fallbackNodeRunner,
timeoutMs: params.timeoutMs,
});
const fallbackSatisfies = nodeVersionSatisfiesEngine(
fallbackRuntime.version,
status.nodeEngine,
);
if (fallbackSatisfies === true) {
return ok({
nodeRunner: fallbackNodeRunner,
replacedNodeRunner: nodeRunner,
targetVersion,
});
}
}
if (satisfies !== false) {
return ok({
...(nodeRunner ? { nodeRunner } : {}),
targetVersion,
});
}
const runtimeLabel = runtime.nodeRunner
? `Node ${runtime.version ?? "unknown"} at ${runtime.nodeRunner}`
: `Node ${runtime.version ?? "unknown"}`;
return resultError(
[
`${runtimeLabel} is too old for openclaw@${targetVersion}.`,
`The requested package requires ${status.nodeEngine}.`,
runtime.nodeRunner
? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`."
: "Upgrade to Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+, 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"),
);
}
async function resolvePackageRuntimeForPreflight(params: {
nodeRunner?: string;
timeoutMs?: number;
}): Promise<{ version: string | null; nodeRunner?: string }> {
const nodeRunner = normalizeOptionalString(params.nodeRunner);
if (!nodeRunner) {
return { version: process.versions.node ?? null };
}
const res = await runCommandWithTimeout([nodeRunner, "--version"], {
timeoutMs: Math.min(params.timeoutMs ?? 10_000, 10_000),
}).catch(() => null);
const rawVersion = res?.code === 0 ? res.stdout.trim() : "";
const version = rawVersion.replace(/^v/u, "") || null;
return { version, nodeRunner };
}
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;
}
export function disableUpdatedPackageCompileCacheEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...env,
NODE_DISABLE_COMPILE_CACHE: "1",
};
}
export function stripGatewayServiceMarkerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const resolvedEnv = { ...env };
delete resolvedEnv.OPENCLAW_SERVICE_MARKER;
delete resolvedEnv.OPENCLAW_SERVICE_KIND;
delete resolvedEnv[GATEWAY_SERVICE_RUNTIME_PID_ENV];
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: NodeJS.ProcessEnv = {
...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);
}
export function resolvePostUpdateServiceStateReadEnv(params: {
updateMode: UpdateRunResult["mode"];
processEnv?: NodeJS.ProcessEnv;
preManagedServiceEnv?: NodeJS.ProcessEnv;
prePackageServiceEnv?: NodeJS.ProcessEnv;
}): NodeJS.ProcessEnv {
if (params.updateMode === "git" && params.preManagedServiceEnv) {
return params.preManagedServiceEnv;
}
if (isPackageManagerUpdateMode(params.updateMode)) {
return (
params.preManagedServiceEnv ?? params.prePackageServiceEnv ?? params.processEnv ?? process.env
);
}
return params.processEnv ?? process.env;
}
async function refreshGatewayServiceEnv(params: {
result: UpdateRunResult;
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
nodeRunner?: string;
}): 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(
[params.nodeRunner ?? 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;
nodeRunner?: string;
timeoutMs: number;
}): 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(
[params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args],
{
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
// Restart health owns migration-aware readiness. Keep only the caller's bounded update
// budget outside it so the former fixed 60-second watchdog cannot preempt that wait.
timeoutMs: params.timeoutMs,
},
);
if (res.code === 0) {
return true;
}
throw new Error(
`updated install restart failed (${entrypoint}): ${formatCommandFailure(res.stdout, res.stderr)}`,
);
}
export async function tryInstallShellCompletion(opts: {
jsonMode: boolean;
skipPrompt: boolean;
}): Promise<void> {
if (opts.jsonMode || !process.stdin.isTTY) {
return;
}
const status = await checkShellCompletionStatus(CLI_NAME);
const generationOptions = { generationMode: "core-only" } as const;
if (status.usesSlowPattern) {
defaultRuntime.log(theme.muted("Upgrading shell completion to cached version..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, generationOptions);
if (cacheGenerated) {
await installShellCompletionForUpdate(status.shell, true);
}
return;
}
if (status.profileInstalled && !status.cacheExists) {
defaultRuntime.log(theme.muted("Regenerating shell completion cache..."));
await ensureCompletionCacheExists(CLI_NAME, generationOptions);
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, generationOptions);
if (!cacheGenerated) {
defaultRuntime.log(theme.warn("Failed to generate completion cache."));
return;
}
await installShellCompletionForUpdate(status.shell, opts.skipPrompt);
}
}
async function installShellCompletionForUpdate(shell: string, yes: boolean): Promise<void> {
try {
await installCompletion(shell, yes, CLI_NAME);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.log(theme.warn(`Shell completion refresh failed: ${message}`));
}
}
async function tryRealpathOrResolve(value: string): Promise<string> {
try {
return await fs.realpath(path.resolve(value));
} catch {
return path.resolve(value);
}
}
function isNodeExecutable(value: string | undefined): boolean {
const base = normalizeOptionalString(value ? path.basename(value) : undefined)?.toLowerCase();
return base === "node" || base === "node.exe";
}
function resolveManagedServiceNodeRunner(
command: GatewayServiceCommandConfig | null,
): string | undefined {
const args = command?.programArguments;
if (!args?.length) {
return undefined;
}
const gatewayIndex = args.indexOf("gateway");
if (gatewayIndex <= 1) {
return undefined;
}
const runner = args[gatewayIndex - 2];
return isNodeExecutable(runner) ? runner : undefined;
}
/**
* Resolve the node binary baked into the managed gateway service unit,
* independent of any package root redirect. This detects when the user's
* current PATH-resolved node differs from the service's baked node even
* when the package root is the same.
*/
export async function resolveManagedServiceNodeRunnerOverride(): Promise<string | undefined> {
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
const serviceNode = resolveManagedServiceNodeRunner(command);
if (!serviceNode) {
return undefined;
}
const currentNode = resolveNodeRunner();
const [serviceNodeReal, currentNodeReal] = await Promise.all([
tryRealpathOrResolve(serviceNode),
tryRealpathOrResolve(currentNode),
]);
if (serviceNodeReal === currentNodeReal) {
return undefined;
}
return serviceNode;
}
export async function resolveManagedServicePackageUpdateRoot(params: {
root: string;
}): Promise<ManagedServiceRootRedirect | null> {
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
const layout = await summarizeGatewayServiceLayout(command);
const serviceRoot = layout?.packageRoot;
if (!serviceRoot || layout.entrypointSourceCheckout === true) {
return null;
}
const [currentRootReal, serviceRootReal] = await Promise.all([
tryRealpathOrResolve(params.root),
tryRealpathOrResolve(serviceRoot),
]);
if (currentRootReal === serviceRootReal) {
return null;
}
const nodeRunner = resolveManagedServiceNodeRunner(command);
return {
root: serviceRoot,
previousRoot: params.root,
...(nodeRunner ? { nodeRunner } : {}),
};
}
export async function gatewayServiceCommandUsesRoot(params: {
root: string | undefined;
env?: NodeJS.ProcessEnv;
command?: GatewayServiceCommandConfig | null;
}): Promise<boolean | null> {
const expectedRoot = normalizeOptionalString(params.root);
if (!expectedRoot) {
return null;
}
const command =
params.command === undefined
? await resolveGatewayService()
.readCommand(params.env ?? process.env)
.catch(() => null)
: params.command;
const layout = await summarizeGatewayServiceLayout(command);
const serviceRoot = layout?.packageRoot;
const serviceEntrypoint = layout?.entrypoint;
if (
!serviceRoot ||
!serviceEntrypoint ||
(!path.isAbsolute(serviceEntrypoint) && !path.win32.isAbsolute(serviceEntrypoint))
) {
return null;
}
const [expectedRootReal, serviceRootReal] = await Promise.all([
tryRealpathOrResolve(expectedRoot),
tryRealpathOrResolve(serviceRoot),
]);
return expectedRootReal === serviceRootReal;
}
export async function maybeRestartService(params: {
shouldRestart: boolean;
result: UpdateRunResult;
opts: UpdateCommandOptions;
refreshServiceEnv: boolean;
serviceEnv?: NodeJS.ProcessEnv;
gatewayPort: number;
restartScriptPath?: string | null;
invocationCwd?: string;
nodeRunner?: string;
skipLegacyServiceRestart?: boolean;
requireRunningServiceAfterRestart?: boolean;
timeoutMs: number;
}): Promise<boolean> {
const verifyRestartedGateway = async (
expectedGatewayVersion: string | undefined,
opts: { requireRunningService?: boolean } = {},
) => {
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,
nodeRunner: params.nodeRunner,
timeoutMs: params.timeoutMs,
});
return;
}
if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
await runDaemonRestart();
}
};
const service = resolveGatewayService();
let supervisorKeepsAlive = await hasLoadedLaunchdKeepAliveSupervisor({
service,
env: params.serviceEnv,
});
let health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
requireRunningService: opts.requireRunningService,
supervisorKeepsAlive,
});
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();
supervisorKeepsAlive = await hasLoadedLaunchdKeepAliveSupervisor({
service,
env: params.serviceEnv,
});
health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
requireRunningService: opts.requireRunningService,
supervisorKeepsAlive,
});
}
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,
);
}
}
const serviceRuntimeHealthy =
!opts.requireRunningService || health.runtime.status === "running";
if (health.healthy && serviceRuntimeHealthy) {
if (!params.opts.json) {
defaultRuntime.log(theme.success("Gateway: restarted and verified."));
}
return true;
}
const diagnosticLines = [
"Gateway did not become healthy after restart.",
...(health.healthy && opts.requireRunningService
? ["Gateway responded, but the managed service did not report running 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) || opts.requireRunningService) {
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);
const canVerifyUpdatedGatewayByVersion =
expectedGatewayVersion !== undefined &&
expectedGatewayVersion !== normalizeOptionalString(params.result.before?.version);
let restarted = false;
let restartInitiated = false;
let refreshedGatewayAlreadyHealthy = false;
let updatedInstallRestartNeedsServiceRootProof = false;
let restartScriptPath = params.restartScriptPath;
if (params.refreshServiceEnv) {
try {
await refreshGatewayServiceEnv({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
if (isPackageUpdate && expectedGatewayVersion) {
const health = await waitForGatewayHealthyRestart({
service: resolveGatewayService(),
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
attempts: POST_REFRESH_ALREADY_HEALTHY_ATTEMPTS,
delayMs: POST_REFRESH_ALREADY_HEALTHY_DELAY_MS,
});
refreshedGatewayAlreadyHealthy = health.healthy;
if (refreshedGatewayAlreadyHealthy && !params.opts.json) {
defaultRuntime.log(
theme.muted(
"Gateway already reports the updated version after service refresh; skipped redundant restart.",
),
);
}
}
} 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) {
restartScriptPath = null;
updatedInstallRestartNeedsServiceRootProof = !canVerifyUpdatedGatewayByVersion;
}
}
}
// Service refresh can bootstrap a RunAtLoad LaunchAgent directly. When
// that already produced the expected gateway version, a second kickstart
// would only race the healthy supervisor-owned process.
if (!refreshedGatewayAlreadyHealthy && restartScriptPath) {
await createUpdateConfigSnapshot();
await runRestartScript(restartScriptPath);
restartInitiated = true;
} else if (!refreshedGatewayAlreadyHealthy && params.refreshServiceEnv && isPackageUpdate) {
await createUpdateConfigSnapshot();
restarted = await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
timeoutMs: params.timeoutMs,
});
if (
updatedInstallRestartNeedsServiceRootProof &&
(await gatewayServiceCommandUsesRoot({
root: params.result.root,
env: params.serviceEnv,
})) !== true
) {
if (!params.opts.json) {
defaultRuntime.log(
theme.warn("Gateway service did not point at the updated install after restart."),
);
}
return false;
}
} else if (
!refreshedGatewayAlreadyHealthy &&
shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode }) &&
!params.skipLegacyServiceRestart
) {
await createUpdateConfigSnapshot();
restarted = await runDaemonRestart();
} else if (!refreshedGatewayAlreadyHealthy && !params.opts.json) {
defaultRuntime.log(theme.muted("Gateway: restart skipped (no installed service found)."));
}
const shouldVerifyRestart =
refreshedGatewayAlreadyHealthy ||
restartInitiated ||
(restarted && expectedGatewayVersion !== undefined);
if (shouldVerifyRestart) {
const requireRunningService =
updatedInstallRestartNeedsServiceRootProof || params.requireRunningServiceAfterRestart;
const restartHealthy = await verifyRestartedGateway(expectedGatewayVersion, {
requireRunningService,
});
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("");
await createUpdateConfigSnapshot();
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(`Gateway: 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) ||
params.requireRunningServiceAfterRestart
) {
return false;
}
}
return true;
}
if (!params.opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.muted("Gateway: restart skipped (--no-restart)."));
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;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */