From c1dd401ce960132713d622ed4bdf3f677df718b5 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 15 Oct 2026 09:48:05 +0800 Subject: [PATCH 1/3] 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). --- .github/ISSUE_TEMPLATE/config.yml | 6 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/workflows/release-artifacts.yml | 76 ++--- CONTRIBUTING.md | 10 +- README.md | 32 ++- README_ZH.md | 27 +- action.yml | 4 +- bin/zero.js | 44 ++- docs/GITHUB_ACTION.md | 10 +- docs/INSTALL.md | 59 ++-- docs/NPM_PACKAGING.md | 48 +++- docs/NPM_WRAPPER_SMOKE.md | 19 +- docs/UPDATE.md | 8 +- internal/installtest/install_scripts_test.go | 4 +- internal/npmwrapper/buildpackages_test.go | 16 +- internal/npmwrapper/npmwrapper_test.go | 52 +++- internal/npmwrapper/publish_test.go | 274 +++++++++++++++++++ internal/update/installmethod.go | 11 +- internal/update/installmethod_test.go | 24 +- internal/update/update.go | 4 +- internal/update/update_test.go | 2 +- package-lock.json | 4 +- package.json | 8 +- release-please-config.json | 2 +- scripts/install.ps1 | 2 +- scripts/install.sh | 4 +- scripts/npm/build-platform-packages.mjs | 43 +-- scripts/npm/publish-packages.sh | 114 ++++++++ scripts/postinstall.mjs | 4 +- 29 files changed, 707 insertions(+), 206 deletions(-) create mode 100644 internal/npmwrapper/publish_test.go create mode 100755 scripts/npm/publish-packages.sh diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9d54226f9..dbe73e52d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5f9232a99..7185e9f6b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index ed5e8f8d4..feadc39b9 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -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,11 @@ 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)." - - - name: Publish wrapper to npm - shell: bash - run: | - version="$(node -p "require('./package.json').version")" - (cd dist/npm/wrapper && npm publish --access public) - # Confirm the wrapper owns `latest` (retry: registry metadata can lag - # a publish by a few seconds). - for attempt in 1 2 3 4 5; do - latest="$(npm view @gitlawb/zero dist-tags.latest || true)" - if [ "$latest" = "$version" ]; then - echo "dist-tag latest is ${version}." - exit 0 - fi - sleep 5 - done - echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2 - exit 1 + run: scripts/npm/publish-packages.sh dist/npm diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b156012b0..e0dcc03e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 45d94976a..5ab421989 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_ZH.md b/README_ZH.md index 822d61968..c43f0b548 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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) | 维护者发布的版本和项目动态 | ## 贡献 diff --git a/action.yml b/action.yml index 14bbfcc66..c28512c20 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/bin/zero.js b/bin/zero.js index ff45290e5..5368ccafc 100755 --- a/bin/zero.js +++ b/bin/zero.js @@ -1,11 +1,31 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } 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))); + +// Used only when the wrapper's own package.json cannot be read. +const DEFAULT_PACKAGE_NAME = '@twigpine/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 DEFAULT_PACKAGE_NAME; +} + +const packageName = readPackageName(packageRoot); + function zeroBinaryName(platform = process.platform) { return platform === 'win32' ? 'zero.exe' : 'zero'; } @@ -81,10 +101,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 +113,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 +133,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 +202,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 +247,6 @@ function downloadMissingBinary(packageRoot) { }); } -const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); - function resolveNativeBinary() { const fromPlatformPackage = platformPackageBinary(); if (fromPlatformPackage) return fromPlatformPackage; diff --git a/docs/GITHUB_ACTION.md b/docs/GITHUB_ACTION.md index 0602a15ed..0508aea7f 100644 --- a/docs/GITHUB_ACTION.md +++ b/docs/GITHUB_ACTION.md @@ -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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 2126d2fb3..10abc7b35 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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,42 @@ 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. + ## 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 +116,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 +127,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 +150,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 +161,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/zero@0.7.1 +mise use -g ubi:Twigpine/zero@0.7.1 ``` Updates come from `mise upgrade`. `zero upgrade` also works, because a @@ -169,7 +196,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 +240,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 +329,7 @@ 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. - **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`. diff --git a/docs/NPM_PACKAGING.md b/docs/NPM_PACKAGING.md index 7f857992b..6647f447d 100644 --- a/docs/NPM_PACKAGING.md +++ b/docs/NPM_PACKAGING.md @@ -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--` and exec the `zero` binary from +1. Resolve `@twigpine/zero--` 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,21 @@ 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--` 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. `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`. ## Runbook: bumping the vendored helpers diff --git a/docs/NPM_WRAPPER_SMOKE.md b/docs/NPM_WRAPPER_SMOKE.md index c58bb76ab..d0cad8b52 100644 --- a/docs/NPM_WRAPPER_SMOKE.md +++ b/docs/NPM_WRAPPER_SMOKE.md @@ -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-- \ - "/tmp/zero-sim/node_modules/@gitlawb/zero--" -node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version + "/tmp/zero-sim/node_modules/@twigpine/zero--" +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--" -node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version +rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero--" +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@--` with matching + `package.json` is `@twigpine/zero@--` 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` diff --git a/docs/UPDATE.md b/docs/UPDATE.md index f89a39b16..fac4e0037 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -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,7 @@ 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`. - 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 +38,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 +65,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` diff --git a/internal/installtest/install_scripts_test.go b/internal/installtest/install_scripts_test.go index 90bdb5a32..9bc30b4c1 100644 --- a/internal/installtest/install_scripts_test.go +++ b/internal/installtest/install_scripts_test.go @@ -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 diff --git a/internal/npmwrapper/buildpackages_test.go b/internal/npmwrapper/buildpackages_test.go index 09c45dd4d..5896df07e 100644 --- a/internal/npmwrapper/buildpackages_test.go +++ b/internal/npmwrapper/buildpackages_test.go @@ -36,7 +36,7 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) { 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, "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) @@ -101,8 +101,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 +198,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) diff --git a/internal/npmwrapper/npmwrapper_test.go b/internal/npmwrapper/npmwrapper_test.go index bc1645633..1e5388d07 100644 --- a/internal/npmwrapper/npmwrapper_test.go +++ b/internal/npmwrapper/npmwrapper_test.go @@ -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,40 @@ 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) + } +} + +// writePlatformPackageFixture installs a fake platform payload where npm puts +// the wrapper's optionalDependency alias --; 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 +427,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) } diff --git a/internal/npmwrapper/publish_test.go b/internal/npmwrapper/publish_test.go new file mode 100644 index 000000000..6e73359ae --- /dev/null +++ b/internal/npmwrapper/publish_test.go @@ -0,0 +1,274 @@ +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 refuses what the +// real registry refuses: a version published twice, and 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. +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 notFound = () => { process.stderr.write('npm error code E404\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 (name !== state.name || state.versions.length === 0) notFound(); + if (field === 'version') { + const version = spec.slice(at + 1); + if (state.versions.includes(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; + fs.writeFileSync(stateFile, JSON.stringify(state)); + 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"` +} + +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) + } +} diff --git a/internal/update/installmethod.go b/internal/update/installmethod.go index ca737f520..0e76f9e6d 100644 --- a/internal/update/installmethod.go +++ b/internal/update/installmethod.go @@ -9,11 +9,12 @@ 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" // InstallMethod identifies how the running zero binary was installed. type InstallMethod string diff --git a/internal/update/installmethod_test.go b/internal/update/installmethod_test.go index c28885a94..bbc25d0a7 100644 --- a/internal/update/installmethod_test.go +++ b/internal/update/installmethod_test.go @@ -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) } diff --git a/internal/update/update.go b/internal/update/update.go index 6d0e980f9..605cdb593 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -15,7 +15,7 @@ import ( ) const ( - DefaultRepository = "Gitlawb/zero" + DefaultRepository = "Twigpine/zero" DefaultTimeout = 5 * time.Second ) @@ -457,7 +457,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. } } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 692be2faa..f5274330c 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -587,7 +587,7 @@ 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) } } diff --git a/package-lock.json b/package-lock.json index 3587bb52a..ab59efd71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 97aa26d72..dd83a332a 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/release-please-config.json b/release-please-config.json index 545d1f3c6..013b0fbf0 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,7 +5,7 @@ "bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a", "packages": { ".": { - "package-name": "@gitlawb/zero" + "package-name": "@twigpine/zero" } } } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 37e50093b..2968ed3f5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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" }) diff --git a/scripts/install.sh b/scripts/install.sh index 026408347..a19480e07 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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. diff --git a/scripts/npm/build-platform-packages.mjs b/scripts/npm/build-platform-packages.mjs index a81aee4ec..00eb7570f 100644 --- a/scripts/npm/build-platform-packages.mjs +++ b/scripts/npm/build-platform-packages.mjs @@ -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/zero@X.Y.Z — bin/zero.js, the +// Layout written under --out-dir (default dist/npm), where is the +// package.json name: +// wrapper/ @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--/ -// @gitlawb/zero@X.Y.Z-- — +// @X.Y.Z-- — // 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 @@ -99,6 +100,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 +109,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 +265,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 +300,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 +317,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 +338,15 @@ 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 { name: packageName, version } = readWrapperPackage(); const selected = MATRIX.filter( (entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`), @@ -356,13 +362,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, diff --git a/scripts/npm/publish-packages.sh b/scripts/npm/publish-packages.sh new file mode 100755 index 000000000..cd2c31f45 --- /dev/null +++ b/scripts/npm/publish-packages.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# publish-packages.sh: publish the npm payloads that build-platform-packages.mjs +# assembled under (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 +# +# 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 " +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 + +already_published() { + [ "$(npm view "${name}@$1" version 2>/dev/null || true)" = "$1" ] +} + +# publish [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." diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 5e9c88ffb..be0268660 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -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 From c211b127980e3f8228b5a3685b69a494bf515b0d Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 15 Oct 2026 09:56:12 +0800 Subject: [PATCH 2/3] 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. --- .github/workflows/release-artifacts.yml | 47 +++++ bin/zero.js | 40 +++- docs/INSTALL.md | 10 +- docs/NPM_PACKAGING.md | 57 ++++++ docs/UPDATE.md | 6 +- internal/npmwrapper/buildpackages_test.go | 218 ++++++++++++++++------ internal/npmwrapper/npmwrapper_test.go | 58 ++++++ internal/update/apply.go | 17 +- internal/update/apply_test.go | 47 +++++ internal/update/installmethod.go | 75 ++++++-- internal/update/installmethod_test.go | 54 ++++++ internal/update/update.go | 9 + internal/update/update_test.go | 24 +++ scripts/npm/build-platform-packages.mjs | 24 ++- 14 files changed, 596 insertions(+), 90 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index feadc39b9..48864b183 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -211,3 +211,50 @@ jobs: - name: Publish npm packages shell: bash run: scripts/npm/publish-packages.sh dist/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")" + latest="" + for attempt in 1 2 3; do + latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)" + if [ -n "$latest" ]; then + break + fi + sleep 10 + done + 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 diff --git a/bin/zero.js b/bin/zero.js index 5368ccafc..66abddbf4 100755 --- a/bin/zero.js +++ b/bin/zero.js @@ -1,15 +1,20 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } 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))); -// Used only when the wrapper's own package.json cannot be read. -const DEFAULT_PACKAGE_NAME = '@twigpine/zero'; +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 @@ -21,11 +26,38 @@ function readPackageName(root) { } catch { // An unreadable manifest must not stop the CLI from launching. } - return DEFAULT_PACKAGE_NAME; + 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'; } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 10abc7b35..95a95bed8 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -86,6 +86,11 @@ 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: @@ -329,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 @twigpine/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`. diff --git a/docs/NPM_PACKAGING.md b/docs/NPM_PACKAGING.md index 6647f447d..e8d5c9929 100644 --- a/docs/NPM_PACKAGING.md +++ b/docs/NPM_PACKAGING.md @@ -151,6 +151,63 @@ registry. `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/zero@0.0.0` by hand with + `npm publish --access public --tag bootstrap`, then + `npm deprecate @twigpine/zero@0.0.0 "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 `, 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 The vendored helper versions are pinned by the repo `package.json` diff --git a/docs/UPDATE.md b/docs/UPDATE.md index fac4e0037..55e30595b 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -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 @twigpine/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. diff --git a/internal/npmwrapper/buildpackages_test.go b/internal/npmwrapper/buildpackages_test.go index 5896df07e..4c1c17ff3 100644 --- a/internal/npmwrapper/buildpackages_test.go +++ b/internal/npmwrapper/buildpackages_test.go @@ -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":"@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) - + 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 { @@ -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- under an @gitlawb/zero- 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) diff --git a/internal/npmwrapper/npmwrapper_test.go b/internal/npmwrapper/npmwrapper_test.go index 1e5388d07..c090ad51e 100644 --- a/internal/npmwrapper/npmwrapper_test.go +++ b/internal/npmwrapper/npmwrapper_test.go @@ -371,6 +371,64 @@ func TestNodeWrapperResolvesPlatformPackageUnderItsOwnName(t *testing.T) { } } +// 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 --; its // zero binary prints label followed by its arguments. diff --git a/internal/update/apply.go b/internal/update/apply.go index 4c70c1ba2..ac0daf4c9 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -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 { diff --git a/internal/update/apply_test.go b/internal/update/apply_test.go index 640f5ffa7..d13dc4c90 100644 --- a/internal/update/apply_test.go +++ b/internal/update/apply_test.go @@ -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") + } +} diff --git a/internal/update/installmethod.go b/internal/update/installmethod.go index 0e76f9e6d..d1e738c6b 100644 --- a/internal/update/installmethod.go +++ b/internal/update/installmethod.go @@ -16,11 +16,21 @@ import ( // 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" ) @@ -54,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 diff --git a/internal/update/installmethod_test.go b/internal/update/installmethod_test.go index bbc25d0a7..0d6f5e2de 100644 --- a/internal/update/installmethod_test.go +++ b/internal/update/installmethod_test.go @@ -93,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) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 605cdb593..8fa298dbc 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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." diff --git a/internal/update/update_test.go b/internal/update/update_test.go index f5274330c..51a0e91bc 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -592,6 +592,30 @@ func TestFormatNpmCustomSourceExplainsInstallSource(t *testing.T) { } } +// 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 { diff --git a/scripts/npm/build-platform-packages.mjs b/scripts/npm/build-platform-packages.mjs index 00eb7570f..d2cb34068 100644 --- a/scripts/npm/build-platform-packages.mjs +++ b/scripts/npm/build-platform-packages.mjs @@ -3,7 +3,7 @@ // from the per-platform archives built by `zero-release package`. // // Layout written under --out-dir (default dist/npm), where is the -// package.json name: +// package.json name, or --package-name when given: // wrapper/ @X.Y.Z — bin/zero.js, the // first-run fallback downloader, and a // package.json with NO scripts and NO @@ -25,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, @@ -72,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 = () => { @@ -90,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}`); } @@ -346,7 +360,9 @@ function buildWrapperPackage(packageName, version, outDir) { function main() { const args = parseArgs(process.argv.slice(2)); - const { name: packageName, version } = readWrapperPackage(); + 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}`), From a4863e9507d5e70e8b2af68cd53339cc4886cc79 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 15 Oct 2026 10:54:46 +0800 Subject: [PATCH 3/3] fix(npm): only skip a publish when npm says the version is not there publish-packages.sh treated any failed `npm view @` 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. --- .github/workflows/release-artifacts.yml | 2 +- docs/NPM_PACKAGING.md | 4 +- internal/npmwrapper/publish_test.go | 71 +++++++++++++++++++++---- scripts/npm/publish-packages.sh | 25 ++++++++- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 48864b183..c1ed0cc88 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -232,7 +232,7 @@ jobs: run: | version="$(node -p "require('./package.json').version")" latest="" - for attempt in 1 2 3; do + for _ in 1 2 3; do latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)" if [ -n "$latest" ]; then break diff --git a/docs/NPM_PACKAGING.md b/docs/NPM_PACKAGING.md index e8d5c9929..50ddffa2d 100644 --- a/docs/NPM_PACKAGING.md +++ b/docs/NPM_PACKAGING.md @@ -145,7 +145,9 @@ registry. 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. + 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 `@twigpine/zero` next to the running binary — true inside diff --git a/internal/npmwrapper/publish_test.go b/internal/npmwrapper/publish_test.go index 6e73359ae..af0e2fc39 100644 --- a/internal/npmwrapper/publish_test.go +++ b/internal/npmwrapper/publish_test.go @@ -14,26 +14,41 @@ import ( ) // 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 refuses what the -// real registry refuses: a version published twice, and 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. +// 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= 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 notFound = () => { process.stderr.write('npm error code E404\n'); process.exit(1); }; +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 (name !== state.name || state.versions.length === 0) notFound(); + 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)) process.stdout.write(version + '\n'); + 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') { @@ -55,7 +70,7 @@ if (args[0] === 'publish') { 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; - fs.writeFileSync(stateFile, JSON.stringify(state)); + save(); process.exit(0); } process.stderr.write('fake npm: unsupported command ' + args.join(' ') + '\n'); @@ -71,6 +86,8 @@ type fakeRegistryState struct { Name string `json:"name"` Versions []string `json:"versions"` DistTags map[string]string `json:"distTags"` + // VersionLookups counts `npm view @ version` calls. + VersionLookups int `json:"versionLookups,omitempty"` } type npmCall struct { @@ -272,3 +289,39 @@ func TestPublishPackagesRefusesAWrapperWhoseAliasesDoNotMatchThePayloads(t *test 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) + } +} diff --git a/scripts/npm/publish-packages.sh b/scripts/npm/publish-packages.sh index cd2c31f45..84c88e4db 100755 --- a/scripts/npm/publish-packages.sh +++ b/scripts/npm/publish-packages.sh @@ -66,8 +66,31 @@ 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() { - [ "$(npm view "${name}@$1" version 2>/dev/null || true)" = "$1" ] + 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 [npm publish flags...]