Compare commits

...
Author SHA1 Message Date
Kevin Codex 1fb753bd40 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-10 16:29:56 +08:00
Kevin Codex 1e31e98087 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.
2026-07-10 09:29:33 +08:00
11 changed files with 1162 additions and 152 deletions

No files matched your search

+69 -11
View File
@@ -97,16 +97,21 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
# assets are live — the package's postinstall downloads its binary from the
# GitHub Release, so publishing before the assets exist would ship a package
# that fails to install.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
name: Publish npm package
needs: package
@@ -156,10 +161,11 @@ jobs:
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
# Probe the exact URLs the wrapper's first-run downloader builds
# (internal/release/release.go asset-name scheme) as an anonymous client
# — install.sh/install.ps1 and `zero update` read them too. This also
# blocks publishing while the repo is still private: private release
# assets 404 without auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
@@ -173,12 +179,64 @@ jobs:
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Publish to npm
run: npm publish --access public
- name: Download release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: zero-*
path: dist/artifacts
merge-multiple: true
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+9 -28
View File
@@ -49,34 +49,15 @@ npm install -g @gitlawb/zero
zero
```
The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
The npm package is a small wrapper whose platform build (Linux and macOS on
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
installs as an optional dependency straight from the npm registry — no install
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
no trust or approval steps. Installs that skip optional dependencies
(`--omit=optional`) still work: the wrapper fetches the binary from the
matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
### Install scripts
+1 -19
View File
@@ -38,25 +38,7 @@ npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
### 安装脚本
+76 -23
View File
@@ -2,6 +2,7 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -39,6 +40,11 @@ function resolveHelper(packageRoot, name) {
return null;
}
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
// installs vendor the helpers inside the platform package instead (a helpers/
// directory next to the binary, found by the Go side on its own), so this
// manifest is usually empty — it only matters for a downloaded-binary install
// whose wrapper still carries helper dependencies.
function localControlHelperManifest(packageRoot) {
const helpers = {};
for (const name of ['agent-browser', 'tuistory']) {
@@ -49,38 +55,85 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
} catch {
return null;
}
const candidate = join(dirname(manifestPath), zeroBinaryName());
return existsSync(candidate) ? candidate : null;
}
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
// Mirrors the release matrix (internal/release/release.go): the platforms a
// GitHub Release asset exists for. android maps to the linux asset; there is
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
function releaseAssetAvailable() {
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
process.platform
];
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
if (!platform || !arch) return false;
return !(platform === 'windows' && arch === 'arm64');
}
// Fallback for installs without a platform package (--omit=optional, package
// managers that skip optionalDependencies): fetch the binary next to the
// wrapper with the same checksum-verified downloader that used to run as
// postinstall. Deliberately NOT cached on failure — a transient network error
// self-heals on the next run — so this retries on every invocation until a
// binary is in place. Platforms with no release asset skip the attempt.
function downloadMissingBinary(packageRoot) {
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
console.error(
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
);
spawnSync(process.execPath, [downloadScript], {
stdio: ['ignore', 'inherit', 'inherit'],
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
const downloaded = join(packageRoot, zeroBinaryName());
if (existsSync(downloaded)) return downloaded;
downloadMissingBinary(packageRoot);
return existsSync(downloaded) ? downloaded : null;
}
const nativePath = resolveNativeBinary();
if (!nativePath) {
console.error(
'[zero] No native binary is available for this install.\n' +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
);
process.exit(1);
}
const env = { ...process.env };
const localControlHelpers = localControlHelperManifest(packageRoot);
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
+21 -48
View File
@@ -6,8 +6,9 @@ Zero is distributed as:
- release archives on GitHub Releases
- source builds with Go 1.25+
The npm package and install scripts download a platform-specific release archive.
They require a published GitHub Release for the requested version.
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
## npm
@@ -16,59 +17,32 @@ npm install -g @gitlawb/zero
zero
```
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
`zero` command and downloads the matching release binary during `postinstall`.
The package supports Linux and macOS on x64 and arm64, and Windows on x64
(Windows on ARM runs the x64 build under emulation). It installs a small
`zero` wrapper plus, as an optional dependency, a platform payload with the
native binary and the bundled browser/terminal control helpers. There are no
install scripts and nothing is downloaded from outside the npm registry, so
the install is silent and works identically under npm, Bun, pnpm, and yarn —
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
package architecture.
Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
- network access to npm
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
If the install skipped optional dependencies (`npm install --omit=optional`,
or a package manager configured to do so), the wrapper falls back to
downloading the binary from the matching GitHub Release, with checksum
verification. The fetch is retried on each run until a binary is in place (a
transient network failure heals itself; an install directory the current user
cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
@@ -252,8 +226,7 @@ Supported targets:
- `linux-arm64`
- `macos-x64`
- `macos-arm64`
- `windows-x64`
- `windows-arm64`
- `windows-x64` (Windows on ARM runs this build under emulation)
Each archive must have a matching `.sha256` file. The install scripts download
both files, verify the checksum, and then copy the binary into the install
+162
View File
@@ -0,0 +1,162 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
`release-artifacts.yml`.
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
install zero without prompts, trust ceremonies, or broken binaries.
- No network fetches outside the npm registry at install time. GitHub being
down or rate-limited must not break `npm install`.
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
of the box.
## Architecture
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
The alias suffixes use Node's `process.platform`/`process.arch` names so
`bin/zero.js` can derive its platform package directly.
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
them and skips the rest.
- A platform payload is assembled from the platform's release archive by
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
the platform's sandbox helpers, and the vendored `helpers/` tree (see
below). It has no `bin`, no scripts, and no dependencies — it is inert on
its own; the wrapper execs the binary out of it.
- The published wrapper has **no scripts and no dependencies** — the
assembly script strips both from the repo `package.json` and injects the
exact-version `optionalDependencies`. (The repo `package.json` keeps
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
the vendored helpers tree; they are never installed by consumers.)
- There is **no windows-arm64 build** (matches the release matrix); Windows on
ARM runs the x64 build under emulation via the first-run fallback.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
3. If neither exists (`--omit=optional`, package managers that skip optional
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
verified against the release's own checksum file, no zip-slip), invoked by
the wrapper itself. Failures are deliberately not cached — the fetch
retries on every run until a binary is in place, so a transient network
error self-heals. Platforms with no release asset (anything outside the
matrix above, including windows-arm64) skip the attempt entirely rather
than probing the network each run.
4. If the download is impossible too, print build-from-source guidance.
There is deliberately **no `scripts.postinstall`** in any published
`package.json`. The downloader exists only as a first-run fallback.
### Vendored helpers (`helpers/`)
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
**vendored binaries/packages inside the platform payload**, not npm
dependencies of the wrapper:
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
every installer. As a vendored tree it produces none, because npm never
resolves it.
- `zero-release package` already stages the helpers tree into every release
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
`package.json` pins + lockfile on the native builder). The assembly script
reuses that staged tree, so npm installs and `install.sh` installs get
identical helpers.
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
wrapper involvement, no configuration.
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
other symlink, so the vendored tree survives publishing. (Verified
empirically; nested `node_modules` under `helpers/` IS packed — only the
package-root `node_modules` is always ignored.)
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
platform (7 total). The assembly script keeps only the payload's own
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
non-`latest` dist-tag would clobber `latest` and users would install a
platform payload as the CLI. The workflow publishes platform versions with
`--tag platform` and asserts `latest` survived before the wrapper publish,
then asserts `latest` equals the wrapper version after it. Both lookups
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
The vendored helper versions are pinned by the repo `package.json`
`dependencies` + `package-lock.json`; the release build vendors exactly what
the lockfile resolves. No `^range` surprises ship: a release carries what its
lockfile pinned at build time.
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
ship with the next release.
- Security response: if a vendored helper publishes a security fix, the bump
is release-worthy on its own — cut a patch release of zero carrying only the
pin change.
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
vendored package directory (`helpers/node_modules/agent-browser/`), which
satisfies redistribution attribution.
## History / rationale
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
`agent-browser`/`tuistory` as regular dependencies. That produced three
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
silently under Bun's default script blocking, and coupled `npm install` to
GitHub Releases availability. The platform-package model removes the warnings
structurally — there is nothing left in the tree for a package manager to warn
about — rather than asking users to approve or suppress them.
+45 -10
View File
@@ -1,8 +1,8 @@
# npm Wrapper Smoke Checklist
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
wrapper.
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -21,17 +21,52 @@ go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
```
## End-To-End Assembly Smoke
Build a real archive for the host platform and assemble the npm payloads from
it (this is exactly what the publish job does):
```bash
go run ./cmd/zero-release package
node scripts/npm/build-platform-packages.mjs \
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
```
Then simulate an installed layout and run the wrapper through each resolution
path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
archive's SHA-256, and extracts only the known binary basenames into place.
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
platform/arch is a non-fatal skip.
- The wrapper binary resolves through the package `bin` entry and
`node_modules/.bin` in a package-install smoke test.
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
matrix.
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
plan), verifies the downloaded archive's SHA-256, and extracts only the
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
(exit 0) and an unsupported platform/arch is a non-fatal skip.
- The wrapper prefers the platform package binary over a previously downloaded
one, and `agent-browser`/`tuistory` run from the platform package's
`helpers/node_modules/.bin` shims.
- The built binary exits 0 for `zero --version` or `zero --help`.
- `zero --version` reports `zero <package.json version>`.
- Release packaging still emits the expected archive and checksum names when
+260
View File
@@ -0,0 +1,260 @@
package npmwrapper
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestBuildPlatformPackagesAssemblesPublishPayloads runs the release-time
// assembly script against a fixture linux-x64 archive shaped like the output
// of `zero-release package` and checks the npm publish payloads it emits:
// the platform payload (suffixed version, os/cpu gate, pack-safe helper
// shims, pruned agent-browser binaries) and the wrapper (no scripts, no
// dependencies, full optionalDependencies alias matrix).
func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX symlinks and shell scripts")
}
node := requireNode(t)
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
outDir := filepath.Join(root, "out")
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Scripts map[string]string
}
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
if platformPkg.Name != "@gitlawb/zero" {
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
}
if want := version + "-linux-x64"; platformPkg.Version != want {
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
}
if len(platformPkg.OS) != 1 || platformPkg.OS[0] != "linux" || len(platformPkg.CPU) != 1 || platformPkg.CPU[0] != "x64" {
t.Fatalf("platform package os/cpu = %v/%v, want [linux]/[x64]", platformPkg.OS, platformPkg.CPU)
}
if len(platformPkg.Scripts) != 0 {
t.Fatalf("platform package has lifecycle scripts: %v", platformPkg.Scripts)
}
info, err := os.Stat(filepath.Join(platformDir, "zero"))
if err != nil {
t.Fatalf("platform package binary missing: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Fatalf("platform package binary is not executable: %v", info.Mode())
}
for _, excluded := range []string{"README.md", "VERSION", "bin"} {
if _, err := os.Stat(filepath.Join(platformDir, excluded)); !os.IsNotExist(err) {
t.Fatalf("platform package still contains wrapper-owned %q", excluded)
}
}
// npm only ships a license file it finds in the packed directory, so the
// assembly must place the repo LICENSE into every payload.
if _, err := os.Stat(filepath.Join(platformDir, "LICENSE")); err != nil {
t.Fatalf("platform package missing LICENSE: %v", err)
}
// npm pack drops symlinks, so the shim must now be a regular executable file.
shimPath := filepath.Join(platformDir, "helpers", "node_modules", ".bin", "agent-browser")
shimInfo, err := os.Lstat(shimPath)
if err != nil {
t.Fatalf("agent-browser shim missing: %v", err)
}
if shimInfo.Mode()&os.ModeSymlink != 0 {
t.Fatalf("agent-browser shim is still a symlink; npm pack would drop it")
}
if shimInfo.Mode()&0o111 == 0 {
t.Fatalf("agent-browser shim is not executable: %v", shimInfo.Mode())
}
shim, err := os.ReadFile(shimPath)
if err != nil {
t.Fatalf("ReadFile shim: %v", err)
}
if !strings.Contains(string(shim), "../agent-browser/bin/agent-browser.js") {
t.Fatalf("shim does not exec the vendored helper: %q", string(shim))
}
if _, err := os.Stat(filepath.Join(platformDir, "helpers", "node_modules", ".bin", "tuistory")); err != nil {
t.Fatalf("tuistory shim missing: %v", err)
}
// Pruning: only the linux-x64 (glibc + musl) agent-browser binaries survive.
prunedBinDir := filepath.Join(platformDir, "helpers", "node_modules", "agent-browser", "bin")
entries, err := os.ReadDir(prunedBinDir)
if err != nil {
t.Fatalf("ReadDir pruned bin: %v", err)
}
got := []string{}
for _, entry := range entries {
got = append(got, entry.Name())
}
want := map[string]bool{
"agent-browser.js": true,
"agent-browser-linux-x64": true,
"agent-browser-linux-musl-x64": true,
}
if len(got) != len(want) {
t.Fatalf("pruned agent-browser bin = %v, want exactly %v", got, want)
}
for _, name := range got {
if !want[name] {
t.Fatalf("pruning left unexpected %q (full set: %v)", name, got)
}
}
wrapperDir := filepath.Join(outDir, "wrapper")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Scripts map[string]string `json:"scripts"`
Dependencies map[string]string `json:"dependencies"`
Optional map[string]string `json:"optionalDependencies"`
}
unmarshalJSONFile(t, filepath.Join(wrapperDir, "package.json"), &wrapperPkg)
if wrapperPkg.Version != version {
t.Fatalf("wrapper version = %q, want %q", wrapperPkg.Version, version)
}
if len(wrapperPkg.Scripts) != 0 {
t.Fatalf("published wrapper has lifecycle scripts: %v", wrapperPkg.Scripts)
}
if len(wrapperPkg.Dependencies) != 0 {
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
}
wantAliases := map[string]string{
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
}
for alias, spec := range wantAliases {
if wrapperPkg.Optional[alias] != spec {
t.Fatalf("wrapper optionalDependencies[%q] = %q, want %q", alias, wrapperPkg.Optional[alias], spec)
}
}
for _, file := range []string{"bin/zero.js", "scripts/postinstall.mjs", "README.md", "LICENSE"} {
if _, err := os.Stat(filepath.Join(wrapperDir, filepath.FromSlash(file))); err != nil {
t.Fatalf("wrapper payload missing %s: %v", file, err)
}
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("Unmarshal %s: %v", path, err)
}
}
func writeFixtureFile(t *testing.T, path string, content string, mode os.FileMode) {
t.Helper()
mustMkdirAllFixture(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if mode&0o111 != 0 {
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
}
func mustMkdirAllFixture(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", path, err)
}
}
func requireCommand(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Skipf("%s is not available", name)
}
return path
}
+142 -10
View File
@@ -41,16 +41,13 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if pkg.Module != "bin/zero.js" {
t.Fatalf("module = %q, want bin/zero.js", pkg.Module)
}
// Only a postinstall hook (which downloads the prebuilt binary) is allowed.
// Repository build scripts (build/prepare/prepack/…) must not ship in the
// published package — the tarball has no Go source to build from.
if pkg.Scripts["postinstall"] != "node scripts/postinstall.mjs" {
t.Fatalf("scripts.postinstall = %q, want node scripts/postinstall.mjs", pkg.Scripts["postinstall"])
}
// The published package must carry NO lifecycle scripts at all: the native
// binary arrives as a platform optionalDependency, with scripts/postinstall.mjs
// kept only as a first-run fallback the wrapper invokes itself. Any script
// here would resurrect the npm/Bun/pnpm install-script warnings the platform
//-package model exists to eliminate (see docs/NPM_PACKAGING.md).
for name := range pkg.Scripts {
if name != "postinstall" {
t.Fatalf("package.json scripts contains %q; only a postinstall hook is allowed (no repository build scripts)", name)
}
t.Fatalf("package.json scripts contains %q; the published package must have no lifecycle scripts", name)
}
if pkg.License == "" {
t.Fatalf("package.json license is empty; set it (ties to the pending LICENSE file) so npm publish is not unlicensed")
@@ -256,11 +253,146 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
if !ok || exitErr.ExitCode() != 1 {
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
}
if !strings.Contains(string(output), "No native binary found next to the npm wrapper") {
if !strings.Contains(string(output), "No native binary is available for this install") {
t.Fatalf("missing-native output = %q", string(output))
}
}
func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
// A stale downloaded binary next to the wrapper must lose to the platform
// package: the platform version is pinned to the wrapper release, the
// downloaded copy is whatever a previous fallback fetched.
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
if err := os.MkdirAll(platformDir, 0o755); err != nil {
t.Fatalf("MkdirAll platform package: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
t.Fatalf("WriteFile platform package.json: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "zero"), []byte("#!/usr/bin/env sh\nprintf 'platform-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile platform binary: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching platform binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "platform-zero --version" {
t.Fatalf("wrapper output = %q, want platform-zero --version", got)
}
}
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching downloaded binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "downloaded-zero" {
t.Fatalf("wrapper output = %q, want downloaded-zero", got)
}
}
func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
// Give the fixture the real downloader so the wrapper's first-run path
// executes it; ZERO_SKIP_DOWNLOAD keeps the test offline, so the download
// "succeeds" without producing a binary and the wrapper must exit 1 with
// guidance.
scriptsDir := filepath.Join(root, "scripts")
if err := os.MkdirAll(scriptsDir, 0o755); err != nil {
t.Fatalf("MkdirAll scripts: %v", err)
}
postinstall, err := os.ReadFile(filepath.Join(repoRoot(t), "scripts", "postinstall.mjs"))
if err != nil {
t.Fatalf("ReadFile postinstall: %v", err)
}
if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil {
t.Fatalf("WriteFile postinstall fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
t.Fatalf("WriteFile package fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(command.Env, "ZERO_SKIP_DOWNLOAD=1")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out on first-run download path: %v; output: %s", ctx.Err(), output)
}
exitErr, ok := err.(*exec.ExitError)
if !ok || exitErr.ExitCode() != 1 {
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
}
text := string(output)
if !strings.Contains(text, "fetching the native binary from the GitHub Release") {
t.Fatalf("output missing fallback download notice: %q", text)
}
if !strings.Contains(text, "skipping native binary download") {
t.Fatalf("output shows the downloader did not run: %q", text)
}
if !strings.Contains(text, "No native binary is available for this install") {
t.Fatalf("output missing guidance: %q", text)
}
}
func nodePlatformName() string {
switch runtime.GOOS {
case "darwin":
return "darwin"
case "windows":
return "win32"
default:
return runtime.GOOS
}
}
func nodeArchName() string {
switch runtime.GOARCH {
case "amd64":
return "x64"
default:
return runtime.GOARCH
}
}
func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
-3
View File
@@ -7,9 +7,6 @@
"bin": {
"zero": "bin/zero.js"
},
"scripts": {
"postinstall": "node scripts/postinstall.mjs"
},
"files": [
"bin/zero.js",
"scripts/postinstall.mjs"
+377
View File
@@ -0,0 +1,377 @@
#!/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();