Files
zero/scripts/npm/build-platform-packages.mjs
Kevin Codex 5e1405d0b7 feat(npm): ship the native binary as platform optionalDependencies (#626)
* feat(npm): ship the native binary as platform optionalDependencies

Replace the postinstall downloader with Codex-style platform packages:
suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the
binary, sandbox helpers, and the vendored agent-browser/tuistory tree,
referenced from the wrapper via npm: aliases in optionalDependencies.

The published wrapper has no lifecycle scripts and no dependencies, so
npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's
node>=24 engines pin, no allow-scripts warnings, no network outside the
npm registry. The old downloader remains as a checksum-verified first-run
fallback for --omit=optional installs and unsupported platforms.

scripts/npm/build-platform-packages.mjs assembles the payloads from the
release archives at publish time: it materializes npm's symlink .bin
shims into regular sh shims (npm pack silently drops symlinks) and prunes
agent-browser's other-platform binaries (~65 MB per package). The publish
job uploads platform versions first under --tag platform, asserts the
latest dist-tag survived (platform versions are semver prereleases of the
wrapper version), then publishes the wrapper.

See docs/NPM_PACKAGING.md for the architecture and publishing rules.

* fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs

- Ship the repo LICENSE in both the wrapper and platform payloads (npm only
  includes a license file physically present in the packed directory).
- Guard the platform-publish dist-tag lookup with || true so a transient
  npm view failure (or a bootstrap publish with no latest tag) cannot abort
  the job between the platform and wrapper publishes.
- Gate the wrapper's fallback downloader on the release matrix so platforms
  with no asset (windows-arm64 and unknown combos) skip the spawn instead of
  attempting it every run; keep deliberate retry-on-failure for supported
  platforms and say so honestly in the notice and docs.
- Correct Windows support wording everywhere: x64 only, ARM runs under
  emulation (README, README_ZH, INSTALL.md incl. the release-target list).
2026-07-11 23:25:41 +08:00

378 lines
15 KiB
JavaScript

#!/usr/bin/env node
// build-platform-packages.mjs: assemble the npm publish payloads for a release
// from the per-platform archives built by `zero-release package`.
//
// Layout written under --out-dir (default dist/npm):
// wrapper/ @gitlawb/[email protected] — bin/zero.js, the
// first-run fallback downloader, and a
// package.json with NO scripts and NO
// dependencies, only optionalDependencies
// aliasing the platform versions below.
// platforms/zero-<platform>-<arch>/
// @gitlawb/[email protected]<platform>-<arch> —
// the zero binary, sandbox helpers, and the
// vendored local-control helpers/ tree,
// copied straight out of the release archive.
//
// The platform "packages" are versions of the SAME @gitlawb/zero package at
// suffixed (semver-prerelease) versions, referenced from the wrapper through
// npm: aliases. One package name keeps a single npm trusted-publisher config.
// See docs/NPM_PACKAGING.md for the publishing rules this feeds.
//
// Archives are verified against their .sha256 files before extraction. All
// five platforms are required unless --only limits the set (testing only —
// CI must always assemble the full matrix, and the wrapper's
// optionalDependencies always reference the full matrix regardless).
//
// Usage:
// node scripts/npm/build-platform-packages.mjs \
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...]
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { basename, dirname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const repoRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
// Node platform/arch names (they name the alias suffixes so bin/zero.js can
// derive its platform package from process.platform/process.arch) mapped to
// the release asset naming from internal/release/release.go. windows-arm64 is
// deliberately absent — the release matrix does not build it.
const MATRIX = [
{ platform: 'darwin', arch: 'arm64', release: 'macos-arm64', ext: 'tar.gz' },
{ platform: 'darwin', arch: 'x64', release: 'macos-x64', ext: 'tar.gz' },
{ platform: 'linux', arch: 'arm64', release: 'linux-arm64', ext: 'tar.gz' },
{ platform: 'linux', arch: 'x64', release: 'linux-x64', ext: 'tar.gz' },
{ platform: 'win32', arch: 'x64', release: 'windows-x64', ext: 'zip' },
];
// Files staged into release archives for the shell installers / humans that
// must NOT ship in a platform npm package (the wrapper package owns them).
const PLATFORM_PACKAGE_EXCLUDES = ['package.json', 'README.md', 'VERSION', 'bin'];
function fail(message) {
console.error(`[build-platform-packages] ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
i += 1;
if (i >= argv.length) fail(`missing value for ${arg}`);
return argv[i];
};
switch (arg) {
case '--artifacts-dir':
args.artifactsDir = next();
break;
case '--out-dir':
args.outDir = next();
break;
case '--only':
args.only = new Set(next().split(',').map((entry) => entry.trim()).filter(Boolean));
break;
default:
fail(`unknown argument ${arg}`);
}
}
if (!args.artifactsDir) fail('--artifacts-dir is required');
return args;
}
function readWrapperPackage() {
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
if (!pkg.version) fail('package.json version is empty');
return pkg;
}
function platformVersion(version, entry) {
return `${version}-${entry.platform}-${entry.arch}`;
}
function aliasName(entry) {
return `@gitlawb/zero-${entry.platform}-${entry.arch}`;
}
function optionalDependencies(version) {
const aliases = {};
for (const entry of MATRIX) {
aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`;
}
return aliases;
}
// Same anchored sha256sum parsing as scripts/postinstall.mjs: when a line
// carries a filename it must match, so a checksum file cannot misattribute a
// digest.
function parseSha256(text, wantName) {
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-fA-F0-9]{64})(?:\s+\*?(.+))?$/);
if (!match) continue;
const [, hex, name] = match;
if (!name || name.trim() === wantName) {
return hex.toLowerCase();
}
}
fail(`could not find a SHA-256 digest for ${wantName} in its .sha256 file`);
}
function verifyChecksum(archivePath, assetName) {
const checksumPath = `${archivePath}.sha256`;
if (!existsSync(checksumPath)) fail(`missing checksum file ${checksumPath}`);
const expected = parseSha256(readFileSync(checksumPath, 'utf8'), assetName);
const actual = createHash('sha256').update(readFileSync(archivePath)).digest('hex');
if (actual !== expected) {
fail(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
}
}
function extractArchive(archivePath, ext, destDir) {
mkdirSync(destDir, { recursive: true });
const commands =
ext === 'zip'
? [
['unzip', ['-q', archivePath, '-d', destDir]],
// bsdtar (macOS / Windows tar.exe) extracts zips; GNU tar does not,
// hence unzip first.
['tar', ['-xf', archivePath, '-C', destDir]],
]
: [['tar', ['-xzf', archivePath, '-C', destDir]]];
let lastError = '';
for (const [command, commandArgs] of commands) {
const result = spawnSync(command, commandArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
if (result.status === 0) return;
lastError = result.error ? result.error.message : (result.stderr || '').toString().trim();
}
fail(`failed to extract ${archivePath}: ${lastError}`);
}
// zero-release archives carry their payload at the archive root; also accept
// a single zero-vX.Y.Z-<platform>-<arch>/ prefix directory (the shape
// install.sh tolerates) so fixtures and future layout changes both work.
function extractedPackageDir(extractDir, packageName, binaryName) {
if (existsSync(join(extractDir, binaryName))) return extractDir;
const exact = join(extractDir, packageName);
if (existsSync(join(exact, binaryName))) return exact;
const entries = readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
if (entries.length === 1 && existsSync(join(extractDir, entries[0].name, binaryName))) {
return join(extractDir, entries[0].name);
}
fail(`archive ${packageName} did not contain the ${binaryName} binary at a recognizable root`);
}
// npm pack silently drops symlinks from the tarball, and the staged helpers
// tree relies on them: npm's own .bin shims are symlinks on POSIX. Replace
// every symlink with something pack-safe — .bin links become relocatable
// `sh` exec shims, anything else becomes a dereferenced copy — so the
// vendored helpers survive publishing intact.
function materializeSymlinks(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isSymbolicLink()) {
let target;
try {
target = realpathSync(full);
} catch {
fail(`broken symlink in staged helpers tree: ${full}`);
}
const targetInfo = statSync(target);
rmSync(full);
if (basename(dir) === '.bin' && !targetInfo.isDirectory()) {
// relative() must compare like with like: target came out of
// realpathSync, so resolve dir the same way (macOS tmp dirs reach the
// same tree via /var and /private/var, which would otherwise produce a
// ../..-through-the-root monstrosity).
const relTarget = relative(realpathSync(dir), target).split(sep).join('/');
writeFileSync(
full,
'#!/bin/sh\n' +
'here="$(cd "$(dirname "$0")" && pwd)"\n' +
`exec "$here/${relTarget}" "$@"\n`,
);
chmodSync(full, 0o755);
// Direct exec relies on the target's own shebang and executable bit;
// npm pack preserves modes, so set it here.
chmodSync(target, 0o755);
} else {
cpSync(target, full, { recursive: true });
}
} else if (entry.isDirectory()) {
materializeSymlinks(full);
}
}
}
// agent-browser's npm package ships one native binary per supported platform
// (~11 MB each); its bin/agent-browser.js launcher picks the right one from
// process.platform/process.arch at runtime. A platform payload only ever runs
// on its own platform, so keep just the matching binary (plus the musl
// variant on linux — the launcher detects musl at runtime) and drop the rest.
function pruneAgentBrowserBinaries(packageDir, entry) {
const binDir = join(packageDir, 'helpers', 'node_modules', 'agent-browser', 'bin');
if (!existsSync(binDir)) return;
const keep = new Set(
entry.platform === 'linux'
? [`linux-${entry.arch}`, `linux-musl-${entry.arch}`]
: [`${entry.platform}-${entry.arch}`],
);
for (const name of readdirSync(binDir)) {
const match = name.match(/^agent-browser-(.+?)(\.exe)?$/);
if (!match || match[1] === '') continue;
if (!keep.has(match[1])) rmSync(join(binDir, name));
}
const kept = readdirSync(binDir).filter((name) => /^agent-browser-.+/.test(name));
if (kept.length === 0) {
fail(`pruning agent-browser binaries for ${entry.platform}-${entry.arch} left none behind`);
}
}
// Mirrors verifyStagedLocalControlHelpers in internal/release/release.go: the
// payload must carry runnable shims for both vendored helpers, or browser /
// terminal control silently degrades to a PATH lookup for every npm user.
function verifyHelperShims(packageDir, entry, assetName) {
const binDir = join(packageDir, 'helpers', 'node_modules', '.bin');
for (const helper of ['agent-browser', 'tuistory']) {
const shimNames =
entry.platform === 'win32' ? [`${helper}.cmd`, `${helper}.exe`, helper] : [helper];
if (!shimNames.some((name) => existsSync(join(binDir, name)))) {
fail(`${assetName} helpers tree is missing an executable ${helper} shim`);
}
}
}
function buildPlatformPackage(entry, version, artifactsDir, outDir) {
const assetName = `zero-v${version}-${entry.release}.${entry.ext}`;
const archivePath = join(artifactsDir, assetName);
if (!existsSync(archivePath)) fail(`missing release archive ${archivePath}`);
verifyChecksum(archivePath, assetName);
const binaryName = entry.platform === 'win32' ? 'zero.exe' : 'zero';
const tempDir = mkdtempSync(join(tmpdir(), 'zero-npm-'));
try {
extractArchive(archivePath, entry.ext, tempDir);
const sourceDir = extractedPackageDir(tempDir, `zero-v${version}-${entry.release}`, binaryName);
const packageDir = join(outDir, 'platforms', `zero-${entry.platform}-${entry.arch}`);
rmSync(packageDir, { recursive: true, force: true });
mkdirSync(packageDir, { recursive: true });
for (const item of readdirSync(sourceDir)) {
if (PLATFORM_PACKAGE_EXCLUDES.includes(item)) continue;
// verbatimSymlinks keeps relative link targets relative; the default
// rewrites them to absolute paths into this soon-to-be-deleted temp dir.
cpSync(join(sourceDir, item), join(packageDir, item), { recursive: true, verbatimSymlinks: true });
}
const binaryPath = join(packageDir, binaryName);
if (!existsSync(binaryPath)) fail(`${assetName} did not contain ${binaryName}`);
if (entry.platform !== 'win32') chmodSync(binaryPath, 0o755);
materializeSymlinks(packageDir);
pruneAgentBrowserBinaries(packageDir, entry);
verifyHelperShims(packageDir, entry, assetName);
// npm only ships a license file it finds in the packed directory;
// package.json's license field alone does not include the text.
cpSync(join(repoRoot, 'LICENSE'), join(packageDir, 'LICENSE'));
const wrapperPkg = readWrapperPackage();
const manifest = {
name: '@gitlawb/zero',
version: platformVersion(version, entry),
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`,
os: [entry.platform],
cpu: [entry.arch],
license: wrapperPkg.license,
repository: wrapperPkg.repository,
// No bin, no scripts, no dependencies: this payload is inert on its own;
// the wrapper package execs the binary out of it.
};
writeFileSync(join(packageDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n');
return packageDir;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function buildWrapperPackage(version, outDir) {
const wrapperDir = join(outDir, 'wrapper');
rmSync(wrapperDir, { recursive: true, force: true });
mkdirSync(join(wrapperDir, 'bin'), { recursive: true });
mkdirSync(join(wrapperDir, 'scripts'), { recursive: true });
cpSync(join(repoRoot, 'bin', 'zero.js'), join(wrapperDir, 'bin', 'zero.js'));
cpSync(join(repoRoot, 'scripts', 'postinstall.mjs'), join(wrapperDir, 'scripts', 'postinstall.mjs'));
cpSync(join(repoRoot, 'README.md'), join(wrapperDir, 'README.md'));
// npm auto-includes LICENSE in the tarball only when the file is present in
// the directory being packed (the files whitelist cannot exclude it).
cpSync(join(repoRoot, 'LICENSE'), join(wrapperDir, 'LICENSE'));
chmodSync(join(wrapperDir, 'bin', 'zero.js'), 0o755);
const pkg = readWrapperPackage();
// The published wrapper must stay warning-free on every package manager:
// no lifecycle scripts and no regular dependencies (the repo's dependencies
// entries exist only to pin the vendored helpers/ tree that zero-release
// stages into the platform payloads — see internal/release/release.go).
delete pkg.scripts;
delete pkg.dependencies;
pkg.optionalDependencies = optionalDependencies(version);
writeFileSync(join(wrapperDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
return wrapperDir;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const version = readWrapperPackage().version;
const selected = MATRIX.filter(
(entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`),
);
if (selected.length === 0) fail('--only matched no platforms');
if (args.only) {
for (const key of args.only) {
if (!MATRIX.some((entry) => `${entry.platform}-${entry.arch}` === key)) {
fail(`--only lists unknown platform ${key}`);
}
}
}
const built = [];
for (const entry of selected) {
built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir));
}
const wrapperDir = buildWrapperPackage(version, args.outDir);
process.stdout.write(
JSON.stringify(
{
version,
wrapperDir,
platformDirs: built,
platformVersions: selected.map((entry) => platformVersion(version, entry)),
},
null,
2,
) + '\n',
);
}
main();