refactor(update): split update-runner into focused modules under the line limit (#110664)

This commit is contained in:
Peter Steinberger
2026-07-18 12:52:54 +01:00
committed by GitHub
parent 39ddf710f0
commit f6de912efe
11 changed files with 1937 additions and 1863 deletions
-1
View File
@@ -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
+99
View File
@@ -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<UpdateStepResult> {
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<UpdateRunResult["reason"]> {
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,
}),
};
}
+57
View File
@@ -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 }
: {}),
};
}
+141
View File
@@ -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<UpdateRunResult["reason"]> {
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);
}
+522
View File
@@ -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<UpdateRunResult["reason"]> };
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<string | null> {
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<UpdateRunResult["reason"]> }
> {
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<GitDevPreflightResult> {
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<ReturnType<typeof testPreflightCandidates>>;
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,
};
}
+107
View File
@@ -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<GitTargetSchemaMetadata> {
let result: Awaited<ReturnType<CommandRunner>>;
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<string | null> {
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<string[]> {
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<UpdateChannel, "dev">,
): Promise<string | null> {
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;
}
+500
View File
@@ -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<UpdateRunResult> {
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?.();
}
}
+170
View File
@@ -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<UpdateRunResult> {
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,
};
}
+151
View File
@@ -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<string | null> {
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<string> {
return await fs.realpath(target).catch(() => path.resolve(target));
}
export async function pathsReferToSameLocation(left: string, right: string): Promise<boolean> {
return (await resolveComparablePath(left)) === (await resolveComparablePath(right));
}
export async function looksLikeGitCheckout(root: string): Promise<boolean> {
try {
await fs.access(path.join(root, ".git"));
return true;
} catch {
return false;
}
}
export async function resolveUpdateInstallSurface(
opts: Pick<UpdateRunnerOptions, "cwd" | "argv1" | "timeoutMs" | "runCommand"> = {},
): Promise<UpdateInstallSurface> {
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 };
}
+152
View File
@@ -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;
};
+38 -1862
View File
File diff suppressed because it is too large Load Diff