mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:00:42 +00:00
883 lines
27 KiB
TypeScript
Executable File
883 lines
27 KiB
TypeScript
Executable File
#!/usr/bin/env -S pnpm tsx
|
|
import { spawn } from "node:child_process";
|
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
die,
|
|
ensureValue,
|
|
makeTempDir,
|
|
packOpenClaw,
|
|
parsePlatformList,
|
|
parseProvider,
|
|
repoRoot,
|
|
resolveHostIp,
|
|
resolveLatestVersion,
|
|
resolveProviderAuth,
|
|
run,
|
|
say,
|
|
startHostServer,
|
|
writeJson,
|
|
type HostServer,
|
|
type PackageArtifact,
|
|
type Platform,
|
|
type Provider,
|
|
type ProviderAuth,
|
|
} from "./common.ts";
|
|
import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm-update-scripts.ts";
|
|
import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts";
|
|
import { encodePowerShell } from "./powershell.ts";
|
|
|
|
interface NpmUpdateOptions {
|
|
packageSpec: string;
|
|
updateTarget: string;
|
|
platforms: Set<Platform>;
|
|
provider: Provider;
|
|
apiKeyEnv?: string;
|
|
modelId?: string;
|
|
json: boolean;
|
|
}
|
|
|
|
interface Job {
|
|
done: boolean;
|
|
label: string;
|
|
logPath: string;
|
|
promise: Promise<number>;
|
|
}
|
|
|
|
interface UpdateJobContext {
|
|
append(chunk: string | Uint8Array): void;
|
|
logPath: string;
|
|
}
|
|
|
|
interface NpmUpdateSummary {
|
|
packageSpec: string;
|
|
updateTarget: string;
|
|
updateExpected: string;
|
|
provider: Provider;
|
|
latestVersion: string;
|
|
currentHead: string;
|
|
runDir: string;
|
|
fresh: Record<Platform, string>;
|
|
update: Record<Platform, { status: string; version: string }>;
|
|
}
|
|
|
|
const macosVm = "macOS Tahoe";
|
|
const windowsVm = "Windows 11";
|
|
const linuxVmDefault = "Ubuntu 24.04.3 ARM64";
|
|
const updateTimeoutSeconds = Number(process.env.OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S || 1200);
|
|
|
|
function usage(): string {
|
|
return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
|
|
|
|
Options:
|
|
--package-spec <npm-spec> Baseline npm package spec. Default: openclaw@latest
|
|
--update-target <target> Target passed to guest 'openclaw update --tag'.
|
|
Default: host-served tgz packed from current checkout.
|
|
--platform <list> Comma-separated platforms to run: all, macos, windows, linux.
|
|
Default: all
|
|
--provider <openai|anthropic|minimax>
|
|
--model <provider/model> Override the model used for agent-turn smoke checks.
|
|
--api-key-env <var> Host env var name for provider API key.
|
|
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
|
--json Print machine-readable JSON summary.
|
|
-h, --help Show help.
|
|
`;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): NpmUpdateOptions {
|
|
const options: NpmUpdateOptions = {
|
|
apiKeyEnv: undefined,
|
|
json: false,
|
|
modelId: undefined,
|
|
packageSpec: "",
|
|
platforms: parsePlatformList("all"),
|
|
provider: "openai",
|
|
updateTarget: "",
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
switch (arg) {
|
|
case "--":
|
|
break;
|
|
case "--package-spec":
|
|
options.packageSpec = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--update-target":
|
|
options.updateTarget = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--platform":
|
|
case "--only":
|
|
options.platforms = parsePlatformList(ensureValue(argv, i, arg));
|
|
i++;
|
|
break;
|
|
case "--provider":
|
|
options.provider = parseProvider(ensureValue(argv, i, arg));
|
|
i++;
|
|
break;
|
|
case "--model":
|
|
options.modelId = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--api-key-env":
|
|
case "--openai-api-key-env":
|
|
options.apiKeyEnv = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--json":
|
|
options.json = true;
|
|
break;
|
|
case "-h":
|
|
case "--help":
|
|
process.stdout.write(usage());
|
|
process.exit(0);
|
|
default:
|
|
die(`unknown arg: ${arg}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function platformRecord<T>(value: T): Record<Platform, T> {
|
|
return { linux: value, macos: value, windows: value };
|
|
}
|
|
|
|
class NpmUpdateSmoke {
|
|
private auth: ProviderAuth;
|
|
private runDir = "";
|
|
private tgzDir = "";
|
|
private latestVersion = "";
|
|
private packageSpec = "";
|
|
private currentHead = "";
|
|
private currentHeadShort = "";
|
|
private hostIp = "";
|
|
private server: HostServer | null = null;
|
|
private artifact: PackageArtifact | null = null;
|
|
private updateTargetEffective = "";
|
|
private updateExpectedNeedle = "";
|
|
private linuxVm = linuxVmDefault;
|
|
|
|
private freshStatus = platformRecord("skip");
|
|
private updateStatus = platformRecord("skip");
|
|
private updateVersion = platformRecord("skip");
|
|
|
|
constructor(private options: NpmUpdateOptions) {
|
|
this.auth = resolveProviderAuth({
|
|
apiKeyEnv: options.apiKeyEnv,
|
|
modelId: options.modelId,
|
|
provider: options.provider,
|
|
});
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
this.runDir = await makeTempDir("openclaw-parallels-npm-update.");
|
|
this.tgzDir = await makeTempDir("openclaw-parallels-npm-update-tgz.");
|
|
try {
|
|
this.latestVersion = resolveLatestVersion();
|
|
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
|
|
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
|
|
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
|
|
quiet: true,
|
|
}).stdout.trim();
|
|
this.hostIp = resolveHostIp("");
|
|
|
|
if (this.options.platforms.has("linux")) {
|
|
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
|
|
}
|
|
this.preflightRegistryUpdateTarget();
|
|
|
|
say(`Run fresh npm baseline: ${this.packageSpec}`);
|
|
say(`Platforms: ${[...this.options.platforms].join(",")}`);
|
|
say(`Run dir: ${this.runDir}`);
|
|
await this.runFreshBaselines();
|
|
|
|
await this.prepareUpdateTarget();
|
|
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
|
|
await this.runSameGuestUpdates();
|
|
|
|
const summaryPath = await this.writeSummary();
|
|
if (this.options.json) {
|
|
process.stdout.write(await readFile(summaryPath, "utf8"));
|
|
} else {
|
|
say(`Run dir: ${this.runDir}`);
|
|
process.stdout.write(await readFile(summaryPath, "utf8"));
|
|
}
|
|
} finally {
|
|
await this.server?.stop().catch(() => undefined);
|
|
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private async runFreshBaselines(): Promise<void> {
|
|
const jobs: Job[] = [];
|
|
if (this.options.platforms.has("macos")) {
|
|
jobs.push(this.spawnFresh("macOS", "macos", []));
|
|
}
|
|
if (this.options.platforms.has("windows")) {
|
|
jobs.push(this.spawnFresh("Windows", "windows", []));
|
|
}
|
|
if (this.options.platforms.has("linux")) {
|
|
jobs.push(
|
|
this.spawnFresh("Linux", "linux", ["--vm", this.linuxVm], {
|
|
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR: "1",
|
|
}),
|
|
);
|
|
}
|
|
await this.monitorJobs("fresh", jobs);
|
|
for (const job of jobs) {
|
|
const status = (await job.promise) === 0 ? "pass" : "fail";
|
|
const platform = this.platformFromLabel(job.label);
|
|
this.freshStatus[platform] = status;
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} fresh baseline failed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private spawnFresh(
|
|
label: string,
|
|
platform: Platform,
|
|
extraArgs: string[],
|
|
env: NodeJS.ProcessEnv = {},
|
|
): Job {
|
|
const logPath = path.join(this.runDir, `${platform}-fresh.log`);
|
|
const args = [
|
|
"exec",
|
|
"tsx",
|
|
`scripts/e2e/parallels/${platform}-smoke.ts`,
|
|
"--mode",
|
|
"fresh",
|
|
"--provider",
|
|
this.options.provider,
|
|
"--model",
|
|
this.auth.modelId,
|
|
"--api-key-env",
|
|
this.auth.apiKeyEnv,
|
|
"--target-package-spec",
|
|
this.packageSpec,
|
|
"--json",
|
|
...extraArgs,
|
|
];
|
|
const job: Job = {
|
|
done: false,
|
|
label,
|
|
logPath,
|
|
promise: Promise.resolve(1),
|
|
};
|
|
job.promise = this.spawnLogged("pnpm", args, logPath, env).finally(() => {
|
|
job.done = true;
|
|
});
|
|
return job;
|
|
}
|
|
|
|
private async prepareUpdateTarget(): Promise<void> {
|
|
if (!this.options.updateTarget || this.options.updateTarget === "local-main") {
|
|
this.artifact = await packOpenClaw({
|
|
destination: this.tgzDir,
|
|
requireControlUi: true,
|
|
stageRuntimeDeps: true,
|
|
});
|
|
this.server = await startHostServer({
|
|
artifactPath: this.artifact.path,
|
|
dir: this.tgzDir,
|
|
hostIp: this.hostIp,
|
|
label: "current main tgz",
|
|
port: 0,
|
|
});
|
|
this.updateTargetEffective = this.server.urlFor(this.artifact.path);
|
|
this.updateExpectedNeedle = this.currentHeadShort;
|
|
return;
|
|
}
|
|
this.updateTargetEffective = this.options.updateTarget;
|
|
this.updateExpectedNeedle = this.isExplicitPackageTarget(this.updateTargetEffective)
|
|
? ""
|
|
: this.resolveRegistryTargetVersion(this.updateTargetEffective) || this.updateTargetEffective;
|
|
}
|
|
|
|
private async runSameGuestUpdates(): Promise<void> {
|
|
const jobs: Job[] = [];
|
|
if (this.options.platforms.has("macos")) {
|
|
ensureVmRunning(macosVm);
|
|
jobs.push(this.spawnUpdate("macOS", "macos", (ctx) => this.runMacosUpdate(ctx)));
|
|
}
|
|
if (this.options.platforms.has("windows")) {
|
|
ensureVmRunning(windowsVm);
|
|
jobs.push(this.spawnUpdate("Windows", "windows", (ctx) => this.runWindowsUpdate(ctx)));
|
|
}
|
|
if (this.options.platforms.has("linux")) {
|
|
ensureVmRunning(this.linuxVm);
|
|
jobs.push(this.spawnUpdate("Linux", "linux", (ctx) => this.runLinuxUpdate(ctx)));
|
|
}
|
|
await this.monitorJobs("update", jobs);
|
|
for (const job of jobs) {
|
|
const platform = this.platformFromLabel(job.label);
|
|
const status = (await job.promise) === 0 ? "pass" : "fail";
|
|
this.updateStatus[platform] = status;
|
|
this.updateVersion[platform] = await this.extractLastVersion(job.logPath);
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} update failed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private spawnUpdate(
|
|
label: string,
|
|
platform: Platform,
|
|
fn: (ctx: UpdateJobContext) => Promise<void> | void,
|
|
): Job {
|
|
const logPath = path.join(this.runDir, `${platform}-update.log`);
|
|
const job: Job = {
|
|
done: false,
|
|
label,
|
|
logPath,
|
|
promise: Promise.resolve(1),
|
|
};
|
|
job.promise = (async () => {
|
|
let log = "";
|
|
const append = (chunk: string | Uint8Array): boolean => {
|
|
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
log += text;
|
|
return true;
|
|
};
|
|
const timeout = setTimeout(() => {
|
|
append(`${label} update timed out after ${updateTimeoutSeconds}s\n`);
|
|
}, updateTimeoutSeconds * 1000);
|
|
try {
|
|
await fn({ append, logPath });
|
|
await writeFile(logPath, log, "utf8");
|
|
return 0;
|
|
} catch (error) {
|
|
append(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
await writeFile(logPath, log, "utf8");
|
|
return 1;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
})().finally(() => {
|
|
job.done = true;
|
|
});
|
|
return job;
|
|
}
|
|
|
|
private async runMacosUpdate(ctx: UpdateJobContext): Promise<void> {
|
|
await this.guestMacos(this.updateScript("macos"), updateTimeoutSeconds * 1000, ctx);
|
|
}
|
|
|
|
private runWindowsUpdate(ctx: UpdateJobContext): Promise<void> {
|
|
return this.guestWindows(this.updateScript("windows"), updateTimeoutSeconds * 1000, ctx);
|
|
}
|
|
|
|
private async runLinuxUpdate(ctx: UpdateJobContext): Promise<void> {
|
|
await this.guestLinux(this.updateScript("linux"), updateTimeoutSeconds * 1000, ctx);
|
|
}
|
|
|
|
private updateScript(platform: Platform): string {
|
|
const input = {
|
|
auth: this.auth,
|
|
expectedNeedle: this.updateExpectedNeedle,
|
|
updateTarget: this.updateTargetEffective,
|
|
};
|
|
switch (platform) {
|
|
case "macos":
|
|
return macosUpdateScript(input);
|
|
case "windows":
|
|
return windowsUpdateScript(input);
|
|
case "linux":
|
|
return linuxUpdateScript(input);
|
|
}
|
|
return die("unsupported platform");
|
|
}
|
|
|
|
private spawnLogged(
|
|
command: string,
|
|
args: string[],
|
|
logPath: string,
|
|
env: NodeJS.ProcessEnv = {},
|
|
): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: repoRoot,
|
|
env: { ...process.env, ...env },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let log = "";
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
log += chunk.toString("utf8");
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
log += chunk.toString("utf8");
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", async (code) => {
|
|
await writeFile(logPath, log, "utf8");
|
|
resolve(code ?? 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async monitorJobs(label: string, jobs: Job[]): Promise<void> {
|
|
const pending = new Set(jobs.map((job) => job.label));
|
|
while (pending.size > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
for (const job of jobs) {
|
|
if (!pending.has(job.label)) {
|
|
continue;
|
|
}
|
|
if (job.done) {
|
|
pending.delete(job.label);
|
|
}
|
|
}
|
|
if (pending.size > 0) {
|
|
say(`${label} still running: ${[...pending].join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async guestMacos(
|
|
script: string,
|
|
timeoutMs: number,
|
|
ctx: UpdateJobContext,
|
|
): Promise<void> {
|
|
const scriptPath = this.writeGuestScript(
|
|
macosVm,
|
|
script,
|
|
"openclaw-parallels-npm-update-macos",
|
|
);
|
|
const macosExecArgs = this.resolveMacosUpdateExecArgs(ctx);
|
|
const sudoUserArgIndex = macosExecArgs.indexOf("-u");
|
|
const sudoUser =
|
|
sudoUserArgIndex >= 0 && sudoUserArgIndex + 1 < macosExecArgs.length
|
|
? macosExecArgs[sudoUserArgIndex + 1]
|
|
: "";
|
|
if (sudoUser) {
|
|
run("prlctl", ["exec", macosVm, "/usr/sbin/chown", sudoUser, scriptPath], {
|
|
timeoutMs: 30_000,
|
|
});
|
|
}
|
|
try {
|
|
const status = await this.runStreamingToJobLog(
|
|
"prlctl",
|
|
["exec", macosVm, ...macosExecArgs, "/bin/bash", scriptPath],
|
|
timeoutMs,
|
|
ctx,
|
|
);
|
|
if (status !== 0) {
|
|
throw new Error(`macOS update command failed with exit code ${status}`);
|
|
}
|
|
} finally {
|
|
this.removeGuestScript(macosVm, scriptPath);
|
|
}
|
|
}
|
|
|
|
private resolveMacosUpdateExecArgs(ctx: UpdateJobContext): string[] {
|
|
const guestPath =
|
|
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
const currentUser = run("prlctl", ["exec", macosVm, "--current-user", "whoami"], {
|
|
check: false,
|
|
quiet: true,
|
|
timeoutMs: 45_000,
|
|
});
|
|
const user = currentUser.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? "";
|
|
if (currentUser.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) {
|
|
return ["--current-user", "/usr/bin/env", `PATH=${guestPath}`];
|
|
}
|
|
|
|
const fallbackUser = this.resolveMacosDesktopUser();
|
|
if (!fallbackUser) {
|
|
ctx.append(currentUser.stdout);
|
|
ctx.append(currentUser.stderr);
|
|
throw new Error("macOS desktop user unavailable before update phase");
|
|
}
|
|
ctx.append(
|
|
`desktop user unavailable via Parallels --current-user; using root sudo fallback for ${fallbackUser}\n`,
|
|
);
|
|
const home = this.resolveMacosDesktopHome(fallbackUser);
|
|
return [
|
|
"/usr/bin/sudo",
|
|
"-H",
|
|
"-u",
|
|
fallbackUser,
|
|
"/usr/bin/env",
|
|
`HOME=${home}`,
|
|
`USER=${fallbackUser}`,
|
|
`LOGNAME=${fallbackUser}`,
|
|
`PATH=${guestPath}`,
|
|
];
|
|
}
|
|
|
|
private resolveMacosDesktopUser(): string {
|
|
const consoleUser =
|
|
run("prlctl", ["exec", macosVm, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
|
|
check: false,
|
|
quiet: true,
|
|
timeoutMs: 30_000,
|
|
})
|
|
.stdout.trim()
|
|
.replaceAll("\r", "")
|
|
.split("\n")
|
|
.at(-1) ?? "";
|
|
if (
|
|
/^[A-Za-z0-9._-]+$/.test(consoleUser) &&
|
|
consoleUser !== "root" &&
|
|
consoleUser !== "loginwindow"
|
|
) {
|
|
return consoleUser;
|
|
}
|
|
const users = run(
|
|
"prlctl",
|
|
["exec", macosVm, "/usr/bin/dscl", ".", "-list", "/Users", "NFSHomeDirectory"],
|
|
{ check: false, quiet: true, timeoutMs: 30_000 },
|
|
).stdout.replaceAll("\r", "");
|
|
for (const line of users.split("\n")) {
|
|
const [user, home] = line.trim().split(/\s+/);
|
|
if (
|
|
user &&
|
|
home?.startsWith("/Users/") &&
|
|
!user.startsWith("_") &&
|
|
user !== "Shared" &&
|
|
user !== ".localized"
|
|
) {
|
|
return user;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
private resolveMacosDesktopHome(user: string): string {
|
|
const output = run(
|
|
"prlctl",
|
|
["exec", macosVm, "/usr/bin/dscl", ".", "-read", `/Users/${user}`, "NFSHomeDirectory"],
|
|
{ check: false, quiet: true, timeoutMs: 30_000 },
|
|
).stdout.replaceAll("\r", "");
|
|
const match = /NFSHomeDirectory:\s*(\S+)/.exec(output);
|
|
return match?.[1] ?? `/Users/${user}`;
|
|
}
|
|
|
|
private async guestWindows(
|
|
script: string,
|
|
timeoutMs: number,
|
|
ctx: UpdateJobContext,
|
|
): Promise<void> {
|
|
const fileBase = `openclaw-parallels-npm-update-windows-${process.pid}-${Date.now()}`;
|
|
const pathsScript = `$base = Join-Path $env:TEMP '${fileBase}'
|
|
$scriptPath = "$base.ps1"
|
|
$logPath = "$base.log"
|
|
$donePath = "$base.done"
|
|
$exitPath = "$base.exit"`;
|
|
const payload = `$ErrorActionPreference = 'Stop'
|
|
$PSNativeCommandUseErrorActionPreference = $false
|
|
${pathsScript}
|
|
try {
|
|
& {
|
|
${script}
|
|
} *>&1 | ForEach-Object { $_ | Out-String | Add-Content -Path $logPath -Encoding UTF8 }
|
|
Set-Content -Path $exitPath -Value '0' -Encoding UTF8
|
|
} catch {
|
|
$_ | Out-String | Add-Content -Path $logPath -Encoding UTF8
|
|
Set-Content -Path $exitPath -Value '1' -Encoding UTF8
|
|
} finally {
|
|
Set-Content -Path $donePath -Value 'done' -Encoding UTF8
|
|
}`;
|
|
const writeScript = run(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
windowsVm,
|
|
"--current-user",
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-EncodedCommand",
|
|
encodePowerShell(`${pathsScript}
|
|
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue
|
|
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))
|
|
if (!(Test-Path $scriptPath)) { throw "background update script was not written" }`),
|
|
],
|
|
{ check: false, input: payload, timeoutMs: Math.min(timeoutMs, 120_000) },
|
|
);
|
|
if (writeScript.stdout) {
|
|
ctx.append(writeScript.stdout);
|
|
}
|
|
if (writeScript.stderr) {
|
|
ctx.append(writeScript.stderr);
|
|
}
|
|
if (writeScript.status !== 0) {
|
|
throw new Error(
|
|
`Windows update background script write failed with exit code ${writeScript.status}`,
|
|
);
|
|
}
|
|
|
|
const launchStatus = await this.runStreamingToJobLog(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
windowsVm,
|
|
"--current-user",
|
|
"cmd.exe",
|
|
"/d",
|
|
"/s",
|
|
"/c",
|
|
`start "" /min powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%TEMP%\\${fileBase}.ps1"`,
|
|
],
|
|
20_000,
|
|
ctx,
|
|
);
|
|
if (launchStatus !== 0 && launchStatus !== 124) {
|
|
throw new Error(`Windows update background launch failed with exit code ${launchStatus}`);
|
|
}
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastLogOffset = 0;
|
|
while (Date.now() < deadline) {
|
|
const poll = run(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
windowsVm,
|
|
"--current-user",
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-EncodedCommand",
|
|
encodePowerShell(`${pathsScript}
|
|
$offset = ${lastLogOffset}
|
|
if (Test-Path $logPath) {
|
|
$bytes = [System.IO.File]::ReadAllBytes($logPath)
|
|
if ($bytes.Length -gt $offset) {
|
|
"__OPENCLAW_LOG_OFFSET__:$($bytes.Length)"
|
|
[System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset)
|
|
}
|
|
}
|
|
if (Test-Path $donePath) {
|
|
$backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' }
|
|
"__OPENCLAW_BACKGROUND_EXIT__:$backgroundExit"
|
|
'__OPENCLAW_BACKGROUND_DONE__'
|
|
if ($backgroundExit -ne '0') { exit 23 }
|
|
exit 0
|
|
}`),
|
|
],
|
|
{ check: false, timeoutMs: Math.min(30_000, Math.max(1_000, deadline - Date.now())) },
|
|
);
|
|
if (poll.stdout) {
|
|
ctx.append(poll.stdout);
|
|
}
|
|
if (poll.stderr) {
|
|
ctx.append(poll.stderr);
|
|
}
|
|
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
|
|
if (offsetMatch) {
|
|
lastLogOffset = Number(offsetMatch[1]);
|
|
}
|
|
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
|
|
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
|
|
const backgroundExit = exitMatch?.[1] ?? "0";
|
|
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
|
|
throw new Error("Windows update failed");
|
|
}
|
|
run(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
windowsVm,
|
|
"--current-user",
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-EncodedCommand",
|
|
encodePowerShell(`${pathsScript}
|
|
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue`),
|
|
],
|
|
{ check: false, timeoutMs: 30_000 },
|
|
);
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
|
}
|
|
throw new Error(`Windows update timed out after ${updateTimeoutSeconds}s`);
|
|
}
|
|
|
|
private async guestLinux(
|
|
script: string,
|
|
timeoutMs: number,
|
|
ctx: UpdateJobContext,
|
|
): Promise<void> {
|
|
const scriptPath = this.writeGuestScript(
|
|
this.linuxVm,
|
|
script,
|
|
"openclaw-parallels-npm-update-linux",
|
|
);
|
|
try {
|
|
const status = await this.runStreamingToJobLog(
|
|
"prlctl",
|
|
[
|
|
"exec",
|
|
this.linuxVm,
|
|
"/usr/bin/env",
|
|
"HOME=/root",
|
|
"PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/snap/bin",
|
|
"bash",
|
|
scriptPath,
|
|
],
|
|
timeoutMs,
|
|
ctx,
|
|
);
|
|
if (status !== 0) {
|
|
throw new Error(`Linux update command failed with exit code ${status}`);
|
|
}
|
|
} finally {
|
|
this.removeGuestScript(this.linuxVm, scriptPath);
|
|
}
|
|
}
|
|
|
|
private writeGuestScript(vm: string, script: string, prefix: string): string {
|
|
const scriptPath = `/tmp/${prefix}-${process.pid}-${Date.now()}.sh`;
|
|
const write = run("prlctl", ["exec", vm, "/usr/bin/tee", scriptPath], {
|
|
check: false,
|
|
input: script,
|
|
quiet: true,
|
|
timeoutMs: 120_000,
|
|
});
|
|
if (write.status !== 0) {
|
|
throw new Error(`failed to write guest script ${scriptPath}: ${write.stderr.trim()}`);
|
|
}
|
|
const chmod = run("prlctl", ["exec", vm, "/bin/chmod", "755", scriptPath], {
|
|
check: false,
|
|
quiet: true,
|
|
timeoutMs: 30_000,
|
|
});
|
|
if (chmod.status !== 0) {
|
|
throw new Error(`failed to chmod guest script ${scriptPath}: ${chmod.stderr.trim()}`);
|
|
}
|
|
return scriptPath;
|
|
}
|
|
|
|
private removeGuestScript(vm: string, scriptPath: string): void {
|
|
run("prlctl", ["exec", vm, "/bin/rm", "-f", scriptPath], {
|
|
check: false,
|
|
quiet: true,
|
|
timeoutMs: 30_000,
|
|
});
|
|
}
|
|
|
|
private async runStreamingToJobLog(
|
|
command: string,
|
|
args: string[],
|
|
timeoutMs: number,
|
|
ctx: UpdateJobContext,
|
|
): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: repoRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => ctx.append(chunk));
|
|
child.stderr.on("data", (chunk: Buffer) => ctx.append(chunk));
|
|
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
|
|
}, timeoutMs);
|
|
|
|
child.on("error", reject);
|
|
child.on("close", (code, signal) => {
|
|
clearTimeout(timer);
|
|
if (timedOut) {
|
|
resolve(124);
|
|
return;
|
|
}
|
|
resolve(code ?? (signal ? 128 : 1));
|
|
});
|
|
});
|
|
}
|
|
|
|
private resolveRegistryTargetVersion(target: string): string {
|
|
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
|
|
return (
|
|
run("npm", ["view", spec, "version"], { check: false, quiet: true })
|
|
.stdout.trim()
|
|
.split("\n")
|
|
.at(-1) ?? ""
|
|
);
|
|
}
|
|
|
|
private isExplicitPackageTarget(target: string): boolean {
|
|
return (
|
|
target.includes("://") ||
|
|
target.includes("#") ||
|
|
/^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/.test(target)
|
|
);
|
|
}
|
|
|
|
private preflightRegistryUpdateTarget(): void {
|
|
if (
|
|
!this.options.updateTarget ||
|
|
this.options.updateTarget === "local-main" ||
|
|
this.isExplicitPackageTarget(this.options.updateTarget)
|
|
) {
|
|
return;
|
|
}
|
|
const baseline = this.resolveRegistryTargetVersion(this.packageSpec);
|
|
const target = this.resolveRegistryTargetVersion(this.options.updateTarget);
|
|
if (baseline && target && baseline === target) {
|
|
die(
|
|
`--update-target ${this.options.updateTarget} resolves to openclaw@${target}, same as baseline ${this.packageSpec}; publish or choose a newer --update-target before running VM update coverage`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private platformFromLabel(label: string): Platform {
|
|
if (label === "macOS") {
|
|
return "macos";
|
|
}
|
|
return label.toLowerCase() as Platform;
|
|
}
|
|
|
|
private async extractLastVersion(logPath: string): Promise<string> {
|
|
const log = await readFile(logPath, "utf8").catch(() => "");
|
|
const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)];
|
|
return matches.at(-1)?.[1] ?? "";
|
|
}
|
|
|
|
private dumpLogTail(logPath: string): void {
|
|
const log = run("tail", ["-n", "80", logPath], { check: false, quiet: true }).stdout;
|
|
if (log) {
|
|
process.stderr.write(log);
|
|
}
|
|
}
|
|
|
|
private async writeSummary(): Promise<string> {
|
|
const summary: NpmUpdateSummary = {
|
|
currentHead: this.currentHeadShort,
|
|
fresh: this.freshStatus,
|
|
latestVersion: this.latestVersion,
|
|
packageSpec: this.packageSpec,
|
|
provider: this.options.provider,
|
|
runDir: this.runDir,
|
|
update: {
|
|
linux: { status: this.updateStatus.linux, version: this.updateVersion.linux },
|
|
macos: { status: this.updateStatus.macos, version: this.updateVersion.macos },
|
|
windows: { status: this.updateStatus.windows, version: this.updateVersion.windows },
|
|
},
|
|
updateExpected: this.updateExpectedNeedle,
|
|
updateTarget: this.updateTargetEffective,
|
|
};
|
|
const summaryPath = path.join(this.runDir, "summary.json");
|
|
await writeJson(summaryPath, summary);
|
|
return summaryPath;
|
|
}
|
|
}
|
|
|
|
await new NpmUpdateSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
|
|
die(error instanceof Error ? error.message : String(error));
|
|
});
|