mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
refactor(update): split update-runner into focused modules under the line limit (#110664)
This commit is contained in:
committed by
GitHub
parent
39ddf710f0
commit
f6de912efe
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user