Files
zero/scripts/postinstall.mjs
Kevin Codex 530563a0bb feat(npm): publish as @twigpine/zero (#1142)
* feat(npm): publish as @twigpine/zero

The npm package moves from @gitlawb/zero to @twigpine/zero, and everything
that finds Zero's GitHub repository by name now names Twigpine/zero.

Package name
- package.json, the lockfile and release-please use @twigpine/zero.
  `repository` names Twigpine/zero: npm checks provenance against it, so a
  publish from this repo with the old URL fails with E422. The homepage is
  zero.twigpine.com.
- The name lives in package.json only. build-platform-packages.mjs names the
  wrapper, platform versions, alias names and alias targets from it, and
  bin/zero.js reads its own package.json for the alias it resolves and for
  every reinstall hint. The Go constant npmPackageName is held equal to
  package.json by a test.

Repository defaults
- The update check (DefaultRepository), install.sh, install.ps1, the npm
  wrapper's fallback downloader, and the GitHub Action's zero-repo default
  all used Gitlawb/zero. That name now belongs to an unrelated GitHub org, and
  these paths download and run what they find there, so they point at
  Twigpine/zero. Docs, README and issue-template links follow. The Go module
  path (and so `go install github.com/Gitlawb/zero/...`) is unchanged here.

Publishing
- The two inline publish steps become scripts/npm/publish-packages.sh, which
  reads the name from the assembled payloads, refuses to start when the
  wrapper's aliases do not match the platform payloads, skips versions
  already on the registry (so re-running a half-finished job is safe), and
  publishes with --ignore-scripts. The dist-tag rules are unchanged.
  internal/npmwrapper/publish_test.go runs it against a fake registry.

Docs explain the switch for existing installs: uninstall @gitlawb/zero first,
because both packages own the `zero` bin and npm refuses the second (EEXIST).

* feat(npm): publish 0.10.0 once more as @gitlawb/zero to point users at the new name

Deprecating @gitlawb/zero alone would strand every existing npm install.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name npm installs nothing, prints no deprecation
warning (it only warns when it installs something), and Zero then reports
"updated via npm to 0.10.0" while staying on 0.9.0.

So this release goes out once more under the old name, as a bridge:

- The publish job assembles the same verified artifacts a second time with
  the new `build-platform-packages.mjs --package-name @gitlawb/zero` and
  publishes them with publish-packages.sh, after the @twigpine/zero publish.
  A gate runs it only while @gitlawb/zero's latest is still 0.9.0 (or is
  this version, on a re-run), so it happens once.
- The build knows its name from the package.json beside it. The Go side
  reports InstallMethodNpmLegacy: `zero upgrade` refuses with the switch
  steps before any release check (so it works offline too), and
  `zero update --check` adds them whether or not an update is out.
- bin/zero.js prints the same steps after zero exits (the TUI would hide a
  notice printed first), only when stderr is a terminal, and not for
  `update`/`upgrade`, which print it themselves.
- Updating from 0.9.0 now installs a real version, so npm also shows the
  deprecation message.

The steps say to uninstall @gitlawb/zero before installing @twigpine/zero:
both own the `zero` bin, npm refuses the second install with EEXIST, and a
forced install loses the bin when the old package is later removed (checked
against npm 11.16).

docs/NPM_PACKAGING.md has the maintainer runbook: bootstrap and trust the new
package, retarget the old one's trusted publisher for the bridge, then
deprecate @gitlawb/zero, revoke its publisher, and delete the bridge steps.

* fix(npm): only skip a publish when npm says the version is not there

publish-packages.sh treated any failed `npm view <name>@<version>` as "not
published". A transient network, auth or registry error on the re-run that the
skip exists for therefore published a version that was already out, and npm
rejected it with EPUBLISHCONFLICT; with the registry unreachable it published
blind. npm 11 reports every kind of missing (no such package, no such version,
including platform versions) as E404, so only E404 now means "not published".
Any other failure is retried three times and then stops the job before it
publishes anything, with npm's own error shown.

The test fake now answers a missing version with E404 the way npm does (it
returned an empty success before), and can fail lookups with a network error.
The two new tests fail on the old lookup: one with EPUBLISHCONFLICT after
transient failures, the other by publishing while the registry is unreachable.

Also renames the unused retry variable in the legacy-name gate step to `_`
(shellcheck SC2034), matching the script.
2026-10-15 11:05:51 +08:00

307 lines
11 KiB
JavaScript

