mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:30:47 +00:00
1044 lines
33 KiB
TypeScript
Executable File
1044 lines
33 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,
|
|
resolveOpenClawRegistryVersion,
|
|
resolveProviderAuth,
|
|
resolveWindowsProviderAuth,
|
|
run,
|
|
say,
|
|
shellQuote,
|
|
startHostServer,
|
|
writeSummaryMarkdown,
|
|
writeJson,
|
|
type HostServer,
|
|
type PackageArtifact,
|
|
type Platform,
|
|
type Provider,
|
|
type ProviderAuth,
|
|
} from "./common.ts";
|
|
import { runWindowsBackgroundPowerShell } from "./guest-transports.ts";
|
|
import { linuxUpdateScript, macosUpdateScript, windowsUpdateScript } from "./npm-update-scripts.ts";
|
|
import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts";
|
|
|
|
interface NpmUpdateOptions {
|
|
betaValidation?: string;
|
|
freshTargetSpec?: string;
|
|
packageSpec: string;
|
|
updateTarget: string;
|
|
platforms: Set<Platform>;
|
|
provider: Provider;
|
|
apiKeyEnv?: string;
|
|
modelId?: string;
|
|
json: boolean;
|
|
}
|
|
|
|
interface Job {
|
|
done: boolean;
|
|
durationMs: number;
|
|
label: string;
|
|
lastBytes: number;
|
|
lastOutputAt: number;
|
|
lastPhase: string;
|
|
logPath: string;
|
|
promise: Promise<number>;
|
|
rerunCommand: string;
|
|
startedAt: number;
|
|
}
|
|
|
|
interface UpdateJobContext {
|
|
append(chunk: string | Uint8Array): void;
|
|
logPath: string;
|
|
}
|
|
|
|
interface NpmUpdateSummary {
|
|
packageSpec: string;
|
|
updateTarget: string;
|
|
updateExpected: string;
|
|
updateTargetBuildCommit: string;
|
|
updateTargetPackageVersion: string;
|
|
updateTargetTarball: string;
|
|
provider: Provider;
|
|
latestVersion: string;
|
|
currentHead: string;
|
|
runDir: string;
|
|
slowestTiming?: {
|
|
durationMs: number;
|
|
label: string;
|
|
phase: "fresh" | "fresh-target" | "update";
|
|
};
|
|
totalDurationMs: number;
|
|
fresh: Record<Platform, string>;
|
|
freshTarget: Record<Platform, string>;
|
|
freshTargetSpec: string;
|
|
update: Record<Platform, { status: string; version: string }>;
|
|
timings: Array<{
|
|
durationMs: number;
|
|
label: string;
|
|
logPath: string;
|
|
phase: "fresh" | "fresh-target" | "update";
|
|
status: 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.
|
|
--fresh-target <npm-spec> Also run fresh install smoke for this package after update lanes.
|
|
--beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update
|
|
plus fresh target install. Default target when flag is bare: beta.
|
|
Aliases like beta3 resolve to the latest *-beta.3 version.
|
|
--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,
|
|
betaValidation: undefined,
|
|
freshTargetSpec: 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 "--fresh-target":
|
|
options.freshTargetSpec = ensureValue(argv, i, arg);
|
|
i++;
|
|
break;
|
|
case "--beta-validation": {
|
|
const next = argv[i + 1];
|
|
if (next && !next.startsWith("-")) {
|
|
options.betaValidation = next;
|
|
i++;
|
|
} else {
|
|
options.betaValidation = "beta";
|
|
}
|
|
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 };
|
|
}
|
|
|
|
function formatDuration(durationMs: number): string {
|
|
const seconds = Math.round(durationMs / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainder = seconds % 60;
|
|
return minutes > 0 ? `${minutes}m ${remainder}s` : `${remainder}s`;
|
|
}
|
|
|
|
class NpmUpdateSmoke {
|
|
private auth: ProviderAuth;
|
|
private windowsAuth: 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 freshTargetSpec = "";
|
|
private startedAt = Date.now();
|
|
private updateTargetBuildCommit = "";
|
|
private updateTargetEffective = "";
|
|
private updateExpectedNeedle = "";
|
|
private updateTargetPackageVersion = "";
|
|
private updateTargetTarball = "";
|
|
private linuxVm = linuxVmDefault;
|
|
|
|
private freshStatus = platformRecord("skip");
|
|
private freshTargetStatus = platformRecord("skip");
|
|
private updateStatus = platformRecord("skip");
|
|
private updateVersion = platformRecord("skip");
|
|
private timings: NpmUpdateSummary["timings"] = [];
|
|
|
|
constructor(private options: NpmUpdateOptions) {
|
|
this.auth = resolveProviderAuth({
|
|
apiKeyEnv: options.apiKeyEnv,
|
|
modelId: options.modelId,
|
|
provider: options.provider,
|
|
});
|
|
this.windowsAuth = resolveWindowsProviderAuth({
|
|
apiKeyEnv: options.apiKeyEnv,
|
|
modelId: options.modelId,
|
|
provider: options.provider,
|
|
});
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
this.startedAt = Date.now();
|
|
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("");
|
|
this.configurePublishedTargets();
|
|
|
|
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();
|
|
|
|
if (this.freshTargetSpec) {
|
|
say(`Run fresh target npm install: ${this.freshTargetSpec}`);
|
|
await this.runFreshTargetInstalls();
|
|
}
|
|
|
|
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;
|
|
this.recordTiming("fresh", job, status);
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} fresh baseline failed; rerun: ${job.rerunCommand}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async runFreshTargetInstalls(): Promise<void> {
|
|
const jobs: Job[] = [];
|
|
if (this.options.platforms.has("macos")) {
|
|
jobs.push(this.spawnFresh("macOS", "macos", [], {}, this.freshTargetSpec, "fresh-target"));
|
|
}
|
|
if (this.options.platforms.has("windows")) {
|
|
jobs.push(
|
|
this.spawnFresh("Windows", "windows", [], {}, this.freshTargetSpec, "fresh-target"),
|
|
);
|
|
}
|
|
if (this.options.platforms.has("linux")) {
|
|
jobs.push(
|
|
this.spawnFresh(
|
|
"Linux",
|
|
"linux",
|
|
["--vm", this.linuxVm],
|
|
{
|
|
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR: "1",
|
|
},
|
|
this.freshTargetSpec,
|
|
"fresh-target",
|
|
),
|
|
);
|
|
}
|
|
await this.monitorJobs("fresh-target", jobs);
|
|
for (const job of jobs) {
|
|
const status = (await job.promise) === 0 ? "pass" : "fail";
|
|
const platform = this.platformFromLabel(job.label);
|
|
this.freshTargetStatus[platform] = status;
|
|
this.recordTiming("fresh-target", job, status);
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} fresh target failed; rerun: ${job.rerunCommand}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private spawnFresh(
|
|
label: string,
|
|
platform: Platform,
|
|
extraArgs: string[],
|
|
env: NodeJS.ProcessEnv = {},
|
|
packageSpec = this.packageSpec,
|
|
phase: "fresh" | "fresh-target" = "fresh",
|
|
): Job {
|
|
const logPath = path.join(this.runDir, `${platform}-${phase}.log`);
|
|
const auth = this.authForPlatform(platform);
|
|
const args = [
|
|
"exec",
|
|
"tsx",
|
|
`scripts/e2e/parallels/${platform}-smoke.ts`,
|
|
"--mode",
|
|
"fresh",
|
|
"--provider",
|
|
this.options.provider,
|
|
"--model",
|
|
auth.modelId,
|
|
"--api-key-env",
|
|
auth.apiKeyEnv,
|
|
"--target-package-spec",
|
|
packageSpec,
|
|
"--json",
|
|
...extraArgs,
|
|
];
|
|
const startedAt = Date.now();
|
|
const job: Job = {
|
|
done: false,
|
|
durationMs: 0,
|
|
label,
|
|
lastBytes: 0,
|
|
lastOutputAt: startedAt,
|
|
lastPhase: "starting",
|
|
logPath,
|
|
promise: Promise.resolve(1),
|
|
rerunCommand: this.formatRerun("pnpm", args, env),
|
|
startedAt,
|
|
};
|
|
job.promise = this.spawnLogged("pnpm", args, logPath, env, (text) =>
|
|
this.noteJobOutput(job, text),
|
|
).finally(() => {
|
|
job.durationMs = Date.now() - job.startedAt;
|
|
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,
|
|
});
|
|
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;
|
|
this.updateTargetPackageVersion = this.artifact.version ?? "";
|
|
this.updateTargetBuildCommit =
|
|
this.artifact.buildCommitShort ?? this.artifact.buildCommit ?? "";
|
|
this.updateTargetTarball = this.updateTargetEffective;
|
|
return;
|
|
}
|
|
this.updateTargetEffective = this.options.updateTarget;
|
|
this.updateExpectedNeedle = this.isExplicitPackageTarget(this.updateTargetEffective)
|
|
? ""
|
|
: resolveOpenClawRegistryVersion(this.updateTargetEffective) || this.updateTargetEffective;
|
|
const metadata = this.resolveRegistryPackageMetadata(this.updateTargetEffective);
|
|
this.updateTargetPackageVersion = metadata.version;
|
|
this.updateTargetBuildCommit =
|
|
metadata.gitHead || this.resolvePackageBuildCommit(metadata.tarball);
|
|
this.updateTargetTarball = metadata.tarball;
|
|
}
|
|
|
|
private resolvePackageBuildCommit(tarball: string): string {
|
|
if (!tarball) {
|
|
return "";
|
|
}
|
|
const output = run(
|
|
"bash",
|
|
["-lc", `curl -fsSL ${shellQuote(tarball)} | tar -xzOf - package/dist/build-info.json`],
|
|
{
|
|
check: false,
|
|
quiet: true,
|
|
},
|
|
).stdout.trim();
|
|
if (!output) {
|
|
return "";
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(output) as { commit?: string };
|
|
return parsed.commit ? parsed.commit.slice(0, 7) : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private resolveRegistryPackageMetadata(target: string): {
|
|
gitHead: string;
|
|
tarball: string;
|
|
version: string;
|
|
} {
|
|
if (this.isExplicitPackageTarget(target)) {
|
|
return { gitHead: "", tarball: "", version: "" };
|
|
}
|
|
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
|
|
const output = run("npm", ["view", spec, "version", "dist.tarball", "gitHead", "--json"], {
|
|
check: false,
|
|
quiet: true,
|
|
}).stdout.trim();
|
|
if (!output) {
|
|
return { gitHead: "", tarball: "", version: "" };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(output) as {
|
|
dist?: { tarball?: string };
|
|
gitHead?: string;
|
|
version?: string;
|
|
};
|
|
return {
|
|
gitHead: parsed.gitHead ?? "",
|
|
tarball: parsed.dist?.tarball ?? "",
|
|
version: parsed.version ?? "",
|
|
};
|
|
} catch {
|
|
return { gitHead: "", tarball: "", version: "" };
|
|
}
|
|
}
|
|
|
|
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);
|
|
this.recordTiming("update", job, status);
|
|
if (status !== "pass") {
|
|
this.dumpLogTail(job.logPath);
|
|
die(`${job.label} update failed; rerun: ${job.rerunCommand}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private spawnUpdate(
|
|
label: string,
|
|
platform: Platform,
|
|
fn: (ctx: UpdateJobContext) => Promise<void> | void,
|
|
): Job {
|
|
const logPath = path.join(this.runDir, `${platform}-update.log`);
|
|
const startedAt = Date.now();
|
|
const job: Job = {
|
|
done: false,
|
|
durationMs: 0,
|
|
label,
|
|
lastBytes: 0,
|
|
lastOutputAt: startedAt,
|
|
lastPhase: "starting",
|
|
logPath,
|
|
promise: Promise.resolve(1),
|
|
rerunCommand: `inspect ${logPath}; rerun aggregate phase with --platform ${platform}`,
|
|
startedAt,
|
|
};
|
|
job.promise = (async () => {
|
|
let log = "";
|
|
const append = (chunk: string | Uint8Array): void => {
|
|
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
log += text;
|
|
this.noteJobOutput(job, text);
|
|
};
|
|
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.durationMs = Date.now() - job.startedAt;
|
|
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.authForPlatform(platform),
|
|
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 authForPlatform(platform: Platform): ProviderAuth {
|
|
return platform === "windows" ? this.windowsAuth : this.auth;
|
|
}
|
|
|
|
private spawnLogged(
|
|
command: string,
|
|
args: string[],
|
|
logPath: string,
|
|
env: NodeJS.ProcessEnv = {},
|
|
onOutput: (text: string) => void = () => undefined,
|
|
): 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) => {
|
|
const text = chunk.toString("utf8");
|
|
log += text;
|
|
onOutput(text);
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
const text = chunk.toString("utf8");
|
|
log += text;
|
|
onOutput(text);
|
|
});
|
|
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) {
|
|
const status = jobs
|
|
.filter((job) => pending.has(job.label))
|
|
.map((job) => {
|
|
const elapsed = Math.floor((Date.now() - job.startedAt) / 1000);
|
|
const stale = Math.floor((Date.now() - job.lastOutputAt) / 1000);
|
|
return `${job.label}:${job.lastPhase} ${elapsed}s stale=${stale}s bytes=${job.lastBytes}`;
|
|
})
|
|
.join(", ");
|
|
say(`${label} still running: ${status}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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> {
|
|
await runWindowsBackgroundPowerShell({
|
|
append: (chunk) => ctx.append(chunk),
|
|
label: "Windows update",
|
|
script,
|
|
timeoutMs,
|
|
vmName: windowsVm,
|
|
});
|
|
}
|
|
|
|
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 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 = resolveOpenClawRegistryVersion(this.packageSpec);
|
|
const target = resolveOpenClawRegistryVersion(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]*)/gi)];
|
|
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(`\n--- tail ${logPath} ---\n`);
|
|
process.stderr.write(log);
|
|
}
|
|
}
|
|
|
|
private recordTiming(phase: "fresh" | "fresh-target" | "update", job: Job, status: string): void {
|
|
this.timings.push({
|
|
durationMs: job.durationMs || Date.now() - job.startedAt,
|
|
label: job.label,
|
|
logPath: job.logPath,
|
|
phase,
|
|
status,
|
|
});
|
|
}
|
|
|
|
private configurePublishedTargets(): void {
|
|
if (this.options.betaValidation) {
|
|
const version = resolveOpenClawRegistryVersion(this.options.betaValidation);
|
|
if (!version) {
|
|
die(`could not resolve beta validation target: ${this.options.betaValidation}`);
|
|
}
|
|
this.options.updateTarget = version;
|
|
this.options.freshTargetSpec = `openclaw@${version}`;
|
|
say(`Beta validation target: openclaw@${version}`);
|
|
} else if (
|
|
this.options.updateTarget &&
|
|
this.options.updateTarget !== "local-main" &&
|
|
!this.isExplicitPackageTarget(this.options.updateTarget)
|
|
) {
|
|
const version = resolveOpenClawRegistryVersion(this.options.updateTarget);
|
|
if (version) {
|
|
this.options.updateTarget = version;
|
|
}
|
|
}
|
|
|
|
if (this.options.freshTargetSpec) {
|
|
const version = resolveOpenClawRegistryVersion(this.options.freshTargetSpec);
|
|
this.freshTargetSpec = version ? `openclaw@${version}` : this.options.freshTargetSpec;
|
|
}
|
|
}
|
|
|
|
private noteJobOutput(job: Job, text: string): void {
|
|
job.lastOutputAt = Date.now();
|
|
job.lastBytes += text.length;
|
|
const matches = [...text.matchAll(/[=]=>\s*([A-Za-z0-9_.-]+)/g)];
|
|
const phase = matches.at(-1)?.[1];
|
|
if (phase) {
|
|
job.lastPhase = phase;
|
|
}
|
|
}
|
|
|
|
private formatRerun(command: string, args: string[], env: NodeJS.ProcessEnv): string {
|
|
const envPrefix = Object.entries(env)
|
|
.filter(([, value]) => value !== undefined)
|
|
.map(([key, value]) => `${key}=${shellQuote(String(value))}`);
|
|
return [...envPrefix, command, ...args.map(shellQuote)].join(" ");
|
|
}
|
|
|
|
private async writeSummary(): Promise<string> {
|
|
const slowestTiming = this.timings.toSorted((a, b) => b.durationMs - a.durationMs)[0];
|
|
const summary: NpmUpdateSummary = {
|
|
currentHead: this.currentHeadShort,
|
|
fresh: this.freshStatus,
|
|
freshTarget: this.freshTargetStatus,
|
|
freshTargetSpec: this.freshTargetSpec,
|
|
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 },
|
|
},
|
|
timings: this.timings,
|
|
slowestTiming: slowestTiming
|
|
? {
|
|
durationMs: slowestTiming.durationMs,
|
|
label: slowestTiming.label,
|
|
phase: slowestTiming.phase,
|
|
}
|
|
: undefined,
|
|
totalDurationMs: Date.now() - this.startedAt,
|
|
updateExpected: this.updateExpectedNeedle,
|
|
updateTargetBuildCommit: this.updateTargetBuildCommit,
|
|
updateTargetPackageVersion: this.updateTargetPackageVersion,
|
|
updateTargetTarball: this.updateTargetTarball,
|
|
updateTarget: this.updateTargetEffective,
|
|
};
|
|
const summaryPath = path.join(this.runDir, "summary.json");
|
|
await writeJson(summaryPath, summary);
|
|
await writeSummaryMarkdown({
|
|
lines: [
|
|
`- package spec: ${summary.packageSpec}`,
|
|
`- update target: ${summary.updateTarget}`,
|
|
`- update target package: ${summary.updateTargetPackageVersion || "unknown"}${summary.updateTargetBuildCommit ? ` (${summary.updateTargetBuildCommit})` : ""}`,
|
|
`- update target tarball: ${summary.updateTargetTarball || "n/a"}`,
|
|
`- update expected: ${summary.updateExpected}`,
|
|
`- fresh: macOS=${summary.fresh.macos}, Windows=${summary.fresh.windows}, Linux=${summary.fresh.linux}`,
|
|
`- update: macOS=${summary.update.macos.status} (${summary.update.macos.version}), Windows=${summary.update.windows.status} (${summary.update.windows.version}), Linux=${summary.update.linux.status} (${summary.update.linux.version})`,
|
|
`- fresh target: ${summary.freshTargetSpec || "skip"} macOS=${summary.freshTarget.macos}, Windows=${summary.freshTarget.windows}, Linux=${summary.freshTarget.linux}`,
|
|
`- wall clock: ${formatDuration(summary.totalDurationMs)}`,
|
|
`- slowest phase: ${summary.slowestTiming ? `${summary.slowestTiming.phase}/${summary.slowestTiming.label} ${formatDuration(summary.slowestTiming.durationMs)}` : "n/a"}`,
|
|
`- logs: ${summary.runDir}`,
|
|
],
|
|
summaryPath,
|
|
title: "Parallels NPM Update Smoke",
|
|
});
|
|
return summaryPath;
|
|
}
|
|
}
|
|
|
|
await new NpmUpdateSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
|
|
die(error instanceof Error ? error.message : String(error));
|
|
});
|