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.
A npm install -g @gitlawb/zero must be silent and self-contained:
- No
EBADENGINEwarnings — ours or from any package in the dependency tree. - No install scripts anywhere in the tree, so npm's
allow-scriptsgating, 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.
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/zeropackage, published at suffixed versions (0.4.0-linux-x64) and referenced throughnpm:aliases inoptionalDependencies. One package name means one npm trusted-publisher configuration — no new publish credentials per platform. The alias suffixes use Node'sprocess.platform/process.archnames sobin/zero.jscan derive its platform package directly. - Each platform version sets
osandcpu, 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.mjsand contains thezerobinary, the platform's sandbox helpers, and the vendoredhelpers/tree (see below). It has nobin, 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.jsonand injects the exact-versionoptionalDependencies. (The repopackage.jsonkeepsagent-browser/tuistoryindependenciespurely 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.
- Resolve
@gitlawb/zero-<platform>-<arch>and exec thezerobinary from it. This wins over any previously downloaded copy — the platform version is pinned to the wrapper release. - Fall back to a binary previously downloaded next to the wrapper.
- 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. - 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.
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 produceEBADENGINEandallow-scriptswarnings for every installer. As a vendored tree it produces none, because npm never resolves it. zero-release packagealready stages the helpers tree into every release archive (stageLocalControlHelpersrunsnpm cifrom the repo'spackage.jsonpins + lockfile on the native builder). The assembly script reuses that staged tree, so npm installs andinstall.shinstalls get identical helpers.- The Go binary resolves helpers from
<binary dir>/helpers/node_modules/.binon its own (internal/localcontrol/browser.go,adjacentHelper) — no wrapper involvement, no configuration. - Symlink materialization:
npm packsilently drops symlinks, and npm's own.binshims are symlinks on POSIX. The assembly script rewrites every.binsymlink into a relocatable#!/bin/shexec shim and dereferences any other symlink, so the vendored tree survives publishing. (Verified empirically; nestednode_modulesunderhelpers/IS packed — only the package-rootnode_modulesis 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.
These are the invariants release-artifacts.yml must hold. Breaking the first
one is user-visible immediately.
- Platform versions must never become
latest.0.4.0-linux-x64is a semver prerelease of0.4.0; publishing it without an explicit non-latestdist-tag would clobberlatestand users would install a platform payload as the CLI. The workflow publishes platform versions with--tag platformand assertslatestsurvived before the wrapper publish, then assertslatestequals the wrapper version after it. Both lookups tolerate a failednpm view(registry lag, or nolatesttag 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. - Platform versions publish before the wrapper. The wrapper's
optionalDependenciespin exact suffixed versions; publishing the wrapper first would create a window where installs resolve aliases that 404. - Exact-version pinning. The wrapper at
X.Y.Zreferences platform versionsX.Y.Z-<platform>-<arch>exactly — never ranges — so a wrapper and its binaries can never skew. - All publishes go through the existing npm OIDC trusted-publishing flow and
the
npm-publishenvironment 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.
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>(ortuistory@…) 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.
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.