#!/usr/bin/env node
// postinstall: fetch the prebuilt `zero` binary for this platform from the
// matching GitHub Release and place it next to bin/zero.js, which execs it.
//
// Mirrors scripts/install.sh and scripts/install.ps1. The asset-name scheme is
// the source of truth in internal/release/release.go:
// zero-v{version}-{linux|macos|windows}-{x64|arm64}.{tar.gz|zip} (+ .sha256)
//
// Safety: HTTPS-only download from the pinned repo, SHA-256 verification against
// the release's own .sha256, and extraction that never trusts archive paths —
// we extract to a temp dir and copy only known binary basenames into place, so a
// crafted archive cannot write outside the package (no zip-slip).
//
// Env overrides (testing / mirrors / locked-down installs):
// ZERO_SKIP_DOWNLOAD=1 skip entirely, exit 0 (wrapper will guide if run)
// ZERO_INSTALL_DRY_RUN=1 print the resolved plan as JSON, no network, exit 0
// ZERO_INSTALL_PLATFORM=… override process.platform (linux|darwin|win32|android)
// ZERO_INSTALL_ARCH=… override process.arch (x64|arm64)
// ZERO_REPO=owner/name override the GitHub repo (default Twigpine/zero)
// ZERO_GITHUB_BASE_URL=… override the download host (default https://github.com)
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
const VERSION = pkg.version;
const REPO = process.env.ZERO_REPO || 'Twigpine/zero';
const BASE = (process.env.ZERO_GITHUB_BASE_URL || 'https://github.com').replace(/\/+$/, '');
// The .sha256 is fetched from the same origin as the archive, so TLS authenticity
// is the only real integrity control — require https unless explicitly overridden
// for local testing (e.g. a localhost mirror in tests).
const ALLOW_INSECURE = process.env.ZERO_ALLOW_INSECURE_DOWNLOAD === '1';
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
function fail(message) {
console.error(`[zero] ${message}`);
process.exit(1);
}
function warnSkip(message) {
// Exit 0 so an unsupported platform or an opt-out does not break `npm install`;
// bin/zero.js reports a clear "no native binary" message if the user runs zero.
console.error(`[zero] ${message}`);
process.exit(0);
}
// Maps mirror internal/release/release.go (ReleasePlatform / ReleaseArch).
function resolvePlatform(p) {
switch (p) {
case 'linux':
case 'android':
return 'linux';
case 'darwin':
return 'macos';
case 'win32':
return 'windows';
default:
return null;
}
}
function resolveArch(a) {
switch (a) {
case 'x64':
return 'x64';
case 'arm64':
return 'arm64';
default:
return null;
}
}
if (process.env.ZERO_SKIP_DOWNLOAD === '1') {
warnSkip('ZERO_SKIP_DOWNLOAD=1 set — skipping native binary download.');
}
const rawPlatform = process.env.ZERO_INSTALL_PLATFORM || process.platform;
const rawArch = process.env.ZERO_INSTALL_ARCH || process.arch;
const platform = resolvePlatform(rawPlatform);
const arch = resolveArch(rawArch);
if (!platform || !arch) {
warnSkip(
`no prebuilt binary for ${rawPlatform}/${rawArch}. Build from source: ` +
`https://github.com/${REPO} (go run ./cmd/zero).`,
);
}
// The release matrix builds linux/macos {x64,arm64} and windows-x64 — there is no
// windows-arm64 artifact. (win32, arm64) otherwise resolves to a valid
// platform/arch, so it would proceed to download a non-existent asset and hard
// -fail npm install on a 404. Guard it explicitly to skip gracefully; Windows on
// ARM runs the x64 build under emulation, so the x64 package is the fallback.
if (platform === 'windows' && arch === 'arm64') {
warnSkip(
`no prebuilt binary for windows-arm64 (the windows-x64 build runs under ` +
`emulation on Windows on ARM). Build from source or install the x64 package: ` +
`https://github.com/${REPO} (go run ./cmd/zero).`,
);
}
const ext = platform === 'windows' ? 'zip' : 'tar.gz';
const binaryName = platform === 'windows' ? 'zero.exe' : 'zero';
const tag = `v${VERSION}`;
const assetName = `zero-v${VERSION}-${platform}-${arch}.${ext}`;
const assetUrl = `${BASE}/${REPO}/releases/download/${tag}/${assetName}`;
const checksumUrl = `${assetUrl}.sha256`;
// Extra binaries the archive may carry; copied when present so the platform
// sandbox finds its adjacent helpers (tier-1 discovery), but never required.
const optionalBinaries =
platform === 'windows'
? ['zero-windows-command-runner.exe', 'zero-windows-sandbox-setup.exe']
: platform === 'linux'
? ['zero-linux-sandbox', 'zero-seccomp']
: [];
if (process.env.ZERO_INSTALL_DRY_RUN === '1') {
process.stdout.write(
JSON.stringify({
version: VERSION,
platform,
arch,
tag,
assetName,
assetUrl,
checksumUrl,
binaryName,
optionalBinaries,
}) + '\n',
);
process.exit(0);
}
// Idempotent: skip if the binary for this exact version is already in place.
const markerPath = join(packageRoot, '.zero-binary-version');
const installedBinary = join(packageRoot, binaryName);
if (
existsSync(installedBinary) &&
existsSync(markerPath) &&
readFileSync(markerPath, 'utf8').trim() === VERSION
) {
process.exit(0);
}
async function download(url) {
if (!ALLOW_INSECURE && !url.startsWith('https://')) {
fail(
`refusing insecure download origin for ${url}: only https:// is allowed ` +
`(set ZERO_ALLOW_INSECURE_DOWNLOAD=1 to override for local testing).`,
);
}
let response;
try {
response = await fetch(url, { redirect: 'follow' });
} catch (error) {
fail(`download failed for ${url}: ${error.message}`);
}
// Catch an https->http downgrade across redirects: the verified bytes must
// still have arrived over TLS for the same-origin checksum to mean anything.
if (!ALLOW_INSECURE && response.url && !response.url.startsWith('https://')) {
fail(`refusing insecure redirect to ${response.url}`);
}
if (!response.ok) {
fail(
`download failed (HTTP ${response.status}) for ${url} (release tag ${tag}). ` +
`If no release exists yet, or the tag and package.json version disagree, ` +
`build from source: https://github.com/${REPO} (go run ./cmd/zero).`,
);
}
const declared = Number(response.headers.get('content-length') || 0);
if (declared && declared > MAX_DOWNLOAD_BYTES) {
fail(`refusing oversized download (${declared} bytes) from ${url}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_DOWNLOAD_BYTES) {
fail(`download from ${url} exceeded ${MAX_DOWNLOAD_BYTES} bytes`);
}
return buffer;
}
// Parse a sha256sum-style file. Anchored per line, and when the line carries a
// filename field it must equal the requested asset — mirrors the Go
// ParseSHA256Checksum 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 ${wantName}.sha256`);
}
function findByBasename(dir, name) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
const nested = findByBasename(full, name);
if (nested) return nested;
} else if (entry.name === name) {
return full;
}
}
return null;
}
// extract runs from `workDir` and passes RELATIVE names so no Windows drive
// letter (e.g. C:\) appears in tar's archive argument — GNU tar otherwise reads
// `C:foo` as a remote `host:path` and fails. archiveName and destName are both
// relative to workDir.
function extract(workDir, archiveName, destName) {
const opts = { stdio: 'inherit', cwd: workDir };
if (ext === 'zip') {
// Windows 10+ ships bsdtar as tar.exe, which extracts zips; fall back to
// PowerShell Expand-Archive if tar is unavailable or cannot read the zip.
const viaTar = spawnSync('tar', ['-xf', archiveName, '-C', destName], opts);
if (viaTar.status === 0) return;
const absArchive = join(workDir, archiveName);
const absDest = join(workDir, destName);
const viaPwsh = spawnSync(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Expand-Archive -LiteralPath '${absArchive.replace(/'/g, "''")}' ` +
`-DestinationPath '${absDest.replace(/'/g, "''")}' -Force`,
],
{ stdio: 'inherit' },
);
if (viaPwsh.status !== 0) fail(`failed to extract ${assetName}`);
return;
}
const viaTar = spawnSync('tar', ['-xzf', archiveName, '-C', destName], opts);
if (viaTar.status !== 0) fail(`failed to extract ${assetName} (tar exited ${viaTar.status})`);
}
async function main() {
const archiveBuffer = await download(assetUrl);
const checksumText = (await download(checksumUrl)).toString('utf8');
const expected = parseSha256(checksumText, assetName);
const actual = createHash('sha256').update(archiveBuffer).digest('hex');
if (actual !== expected) {
fail(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
}
const tempDir = mkdtempSync(join(tmpdir(), 'zero-install-'));
try {
const archivePath = join(tempDir, assetName);
writeFileSync(archivePath, archiveBuffer);
const extractDir = join(tempDir, 'extracted');
mkdirSync(extractDir);
extract(tempDir, assetName, 'extracted');
// Copy only known basenames into the package root. We never honor
// archive-relative paths, so a crafted entry cannot escape the package.
const primarySource = findByBasename(extractDir, binaryName);
if (!primarySource) fail(`archive ${assetName} did not contain ${binaryName}`);
copyFileSync(primarySource, installedBinary);
if (platform !== 'windows') chmodSync(installedBinary, 0o755);
for (const name of optionalBinaries) {
const source = findByBasename(extractDir, name);
if (!source) {
console.error(
`[zero] note: optional helper ${name} was not in ${assetName}; ` +
`the sandbox may run with reduced isolation.`,
);
continue;
}
const dest = join(packageRoot, name);
copyFileSync(source, dest);
if (platform !== 'windows') chmodSync(dest, 0o755);
}
writeFileSync(markerPath, VERSION + '\n');
const sizeKb = Math.round(statSync(installedBinary).size / 1024);
console.error(`[zero] installed ${binaryName} ${VERSION} (${platform}-${arch}, ${sizeKb} KB).`);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
main().catch((error) => fail(error.message));