Files
openclaw/scripts/lib/cross-os-release-checks/installed.ts
2026-07-13 04:01:40 -07:00

819 lines
24 KiB
TypeScript

import { spawn } from "node:child_process";
import { appendFileSync, createWriteStream, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import {
agentOutputHasExpectedOkMarker,
agentTurnUsedEmbeddedFallback,
buildCrossOsReleaseAgentSessionId,
buildReleaseAgentTurnArgs,
maybeBuildOptionalAgentTurnSkipResult,
shouldRetryCrossOsAgentTurnError,
} from "./agent.ts";
import type {
AgentTurnResult,
CommandOptions,
GatewayHandle,
LaneState,
ProviderConfig,
} from "./config.ts";
import {
CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS,
CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS,
CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS,
CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE,
buildCrossOsReleaseSmokeMemorySlotConfigArgs,
buildCrossOsReleaseSmokePluginAllowlist,
buildReleaseProviderConfigOverride,
gatewayReadyDeadlineMs,
installTimeoutMs,
looksLikeCommitSha,
resolveExplicitBaselineVersion,
resolveExpectedDevUpdateRef,
shouldSkipInstallerDaemonHealthCheck,
} from "./config.ts";
import {
installedEntryPath,
normalizeWindowsInstalledCliPath,
npmCommand,
resolveInstalledPrefixDirFromCliPath,
} from "./install.ts";
import { readLogFileSize, readLogTextSince } from "./logs.ts";
import {
canConnectToLoopbackPort,
resolveCommandSpawnInvocation,
runCommand,
runCommandInvocation,
withAllocatedGatewayPort,
} from "./process.ts";
import { formatError, shellEscapeForSh, sleep } from "./shared.ts";
export async function resolveInstallerTargetVersion(params: {
baselineSpec: string;
logsDir: string;
suiteName: string;
}) {
const resolvedVersion = resolveExplicitBaselineVersion(params.baselineSpec);
if (resolvedVersion) {
return resolvedVersion;
}
const latestResult = await runCommand(npmCommand(), ["view", "openclaw@latest", "version"], {
logPath: join(params.logsDir, `${params.suiteName}-latest-version.log`),
timeoutMs: 2 * 60 * 1000,
});
const latestVersion = latestResult.stdout.trim();
if (!latestVersion) {
throw new Error("npm view openclaw@latest version did not return a version.");
}
return latestVersion;
}
function powerShellSingleQuote(value: string) {
return value.replace(/'/gu, "''");
}
function parseMarkerLine(output: string, marker: string) {
return output
.split(/\r?\n/gu)
.find((line) => line.startsWith(marker))
?.slice(marker.length)
.trim();
}
export function resolveInstalledCliInvocation(
cliPath: string,
args: string[] = [],
options: { platform?: NodeJS.Platform; comSpec?: string; env?: NodeJS.ProcessEnv } = {
platform: process.platform,
},
) {
const platform = options.platform ?? process.platform;
if (platform !== "win32") {
return { command: cliPath, args, shell: false };
}
const normalizedCliPath = normalizeWindowsInstalledCliPath(cliPath);
if (!/\.cmd$/iu.test(normalizedCliPath)) {
return { command: normalizedCliPath, args, shell: false };
}
const entryPath = installedEntryPath(
resolveInstalledPrefixDirFromCliPath(normalizedCliPath, platform),
);
if (existsSync(entryPath)) {
return {
command: process.execPath,
args: [entryPath, ...args],
shell: false,
};
}
return resolveCommandSpawnInvocation(normalizedCliPath, args, {
comSpec: options.comSpec,
env: options.env,
platform,
});
}
async function runPosixShellScript(script: string, options: CommandOptions) {
return runCommand("/bin/bash", ["-lc", script], options);
}
async function runPowerShellScript(script: string, options: CommandOptions) {
return runCommand(
"powershell.exe",
["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
options,
);
}
export async function runInstallerSmoke(params: {
lane: LaneState;
env: NodeJS.ProcessEnv;
installerUrl: string;
installTarget: string;
logPath: string;
}) {
if (process.platform === "win32") {
const script = `
$response = Invoke-WebRequest -UseBasicParsing '${powerShellSingleQuote(params.installerUrl)}'
$content = $response.Content
if ($content -is [byte[]]) {
$content = [System.Text.Encoding]::UTF8.GetString($content)
}
& ([scriptblock]::Create([string]$content)) -Tag '${powerShellSingleQuote(params.installTarget)}' -NoOnboard
`;
await runPowerShellScript(script, {
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: installTimeoutMs(),
});
return;
}
const script = [
"set -euo pipefail",
`curl -fsSL '${shellEscapeForSh(params.installerUrl)}' | bash -s -- --version '${shellEscapeForSh(params.installTarget)}' --no-onboard`,
].join("\n");
await runPosixShellScript(script, {
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: installTimeoutMs(),
});
}
export function buildWindowsPathBootstrapScript(
options: { includeCurrentProcessPath?: boolean } = {},
) {
const includeCurrentProcessPath = options.includeCurrentProcessPath !== false;
const pathCandidates = includeCurrentProcessPath
? "@($userPath, $machinePath, $env:Path)"
: "@($userPath, $machinePath)";
return `
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$segments = New-Object System.Collections.Generic.List[string]
foreach ($candidate in ${pathCandidates}) {
foreach ($segment in ($candidate -split ';')) {
if ([string]::IsNullOrWhiteSpace($segment)) {
continue
}
if (-not $segments.Contains($segment)) {
$segments.Add($segment)
}
}
}
$env:Path = [string]::Join(';', $segments)
`.trim();
}
export function buildWindowsFreshShellVersionCheckScript(params: { expectedNeedle?: string } = {}) {
const expectedNeedle = powerShellSingleQuote(params.expectedNeedle ?? "");
return `
${buildWindowsPathBootstrapScript()}
$commandPath = $null
$npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue
if ($null -eq $npmCommand) {
$npmCommand = Get-Command npm -ErrorAction SilentlyContinue
}
if ($null -ne $npmCommand) {
$npmPrefix = (& $npmCommand.Source config get prefix 2>$null | Out-String).Trim()
if (-not [string]::IsNullOrWhiteSpace($npmPrefix)) {
$env:Path = "$npmPrefix;$env:Path"
foreach ($candidate in @(
(Join-Path $npmPrefix 'openclaw.cmd'),
(Join-Path $npmPrefix 'openclaw.ps1')
)) {
if (Test-Path -LiteralPath $candidate) {
$commandPath = $candidate
break
}
}
}
}
if ([string]::IsNullOrWhiteSpace($commandPath)) {
$cmd = Get-Command openclaw -ErrorAction Stop
$commandPath = $cmd.Source
}
if ($commandPath -match '(?i)\\.ps1$') {
$cmdPath = [System.IO.Path]::ChangeExtension($commandPath, '.cmd')
if (Test-Path -LiteralPath $cmdPath) {
$commandPath = $cmdPath
}
}
$version = (& $commandPath --version 2>&1 | Out-String).Trim()
Write-Output "__OPENCLAW_PATH__=$commandPath"
Write-Output $version
if ('${expectedNeedle}'.Length -gt 0 -and $version -notmatch [regex]::Escape('${expectedNeedle}')) {
throw "version mismatch: expected substring ${expectedNeedle}"
}
`.trim();
}
export function buildWindowsDevUpdateToolchainCheckScript() {
return `
${buildWindowsPathBootstrapScript()}
function Resolve-CommandPath([string]$Name) {
$command = Get-Command $Name -ErrorAction SilentlyContinue
if ($null -eq $command) {
return $null
}
$commandPath = $command.Source
if ($commandPath -match '(?i)\\.ps1$') {
$cmdPath = [System.IO.Path]::ChangeExtension($commandPath, '.cmd')
if (Test-Path -LiteralPath $cmdPath) {
$commandPath = $cmdPath
}
}
return $commandPath
}
$pnpmPath = Resolve-CommandPath 'pnpm'
if ($null -ne $pnpmPath) {
Write-Output "__UPDATE_TOOL__=pnpm"
Write-Output "__UPDATE_TOOL_PATH__=$pnpmPath"
& $pnpmPath --version
return
}
$corepackPath = Resolve-CommandPath 'corepack'
if ($null -ne $corepackPath) {
Write-Output "__UPDATE_TOOL__=corepack"
Write-Output "__UPDATE_TOOL_PATH__=$corepackPath"
& $corepackPath --version
return
}
$npmPath = Resolve-CommandPath 'npm'
if ($null -ne $npmPath) {
Write-Output "__UPDATE_TOOL__=npm"
Write-Output "__UPDATE_TOOL_PATH__=$npmPath"
& $npmPath --version
return
}
throw 'Neither pnpm, corepack, nor npm is discoverable from the reconstructed Windows PATH.'
`.trim();
}
export async function verifyFreshShellCommand(params: {
lane: LaneState;
env: NodeJS.ProcessEnv;
expectedNeedle: string;
logPath: string;
}) {
if (process.platform === "win32") {
const script = buildWindowsFreshShellVersionCheckScript({
expectedNeedle: params.expectedNeedle,
});
const result = await runPowerShellScript(script, {
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
const cliPath = normalizeWindowsInstalledCliPath(
parseMarkerLine(result.stdout, "__OPENCLAW_PATH__=") ?? "",
);
if (!cliPath) {
throw new Error("Failed to resolve installed openclaw path from fresh Windows shell.");
}
return {
cliPath,
versionOutput: `${result.stdout}\n${result.stderr}`.trim(),
};
}
const script = [
"set -euo pipefail",
'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
"command -v openclaw >/dev/null 2>&1",
'printf "__OPENCLAW_PATH__=%s\\n" "$(command -v openclaw)"',
"openclaw --version",
].join("\n");
const result = await runPosixShellScript(script, {
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
const cliPath = parseMarkerLine(result.stdout, "__OPENCLAW_PATH__=");
const versionOutput = `${result.stdout}\n${result.stderr}`.trim();
if (!cliPath) {
throw new Error("Failed to resolve installed openclaw path from fresh POSIX shell.");
}
if (params.expectedNeedle && !versionOutput.includes(params.expectedNeedle)) {
throw new Error(
`Installed CLI version did not contain expected substring ${params.expectedNeedle}.`,
);
}
return { cliPath, versionOutput };
}
export async function runInstalledCli(params: {
cliPath: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
logPath: string;
timeoutMs?: number;
check?: boolean;
}) {
const invocation = resolveInstalledCliInvocation(params.cliPath, params.args, {
env: params.env,
platform: process.platform,
});
return runCommandInvocation(invocation, {
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: params.timeoutMs,
check: params.check ?? true,
});
}
async function readInstalledUpdateStatus(params: {
cliPath: string;
cwd: string;
env: NodeJS.ProcessEnv;
logPath: string;
}) {
return runInstalledCli({
cliPath: params.cliPath,
args: ["update", "status", "--json"],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
}
export async function ensureDevUpdateGitInstall(params: {
lane: LaneState;
env: NodeJS.ProcessEnv;
cliPath: string;
logsDir: string;
requestedRef: string;
}) {
const updateStatus = await readInstalledUpdateStatus({
cliPath: params.cliPath,
cwd: params.lane.homeDir,
env: params.env,
logPath: join(params.logsDir, "dev-update-status.log"),
});
// The dev-update lane must prove that `openclaw update --channel dev` landed on
// the expected git checkout. Falling back to a manual repair here would hide
// updater regressions and turn the suite into a false green.
verifyDevUpdateStatus(updateStatus.stdout, { ref: params.requestedRef });
return { cliPath: params.cliPath };
}
export async function runOnboardWithInstalledCli(params: {
lane: LaneState;
cliPath: string;
env: NodeJS.ProcessEnv;
providerConfig: ProviderConfig;
installDaemon: boolean;
logPath: string;
}) {
await withAllocatedGatewayPort(params.lane, async () => {
const args = buildReleaseOnboardArgs({
authChoice: params.providerConfig.authChoice,
gatewayPort: params.lane.gatewayPort,
installDaemon: params.installDaemon,
skipHealth: !params.installDaemon || shouldSkipInstallerDaemonHealthCheck(),
});
await runInstalledCli({
cliPath: params.cliPath,
args,
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: 10 * 60 * 1000,
});
});
}
export function buildReleaseOnboardArgs(params: {
authChoice: string;
gatewayPort: number;
installDaemon?: boolean;
skipHealth?: boolean;
}) {
const args: string[] = [
"onboard",
"--non-interactive",
"--mode",
"local",
"--auth-choice",
params.authChoice,
"--secret-input-mode",
"ref",
"--gateway-port",
String(params.gatewayPort),
"--gateway-bind",
"loopback",
"--skip-skills",
"--skip-bootstrap",
"--accept-risk",
"--json",
];
if (params.installDaemon) {
args.push("--install-daemon");
}
if (params.skipHealth) {
args.push("--skip-health");
}
return args;
}
export async function startManualGatewayFromInstalledCli(params: {
lane: LaneState;
cliPath: string;
env: NodeJS.ProcessEnv;
logPath: string;
}): Promise<GatewayHandle> {
mkdirSync(dirname(params.logPath), { recursive: true });
const gatewayLog = createWriteStream(params.logPath, { flags: "a" });
const invocation = resolveInstalledCliInvocation(
params.cliPath,
["gateway", "run", "--bind", "loopback", "--port", String(params.lane.gatewayPort), "--force"],
{
env: params.env,
platform: process.platform,
},
);
const child = spawn(invocation.command, invocation.args, {
cwd: params.lane.homeDir,
env: params.env,
shell: invocation.shell,
stdio: ["ignore", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
windowsHide: true,
});
child.stdout?.on("data", (chunk) => {
gatewayLog.write(chunk);
});
child.stderr?.on("data", (chunk) => {
gatewayLog.write(chunk);
});
let logClosed = false;
const closeLog = async () => {
if (logClosed) {
return;
}
logClosed = true;
await new Promise<void>((resolvePromise) => {
gatewayLog.once("error", () => resolvePromise());
gatewayLog.end(() => resolvePromise());
});
};
child.once("close", () => {
void closeLog();
});
child.once("error", () => {
void closeLog();
});
return { child, closeLog, logPath: params.logPath };
}
async function resolveInstalledGatewayStatusArgs(params: {
cliPath: string;
cwd: string;
env: NodeJS.ProcessEnv;
logPath: string;
requireRpc?: boolean;
}) {
const requireRpc = params.requireRpc !== false;
try {
const help = await runInstalledCli({
cliPath: params.cliPath,
args: ["gateway", "status", "--help"],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 15_000,
check: false,
});
return buildGatewayStatusArgsFromHelpText(`${help.stdout}\n${help.stderr}`, { requireRpc });
} catch (error) {
appendGatewayStatusHelpProbeFallback(params.logPath, error);
return buildGatewayStatusArgsFromHelpText("--require-rpc", { requireRpc });
}
}
export function buildGatewayStatusArgsFromHelpText(
helpText: string,
options: { requireRpc?: boolean } = {},
) {
const requireRpc = options.requireRpc !== false;
if (requireRpc && helpText.includes("--require-rpc")) {
return [
"gateway",
"status",
"--require-rpc",
"--timeout",
String(CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS),
];
}
return ["gateway", "status"];
}
export function appendGatewayStatusHelpProbeFallback(logPath: string, error: unknown) {
appendFileSync(
logPath,
`${new Date().toISOString()} gateway status help probe failed; assuming current --require-rpc support: ${formatError(error)}\n`,
);
}
export async function waitForInstalledGateway(params: {
lane: LaneState;
cliPath: string;
env: NodeJS.ProcessEnv;
logPath: string;
}) {
const statusArgs = await resolveInstalledGatewayStatusArgs({
cliPath: params.cliPath,
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
});
const deadline = Date.now() + gatewayReadyDeadlineMs();
while (Date.now() < deadline) {
const result = await runInstalledCli({
cliPath: params.cliPath,
args: statusArgs,
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS,
check: false,
});
if (result.exitCode === 0) {
return;
}
await sleep(2_000);
}
throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`);
}
export async function waitForInstalledGatewayToStop(params: {
lane: LaneState;
cliPath: string;
env: NodeJS.ProcessEnv;
logPath: string;
}) {
const statusArgs = await resolveInstalledGatewayStatusArgs({
cliPath: params.cliPath,
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
requireRpc: false,
});
const deadline = Date.now() + gatewayReadyDeadlineMs();
while (Date.now() < deadline) {
await runInstalledCli({
cliPath: params.cliPath,
args: statusArgs,
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS,
check: false,
});
const portReachable = await canConnectToLoopbackPort(params.lane.gatewayPort);
if (!portReachable) {
return;
}
await sleep(2_000);
}
throw new Error(
`Managed gateway did not stop on port ${params.lane.gatewayPort} before manual fallback.`,
);
}
export async function ensureManagedGatewayReady(params: {
lane: LaneState;
cliPath: string;
env: NodeJS.ProcessEnv;
logPath: string;
}) {
try {
await waitForInstalledGateway(params);
return;
} catch {
await runInstalledCli({
cliPath: params.cliPath,
args: ["gateway", "start"],
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
check: false,
});
}
await waitForInstalledGateway(params);
}
export async function runInstalledModelsSet(params: {
cliPath: string;
cwd: string;
env: NodeJS.ProcessEnv;
providerConfig: ProviderConfig;
logPath: string;
}) {
await runInstalledCli({
cliPath: params.cliPath,
args: ["models", "set", params.providerConfig.model],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
const providerConfigOverride = buildReleaseProviderConfigOverride(params.providerConfig);
if (providerConfigOverride) {
await runInstalledCli({
cliPath: params.cliPath,
args: [
"config",
"set",
`models.providers.${params.providerConfig.extensionId}`,
JSON.stringify(providerConfigOverride),
"--strict-json",
"--merge",
],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
}
await runInstalledCli({
cliPath: params.cliPath,
args: [
"config",
"set",
"plugins.allow",
JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)),
"--strict-json",
],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runInstalledCli({
cliPath: params.cliPath,
args: buildCrossOsReleaseSmokeMemorySlotConfigArgs(),
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runInstalledCli({
cliPath: params.cliPath,
args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
await runInstalledCli({
cliPath: params.cliPath,
args: ["config", "set", "tools.profile", CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
}
export async function runInstalledAgentTurn(params: {
cliPath: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
logPath: string;
}): Promise<AgentTurnResult> {
let lastError;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const sessionId = buildCrossOsReleaseAgentSessionId(params.label, attempt);
try {
const logOffset = readLogFileSize(params.logPath);
const result = await runInstalledCli({
cliPath: params.cliPath,
args: buildReleaseAgentTurnArgs(sessionId),
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: (CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS + 60) * 1000,
});
const logText = readLogTextSince(params.logPath, logOffset);
if (!agentOutputHasExpectedOkMarker(result.stdout, { logText })) {
throw new Error("Agent output did not contain the expected OK marker.");
}
if (agentTurnUsedEmbeddedFallback(result, { logText })) {
throw new Error("Agent turn used embedded fallback instead of gateway.");
}
return result;
} catch (error) {
lastError = error;
const skipped = maybeBuildOptionalAgentTurnSkipResult(error, params.logPath, {
attempt,
maxAttempts: 2,
});
if (skipped) {
return skipped;
}
if (attempt >= 2 || !shouldRetryCrossOsAgentTurnError(error)) {
throw error;
}
appendFileSync(
params.logPath,
`\n[release-checks] retrying installed agent turn after retryable live failure: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
}
throw lastError;
}
export function verifyDevUpdateStatus(stdout: string, options: { ref?: string } = {}) {
let payload;
try {
payload = JSON.parse(stdout) as {
update?: { installKind?: string; git?: { branch?: string; sha?: string } };
channel?: { value?: string; channel?: string };
installKind?: string;
git?: { branch?: string; sha?: string };
};
} catch {
payload = null;
}
const expectedRef = resolveExpectedDevUpdateRef(options.ref);
const update = payload?.update ?? payload;
const installKind = update?.installKind ?? null;
const branch = update?.git?.branch ?? null;
const sha = update?.git?.sha ?? null;
const channelValue = payload?.channel?.value ?? payload?.channel?.channel ?? null;
if (installKind !== "git") {
throw new Error(
`Dev update did not land on a git install. Found ${installKind ?? "<missing>"}.`,
);
}
if (channelValue !== "dev") {
throw new Error(
`Dev update status did not report channel=dev. Found ${channelValue ?? "<missing>"}.`,
);
}
if (looksLikeCommitSha(expectedRef)) {
const normalizedSha = typeof sha === "string" ? sha.toLowerCase() : "";
const normalizedExpectedRef = expectedRef.toLowerCase();
if (!normalizedSha || !normalizedSha.startsWith(normalizedExpectedRef)) {
throw new Error(
`Dev update status did not report sha=${expectedRef}. Found ${sha ?? "<missing>"}.`,
);
}
return;
}
if (branch !== expectedRef) {
throw new Error(
`Dev update status did not report branch=${expectedRef}. Found ${branch ?? "<missing>"}.`,
);
}
}
export async function verifyWindowsDevUpdateToolchain(params: {
lane: LaneState;
env: NodeJS.ProcessEnv;
logPath: string;
}) {
const script = buildWindowsDevUpdateToolchainCheckScript();
const result = await runPowerShellScript(script, {
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: 2 * 60 * 1000,
});
if (!parseMarkerLine(result.stdout, "__UPDATE_TOOL__=")) {
throw new Error(
"No Windows update bootstrap tool (pnpm, corepack, or npm) was discoverable after the dev update.",
);
}
}