| |
| 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 = { |
| |
| |
| recovery?: unknown; |
| reapplyLocalOverrides?: boolean; |
| |
| run?: { |
| runId: string; |
| defaultStepTimeoutMs?: number; |
| activationTimeoutMs?: number; |
| env: NodeJS.ProcessEnv; |
| |
| requesterAuthority?: UpdateRequesterAuthority; |
| |
| 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; |
| |
| 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); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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"; |
| |
| |
| const GIT_CLONE_BLOB_FILTER = "--filter=blob:none"; |
|
|
| export const DEFAULT_PACKAGE_NAME = "openclaw"; |
| const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); |
|
|
| |
| 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 }; |
|
|
| |
| export async function resolveTargetVersion( |
| tag: string, |
| timeoutMs?: number, |
| options: { spec?: string; command?: string; cwd?: string; env?: NodeJS.ProcessEnv } = {}, |
| ): Promise<string | null> { |
| 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; |
| } |
|
|
| |
| export async function isGitCheckout(root: string): Promise<boolean> { |
| try { |
| await fs.stat(path.join(root, ".git")); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| async function isCorePackage(root: string): Promise<boolean> { |
| const name = await readPackageName(root); |
| return Boolean(name && CORE_PACKAGE_NAMES.has(name)); |
| } |
|
|
| |
| export async function isEmptyDir(targetPath: string): Promise<boolean> { |
| try { |
| const entries = await fs.readdir(targetPath); |
| return entries.length === 0; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| 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"); |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| export async function resolveUpdateRoot(): Promise<string> { |
| |
| |
| 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() |
| ); |
| } |
|
|
| |
| export async function runUpdateStep(params: { |
| name: string; |
| argv: string[]; |
| cwd?: string; |
| timeoutMs: number; |
| progress?: UpdateStepProgress; |
| env?: NodeJS.ProcessEnv; |
| runCommand?: Parameters<typeof runStep>[0]["runCommand"]; |
| }): Promise<UpdateStepResult> { |
| 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<string>, |
| targetRoot: string, |
| ) => Promise<void>; |
|
|
| async function cloneGitCheckoutTransactionally(params: { |
| dir: string; |
| timeoutMs: number; |
| progress?: UpdateStepProgress; |
| env?: NodeJS.ProcessEnv; |
| useStagedCheckout?: StagedGitCheckout; |
| }): Promise<GitCheckoutResult> { |
| 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<string> => { |
| 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 }); |
| } |
| } |
| } |
|
|
| |
| export async function ensureGitCheckout(params: { |
| dir: string; |
| timeoutMs: number; |
| progress?: UpdateStepProgress; |
| env?: NodeJS.ProcessEnv; |
| useStagedCheckout?: StagedGitCheckout; |
| }): Promise<GitCheckoutResult> { |
| 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 }; |
| } |
|
|
| |
| export async function resolveGlobalManager(params: { |
| root: string; |
| installKind: "git" | "package" | "unknown"; |
| timeoutMs: number; |
| pkgOwnership?: FreeBsdPkgOwnershipInspection; |
| }): Promise<GlobalInstallManager> { |
| 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"; |
|
|
| |
| 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<boolean> { |
| 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; |
| } |
|
|