mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb753bd40 | ||
|
|
1e31e98087 |
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"bin": {
|
||||
"zero": "bin/zero.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin/zero.js",
|
||||
"scripts/postinstall.mjs"
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user