diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 433a2aa64aff..9a758d4cce5d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -944,7 +944,6 @@ src/infra/state-migrations.test.ts src/infra/update-global.ts src/infra/update-managed-service-handoff.ts src/infra/update-runner.test.ts -src/infra/update-runner.ts src/infra/update-startup.test.ts src/infra/update-startup.ts src/infra/windows-gateway-firewall-diagnostics.ts diff --git a/src/infra/update-runner-command.ts b/src/infra/update-runner-command.ts new file mode 100644 index 000000000000..68fb82fc451d --- /dev/null +++ b/src/infra/update-runner-command.ts @@ -0,0 +1,99 @@ +import { runCommandWithTimeout } from "../process/exec.js"; +import { trimLogTail } from "./restart-sentinel.js"; +import { createGlobalInstallEnv } from "./update-global.js"; +import type { + CommandRunner, + RunStepOptions, + UpdateRunResult, + UpdateStepInfo, + UpdateStepResult, +} from "./update-runner-types.js"; + +export const DEFAULT_TIMEOUT_MS = 20 * 60_000; +export const MAX_LOG_CHARS = 8000; + +function mergeCommandEnvironments( + baseEnv: NodeJS.ProcessEnv | undefined, + overrideEnv: NodeJS.ProcessEnv | undefined, +): NodeJS.ProcessEnv | undefined { + if (!baseEnv) { + return overrideEnv; + } + if (!overrideEnv) { + return baseEnv; + } + return { ...baseEnv, ...overrideEnv }; +} + +export async function runStep(opts: RunStepOptions): Promise { + const { runCommand, name, argv, cwd, timeoutMs, env, progress, stepIndex, totalSteps } = opts; + const command = argv.join(" "); + const stepInfo: UpdateStepInfo = { name, command, index: stepIndex, total: totalSteps }; + progress?.onStepStart?.(stepInfo); + + const started = Date.now(); + const result = await runCommand(argv, { cwd, timeoutMs, env }); + const durationMs = Date.now() - started; + const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS); + + progress?.onStepComplete?.({ + ...stepInfo, + durationMs, + exitCode: result.code, + stderrTail, + signal: result.signal, + killed: result.killed, + termination: result.termination, + }); + + return { + name, + command, + cwd, + durationMs, + exitCode: result.code, + stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), + stderrTail, + signal: result.signal, + killed: result.killed, + termination: result.termination, + }; +} + +export function normalizeFallbackFailureReason( + stepName: string, +): NonNullable { + switch (stepName) { + case "global update": + case "global update (omit optional)": + case "global install stage": + case "global install verify": + case "global install swap": + return "global-install-failed"; + case "openclaw doctor": + return "doctor-failed"; + case "ui:build (post-doctor repair)": + return "ui-build-failed"; + default: + return "unexpected-error"; + } +} + +export async function buildUpdateCommandRunner( + runCommand?: CommandRunner, +): Promise<{ defaultCommandEnv: NodeJS.ProcessEnv | undefined; runCommand: CommandRunner }> { + const defaultCommandEnv = await createGlobalInstallEnv(); + if (runCommand) { + return { defaultCommandEnv, runCommand }; + } + return { + defaultCommandEnv, + runCommand: async (argv, options) => + await runCommandWithTimeout(argv, { + ...options, + env: mergeCommandEnvironments(defaultCommandEnv, options.env), + // Package-manager trees must not outlive a timed-out updater. + killProcessTree: true, + }), + }; +} diff --git a/src/infra/update-runner-doctor.ts b/src/infra/update-runner-doctor.ts new file mode 100644 index 000000000000..60bdb23b2ec7 --- /dev/null +++ b/src/infra/update-runner-doctor.ts @@ -0,0 +1,57 @@ +import { compareSemverStrings } from "./update-check.js"; + +const UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV = + "OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR"; +const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = + "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; +const UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV = + "OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART"; +const UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV = + "OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR"; +const UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV = + "OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION"; +const UPDATE_DOCTOR_SERVICE_REPAIR_POLICY_ENV = "OPENCLAW_SERVICE_REPAIR_POLICY"; +const EXTERNAL_SERVICE_REPAIR_POLICY_MIN_VERSION = "2026.4.25-beta.1"; + +export function resolveUpdateDoctorExecutionPolicy(params: { + targetVersion: string | null; + allowGatewayServiceRepair: boolean; +}): { fix: boolean; serviceRepairPolicy?: "external" } { + if (params.allowGatewayServiceRepair) { + return { fix: true }; + } + const support = compareSemverStrings( + params.targetVersion, + EXTERNAL_SERVICE_REPAIR_POLICY_MIN_VERSION, + ); + if (support !== null && support >= 0) { + return { fix: true, serviceRepairPolicy: "external" }; + } + // Older targets ignore ownership markers and the external-service policy. + return { fix: false }; +} + +export function buildUpdateDoctorEnv(params: { + allowGatewayServiceRepair: boolean; + allowGatewayActivation: boolean; + serviceRepairPolicy?: "external"; + deferConfiguredPluginInstallRepair?: boolean; + compatibilityHostVersion?: string | null; +}): NodeJS.ProcessEnv { + return { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + ...(params.deferConfiguredPluginInstallRepair + ? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" } + : {}), + [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + [UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1", + [UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: params.allowGatewayServiceRepair ? "1" : "0", + [UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV]: params.allowGatewayActivation ? "1" : "0", + ...(params.serviceRepairPolicy + ? { [UPDATE_DOCTOR_SERVICE_REPAIR_POLICY_ENV]: params.serviceRepairPolicy } + : {}), + ...(params.compatibilityHostVersion + ? { OPENCLAW_COMPATIBILITY_HOST_VERSION: params.compatibilityHostVersion } + : {}), + }; +} diff --git a/src/infra/update-runner-git-commands.ts b/src/infra/update-runner-git-commands.ts new file mode 100644 index 000000000000..987a0dca678c --- /dev/null +++ b/src/infra/update-runner-git-commands.ts @@ -0,0 +1,141 @@ +import { DEV_BRANCH } from "./update-channels.js"; +import { + managerInstallIgnoreScriptsArgs, + type UpdatePackageManagerFailureReason, +} from "./update-package-manager.js"; +import type { UpdateRunResult, UpdateStepResult } from "./update-runner-types.js"; + +const BUILD_MAX_OLD_SPACE_MB = 8192; +const DEV_PREFLIGHT_LINT_ENV: NodeJS.ProcessEnv = { + OPENCLAW_LOCAL_CHECK: "1", + OPENCLAW_LOCAL_CHECK_MODE: "throttled", + OPENCLAW_OXLINT_SHARDS_SERIAL: "1", +}; +const DEV_PREFLIGHT_LINT_OPT_IN_ENV = "OPENCLAW_UPDATE_PREFLIGHT_LINT"; + +export function mapManagerResolutionFailure( + reason: UpdatePackageManagerFailureReason, +): NonNullable { + return reason; +} + +export function shouldRetryWindowsInstallIgnoringScripts(manager: "pnpm" | "bun" | "npm"): boolean { + return process.platform === "win32" && manager === "pnpm"; +} + +export function shouldPreferIgnoreScriptsForWindowsPreflight( + manager: "pnpm" | "bun" | "npm", +): boolean { + return process.platform === "win32" && manager === "pnpm"; +} + +function resolveBuildNodeOptions(baseOptions: string | undefined): string { + const current = baseOptions?.trim() ?? ""; + const desired = `--max-old-space-size=${BUILD_MAX_OLD_SPACE_MB}`; + const existingMatch = /(?:^|\s)--max-old-space-size=(\d+)(?=\s|$)/.exec(current); + if (!existingMatch) { + return current ? `${current} ${desired}` : desired; + } + const existingValue = Number(existingMatch[1]); + if (Number.isFinite(existingValue) && existingValue >= BUILD_MAX_OLD_SPACE_MB) { + return current; + } + return current.replace(/(?:^|\s)--max-old-space-size=\d+(?=\s|$)/, ` ${desired}`).trim(); +} + +export function resolveBuildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined { + const currentNodeOptions = env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS; + const nextNodeOptions = resolveBuildNodeOptions(currentNodeOptions); + if (nextNodeOptions === currentNodeOptions) { + return env; + } + return { ...env, NODE_OPTIONS: nextNodeOptions }; +} + +export function resolveInstallEnv( + manager: "pnpm" | "bun" | "npm", + env?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv | undefined { + if (manager !== "pnpm") { + return env; + } + return { + ...env, + PNPM_CONFIG_RESOLUTION_MODE: env?.PNPM_CONFIG_RESOLUTION_MODE ?? "highest", + npm_config_resolution_mode: env?.npm_config_resolution_mode ?? "highest", + pnpm_config_resolution_mode: env?.pnpm_config_resolution_mode ?? "highest", + }; +} + +function isSupersededInstallFailure( + step: UpdateStepResult, + steps: readonly UpdateStepResult[], +): boolean { + if (step.exitCode === 0) { + return false; + } + if (step.name === "deps install") { + return steps.some( + (candidate) => candidate.name === "deps install (ignore scripts)" && candidate.exitCode === 0, + ); + } + const preflightMatch = /^preflight deps install \((.+)\)$/.exec(step.name); + if (!preflightMatch) { + return false; + } + const retryName = `preflight deps install (ignore scripts) (${preflightMatch[1]})`; + return steps.some((candidate) => candidate.name === retryName && candidate.exitCode === 0); +} + +function isPreflightCandidateFailure(step: UpdateStepResult): boolean { + return /^preflight (?:checkout|package manager|deps install(?: \(ignore scripts\))?|build|lint) \(.+\)$/u.test( + step.name, + ); +} + +function isSupersededTargetRefFailure( + step: UpdateStepResult, + followingSteps: readonly UpdateStepResult[], +): boolean { + const isTargetRefProbe = step.name.startsWith("git rev-parse "); + const isTargetTagFetch = step.name.startsWith("git fetch ") && step.name.includes(" refs/tags/"); + const isUpstreamProbe = step.name === "upstream check"; + const isLocalDevBranchProbe = step.name === `git show-ref ${DEV_BRANCH}`; + if (!isTargetRefProbe && !isTargetTagFetch && !isUpstreamProbe && !isLocalDevBranchProbe) { + return false; + } + if (isLocalDevBranchProbe) { + return followingSteps.some( + (candidate) => + candidate.name.startsWith(`git checkout -B ${DEV_BRANCH} `) && candidate.exitCode === 0, + ); + } + return followingSteps.some( + (candidate) => candidate.name.startsWith("git rev-parse ") && candidate.exitCode === 0, + ); +} + +export function findBlockingGitFailure( + steps: readonly UpdateStepResult[], +): UpdateStepResult | undefined { + return steps.find( + (step, index) => + step.exitCode !== 0 && + !isPreflightCandidateFailure(step) && + !isSupersededInstallFailure(step, steps) && + !isSupersededTargetRefFailure(step, steps.slice(index + 1)), + ); +} + +export function shouldRunDevPreflightLint(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env[DEV_PREFLIGHT_LINT_OPT_IN_ENV]?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +export function resolveDevPreflightLintEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + return { ...env, ...DEV_PREFLIGHT_LINT_ENV }; +} + +export function resolveRetryInstallArgs(manager: "pnpm" | "bun" | "npm") { + return managerInstallIgnoreScriptsArgs(manager); +} diff --git a/src/infra/update-runner-git-preflight.ts b/src/infra/update-runner-git-preflight.ts new file mode 100644 index 000000000000..73fa7123c9f5 --- /dev/null +++ b/src/infra/update-runner-git-preflight.ts @@ -0,0 +1,522 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { trimLogTail } from "./restart-sentinel.js"; +import { DEV_BRANCH } from "./update-channels.js"; +import { + managerInstallArgs, + managerInstallIgnoreScriptsArgs, + managerScriptArgs, + resolveUpdateBuildManager, +} from "./update-package-manager.js"; +import { MAX_LOG_CHARS, runStep } from "./update-runner-command.js"; +import { + mapManagerResolutionFailure, + resolveBuildEnv, + resolveDevPreflightLintEnv, + resolveInstallEnv, + resolveRetryInstallArgs, + shouldPreferIgnoreScriptsForWindowsPreflight, + shouldRetryWindowsInstallIgnoringScripts, + shouldRunDevPreflightLint, +} from "./update-runner-git-commands.js"; +import type { + CommandRunner, + RunStepOptions, + UpdateRunResult, + UpdateStepResult, +} from "./update-runner-types.js"; + +const PREFLIGHT_MAX_COMMITS = 10; +const PREFLIGHT_TEMP_PREFIX = + process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-"; +const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree"; +const PREFLIGHT_CLEANUP_TIMEOUT_MS = 60_000; +const WINDOWS_PREFLIGHT_BASE_DIR = "ocu"; + +type StepFactory = ( + name: string, + argv: string[], + cwd: string, + env?: NodeJS.ProcessEnv, +) => RunStepOptions; + +type GitDevPreflightResult = + | { + status: "ok"; + selectedSha: string; + selectedDevUpstream: string | null; + localDevBranchExists: boolean | null; + } + | { status: "error" | "skipped"; reason: NonNullable }; + +function normalizeDevTargetRef(value?: string | null): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function looksLikeFullCommitSha(value: string): boolean { + return /^[0-9a-f]{40}$/i.test(value.trim()); +} + +function resolveTagFetchRef(candidate: string): string | null { + const ref = candidate.endsWith("^{}") ? candidate.slice(0, -"^{}".length) : candidate; + return ref.startsWith("refs/tags/") ? ref : null; +} + +function buildDevTargetRefResolutionCandidates(devTargetRef: string): string[] { + const trimmed = devTargetRef.trim(); + const candidates: string[] = []; + const addCandidate = (candidate?: string | null) => { + if (candidate && !candidates.includes(candidate)) { + candidates.push(candidate); + } + }; + if (looksLikeFullCommitSha(trimmed) || trimmed.startsWith("refs/remotes/")) { + addCandidate(trimmed); + return candidates; + } + if (trimmed.startsWith("refs/heads/")) { + addCandidate(`refs/remotes/origin/${trimmed.slice("refs/heads/".length)}`); + return candidates; + } + if (trimmed.startsWith("origin/")) { + addCandidate(`refs/remotes/${trimmed}`); + return candidates; + } + if (trimmed.startsWith("refs/tags/")) { + addCandidate(`${trimmed}^{}`); + addCandidate(trimmed); + return candidates; + } + // Plain branch names resolve from the freshly fetched remote ref. + addCandidate(`refs/remotes/origin/${trimmed}`); + addCandidate(`refs/tags/${trimmed}^{}`); + addCandidate(`refs/tags/${trimmed}`); + return candidates; +} + +function resolvePreflightWorktreeDir(preflightRoot: string) { + return path.join(preflightRoot, PREFLIGHT_WORKTREE_DIRNAME); +} + +async function createPreflightRoot() { + if (process.platform === "win32" && path.sep === "\\") { + const baseDir = path.win32.join(process.env.SystemDrive ?? "C:", WINDOWS_PREFLIGHT_BASE_DIR); + await fs.mkdir(baseDir, { recursive: true }); + return fs.mkdtemp(path.win32.join(baseDir, PREFLIGHT_TEMP_PREFIX)); + } + return fs.mkdtemp(path.join(os.tmpdir(), PREFLIGHT_TEMP_PREFIX)); +} + +async function removePathRecursive(target: string) { + await fs + .rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + .catch(() => {}); +} + +async function repairPreflightCleanup(worktreeDir: string, preflightRoot: string) { + try { + await fs.rm(worktreeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + await fs.rm(preflightRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + return true; + } catch { + return false; + } +} + +async function resolveExplicitTarget(params: { + devTargetRef: string; + gitRoot: string; + steps: UpdateStepResult[]; + step: StepFactory; +}): Promise { + for (const candidate of buildDevTargetRefResolutionCandidates(params.devTargetRef)) { + const tagFetchRef = resolveTagFetchRef(candidate); + if (tagFetchRef) { + const remoteStep = await runStep( + params.step("git remote", ["git", "-C", params.gitRoot, "remote"], params.gitRoot), + ); + params.steps.push(remoteStep); + const remotes = normalizeStringEntries((remoteStep.stdoutTail ?? "").split("\n")); + let fetchedTag = false; + for (const remote of remotes) { + const fetchStep = await runStep( + params.step( + `git fetch ${remote} ${tagFetchRef}`, + ["git", "-C", params.gitRoot, "fetch", remote, `+${tagFetchRef}:${tagFetchRef}`], + params.gitRoot, + ), + ); + params.steps.push(fetchStep); + if (fetchStep.exitCode === 0) { + fetchedTag = true; + break; + } + } + if (remotes.length > 0 && !fetchedTag) { + continue; + } + } + const shaStep = await runStep( + params.step( + `git rev-parse ${candidate}`, + ["git", "-C", params.gitRoot, "rev-parse", candidate], + params.gitRoot, + ), + ); + params.steps.push(shaStep); + const sha = shaStep.stdoutTail?.trim(); + if (shaStep.exitCode === 0 && sha) { + return sha; + } + } + return null; +} + +async function resolveUpstreamCandidates(params: { + gitRoot: string; + needsCheckoutMain: boolean; + steps: UpdateStepResult[]; + step: StepFactory; +}): Promise< + | { + status: "ok"; + sha: string; + candidates: string[]; + selectedDevUpstream: string | null; + localDevBranchExists: boolean | null; + } + | { status: "error" | "skipped"; reason: NonNullable } +> { + let localDevBranchExists: boolean | null = null; + let remoteBranchRefs: string[] = []; + if (params.needsCheckoutMain) { + const localMainStep = await runStep( + params.step( + `git show-ref ${DEV_BRANCH}`, + ["git", "-C", params.gitRoot, "show-ref", "--verify", `refs/heads/${DEV_BRANCH}`], + params.gitRoot, + ), + ); + params.steps.push(localMainStep); + localDevBranchExists = localMainStep.exitCode === 0; + } + if (params.needsCheckoutMain && localDevBranchExists === false) { + const remoteStep = await runStep( + params.step("git remote", ["git", "-C", params.gitRoot, "remote"], params.gitRoot), + ); + params.steps.push(remoteStep); + if (remoteStep.exitCode === 0) { + remoteBranchRefs = normalizeStringEntries((remoteStep.stdoutTail ?? "").split("\n")).map( + (remote) => `refs/remotes/${remote}/${DEV_BRANCH}`, + ); + } + } + const upstreamRefs = params.needsCheckoutMain + ? [`${DEV_BRANCH}@{upstream}`, ...remoteBranchRefs] + : ["@{upstream}"]; + let upstreamSha: string | null = null; + let selectedDevUpstream: string | null = null; + let sawResolvableUpstreamRef = false; + for (const upstreamRef of upstreamRefs) { + if (upstreamRef.endsWith("@{upstream}")) { + const upstreamStep = await runStep( + params.step( + "upstream check", + [ + "git", + "-C", + params.gitRoot, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + upstreamRef, + ], + params.gitRoot, + ), + ); + params.steps.push(upstreamStep); + if (upstreamStep.exitCode !== 0) { + continue; + } + sawResolvableUpstreamRef = true; + } + const shaStep = await runStep( + params.step( + `git rev-parse ${upstreamRef}`, + ["git", "-C", params.gitRoot, "rev-parse", upstreamRef], + params.gitRoot, + ), + ); + params.steps.push(shaStep); + const sha = shaStep.stdoutTail?.trim(); + if (shaStep.exitCode === 0 && sha) { + upstreamSha = sha; + selectedDevUpstream = /^refs\/remotes\/(.+)$/u.exec(upstreamRef)?.[1] ?? null; + break; + } + if (shaStep.exitCode === 0) { + sawResolvableUpstreamRef = true; + } + } + if (!upstreamSha) { + return sawResolvableUpstreamRef + ? { status: "error", reason: "no-upstream-sha" } + : { status: "skipped", reason: "no-upstream" }; + } + const revListStep = await runStep( + params.step( + "git rev-list", + [ + "git", + "-C", + params.gitRoot, + "rev-list", + `--max-count=${PREFLIGHT_MAX_COMMITS}`, + upstreamSha, + ], + params.gitRoot, + ), + ); + params.steps.push(revListStep); + if (revListStep.exitCode !== 0) { + return { status: "error", reason: "preflight-revlist-failed" }; + } + const candidates = normalizeStringEntries((revListStep.stdoutTail ?? "").split("\n")); + if (candidates.length === 0) { + return { status: "error", reason: "preflight-no-candidates" }; + } + return { + status: "ok", + sha: upstreamSha, + candidates, + selectedDevUpstream, + localDevBranchExists, + }; +} + +async function testPreflightCandidates(params: { + gitRoot: string; + worktreeDir: string; + candidates: string[]; + runCommand: CommandRunner; + timeoutMs: number; + defaultCommandEnv: NodeJS.ProcessEnv | undefined; + steps: UpdateStepResult[]; + step: StepFactory; +}): Promise<{ + selectedSha: string | null; + managerReason: string | null; + sawOtherFailure: boolean; +}> { + let selectedSha: string | null = null; + let managerReason: string | null = null; + let sawOtherFailure = false; + for (const sha of params.candidates) { + const shortSha = sha.slice(0, 8); + const checkoutStep = await runStep( + params.step( + `preflight checkout (${shortSha})`, + ["git", "-C", params.worktreeDir, "checkout", "--detach", sha], + params.worktreeDir, + ), + ); + params.steps.push(checkoutStep); + if (checkoutStep.exitCode !== 0) { + sawOtherFailure = true; + continue; + } + const manager = await resolveUpdateBuildManager( + (argv, options) => + params.runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }), + params.worktreeDir, + params.timeoutMs, + params.defaultCommandEnv, + "require-preferred", + ); + if (manager.kind === "missing-required") { + managerReason = mapManagerResolutionFailure(manager.reason); + params.steps.push({ + name: `preflight package manager (${shortSha})`, + command: `resolve ${manager.preferred} package manager`, + cwd: params.worktreeDir, + durationMs: 0, + exitCode: 1, + stderrTail: managerReason, + }); + continue; + } + try { + const preferIgnoreScripts = shouldPreferIgnoreScriptsForWindowsPreflight(manager.manager); + const ignoreScriptsArgv = managerInstallIgnoreScriptsArgs(manager.manager); + const installArgv = + preferIgnoreScripts && ignoreScriptsArgv + ? ignoreScriptsArgv + : managerInstallArgs(manager.manager, { + compatFallback: manager.fallback && manager.manager === "npm", + }); + const installName = preferIgnoreScripts + ? `preflight deps install (ignore scripts) (${shortSha})` + : `preflight deps install (${shortSha})`; + const installEnv = resolveInstallEnv(manager.manager, manager.env); + let installStep = await runStep( + params.step(installName, installArgv, params.worktreeDir, installEnv), + ); + params.steps.push(installStep); + if ( + installStep.exitCode !== 0 && + !preferIgnoreScripts && + shouldRetryWindowsInstallIgnoringScripts(manager.manager) + ) { + const retryArgv = resolveRetryInstallArgs(manager.manager); + if (retryArgv) { + installStep = await runStep( + params.step( + `preflight deps install (ignore scripts) (${shortSha})`, + retryArgv, + params.worktreeDir, + installEnv, + ), + ); + params.steps.push(installStep); + } + } + if (installStep.exitCode !== 0) { + sawOtherFailure = true; + continue; + } + const buildStep = await runStep( + params.step( + `preflight build (${shortSha})`, + managerScriptArgs(manager.manager, "build"), + params.worktreeDir, + resolveBuildEnv(manager.env), + ), + ); + params.steps.push(buildStep); + if (buildStep.exitCode !== 0) { + sawOtherFailure = true; + continue; + } + if (shouldRunDevPreflightLint()) { + const lintStep = await runStep( + params.step( + `preflight lint (${shortSha})`, + managerScriptArgs(manager.manager, "lint"), + params.worktreeDir, + resolveDevPreflightLintEnv(manager.env), + ), + ); + params.steps.push(lintStep); + if (lintStep.exitCode !== 0) { + sawOtherFailure = true; + continue; + } + } + selectedSha = sha; + break; + } finally { + await manager.cleanup?.(); + } + } + return { selectedSha, managerReason, sawOtherFailure }; +} + +export async function runGitDevPreflight(params: { + gitRoot: string; + devTargetRef?: string; + needsCheckoutMain: boolean; + runCommand: CommandRunner; + timeoutMs: number; + defaultCommandEnv: NodeJS.ProcessEnv | undefined; + steps: UpdateStepResult[]; + step: StepFactory; +}): Promise { + const devTargetRef = normalizeDevTargetRef(params.devTargetRef); + let preflightBaseSha: string; + let candidates: string[]; + let selectedDevUpstream: string | null = null; + let localDevBranchExists: boolean | null = null; + if (devTargetRef) { + const targetSha = await resolveExplicitTarget({ ...params, devTargetRef }); + if (!targetSha) { + return { status: "error", reason: "no-target-sha" }; + } + preflightBaseSha = targetSha; + candidates = [targetSha]; + } else { + const upstream = await resolveUpstreamCandidates(params); + if (upstream.status !== "ok") { + return upstream; + } + preflightBaseSha = upstream.sha; + candidates = upstream.candidates; + selectedDevUpstream = upstream.selectedDevUpstream; + localDevBranchExists = upstream.localDevBranchExists; + } + + const preflightRoot = await createPreflightRoot(); + const worktreeDir = resolvePreflightWorktreeDir(preflightRoot); + const worktreeStep = await runStep( + params.step( + "preflight worktree", + ["git", "-C", params.gitRoot, "worktree", "add", "--detach", worktreeDir, preflightBaseSha], + params.gitRoot, + ), + ); + params.steps.push(worktreeStep); + if (worktreeStep.exitCode !== 0) { + await removePathRecursive(preflightRoot); + return { status: "error", reason: "preflight-worktree-failed" }; + } + + let tested: Awaited>; + try { + tested = await testPreflightCandidates({ ...params, worktreeDir, candidates }); + } finally { + const removeStep = await runStep({ + ...params.step( + "preflight cleanup", + ["git", "-C", params.gitRoot, "worktree", "remove", "--force", worktreeDir], + params.gitRoot, + ), + timeoutMs: Math.min(params.timeoutMs, PREFLIGHT_CLEANUP_TIMEOUT_MS), + }); + if (removeStep.exitCode !== 0 && (await repairPreflightCleanup(worktreeDir, preflightRoot))) { + removeStep.exitCode = 0; + const message = + process.platform === "win32" + ? "windows fallback cleanup removed preflight tree" + : "fallback cleanup removed preflight tree"; + removeStep.stderrTail = trimLogTail( + [removeStep.stderrTail, message].filter(Boolean).join("\n"), + MAX_LOG_CHARS, + ); + } + params.steps.push(removeStep); + await params + .runCommand(["git", "-C", params.gitRoot, "worktree", "prune"], { + cwd: params.gitRoot, + timeoutMs: params.timeoutMs, + }) + .catch(() => null); + await removePathRecursive(preflightRoot); + } + if (!tested.selectedSha) { + return { + status: "error", + reason: + tested.managerReason && !tested.sawOtherFailure + ? tested.managerReason + : "preflight-no-good-commit", + }; + } + return { + status: "ok", + selectedSha: tested.selectedSha, + selectedDevUpstream, + localDevBranchExists, + }; +} diff --git a/src/infra/update-runner-git-target.ts b/src/infra/update-runner-git-target.ts new file mode 100644 index 000000000000..22aeee2d23db --- /dev/null +++ b/src/infra/update-runner-git-target.ts @@ -0,0 +1,107 @@ +import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { + parsePackageOpenClawSchemaVersions, + type OpenClawSchemaVersions, +} from "../state/openclaw-schema-versions.js"; +import { isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js"; +import { compareSemverStrings } from "./update-check.js"; +import type { CommandRunner, UpdateRunnerOptions } from "./update-runner-types.js"; + +type GitTargetSchemaMetadata = + | { status: "ok"; schemaVersions?: OpenClawSchemaVersions } + | { status: "unreadable"; reason: string }; + +async function readGitTargetSchemaVersions(params: { + runCommand: CommandRunner; + root: string; + revision: string; + timeoutMs: number; +}): Promise { + let result: Awaited>; + try { + result = await params.runCommand( + ["git", "-C", params.root, "show", `${params.revision}:package.json`], + { cwd: params.root, timeoutMs: params.timeoutMs }, + ); + } catch (error) { + return { status: "unreadable", reason: String(error) }; + } + if (result.code !== 0) { + return { + status: "unreadable", + reason: `git show ${params.revision}:package.json exited ${result.code}`, + }; + } + try { + const schemaVersions = parsePackageOpenClawSchemaVersions(JSON.parse(result.stdout) as unknown); + return { status: "ok", ...(schemaVersions ? { schemaVersions } : {}) }; + } catch (error) { + return { status: "unreadable", reason: `target package.json unparseable: ${String(error)}` }; + } +} + +export async function prepareGitMutation(params: { + runCommand: CommandRunner; + root: string; + revision: string; + timeoutMs: number; + beforeGitMutation?: UpdateRunnerOptions["beforeGitMutation"]; +}): Promise<{ + allowGatewayServiceRepair?: boolean; + allowGatewayActivation?: boolean; +}> { + const target = await readGitTargetSchemaVersions(params); + const preparation = await params.beforeGitMutation?.( + target.status === "ok" + ? target.schemaVersions + ? { schemaVersions: target.schemaVersions } + : {} + : { metadataUnreadable: target.reason }, + ); + return preparation ?? {}; +} + +export async function readBranchName( + runCommand: CommandRunner, + root: string, + timeoutMs: number, +): Promise { + const result = await runCommand(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], { + timeoutMs, + }).catch(() => null); + const branch = result?.code === 0 ? result.stdout.trim() : ""; + return branch || null; +} + +async function listGitTags( + runCommand: CommandRunner, + root: string, + timeoutMs: number, +): Promise { + const result = await runCommand(["git", "-C", root, "tag", "--list", "v*", "--sort=-v:refname"], { + timeoutMs, + }).catch(() => null); + return result?.code === 0 ? normalizeStringEntries(result.stdout.split("\n")) : []; +} + +export async function resolveChannelTag( + runCommand: CommandRunner, + root: string, + timeoutMs: number, + channel: Exclude, +): Promise { + const tags = await listGitTags(runCommand, root, timeoutMs); + if (channel === "beta") { + const betaTag = tags.find((tag) => isBetaTag(tag)) ?? null; + const stableTag = tags.find((tag) => isStableTag(tag)) ?? null; + if (!betaTag) { + return stableTag; + } + if (!stableTag) { + return betaTag; + } + const comparison = compareSemverStrings(betaTag, stableTag); + return comparison != null && comparison < 0 ? stableTag : betaTag; + } + return tags.find((tag) => isStableTag(tag)) ?? null; +} diff --git a/src/infra/update-runner-git.ts b/src/infra/update-runner-git.ts new file mode 100644 index 000000000000..e5c674f9f351 --- /dev/null +++ b/src/infra/update-runner-git.ts @@ -0,0 +1,500 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + resolveControlUiDistIndexHealth, + resolveControlUiDistIndexPathForRoot, +} from "./control-ui-assets.js"; +import { readPackageVersion } from "./package-json.js"; +import { trimLogTail } from "./restart-sentinel.js"; +import { resolveStableNodePath } from "./stable-node-path.js"; +import { DEV_BRANCH, type UpdateChannel } from "./update-channels.js"; +import { + managerInstallArgs, + managerScriptArgs, + resolveUpdateBuildManager, +} from "./update-package-manager.js"; +import { MAX_LOG_CHARS, normalizeFallbackFailureReason, runStep } from "./update-runner-command.js"; +import { + buildUpdateDoctorEnv, + resolveUpdateDoctorExecutionPolicy, +} from "./update-runner-doctor.js"; +import { + findBlockingGitFailure, + mapManagerResolutionFailure, + resolveBuildEnv, + resolveInstallEnv, + resolveRetryInstallArgs, + shouldRetryWindowsInstallIgnoringScripts, +} from "./update-runner-git-commands.js"; +import { runGitDevPreflight } from "./update-runner-git-preflight.js"; +import { + prepareGitMutation, + readBranchName, + resolveChannelTag, +} from "./update-runner-git-target.js"; +import type { + CommandRunner, + RunStepOptions, + UpdateRunResult, + UpdateRunnerOptions, + UpdateStepResult, +} from "./update-runner-types.js"; + +export async function runGitUpdate(params: { + opts: UpdateRunnerOptions; + gitRoot: string; + runCommand: CommandRunner; + defaultCommandEnv: NodeJS.ProcessEnv | undefined; + timeoutMs: number; + startedAt: number; +}): Promise { + const { opts, gitRoot, runCommand, defaultCommandEnv, timeoutMs, startedAt } = params; + const channel: UpdateChannel = opts.channel ?? "dev"; + if (channel === "extended-stable") { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "unsupported_git_channel", + steps: [], + durationMs: Date.now() - startedAt, + }; + } + + const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], { + cwd: gitRoot, + timeoutMs, + }); + const beforeSha = beforeShaResult.stdout.trim() || null; + const beforeVersion = await readPackageVersion(gitRoot); + const branch = await readBranchName(runCommand, gitRoot, timeoutMs); + const hasDevTargetRef = channel === "dev" && Boolean(opts.devTargetRef?.trim()); + const needsCheckoutMain = channel === "dev" && !hasDevTargetRef && branch !== DEV_BRANCH; + const totalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9; + const steps: UpdateStepResult[] = []; + let stepIndex = 0; + const step = ( + name: string, + argv: string[], + cwd: string, + env?: NodeJS.ProcessEnv, + ): RunStepOptions => ({ + runCommand, + name, + argv, + cwd, + timeoutMs, + env, + progress: opts.progress, + stepIndex: stepIndex++, + totalSteps, + }); + + let allowGatewayServiceRepair = opts.allowGatewayServiceRepair !== false; + let allowGatewayActivation = opts.allowGatewayActivation === true; + let mutationPrepared = false; + let createdDevBranchDuringUpdate = false; + const prepareMutation = async (revision: string) => { + if (mutationPrepared) { + return; + } + const preparation = await prepareGitMutation({ + runCommand, + root: gitRoot, + revision, + timeoutMs, + beforeGitMutation: opts.beforeGitMutation, + }); + if (typeof preparation.allowGatewayServiceRepair === "boolean") { + allowGatewayServiceRepair = preparation.allowGatewayServiceRepair; + } + if (typeof preparation.allowGatewayActivation === "boolean") { + allowGatewayActivation = preparation.allowGatewayActivation; + } + mutationPrepared = true; + }; + const buildError = (reason: string, status: "error" | "skipped" = "error"): UpdateRunResult => ({ + status, + mode: "git", + root: gitRoot, + reason, + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }); + const runRequiredStep = async (name: string, argv: string[], reason: string) => { + const result = await runStep(step(name, argv, gitRoot)); + steps.push(result); + return result.exitCode === 0 ? null : buildError(reason); + }; + const appendRecoveryStep = async (name: string, argv: string[]) => { + const started = Date.now(); + const result = await runCommand(argv, { cwd: gitRoot, timeoutMs }); + steps.push({ + name, + command: argv.join(" "), + cwd: gitRoot, + durationMs: Date.now() - started, + exitCode: result.code, + stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS), + }); + return result.code === 0; + }; + const rollback = async () => { + if (!beforeSha) { + return; + } + await appendRecoveryStep("git rollback clean", ["git", "-C", gitRoot, "reset", "--hard"]); + if (branch && branch !== "HEAD") { + const checkedOut = await appendRecoveryStep("git rollback checkout", [ + "git", + "-C", + gitRoot, + "checkout", + "--force", + branch, + ]); + if (checkedOut) { + await appendRecoveryStep("git rollback reset", [ + "git", + "-C", + gitRoot, + "reset", + "--hard", + beforeSha, + ]); + if (createdDevBranchDuringUpdate) { + await appendRecoveryStep(`git rollback delete ${DEV_BRANCH}`, [ + "git", + "-C", + gitRoot, + "branch", + "-D", + DEV_BRANCH, + ]); + } + } + return; + } + await appendRecoveryStep("git rollback checkout", [ + "git", + "-C", + gitRoot, + "checkout", + "--detach", + beforeSha, + ]); + if (createdDevBranchDuringUpdate) { + await appendRecoveryStep(`git rollback delete ${DEV_BRANCH}`, [ + "git", + "-C", + gitRoot, + "branch", + "-D", + DEV_BRANCH, + ]); + } + }; + const rollbackError = async (reason: string) => { + await rollback(); + return buildError(reason); + }; + + const statusCheck = await runStep( + step( + "clean check", + ["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"], + gitRoot, + ), + ); + steps.push(statusCheck); + if (statusCheck.stdoutTail?.trim()) { + return buildError("dirty", "skipped"); + } + + if (channel === "dev") { + const fetchFailure = await runRequiredStep( + "git fetch", + ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--no-tags"], + "fetch-failed", + ); + if (fetchFailure) { + return fetchFailure; + } + const preflight = await runGitDevPreflight({ + gitRoot, + devTargetRef: opts.devTargetRef, + needsCheckoutMain, + runCommand, + timeoutMs, + defaultCommandEnv, + steps, + step, + }); + if (preflight.status !== "ok") { + return buildError(preflight.reason, preflight.status); + } + await prepareMutation(preflight.selectedSha); + if (hasDevTargetRef) { + const failure = await runRequiredStep( + `git checkout ${preflight.selectedSha}`, + ["git", "-C", gitRoot, "checkout", "--detach", preflight.selectedSha], + "checkout-failed", + ); + if (failure) { + return failure; + } + } else { + let createdAtSelectedSha = false; + if (needsCheckoutMain) { + const hasLocalMain = preflight.localDevBranchExists !== false; + const failure = await runRequiredStep( + hasLocalMain + ? `git checkout ${DEV_BRANCH}` + : `git checkout -B ${DEV_BRANCH} ${preflight.selectedSha}`, + hasLocalMain + ? ["git", "-C", gitRoot, "checkout", DEV_BRANCH] + : ["git", "-C", gitRoot, "checkout", "-B", DEV_BRANCH, preflight.selectedSha], + "checkout-failed", + ); + if (failure) { + return failure; + } + createdAtSelectedSha = !hasLocalMain; + createdDevBranchDuringUpdate = createdAtSelectedSha; + if (createdAtSelectedSha && preflight.selectedDevUpstream) { + const upstreamFailure = await runRequiredStep( + `git branch --set-upstream-to ${preflight.selectedDevUpstream} ${DEV_BRANCH}`, + [ + "git", + "-C", + gitRoot, + "branch", + "--set-upstream-to", + preflight.selectedDevUpstream, + DEV_BRANCH, + ], + "checkout-failed", + ); + if (upstreamFailure) { + return await rollbackError("checkout-failed"); + } + } + } + if (createdAtSelectedSha) { + steps.push({ + name: "git rebase", + command: `git rebase ${preflight.selectedSha}`, + cwd: gitRoot, + durationMs: 0, + exitCode: 0, + stdoutTail: `skipped; ${DEV_BRANCH} was created at selected preflight SHA`, + }); + } else { + const rebaseStep = await runStep( + step("git rebase", ["git", "-C", gitRoot, "rebase", preflight.selectedSha], gitRoot), + ); + steps.push(rebaseStep); + if (rebaseStep.exitCode !== 0) { + const abort = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { + cwd: gitRoot, + timeoutMs, + }); + steps.push({ + name: "git rebase --abort", + command: "git rebase --abort", + cwd: gitRoot, + durationMs: 0, + exitCode: abort.code, + stdoutTail: trimLogTail(abort.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(abort.stderr, MAX_LOG_CHARS), + }); + return buildError("rebase-failed"); + } + } + } + } else { + const fetchFailure = await runRequiredStep( + "git fetch", + ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], + "fetch-failed", + ); + if (fetchFailure) { + return fetchFailure; + } + const tag = await resolveChannelTag(runCommand, gitRoot, timeoutMs, channel); + if (!tag) { + return buildError("no-release-tag"); + } + await prepareMutation(tag); + const failure = await runRequiredStep( + `git checkout ${tag}`, + ["git", "-C", gitRoot, "checkout", "--detach", tag], + "checkout-failed", + ); + if (failure) { + return failure; + } + } + + const manager = await resolveUpdateBuildManager( + (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }), + gitRoot, + timeoutMs, + defaultCommandEnv, + "require-preferred", + ); + if (manager.kind === "missing-required") { + return await rollbackError(mapManagerResolutionFailure(manager.reason)); + } + try { + const installEnv = resolveInstallEnv(manager.manager, manager.env); + let installStep = await runStep( + step( + "deps install", + managerInstallArgs(manager.manager, { + compatFallback: manager.fallback && manager.manager === "npm", + }), + gitRoot, + installEnv, + ), + ); + steps.push(installStep); + if (installStep.exitCode !== 0 && shouldRetryWindowsInstallIgnoringScripts(manager.manager)) { + const retryArgv = resolveRetryInstallArgs(manager.manager); + if (retryArgv) { + installStep = await runStep( + step("deps install (ignore scripts)", retryArgv, gitRoot, installEnv), + ); + steps.push(installStep); + } + } + if (installStep.exitCode !== 0) { + return await rollbackError("deps-install-failed"); + } + const buildStep = await runStep( + step( + "build", + managerScriptArgs(manager.manager, "build"), + gitRoot, + resolveBuildEnv(manager.env), + ), + ); + steps.push(buildStep); + if (buildStep.exitCode !== 0) { + return await rollbackError("build-failed"); + } + const uiBuildStep = await runStep( + step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot, manager.env), + ); + steps.push(uiBuildStep); + if (uiBuildStep.exitCode !== 0) { + return await rollbackError("ui-build-failed"); + } + + const doctorEntry = path.join(gitRoot, "openclaw.mjs"); + const doctorEntryExists = await fs.stat(doctorEntry).then( + () => true, + () => false, + ); + if (!doctorEntryExists) { + steps.push({ + name: "openclaw doctor entry", + command: `verify ${doctorEntry}`, + cwd: gitRoot, + durationMs: 0, + exitCode: 1, + stderrTail: `missing ${doctorEntry}`, + }); + return await rollbackError("doctor-entry-missing"); + } + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorTargetVersion = await readPackageVersion(gitRoot); + const doctorPolicy = resolveUpdateDoctorExecutionPolicy({ + targetVersion: doctorTargetVersion, + allowGatewayServiceRepair, + }); + const doctorStep = await runStep( + step( + "openclaw doctor", + [ + doctorNodePath, + doctorEntry, + "doctor", + "--non-interactive", + ...(doctorPolicy.fix ? ["--fix"] : []), + ], + gitRoot, + buildUpdateDoctorEnv({ + allowGatewayServiceRepair, + allowGatewayActivation, + serviceRepairPolicy: doctorPolicy.serviceRepairPolicy, + deferConfiguredPluginInstallRepair: opts.deferConfiguredPluginInstallRepair, + }), + ), + ); + steps.push(doctorStep); + if (doctorStep.exitCode !== 0) { + return await rollbackError("doctor-failed"); + } + + const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); + if (!uiIndexHealth.exists) { + const repairArgv = managerScriptArgs(manager.manager, "ui:build"); + const repairStarted = Date.now(); + const repairResult = await runCommand(repairArgv, { + cwd: gitRoot, + timeoutMs, + env: manager.env, + }); + steps.push({ + name: "ui:build (post-doctor repair)", + command: repairArgv.join(" "), + cwd: gitRoot, + durationMs: Date.now() - repairStarted, + exitCode: repairResult.code, + stdoutTail: trimLogTail(repairResult.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(repairResult.stderr, MAX_LOG_CHARS), + }); + if (repairResult.code !== 0) { + return await rollbackError("ui-build-failed"); + } + const repairedHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); + if (!repairedHealth.exists) { + const uiIndexPath = + repairedHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(gitRoot); + steps.push({ + name: "ui assets verify", + command: `verify ${uiIndexPath}`, + cwd: gitRoot, + durationMs: 0, + exitCode: 1, + stderrTail: `missing ${uiIndexPath}`, + }); + return await rollbackError("ui-assets-missing"); + } + } + + const failedStep = findBlockingGitFailure(steps); + const afterShaStep = await runStep( + step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot), + ); + steps.push(afterShaStep); + return { + status: failedStep ? "error" : "ok", + mode: "git", + root: gitRoot, + reason: failedStep ? normalizeFallbackFailureReason(failedStep.name) : undefined, + before: { sha: beforeSha, version: beforeVersion }, + after: { + sha: afterShaStep.stdoutTail?.trim() ?? null, + version: await readPackageVersion(gitRoot), + }, + steps, + durationMs: Date.now() - startedAt, + }; + } finally { + await manager.cleanup?.(); + } +} diff --git a/src/infra/update-runner-global.ts b/src/infra/update-runner-global.ts new file mode 100644 index 000000000000..ec32d580f35b --- /dev/null +++ b/src/infra/update-runner-global.ts @@ -0,0 +1,170 @@ +import path from "node:path"; +import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; +import { readPackageName, readPackageVersion } from "./package-json.js"; +import { normalizePackageTagInput } from "./package-tag.js"; +import { runGlobalPackageUpdateSteps } from "./package-update-steps.js"; +import { resolveStableNodePath } from "./stable-node-path.js"; +import { + channelToNpmTag, + DEFAULT_PACKAGE_CHANNEL, + EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, +} from "./update-channels.js"; +import { resolveExtendedStablePackage } from "./update-check.js"; +import { + cleanupGlobalRenameDirs, + createGlobalInstallEnv, + resolveGlobalInstallSpec, + resolveGlobalInstallTarget, + type GlobalInstallManager, +} from "./update-global.js"; +import { normalizeFallbackFailureReason, runStep } from "./update-runner-command.js"; +import { + buildUpdateDoctorEnv, + resolveUpdateDoctorExecutionPolicy, +} from "./update-runner-doctor.js"; +import type { CommandRunner, UpdateRunResult, UpdateRunnerOptions } from "./update-runner-types.js"; + +const DEFAULT_PACKAGE_NAME = "openclaw"; + +function normalizeTag(tag?: string) { + return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest"; +} + +export async function runGlobalUpdate(params: { + opts: UpdateRunnerOptions; + pkgRoot: string; + globalManager: GlobalInstallManager; + runCommand: CommandRunner; + timeoutMs: number; + startedAt: number; + beforeVersion: string | null; + allowGatewayServiceRepair: boolean; + allowGatewayActivation: boolean; +}): Promise { + const { + opts, + pkgRoot, + globalManager, + runCommand, + timeoutMs, + startedAt, + beforeVersion, + allowGatewayServiceRepair, + allowGatewayActivation, + } = params; + const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; + if (channel === "extended-stable" && opts.tag !== undefined) { + return { + status: "error", + mode: globalManager, + root: pkgRoot, + reason: EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, + before: { version: beforeVersion }, + steps: [], + durationMs: Date.now() - startedAt, + }; + } + + const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME; + const installTarget = await resolveGlobalInstallTarget({ + manager: globalManager, + runCommand, + timeoutMs, + pkgRoot, + packageName, + }); + await cleanupGlobalRenameDirs({ globalRoot: path.dirname(pkgRoot), packageName }); + const extendedStable = + channel === "extended-stable" + ? await resolveExtendedStablePackage({ installKind: "package", timeoutMs, packageName }) + : null; + if (extendedStable?.status === "failed") { + return { + status: "error", + mode: globalManager, + root: pkgRoot, + reason: extendedStable.reason, + before: { version: beforeVersion }, + steps: [], + durationMs: Date.now() - startedAt, + }; + } + + const tag = normalizeTag( + extendedStable?.status === "resolved" + ? extendedStable.version + : (opts.tag ?? channelToNpmTag(channel)), + ); + const globalInstallEnv = await createGlobalInstallEnv(); + const spec = + extendedStable?.status === "resolved" + ? extendedStable.packageSpec + : resolveGlobalInstallSpec({ packageName, tag, env: globalInstallEnv }); + + const packageUpdate = await runGlobalPackageUpdateSteps({ + installTarget, + installSpec: spec, + packageName, + packageRoot: pkgRoot, + runCommand, + timeoutMs, + ...(globalInstallEnv === undefined ? {} : { env: globalInstallEnv }), + installCwd: pkgRoot, + runStep: (stepParams) => + runStep({ + runCommand, + ...stepParams, + cwd: stepParams.cwd ?? pkgRoot, + progress: opts.progress, + stepIndex: 0, + totalSteps: 1, + }), + postVerifyStep: async (verifiedPackageRoot) => { + const doctorEntry = await resolveGatewayInstallEntrypoint(verifiedPackageRoot); + if (!doctorEntry) { + return null; + } + const doctorNodePath = await resolveStableNodePath(process.execPath); + const candidateHostVersion = await readPackageVersion(verifiedPackageRoot); + const doctorPolicy = resolveUpdateDoctorExecutionPolicy({ + targetVersion: candidateHostVersion, + allowGatewayServiceRepair, + }); + return await runStep({ + runCommand, + name: "openclaw doctor", + argv: [ + doctorNodePath, + doctorEntry, + "doctor", + "--non-interactive", + ...(doctorPolicy.fix ? ["--fix"] : []), + ], + cwd: verifiedPackageRoot, + timeoutMs, + env: buildUpdateDoctorEnv({ + allowGatewayServiceRepair, + allowGatewayActivation, + serviceRepairPolicy: doctorPolicy.serviceRepairPolicy, + compatibilityHostVersion: candidateHostVersion, + }), + progress: opts.progress, + stepIndex: 0, + totalSteps: 1, + }); + }, + }); + + return { + status: packageUpdate.failedStep ? "error" : "ok", + mode: globalManager, + root: packageUpdate.verifiedPackageRoot ?? pkgRoot, + reason: packageUpdate.failedStep + ? normalizeFallbackFailureReason(packageUpdate.failedStep.name) + : undefined, + before: { version: beforeVersion }, + after: { version: packageUpdate.afterVersion }, + steps: packageUpdate.steps, + durationMs: Date.now() - startedAt, + }; +} diff --git a/src/infra/update-runner-install-surface.ts b/src/infra/update-runner-install-surface.ts new file mode 100644 index 000000000000..83112e30ca7a --- /dev/null +++ b/src/infra/update-runner-install-surface.ts @@ -0,0 +1,151 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { detectGlobalInstallManagerForRoot } from "./update-global.js"; +import { buildUpdateCommandRunner, DEFAULT_TIMEOUT_MS } from "./update-runner-command.js"; +import type { + CommandRunner, + UpdateInstallSurface, + UpdateRunnerOptions, +} from "./update-runner-types.js"; + +const DEFAULT_PACKAGE_NAME = "openclaw"; +const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); + +export function normalizeDir(value?: string | null) { + if (!value) { + return null; + } + const trimmed = value.trim(); + return trimmed ? path.resolve(trimmed) : null; +} + +function resolveNodeModulesBinPackageRoot(argv1: string): string | null { + const normalized = path.resolve(argv1); + const parts = normalized.split(path.sep); + const binIndex = parts.lastIndexOf(".bin"); + if (binIndex <= 0 || parts[binIndex - 1] !== "node_modules") { + return null; + } + const binName = path.basename(normalized); + const nodeModulesDir = parts.slice(0, binIndex).join(path.sep); + return path.join(nodeModulesDir, binName); +} + +export function buildStartDirs(opts: UpdateRunnerOptions): string[] { + const dirs: string[] = []; + const argv1 = normalizeDir(opts.argv1); + if (argv1) { + // The lexical shim identifies its owner; pnpm store realpaths often do not. + dirs.push(path.dirname(argv1)); + const packageRoot = resolveNodeModulesBinPackageRoot(argv1); + if (packageRoot) { + dirs.push(packageRoot); + } + } + const cwd = normalizeDir(opts.cwd); + if (cwd) { + dirs.push(cwd); + } + let processCwd: string | null; + try { + processCwd = normalizeDir(process.cwd()); + } catch { + processCwd = null; + } + if (processCwd) { + dirs.push(processCwd); + } + return uniqueStrings(dirs); +} + +export async function resolveGitRoot( + runCommand: CommandRunner, + candidates: string[], + timeoutMs: number, +): Promise { + for (const dir of candidates) { + const result = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], { + timeoutMs, + }).catch(() => null); + const root = result?.code === 0 ? result.stdout.trim() : ""; + if (root) { + return root; + } + } + return null; +} + +export async function findPackageRoot(candidates: string[]) { + for (const dir of candidates) { + let current = dir; + for (let index = 0; index < 12; index += 1) { + try { + const raw = await fs.readFile(path.join(current, "package.json"), "utf-8"); + const name = (JSON.parse(raw) as { name?: string }).name?.trim(); + if (name && CORE_PACKAGE_NAMES.has(name)) { + return current; + } + } catch { + // Continue walking toward the filesystem root. + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + } + return null; +} + +export async function resolveComparablePath(target: string): Promise { + return await fs.realpath(target).catch(() => path.resolve(target)); +} + +export async function pathsReferToSameLocation(left: string, right: string): Promise { + return (await resolveComparablePath(left)) === (await resolveComparablePath(right)); +} + +export async function looksLikeGitCheckout(root: string): Promise { + try { + await fs.access(path.join(root, ".git")); + return true; + } catch { + return false; + } +} + +export async function resolveUpdateInstallSurface( + opts: Pick = {}, +): Promise { + const { runCommand } = await buildUpdateCommandRunner(opts.runCommand); + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const candidates = buildStartDirs(opts); + const packageRoot = await findPackageRoot(candidates); + + let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); + if (gitRoot && packageRoot && path.resolve(gitRoot) !== path.resolve(packageRoot)) { + gitRoot = null; + } + if (gitRoot && !packageRoot) { + return { kind: "missing", mode: "unknown", root: gitRoot }; + } + if (gitRoot && packageRoot && path.resolve(gitRoot) === path.resolve(packageRoot)) { + return { kind: "git", mode: "git", root: gitRoot, packageRoot }; + } + if (!packageRoot) { + return { kind: "missing", mode: "unknown" }; + } + + const globalManager = await detectGlobalInstallManagerForRoot(runCommand, packageRoot, timeoutMs); + if (globalManager) { + return { + kind: "global", + mode: globalManager, + root: packageRoot, + packageRoot, + }; + } + return { kind: "package-root", mode: "unknown", root: packageRoot, packageRoot }; +} diff --git a/src/infra/update-runner-types.ts b/src/infra/update-runner-types.ts new file mode 100644 index 000000000000..78fe9632e978 --- /dev/null +++ b/src/infra/update-runner-types.ts @@ -0,0 +1,152 @@ +import type { CommandOptions } from "../process/exec.js"; +import type { OpenClawSchemaVersions } from "../state/openclaw-schema-versions.js"; +import type { PackageUpdateStepAdvisory } from "./package-update-steps.js"; +import type { UpdateChannel } from "./update-channels.js"; +import type { GlobalInstallManager } from "./update-global.js"; + +export type UpdateStepAdvisory = PackageUpdateStepAdvisory; + +export type UpdateStepResult = { + name: string; + command: string; + cwd: string; + durationMs: number; + exitCode: number | null; + stdoutTail?: string | null; + stderrTail?: string | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; + advisory?: UpdateStepAdvisory; +}; + +export type UpdateRunResult = { + status: "ok" | "error" | "skipped"; + mode: "git" | "pnpm" | "bun" | "npm" | "unknown"; + root?: string; + reason?: string; + before?: { sha?: string | null; version?: string | null }; + after?: { sha?: string | null; version?: string | null }; + steps: UpdateStepResult[]; + durationMs: number; + postUpdate?: { + plugins?: { + status: "ok" | "warning" | "skipped" | "error"; + reason?: string; + changed: boolean; + warnings?: Array<{ + pluginId?: string; + reason: string; + message: string; + guidance: string[]; + }>; + sync: { + changed: boolean; + switchedToBundled: string[]; + switchedToNpm: string[]; + warnings: string[]; + errors: string[]; + }; + npm: { + changed: boolean; + outcomes: Array<{ + pluginId: string; + status: "updated" | "unchanged" | "skipped" | "error"; + message: string; + currentVersion?: string; + nextVersion?: string; + channelFallback?: { + requestedSpec: string; + usedSpec: string; + requestedLabel: string; + usedLabel: string; + reason: "unavailable" | "failed"; + message: string; + }; + }>; + }; + integrityDrifts: Array<{ + pluginId: string; + spec: string; + expectedIntegrity: string; + actualIntegrity: string; + resolvedSpec?: string; + resolvedVersion?: string; + action: "aborted"; + }>; + }; + }; +}; + +export type CommandRunner = ( + argv: string[], + options: CommandOptions, +) => Promise<{ + stdout: string; + stderr: string; + code: number | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; +}>; + +export type UpdateStepInfo = { + name: string; + command: string; + index: number; + total: number; +}; + +type UpdateStepCompletion = UpdateStepInfo & { + durationMs: number; + exitCode: number | null; + stderrTail?: string | null; + signal?: NodeJS.Signals | null; + killed?: boolean; + termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; + advisory?: UpdateStepAdvisory; +}; + +export type UpdateStepProgress = { + onStepStart?: (step: UpdateStepInfo) => void; + onStepComplete?: (step: UpdateStepCompletion) => void; +}; + +export type UpdateRunnerOptions = { + cwd?: string; + argv1?: string; + tag?: string; + channel?: UpdateChannel; + devTargetRef?: string; + deferConfiguredPluginInstallRepair?: boolean; + allowGatewayServiceRepair?: boolean; + allowGatewayActivation?: boolean; + beforeGitMutation?: (target: { + schemaVersions?: OpenClawSchemaVersions; + metadataUnreadable?: string; + }) => Promise<{ + allowGatewayServiceRepair?: boolean; + allowGatewayActivation?: boolean; + } | void>; + timeoutMs?: number; + runCommand?: CommandRunner; + progress?: UpdateStepProgress; +}; + +export type UpdateInstallSurface = + | { kind: "git"; mode: "git"; root: string; packageRoot: string } + | { kind: "global"; mode: GlobalInstallManager; root: string; packageRoot: string } + | { kind: "package-root"; mode: "unknown"; root: string; packageRoot: string } + | { kind: "missing"; mode: "unknown"; root?: string; packageRoot?: undefined }; + +export type RunStepOptions = { + runCommand: CommandRunner; + name: string; + argv: string[]; + cwd: string; + timeoutMs: number; + env?: NodeJS.ProcessEnv; + progress?: UpdateStepProgress; + stepIndex: number; + totalSteps: number; +}; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index bc8e02bcb5b8..6f59b34f2944 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -1,889 +1,36 @@ +import { readPackageVersion } from "./package-json.js"; // Runs OpenClaw package update checks, package steps, and restart handoff. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +import { detectGlobalInstallManagerForRoot } from "./update-global.js"; +import { buildUpdateCommandRunner, DEFAULT_TIMEOUT_MS } from "./update-runner-command.js"; +import { resolveUpdateDoctorExecutionPolicy } from "./update-runner-doctor.js"; +import { runGitUpdate } from "./update-runner-git.js"; +import { runGlobalUpdate } from "./update-runner-global.js"; import { - normalizeStringEntries, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; -import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; -import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; -import { - parsePackageOpenClawSchemaVersions, - type OpenClawSchemaVersions, -} from "../state/openclaw-schema-versions.js"; -import { - resolveControlUiDistIndexHealth, - resolveControlUiDistIndexPathForRoot, -} from "./control-ui-assets.js"; -import { readPackageName, readPackageVersion } from "./package-json.js"; -import { normalizePackageTagInput } from "./package-tag.js"; -import { - runGlobalPackageUpdateSteps, - type PackageUpdateStepAdvisory, -} from "./package-update-steps.js"; -import { trimLogTail } from "./restart-sentinel.js"; -import { resolveStableNodePath } from "./stable-node-path.js"; -import { - channelToNpmTag, - DEFAULT_PACKAGE_CHANNEL, - DEV_BRANCH, - EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, - isBetaTag, - isStableTag, - type UpdateChannel, -} from "./update-channels.js"; -import { compareSemverStrings, resolveExtendedStablePackage } from "./update-check.js"; -import { - cleanupGlobalRenameDirs, - createGlobalInstallEnv, - detectGlobalInstallManagerForRoot, - resolveGlobalInstallTarget, - resolveGlobalInstallSpec, - type GlobalInstallManager, -} from "./update-global.js"; -import { - managerInstallIgnoreScriptsArgs, - managerInstallArgs, - managerScriptArgs, - resolveUpdateBuildManager, - type UpdatePackageManagerFailureReason, -} from "./update-package-manager.js"; - -export type UpdateStepAdvisory = PackageUpdateStepAdvisory; - -export type UpdateStepResult = { - name: string; - command: string; - cwd: string; - durationMs: number; - exitCode: number | null; - stdoutTail?: string | null; - stderrTail?: string | null; - signal?: NodeJS.Signals | null; - killed?: boolean; - termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; - advisory?: UpdateStepAdvisory; -}; - -export type UpdateRunResult = { - status: "ok" | "error" | "skipped"; - mode: "git" | "pnpm" | "bun" | "npm" | "unknown"; - root?: string; - reason?: string; - before?: { sha?: string | null; version?: string | null }; - after?: { sha?: string | null; version?: string | null }; - steps: UpdateStepResult[]; - durationMs: number; - postUpdate?: { - plugins?: { - status: "ok" | "warning" | "skipped" | "error"; - reason?: string; - changed: boolean; - warnings?: Array<{ - pluginId?: string; - reason: string; - message: string; - guidance: string[]; - }>; - sync: { - changed: boolean; - switchedToBundled: string[]; - switchedToNpm: string[]; - warnings: string[]; - errors: string[]; - }; - npm: { - changed: boolean; - outcomes: Array<{ - pluginId: string; - status: "updated" | "unchanged" | "skipped" | "error"; - message: string; - currentVersion?: string; - nextVersion?: string; - channelFallback?: { - requestedSpec: string; - usedSpec: string; - requestedLabel: string; - usedLabel: string; - reason: "unavailable" | "failed"; - message: string; - }; - }>; - }; - integrityDrifts: Array<{ - pluginId: string; - spec: string; - expectedIntegrity: string; - actualIntegrity: string; - resolvedSpec?: string; - resolvedVersion?: string; - action: "aborted"; - }>; - }; - }; -}; - -type CommandRunner = ( - argv: string[], - options: CommandOptions, -) => Promise<{ - stdout: string; - stderr: string; - code: number | null; - signal?: NodeJS.Signals | null; - killed?: boolean; - termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; -}>; - -export type UpdateStepInfo = { - name: string; - command: string; - index: number; - total: number; -}; - -type UpdateStepCompletion = UpdateStepInfo & { - durationMs: number; - exitCode: number | null; - stderrTail?: string | null; - signal?: NodeJS.Signals | null; - killed?: boolean; - termination?: "exit" | "timeout" | "no-output-timeout" | "signal"; - advisory?: UpdateStepAdvisory; -}; - -export type UpdateStepProgress = { - onStepStart?: (step: UpdateStepInfo) => void; - onStepComplete?: (step: UpdateStepCompletion) => void; -}; - -type UpdateRunnerOptions = { - cwd?: string; - argv1?: string; - tag?: string; - channel?: UpdateChannel; - devTargetRef?: string; - deferConfiguredPluginInstallRepair?: boolean; - allowGatewayServiceRepair?: boolean; - allowGatewayActivation?: boolean; - beforeGitMutation?: (target: { - schemaVersions?: OpenClawSchemaVersions; - metadataUnreadable?: string; - }) => Promise<{ - allowGatewayServiceRepair?: boolean; - allowGatewayActivation?: boolean; - } | void>; - timeoutMs?: number; - runCommand?: CommandRunner; - progress?: UpdateStepProgress; -}; - -type UpdateInstallSurface = - | { - kind: "git"; - mode: "git"; - root: string; - packageRoot: string; - } - | { - kind: "global"; - mode: GlobalInstallManager; - root: string; - packageRoot: string; - } - | { - kind: "package-root"; - mode: "unknown"; - root: string; - packageRoot: string; - } - | { - kind: "missing"; - mode: "unknown"; - root?: string; - packageRoot?: undefined; - }; - -// Only a target we actually read may skip the schema guard as legacy; a failed -// read must abort before mutation or the guard is silently bypassed. -type GitTargetSchemaMetadata = - | { status: "ok"; schemaVersions?: OpenClawSchemaVersions } - | { status: "unreadable"; reason: string }; - -async function readGitTargetSchemaVersions(params: { - runCommand: CommandRunner; - root: string; - revision: string; - timeoutMs: number; -}): Promise { - let result: Awaited>; - try { - result = await params.runCommand( - ["git", "-C", params.root, "show", `${params.revision}:package.json`], - { cwd: params.root, timeoutMs: params.timeoutMs }, - ); - } catch (error) { - return { status: "unreadable", reason: String(error) }; - } - if (result.code !== 0) { - return { - status: "unreadable", - reason: `git show ${params.revision}:package.json exited ${result.code}`, - }; - } - try { - const schemaVersions = parsePackageOpenClawSchemaVersions(JSON.parse(result.stdout) as unknown); - return { status: "ok", ...(schemaVersions ? { schemaVersions } : {}) }; - } catch (error) { - return { status: "unreadable", reason: `target package.json unparseable: ${String(error)}` }; - } -} - -function mapManagerResolutionFailure( - reason: UpdatePackageManagerFailureReason, -): NonNullable { - return reason; -} - -const DEFAULT_TIMEOUT_MS = 20 * 60_000; -const MAX_LOG_CHARS = 8000; -const PREFLIGHT_MAX_COMMITS = 10; -const DEFAULT_PACKAGE_NAME = "openclaw"; -const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); -const UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV = - "OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR"; -const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = - "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; -const UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV = - "OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART"; -const UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV = - "OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR"; -const UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV = - "OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION"; -const UPDATE_DOCTOR_SERVICE_REPAIR_POLICY_ENV = "OPENCLAW_SERVICE_REPAIR_POLICY"; -const EXTERNAL_SERVICE_REPAIR_POLICY_MIN_VERSION = "2026.4.25-beta.1"; -const PREFLIGHT_TEMP_PREFIX = - process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-"; -const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree"; -const PREFLIGHT_CLEANUP_TIMEOUT_MS = 60_000; -const WINDOWS_PREFLIGHT_BASE_DIR = "ocu"; -const BUILD_MAX_OLD_SPACE_MB = 8192; -const DEV_PREFLIGHT_LINT_ENV: NodeJS.ProcessEnv = { - OPENCLAW_LOCAL_CHECK: "1", - OPENCLAW_LOCAL_CHECK_MODE: "throttled", - OPENCLAW_OXLINT_SHARDS_SERIAL: "1", -}; -const DEV_PREFLIGHT_LINT_OPT_IN_ENV = "OPENCLAW_UPDATE_PREFLIGHT_LINT"; - -export function resolveUpdateDoctorExecutionPolicy(params: { - targetVersion: string | null; - allowGatewayServiceRepair: boolean; -}): { fix: boolean; serviceRepairPolicy?: "external" } { - if (params.allowGatewayServiceRepair) { - return { fix: true }; - } - const externalPolicySupport = compareSemverStrings( - params.targetVersion, - EXTERNAL_SERVICE_REPAIR_POLICY_MIN_VERSION, - ); - if (externalPolicySupport !== null && externalPolicySupport >= 0) { - return { fix: true, serviceRepairPolicy: "external" }; - } - // Older targets ignore both ownership markers and the external-service policy. - return { fix: false }; -} - -function normalizeDir(value?: string | null) { - if (!value) { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - return path.resolve(trimmed); -} - -function resolveNodeModulesBinPackageRoot(argv1: string): string | null { - const normalized = path.resolve(argv1); - const parts = normalized.split(path.sep); - const binIndex = parts.lastIndexOf(".bin"); - if (binIndex <= 0) { - return null; - } - if (parts[binIndex - 1] !== "node_modules") { - return null; - } - const binName = path.basename(normalized); - const nodeModulesDir = parts.slice(0, binIndex).join(path.sep); - return path.join(nodeModulesDir, binName); -} - -function buildStartDirs(opts: UpdateRunnerOptions): string[] { - const dirs: string[] = []; - const argv1 = normalizeDir(opts.argv1); - if (argv1) { - // Keep the lexical shim path ahead of a module-derived cwd. pnpm 11 module - // realpaths can point into a shared store that does not identify the owner. - dirs.push(path.dirname(argv1)); - const packageRoot = resolveNodeModulesBinPackageRoot(argv1); - if (packageRoot) { - dirs.push(packageRoot); - } - } - const cwd = normalizeDir(opts.cwd); - if (cwd) { - dirs.push(cwd); - } - let proc: string | null; - try { - proc = normalizeDir(process.cwd()); - } catch { - proc = null; - } - if (proc) { - dirs.push(proc); - } - return uniqueStrings(dirs); -} - -function resolvePreflightTempRootPrefix() { - return path.join(os.tmpdir(), PREFLIGHT_TEMP_PREFIX); -} - -function resolvePreflightWorktreeDir(preflightRoot: string) { - return path.join(preflightRoot, PREFLIGHT_WORKTREE_DIRNAME); -} - -function shouldUseNativeWindowsTempRoot() { - return process.platform === "win32" && path.sep === "\\"; -} - -async function createPreflightRoot() { - if (shouldUseNativeWindowsTempRoot()) { - const baseDir = path.win32.join(process.env.SystemDrive ?? "C:", WINDOWS_PREFLIGHT_BASE_DIR); - await fs.mkdir(baseDir, { recursive: true }); - return fs.mkdtemp(path.win32.join(baseDir, PREFLIGHT_TEMP_PREFIX)); - } - return fs.mkdtemp(resolvePreflightTempRootPrefix()); -} - -async function removePathRecursive(target: string) { - await fs - .rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) - .catch(() => {}); -} - -async function repairPreflightCleanup(worktreeDir: string, preflightRoot: string) { - try { - await fs.rm(worktreeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); - await fs.rm(preflightRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); - return true; - } catch { - return false; - } -} - -async function readBranchName( - runCommand: CommandRunner, - root: string, - timeoutMs: number, -): Promise { - const res = await runCommand(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], { - timeoutMs, - }).catch(() => null); - if (!res || res.code !== 0) { - return null; - } - const branch = res.stdout.trim(); - return branch || null; -} - -async function listGitTags( - runCommand: CommandRunner, - root: string, - timeoutMs: number, - pattern = "v*", -): Promise { - const res = await runCommand(["git", "-C", root, "tag", "--list", pattern, "--sort=-v:refname"], { - timeoutMs, - }).catch(() => null); - if (!res || res.code !== 0) { - return []; - } - return normalizeStringEntries(res.stdout.split("\n")); -} - -async function resolveChannelTag( - runCommand: CommandRunner, - root: string, - timeoutMs: number, - channel: Exclude, -): Promise { - const tags = await listGitTags(runCommand, root, timeoutMs); - if (channel === "beta") { - const betaTag = tags.find((tag) => isBetaTag(tag)) ?? null; - const stableTag = tags.find((tag) => isStableTag(tag)) ?? null; - if (!betaTag) { - return stableTag; - } - if (!stableTag) { - return betaTag; - } - const cmp = compareSemverStrings(betaTag, stableTag); - if (cmp != null && cmp < 0) { - return stableTag; - } - return betaTag; - } - return tags.find((tag) => isStableTag(tag)) ?? null; -} - -async function resolveGitRoot( - runCommand: CommandRunner, - candidates: string[], - timeoutMs: number, -): Promise { - for (const dir of candidates) { - const res = await runCommand(["git", "-C", dir, "rev-parse", "--show-toplevel"], { - timeoutMs, - }).catch(() => null); - if (!res) { - continue; - } - if (res.code === 0) { - const root = res.stdout.trim(); - if (root) { - return root; - } - } - } - return null; -} - -async function findPackageRoot(candidates: string[]) { - for (const dir of candidates) { - let current = dir; - for (let i = 0; i < 12; i += 1) { - const pkgPath = path.join(current, "package.json"); - try { - const raw = await fs.readFile(pkgPath, "utf-8"); - const parsed = JSON.parse(raw) as { name?: string }; - const name = parsed?.name?.trim(); - if (name && CORE_PACKAGE_NAMES.has(name)) { - return current; - } - } catch { - // ignore - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - } - return null; -} - -type RunStepOptions = { - runCommand: CommandRunner; - name: string; - argv: string[]; - cwd: string; - timeoutMs: number; - env?: NodeJS.ProcessEnv; - progress?: UpdateStepProgress; - stepIndex: number; - totalSteps: number; -}; - -async function runStep(opts: RunStepOptions): Promise { - const { runCommand, name, argv, cwd, timeoutMs, env, progress, stepIndex, totalSteps } = opts; - const command = argv.join(" "); - - const stepInfo: UpdateStepInfo = { - name, - command, - index: stepIndex, - total: totalSteps, - }; - - progress?.onStepStart?.(stepInfo); - - const started = Date.now(); - const result = await runCommand(argv, { cwd, timeoutMs, env }); - const durationMs = Date.now() - started; - - const stderrTail = trimLogTail(result.stderr, MAX_LOG_CHARS); - - progress?.onStepComplete?.({ - ...stepInfo, - durationMs, - exitCode: result.code, - stderrTail, - signal: result.signal, - killed: result.killed, - termination: result.termination, - }); - - return { - name, - command, - cwd, - durationMs, - exitCode: result.code, - stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS), - signal: result.signal, - killed: result.killed, - termination: result.termination, - }; -} - -function normalizeTag(tag?: string) { - return normalizePackageTagInput(tag, ["openclaw", DEFAULT_PACKAGE_NAME]) ?? "latest"; -} - -function normalizeDevTargetRef(value?: string | null): string | null { - const trimmed = value?.trim(); - return trimmed ? trimmed : null; -} - -function looksLikeFullCommitSha(value: string): boolean { - return /^[0-9a-f]{40}$/i.test(value.trim()); -} - -function resolveTagFetchRef(candidate: string): string | null { - const ref = candidate.endsWith("^{}") ? candidate.slice(0, -"^{}".length) : candidate; - return ref.startsWith("refs/tags/") ? ref : null; -} - -function buildDevTargetRefResolutionCandidates(devTargetRef: string): string[] { - const trimmed = devTargetRef.trim(); - const candidates: string[] = []; - const addCandidate = (candidate?: string | null) => { - if (!candidate || candidates.includes(candidate)) { - return; - } - candidates.push(candidate); - }; - - if (looksLikeFullCommitSha(trimmed)) { - addCandidate(trimmed); - return candidates; - } - - if (trimmed.startsWith("refs/remotes/")) { - addCandidate(trimmed); - return candidates; - } - - if (trimmed.startsWith("refs/heads/")) { - addCandidate(`refs/remotes/origin/${trimmed.slice("refs/heads/".length)}`); - return candidates; - } - - if (trimmed.startsWith("origin/")) { - addCandidate(`refs/remotes/${trimmed}`); - return candidates; - } - - if (trimmed.startsWith("refs/tags/")) { - addCandidate(`${trimmed}^{}`); - addCandidate(trimmed); - return candidates; - } - - // Resolve plain branch names from the freshly fetched remote ref instead of - // a possibly stale local branch checkout. - addCandidate(`refs/remotes/origin/${trimmed}`); - addCandidate(`refs/tags/${trimmed}^{}`); - addCandidate(`refs/tags/${trimmed}`); - return candidates; -} - -async function resolveComparablePath(target: string): Promise { - return await fs.realpath(target).catch(() => path.resolve(target)); -} - -async function pathsReferToSameLocation(left: string, right: string): Promise { - return (await resolveComparablePath(left)) === (await resolveComparablePath(right)); -} - -async function looksLikeGitCheckout(root: string): Promise { - try { - await fs.access(path.join(root, ".git")); - return true; - } catch { - return false; - } -} - -function shouldRetryWindowsInstallIgnoringScripts(manager: "pnpm" | "bun" | "npm"): boolean { - return process.platform === "win32" && manager === "pnpm"; -} - -function shouldPreferIgnoreScriptsForWindowsPreflight(manager: "pnpm" | "bun" | "npm"): boolean { - return process.platform === "win32" && manager === "pnpm"; -} - -function resolveBuildNodeOptions(baseOptions: string | undefined): string { - const current = baseOptions?.trim() ?? ""; - const desired = `--max-old-space-size=${BUILD_MAX_OLD_SPACE_MB}`; - const existingMatch = /(?:^|\s)--max-old-space-size=(\d+)(?=\s|$)/.exec(current); - if (!existingMatch) { - return current ? `${current} ${desired}` : desired; - } - const existingValue = Number(existingMatch[1]); - if (Number.isFinite(existingValue) && existingValue >= BUILD_MAX_OLD_SPACE_MB) { - return current; - } - return current.replace(/(?:^|\s)--max-old-space-size=\d+(?=\s|$)/, ` ${desired}`).trim(); -} - -function resolveBuildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined { - const currentNodeOptions = env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS; - const nextNodeOptions = resolveBuildNodeOptions(currentNodeOptions); - if (nextNodeOptions === currentNodeOptions) { - return env; - } - return { - ...env, - NODE_OPTIONS: nextNodeOptions, - }; -} - -function resolveInstallEnv( - manager: "pnpm" | "bun" | "npm", - env?: NodeJS.ProcessEnv, -): NodeJS.ProcessEnv | undefined { - if (manager !== "pnpm") { - return env; - } - return { - ...env, - PNPM_CONFIG_RESOLUTION_MODE: env?.PNPM_CONFIG_RESOLUTION_MODE ?? "highest", - npm_config_resolution_mode: env?.npm_config_resolution_mode ?? "highest", - pnpm_config_resolution_mode: env?.pnpm_config_resolution_mode ?? "highest", - }; -} - -function isSupersededInstallFailure( - step: UpdateStepResult, - steps: readonly UpdateStepResult[], -): boolean { - if (step.exitCode === 0) { - return false; - } - if (step.name === "deps install") { - return steps.some( - (candidate) => candidate.name === "deps install (ignore scripts)" && candidate.exitCode === 0, - ); - } - const preflightMatch = /^preflight deps install \((.+)\)$/.exec(step.name); - if (!preflightMatch) { - return false; - } - const retryName = `preflight deps install (ignore scripts) (${preflightMatch[1]})`; - return steps.some((candidate) => candidate.name === retryName && candidate.exitCode === 0); -} - -function isPreflightCandidateFailure(step: UpdateStepResult): boolean { - return /^preflight (?:checkout|package manager|deps install(?: \(ignore scripts\))?|build|lint) \(.+\)$/u.test( - step.name, - ); -} - -function findBlockingGitFailure(steps: readonly UpdateStepResult[]): UpdateStepResult | undefined { - return steps.find( - (step, index) => - step.exitCode !== 0 && - !isPreflightCandidateFailure(step) && - !isSupersededInstallFailure(step, steps) && - !isSupersededTargetRefFailure(step, steps.slice(index + 1)), - ); -} - -function isSupersededTargetRefFailure( - step: UpdateStepResult, - followingSteps: readonly UpdateStepResult[], -): boolean { - const isTargetRefProbe = step.name.startsWith("git rev-parse "); - const isTargetTagFetch = step.name.startsWith("git fetch ") && step.name.includes(" refs/tags/"); - const isUpstreamProbe = step.name === "upstream check"; - const isLocalDevBranchProbe = step.name === `git show-ref ${DEV_BRANCH}`; - if (!isTargetRefProbe && !isTargetTagFetch && !isUpstreamProbe && !isLocalDevBranchProbe) { - return false; - } - if (isLocalDevBranchProbe) { - return followingSteps.some( - (candidate) => - candidate.name.startsWith(`git checkout -B ${DEV_BRANCH} `) && candidate.exitCode === 0, - ); - } - return followingSteps.some( - (candidate) => candidate.name.startsWith("git rev-parse ") && candidate.exitCode === 0, - ); -} - -function mergeCommandEnvironments( - baseEnv: NodeJS.ProcessEnv | undefined, - overrideEnv: NodeJS.ProcessEnv | undefined, -): NodeJS.ProcessEnv | undefined { - if (!baseEnv) { - return overrideEnv; - } - if (!overrideEnv) { - return baseEnv; - } - return { - ...baseEnv, - ...overrideEnv, - }; -} - -function shouldRunDevPreflightLint(env: NodeJS.ProcessEnv = process.env): boolean { - const value = env[DEV_PREFLIGHT_LINT_OPT_IN_ENV]?.trim().toLowerCase(); - return value === "1" || value === "true"; -} - -function resolveDevPreflightLintEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { - return { - ...env, - ...DEV_PREFLIGHT_LINT_ENV, - }; -} - -function normalizeFallbackFailureReason(stepName: string): NonNullable { - switch (stepName) { - case "global update": - case "global update (omit optional)": - case "global install stage": - case "global install verify": - case "global install swap": - return "global-install-failed"; - case "openclaw doctor": - return "doctor-failed"; - case "ui:build (post-doctor repair)": - return "ui-build-failed"; - default: - return "unexpected-error"; - } -} - -async function buildUpdateCommandRunner( - runCommand?: CommandRunner, -): Promise<{ defaultCommandEnv: NodeJS.ProcessEnv | undefined; runCommand: CommandRunner }> { - const defaultCommandEnv = await createGlobalInstallEnv(); - if (runCommand) { - return { - defaultCommandEnv, - runCommand, - }; - } - return { - defaultCommandEnv, - runCommand: async (argv, options) => { - const res = await runCommandWithTimeout(argv, { - ...options, - env: mergeCommandEnvironments(defaultCommandEnv, options.env), - // Update steps invoke package-manager trees; timeout must retire the - // whole tree or detached build workers can outlive the updater. - killProcessTree: true, - }); - return res; - }, - }; -} - -export async function resolveUpdateInstallSurface( - opts: Pick = {}, -): Promise { - const { runCommand } = await buildUpdateCommandRunner(opts.runCommand); - const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const candidates = buildStartDirs(opts); - const pkgRoot = await findPackageRoot(candidates); - - let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); - if (gitRoot && pkgRoot && path.resolve(gitRoot) !== path.resolve(pkgRoot)) { - gitRoot = null; - } - if (gitRoot && !pkgRoot) { - return { - kind: "missing", - mode: "unknown", - root: gitRoot, - }; - } - if (gitRoot && pkgRoot && path.resolve(gitRoot) === path.resolve(pkgRoot)) { - return { - kind: "git", - mode: "git", - root: gitRoot, - packageRoot: pkgRoot, - }; - } - if (!pkgRoot) { - return { - kind: "missing", - mode: "unknown", - }; - } - - const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs); - if (globalManager) { - return { - kind: "global", - mode: globalManager, - root: pkgRoot, - packageRoot: pkgRoot, - }; - } - - return { - kind: "package-root", - mode: "unknown", - root: pkgRoot, - packageRoot: pkgRoot, - }; -} + buildStartDirs, + findPackageRoot, + looksLikeGitCheckout, + normalizeDir, + pathsReferToSameLocation, + resolveComparablePath, + resolveGitRoot, + resolveUpdateInstallSurface, +} from "./update-runner-install-surface.js"; +import type { UpdateRunResult, UpdateRunnerOptions } from "./update-runner-types.js"; + +export type { + UpdateRunResult, + UpdateStepAdvisory, + UpdateStepInfo, + UpdateStepProgress, + UpdateStepResult, +} from "./update-runner-types.js"; +export { resolveUpdateDoctorExecutionPolicy, resolveUpdateInstallSurface }; export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise { const startedAt = Date.now(); const { defaultCommandEnv, runCommand } = await buildUpdateCommandRunner(opts.runCommand); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const progress = opts.progress; - const steps: UpdateStepResult[] = []; const candidates = buildStartDirs(opts); - let allowGatewayServiceRepair = opts.allowGatewayServiceRepair !== false; - let allowGatewayActivation = opts.allowGatewayActivation === true; - - let stepIndex = 0; - let gitTotalSteps = 0; - - const step = ( - name: string, - argv: string[], - cwd: string, - env?: NodeJS.ProcessEnv, - ): RunStepOptions => { - const currentIndex = stepIndex; - stepIndex += 1; - return { - runCommand, - name, - argv, - cwd, - timeoutMs, - env, - progress, - stepIndex: currentIndex, - totalSteps: gitTotalSteps, - }; - }; - const pkgRoot = await findPackageRoot(candidates); let gitRoot = await resolveGitRoot(runCommand, candidates, timeoutMs); @@ -900,7 +47,6 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< if (gitRoot && pkgRoot && !(await pathsReferToSameLocation(gitRoot, pkgRoot))) { gitRoot = null; } - if (gitRoot && !pkgRoot) { return { status: "error", @@ -911,864 +57,16 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< durationMs: Date.now() - startedAt, }; } - if (gitRoot && pkgRoot && (await pathsReferToSameLocation(gitRoot, pkgRoot))) { - const channel: UpdateChannel = opts.channel ?? "dev"; - if (channel === "extended-stable") { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "unsupported_git_channel", - steps: [], - durationMs: Date.now() - startedAt, - }; - } - // Get current SHA (not a visible step, no progress) - const beforeShaResult = await runCommand(["git", "-C", gitRoot, "rev-parse", "HEAD"], { - cwd: gitRoot, - timeoutMs, - }); - const beforeSha = beforeShaResult.stdout.trim() || null; - const beforeVersion = await readPackageVersion(gitRoot); - const devTargetRef = channel === "dev" ? normalizeDevTargetRef(opts.devTargetRef) : null; - const branch = await readBranchName(runCommand, gitRoot, timeoutMs); - const needsCheckoutMain = channel === "dev" && !devTargetRef && branch !== DEV_BRANCH; - gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9; - let gitMutationPrepared = false; - let createdDevBranchDuringUpdate = false; - let localDevBranchExists: boolean | null = null; - const prepareGitMutation = async (targetRevision: string) => { - if (gitMutationPrepared) { - return; - } - const targetMetadata = await readGitTargetSchemaVersions({ - runCommand, - root: gitRoot, - revision: targetRevision, - timeoutMs, - }); - const preparation = await opts.beforeGitMutation?.( - targetMetadata.status === "ok" - ? targetMetadata.schemaVersions - ? { schemaVersions: targetMetadata.schemaVersions } - : {} - : { metadataUnreadable: targetMetadata.reason }, - ); - if (typeof preparation?.allowGatewayServiceRepair === "boolean") { - allowGatewayServiceRepair = preparation.allowGatewayServiceRepair; - } - if (typeof preparation?.allowGatewayActivation === "boolean") { - allowGatewayActivation = preparation.allowGatewayActivation; - } - gitMutationPrepared = true; - }; - const buildGitErrorResult = (reason: string): UpdateRunResult => ({ - status: "error", - mode: "git", - root: gitRoot, - reason, - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }); - const runRequiredGitStep = async (name: string, argv: string[], reason: string) => { - const gitStep = await runStep(step(name, argv, gitRoot)); - steps.push(gitStep); - if (gitStep.exitCode !== 0) { - return buildGitErrorResult(reason); - } - return null; - }; - const appendRecoveryStep = async (name: string, argv: string[]) => { - const started = Date.now(); - const result = await runCommand(argv, { cwd: gitRoot, timeoutMs }); - const recoveryStep: UpdateStepResult = { - name, - command: argv.join(" "), - cwd: gitRoot, - durationMs: Date.now() - started, - exitCode: result.code, - stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS), - }; - steps.push(recoveryStep); - return recoveryStep.exitCode === 0; - }; - const rollbackGitCheckout = async () => { - if (!beforeSha) { - return; - } - await appendRecoveryStep("git rollback clean", ["git", "-C", gitRoot, "reset", "--hard"]); - if (branch && branch !== "HEAD") { - const checkedOutBranch = await appendRecoveryStep("git rollback checkout", [ - "git", - "-C", - gitRoot, - "checkout", - "--force", - branch, - ]); - if (checkedOutBranch) { - await appendRecoveryStep("git rollback reset", [ - "git", - "-C", - gitRoot, - "reset", - "--hard", - beforeSha, - ]); - if (createdDevBranchDuringUpdate) { - await appendRecoveryStep(`git rollback delete ${DEV_BRANCH}`, [ - "git", - "-C", - gitRoot, - "branch", - "-D", - DEV_BRANCH, - ]); - } - } - return; - } - await appendRecoveryStep("git rollback checkout", [ - "git", - "-C", - gitRoot, - "checkout", - "--detach", - beforeSha, - ]); - if (createdDevBranchDuringUpdate) { - await appendRecoveryStep(`git rollback delete ${DEV_BRANCH}`, [ - "git", - "-C", - gitRoot, - "branch", - "-D", - DEV_BRANCH, - ]); - } - }; - const buildGitErrorResultWithRollback = async (reason: string): Promise => { - await rollbackGitCheckout(); - return buildGitErrorResult(reason); - }; - - const statusCheck = await runStep( - step( - "clean check", - ["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"], - gitRoot, - ), - ); - steps.push(statusCheck); - const hasUncommittedChanges = - statusCheck.stdoutTail && statusCheck.stdoutTail.trim().length > 0; - if (hasUncommittedChanges) { - return { - status: "skipped", - mode: "git", - root: gitRoot, - reason: "dirty", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - if (channel === "dev") { - const fetchFailure = await runRequiredGitStep( - "git fetch", - ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--no-tags"], - "fetch-failed", - ); - if (fetchFailure) { - return fetchFailure; - } - let preflightBaseSha: string | null; - let candidatesLocal: string[]; - let selectedDevUpstream: string | null = null; - if (devTargetRef) { - let targetSha: string | null = null; - for (const targetRefCandidate of buildDevTargetRefResolutionCandidates(devTargetRef)) { - const tagFetchRef = resolveTagFetchRef(targetRefCandidate); - if (tagFetchRef) { - const remoteListStep = await runStep( - step("git remote", ["git", "-C", gitRoot, "remote"], gitRoot), - ); - steps.push(remoteListStep); - const remotes = normalizeStringEntries((remoteListStep.stdoutTail ?? "").split("\n")); - let fetchedTag = false; - for (const remote of remotes) { - const targetTagFetchStep = await runStep( - step( - `git fetch ${remote} ${tagFetchRef}`, - ["git", "-C", gitRoot, "fetch", remote, `+${tagFetchRef}:${tagFetchRef}`], - gitRoot, - ), - ); - steps.push(targetTagFetchStep); - if (targetTagFetchStep.exitCode === 0) { - fetchedTag = true; - break; - } - } - if (remotes.length > 0 && !fetchedTag) { - continue; - } - } - const targetShaStep = await runStep( - step( - `git rev-parse ${targetRefCandidate}`, - ["git", "-C", gitRoot, "rev-parse", targetRefCandidate], - gitRoot, - ), - ); - steps.push(targetShaStep); - const resolvedTargetSha = targetShaStep.stdoutTail?.trim(); - if (targetShaStep.exitCode === 0 && resolvedTargetSha) { - targetSha = resolvedTargetSha; - break; - } - } - if (!targetSha) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "no-target-sha", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - preflightBaseSha = targetSha; - candidatesLocal = [targetSha]; - } else { - if (needsCheckoutMain) { - const localMainStep = await runStep( - step( - `git show-ref ${DEV_BRANCH}`, - ["git", "-C", gitRoot, "show-ref", "--verify", `refs/heads/${DEV_BRANCH}`], - gitRoot, - ), - ); - steps.push(localMainStep); - localDevBranchExists = localMainStep.exitCode === 0; - } - let remoteBranchRefs: string[] = []; - if (needsCheckoutMain && localDevBranchExists === false) { - const remoteStep = await runStep( - step("git remote", ["git", "-C", gitRoot, "remote"], gitRoot), - ); - steps.push(remoteStep); - if (remoteStep.exitCode === 0) { - remoteBranchRefs = normalizeStringEntries( - (remoteStep.stdoutTail ?? "").split("\n"), - ).map((remote) => `refs/remotes/${remote}/${DEV_BRANCH}`); - } - } - const upstreamRefs = needsCheckoutMain - ? [`${DEV_BRANCH}@{upstream}`, ...remoteBranchRefs] - : ["@{upstream}"]; - let upstreamSha: string | null = null; - let sawResolvableUpstreamRef = false; - for (const upstreamRef of upstreamRefs) { - if (upstreamRef.endsWith("@{upstream}")) { - const upstreamStep = await runStep( - step( - "upstream check", - [ - "git", - "-C", - gitRoot, - "rev-parse", - "--abbrev-ref", - "--symbolic-full-name", - upstreamRef, - ], - gitRoot, - ), - ); - steps.push(upstreamStep); - if (upstreamStep.exitCode !== 0) { - continue; - } - sawResolvableUpstreamRef = true; - } - - const upstreamShaStep = await runStep( - step( - `git rev-parse ${upstreamRef}`, - ["git", "-C", gitRoot, "rev-parse", upstreamRef], - gitRoot, - ), - ); - steps.push(upstreamShaStep); - const candidateSha = upstreamShaStep.stdoutTail?.trim(); - if (upstreamShaStep.exitCode === 0 && candidateSha) { - upstreamSha = candidateSha; - const remoteBranchMatch = /^refs\/remotes\/(.+)$/u.exec(upstreamRef); - selectedDevUpstream = remoteBranchMatch?.[1] ?? null; - break; - } - if (upstreamShaStep.exitCode === 0) { - sawResolvableUpstreamRef = true; - } - } - if (!upstreamSha && !sawResolvableUpstreamRef) { - return { - status: "skipped", - mode: "git", - root: gitRoot, - reason: "no-upstream", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - if (!upstreamSha) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "no-upstream-sha", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - const revListStep = await runStep( - step( - "git rev-list", - ["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha], - gitRoot, - ), - ); - steps.push(revListStep); - if (revListStep.exitCode !== 0) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "preflight-revlist-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - candidatesLocal = normalizeStringEntries((revListStep.stdoutTail ?? "").split("\n")); - if (candidatesLocal.length === 0) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "preflight-no-candidates", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - preflightBaseSha = upstreamSha; - } - if (!preflightBaseSha) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "preflight-base-missing", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - const preflightRoot = await createPreflightRoot(); - const worktreeDir = resolvePreflightWorktreeDir(preflightRoot); - const worktreeStep = await runStep( - step( - "preflight worktree", - ["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, preflightBaseSha], - gitRoot, - ), - ); - steps.push(worktreeStep); - if (worktreeStep.exitCode !== 0) { - await removePathRecursive(preflightRoot); - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "preflight-worktree-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - let selectedSha: string | null = null; - let preflightManagerFailureReason: NonNullable | null = null; - let sawNonManagerPreflightFailure = false; - try { - for (const sha of candidatesLocal) { - const shortSha = sha.slice(0, 8); - const checkoutStep = await runStep( - step( - `preflight checkout (${shortSha})`, - ["git", "-C", worktreeDir, "checkout", "--detach", sha], - worktreeDir, - ), - ); - steps.push(checkoutStep); - if (checkoutStep.exitCode !== 0) { - sawNonManagerPreflightFailure = true; - continue; - } - - const manager = await resolveUpdateBuildManager( - (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }), - worktreeDir, - timeoutMs, - defaultCommandEnv, - "require-preferred", - ); - if (manager.kind === "missing-required") { - preflightManagerFailureReason = mapManagerResolutionFailure(manager.reason); - steps.push({ - name: `preflight package manager (${shortSha})`, - command: `resolve ${manager.preferred} package manager`, - cwd: worktreeDir, - durationMs: 0, - exitCode: 1, - stderrTail: preflightManagerFailureReason, - }); - continue; - } - try { - const preflightIgnoreScripts = shouldPreferIgnoreScriptsForWindowsPreflight( - manager.manager, - ); - const preflightIgnoreScriptsArgv = managerInstallIgnoreScriptsArgs(manager.manager); - const depsStepArgv = - preflightIgnoreScripts && preflightIgnoreScriptsArgv - ? preflightIgnoreScriptsArgv - : managerInstallArgs(manager.manager, { - compatFallback: manager.fallback && manager.manager === "npm", - }); - const depsStepName = preflightIgnoreScripts - ? `preflight deps install (ignore scripts) (${shortSha})` - : `preflight deps install (${shortSha})`; - const installEnv = resolveInstallEnv(manager.manager, manager.env); - const depsStep = await runStep( - step(depsStepName, depsStepArgv, worktreeDir, installEnv), - ); - steps.push(depsStep); - let finalDepsStep = depsStep; - if ( - depsStep.exitCode !== 0 && - !preflightIgnoreScripts && - shouldRetryWindowsInstallIgnoringScripts(manager.manager) - ) { - const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager); - if (retryArgv) { - const retryStep = await runStep( - step( - `preflight deps install (ignore scripts) (${shortSha})`, - retryArgv, - worktreeDir, - installEnv, - ), - ); - steps.push(retryStep); - finalDepsStep = retryStep; - } - } - if (finalDepsStep.exitCode !== 0) { - sawNonManagerPreflightFailure = true; - continue; - } - - const buildStep = await runStep( - step( - `preflight build (${shortSha})`, - managerScriptArgs(manager.manager, "build"), - worktreeDir, - resolveBuildEnv(manager.env), - ), - ); - steps.push(buildStep); - if (buildStep.exitCode !== 0) { - sawNonManagerPreflightFailure = true; - continue; - } - - if (shouldRunDevPreflightLint()) { - const lintStep = await runStep( - step( - `preflight lint (${shortSha})`, - managerScriptArgs(manager.manager, "lint"), - worktreeDir, - resolveDevPreflightLintEnv(manager.env), - ), - ); - steps.push(lintStep); - if (lintStep.exitCode !== 0) { - sawNonManagerPreflightFailure = true; - continue; - } - } - - selectedSha = sha; - break; - } finally { - await manager.cleanup?.(); - } - } - } finally { - const removeStep = await runStep({ - ...step( - "preflight cleanup", - ["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir], - gitRoot, - ), - timeoutMs: Math.min(timeoutMs, PREFLIGHT_CLEANUP_TIMEOUT_MS), - }); - if ( - removeStep.exitCode !== 0 && - (await repairPreflightCleanup(worktreeDir, preflightRoot)) - ) { - removeStep.exitCode = 0; - const fallbackMessage = - process.platform === "win32" - ? "windows fallback cleanup removed preflight tree" - : "fallback cleanup removed preflight tree"; - removeStep.stderrTail = trimLogTail( - [removeStep.stderrTail, fallbackMessage].filter(Boolean).join("\n"), - MAX_LOG_CHARS, - ); - } - steps.push(removeStep); - await runCommand(["git", "-C", gitRoot, "worktree", "prune"], { - cwd: gitRoot, - timeoutMs, - }).catch(() => null); - await removePathRecursive(preflightRoot); - } - - if (!selectedSha) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: - preflightManagerFailureReason && !sawNonManagerPreflightFailure - ? preflightManagerFailureReason - : "preflight-no-good-commit", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - if (devTargetRef) { - await prepareGitMutation(selectedSha); - const failure = await runRequiredGitStep( - `git checkout ${selectedSha}`, - ["git", "-C", gitRoot, "checkout", "--detach", selectedSha], - "checkout-failed", - ); - if (failure) { - return failure; - } - } else { - await prepareGitMutation(selectedSha); - let checkedOutSelectedSha = false; - if (needsCheckoutMain) { - const hasLocalDevBranch = localDevBranchExists !== false; - const failure = await runRequiredGitStep( - hasLocalDevBranch - ? `git checkout ${DEV_BRANCH}` - : `git checkout -B ${DEV_BRANCH} ${selectedSha}`, - hasLocalDevBranch - ? ["git", "-C", gitRoot, "checkout", DEV_BRANCH] - : ["git", "-C", gitRoot, "checkout", "-B", DEV_BRANCH, selectedSha], - "checkout-failed", - ); - if (failure) { - return failure; - } - checkedOutSelectedSha = !hasLocalDevBranch; - createdDevBranchDuringUpdate = checkedOutSelectedSha; - if (checkedOutSelectedSha && selectedDevUpstream) { - const upstreamFailure = await runRequiredGitStep( - `git branch --set-upstream-to ${selectedDevUpstream} ${DEV_BRANCH}`, - [ - "git", - "-C", - gitRoot, - "branch", - "--set-upstream-to", - selectedDevUpstream, - DEV_BRANCH, - ], - "checkout-failed", - ); - if (upstreamFailure) { - return await buildGitErrorResultWithRollback("checkout-failed"); - } - } - } - if (checkedOutSelectedSha) { - steps.push({ - name: "git rebase", - command: `git rebase ${selectedSha}`, - cwd: gitRoot, - durationMs: 0, - exitCode: 0, - stdoutTail: `skipped; ${DEV_BRANCH} was created at selected preflight SHA`, - }); - } else { - const rebaseStep = await runStep( - step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot), - ); - steps.push(rebaseStep); - if (rebaseStep.exitCode !== 0) { - const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { - cwd: gitRoot, - timeoutMs, - }); - steps.push({ - name: "git rebase --abort", - command: "git rebase --abort", - cwd: gitRoot, - durationMs: 0, - exitCode: abortResult.code, - stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), - }); - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "rebase-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - } - } - } else { - const fetchFailure = await runRequiredGitStep( - "git fetch", - ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], - "fetch-failed", - ); - if (fetchFailure) { - return fetchFailure; - } - - const tag = await resolveChannelTag(runCommand, gitRoot, timeoutMs, channel); - if (!tag) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "no-release-tag", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - await prepareGitMutation(tag); - const failure = await runRequiredGitStep( - `git checkout ${tag}`, - ["git", "-C", gitRoot, "checkout", "--detach", tag], - "checkout-failed", - ); - if (failure) { - return failure; - } - } - - const manager = await resolveUpdateBuildManager( - (argv, options) => runCommand(argv, { timeoutMs: options.timeoutMs, env: options.env }), + return await runGitUpdate({ + opts, gitRoot, - timeoutMs, + runCommand, defaultCommandEnv, - "require-preferred", - ); - if (manager.kind === "missing-required") { - return await buildGitErrorResultWithRollback(mapManagerResolutionFailure(manager.reason)); - } - try { - const installEnv = resolveInstallEnv(manager.manager, manager.env); - const depsStep = await runStep( - step( - "deps install", - managerInstallArgs(manager.manager, { - compatFallback: manager.fallback && manager.manager === "npm", - }), - gitRoot, - installEnv, - ), - ); - steps.push(depsStep); - let finalDepsStep = depsStep; - if (depsStep.exitCode !== 0 && shouldRetryWindowsInstallIgnoringScripts(manager.manager)) { - const retryArgv = managerInstallIgnoreScriptsArgs(manager.manager); - if (retryArgv) { - const retryStep = await runStep( - step("deps install (ignore scripts)", retryArgv, gitRoot, installEnv), - ); - steps.push(retryStep); - finalDepsStep = retryStep; - } - } - if (finalDepsStep.exitCode !== 0) { - return await buildGitErrorResultWithRollback("deps-install-failed"); - } - - const buildStep = await runStep( - step( - "build", - managerScriptArgs(manager.manager, "build"), - gitRoot, - resolveBuildEnv(manager.env), - ), - ); - steps.push(buildStep); - if (buildStep.exitCode !== 0) { - return await buildGitErrorResultWithRollback("build-failed"); - } - - const uiBuildStep = await runStep( - step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot, manager.env), - ); - steps.push(uiBuildStep); - if (uiBuildStep.exitCode !== 0) { - return await buildGitErrorResultWithRollback("ui-build-failed"); - } - - const doctorEntry = path.join(gitRoot, "openclaw.mjs"); - const doctorEntryExists = await fs - .stat(doctorEntry) - .then(() => true) - .catch(() => false); - if (!doctorEntryExists) { - steps.push({ - name: "openclaw doctor entry", - command: `verify ${doctorEntry}`, - cwd: gitRoot, - durationMs: 0, - exitCode: 1, - stderrTail: `missing ${doctorEntry}`, - }); - return await buildGitErrorResultWithRollback("doctor-entry-missing"); - } - - const doctorNodePath = await resolveStableNodePath(process.execPath); - const doctorTargetVersion = await readPackageVersion(gitRoot); - const doctorPolicy = resolveUpdateDoctorExecutionPolicy({ - targetVersion: doctorTargetVersion, - allowGatewayServiceRepair, - }); - const doctorArgv = [ - doctorNodePath, - doctorEntry, - "doctor", - "--non-interactive", - ...(doctorPolicy.fix ? ["--fix"] : []), - ]; - const doctorStep = await runStep( - step("openclaw doctor", doctorArgv, gitRoot, { - OPENCLAW_UPDATE_IN_PROGRESS: "1", - ...(opts.deferConfiguredPluginInstallRepair - ? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" } - : {}), - [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", - [UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1", - [UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: allowGatewayServiceRepair ? "1" : "0", - [UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV]: allowGatewayActivation ? "1" : "0", - ...(doctorPolicy.serviceRepairPolicy - ? { [UPDATE_DOCTOR_SERVICE_REPAIR_POLICY_ENV]: doctorPolicy.serviceRepairPolicy } - : {}), - }), - ); - steps.push(doctorStep); - if (doctorStep.exitCode !== 0) { - return await buildGitErrorResultWithRollback("doctor-failed"); - } - - const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); - if (!uiIndexHealth.exists) { - const repairArgv = managerScriptArgs(manager.manager, "ui:build"); - const started = Date.now(); - const repairResult = await runCommand(repairArgv, { - cwd: gitRoot, - timeoutMs, - env: manager.env, - }); - const repairStep: UpdateStepResult = { - name: "ui:build (post-doctor repair)", - command: repairArgv.join(" "), - cwd: gitRoot, - durationMs: Date.now() - started, - exitCode: repairResult.code, - stdoutTail: trimLogTail(repairResult.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(repairResult.stderr, MAX_LOG_CHARS), - }; - steps.push(repairStep); - - if (repairResult.code !== 0) { - return await buildGitErrorResultWithRollback("ui-build-failed"); - } - - const repairedUiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); - if (!repairedUiIndexHealth.exists) { - const uiIndexPath = - repairedUiIndexHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(gitRoot); - steps.push({ - name: "ui assets verify", - command: `verify ${uiIndexPath}`, - cwd: gitRoot, - durationMs: 0, - exitCode: 1, - stderrTail: `missing ${uiIndexPath}`, - }); - return await buildGitErrorResultWithRollback("ui-assets-missing"); - } - } - - const failedStep = findBlockingGitFailure(steps); - const afterShaStep = await runStep( - step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot), - ); - steps.push(afterShaStep); - const afterVersion = await readPackageVersion(gitRoot); - - return { - status: failedStep ? "error" : "ok", - mode: "git", - root: gitRoot, - reason: failedStep ? normalizeFallbackFailureReason(failedStep.name) : undefined, - before: { sha: beforeSha, version: beforeVersion }, - after: { - sha: afterShaStep.stdoutTail?.trim() ?? null, - version: afterVersion, - }, - steps, - durationMs: Date.now() - startedAt, - }; - } finally { - await manager.cleanup?.(); - } + timeoutMs, + startedAt, + }); } - if (!pkgRoot) { return { status: "error", @@ -1782,139 +80,18 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const beforeVersion = await readPackageVersion(pkgRoot); const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs); if (globalManager) { - const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; - if (channel === "extended-stable" && opts.tag !== undefined) { - return { - status: "error", - mode: globalManager, - root: pkgRoot, - reason: EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, - before: { version: beforeVersion }, - steps: [], - durationMs: Date.now() - startedAt, - }; - } - const packageName = (await readPackageName(pkgRoot)) ?? DEFAULT_PACKAGE_NAME; - const installTarget = await resolveGlobalInstallTarget({ - manager: globalManager, - runCommand, - timeoutMs, + return await runGlobalUpdate({ + opts, pkgRoot, - packageName, - }); - await cleanupGlobalRenameDirs({ - globalRoot: path.dirname(pkgRoot), - packageName, - }); - const extendedStable = - channel === "extended-stable" - ? await resolveExtendedStablePackage({ - installKind: "package", - timeoutMs, - packageName, - }) - : null; - if (extendedStable?.status === "failed") { - return { - status: "error", - mode: globalManager, - root: pkgRoot, - reason: extendedStable.reason, - before: { version: beforeVersion }, - steps: [], - durationMs: Date.now() - startedAt, - }; - } - const tag = normalizeTag( - extendedStable?.status === "resolved" - ? extendedStable.version - : (opts.tag ?? channelToNpmTag(channel)), - ); - const globalInstallEnv = await createGlobalInstallEnv(); - const spec = - extendedStable?.status === "resolved" - ? extendedStable.packageSpec - : resolveGlobalInstallSpec({ - packageName, - tag, - env: globalInstallEnv, - }); - const packageUpdate = await runGlobalPackageUpdateSteps({ - installTarget, - installSpec: spec, - packageName, - packageRoot: pkgRoot, + globalManager, runCommand, timeoutMs, - ...(globalInstallEnv === undefined ? {} : { env: globalInstallEnv }), - installCwd: pkgRoot, - runStep: (stepParams) => - runStep({ - runCommand, - ...stepParams, - cwd: stepParams.cwd ?? pkgRoot, - progress, - stepIndex: 0, - totalSteps: 1, - }), - postVerifyStep: async (verifiedPackageRoot) => { - const doctorEntry = await resolveGatewayInstallEntrypoint(verifiedPackageRoot); - if (!doctorEntry) { - return null; - } - const doctorNodePath = await resolveStableNodePath(process.execPath); - const candidateHostVersion = await readPackageVersion(verifiedPackageRoot); - const doctorPolicy = resolveUpdateDoctorExecutionPolicy({ - targetVersion: candidateHostVersion, - allowGatewayServiceRepair, - }); - return await runStep({ - runCommand, - name: "openclaw doctor", - argv: [ - doctorNodePath, - doctorEntry, - "doctor", - "--non-interactive", - ...(doctorPolicy.fix ? ["--fix"] : []), - ], - cwd: verifiedPackageRoot, - timeoutMs, - env: { - OPENCLAW_UPDATE_IN_PROGRESS: "1", - [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", - [UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1", - [UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: allowGatewayServiceRepair - ? "1" - : "0", - [UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV]: allowGatewayActivation ? "1" : "0", - ...(doctorPolicy.serviceRepairPolicy - ? { [UPDATE_DOCTOR_SERVICE_REPAIR_POLICY_ENV]: doctorPolicy.serviceRepairPolicy } - : {}), - ...(candidateHostVersion === null - ? {} - : { OPENCLAW_COMPATIBILITY_HOST_VERSION: candidateHostVersion }), - }, - progress, - stepIndex: 0, - totalSteps: 1, - }); - }, + startedAt, + beforeVersion, + allowGatewayServiceRepair: opts.allowGatewayServiceRepair !== false, + allowGatewayActivation: opts.allowGatewayActivation === true, }); - return { - status: packageUpdate.failedStep ? "error" : "ok", - mode: globalManager, - root: packageUpdate.verifiedPackageRoot ?? pkgRoot, - reason: packageUpdate.failedStep - ? normalizeFallbackFailureReason(packageUpdate.failedStep.name) - : undefined, - before: { version: beforeVersion }, - after: { version: packageUpdate.afterVersion }, - steps: packageUpdate.steps, - durationMs: Date.now() - startedAt, - }; } - return { status: "skipped", mode: "unknown", @@ -1925,4 +102,3 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< durationMs: Date.now() - startedAt, }; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */