#!/usr/bin/env -S node --import tsx // Executed directly via Node.js + tsx in the release workflow. import { spawn } from "node:child_process"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { mkdtempSync } from "node:fs"; import { createServer } from "node:http"; import { createConnection as createNetConnection, createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve, win32 as pathWin32 } from "node:path"; import { fileURLToPath } from "node:url"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; const SUPPORTED_MODES = new Set(["fresh", "upgrade", "both"]); const SUPPORTED_SUITES = new Set([ "packaged-fresh", "installer-fresh", "packaged-upgrade", "dev-update", ]); const providerConfig = { openai: { extensionId: "openai", secretEnv: "OPENAI_API_KEY", authChoice: "openai-api-key", model: "openai/gpt-5.4", }, anthropic: { extensionId: "anthropic", secretEnv: "ANTHROPIC_API_KEY", authChoice: "apiKey", model: "anthropic/claude-sonnet-4-6", }, minimax: { extensionId: "minimax", secretEnv: "MINIMAX_API_KEY", authChoice: "minimax-global-api", model: "minimax/MiniMax-M2.7", }, }; const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; const PACKAGED_QA_RUNTIME_PATHS = new Set(["dist/extensions/qa-channel/runtime-api.js"]); const OMITTED_QA_EXTENSION_PREFIXES = [ "dist/extensions/qa-channel/", "dist/extensions/qa-lab/", "dist/extensions/qa-matrix/", ]; if (isMainModule()) { try { await main(process.argv.slice(2)); } catch (error) { process.stderr.write(`${formatError(error)}\n`); process.exit(1); } } function isMainModule() { const invokedPath = process.argv[1]?.trim(); if (!invokedPath) { return false; } return resolve(invokedPath) === SCRIPT_PATH; } export function parseArgs(argv) { const parsed = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { continue; } const key = token.slice(2); const next = argv[index + 1]; if (next === undefined || next.startsWith("--")) { parsed[key] = "true"; continue; } parsed[key] = next; index += 1; } return parsed; } export function looksLikeReleaseVersionRef(ref) { const trimmed = normalizeRequestedRef(ref); return /^v?[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[1-9][0-9]*)|[-.](?:beta|rc)[-.]?[0-9]+)?$/iu.test( trimmed, ); } export function normalizeRequestedRef(ref) { const trimmed = ref?.trim() || ""; if (!trimmed) { return ""; } if (trimmed.startsWith("refs/heads/")) { return trimmed.slice("refs/heads/".length); } if (trimmed.startsWith("refs/tags/")) { return trimmed.slice("refs/tags/".length); } return trimmed; } export function isImmutableReleaseRef(ref) { const trimmed = ref?.trim() || ""; return trimmed.startsWith("refs/tags/") || looksLikeReleaseVersionRef(trimmed); } export function resolveRequestedSuites(mode, ref) { if (!SUPPORTED_MODES.has(mode)) { throw new Error(`Unsupported mode "${mode}".`); } const suites = []; if (mode === "fresh" || mode === "both") { suites.push("packaged-fresh", "installer-fresh"); } if (mode === "upgrade" || mode === "both") { suites.push("packaged-upgrade"); if (shouldRunMainChannelDevUpdate(ref)) { suites.push("dev-update"); } } return suites; } export function resolveRunnerMatrix(params) { const pick = (...values) => values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim(); const suites = resolveRequestedSuites(params.mode, params.ref); const runners = [ { os_id: "ubuntu", display_name: "Linux", runner: pick(params.ubuntuRunner, params.varUbuntuRunner, "ubuntu-latest"), artifact_name: "linux", }, { os_id: "windows", display_name: "Windows", runner: pick(params.windowsRunner, params.varWindowsRunner, "blacksmith-32vcpu-windows-2025"), artifact_name: "windows", }, { os_id: "macos", display_name: "macOS", runner: pick(params.macosRunner, params.varMacosRunner, "macos-latest-xlarge"), artifact_name: "macos", }, ]; return { include: runners.flatMap((runner) => suites.map((suite) => Object.assign({}, runner, { suite, suite_label: formatSuiteLabel(suite), lane: suite.includes(`upgrade`) || suite === `dev-update` ? `upgrade` : `fresh`, }), ), ), }; } export function readRunnerOverrideEnv(env = process.env) { const preferNonEmptyEnv = (primary: string | undefined, legacy: string | undefined) => { const primaryValue = primary?.trim(); if (primaryValue) { return primaryValue; } const legacyValue = legacy?.trim(); return legacyValue || ""; }; return { varUbuntuRunner: preferNonEmptyEnv( env.VAR_UBUNTU_RUNNER, env.OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER, ), varWindowsRunner: preferNonEmptyEnv( env.VAR_WINDOWS_RUNNER, env.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER, ), varMacosRunner: preferNonEmptyEnv( env.VAR_MACOS_RUNNER, env.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER, ), }; } function formatSuiteLabel(suite) { if (suite === "packaged-fresh") { return "packaged fresh"; } if (suite === "installer-fresh") { return "installer fresh"; } if (suite === "packaged-upgrade") { return "packaged upgrade"; } return "dev update"; } async function main(argv) { const args = parseArgs(argv); if (args["resolve-matrix"] === "true") { const mode = args["mode"] ?? "both"; const ref = args["ref"]?.trim() || "main"; const runnerOverrideEnv = readRunnerOverrideEnv(process.env); process.stdout.write( `${JSON.stringify( resolveRunnerMatrix({ mode, ref, ubuntuRunner: args["ubuntu-runner"], windowsRunner: args["windows-runner"], macosRunner: args["macos-runner"], ...runnerOverrideEnv, }), )}\n`, ); return; } const outputDir = resolve(requireArg(args, "output-dir")); const prepareOnly = args["prepare-only"] === "true"; const sourceDir = args["source-dir"]?.trim() ? resolve(args["source-dir"].trim()) : ""; const provider = args["provider"]?.trim() || ""; const suite = args["suite"]?.trim() || ""; const mode = args["mode"] ?? "both"; const inputRef = args["ref"]?.trim() || ""; const previousVersion = args["previous-version"]?.trim() || ""; const baselineSpec = args["baseline-spec"]?.trim() || (previousVersion ? `openclaw@${previousVersion}` : "openclaw@latest"); const providedBaselineTgz = args["baseline-tgz"]?.trim() ? resolve(args["baseline-tgz"].trim()) : ""; const providedCandidateTgz = args["candidate-tgz"]?.trim() ? resolve(args["candidate-tgz"].trim()) : ""; const providedCandidateVersion = args["candidate-version"]?.trim() || ""; const providedSourceSha = args["source-sha"]?.trim() || ""; const runDiscordRoundtrip = args["run-discord-roundtrip"] === "true"; mkdirSync(outputDir, { recursive: true }); const logsDir = join(outputDir, "logs"); mkdirSync(logsDir, { recursive: true }); if (prepareOnly) { if (!sourceDir) { throw new Error("--prepare-only requires --source-dir."); } const build = await prepareCandidate({ outputDir, sourceDir, logsDir, }); writeCandidateManifest(outputDir, build); return; } if (!SUPPORTED_SUITES.has(suite)) { throw new Error(`Unsupported suite "${suite}".`); } if (!Object.hasOwn(providerConfig, provider)) { throw new Error(`Unsupported provider "${provider}".`); } const selectedProvider = providerConfig[provider]; const providerSecretValue = process.env[selectedProvider.secretEnv]?.trim(); if (!providerSecretValue) { throw new Error(`Missing ${selectedProvider.secretEnv}.`); } const summary = { platform: process.platform, runnerOs: process.env.OPENCLAW_RELEASE_CHECK_OS ?? "", runnerLabel: process.env.OPENCLAW_RELEASE_CHECK_RUNNER ?? "", provider, mode, suite, ref: inputRef || null, previousVersion: previousVersion || null, sourceDir, sourceSha: "", candidateVersion: "", candidateTgz: "", baselineSpec, result: { status: "pending", }, discordRoundtrip: runDiscordRoundtrip, }; let build; try { build = sourceDir ? await prepareCandidate({ outputDir, sourceDir, logsDir, }) : readProvidedCandidate({ candidateTgz: providedCandidateTgz, candidateVersion: providedCandidateVersion, sourceSha: providedSourceSha, }); summary.sourceSha = build.sourceSha; summary.candidateVersion = build.candidateVersion; summary.candidateTgz = build.candidateTgz; if (suite === "packaged-fresh") { summary.result = await runFreshLane({ build, logsDir, providerConfig: selectedProvider, providerSecretValue, }); } else if (suite === "packaged-upgrade") { const tgzServer = await startStaticFileServer({ filePath: build.candidateTgz, logPath: join(logsDir, "candidate-http-server.log"), }); try { summary.result = await runUpgradeLane({ baselineSpec, baselineTgz: providedBaselineTgz, build, candidateUrl: tgzServer.url, logsDir, providerConfig: selectedProvider, providerSecretValue, }); } finally { await tgzServer.close(); } } else if (suite === "installer-fresh") { summary.result = await runInstallerFreshSuite({ build, logsDir, providerConfig: selectedProvider, providerSecretValue, runDiscordRoundtrip, }); } else { summary.result = await runDevUpdateSuite({ baselineSpec, logsDir, providerConfig: selectedProvider, providerSecretValue, ref: inputRef || "main", sourceSha: build.sourceSha, runDiscordRoundtrip, }); } } catch (error) { summary.result = { status: "fail", error: formatError(error), }; } writeSummary(outputDir, summary); if (summary.result.status !== "pass") { process.exit(1); } } async function prepareCandidate(params) { logPhase("prepare", "resolve-source-sha"); const packageJson = readPackageJson(params.sourceDir); const hasUiBuildScript = packageJsonHasScript(packageJson, "ui:build"); const sourceSha = ( await runCommand(gitCommand(), ["rev-parse", "HEAD"], { cwd: params.sourceDir, logPath: join(params.logsDir, "git-rev-parse.log"), }) ).stdout.trim(); const buildEnv = { ...process.env, NODE_OPTIONS: "--max-old-space-size=6144", }; logPhase("prepare", "pnpm-install"); await runCommand(pnpmCommand(), ["install", "--frozen-lockfile"], { cwd: params.sourceDir, env: buildEnv, logPath: join(params.logsDir, "pnpm-install.log"), timeoutMs: 45 * 60 * 1000, }); logPhase("prepare", "pnpm-build"); await runCommand(pnpmCommand(), ["build"], { cwd: params.sourceDir, env: buildEnv, logPath: join(params.logsDir, "pnpm-build.log"), timeoutMs: 45 * 60 * 1000, }); if (hasUiBuildScript) { // pnpm build does not regenerate dist/control-ui, and checked-in bundles can // otherwise leak into npm pack when a ref changes UI assets. logPhase("prepare", "pnpm-ui-build"); await runCommand(pnpmCommand(), ["ui:build"], { cwd: params.sourceDir, env: buildEnv, logPath: join(params.logsDir, "pnpm-ui-build.log"), timeoutMs: 30 * 60 * 1000, }); } const packDir = join(params.outputDir, "package"); mkdirSync(packDir, { recursive: true }); const packJsonPath = join(packDir, "pack.json"); logPhase("prepare", "package-dist-inventory"); await writePackageDistInventoryForCandidate({ sourceDir: params.sourceDir, logPath: join(params.logsDir, "npm-pack-dry-run.log"), }); logPhase("prepare", "npm-pack"); const packResult = await runCommand( npmCommand(), ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], { cwd: params.sourceDir, logPath: join(params.logsDir, "npm-pack.log"), timeoutMs: 10 * 60 * 1000, }, ); writeFileSync(packJsonPath, packResult.stdout, "utf8"); const parsedPack = JSON.parse(packResult.stdout); const lastPack = Array.isArray(parsedPack) ? parsedPack.at(-1) : null; if (!lastPack?.filename) { throw new Error("npm pack did not report a filename."); } return { sourceDir: params.sourceDir, sourceSha, candidateVersion: String(lastPack.version ?? packageJson.version ?? "").trim(), candidateTgz: join(packDir, lastPack.filename), candidateFileName: String(lastPack.filename).trim(), }; } function normalizeRelativePath(value) { return value.replace(/\\/gu, "/"); } function isPackagedDistPath(relativePath) { if (!relativePath.startsWith("dist/")) { return false; } if (relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) { return false; } if (relativePath.endsWith(".map")) { return false; } if (relativePath === "dist/plugin-sdk/.tsbuildinfo") { return false; } if (OMITTED_QA_EXTENSION_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { return PACKAGED_QA_RUNTIME_PATHS.has(relativePath); } return true; } async function writePackageDistInventoryForCandidate(params) { const dryRun = await runCommand( npmCommand(), ["pack", "--dry-run", "--ignore-scripts", "--json"], { cwd: params.sourceDir, logPath: params.logPath, timeoutMs: 5 * 60 * 1000, }, ); const parsedPack = JSON.parse(dryRun.stdout); const lastPack = Array.isArray(parsedPack) ? parsedPack.at(-1) : null; const files = Array.isArray(lastPack?.files) ? lastPack.files : []; if (files.length === 0) { throw new Error( "npm pack --dry-run did not report package files for dist inventory generation.", ); } const inventory = files .flatMap((entry) => { const relativePath = normalizeRelativePath(String(entry?.path ?? "").trim()); return isPackagedDistPath(relativePath) ? [relativePath] : []; }) .toSorted((left, right) => left.localeCompare(right)); const inventoryPath = join(params.sourceDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); mkdirSync(dirname(inventoryPath), { recursive: true }); writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8"); } function readProvidedCandidate(params) { if (!params.candidateTgz) { throw new Error("Missing required --candidate-tgz argument when --source-dir is not provided."); } if (!existsSync(params.candidateTgz)) { throw new Error(`Candidate package not found: ${params.candidateTgz}`); } if (!params.candidateVersion) { throw new Error( "Missing required --candidate-version argument when --source-dir is not provided.", ); } if (!params.sourceSha) { throw new Error("Missing required --source-sha argument when --source-dir is not provided."); } return { sourceDir: "", sourceSha: params.sourceSha, candidateVersion: params.candidateVersion, candidateTgz: params.candidateTgz, candidateFileName: params.candidateTgz.split(/[/\\]/u).at(-1) ?? "", }; } async function runFreshLane(params) { const lane = createLaneState("fresh"); const cleanup = []; try { const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue); logLanePhase(lane, "install-candidate"); await installTarballPackage({ lane, env, tgzPath: params.build.candidateTgz, logPath: join(params.logsDir, "fresh-install.log"), restoreBundledPluginRuntimeDeps: false, }); const installed = readInstalledMetadata(lane.prefixDir); verifyInstalledCandidate(installed, params.build); logLanePhase(lane, "restore-bundled-plugin-runtime-deps"); await runBundledPluginPostinstall({ lane, env, logPath: join(params.logsDir, "fresh-install.log"), }); logLanePhase(lane, "onboard"); await runOnboard({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "fresh-onboard.log"), }); logLanePhase(lane, "start-gateway"); const gateway = await startGateway({ lane, env, logPath: join(params.logsDir, "fresh-gateway.log"), }); cleanup.push(() => stopGateway(gateway)); logLanePhase(lane, "wait-gateway"); await waitForGateway({ lane, env, logPath: join(params.logsDir, "fresh-gateway-status.log"), }); logLanePhase(lane, "dashboard"); await runDashboardSmoke({ lane, logPath: join(params.logsDir, "fresh-dashboard.log"), }); logLanePhase(lane, "models-set"); await runModelsSet({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "fresh-models-set.log"), }); logLanePhase(lane, "agent-turn"); const agent = await runAgentTurn({ lane, env, label: "fresh", logPath: join(params.logsDir, "fresh-agent.log"), }); return { status: "pass", installedVersion: installed.version, installedCommit: installed.commit, dashboardStatus: "pass", gatewayPort: lane.gatewayPort, agentOutput: trimForSummary(agent.stdout), }; } finally { await runCleanup(cleanup); } } async function runUpgradeLane(params) { if (!params.baselineTgz && !params.baselineSpec) { throw new Error("Missing required --baseline-tgz argument for upgrade mode."); } if (!params.candidateUrl) { throw new Error("Missing candidate package URL for upgrade mode."); } const lane = createLaneState("upgrade"); const cleanup = []; try { const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue); logLanePhase(lane, "install-baseline"); if (!params.baselineTgz && params.baselineSpec) { await installPackageSpec({ lane, env, packageSpec: params.baselineSpec, logPath: join(params.logsDir, "upgrade-install-baseline.log"), }); } else { await installTarballPackage({ lane, env, tgzPath: params.baselineTgz, logPath: join(params.logsDir, "upgrade-install-baseline.log"), restoreBundledPluginRuntimeDeps: false, }); } logLanePhase(lane, "restore-baseline-bundled-plugin-runtime-deps"); await runBundledPluginPostinstall({ lane, env, logPath: join(params.logsDir, "upgrade-install-baseline.log"), }); const baseline = { version: readInstalledVersion(lane.prefixDir), }; logLanePhase(lane, "update"); const updateEnv = buildRealUpdateEnv(env); const updateArgs = [ "update", "--tag", params.candidateUrl, "--yes", "--json", "--timeout", String(updateStepTimeoutSeconds()), ]; await runOpenClaw({ lane, env: updateEnv, args: updateArgs, logPath: join(params.logsDir, "upgrade-update.log"), timeoutMs: updateTimeoutMs(), }); logLanePhase(lane, "update-status"); await runOpenClaw({ lane, env, args: ["update", "status", "--json"], logPath: join(params.logsDir, "upgrade-update-status.log"), timeoutMs: 2 * 60 * 1000, }); logLanePhase(lane, "restore-bundled-plugin-runtime-deps"); await runBundledPluginPostinstall({ lane, env, logPath: join(params.logsDir, "upgrade-bundled-plugin-postinstall.log"), }); const installed = readInstalledMetadata(lane.prefixDir); verifyInstalledCandidate(installed, params.build); logLanePhase(lane, "onboard"); await runOnboard({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "upgrade-onboard.log"), }); logLanePhase(lane, "start-gateway"); const gateway = await startGateway({ lane, env, logPath: join(params.logsDir, "upgrade-gateway.log"), }); cleanup.push(() => stopGateway(gateway)); logLanePhase(lane, "wait-gateway"); await waitForGateway({ lane, env, logPath: join(params.logsDir, "upgrade-gateway-status.log"), }); logLanePhase(lane, "dashboard"); await runDashboardSmoke({ lane, logPath: join(params.logsDir, "upgrade-dashboard.log"), }); logLanePhase(lane, "models-set"); await runModelsSet({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "upgrade-models-set.log"), }); logLanePhase(lane, "agent-turn"); const agent = await runAgentTurn({ lane, env, label: "upgrade", logPath: join(params.logsDir, "upgrade-agent.log"), }); return { status: "pass", baselineVersion: baseline.version, installedVersion: installed.version, installedCommit: installed.commit, dashboardStatus: "pass", gatewayPort: lane.gatewayPort, agentOutput: trimForSummary(agent.stdout), }; } finally { await runCleanup(cleanup); } } async function runInstallerFreshSuite(params) { const lane = createLaneState("installer-fresh"); const cleanup = []; const usesManagedGateway = shouldUseManagedGatewayService(); const useManagedGatewayAfterInstall = shouldUseManagedGatewayForInstallerRuntime(); const manualGateway = { current: null }; try { const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); // Drive the public installer against the exact candidate artifact built from the requested ref. const candidateServer = await startStaticFileServer({ filePath: params.build.candidateTgz, logPath: join(params.logsDir, "installer-candidate-http-server.log"), }); cleanup.push(() => candidateServer.close()); const installTarget = candidateServer.url; const installerUrl = resolvePublishedInstallerUrl(); logLanePhase(lane, "installer-run"); await runInstallerSmoke({ lane, env, installerUrl, installTarget, logPath: join(params.logsDir, "installer-fresh-install.log"), }); logLanePhase(lane, "fresh-shell"); const freshShell = await verifyFreshShellCommand({ lane, env, expectedNeedle: params.build.candidateVersion, logPath: join(params.logsDir, "installer-fresh-shell.log"), }); const installed = readInstalledMetadataFromCliPath(freshShell.cliPath); verifyInstalledCandidate(installed, params.build); logLanePhase(lane, "onboard"); await runOnboardWithInstalledCli({ lane, cliPath: freshShell.cliPath, env, providerConfig: params.providerConfig, installDaemon: usesManagedGateway, logPath: join(params.logsDir, "installer-fresh-onboard.log"), }); if (shouldExerciseManagedGatewayLifecycleAfterInstall()) { await exerciseManagedGatewayLifecycle({ lane, cliPath: freshShell.cliPath, env, logPrefix: join(params.logsDir, "installer-fresh-gateway"), }); } if (!useManagedGatewayAfterInstall) { // Keep the Windows installer lane validating Scheduled Task registration during // onboarding and lifecycle commands, but use a manual gateway for the runtime // checks after that so the installer validation does not depend on the more // failure-prone managed Windows session state for the remainder of the lane. if (shouldStopManagedGatewayBeforeManualFallback()) { logLanePhase(lane, "gateway-stop-managed"); await runInstalledCli({ cliPath: freshShell.cliPath, args: ["gateway", "stop"], env, cwd: lane.homeDir, logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed.log"), timeoutMs: 2 * 60 * 1000, check: false, }); await waitForInstalledGatewayToStop({ lane, cliPath: freshShell.cliPath, env, logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed-status.log"), }); } logLanePhase(lane, "gateway-start"); const gateway = await startManualGatewayFromInstalledCli({ lane, cliPath: freshShell.cliPath, env, logPath: join(params.logsDir, "installer-fresh-gateway.log"), }); manualGateway.current = gateway; cleanup.push(() => stopGateway(manualGateway.current)); logLanePhase(lane, "gateway-status"); await waitForInstalledGateway({ lane, cliPath: freshShell.cliPath, env, logPath: join(params.logsDir, "installer-fresh-gateway-status.log"), }); } logLanePhase(lane, "dashboard"); await runDashboardSmoke({ lane, logPath: join(params.logsDir, "installer-fresh-dashboard.log"), }); logLanePhase(lane, "models-set"); await runInstalledModelsSet({ cliPath: freshShell.cliPath, env, providerConfig: params.providerConfig, cwd: lane.homeDir, logPath: join(params.logsDir, "installer-fresh-models-set.log"), }); logLanePhase(lane, "agent-turn"); const agent = await runInstalledAgentTurn({ cliPath: freshShell.cliPath, env, cwd: lane.homeDir, label: "installer-fresh", logPath: join(params.logsDir, "installer-fresh-agent.log"), }); let discordStatus = "skipped"; if (params.runDiscordRoundtrip && process.platform === "darwin") { logLanePhase(lane, "discord-roundtrip"); discordStatus = await maybeRunDiscordRoundtrip({ lane, cliPath: freshShell.cliPath, env, gatewayHolder: manualGateway, logPath: join(params.logsDir, "installer-fresh-discord.log"), }); } return { status: "pass", installTarget, installVersion: installed.version, cliPath: freshShell.cliPath, installedVersion: installed.version, installedCommit: installed.commit, gatewayPort: lane.gatewayPort, dashboardStatus: "pass", discordStatus, agentOutput: trimForSummary(agent.stdout), }; } finally { await runCleanup(cleanup); } } async function runDevUpdateSuite(params) { const lane = createLaneState("dev-update"); const cleanup = []; const installTarget = await resolveInstallerTargetVersion({ baselineSpec: params.baselineSpec, logsDir: params.logsDir, suiteName: "dev-update", }); const usesManagedGateway = shouldUseManagedGatewayService(); // Keep dev-update on a manual gateway even on Windows. The packaged lanes // already cover the Scheduled Task path, while repaired git installs live in // an ephemeral checkout that has proven flaky as a managed service in CI. const useManagedGatewayAfterDevUpdate = usesManagedGateway && process.platform !== "win32"; const requestedRef = resolveExpectedDevUpdateRef(params.ref); if (!shouldRunMainChannelDevUpdate(requestedRef)) { throw new Error( `The dev-update suite only supports main. Received ${normalizeRequestedRef(params.ref) || ""}.`, ); } const verificationRef = resolveDevUpdateVerificationRef(params.ref, params.sourceSha); const manualGateway = { current: null }; try { const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); const installerUrl = resolvePublishedInstallerUrl(); logLanePhase(lane, "installer-baseline"); await runInstallerSmoke({ lane, env, installerUrl, installTarget, logPath: join(params.logsDir, "dev-update-install.log"), }); logLanePhase(lane, "fresh-shell-baseline"); const baselineShell = await verifyFreshShellCommand({ lane, env, expectedNeedle: installTarget, logPath: join(params.logsDir, "dev-update-baseline-shell.log"), }); logLanePhase(lane, "update-dev"); await runInstalledCli({ cliPath: baselineShell.cliPath, args: ["update", "--channel", "dev", "--yes", "--json"], env: { ...buildRealUpdateEnv(env), OPENCLAW_UPDATE_DEV_TARGET_REF: verificationRef, }, cwd: lane.homeDir, logPath: join(params.logsDir, "dev-update.log"), timeoutMs: updateTimeoutMs(), }); logLanePhase(lane, "fresh-shell-updated"); const updatedShell = await verifyFreshShellCommand({ lane, env, expectedNeedle: "OpenClaw", logPath: join(params.logsDir, "dev-update-shell.log"), }); logLanePhase(lane, "update-status"); const verifiedShell = await ensureDevUpdateGitInstall({ lane, env, cliPath: updatedShell.cliPath, logsDir: params.logsDir, requestedRef: verificationRef, }); if (process.platform === "win32") { logLanePhase(lane, "windows-toolchain"); await verifyWindowsDevUpdateToolchain({ lane, env, logPath: join(params.logsDir, "dev-update-windows-toolchain.log"), }); } logLanePhase(lane, "onboard"); await runOnboardWithInstalledCli({ lane, cliPath: verifiedShell.cliPath, env, providerConfig: params.providerConfig, installDaemon: useManagedGatewayAfterDevUpdate, logPath: join(params.logsDir, "dev-update-onboard.log"), }); if (!useManagedGatewayAfterDevUpdate) { logLanePhase(lane, "gateway-start"); const gateway = await startManualGatewayFromInstalledCli({ lane, cliPath: verifiedShell.cliPath, env, logPath: join(params.logsDir, "dev-update-gateway.log"), }); manualGateway.current = gateway; cleanup.push(() => stopGateway(manualGateway.current)); logLanePhase(lane, "gateway-status"); await waitForInstalledGateway({ lane, cliPath: verifiedShell.cliPath, env, logPath: join(params.logsDir, "dev-update-gateway-status.log"), }); } else { logLanePhase(lane, "gateway-ready"); await ensureManagedGatewayReady({ lane, cliPath: verifiedShell.cliPath, env, logPath: join(params.logsDir, "dev-update-gateway-ready.log"), }); } logLanePhase(lane, "dashboard"); await runDashboardSmoke({ lane, logPath: join(params.logsDir, "dev-update-dashboard.log"), }); logLanePhase(lane, "models-set"); await runInstalledModelsSet({ cliPath: verifiedShell.cliPath, env, providerConfig: params.providerConfig, cwd: lane.homeDir, logPath: join(params.logsDir, "dev-update-models-set.log"), }); logLanePhase(lane, "agent-turn"); const agent = await runInstalledAgentTurn({ cliPath: verifiedShell.cliPath, env, cwd: lane.homeDir, label: "dev-update", logPath: join(params.logsDir, "dev-update-agent.log"), }); let discordStatus = "skipped"; if (params.runDiscordRoundtrip && process.platform === "darwin") { logLanePhase(lane, "discord-roundtrip"); discordStatus = await maybeRunDiscordRoundtrip({ lane, cliPath: verifiedShell.cliPath, env, gatewayHolder: manualGateway, logPath: join(params.logsDir, "dev-update-discord.log"), }); } return { status: "pass", installVersion: installTarget, cliPath: updatedShell.cliPath, gatewayPort: lane.gatewayPort, dashboardStatus: "pass", discordStatus, agentOutput: trimForSummary(agent.stdout), }; } finally { await runCleanup(cleanup); } } function createLaneState(name) { const rootDir = mkdtempSync(join(tmpdir(), `openclaw-${name}-`)); const prefixDir = join(rootDir, "prefix"); const homeDir = join(rootDir, "home"); const stateDir = join(homeDir, ".openclaw"); const appDataDir = process.platform === "win32" ? join(homeDir, "AppData", "Roaming") : stateDir; mkdirSync(prefixDir, { recursive: true }); mkdirSync(homeDir, { recursive: true }); mkdirSync(stateDir, { recursive: true }); mkdirSync(appDataDir, { recursive: true }); if (process.platform !== "win32") { writeFileSync(join(homeDir, ".bashrc"), "", "utf8"); writeFileSync(join(homeDir, ".zshrc"), "", "utf8"); } return { name, rootDir, prefixDir, homeDir, stateDir, appDataDir, gatewayPort: 0, }; } function buildLaneEnv(lane, providerMeta, providerSecretValue) { ensureLocalNpmShim(lane); return { ...process.env, HOME: lane.homeDir, USERPROFILE: lane.homeDir, APPDATA: lane.appDataDir, LOCALAPPDATA: join(lane.homeDir, "AppData", "Local"), OPENCLAW_HOME: lane.homeDir, OPENCLAW_STATE_DIR: lane.stateDir, OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"), OPENCLAW_DISABLE_BONJOUR: "1", OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", NPM_CONFIG_PREFIX: lane.prefixDir, PATH: `${binDirForPrefix(lane.prefixDir)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, [providerMeta.secretEnv]: providerSecretValue, }; } function buildInstallerEnv(lane, providerMeta, providerSecretValue) { const localAppData = join(lane.homeDir, "AppData", "Local"); mkdirSync(localAppData, { recursive: true }); return { ...process.env, HOME: lane.homeDir, USERPROFILE: lane.homeDir, APPDATA: lane.appDataDir, LOCALAPPDATA: localAppData, OPENCLAW_HOME: lane.homeDir, OPENCLAW_STATE_DIR: lane.stateDir, OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"), OPENCLAW_DISABLE_BONJOUR: "1", OPENCLAW_NO_ONBOARD: "1", OPENCLAW_NO_PROMPT: "1", CI: "1", NODE_OPTIONS: "--max-old-space-size=6144", [providerMeta.secretEnv]: providerSecretValue, }; } export function shouldUseManagedGatewayService(platform = process.platform) { return platform === "win32"; } export function shouldUseManagedGatewayForInstallerRuntime(platform = process.platform) { return shouldUseManagedGatewayService(platform) && platform !== "win32"; } export function shouldExerciseManagedGatewayLifecycleAfterInstall(platform = process.platform) { return shouldUseManagedGatewayService(platform); } export function shouldStopManagedGatewayBeforeManualFallback(platform = process.platform) { return shouldUseManagedGatewayService(platform); } function shouldRestoreBundledPluginRuntimeDeps() { return true; } function looksLikeCommitSha(ref) { return /^[0-9a-f]{7,40}$/iu.test(ref.trim()); } function resolveExpectedDevUpdateRef(ref) { const trimmed = normalizeRequestedRef(ref) || "main"; return trimmed || "main"; } export function resolveDevUpdateVerificationRef(ref, sourceSha) { if (resolveExpectedDevUpdateRef(ref) === "main" && looksLikeCommitSha(sourceSha ?? "")) { return sourceSha.trim(); } return resolveExpectedDevUpdateRef(ref); } export function shouldRunMainChannelDevUpdate(ref) { if (isImmutableReleaseRef(ref)) { return false; } return resolveExpectedDevUpdateRef(ref) === "main"; } export function shouldSkipInstallerDaemonHealthCheck(platform = process.platform) { return platform === "win32"; } export function buildRealUpdateEnv(env) { const updateEnv = { ...env }; delete updateEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL; return updateEnv; } export function resolveExplicitBaselineVersion(baselineSpec) { const trimmed = baselineSpec.trim(); if (!trimmed || trimmed === "openclaw@latest") { return ""; } if (trimmed.startsWith("openclaw@")) { return trimmed.slice("openclaw@".length); } return trimmed; } async function resolveInstallerTargetVersion(params) { 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) { return value.replace(/'/gu, "''"); } function readPackageJson(packageRoot) { return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); } function packageJsonHasScript(packageJson, scriptName) { return typeof packageJson?.scripts?.[scriptName] === "string"; } export function packageHasScript(packageRoot, scriptName) { try { return packageJsonHasScript(readPackageJson(packageRoot), scriptName); } catch { return false; } } function parseMarkerLine(output, marker) { return `${output}` .split(/\r?\n/gu) .find((line) => line.startsWith(marker)) ?.slice(marker.length) .trim(); } export function normalizeWindowsInstalledCliPath(cliPath) { return normalizeWindowsCommandShimPath(cliPath); } export function normalizeWindowsCommandShimPath(commandPath) { if (typeof commandPath !== "string") { return commandPath; } return commandPath.replace(/\.ps1$/iu, ".cmd"); } export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process.platform) { const resolvedCliPath = platform === "win32" ? normalizeWindowsInstalledCliPath(cliPath) : String(cliPath ?? ""); if (!resolvedCliPath?.trim()) { throw new Error("Missing installed CLI path."); } if (platform === "win32") { return pathWin32.dirname(resolvedCliPath); } return dirname(dirname(resolvedCliPath)); } function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) { return readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform)); } function resolveInstalledCliInvocation(cliPath, platform = process.platform) { if (platform !== "win32") { return { command: cliPath, argsPrefix: [], shell: false }; } const normalizedCliPath = normalizeWindowsInstalledCliPath(cliPath); if (!/\.cmd$/iu.test(normalizedCliPath)) { return { command: normalizedCliPath, argsPrefix: [], shell: false }; } const entryPath = installedEntryPath( resolveInstalledPrefixDirFromCliPath(normalizedCliPath, platform), ); if (existsSync(entryPath)) { return { command: process.execPath, argsPrefix: [entryPath], shell: false, }; } return { command: normalizedCliPath, argsPrefix: [], shell: true }; } async function runPosixShellScript(script, options) { return runCommand("/bin/bash", ["-lc", script], options); } async function runPowerShellScript(script, options) { return runCommand( "powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], options, ); } async function runInstallerSmoke(params) { 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 = {}) { 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 = {}) { 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(); } async function verifyFreshShellCommand(params) { 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 }; } async function runInstalledCli(params) { const invocation = resolveInstalledCliInvocation(params.cliPath); return runCommand(invocation.command, [...invocation.argsPrefix, ...params.args], { cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: params.timeoutMs, check: params.check ?? true, }); } async function readInstalledUpdateStatus(params) { return runInstalledCli({ cliPath: params.cliPath, args: ["update", "status", "--json"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } async function ensureDevUpdateGitInstall(params) { 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 }; } async function runOnboardWithInstalledCli(params) { await withAllocatedGatewayPort(params.lane, async () => { const args = [ "onboard", "--non-interactive", "--mode", "local", "--auth-choice", params.providerConfig.authChoice, "--secret-input-mode", "ref", "--gateway-port", String(params.lane.gatewayPort), "--gateway-bind", "loopback", "--skip-skills", "--accept-risk", "--json", ]; if (params.installDaemon) { args.push("--install-daemon"); } if (!params.installDaemon || shouldSkipInstallerDaemonHealthCheck()) { args.push("--skip-health"); } await runInstalledCli({ cliPath: params.cliPath, args, cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); }); } async function startManualGatewayFromInstalledCli(params) { mkdirSync(dirname(params.logPath), { recursive: true }); const gatewayLog = createWriteStream(params.logPath, { flags: "a" }); const invocation = resolveInstalledCliInvocation(params.cliPath); const child = spawn( invocation.command, [ ...invocation.argsPrefix, "gateway", "run", "--bind", "loopback", "--port", String(params.lane.gatewayPort), "--force", ], { cwd: params.lane.homeDir, env: params.env, shell: invocation.shell, stdio: ["ignore", "pipe", "pipe"], 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((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) { const requireRpc = params.requireRpc !== false; 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, }); if ( requireRpc && (help.stdout.includes("--require-rpc") || help.stderr.includes("--require-rpc")) ) { return ["gateway", "status", "--deep", "--require-rpc", "--timeout", "5000"]; } return ["gateway", "status", "--deep"]; } export async function canConnectToLoopbackPort(port, timeoutMs = 1_000) { if (!Number.isInteger(port) || port <= 0) { return false; } return await new Promise((resolvePromise) => { let settled = false; const socket = createNetConnection({ host: "127.0.0.1", port, }); const settle = (value) => { if (settled) { return; } settled = true; socket.destroy(); resolvePromise(value); }; socket.setTimeout(timeoutMs); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); socket.once("error", () => settle(false)); }); } async function waitForInstalledGateway(params) { 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: 20_000, check: false, }); if (result.exitCode === 0) { return; } await sleep(2_000); } throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`); } async function waitForInstalledGatewayToStop(params) { 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: 20_000, 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.`, ); } async function ensureManagedGatewayReady(params) { 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); } async function runInstalledModelsSet(params) { await runInstalledCli({ cliPath: params.cliPath, args: ["models", "set", params.providerConfig.model], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } async function runInstalledAgentTurn(params) { const sessionId = `cross-os-release-check-${params.label}-${Date.now()}`; const result = await runInstalledCli({ cliPath: params.cliPath, args: [ "agent", "--agent", "main", "--session-id", sessionId, "--message", "Reply with exact ASCII text OK only.", "--json", ], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); const payloadTexts = parseAgentPayloadTexts(result.stdout); if (!payloadTexts.some((text) => text.trim() === "OK")) { throw new Error("Agent output did not contain the expected OK marker."); } return result; } export function verifyDevUpdateStatus(stdout, options = {}) { let payload = null; try { payload = JSON.parse(stdout); } 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 ?? ""}.`, ); } if (channelValue !== "dev") { throw new Error( `Dev update status did not report channel=dev. Found ${channelValue ?? ""}.`, ); } 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 ?? ""}.`, ); } return; } if (branch !== expectedRef) { throw new Error( `Dev update status did not report branch=${expectedRef}. Found ${branch ?? ""}.`, ); } } async function verifyWindowsDevUpdateToolchain(params) { 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.", ); } } export function buildDiscordSmokeGuildsConfig(guildId, channelId) { return { [guildId]: { channels: { [channelId]: { enabled: true, requireMention: false, }, }, }, }; } async function configureDiscordSmoke(params) { const guildsJson = JSON.stringify( buildDiscordSmokeGuildsConfig(params.guildId, params.channelId), ); await runInstalledCli({ cliPath: params.cliPath, args: [ "config", "set", "channels.discord.token", "--ref-provider", "default", "--ref-source", "env", "--ref-id", "DISCORD_BOT_TOKEN", ], cwd: params.cwd, env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "channels.discord.enabled", "true"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "channels.discord.groupPolicy", "allowlist"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "channels.discord.guilds", guildsJson, "--strict-json"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); if (!shouldUseManagedGatewayService()) { const gatewayEnv = { ...params.env, DISCORD_BOT_TOKEN: params.token }; if (params.gatewayHolder?.current) { await stopGateway(params.gatewayHolder.current); params.gatewayHolder.current = null; } const gateway = await startManualGatewayFromInstalledCli({ lane: params.lane, cliPath: params.cliPath, env: gatewayEnv, logPath: join(params.cwd, `.openclaw/logs/${params.lane.name}-discord-gateway.log`), }); if (params.gatewayHolder) { params.gatewayHolder.current = gateway; } await waitForInstalledGateway({ lane: params.lane, cliPath: params.cliPath, env: gatewayEnv, logPath: params.logPath, }); return; } await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "restart"], cwd: params.cwd, env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, check: false, }); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, logPath: params.logPath, }); } async function waitForDiscordMessage(params) { const deadline = Date.now() + 3 * 60 * 1000; while (Date.now() < deadline) { const response = await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`, { headers: { Authorization: `Bot ${params.token}`, }, }, ); const text = await response.text(); if (!response.ok) { await sleep(2_000); continue; } if (text.includes(params.needle)) { return; } await sleep(2_000); } throw new Error(`Discord host-side visibility check timed out for ${params.needle}.`); } async function postDiscordMessage(params) { const response = await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages`, { method: "POST", headers: { Authorization: `Bot ${params.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: params.content, flags: 4096, }), }, ); const text = await response.text(); if (!response.ok) { throw new Error(`Failed to post Discord smoke message: ${text}`); } try { return JSON.parse(text)?.id ?? null; } catch { return null; } } async function deleteDiscordMessage(params) { if (!params.messageId) { return; } await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages/${params.messageId}`, { method: "DELETE", headers: { Authorization: `Bot ${params.token}`, }, }, ).catch(() => undefined); } async function waitForInstalledDiscordReadback(params) { const deadline = Date.now() + 3 * 60 * 1000; while (Date.now() < deadline) { const response = await runInstalledCli({ cliPath: params.cliPath, args: [ "message", "read", "--channel", "discord", "--target", `channel:${params.channelId}`, "--limit", "20", "--json", ], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 60_000, check: false, }); if (response.exitCode === 0 && response.stdout.includes(params.needle)) { return; } await sleep(3_000); } throw new Error(`Discord guest readback timed out for ${params.needle}.`); } async function maybeRunDiscordRoundtrip(params) { const token = process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN?.trim() || process.env.DISCORD_BOT_TOKEN?.trim() || ""; const guildId = process.env.OPENCLAW_DISCORD_SMOKE_GUILD_ID?.trim() || ""; const channelId = process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID?.trim() || ""; if (!token || !guildId || !channelId) { return "skipped-missing-config"; } const outboundNonce = `native-cross-os-outbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const inboundNonce = `native-cross-os-inbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; let sentMessageId = null; let hostMessageId = null; try { await configureDiscordSmoke({ lane: params.lane, cliPath: params.cliPath, cwd: params.lane.homeDir, env: params.env, gatewayHolder: params.gatewayHolder, logPath: params.logPath, token, guildId, channelId, }); const sendResult = await runInstalledCli({ cliPath: params.cliPath, args: [ "message", "send", "--channel", "discord", "--target", `channel:${channelId}`, "--message", outboundNonce, "--silent", "--json", ], cwd: params.lane.homeDir, env: { ...params.env, DISCORD_BOT_TOKEN: token }, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); let parsedSendResult = null; try { parsedSendResult = JSON.parse(sendResult.stdout); } catch { parsedSendResult = null; } sentMessageId = parsedSendResult?.payload?.messageId ?? parsedSendResult?.payload?.result?.messageId ?? null; await waitForDiscordMessage({ token, channelId, needle: outboundNonce, }); hostMessageId = await postDiscordMessage({ token, channelId, content: inboundNonce, }); await waitForInstalledDiscordReadback({ cliPath: params.cliPath, cwd: params.lane.homeDir, env: { ...params.env, DISCORD_BOT_TOKEN: token }, logPath: params.logPath, channelId, needle: inboundNonce, }); return "pass"; } finally { await deleteDiscordMessage({ token, channelId, messageId: sentMessageId }); await deleteDiscordMessage({ token, channelId, messageId: hostMessageId }); } } async function installTarballPackage(params) { await installPackageSpec({ lane: params.lane, env: params.env, packageSpec: params.tgzPath, logPath: params.logPath, timeoutMs: params.timeoutMs, }); if ( params.restoreBundledPluginRuntimeDeps !== false && shouldRestoreBundledPluginRuntimeDeps({ lane: params.lane }) ) { await runBundledPluginPostinstall({ lane: params.lane, env: params.env, logPath: params.logPath, }); } } async function installPackageSpec(params) { const installEnv = { ...params.env, npm_config_global: "true", npm_config_location: "global", npm_config_prefix: params.lane.prefixDir, }; rmSync(installedPackageRoot(params.lane.prefixDir), { force: true, recursive: true }); await runCommand( npmCommand(), [ "install", "-g", params.packageSpec, "--omit=dev", "--no-fund", "--no-audit", "--loglevel=notice", ], { cwd: params.lane.homeDir, env: installEnv, logPath: params.logPath, timeoutMs: params.timeoutMs ?? installTimeoutMs(), }, ); } function installTimeoutMs() { return process.platform === "win32" ? 45 * 60 * 1000 : 20 * 60 * 1000; } function updateTimeoutMs() { return process.platform === "win32" ? 30 * 60 * 1000 : 20 * 60 * 1000; } function updateStepTimeoutSeconds() { return process.platform === "win32" ? 1800 : 1200; } async function runBundledPluginPostinstall(params) { const packageRoot = installedPackageRoot(params.lane.prefixDir); const scriptPath = join(packageRoot, "scripts", "postinstall-bundled-plugins.mjs"); if (!existsSync(scriptPath)) { return; } const installEnv = { ...params.env, }; delete installEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL; delete installEnv.NPM_CONFIG_PREFIX; delete installEnv.npm_config_global; delete installEnv.npm_config_location; delete installEnv.npm_config_prefix; await runCommand(process.execPath, [scriptPath], { cwd: packageRoot, env: installEnv, logPath: params.logPath, timeoutMs: 20 * 60 * 1000, }); } function ensureLocalNpmShim(lane) { const shimPath = npmShimPath(lane.prefixDir); if (existsSync(shimPath)) { return; } mkdirSync(dirname(shimPath), { recursive: true }); const resolvedNpm = resolveCommandPath(npmCommand()); if (!resolvedNpm) { throw new Error(`Failed to resolve ${npmCommand()} on PATH.`); } if (process.platform === "win32") { writeFileSync( shimPath, `@echo off\r\nset "NPM_CONFIG_PREFIX=${lane.prefixDir}"\r\n"${resolvedNpm}" %*\r\n`, "utf8", ); return; } writeFileSync( shimPath, `#!/bin/sh\nexport NPM_CONFIG_PREFIX='${shellEscapeForSh(lane.prefixDir)}'\nexec '${shellEscapeForSh(resolvedNpm)}' "$@"\n`, "utf8", ); chmodSync(shimPath, 0o755); } async function runOnboard(params) { await withAllocatedGatewayPort(params.lane, async () => { await runOpenClaw({ lane: params.lane, env: params.env, args: [ "onboard", "--non-interactive", "--mode", "local", "--auth-choice", params.providerConfig.authChoice, "--secret-input-mode", "ref", "--gateway-port", String(params.lane.gatewayPort), "--gateway-bind", "loopback", "--skip-skills", "--skip-health", "--accept-risk", "--json", ], logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); }); } async function exerciseManagedGatewayLifecycle(params) { logLanePhase(params.lane, "gateway-ready"); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: params.env, logPath: `${params.logPrefix}-ready.log`, }); logLanePhase(params.lane, "gateway-restart"); await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "restart"], env: params.env, cwd: params.lane.homeDir, logPath: `${params.logPrefix}-restart.log`, timeoutMs: 2 * 60 * 1000, }); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: params.env, logPath: `${params.logPrefix}-ready-after-restart.log`, }); logLanePhase(params.lane, "gateway-stop"); await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "stop"], env: params.env, cwd: params.lane.homeDir, logPath: `${params.logPrefix}-stop.log`, timeoutMs: 2 * 60 * 1000, }); logLanePhase(params.lane, "gateway-start"); await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "start"], env: params.env, cwd: params.lane.homeDir, logPath: `${params.logPrefix}-start.log`, timeoutMs: 2 * 60 * 1000, }); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: params.env, logPath: `${params.logPrefix}-ready-after-start.log`, }); } async function startGateway(params) { const gatewayLog = createWriteStream(params.logPath, { flags: "a" }); const child = spawn( process.execPath, [ installedEntryPath(params.lane.prefixDir), "gateway", "run", "--bind", "loopback", "--port", String(params.lane.gatewayPort), "--force", ], { cwd: params.lane.homeDir, env: params.env, stdio: ["ignore", "pipe", "pipe"], 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((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 waitForGateway(params) { const statusArgs = await resolveGatewayStatusArgs(params.lane, params.env, params.logPath); const deadline = Date.now() + gatewayReadyDeadlineMs(); while (Date.now() < deadline) { let result; try { result = await runOpenClaw({ lane: params.lane, env: params.env, args: statusArgs, logPath: params.logPath, timeoutMs: 20_000, check: false, }); } catch { await sleep(2_000); continue; } if (result.exitCode === 0) { return; } await sleep(2_000); } throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`); } function gatewayReadyDeadlineMs() { return process.platform === "win32" ? 5 * 60 * 1000 : 90_000; } async function resolveGatewayStatusArgs(lane, env, logPath) { const help = await runOpenClaw({ lane, env, args: ["gateway", "status", "--help"], logPath, timeoutMs: 15_000, check: false, }); if (help.stdout.includes("--require-rpc") || help.stderr.includes("--require-rpc")) { return ["gateway", "status", "--deep", "--require-rpc", "--timeout", "5000"]; } return ["gateway", "status", "--deep"]; } async function runModelsSet(params) { await runOpenClaw({ lane: params.lane, env: params.env, args: ["models", "set", params.providerConfig.model], logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } async function runAgentTurn(params) { const sessionId = `cross-os-release-check-${params.label}-${Date.now()}`; const result = await runOpenClaw({ lane: params.lane, env: params.env, args: [ "agent", "--agent", "main", "--session-id", sessionId, "--message", "Reply with exact ASCII text OK only.", "--json", ], logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); const payloadTexts = parseAgentPayloadTexts(result.stdout); if (!payloadTexts.some((text) => text.trim() === "OK")) { throw new Error("Agent output did not contain the expected OK marker."); } return result; } function parseAgentPayloadTexts(stdout) { try { const payload = JSON.parse(stdout); const entries = Array.isArray(payload?.payloads) ? payload.payloads : Array.isArray(payload?.result?.payloads) ? payload.result.payloads : []; if (!Array.isArray(entries)) { return []; } return entries.flatMap((entry) => (typeof entry?.text === "string" ? [entry.text] : [])); } catch { return stdout.trim() ? [stdout] : []; } } async function runDashboardSmoke(params) { const dashboardUrl = `http://127.0.0.1:${params.lane.gatewayPort}/`; const logStream = createWriteStream(params.logPath, { flags: "a" }); const deadline = Date.now() + 30_000; let attempt = 0; try { while (Date.now() < deadline) { attempt += 1; logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`); try { const response = await fetch(dashboardUrl, { signal: AbortSignal.timeout(5_000), }); const html = await response.text(); if ( response.ok && html.includes("OpenClaw Control") && html.includes("") ) { logStream.write( `${new Date().toISOString()} dashboard-ready status=${response.status}\n`, ); return; } logStream.write( `${new Date().toISOString()} dashboard-not-ready status=${response.status} title=${html.includes("OpenClaw Control")} app=${html.includes("")}\n`, ); } catch (error) { logStream.write( `${new Date().toISOString()} dashboard-fetch-error ${formatError(error)}\n`, ); } await sleep(1_000); } } finally { logStream.end(); } throw new Error(`Dashboard HTML did not become ready at ${dashboardUrl}.`); } async function stopGateway(gateway) { try { if (!gateway?.child?.pid) { return; } if (process.platform === "win32") { await runCommand("taskkill", ["/PID", String(gateway.child.pid), "/T", "/F"], { logPath: gateway.logPath, check: false, timeoutMs: 30_000, }); const exited = await waitForChildExit(gateway.child, 10_000); if (!exited) { gateway.child.stdout?.destroy(); gateway.child.stderr?.destroy(); } return; } if (gateway.child.exitCode !== null) { return; } gateway.child.kill("SIGTERM"); const exitedAfterTerm = await waitForChildExit(gateway.child, 2_000); if (!exitedAfterTerm && gateway.child.exitCode === null) { gateway.child.kill("SIGKILL"); await waitForChildExit(gateway.child, 5_000); } } finally { await gateway?.closeLog?.(); } } async function waitForChildExit(child, timeoutMs) { if (child.exitCode !== null) { return true; } return new Promise((resolvePromise) => { let settled = false; const finish = (didExit) => { if (settled) { return; } settled = true; if (timer) { clearTimeout(timer); } child.off("exit", onExit); child.off("close", onClose); child.off("error", onError); resolvePromise(didExit); }; const onExit = () => finish(true); const onClose = () => finish(true); const onError = () => finish(true); const timer = timeoutMs > 0 ? setTimeout(() => { finish(false); }, timeoutMs) : null; child.once("exit", onExit); child.once("close", onClose); child.once("error", onError); }); } async function runCleanup(cleanupFns) { for (const cleanupFn of cleanupFns.toReversed()) { try { await cleanupFn(); } catch { // Ignore cleanup failures so the main failure surface stays visible. } } } async function runOpenClaw(params) { return runCommand(process.execPath, [installedEntryPath(params.lane.prefixDir), ...params.args], { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: params.timeoutMs, check: params.check ?? true, }); } function readInstalledPackageManifest(prefixDir) { const packageRoot = installedPackageRoot(prefixDir); const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) { throw new Error(`Installed package manifest missing: ${packageJsonPath}`); } const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown; }; return { packageJson, packageRoot }; } export function readInstalledVersion(prefixDir) { const { packageJson } = readInstalledPackageManifest(prefixDir); return typeof packageJson.version === "string" ? packageJson.version.trim() : ""; } function readInstalledMetadata(prefixDir) { const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir); const buildInfoPath = join(packageRoot, "dist", "build-info.json"); if (!existsSync(buildInfoPath)) { throw new Error(`Installed build info missing: ${buildInfoPath}`); } const buildInfo = JSON.parse(readFileSync(buildInfoPath, "utf8")) as { commit?: unknown; }; return { version: typeof packageJson.version === "string" ? packageJson.version.trim() : "", commit: typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : "", }; } function verifyInstalledCandidate(installed, build) { if (installed.version !== build.candidateVersion) { throw new Error( `Installed version mismatch. Expected ${build.candidateVersion}, found ${installed.version || ""}.`, ); } if (installed.commit !== build.sourceSha) { throw new Error( `Installed build commit mismatch. Expected ${build.sourceSha}, found ${installed.commit || ""}.`, ); } } function installedPackageRoot(prefixDir) { return process.platform === "win32" ? join(prefixDir, "node_modules", "openclaw") : join(prefixDir, "lib", "node_modules", "openclaw"); } function installedEntryPath(prefixDir) { return join(installedPackageRoot(prefixDir), "openclaw.mjs"); } function npmShimPath(prefixDir) { return process.platform === "win32" ? join(prefixDir, "npm.cmd") : join(prefixDir, "bin", "npm"); } function binDirForPrefix(prefixDir) { return process.platform === "win32" ? prefixDir : join(prefixDir, "bin"); } function pnpmCommand() { return process.platform === "win32" ? "pnpm.cmd" : "pnpm"; } function npmCommand() { return process.platform === "win32" ? "npm.cmd" : "npm"; } function gitCommand() { return process.platform === "win32" ? "git.exe" : "git"; } async function runCommand(command, args, options) { return new Promise((resolvePromise, rejectPromise) => { const useWindowsShell = process.platform === "win32" && /\.(cmd|bat)$/iu.test(command); const child = spawn(command, args, { cwd: options.cwd, env: options.env, shell: useWindowsShell, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); const logStream = createWriteStream(options.logPath, { flags: "a" }); let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; const clearTimers = () => { if (timer) { clearTimeout(timer); } if (killWaitTimer) { clearTimeout(killWaitTimer); } }; const finalize = (callback) => { if (settled) { return; } settled = true; clearTimers(); logStream.end(); callback(); }; const requestKill = () => { if (process.platform === "win32" && child.pid) { try { const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, }); killer.on("error", () => { child.kill(); }); return; } catch { child.kill(); return; } } child.kill(process.platform === "win32" ? undefined : "SIGKILL"); }; let killWaitTimer = null; const timer = options.timeoutMs && Number.isFinite(options.timeoutMs) ? setTimeout(() => { timedOut = true; logStream.write( `${new Date().toISOString()} timeout command=${command} args=${args.join(" ")}\n`, ); requestKill(); killWaitTimer = setTimeout(() => { finalize(() => { rejectPromise( new Error( `Command timed out and could not be terminated cleanly: ${command} ${args.join(" ")}`, ), ); }); }, 15_000); }, options.timeoutMs) : null; child.stdout?.on("data", (chunk) => { const text = chunk.toString(); stdout += text; logStream.write(text); }); child.stderr?.on("data", (chunk) => { const text = chunk.toString(); stderr += text; logStream.write(text); }); child.on("error", (error) => { finalize(() => rejectPromise(error)); }); child.on("close", (exitCode) => { finalize(() => { const result = { exitCode: exitCode ?? 1, stdout, stderr, }; if (timedOut) { rejectPromise(new Error(`Command timed out: ${command} ${args.join(" ")}`)); return; } if ((options.check ?? true) && result.exitCode !== 0) { rejectPromise( new Error( `Command failed (${result.exitCode}): ${command} ${args.join(" ")}\n${trimForSummary( `${stdout}\n${stderr}`, )}`, ), ); return; } resolvePromise(result); }); }); }); } async function startStaticFileServer(params) { mkdirSync(dirname(params.logPath), { recursive: true }); const logStream = createWriteStream(params.logPath, { flags: "a" }); const fileName = String(params.filePath.split(/[/\\]/u).at(-1) ?? "artifact"); const fileBytes = readFileSync(params.filePath); const server = createServer((request, response) => { logStream.write(`${new Date().toISOString()} ${request.method} ${request.url}\n`); if (request.url !== `/${fileName}`) { response.statusCode = 404; response.end("not found"); return; } response.statusCode = 200; response.setHeader("content-type", resolveStaticFileContentType(params.filePath)); response.setHeader("content-length", String(fileBytes.length)); response.end(fileBytes); }); await new Promise((resolvePromise, rejectPromise) => { server.once("error", rejectPromise); server.listen(0, "127.0.0.1", resolvePromise); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Failed to bind static file server."); } const port = address.port; return { url: `http://127.0.0.1:${port}/${fileName}`, close: () => new Promise((resolvePromise, rejectPromise) => { server.close((error) => { logStream.end(); if (error) { rejectPromise(error); return; } resolvePromise(); }); }), }; } export function resolveStaticFileContentType(filePath) { if (filePath.endsWith(".sh") || filePath.endsWith(".ps1")) { return "text/plain; charset=utf-8"; } return "application/octet-stream"; } export function resolvePublishedInstallerUrl(platform = process.platform) { if (platform === "win32") { return `${PUBLISHED_INSTALLER_BASE_URL}/install.ps1`; } return `${PUBLISHED_INSTALLER_BASE_URL}/install.sh`; } function writeSummary(baseDir, summaryPayload) { const summaryJsonPath = join(baseDir, "summary.json"); const summaryMarkdownPath = join(baseDir, "summary.md"); writeFileSync(summaryJsonPath, `${JSON.stringify(summaryPayload, null, 2)}\n`, "utf8"); const result = summaryPayload.result ?? {}; const lines = [ `## ${platformLabel()}`, "", `- Provider: \`${summaryPayload.provider}\``, `- Suite: \`${summaryPayload.suite}\``, `- Mode: \`${summaryPayload.mode}\``, `- Source SHA: \`${summaryPayload.sourceSha || "unknown"}\``, `- Candidate version: \`${summaryPayload.candidateVersion || "unknown"}\``, `- Baseline spec: \`${summaryPayload.baselineSpec}\``, result.status ? `- Result: \`${result.status}\`` : "", result.installTarget ? `- Install target: \`${result.installTarget}\`` : "", result.installVersion ? `- Install version: \`${result.installVersion}\`` : "", result.baselineVersion ? `- Baseline version: \`${result.baselineVersion}\`` : "", result.installedVersion ? `- Installed version: \`${result.installedVersion}\`` : "", result.installedCommit ? `- Installed commit: \`${result.installedCommit}\`` : "", result.cliPath ? `- CLI path: \`${result.cliPath}\`` : "", result.gatewayPort ? `- Gateway port: \`${result.gatewayPort}\`` : "", result.dashboardStatus ? `- Dashboard: \`${result.dashboardStatus}\`` : "", result.discordStatus ? `- Discord: \`${result.discordStatus}\`` : "", result.agentOutput ? `- Agent output: \`${trimForSummary(result.agentOutput)}\`` : "", result.error ? `- Error: \`${trimForSummary(result.error)}\`` : "", ].filter(Boolean); writeFileSync(summaryMarkdownPath, `${lines.join("\n")}\n`, "utf8"); } function writeCandidateManifest(baseDir, build) { const manifestPath = join(baseDir, "candidate.json"); writeFileSync( manifestPath, `${JSON.stringify( { sourceSha: build.sourceSha, candidateVersion: build.candidateVersion, candidateFileName: build.candidateFileName, }, null, 2, )}\n`, "utf8", ); } function platformLabel() { if (process.platform === "darwin") { return "macOS Release Checks"; } if (process.platform === "win32") { return "Windows Release Checks"; } return "Linux Release Checks"; } function requireArg(argsMap, key) { const value = argsMap[key]?.trim(); if (!value) { throw new Error(`Missing required --${key} argument.`); } return value; } function resolveCommandPath(command) { const pathValue = process.env.PATH ?? ""; const pathEntries = pathValue.split(process.platform === "win32" ? ";" : ":").filter(Boolean); const candidates = process.platform === "win32" && !command.toLowerCase().endsWith(".cmd") ? [`${command}.cmd`, `${command}.exe`, command] : [command]; for (const entry of pathEntries) { for (const candidate of candidates) { const fullPath = join(entry, candidate); if (existsSync(fullPath)) { return fullPath; } } } return null; } function shellEscapeForSh(value) { return value.replace(/'/gu, `'"'"'`); } function logPhase(scope, phase) { process.stdout.write(`[release-checks] ${scope}: ${phase}\n`); } function logLanePhase(lane, phase) { logPhase(`lane.${lane.name}`, phase); } function trimForSummary(value) { const trimmed = value.trim(); if (trimmed.length <= 600) { return trimmed; } return `${trimmed.slice(0, 600)}...`; } function formatError(error) { if (error instanceof Error) { return error.stack || error.message; } return String(error); } function sleep(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); } async function withAllocatedGatewayPort(lane, callback) { let lastError = null; for (let attempt = 1; attempt <= 3; attempt += 1) { const reservation = await reservePort(); lane.gatewayPort = reservation.port; await reservation.release(); try { return await callback(); } catch (error) { lastError = error; if (!isAddressInUseError(error) || attempt === 3) { throw error; } await sleep(250 * attempt); } } throw lastError ?? new Error("Failed to allocate a gateway port."); } function reservePort() { return new Promise((resolvePromise, rejectPromise) => { const server = createNetServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(); rejectPromise(new Error("Failed to allocate a TCP port.")); return; } resolvePromise({ port: address.port, release: () => new Promise((releaseResolve, releaseReject) => { server.close((error) => { if (error) { releaseReject(error); return; } releaseResolve(); }); }), }); }); server.once("error", rejectPromise); }); } function isAddressInUseError(error) { const message = formatError(error); return message.includes("EADDRINUSE") || /address.+in use/iu.test(message); }