Compare commits

...
Author SHA1 Message Date
Kevin Codex a4863e9507 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 10:55:12 +08:00
Kevin Codex c211b12798 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.
2026-10-15 10:55:12 +08:00
Kevin Codex c1dd401ce9 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).
2026-10-15 10:55:12 +08:00
31 changed files with 1366 additions and 281 deletions

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
url: https://github.com/Twigpine/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
url: https://github.com/Twigpine/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Gitlawb/zero/security/advisories/new
url: https://github.com/Twigpine/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
+62 -49
View File
@@ -100,19 +100,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 @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.
# Publishes the npm package named in package.json (@twigpine/zero): 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.
# npmjs.com settings must list this repo + workflow file + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
@@ -125,7 +127,7 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
url: https://www.npmjs.com/package/@twigpine/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
@@ -201,47 +203,58 @@ jobs:
- 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
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). Versions already on the registry are skipped, so "Re-run
# failed jobs" is safe after a partial publish.
- name: Publish npm packages
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)."
run: scripts/npm/publish-packages.sh dist/npm
- name: Publish wrapper to npm
# One final release under the pre-rename name @gitlawb/zero. Zero 0.9.0's
# `zero upgrade` runs `npm install -g @gitlawb/zero@latest`, and people
# reinstall the old name from shell history and old tutorials, so this
# release is published once more under it: the same verified artifacts,
# assembled as @gitlawb/zero, which the binary and wrapper recognise and
# answer with the switch steps (docs/NPM_PACKAGING.md). Runs after the
# @twigpine/zero publish, so the package it points at already exists.
#
# The gate keeps this to a single release: it is owed only while the old
# name's latest is still the last pre-rename version, or is this version
# (a re-run after it went out). After this release, `npm deprecate` the
# old package and delete these two steps.
- name: Check whether the final @gitlawb/zero release is owed
id: legacy-name
shell: bash
env:
LAST_PRE_RENAME_VERSION: 0.9.0
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
latest=""
for _ in 1 2 3; do
latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)"
if [ -n "$latest" ]; then
break
fi
sleep 5
sleep 10
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
if [ -z "$latest" ]; then
echo "::error::could not read the latest dist-tag of @gitlawb/zero from the registry" >&2
exit 1
fi
if [ "$latest" = "$LAST_PRE_RENAME_VERSION" ] || [ "$latest" = "$version" ]; then
owed=true
else
owed=false
fi
echo "@gitlawb/zero latest is ${latest}; final release under the old name owed: ${owed}"
echo "owed=${owed}" >> "$GITHUB_OUTPUT"
- name: Publish the final @gitlawb/zero release
if: ${{ steps.legacy-name.outputs.owed == 'true' }}
shell: bash
run: |
node scripts/npm/build-platform-packages.mjs \
--artifacts-dir dist/artifacts --out-dir dist/npm-legacy --package-name @gitlawb/zero
scripts/npm/publish-packages.sh dist/npm-legacy
+5 -5
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Propose a feature in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
@@ -193,7 +193,7 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
@@ -222,7 +222,7 @@ contribution phase.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+22 -10
View File
@@ -116,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
@@ -130,18 +130,30 @@ 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.
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
0.10.0 and the old name gets no further releases. Both packages provide the
`zero` command, so quit any running `zero`, then remove the old package first:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
Your config, sessions, and logins are kept; they live outside the package. For
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
### Install scripts
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
### From source
@@ -149,7 +161,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
Source builds require Go 1.26.9+.
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -526,19 +538,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+18 -9
View File
@@ -105,24 +105,33 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
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)。
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
@@ -130,7 +139,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
源码构建需要 Go 1.26.9+。
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -358,14 +367,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Gitlawb
author: Twigpine
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Gitlawb/zero
default: Twigpine/zero
outputs:
exit-code:
+63 -13
View File
@@ -1,11 +1,63 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
// release was also published once under the old name, so 0.9.0's `zero
// upgrade` and anyone still installing the old name land on a working build
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
// published under the old name after that.
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
// The npm name this wrapper was published under, read from its own
// package.json so the name lives in one place. The platform payload alias and
// every reinstall hint below derive from it.
function readPackageName(root) {
try {
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
if (typeof name === 'string' && name !== '') return name;
} catch {
// An unreadable manifest must not stop the CLI from launching.
}
return CURRENT_PACKAGE_NAME;
}
const packageName = readPackageName(packageRoot);
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
// Uninstalling first is the only safe order: both packages own the `zero` bin,
// so npm refuses to install the new one over the old (EEXIST), and forcing it
// lets a later uninstall of the old package delete the shared bin.
function legacyPackageNotice() {
return (
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
'To keep getting updates, quit zero and run:\n' +
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
);
}
// Printed when the wrapper exits rather than before zero starts, because the
// TUI takes over the screen and would hide anything printed first. Only on a
// terminal, so scripts that capture stderr see nothing new. `zero update` and
// `zero upgrade` print the same note themselves.
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
process.on('exit', () => {
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
// Synchronous: an exit handler cannot wait for an asynchronous write.
writeSync(2, legacyPackageNotice());
}
});
}
function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
@@ -81,10 +133,10 @@ function bunRecoveryParagraph() {
return (
'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' +
` bun pm trust ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\n` +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
` "trustedDependencies": ["${packageName}"]\n` +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -93,14 +145,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
@@ -113,7 +165,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
` npm install -g ${packageName}\n` +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -182,12 +234,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// 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
// The platform payload is a version of this same package, 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}`;
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -227,8 +279,6 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
with:
prompt: ${{ inputs.task }}
provider: openai
@@ -66,7 +66,7 @@ jobs:
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
with:
prompt: |
Investigate this issue and propose a fix.
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Gitlawb/zero@v1
- uses: Twigpine/zero@v1
id: zero
with:
prompt-file: .github/zero/upgrade-deps.md
@@ -176,7 +176,7 @@ jobs:
- **Least-privilege tokens.** Grant only the permissions the workflow needs
(`contents: write` to edit files, `pull-requests: write` to comment). The
default `GITHUB_TOKEN` is scoped to the repository.
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
workflow run uses a known ZERO version. `zero-version` lets you pin the
installed binary independently of the action ref.
- **Linux and macOS runners** are supported; Windows runners are rejected with a
+51 -16
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
@@ -29,7 +29,7 @@ Release and verify its checksum.
## npm
```bash
npm install -g @gitlawb/zero
npm install -g @twigpine/zero
zero
```
@@ -56,15 +56,47 @@ cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
```
### Moving from @gitlawb/zero
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
`@twigpine/zero`, and the old name gets no further releases.
Both packages provide the `zero` command, so npm refuses to install the new
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
uninstall of the old package removes the shared `zero` command. Quit any
running `zero` (on Windows a running `zero.exe` cannot be removed), then
uninstall the old package before installing the new one:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
With another package manager, the same two steps:
```bash
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
```
Config, sessions, logins, and plugins live in your user config and home
directories, not in the package, so they carry over unchanged.
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
updates lands on a working build. That build prints these steps when `zero`
exits in a terminal, and `zero upgrade` and `zero update --check` print them
too.
## Linux And macOS Script
Install the latest release:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -89,7 +121,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Gitlawb/zero`
- Repository: `Twigpine/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -100,7 +132,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -123,7 +155,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Gitlawb/zero`
- Repository: `Twigpine/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -134,13 +166,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
mise use -g ubi:Twigpine/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
mise use -g ubi:Twigpine/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -169,7 +201,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
go run ./cmd/zero
```
@@ -213,7 +245,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
git clone https://github.com/Twigpine/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -302,7 +334,10 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
the old `@gitlawb/zero` package refuses instead and prints the steps in
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
further releases, so npm would only reinstall the same version.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
+92 -15
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
How `@twigpine/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
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
A `npm install -g @twigpine/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,
@@ -25,15 +25,15 @@ 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
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
- The platform "packages" are **versions of the same `@twigpine/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.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
1. Resolve `@twigpine/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.
@@ -103,10 +103,22 @@ dependencies of the wrapper:
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
### One package name
The npm name lives in `package.json` and nothing else repeats it:
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
versions, the alias names, and the alias targets from it, and `bin/zero.js`
reads its own `package.json` to derive the alias it resolves. The Go side
needs the name as a constant (`npmPackageName` in
`internal/update/installmethod.go`), and a test holds it equal to
`package.json`.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately.
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
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
@@ -123,15 +135,80 @@ one is user-visible immediately.
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.
and its binaries can never skew. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
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.
trusted-publisher configuration. npm checks provenance against
`package.json` `repository`, so it must name the repository the workflow
runs in.
5. **Re-runs are safe.** A version already on the registry is skipped rather
than republished, so re-running a job that failed between the platform and
wrapper publishes finishes the release. Only npm's E404 counts as "not
published"; a lookup that fails any other way is retried and then stops the
job before anything is published.
`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`.
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone does not reach existing users. 0.9.0's
`zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with nothing newer
under that name, npm reinstalls nothing, prints no deprecation warning (it
only warns when it installs something), and Zero reports the update as done.
So 0.10.0 is published once more under the old name, as a bridge:
- The publish job assembles the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and publishes
them with the same script, after the `@twigpine/zero` publish. A gate runs
it only while the old name's `latest` is still 0.9.0 (or is this version,
on a re-run), so it cannot happen twice.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only.
- Updating from 0.9.0 now installs a real version, so npm also shows the
deprecation message.
### Runbook
Before the first release under the new name:
1. Create the package, since npm only attaches a trusted publisher to a
package that exists: publish an empty `@twigpine/[email protected]` by hand with
`npm publish --access public --tag bootstrap`, then
`npm deprecate @twigpine/[email protected] "Placeholder; install @twigpine/zero@latest"`.
2. Trust this workflow for it:
`npm trust github @twigpine/zero --repo Twigpine/zero --file release-artifacts.yml --env npm-publish --allow-publish`.
3. Point the old package's trusted publisher at this repository too, for the
bridge publish: `npm trust list @gitlawb/zero`, revoke the existing entry
with `npm trust revoke @gitlawb/zero --id <id>`, then run the same
`npm trust github` command for `@gitlawb/zero`.
4. Confirm Settings → Environments → `npm-publish` has a required reviewer.
The environment exists either way; without a reviewer the job does not
wait for approval.
After the release, once both packages show 0.10.0 as `latest`:
1. `npm deprecate @gitlawb/zero "Zero moved to @twigpine/zero. Switch with: npm uninstall -g @gitlawb/zero && npm install -g @twigpine/zero"`
(no version range, so every version, including the platform payloads,
carries it). Never unpublish: existing installs resolve their platform
payload through these versions.
2. Revoke the old package's trusted publisher (`npm trust revoke`), so
nothing can publish under that name again.
3. Delete the two `@gitlawb/zero` steps from `release-artifacts.yml`. The Go
and wrapper handling of the old name can stay; it only activates in a
build installed under that name.
## Runbook: bumping the vendored helpers
+10 -9
View File
@@ -2,7 +2,8 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
## Required Checks
@@ -37,24 +38,24 @@ 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
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/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
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/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
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@gitlawb/zero`), version,
- `package.json` has the expected package name (`@twigpine/zero`), version,
`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
`package.json` is `@twigpine/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`
+8 -4
View File
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Gitlawb/zero
zero update --check --repo Twigpine/zero
zero update --check --target windows-x64
zero upgrade
@@ -28,7 +28,11 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
of the old `@gitlawb/zero` package refuses, without a release check, and
prints the steps to switch to `@twigpine/zero` (see
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
too.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
@@ -38,7 +42,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
install: that path delegates to `npm install -g @twigpine/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
@@ -65,7 +69,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
Installer scripts download the matching release asset for the local platform and
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
+2 -2
View File
@@ -22,7 +22,7 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
}
containsAll(t, script, []string{
"set -euo pipefail",
`ZERO_REPO="${ZERO_REPO:-Gitlawb/zero}"`,
`ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"`,
`ZERO_INSTALL_DIR="${ZERO_INSTALL_DIR:-$HOME/.local/bin}"`,
`archive_name="zero-v${version}-${platform}-${arch}.tar.gz"`,
`checksum_name="${archive_name}.sha256"`,
@@ -220,7 +220,7 @@ func runUnixInstaller(t *testing.T, fixture unixInstallFixture) (string, string,
command.Env = append(os.Environ(),
"PATH="+fixture.mockBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"ZERO_GITHUB_BASE_URL=https://example.test",
"ZERO_REPO=Gitlawb/zero",
"ZERO_REPO=Twigpine/zero",
)
var stdout bytes.Buffer
var stderr bytes.Buffer
+163 -69
View File
@@ -28,69 +28,9 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.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)
root := writePlatformArchiveFixture(t, tar, version)
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)
}
runBuildPlatformPackages(t, node, root, outDir)
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
@@ -101,8 +41,8 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
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 platformPkg.Name != "@twigpine/zero" {
t.Fatalf("platform package name = %q, want @twigpine/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)
@@ -198,11 +138,11 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
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",
"@twigpine/zero-darwin-arm64": "npm:@twigpine/zero@" + version + "-darwin-arm64",
"@twigpine/zero-darwin-x64": "npm:@twigpine/zero@" + version + "-darwin-x64",
"@twigpine/zero-linux-arm64": "npm:@twigpine/zero@" + version + "-linux-arm64",
"@twigpine/zero-linux-x64": "npm:@twigpine/zero@" + version + "-linux-x64",
"@twigpine/zero-win32-x64": "npm:@twigpine/zero@" + version + "-win32-x64",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
@@ -219,6 +159,160 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
}
}
// writePlatformArchiveFixture writes a linux-x64 release archive shaped like
// the output of `zero-release package`, with its .sha256 sidecar, into a new
// directory and returns that directory.
func writePlatformArchiveFixture(t *testing.T, tar string, version string) string {
t.Helper()
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":"@twigpine/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)
return root
}
func runBuildPlatformPackages(t *testing.T, node string, artifactsDir string, outDir string, extraArgs ...string) {
t.Helper()
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
args := append([]string{script, "--artifacts-dir", artifactsDir, "--out-dir", outDir, "--only", "linux-x64"}, extraArgs...)
command := exec.CommandContext(ctx, node, args...)
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)
}
}
// The final release under the pre-rename name is the same artifacts assembled
// with --package-name. Every place a payload names its package must follow it:
// a wrapper that kept the current name in one alias would resolve a package
// npm never installed.
func TestBuildPlatformPackagesAssemblesUnderAnotherPackageName(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 := writePlatformArchiveFixture(t, tar, version)
outDir := filepath.Join(root, "out")
runBuildPlatformPackages(t, node, root, outDir, "--package-name", "@gitlawb/zero")
platformManifest := filepath.Join(outDir, "platforms", "zero-linux-x64", "package.json")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
}
unmarshalJSONFile(t, platformManifest, &platformPkg)
if platformPkg.Name != "@gitlawb/zero" || platformPkg.Version != version+"-linux-x64" {
t.Fatalf("platform package = %s@%s, want @gitlawb/zero@%s-linux-x64", platformPkg.Name, platformPkg.Version, version)
}
wrapperManifest := filepath.Join(outDir, "wrapper", "package.json")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Optional map[string]string `json:"optionalDependencies"`
Repository struct {
URL string `json:"url"`
} `json:"repository"`
}
unmarshalJSONFile(t, wrapperManifest, &wrapperPkg)
if wrapperPkg.Name != "@gitlawb/zero" || wrapperPkg.Version != version {
t.Fatalf("wrapper = %s@%s, want @gitlawb/zero@%s", wrapperPkg.Name, wrapperPkg.Version, version)
}
if len(wrapperPkg.Optional) != 5 {
t.Fatalf("wrapper optionalDependencies = %v, want the full five-platform matrix", wrapperPkg.Optional)
}
for alias, spec := range wrapperPkg.Optional {
platform := strings.TrimPrefix(alias, "@gitlawb/zero-")
if platform == alias || spec != "npm:@gitlawb/zero@"+version+"-"+platform {
t.Fatalf("optionalDependencies[%q] = %q, want npm:@gitlawb/zero@%s-<platform> under an @gitlawb/zero-<platform> alias", alias, spec, version)
}
}
// Provenance is checked against the repository the workflow runs in, which
// is the same for both names.
if !strings.Contains(wrapperPkg.Repository.URL, "github.com/Twigpine/zero") {
t.Fatalf("wrapper repository = %q, want the Twigpine/zero repository npm checks provenance against", wrapperPkg.Repository.URL)
}
for _, manifest := range []string{platformManifest, wrapperManifest} {
data, err := os.ReadFile(manifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "@twigpine/zero") {
t.Fatalf("%s still names @twigpine/zero:\n%s", manifest, data)
}
}
}
func TestBuildPlatformPackagesRejectsAnInvalidPackageName(t *testing.T) {
node := requireNode(t)
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := exec.CommandContext(ctx, node, script, "--artifacts-dir", t.TempDir(), "--package-name", "../zero")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("build-platform-packages accepted --package-name ../zero:\n%s", output)
}
if !strings.Contains(string(output), "is not an npm package name") {
t.Fatalf("output does not explain the rejection: %s", output)
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
+101 -9
View File
@@ -24,9 +24,9 @@ const (
// by the generic missing-binary path and the doctor text fallback.
const (
bunRecoveryLead = "You installed with Bun, which does not run dependency lifecycle scripts"
bunPmTrustProject = "bun pm trust @gitlawb/zero"
bunPmTrustGlobal = "bun pm -g trust @gitlawb/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@gitlawb/zero"]`
bunPmTrustProject = "bun pm trust @twigpine/zero"
bunPmTrustGlobal = "bun pm -g trust @twigpine/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@twigpine/zero"]`
buildFromSourceLead = "If this platform has no prebuilt binary, build from source:"
)
@@ -80,8 +80,8 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if err := json.Unmarshal(bytes, &pkg); err != nil {
t.Fatalf("Unmarshal package.json: %v", err)
}
if pkg.Name != "@gitlawb/zero" {
t.Fatalf("name = %q, want @gitlawb/zero", pkg.Name)
if pkg.Name != "@twigpine/zero" {
t.Fatalf("name = %q, want @twigpine/zero", pkg.Name)
}
if pkg.Bin["zero"] != "bin/zero.js" {
t.Fatalf("bin.zero = %q, want bin/zero.js", pkg.Bin["zero"])
@@ -158,7 +158,7 @@ func TestPostinstallComputesAssetPlan(t *testing.T) {
if plan.BinaryName != tc.wantBinary {
t.Fatalf("%s/%s: binaryName=%q want %q", tc.platform, tc.arch, plan.BinaryName, tc.wantBinary)
}
wantURL := "https://github.com/Gitlawb/zero/releases/download/v" + version + "/" + tc.wantAsset
wantURL := "https://github.com/Twigpine/zero/releases/download/v" + version + "/" + tc.wantAsset
if plan.AssetURL != wantURL {
t.Fatalf("%s/%s: assetUrl=%q want %q", tc.platform, tc.arch, plan.AssetURL, wantURL)
}
@@ -321,11 +321,11 @@ func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
platformDir := filepath.Join(root, "node_modules", "@twigpine", "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 {
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@twigpine/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 {
@@ -347,6 +347,98 @@ func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
}
}
// bin/zero.js derives the platform payload alias from its own package.json
// name rather than a hardcoded one, so a wrapper assembled under any name
// finds the payload published under that same name
// (build-platform-packages.mjs names both from one value).
func TestNodeWrapperResolvesPlatformPackageUnderItsOwnName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@example/zero"}`, 0o644)
// A payload under the default name sits beside it and must lose.
writePlatformPackageFixture(t, root, "@twigpine/zero", "default-name-zero")
writePlatformPackageFixture(t, root, "@example/zero", "own-name-zero")
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "--version")
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if got := strings.TrimSpace(stdout); got != "own-name-zero --version" {
t.Fatalf("wrapper output = %q, want the payload aliased under its own name (own-name-zero --version)", got)
}
}
// The bridge release published under the old name runs normally and, once
// zero exits, says where the package went and how to switch, in the one order
// npm accepts.
func TestNodeWrapperLegacyBuildTellsUsersToSwitchAfterExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@gitlawb/zero"}`, 0o644)
writePlatformPackageFixture(t, root, "@gitlawb/zero", "legacy-zero")
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, "--version")
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if got := strings.TrimSpace(stdout); got != "legacy-zero --version" {
t.Fatalf("stdout = %q, want the legacy build to run normally with the notice kept off stdout", got)
}
uninstall := strings.Index(stderr, "npm uninstall -g @gitlawb/zero")
install := strings.Index(stderr, "npm install -g @twigpine/zero")
if uninstall < 0 || install < 0 || uninstall > install {
t.Fatalf("stderr does not give the switch steps, uninstall first: %q", stderr)
}
}
func TestNodeWrapperLegacyNoticeStaysOutOfTheWay(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
for _, tc := range []struct {
name, packageName string
env []string
args []string
}{
// Scripts capturing stderr must not see a new line in it.
{name: "not a terminal", packageName: "@gitlawb/zero", args: []string{"--version"}},
// The Go side prints the same note for these; once is enough.
{name: "zero upgrade", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"upgrade"}},
{name: "zero update", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"update", "--check"}},
{name: "current package", packageName: "@twigpine/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"--version"}},
} {
t.Run(tc.name, func(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"`+tc.packageName+`"}`, 0o644)
writePlatformPackageFixture(t, root, tc.packageName, "zero")
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, tc.env, tc.args...)
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("stderr = %q, want nothing", stderr)
}
})
}
}
// writePlatformPackageFixture installs a fake platform payload where npm puts
// the wrapper's optionalDependency alias <packageName>-<platform>-<arch>; its
// zero binary prints label followed by its arguments.
func writePlatformPackageFixture(t *testing.T, root string, packageName string, label string) {
t.Helper()
dir := filepath.Join(root, "node_modules", filepath.FromSlash(packageName+"-"+nodePlatformName()+"-"+nodeArchName()))
writeFixtureFile(t, filepath.Join(dir, "package.json"), `{"name":"`+packageName+`"}`, 0o644)
writeFixtureFile(t, filepath.Join(dir, "zero"), "#!/usr/bin/env sh\nprintf '"+label+"'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n", 0o755)
}
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
@@ -393,7 +485,7 @@ func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
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 {
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@twigpine/zero","version":"0.0.0"}`), 0o644); err != nil {
t.Fatalf("WriteFile package fixture: %v", err)
}
+327
View File
@@ -0,0 +1,327 @@
package npmwrapper
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// fakeNpm stands in for npm on PATH when scripts/npm/publish-packages.sh runs.
// It keeps one package's registry state in FAKE_NPM_STATE and answers the way
// npm 11 does: a lookup of a missing package or a missing version fails with
// E404, and it refuses a version published twice or any lookup or publish for
// a package name other than the one it holds. Every call is appended to
// FAKE_NPM_STATE.log.
//
// FAKE_NPM_CLOBBER_LATEST=1 makes every publish take `latest`, the failure the
// script must stop on. FAKE_NPM_VERSION_LOOKUP_ERRORS=<n> fails the first n
// version lookups with a network error instead of answering them.
const fakeNpm = `#!/usr/bin/env node
const fs = require('fs');
const stateFile = process.env.FAKE_NPM_STATE;
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
const args = process.argv.slice(2);
fs.appendFileSync(stateFile + '.log', JSON.stringify({ args, cwd: process.cwd() }) + '\n');
const save = () => fs.writeFileSync(stateFile, JSON.stringify(state));
const notFound = (detail) => { process.stderr.write('npm error code E404\nnpm error 404 ' + detail + '\n'); process.exit(1); };
if (args[0] === 'view') {
const [spec, field] = args.slice(1);
const at = spec.lastIndexOf('@');
const name = at > 0 ? spec.slice(0, at) : spec;
if (field === 'version') {
const failures = Number(process.env.FAKE_NPM_VERSION_LOOKUP_ERRORS || 0);
state.versionLookups = (state.versionLookups || 0) + 1;
save();
if (state.versionLookups <= failures) {
process.stderr.write('npm error code ECONNRESET\nnpm error network aborted\n');
process.exit(1);
}
}
if (name !== state.name || state.versions.length === 0) notFound('Not Found - GET ' + name);
if (field === 'version') {
const version = spec.slice(at + 1);
if (!state.versions.includes(version)) notFound('No match found for version ' + version);
process.stdout.write(version + '\n');
process.exit(0);
}
if (field === 'dist-tags.latest') {
if (state.distTags.latest) process.stdout.write(state.distTags.latest + '\n');
process.exit(0);
}
}
if (args[0] === 'publish') {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
if (pkg.name !== state.name) {
process.stderr.write('fake npm: refusing to publish ' + pkg.name + '\n');
process.exit(1);
}
if (state.versions.includes(pkg.version)) {
process.stderr.write('npm error code EPUBLISHCONFLICT\n');
process.exit(1);
}
const tagIndex = args.indexOf('--tag');
state.versions.push(pkg.version);
state.distTags[tagIndex === -1 ? 'latest' : args[tagIndex + 1]] = pkg.version;
if (process.env.FAKE_NPM_CLOBBER_LATEST === '1') state.distTags.latest = pkg.version;
save();
process.exit(0);
}
process.stderr.write('fake npm: unsupported command ' + args.join(' ') + '\n');
process.exit(2);
`
const (
publishFixtureName = "@example/zero"
publishFixtureVersion = "1.2.3"
)
type fakeRegistryState struct {
Name string `json:"name"`
Versions []string `json:"versions"`
DistTags map[string]string `json:"distTags"`
// VersionLookups counts `npm view <name>@<version> version` calls.
VersionLookups int `json:"versionLookups,omitempty"`
}
type npmCall struct {
Args []string `json:"args"`
Cwd string `json:"cwd"`
}
type publishRun struct {
stdout, stderr string
exitCode int
state fakeRegistryState
publishes []npmCall
}
// writePublishFixture lays out what build-platform-packages.mjs emits: a
// wrapper whose optionalDependencies alias the given platform payloads, and
// one directory per payload in platforms.
func writePublishFixture(t *testing.T, aliasedPlatforms []string, platformDirs []string) string {
t.Helper()
npmDir := filepath.Join(t.TempDir(), "npm")
aliases := map[string]string{}
for _, platform := range aliasedPlatforms {
aliases[publishFixtureName+"-"+platform] = "npm:" + publishFixtureName + "@" + publishFixtureVersion + "-" + platform
}
wrapper, err := json.Marshal(map[string]any{
"name": publishFixtureName,
"version": publishFixtureVersion,
"optionalDependencies": aliases,
})
if err != nil {
t.Fatal(err)
}
writeFixtureFile(t, filepath.Join(npmDir, "wrapper", "package.json"), string(wrapper), 0o644)
for _, platform := range platformDirs {
manifest := `{"name":"` + publishFixtureName + `","version":"` + publishFixtureVersion + "-" + platform + `"}`
writeFixtureFile(t, filepath.Join(npmDir, "platforms", "zero-"+platform, "package.json"), manifest, 0o644)
}
return npmDir
}
func runPublishPackages(t *testing.T, npmDir string, registry fakeRegistryState, extraEnv ...string) publishRun {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("publish-packages.sh runs on the Linux release runner; the fake npm is a POSIX executable")
}
bash := requireCommand(t, "bash")
requireNode(t)
binDir := t.TempDir()
writeFixtureFile(t, filepath.Join(binDir, "npm"), fakeNpm, 0o755)
if registry.DistTags == nil {
registry.DistTags = map[string]string{}
}
if registry.Versions == nil {
registry.Versions = []string{}
}
stateFile := filepath.Join(t.TempDir(), "registry.json")
initial, err := json.Marshal(registry)
if err != nil {
t.Fatal(err)
}
writeFixtureFile(t, stateFile, string(initial), 0o644)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
command := exec.CommandContext(ctx, bash, filepath.Join(repoRoot(t), "scripts", "npm", "publish-packages.sh"), npmDir)
command.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_NPM_STATE="+stateFile,
"ZERO_NPM_PUBLISH_RETRY_DELAY=0",
"NODE_OPTIONS=",
)
command.Env = append(command.Env, extraEnv...)
var stdout, stderr strings.Builder
command.Stdout = &stdout
command.Stderr = &stderr
run := publishRun{}
if err := command.Run(); err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("publish-packages.sh: %v; stderr=%s", err, stderr.String())
}
run.exitCode = exitErr.ExitCode()
}
if ctx.Err() != nil {
t.Fatalf("publish-packages.sh timed out; stdout=%s stderr=%s", stdout.String(), stderr.String())
}
run.stdout, run.stderr = stdout.String(), stderr.String()
unmarshalJSONFile(t, stateFile, &run.state)
if log, err := os.Open(stateFile + ".log"); err == nil {
defer log.Close()
scanner := bufio.NewScanner(log)
for scanner.Scan() {
var call npmCall
if err := json.Unmarshal(scanner.Bytes(), &call); err != nil {
t.Fatalf("fake npm log line %q: %v", scanner.Text(), err)
}
if len(call.Args) > 0 && call.Args[0] == "publish" {
run.publishes = append(run.publishes, call)
}
}
}
return run
}
// publishedDirs names each publish by the payload directory it ran in, in order.
func (run publishRun) publishedDirs() []string {
dirs := []string{}
for _, call := range run.publishes {
dirs = append(dirs, filepath.Base(call.Cwd))
}
return dirs
}
func TestPublishPackagesPublishesPlatformPayloadsBeforeTheWrapper(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName})
if run.exitCode != 0 {
t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got, want := strings.Join(run.publishedDirs(), ","), "zero-darwin-arm64,zero-linux-x64,wrapper"; got != want {
t.Fatalf("publish order = %s, want %s (the wrapper pins the platform versions, so it must go last)", got, want)
}
for index, call := range run.publishes {
args := strings.Join(call.Args, " ")
if !strings.Contains(args, "--access public") || !strings.Contains(args, "--ignore-scripts") {
t.Fatalf("publish %d args = %q, want --access public and --ignore-scripts", index, args)
}
isWrapper := filepath.Base(call.Cwd) == "wrapper"
if hasPlatformTag := strings.Contains(args, "--tag platform"); hasPlatformTag == isWrapper {
t.Fatalf("publish %d (%s) args = %q: platform payloads need --tag platform and the wrapper must take latest", index, filepath.Base(call.Cwd), args)
}
}
if latest := run.state.DistTags["latest"]; latest != publishFixtureVersion {
t.Fatalf("latest = %q, want %q", latest, publishFixtureVersion)
}
}
// "Re-run failed jobs" after a partial publish must finish the release rather
// than fail on the versions that already went out.
func TestPublishPackagesSkipsVersionsAlreadyOnTheRegistry(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{
Name: publishFixtureName,
Versions: []string{"1.2.2", publishFixtureVersion + "-darwin-arm64"},
DistTags: map[string]string{"latest": "1.2.2", "platform": publishFixtureVersion + "-darwin-arm64"},
})
if run.exitCode != 0 {
t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got, want := strings.Join(run.publishedDirs(), ","), "zero-linux-x64,wrapper"; got != want {
t.Fatalf("published %s, want %s (darwin-arm64 was already on the registry)", got, want)
}
if !strings.Contains(run.stdout, publishFixtureName+"@"+publishFixtureVersion+"-darwin-arm64 is already on the registry") {
t.Fatalf("stdout does not report the skipped version: %s", run.stdout)
}
// Re-running once everything is out publishes nothing and still succeeds.
rerun := runPublishPackages(t, npmDir, run.state)
if rerun.exitCode != 0 {
t.Fatalf("rerun exit = %d; stdout=%s stderr=%s", rerun.exitCode, rerun.stdout, rerun.stderr)
}
if len(rerun.publishes) != 0 {
t.Fatalf("rerun published %v, want nothing", rerun.publishedDirs())
}
}
func TestPublishPackagesStopsBeforeTheWrapperWhenAPlatformTookLatest(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_CLOBBER_LATEST=1")
if run.exitCode == 0 {
t.Fatalf("exit = 0 after a platform publish took latest; stdout=%s", run.stdout)
}
if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64" {
t.Fatalf("published %s, want only the platform payload (the wrapper must not follow a clobbered latest)", got)
}
if !strings.Contains(run.stderr, "clobbered") {
t.Fatalf("stderr does not explain the failure: %s", run.stderr)
}
}
// A wrapper that aliases a platform version with no payload beside it would
// install an alias that 404s, so nothing may be published at all.
func TestPublishPackagesRefusesAWrapperWhoseAliasesDoNotMatchThePayloads(t *testing.T) {
npmDir := writePublishFixture(t, []string{"darwin-arm64", "linux-x64"}, []string{"linux-x64"})
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName})
if run.exitCode == 0 {
t.Fatalf("exit = 0 with a missing platform payload; stdout=%s", run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v before noticing the mismatch, want nothing", run.publishedDirs())
}
if !strings.Contains(run.stderr, "do not match the platform payloads") {
t.Fatalf("stderr does not explain the failure: %s", run.stderr)
}
}
// A lookup that fails for a reason other than E404 must not read as "not
// published": on the re-run this skip exists for, the version is usually out
// already, and publishing it again fails with EPUBLISHCONFLICT.
func TestPublishPackagesRetriesAFailedLookupInsteadOfPublishing(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{
Name: publishFixtureName,
Versions: []string{publishFixtureVersion + "-linux-x64"},
DistTags: map[string]string{"platform": publishFixtureVersion + "-linux-x64"},
}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=2")
if run.exitCode != 0 {
t.Fatalf("exit = %d after two transient lookup failures; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got := strings.Join(run.publishedDirs(), ","); got != "wrapper" {
t.Fatalf("published %s, want only the wrapper (the platform version was already out)", got)
}
}
// When the registry cannot answer at all, nothing may be published: the
// script cannot tell which versions are already out.
func TestPublishPackagesFailsWhenTheRegistryCannotBeReached(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=1000")
if run.exitCode == 0 {
t.Fatalf("exit = 0 with the registry unreachable; stdout=%s", run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v without being able to look anything up, want nothing", run.publishedDirs())
}
if !strings.Contains(run.stderr, "could not look up") || !strings.Contains(run.stderr, "ECONNRESET") {
t.Fatalf("stderr does not explain the failure or show npm's error: %s", run.stderr)
}
}
+12 -5
View File
@@ -53,11 +53,6 @@ var (
// `npm install -g` for npm-managed installs, or by downloading, verifying,
// and atomically replacing the binary for standalone installs.
func Apply(ctx context.Context, options Options) (ApplyResult, error) {
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
executablePath, err := currentExecutable()
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
@@ -66,6 +61,18 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
executablePath = resolved
}
method := DetectInstallMethod(executablePath)
if method == InstallMethodNpmLegacy {
// Nothing newer is ever published under the old name, so npm would
// reinstall this same build and the update would report success. Refused
// before the release check because the answer does not depend on it, and
// it has to reach someone who is offline too.
return ApplyResult{}, errors.New(legacyPackageSwitchMessage())
}
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
// Only the standalone path rewrites the binary in place, so only it needs the
// recovery state. npm and Homebrew hand the work to the package manager.
if method == InstallMethodStandalone {
+47
View File
@@ -433,3 +433,50 @@ func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
}
}
// An install of the old package name gets no further releases, so npm would
// reinstall the same build and Apply would report an update that did not
// happen. It must refuse with the switch steps in the one safe order, without
// waiting on a release check that cannot change the answer.
func TestApplyRefusesALegacyNpmInstallWithTheSwitchSteps(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "zero")
original := []byte("legacy binary")
if err := os.WriteFile(binary, original, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","version":"0.10.0-linux-x64","os":["linux"],"cpu":["x64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
restore := currentExecutable
currentExecutable = func() (string, error) { return binary, nil }
t.Cleanup(func() { currentExecutable = restore })
_, err := Apply(context.Background(), Options{
CurrentVersion: "0.10.0",
Fetch: func(context.Context, string) (Release, error) {
t.Error("Apply ran the release check for a legacy install; the refusal must not depend on the network")
return Release{}, errors.New("offline")
},
})
if err == nil {
t.Fatal("Apply did not refuse a legacy npm install")
}
message := err.Error()
uninstall := strings.Index(message, "npm uninstall -g @gitlawb/zero")
install := strings.Index(message, "npm install -g @twigpine/zero")
if uninstall < 0 || install < 0 {
t.Fatalf("refusal does not give both switch commands: %q", message)
}
if uninstall > install {
t.Fatalf("refusal installs before uninstalling, which npm rejects with EEXIST: %q", message)
}
after, readErr := os.ReadFile(binary)
if readErr != nil {
t.Fatal(readErr)
}
if string(after) != string(original) {
t.Error("Apply rewrote the binary it refused to update")
}
}
+68 -18
View File
@@ -9,17 +9,28 @@ import (
)
// npmPackageName is the published package name for the npm distribution of
// zero (see package.json). scripts/postinstall.mjs downloads the native
// binary into the same directory as package.json and leaves a
// ".zero-binary-version" marker file next to it — both are reliable signals
// that a given executable came from an npm install.
const npmPackageName = "@gitlawb/zero"
// zero, and must equal the name in the repository's package.json
// (TestNpmPackageNameMatchesPackageJSON holds the two together).
// scripts/postinstall.mjs downloads the native binary into the same directory
// as package.json and leaves a ".zero-binary-version" marker file next to it —
// both are reliable signals that a given executable came from an npm install.
const npmPackageName = "@twigpine/zero"
// legacyNpmPackageName is the name zero was published under on npm up to
// 0.9.0. The 0.10.0 release is published once more under it (the legacy step
// in .github/workflows/release-artifacts.yml), so that 0.9.0's `zero upgrade`
// and anyone still installing the old name land on a build that says where
// the package went. Nothing is published under it after that, which is why an
// install of it is told to switch instead of offered an update.
const legacyNpmPackageName = "@gitlawb/zero"
// InstallMethod identifies how the running zero binary was installed.
type InstallMethod string
const (
InstallMethodNpm InstallMethod = "npm"
InstallMethodNpm InstallMethod = "npm"
// InstallMethodNpmLegacy is an npm install of legacyNpmPackageName.
InstallMethodNpmLegacy InstallMethod = "npm-legacy"
InstallMethodHomebrew InstallMethod = "homebrew"
InstallMethodStandalone InstallMethod = "standalone"
)
@@ -53,32 +64,71 @@ func DetectInstallMethod(executablePath string) InstallMethod {
return InstallMethodHomebrew
}
dir := filepath.Dir(executablePath)
pkg, hasManifest := readNpmManifest(dir)
if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil {
// The first-run downloader put this binary next to the wrapper's own
// package.json, which names the package it was installed as.
if hasManifest && pkg.Name == legacyNpmPackageName {
return InstallMethodNpmLegacy
}
return InstallMethodNpm
}
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return InstallMethodStandalone
}
var pkg struct {
Name string `json:"name"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Bin json.RawMessage `json:"bin"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
if !hasManifest {
return InstallMethodStandalone
}
// The native platform package is inert and constrained to one OS/CPU. The
// repository's wrapper package has the same name but carries a bin entry and
// broad platform lists, so name alone would misclassify `go build -o zero`
// from the repository root as an npm-managed install.
if pkg.Name == npmPackageName && len(pkg.OS) == 1 && len(pkg.CPU) == 1 && len(pkg.Bin) == 0 {
if len(pkg.OS) != 1 || len(pkg.CPU) != 1 || len(pkg.Bin) != 0 {
return InstallMethodStandalone
}
switch pkg.Name {
case npmPackageName:
return InstallMethodNpm
case legacyNpmPackageName:
return InstallMethodNpmLegacy
}
return InstallMethodStandalone
}
type npmManifest struct {
Name string `json:"name"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Bin json.RawMessage `json:"bin"`
}
// readNpmManifest reads the package.json in dir, reporting false when there is
// none or it does not parse.
func readNpmManifest(dir string) (npmManifest, bool) {
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return npmManifest{}, false
}
var pkg npmManifest
if err := json.Unmarshal(data, &pkg); err != nil {
return npmManifest{}, false
}
return pkg, true
}
// legacyPackageSwitchMessage tells someone running the build published under
// legacyNpmPackageName how to move to npmPackageName. bin/zero.js prints the
// same note.
//
// There is no in-place migration. Both packages own the `zero` bin, so npm
// refuses to install the new one over the old (EEXIST), and forcing it lets a
// later uninstall of the old package delete the shared bin. Uninstalling first
// is the only safe order, and it has to be the user's own step: the running
// binary is inside the package it would remove.
func legacyPackageSwitchMessage() string {
return "Zero has moved to " + npmPackageName + " on npm, and " + legacyNpmPackageName + " gets no further releases. To keep getting updates, quit zero and run:\n" +
" npm uninstall -g " + legacyNpmPackageName + "\n" +
" npm install -g " + npmPackageName + "\n" +
"Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead."
}
// isHomebrewPath reports whether executablePath is inside a Homebrew keg.
//
// goos is a parameter rather than runtime.GOOS so the decision can be tested on
+76 -2
View File
@@ -1,11 +1,31 @@
package update
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The self-updater installs npmPackageName and recognises installs by it, so
// it must be the name the release actually publishes. A rename that misses
// this constant leaves `zero upgrade` reinstalling the old package.
func TestNpmPackageNameMatchesPackageJSON(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "package.json"))
if err != nil {
t.Fatalf("ReadFile package.json: %v", err)
}
var pkg struct {
Name string `json:"name"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
t.Fatalf("Unmarshal package.json: %v", err)
}
if pkg.Name != npmPackageName {
t.Fatalf("package.json name = %q, npmPackageName = %q; they must match", pkg.Name, npmPackageName)
}
}
func TestDetectInstallMethodStandaloneByDefault(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
@@ -37,7 +57,7 @@ func TestDetectInstallMethodNpmViaPackageJSON(t *testing.T) {
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@gitlawb/zero","version":"0.5.0-linux-x64","os":["linux"],"cpu":["x64"]}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@twigpine/zero","version":"0.5.0-linux-x64","os":["linux"],"cpu":["x64"]}`), 0o644); err != nil {
t.Fatalf("WriteFile package.json: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpm {
@@ -51,7 +71,7 @@ func TestDetectInstallMethodDoesNotTreatRepositoryManifestAsNpmBinary(t *testing
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
manifest := `{"name":"@twigpine/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
@@ -73,3 +93,57 @@ func TestDetectInstallMethodIgnoresUnrelatedPackageJSON(t *testing.T) {
t.Fatalf("DetectInstallMethod = %q, want standalone", method)
}
}
// The bridge release published under the old name must recognise itself, so
// `zero upgrade` can tell it to switch instead of reinstalling the same build.
func TestDetectInstallMethodRecognisesALegacyPlatformPayload(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","version":"0.10.0-linux-x64","os":["linux"],"cpu":["x64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpmLegacy {
t.Fatalf("DetectInstallMethod = %q, want %q", method, InstallMethodNpmLegacy)
}
}
// A binary the first-run downloader fetched sits next to the wrapper's own
// package.json, which is the only place the installed name is recorded.
func TestDetectInstallMethodRecognisesALegacyDownloadedBinary(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.10.0\n"), 0o644); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpmLegacy {
t.Fatalf("DetectInstallMethod = %q, want %q", method, InstallMethodNpmLegacy)
}
}
// Without the downloader's marker, a wrapper-shaped manifest under the old
// name is a checkout, not an install, exactly as for the current name.
func TestDetectInstallMethodDoesNotTreatALegacyWrapperManifestAsAnInstall(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodStandalone {
t.Fatalf("DetectInstallMethod = %q, want standalone", method)
}
}
+11 -2
View File
@@ -15,7 +15,7 @@ import (
)
const (
DefaultRepository = "Gitlawb/zero"
DefaultRepository = "Twigpine/zero"
DefaultTimeout = 5 * time.Second
)
@@ -233,6 +233,10 @@ func Format(result Result) string {
"Latest release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
if result.installMethod == InstallMethodNpmLegacy {
// Up to date today, but the next release will not reach this install.
lines = append(lines, legacyPackageSwitchMessage())
}
return strings.Join(lines, "\n")
}
@@ -322,6 +326,11 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM
// `brew upgrade zero` would change this one instead.
return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed."
}
if installMethod == InstallMethodNpmLegacy {
// Placed like the Homebrew answer above, and for the same reason: it is a
// property of the install on this machine, and `zero upgrade` refuses it.
return legacyPackageSwitchMessage()
}
if sourceFlag != "" {
if installMethod == InstallMethodNpm {
return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source."
@@ -457,7 +466,7 @@ func releasePlatform(goos string) (string, error) {
// self-updating release asset. Point users at `npm update` rather
// than a source rebuild, since that's the documented Termux
// install/upgrade path and doesn't require a Go toolchain.
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos) //nolint:staticcheck // Preserve established user-facing error text.
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g %s` to get the latest.", goos, npmPackageName) //nolint:staticcheck // Preserve established user-facing error text.
}
}
+25 -1
View File
@@ -587,11 +587,35 @@ func TestFormatNpmCustomSourceExplainsInstallSource(t *testing.T) {
SourceFlag: "--repo someone/fork", installMethod: InstallMethodNpm,
}
output := Format(result)
if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @gitlawb/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") {
if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @twigpine/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") {
t.Fatalf("npm custom-source guidance is misleading: %q", output)
}
}
// A legacy install is told to switch whether or not an update is out: up to
// date today, the next release will not reach it, and `zero upgrade` refuses.
func TestFormatTellsALegacyNpmInstallToSwitch(t *testing.T) {
asset := assetCheckForTest(t, "v0.11.0", runtime.GOOS, runtime.GOARCH)
for name, result := range map[string]Result{
"up to date": {
CurrentVersion: "0.10.0", LatestVersion: "0.10.0", ReleaseURL: "https://example.test/release",
ReleaseAsset: asset, installMethod: InstallMethodNpmLegacy,
},
"update available": {
CurrentVersion: "0.10.0", LatestVersion: "0.11.0", ReleaseURL: "https://example.test/release",
ReleaseAsset: asset, UpdateAvailable: true, installMethod: InstallMethodNpmLegacy,
},
} {
output := Format(result)
if !strings.Contains(output, "npm uninstall -g @gitlawb/zero") || !strings.Contains(output, "npm install -g @twigpine/zero") {
t.Errorf("%s: output does not give the switch steps: %q", name, output)
}
if strings.Contains(output, "Run `zero upgrade`") {
t.Errorf("%s: output recommends `zero upgrade`, which refuses a legacy install: %q", name, output)
}
}
}
func TestFormatCrossTargetCustomSourceKeepsAccurateGuidance(t *testing.T) {
goos, goarch := "linux", "amd64"
if runtime.GOOS == goos && runtime.GOARCH == goarch {
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@gitlawb/zero",
"name": "@twigpine/zero",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gitlawb/zero",
"name": "@twigpine/zero",
"version": "0.9.0",
"cpu": [
"x64",
+4 -4
View File
@@ -1,5 +1,5 @@
{
"name": "@gitlawb/zero",
"name": "@twigpine/zero",
"version": "0.9.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
@@ -30,11 +30,11 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/Gitlawb/zero.git"
"url": "git+https://github.com/Twigpine/zero.git"
},
"homepage": "https://github.com/Gitlawb/zero#readme",
"homepage": "https://zero.twigpine.com",
"bugs": {
"url": "https://github.com/Gitlawb/zero/issues"
"url": "https://github.com/Twigpine/zero/issues"
},
"license": "MIT"
}
+1 -1
View File
@@ -5,7 +5,7 @@
"bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a",
"packages": {
".": {
"package-name": "@gitlawb/zero"
"package-name": "@twigpine/zero"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[CmdletBinding()]
param(
[string]$Version = $env:ZERO_VERSION,
[string]$Repository = $(if ($env:ZERO_REPO) { $env:ZERO_REPO } else { "Gitlawb/zero" }),
[string]$Repository = $(if ($env:ZERO_REPO) { $env:ZERO_REPO } else { "Twigpine/zero" }),
[string]$InstallDir = $env:ZERO_INSTALL_DIR,
[string]$GitHubApi = $(if ($env:ZERO_GITHUB_API) { $env:ZERO_GITHUB_API } else { "https://api.github.com" }),
[string]$GitHubBaseUrl = $(if ($env:ZERO_GITHUB_BASE_URL) { $env:ZERO_GITHUB_BASE_URL } else { "https://github.com" })
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ZERO_REPO="${ZERO_REPO:-Gitlawb/zero}"
ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"
ZERO_VERSION="${ZERO_VERSION:-latest}"
ZERO_INSTALL_DIR="${ZERO_INSTALL_DIR:-$HOME/.local/bin}"
ZERO_GITHUB_API="${ZERO_GITHUB_API:-https://api.github.com}"
@@ -16,7 +16,7 @@ Usage:
Environment:
ZERO_VERSION Release version or tag. Defaults to latest.
ZERO_REPO GitHub repository. Defaults to Gitlawb/zero.
ZERO_REPO GitHub repository. Defaults to Twigpine/zero.
ZERO_INSTALL_DIR Directory for the zero binary. Defaults to ~/.local/bin.
ZERO_GITHUB_API GitHub API base URL. Defaults to https://api.github.com.
ZERO_GITHUB_BASE_URL GitHub web base URL. Defaults to https://github.com.
+43 -20
View File
@@ -2,21 +2,22 @@
// 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
// Layout written under --out-dir (default dist/npm), where <name> is the
// package.json name, or --package-name when given:
// wrapper/ <name>@X.Y.Z — 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/zero@X.Y.Z-<platform>-<arch> —
// <name>@X.Y.Z-<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.
// The platform "packages" are versions of the SAME 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
@@ -24,9 +25,15 @@
// CI must always assemble the full matrix, and the wrapper's
// optionalDependencies always reference the full matrix regardless).
//
// --package-name assembles the same artifacts under another name. It exists
// for the one final release under the pre-rename name @gitlawb/zero (see
// docs/NPM_PACKAGING.md); everything in the payloads that names the package
// follows it, so the binary and wrapper know which name they were installed as.
//
// Usage:
// node scripts/npm/build-platform-packages.mjs \
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...]
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...] \
// [--package-name @scope/name]
import {
chmodSync,
@@ -71,7 +78,7 @@ function fail(message) {
}
function parseArgs(argv) {
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null };
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null, packageName: '' };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
@@ -89,6 +96,14 @@ function parseArgs(argv) {
case '--only':
args.only = new Set(next().split(',').map((entry) => entry.trim()).filter(Boolean));
break;
case '--package-name':
args.packageName = next();
// A scoped or unscoped npm name; anything else would also become a
// path segment in the alias npm installs.
if (!/^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/.test(args.packageName)) {
fail(`--package-name ${JSON.stringify(args.packageName)} is not an npm package name`);
}
break;
default:
fail(`unknown argument ${arg}`);
}
@@ -99,6 +114,7 @@ function parseArgs(argv) {
function readWrapperPackage() {
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
if (!pkg.name) fail('package.json name is empty');
if (!pkg.version) fail('package.json version is empty');
return pkg;
}
@@ -107,14 +123,17 @@ function platformVersion(version, entry) {
return `${version}-${entry.platform}-${entry.arch}`;
}
function aliasName(entry) {
return `@gitlawb/zero-${entry.platform}-${entry.arch}`;
// bin/zero.js derives the same alias from its own package name at runtime, so
// the wrapper name, the alias names, and the alias targets must all come from
// one value.
function aliasName(packageName, entry) {
return `${packageName}-${entry.platform}-${entry.arch}`;
}
function optionalDependencies(version) {
function optionalDependencies(packageName, version) {
const aliases = {};
for (const entry of MATRIX) {
aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`;
aliases[aliasName(packageName, entry)] = `npm:${packageName}@${platformVersion(version, entry)}`;
}
return aliases;
}
@@ -260,7 +279,7 @@ function verifyHelperShims(packageDir, entry, assetName) {
}
}
function buildPlatformPackage(entry, version, artifactsDir, outDir) {
function buildPlatformPackage(entry, packageName, 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}`);
@@ -295,9 +314,9 @@ function buildPlatformPackage(entry, version, artifactsDir, outDir) {
const wrapperPkg = readWrapperPackage();
const manifest = {
name: '@gitlawb/zero',
name: packageName,
version: platformVersion(version, entry),
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`,
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via ${packageName}@${version})`,
os: [entry.platform],
cpu: [entry.arch],
license: wrapperPkg.license,
@@ -312,7 +331,7 @@ function buildPlatformPackage(entry, version, artifactsDir, outDir) {
}
}
function buildWrapperPackage(version, outDir) {
function buildWrapperPackage(packageName, version, outDir) {
const wrapperDir = join(outDir, 'wrapper');
rmSync(wrapperDir, { recursive: true, force: true });
mkdirSync(join(wrapperDir, 'bin'), { recursive: true });
@@ -333,14 +352,17 @@ function buildWrapperPackage(version, outDir) {
// stages into the platform payloads — see internal/release/release.go).
delete pkg.scripts;
delete pkg.dependencies;
pkg.optionalDependencies = optionalDependencies(version);
pkg.name = packageName;
pkg.optionalDependencies = optionalDependencies(packageName, 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 wrapperPkg = readWrapperPackage();
const packageName = args.packageName || wrapperPkg.name;
const { version } = wrapperPkg;
const selected = MATRIX.filter(
(entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`),
@@ -356,13 +378,14 @@ function main() {
const built = [];
for (const entry of selected) {
built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir));
built.push(buildPlatformPackage(entry, packageName, version, args.artifactsDir, args.outDir));
}
const wrapperDir = buildWrapperPackage(version, args.outDir);
const wrapperDir = buildWrapperPackage(packageName, version, args.outDir);
process.stdout.write(
JSON.stringify(
{
name: packageName,
version,
wrapperDir,
platformDirs: built,
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# publish-packages.sh: publish the npm payloads that build-platform-packages.mjs
# assembled under <npm-dir> (wrapper/ plus platforms/*/), in the order and
# with the dist-tags docs/NPM_PACKAGING.md requires:
#
# 1. every platform payload, under the `platform` dist-tag, never `latest`;
# 2. check that `latest` was not moved onto a platform version;
# 3. the wrapper, which takes `latest`, then confirm `latest` is its version.
#
# The package name comes from the assembled wrapper's package.json, so the
# same script publishes whatever name the payloads were built under.
#
# A version that is already on the registry is skipped, which makes
# re-running a release job that failed halfway safe: the platform payloads
# that went out are not published twice, and the wrapper still follows them.
# Publishes pass --ignore-scripts because the payloads are already assembled
# and verified; nothing should run at pack time.
#
# Usage: scripts/npm/publish-packages.sh <npm-dir>
#
# Auth comes from the environment (npm OIDC trusted publishing in CI).
# ZERO_NPM_PUBLISH_RETRY_DELAY overrides the seconds between dist-tag lookups.
set -euo pipefail
fail() {
echo "::error::$*" >&2
exit 1
}
npm_dir="${1:-}"
[ -n "$npm_dir" ] || fail "usage: publish-packages.sh <npm-dir>"
wrapper_dir="${npm_dir%/}/wrapper"
[ -f "${wrapper_dir}/package.json" ] || fail "${wrapper_dir}/package.json not found; run build-platform-packages.mjs first"
manifest_field() {
node -e 'const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))[process.argv[2]]; process.stdout.write(value === undefined ? "" : String(value));' "$1/package.json" "$2"
}
name="$(manifest_field "$wrapper_dir" name)"
version="$(manifest_field "$wrapper_dir" version)"
[ -n "$name" ] && [ -n "$version" ] || fail "${wrapper_dir}/package.json has no name or version"
delay="${ZERO_NPM_PUBLISH_RETRY_DELAY:-5}"
# The wrapper pins every platform version exactly, so a wrapper whose aliases
# do not match the payloads beside it would install aliases that 404. Check
# before anything is published.
shopt -s nullglob
platform_dirs=("${npm_dir%/}"/platforms/*/)
shopt -u nullglob
[ "${#platform_dirs[@]}" -gt 0 ] || fail "no platform payloads under ${npm_dir%/}/platforms"
expected_platforms="$(node -e 'const pkg = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(Object.values(pkg.optionalDependencies || {}).sort().join("\n"));' "${wrapper_dir}/package.json")"
actual_platforms=""
for dir in "${platform_dirs[@]}"; do
dir="${dir%/}"
platform_name="$(manifest_field "$dir" name)"
platform_version="$(manifest_field "$dir" version)"
[ "$platform_name" = "$name" ] || fail "${dir} is ${platform_name}, not ${name}"
case "$platform_version" in
"${version}-"*) ;;
*) fail "${dir} version ${platform_version} is not a platform version of ${name}@${version}" ;;
esac
actual_platforms="${actual_platforms}npm:${name}@${platform_version}"$'\n'
done
actual_platforms="$(printf '%s' "$actual_platforms" | sort)"
if [ "$actual_platforms" != "$expected_platforms" ]; then
fail "the wrapper's optionalDependencies do not match the platform payloads under ${npm_dir%/}/platforms (wrapper: $(echo "$expected_platforms" | tr '\n' ' '); payloads: $(echo "$actual_platforms" | tr '\n' ' '))"
fi
# npm reports every kind of "not on the registry" (no such package, no such
# version) as E404. Any other lookup failure (network, auth, a registry
# outage) is retried and then fails the job: reading it as "not published"
# would publish a version that may already be out, and npm would reject that
# with EPUBLISHCONFLICT on the very re-run this skip exists for.
already_published() {
local output status errors
errors="$(mktemp)"
for _ in 1 2 3; do
status=0
output="$(npm view "${name}@$1" version 2>"$errors")" || status=$?
if [ "$status" -eq 0 ]; then
rm -f "$errors"
[ "$output" = "$1" ]
return
fi
if grep -q 'code E404' "$errors"; then
rm -f "$errors"
return 1
fi
sleep "$delay"
done
cat "$errors" >&2
rm -f "$errors"
fail "could not look up ${name}@$1 on the registry, so cannot tell whether it is already published"
}
# publish <dir> <version> [npm publish flags...]
publish() {
local dir="$1" package_version="$2"
shift 2
if already_published "$package_version"; then
echo "${name}@${package_version} is already on the registry; not publishing it again."
return 0
fi
echo "publishing ${name}@${package_version} from ${dir}"
(cd "$dir" && npm publish --access public --ignore-scripts "$@")
}
for dir in "${platform_dirs[@]}"; do
dir="${dir%/}"
publish "$dir" "$(manifest_field "$dir" version)" --tag platform
done
# Platform versions are semver prereleases of the wrapper version, so a
# platform publish that took `latest` would make a plain install deliver a
# payload with no bin. Stop before the wrapper goes out. A failed lookup is
# tolerated (registry lag, or no `latest` yet on a package's first release):
# failing here would strand the platform payloads without their wrapper.
latest="$(npm view "$name" dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
fail "dist-tag latest points at platform version ${latest}; a platform publish clobbered it. Fix the dist-tag before publishing the wrapper."
;;
esac
echo "dist-tag latest is ${latest:-unset} (unchanged by the platform publishes)."
publish "$wrapper_dir" "$version"
# Registry metadata can lag a publish by a few seconds.
for _ in 1 2 3 4 5; do
latest="$(npm view "$name" dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep "$delay"
done
fail "dist-tag latest of ${name} is ${latest:-unset}, expected ${version} after the wrapper publish."
+2 -2
View File
@@ -16,7 +16,7 @@
// 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 Gitlawb/zero)
// ZERO_REPO=owner/name override the GitHub repo (default Twigpine/zero)
// ZERO_GITHUB_BASE_URL=… override the download host (default https://github.com)
import {
@@ -41,7 +41,7 @@ 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 || 'Gitlawb/zero';
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