// Shared update command primitives for channel resolution, install roots, and subprocess steps. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { hasErrnoCode } from "../../infra/errors.js"; import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; import { readPackageName, readPackageVersion } from "../../infra/package-json.js"; import { normalizePackageTagInput } from "../../infra/package-tag.js"; import { parseSemver } from "../../infra/runtime-guard.js"; import { fetchNpmTagVersion } from "../../infra/update-check.js"; import { normalizeUpdateFailureFacts, type UpdateFailureFact, } from "../../infra/update-failure-facts.js"; import { createFreeBsdPkgOwnershipInspection, type FreeBsdPkgOwnershipInspection, } from "../../infra/update-freebsd-pkg-ownership.js"; import { canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, detectGlobalInstallManagerByPresence, detectGlobalInstallManagerForRoot, type GlobalInstallManager, } from "../../infra/update-global.js"; import type { UpdateRequesterAuthority } from "../../infra/update-requester-authority.js"; import type { UpdateRecoveryFence } from "../../infra/update-run-recovery.js"; import { runStep } from "../../infra/update-runner-command.js"; import { resolveUnmanagedUpdateInstallReason } from "../../infra/update-runner-install-surface.js"; import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; import { UPDATE_INSTALL_SKIP_GUIDANCE } from "../../shared/update-outcome.js"; import { pathExists } from "../../utils.js"; import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../completion-runtime.js"; import { isJsonOutputModeActive } from "../json-output-mode.js"; export type UpdateCommandOptions = { /** In-process executor only; workers must reacquire authority, never deserialize this. */ /** Legacy live context is unsupported; its presence is refusal-only. */ recovery?: unknown; reapplyLocalOverrides?: boolean; /** Internal orchestration context, shared across update phases and child processes. */ run?: { runId: string; defaultStepTimeoutMs?: number; activationTimeoutMs?: number; env: NodeJS.ProcessEnv; /** Prepared before replacement; never load the old authority graph after activation. */ requesterAuthority?: UpdateRequesterAuthority; /** Live local executor only. A child must independently acquire its owner. */ executorFence?: UpdateRecoveryFence; }; acceptCapabilities?: boolean; json?: boolean; restart?: boolean; dryRun?: boolean; channel?: string; tag?: string; timeout?: string; yes?: boolean; }; export type UpdateStatusOptions = { json?: boolean; timeout?: string; }; export type UpdateFinalizeOptions = { acceptCapabilities?: boolean; json?: boolean; channel?: string; timeout?: string; yes?: boolean; restart?: boolean; /** Internal external-supervisor handshake; public repair always leaves this false. */ deferCompletionCache?: boolean; }; export type UpdateWizardOptions = { acceptCapabilities?: boolean; timeout?: string; }; export class UpdatePreMutationError extends Error { readonly failureFacts: UpdateFailureFact[]; constructor( readonly reason: string, message: string, options?: ErrorOptions & { failureFacts?: readonly UpdateFailureFact[] }, ) { super(message, options); this.name = "UpdatePreMutationError"; this.failureFacts = normalizeUpdateFailureFacts( options?.failureFacts ?? [{ check: reason, code: reason, message }], ); } } const INVALID_TIMEOUT_ERROR = "--timeout must be a positive integer (seconds)"; const MAX_SAFE_TIMEOUT_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000); /** Parse the shared timeout contract without exiting an owning operation. */ export function parseUpdateTimeoutMs(timeout?: string): number | undefined { if (timeout === undefined) { return undefined; } const trimmed = timeout.trim(); const seconds = parseStrictPositiveInteger(trimmed); if (seconds === undefined || seconds > MAX_SAFE_TIMEOUT_SECONDS) { throw new Error(INVALID_TIMEOUT_ERROR); } return seconds * 1000; } /** Parse a CLI timeout in seconds, exiting through the runtime on invalid input. */ export function parseTimeoutMsOrExit(timeout?: string): number | undefined | null { try { return parseUpdateTimeoutMs(timeout); } catch (error) { if (isJsonOutputModeActive(process.argv)) { throw error; } defaultRuntime.error(INVALID_TIMEOUT_ERROR); defaultRuntime.exit(1); return null; } } const UPSTREAM_REPOSITORY_URL = "https://github.com/openclaw/openclaw.git"; // Keep the full commit graph for dev ref switching while deferring historical blobs. // A shallow clone would make older or non-default dev targets unreachable. const GIT_CLONE_BLOB_FILTER = "--filter=blob:none"; export const DEFAULT_PACKAGE_NAME = "openclaw"; const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); /** Normalize a CLI tag/version/spec into the npm target form accepted by update flows. */ export function normalizeTag(value?: string | null): string | null { return normalizePackageTagInput(value, ["openclaw", DEFAULT_PACKAGE_NAME]); } function normalizeVersionTag(tag: string): string | null { const trimmed = tag.trim(); if (!trimmed) { return null; } const cleaned = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed; return parseSemver(cleaned) ? cleaned : null; } export { readPackageName, readPackageVersion }; /** Resolve an npm dist-tag or explicit version into a concrete package version. */ export async function resolveTargetVersion( tag: string, timeoutMs?: number, options: { spec?: string; command?: string; cwd?: string; env?: NodeJS.ProcessEnv } = {}, ): Promise { if (!canResolveRegistryVersionForPackageTarget(tag)) { return null; } const direct = normalizeVersionTag(tag); if (direct) { return direct; } const res = await fetchNpmTagVersion({ tag, timeoutMs, spec: options.spec, command: options.command, cwd: options.cwd, env: options.env, }); return res.version ?? null; } /** Return true when `root` is a local git checkout directory. */ export async function isGitCheckout(root: string): Promise { try { await fs.stat(path.join(root, ".git")); return true; } catch { return false; } } async function isCorePackage(root: string): Promise { const name = await readPackageName(root); return Boolean(name && CORE_PACKAGE_NAMES.has(name)); } /** Return true only for existing directories with no entries. */ export async function isEmptyDir(targetPath: string): Promise { try { const entries = await fs.readdir(targetPath); return entries.length === 0; } catch { return false; } } /** Resolve the checkout path used by source-based self-update. */ export function resolveGitInstallDir(): string { const override = process.env.OPENCLAW_GIT_DIR?.trim(); if (override) { return path.resolve(override); } return resolveDefaultGitDir(); } function resolveDefaultGitDir(): string { const home = resolveRequiredHomeDir(process.env, os.homedir); if (home.startsWith("/")) { return path.posix.join(home, "openclaw"); } return path.join(home, "openclaw"); } /** Prefer the current Node executable, falling back to `node` when run through another shim. */ export function resolveNodeRunner(): string { const base = normalizeLowercaseStringOrEmpty(path.basename(process.execPath)); if (base === "node" || base === "node.exe") { return process.execPath; } return "node"; } export function tryResolveInvocationCwd(): string | undefined { try { return process.cwd(); } catch { return undefined; } } /** Locate the installed OpenClaw package root that should receive update operations. */ export async function resolveUpdateRoot(): Promise { // Preserve the lexical package path from the invoking shim. pnpm 11 package // modules realpath into a shared store, which is not the install owner. const invocationRoot = process.argv[1] ? await resolveOpenClawPackageRoot({ cwd: path.dirname(path.resolve(process.argv[1])) }) : null; return ( invocationRoot ?? (await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, cwd: process.cwd() })) ?? process.cwd() ); } /** Run one update subprocess and report bounded stdout/stderr tails to progress listeners. */ export async function runUpdateStep(params: { name: string; argv: string[]; cwd?: string; timeoutMs: number; progress?: UpdateStepProgress; env?: NodeJS.ProcessEnv; runCommand?: Parameters[0]["runCommand"]; }): Promise { return await runStep({ ...params, cwd: params.cwd ?? process.cwd(), runCommand: params.runCommand ?? runCommandWithTimeout, stepIndex: 0, totalSteps: 0, }); } type GitCheckoutResult = { checkoutDir: string; step: UpdateStepResult | null; }; type StagedGitCheckout = ( root: string, publish: () => Promise, targetRoot: string, ) => Promise; async function cloneGitCheckoutTransactionally(params: { dir: string; timeoutMs: number; progress?: UpdateStepProgress; env?: NodeJS.ProcessEnv; useStagedCheckout?: StagedGitCheckout; }): Promise { const parentDir = path.dirname(params.dir); await fs.mkdir(parentDir, { recursive: true }); const canonicalParentDir = await fs.realpath(parentDir); const preserveDir = (await pathExists(params.dir)) && (await isEmptyDir(params.dir)); const targetDir = preserveDir ? await fs.realpath(params.dir) : path.join(canonicalParentDir, path.basename(params.dir)); const stagingParent = preserveDir ? targetDir : canonicalParentDir; const stagingDir = await fs.mkdtemp(path.join(stagingParent, ".openclaw-clone-")); let cleanupStaging = true; try { const result = await runUpdateStep({ name: "git clone", argv: ["git", "clone", GIT_CLONE_BLOB_FILTER, UPSTREAM_REPOSITORY_URL, stagingDir], env: params.env, timeoutMs: params.timeoutMs, progress: params.progress, }); if (result.exitCode !== 0) { return { checkoutDir: targetDir, step: result }; } const publish = async (): Promise => { if (!preserveDir) { try { await fs.lstat(targetDir); } catch (error) { if (!hasErrnoCode(error, "ENOENT")) { throw error; } await fs.rename(stagingDir, targetDir); return targetDir; } } if (!preserveDir) { throw new Error( `OPENCLAW_GIT_DIR appeared while cloning: ${params.dir}. The existing path was left unchanged; move it or choose another OPENCLAW_GIT_DIR, then retry.`, ); } const expectedEntries = preserveDir ? [path.basename(stagingDir)] : []; const destinationEntries = await fs.readdir(targetDir); if (destinationEntries.toSorted().join("\0") !== expectedEntries.toSorted().join("\0")) { throw new Error( `OPENCLAW_GIT_DIR appeared while cloning: ${params.dir}. The existing path was left unchanged; move it or choose another OPENCLAW_GIT_DIR, then retry.`, ); } const entries = (await fs.readdir(stagingDir)).toSorted((a, b) => a === ".git" ? 1 : b === ".git" ? -1 : 0, ); const moved: string[] = []; let publishError: { value: unknown } | undefined; try { for (const entry of entries) { await fs.rename(path.join(stagingDir, entry), path.join(targetDir, entry)); moved.push(entry); } } catch (error) { publishError = { value: error }; } if (publishError) { const rollbackErrors: unknown[] = []; for (const entry of moved.toReversed()) { try { await fs.rename(path.join(targetDir, entry), path.join(stagingDir, entry)); } catch (rollbackError) { rollbackErrors.push(rollbackError); } } if (rollbackErrors.length > 0) { cleanupStaging = false; throw new AggregateError( [publishError.value, ...rollbackErrors], `Could not publish or fully roll back the cloned checkout at ${targetDir}; recovery files remain at ${stagingDir}`, ); } throw publishError.value; } return targetDir; }; if (params.useStagedCheckout) { await params.useStagedCheckout(stagingDir, publish, targetDir); } else { await publish(); } return { checkoutDir: targetDir, step: result }; } finally { if (cleanupStaging) { await fs.rm(stagingDir, { recursive: true, force: true }); } } } /** Ensure the configured source-update directory exists and points at an OpenClaw checkout. */ export async function ensureGitCheckout(params: { dir: string; timeoutMs: number; progress?: UpdateStepProgress; env?: NodeJS.ProcessEnv; useStagedCheckout?: StagedGitCheckout; }): Promise { const gitEnv = params.env ?? (await createGlobalInstallEnv()); const dirExists = await pathExists(params.dir); if (!dirExists) { return await cloneGitCheckoutTransactionally({ dir: params.dir, env: gitEnv, timeoutMs: params.timeoutMs, progress: params.progress, useStagedCheckout: params.useStagedCheckout, }); } if (!(await isGitCheckout(params.dir))) { const empty = await isEmptyDir(params.dir); if (!empty) { throw new UpdatePreMutationError( "invalid-git-directory", `OPENCLAW_GIT_DIR points at a non-git directory: ${params.dir}. Set OPENCLAW_GIT_DIR to an empty folder or an openclaw checkout.`, ); } return await cloneGitCheckoutTransactionally({ dir: params.dir, env: gitEnv, timeoutMs: params.timeoutMs, progress: params.progress, useStagedCheckout: params.useStagedCheckout, }); } if (!(await isCorePackage(params.dir))) { throw new UpdatePreMutationError( "invalid-git-directory", `OPENCLAW_GIT_DIR does not look like a core checkout: ${params.dir}.`, ); } return { checkoutDir: await fs.realpath(params.dir), step: null }; } /** Detect the package manager that owns a global/package OpenClaw install. */ export async function resolveGlobalManager(params: { root: string; installKind: "git" | "package" | "unknown"; timeoutMs: number; pkgOwnership?: FreeBsdPkgOwnershipInspection; }): Promise { await ( params.pkgOwnership ?? createFreeBsdPkgOwnershipInspection(params.timeoutMs) ).assertUnowned(params.root); if (params.installKind === "package") { const diagnostics: string[] = []; const detected = await detectGlobalInstallManagerForRoot( runCommandWithTimeout, params.root, params.timeoutMs, diagnostics, ); if (!detected) { const reason = resolveUnmanagedUpdateInstallReason(); throw new UpdatePreMutationError( reason, `${UPDATE_INSTALL_SKIP_GUIDANCE[reason]} Inspected: ${diagnostics.join("; ")}.`, ); } return detected; } const byPresence = await detectGlobalInstallManagerByPresence( runCommandWithTimeout, params.timeoutMs, ); return byPresence ?? "npm"; } const COMPLETION_CACHE_WRITE_TIMEOUT_MS = 30_000; const COMPLETION_CACHE_MANUAL_REFRESH_HINT = "Shell tab-completion may be stale; refresh manually with: openclaw completion --write-state"; /** Best-effort refresh of shell completion state after a successful update. */ export async function tryWriteCompletionCache( root: string, jsonMode: boolean, timeoutMs = COMPLETION_CACHE_WRITE_TIMEOUT_MS, ): Promise<"completed" | "failed" | "skipped"> { const binPath = path.join(root, "openclaw.mjs"); if (!(await pathExists(binPath))) { return "skipped"; } let failure: string; try { const result = await runCommandWithTimeout( [resolveNodeRunner(), binPath, "completion", "--write-state"], { cwd: root, env: { ...process.env, [COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1" }, input: "", timeoutMs, killProcessTree: true, }, ); if (result.code === 0) { return "completed"; } failure = result.termination === "timeout" ? `timed out after ${timeoutMs / 1000}s` : result.stderr.trim(); } catch (error) { failure = String(error); } if (!jsonMode) { defaultRuntime.log( theme.warn( `Completion cache update failed${failure ? `: ${failure}` : ""}. ${COMPLETION_CACHE_MANUAL_REFRESH_HINT}`, ), ); } return "failed"; } export async function requestUpdateDowngradeConfirmation(params: { json: boolean; currentVersion: string | null; targetVersion: string | null; tag: string; }): Promise<"confirmed" | "cancelled" | "confirmation-required"> { if (!process.stdin.isTTY || params.json) { return "confirmation-required"; } const { confirm, isCancel } = await import("@clack/prompts"); const { stylePromptMessage } = await import("../../../packages/terminal-core/src/prompt-style.js"); const targetLabel = params.targetVersion ?? `${params.tag} (unknown)`; const message = `Downgrading from ${params.currentVersion} to ${targetLabel} can break configuration. Continue?`; const ok = await confirm({ message: stylePromptMessage(message), initialValue: false }); return isCancel(ok) || !ok ? "cancelled" : "confirmed"; } export async function confirmUpdateDowngrade(params: { opts: UpdateCommandOptions; currentVersion: string | null; targetVersion: string | null; tag: string; }): Promise { const { finishUpdateRun } = await import("../../infra/update-run-ledger.js"); const { opts, currentVersion, targetVersion, tag } = params; const decision = await requestUpdateDowngradeConfirmation({ json: Boolean(opts.json), currentVersion, targetVersion, tag, }); const run = opts.run!; if (decision === "confirmation-required") { finishUpdateRun( run.runId, { status: "skipped", reason: "downgrade-confirmation-required" }, { env: run.env }, ); defaultRuntime.error( "Downgrade confirmation required.\nDowngrading can break configuration. Re-run in a TTY to confirm.", ); defaultRuntime.exit(1); return false; } if (decision === "cancelled") { finishUpdateRun(run.runId, { status: "skipped", reason: "cancelled" }, { env: run.env }); if (!opts.json) { defaultRuntime.log(theme.muted("Update cancelled.")); } defaultRuntime.exit(0); return false; } return true; }