#!/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 --output-dir [options] Options: --package-spec Published npm spec for source=npm. --package-url HTTPS tarball URL for source=url. --package-sha256 Expected tarball SHA-256 for source=url or source=artifact. --artifact-dir Directory containing exactly one .tgz for source=artifact. --output-name Output tarball filename. Default: ${DEFAULT_OUTPUT_NAME} --metadata Write package metadata JSON. --github-output 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 || ""}`); } 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); }); }