mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:10:43 +00:00
331 lines
10 KiB
JavaScript
331 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// Normalizes package-acceptance inputs into the tarball shape consumed by Docker E2E.
|
|
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { createWriteStream } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const DEFAULT_OUTPUT_NAME = "openclaw-current.tgz";
|
|
export const OPENCLAW_PACKAGE_SPEC_RE =
|
|
/^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$/u;
|
|
|
|
function usage() {
|
|
return `Usage: node scripts/resolve-openclaw-package-candidate.mjs --source <ref|npm|url|artifact> --output-dir <dir> [options]
|
|
|
|
Options:
|
|
--package-spec <spec> Published npm spec for source=npm.
|
|
--package-url <url> HTTPS tarball URL for source=url.
|
|
--package-sha256 <sha256> Expected tarball SHA-256 for source=url or source=artifact.
|
|
--artifact-dir <dir> Directory containing exactly one .tgz for source=artifact.
|
|
--output-name <name> Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME}
|
|
--metadata <file> Write package metadata JSON.
|
|
--github-output <file> Append tarball, sha256, package name/version outputs.`;
|
|
}
|
|
|
|
export function parseArgs(argv) {
|
|
const options = {
|
|
artifactDir: "",
|
|
githubOutput: "",
|
|
metadata: "",
|
|
outputDir: "",
|
|
outputName: DEFAULT_OUTPUT_NAME,
|
|
packageSha256: "",
|
|
packageSpec: "",
|
|
packageUrl: "",
|
|
source: "",
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const readValue = (name) => {
|
|
const value = argv[(index += 1)];
|
|
if (value === undefined) {
|
|
throw new Error(`${name} requires a value`);
|
|
}
|
|
return value;
|
|
};
|
|
if (arg === "--artifact-dir") {
|
|
options.artifactDir = readValue(arg);
|
|
} else if (arg === "--github-output") {
|
|
options.githubOutput = readValue(arg);
|
|
} else if (arg === "--metadata") {
|
|
options.metadata = readValue(arg);
|
|
} else if (arg === "--output-dir") {
|
|
options.outputDir = readValue(arg);
|
|
} else if (arg === "--output-name") {
|
|
options.outputName = readValue(arg);
|
|
} else if (arg === "--package-sha256") {
|
|
options.packageSha256 = readValue(arg).toLowerCase();
|
|
} else if (arg === "--package-spec") {
|
|
options.packageSpec = readValue(arg);
|
|
} else if (arg === "--package-url") {
|
|
options.packageUrl = readValue(arg);
|
|
} else if (arg === "--source") {
|
|
options.source = readValue(arg);
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
} else {
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
export function validateOpenClawPackageSpec(spec) {
|
|
if (!OPENCLAW_PACKAGE_SPEC_RE.test(spec)) {
|
|
throw new Error(
|
|
`package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${spec}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd ?? ROOT_DIR,
|
|
stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
if (options.capture) {
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
}
|
|
child.on("error", reject);
|
|
child.on("close", (status, signal) => {
|
|
if (status === 0) {
|
|
resolve(stdout);
|
|
return;
|
|
}
|
|
const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
|
|
reject(new Error(`${command} ${args.join(" ")} failed with ${status ?? signal}${detail}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function walkFiles(dir) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const absolute = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...(await walkFiles(absolute)));
|
|
} else if (entry.isFile()) {
|
|
files.push(absolute);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
async function sha256(file) {
|
|
const hash = createHash("sha256");
|
|
const handle = await fs.open(file, "r");
|
|
try {
|
|
for await (const chunk of handle.createReadStream()) {
|
|
hash.update(chunk);
|
|
}
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
function assertSha256(value) {
|
|
if (!/^[a-f0-9]{64}$/u.test(value)) {
|
|
throw new Error(`package_sha256 must be a lowercase or uppercase 64-character SHA-256 digest`);
|
|
}
|
|
}
|
|
|
|
async function assertExpectedSha256(file, expected) {
|
|
if (!expected) {
|
|
return await sha256(file);
|
|
}
|
|
assertSha256(expected);
|
|
const actual = await sha256(file);
|
|
if (actual !== expected.toLowerCase()) {
|
|
throw new Error(`package SHA-256 mismatch: expected ${expected}, got ${actual}`);
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
async function findSingleTarball(dir) {
|
|
const files = (await walkFiles(path.resolve(ROOT_DIR, dir)))
|
|
.filter((file) => /\.t(?:ar\.)?gz$/u.test(path.basename(file)))
|
|
.toSorted((a, b) => a.localeCompare(b));
|
|
if (files.length !== 1) {
|
|
throw new Error(
|
|
`source=artifact requires exactly one .tgz under ${dir}; found ${files.length}: ${files.join(", ")}`,
|
|
);
|
|
}
|
|
return files[0];
|
|
}
|
|
|
|
async function moveNewestPackedTarball(outputDir, packOutput, outputName) {
|
|
let filename = "";
|
|
try {
|
|
const parsed = JSON.parse(packOutput);
|
|
if (Array.isArray(parsed)) {
|
|
filename = parsed.find((entry) => typeof entry?.filename === "string")?.filename ?? "";
|
|
}
|
|
} catch {}
|
|
if (!filename) {
|
|
for (const line of packOutput.split(/\r?\n/u)) {
|
|
const trimmed = line.trim();
|
|
if (/^openclaw-.*\.tgz$/u.test(trimmed)) {
|
|
filename = trimmed;
|
|
}
|
|
}
|
|
}
|
|
if (!filename) {
|
|
const entries = await fs.readdir(outputDir);
|
|
filename = entries
|
|
.filter((entry) => /^openclaw-.*\.tgz$/u.test(entry))
|
|
.toSorted((a, b) => a.localeCompare(b))
|
|
.at(-1);
|
|
}
|
|
if (!filename) {
|
|
throw new Error(`npm pack produced no OpenClaw tarball in ${outputDir}`);
|
|
}
|
|
const packed = path.join(outputDir, filename);
|
|
const target = path.join(outputDir, outputName);
|
|
if (packed !== target) {
|
|
await fs.rm(target, { force: true });
|
|
await fs.rename(packed, target);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
async function downloadUrl(url, target) {
|
|
const parsed = new URL(url);
|
|
if (parsed.protocol !== "https:") {
|
|
throw new Error(`package_url must use https: ${url}`);
|
|
}
|
|
const response = await fetch(parsed);
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`failed to download package_url: HTTP ${response.status}`);
|
|
}
|
|
await pipeline(response.body, createWriteStream(target));
|
|
}
|
|
|
|
async function readPackageJson(tarball) {
|
|
const raw = await run("tar", ["-xOf", tarball, "package/package.json"], { capture: true });
|
|
const pkg = JSON.parse(raw);
|
|
return {
|
|
name: typeof pkg.name === "string" ? pkg.name : "",
|
|
version: typeof pkg.version === "string" ? pkg.version : "",
|
|
};
|
|
}
|
|
|
|
async function appendGithubOutputs(file, outputs) {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const body = Object.entries(outputs)
|
|
.map(([key, value]) => `${key}=${String(value).replace(/\n/gu, " ")}`)
|
|
.join("\n");
|
|
await fs.appendFile(file, `${body}\n`);
|
|
}
|
|
|
|
async function resolveCandidate(options) {
|
|
const outputDir = path.resolve(ROOT_DIR, options.outputDir);
|
|
const target = path.join(outputDir, options.outputName || DEFAULT_OUTPUT_NAME);
|
|
await fs.mkdir(outputDir, { recursive: true });
|
|
await fs.rm(target, { force: true });
|
|
|
|
if (options.source === "ref") {
|
|
await run("node", [
|
|
"scripts/package-openclaw-for-docker.mjs",
|
|
"--output-dir",
|
|
outputDir,
|
|
"--output-name",
|
|
options.outputName || DEFAULT_OUTPUT_NAME,
|
|
]);
|
|
} else if (options.source === "npm") {
|
|
validateOpenClawPackageSpec(options.packageSpec);
|
|
const packOutput = await run(
|
|
"npm",
|
|
["pack", options.packageSpec, "--ignore-scripts", "--json", "--pack-destination", outputDir],
|
|
{ capture: true },
|
|
);
|
|
await moveNewestPackedTarball(outputDir, packOutput, options.outputName || DEFAULT_OUTPUT_NAME);
|
|
} else if (options.source === "url") {
|
|
if (!options.packageUrl) {
|
|
throw new Error("source=url requires --package-url");
|
|
}
|
|
if (!options.packageSha256) {
|
|
throw new Error("source=url requires --package-sha256");
|
|
}
|
|
await downloadUrl(options.packageUrl, target);
|
|
} else if (options.source === "artifact") {
|
|
if (!options.artifactDir) {
|
|
throw new Error("source=artifact requires --artifact-dir");
|
|
}
|
|
const input = await findSingleTarball(options.artifactDir);
|
|
await fs.copyFile(input, target);
|
|
} else {
|
|
throw new Error(`source must be one of: ref, npm, url, artifact. Got: ${options.source}`);
|
|
}
|
|
|
|
const digest = await assertExpectedSha256(target, options.packageSha256);
|
|
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target]);
|
|
const pkg = await readPackageJson(target);
|
|
const metadata = {
|
|
name: pkg.name,
|
|
packageSpec: options.packageSpec || "",
|
|
sha256: digest,
|
|
source: options.source,
|
|
tarball: path.relative(ROOT_DIR, target),
|
|
version: pkg.version,
|
|
};
|
|
|
|
if (pkg.name !== "openclaw") {
|
|
throw new Error(`package candidate must be named "openclaw"; got: ${pkg.name || "<missing>"}`);
|
|
}
|
|
if (!pkg.version) {
|
|
throw new Error("package candidate package.json has no version");
|
|
}
|
|
|
|
if (options.metadata) {
|
|
await fs.mkdir(path.dirname(path.resolve(ROOT_DIR, options.metadata)), { recursive: true });
|
|
await fs.writeFile(
|
|
path.resolve(ROOT_DIR, options.metadata),
|
|
`${JSON.stringify(metadata, null, 2)}\n`,
|
|
);
|
|
}
|
|
await appendGithubOutputs(options.githubOutput, {
|
|
package_name: pkg.name,
|
|
package_version: pkg.version,
|
|
sha256: digest,
|
|
tarball: metadata.tarball,
|
|
});
|
|
return metadata;
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
const options = parseArgs(argv);
|
|
if (options.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
if (!options.outputDir) {
|
|
throw new Error("--output-dir is required");
|
|
}
|
|
const metadata = await resolveCandidate(options);
|
|
console.log(JSON.stringify(metadata, null, 2));
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
console.error(usage());
|
|
process.exit(1);
|
|
});
|
|
}
|