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..c1ed0cc88 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,58 @@ jobs: - name: Assemble npm packages run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm - # Platform versions are semver PRERELEASES of the wrapper version - # (X.Y.Z-linux-x64), so they must never publish under the default - # `latest` dist-tag — that would make `npm install @gitlawb/zero` - # deliver a platform payload as the CLI. Publish them under a dedicated - # tag and assert `latest` survived before the wrapper goes out. - - name: Publish platform packages to npm + # Platform payloads first under the `platform` dist-tag, then the wrapper, + # with the `latest` dist-tag checked before and after (platform versions + # are semver PRERELEASES of the wrapper version and must never become + # `latest`). Versions already on the registry are skipped, so "Re-run + # failed jobs" is safe after a partial publish. + - name: Publish npm packages shell: bash - run: | - version="$(node -p "require('./package.json').version")" - for dir in dist/npm/platforms/*/; do - echo "publishing ${dir}" - (cd "$dir" && npm publish --access public --tag platform) - done - # Tolerate a failed lookup (registry lag, or no latest tag yet on a - # bootstrap publish): under set -e a bare failure here would abort - # AFTER the platform payloads went out, leaving no wrapper and a - # re-run that fails on republishing existing versions. An empty - # value falls through the guard below. - latest="$(npm view @gitlawb/zero dist-tags.latest || true)" - case "$latest" in - "${version}-"*) - echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2 - exit 1 - ;; - esac - echo "dist-tag latest is ${latest} (unchanged by platform publishes)." - - - name: Publish wrapper to npm + 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")" - (cd dist/npm/wrapper && npm publish --access public) - # Confirm the wrapper owns `latest` (retry: registry metadata can lag - # a publish by a few seconds). - for attempt in 1 2 3 4 5; do - latest="$(npm view @gitlawb/zero dist-tags.latest || true)" - if [ "$latest" = "$version" ]; then - echo "dist-tag latest is ${version}." - exit 0 + latest="" + for _ in 1 2 3; do + latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)" + if [ -n "$latest" ]; then + break fi - sleep 5 + sleep 10 done - echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2 - exit 1 + if [ -z "$latest" ]; then + echo "::error::could not read the latest dist-tag of @gitlawb/zero from the registry" >&2 + exit 1 + fi + if [ "$latest" = "$LAST_PRE_RENAME_VERSION" ] || [ "$latest" = "$version" ]; then + owed=true + else + owed=false + fi + echo "@gitlawb/zero latest is ${latest}; final release under the old name owed: ${owed}" + echo "owed=${owed}" >> "$GITHUB_OUTPUT" + + - name: Publish the final @gitlawb/zero release + if: ${{ steps.legacy-name.outputs.owed == 'true' }} + shell: bash + run: | + node scripts/npm/build-platform-packages.mjs \ + --artifacts-dir dist/artifacts --out-dir dist/npm-legacy --package-name @gitlawb/zero + scripts/npm/publish-packages.sh dist/npm-legacy 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..66abddbf4 100755 --- a/bin/zero.js +++ b/bin/zero.js @@ -1,11 +1,63 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync, writeSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +const CURRENT_PACKAGE_NAME = '@twigpine/zero'; +// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That +// release was also published once under the old name, so 0.9.0's `zero +// upgrade` and anyone still installing the old name land on a working build +// that says where the package went (docs/NPM_PACKAGING.md). Nothing is +// published under the old name after that. +const LEGACY_PACKAGE_NAME = '@gitlawb/zero'; + +// The npm name this wrapper was published under, read from its own +// package.json so the name lives in one place. The platform payload alias and +// every reinstall hint below derive from it. +function readPackageName(root) { + try { + const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + if (typeof name === 'string' && name !== '') return name; + } catch { + // An unreadable manifest must not stop the CLI from launching. + } + return CURRENT_PACKAGE_NAME; +} + +const packageName = readPackageName(packageRoot); + +// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go. +// Uninstalling first is the only safe order: both packages own the `zero` bin, +// so npm refuses to install the new one over the old (EEXIST), and forcing it +// lets a later uninstall of the old package delete the shared bin. +function legacyPackageNotice() { + return ( + `[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` + + 'To keep getting updates, quit zero and run:\n' + + ` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` + + ` npm install -g ${CURRENT_PACKAGE_NAME}\n` + + 'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n' + ); +} + +// Printed when the wrapper exits rather than before zero starts, because the +// TUI takes over the screen and would hide anything printed first. Only on a +// terminal, so scripts that capture stderr see nothing new. `zero update` and +// `zero upgrade` print the same note themselves. +if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) { + process.on('exit', () => { + if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') { + // Synchronous: an exit handler cannot wait for an asynchronous write. + writeSync(2, legacyPackageNotice()); + } + }); +} + function zeroBinaryName(platform = process.platform) { return platform === 'win32' ? 'zero.exe' : 'zero'; } @@ -81,10 +133,10 @@ function bunRecoveryParagraph() { return ( 'You installed with Bun, which does not run dependency lifecycle scripts\n' + 'by default. Trust the package to run the blocked postinstall:\n' + - ' bun pm trust @gitlawb/zero (project install)\n' + - ' bun pm -g trust @gitlawb/zero (global install)\n' + + ` bun pm trust ${packageName} (project install)\n` + + ` bun pm -g trust ${packageName} (global install)\n` + 'On Bun versions without `bun pm trust`, add\n' + - ' "trustedDependencies": ["@gitlawb/zero"]\n' + + ` "trustedDependencies": ["${packageName}"]\n` + 'to your project package.json and reinstall.\n' + '\n' ); @@ -93,14 +145,14 @@ function bunRecoveryParagraph() { function buildFromSourceParagraph() { return ( 'If this platform has no prebuilt binary, build from source:\n' + - 'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).' + 'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).' ); } function missingBinaryContextParagraph() { return ( - 'Normally npm installs it as an optional dependency of @gitlawb/zero\n' + - `(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` + + `Normally npm installs it as an optional dependency of ${packageName}\n` + + `(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` + 'also download it from the GitHub Release when it is missing.' ); } @@ -113,7 +165,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) { '\n' + 'Things to try:\n' + ' - reinstall without omitting optional dependencies:\n' + - ' npm install -g @gitlawb/zero\n' + + ` npm install -g ${packageName}\n` + ' - run the downloader manually (needs write access to the package dir):\n' + ` node "${postinstallScript}"\n` + '\n' + @@ -182,12 +234,12 @@ function missingNativeDoctorTextReport(postinstallScript) { ); } -// The platform payload is a version of @gitlawb/zero itself, installed under -// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from +// The platform payload is a version of this same package, installed under an +// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from // process.platform/process.arch, so an unsupported platform simply fails to // resolve and we fall through to the downloader. function platformPackageBinary() { - const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`; + const aliasedName = `${packageName}-${process.platform}-${process.arch}`; let manifestPath; try { manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`); @@ -227,8 +279,6 @@ function downloadMissingBinary(packageRoot) { }); } -const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); - function resolveNativeBinary() { const fromPlatformPackage = platformPackageBinary(); if (fromPlatformPackage) return fromPlatformPackage; 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..95a95bed8 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,47 @@ cannot write to keeps failing — rerun the install or the command below with sufficient permissions). To trigger the fetch manually: ```bash -node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs" +node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs" ``` +### Moving from @gitlawb/zero + +Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is +`@twigpine/zero`, and the old name gets no further releases. + +Both packages provide the `zero` command, so npm refuses to install the new +one while the old one is installed (`EEXIST`). Forcing it is worse: a later +uninstall of the old package removes the shared `zero` command. Quit any +running `zero` (on Windows a running `zero.exe` cannot be removed), then +uninstall the old package before installing the new one: + +```bash +npm uninstall -g @gitlawb/zero +npm install -g @twigpine/zero +``` + +With another package manager, the same two steps: + +```bash +bun remove -g @gitlawb/zero && bun add -g @twigpine/zero +pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero +yarn global remove @gitlawb/zero && yarn global add @twigpine/zero +``` + +Config, sessions, logins, and plugins live in your user config and home +directories, not in the package, so they carry over unchanged. + +0.10.0 was also published once as `@gitlawb/zero`, so an old install that +updates lands on a working build. That build prints these steps when `zero` +exits in a terminal, and `zero upgrade` and `zero update --check` print them +too. + ## Linux And macOS Script Install the latest release: ```bash -curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash ``` From a checkout: @@ -89,7 +121,7 @@ scripts/install.sh --install-dir "$HOME/bin" Defaults: -- Repository: `Gitlawb/zero` +- Repository: `Twigpine/zero` - Version: latest GitHub release - Install path: `~/.local/bin/zero` @@ -100,7 +132,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`. Install the latest release: ```powershell -irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex +irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex ``` From a checkout: @@ -123,7 +155,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U Defaults: -- Repository: `Gitlawb/zero` +- Repository: `Twigpine/zero` - Version: latest GitHub release - Install path: `%LOCALAPPDATA%\zero\bin\zero.exe` @@ -134,13 +166,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no registry in the middle: ```bash -mise use -g ubi:Gitlawb/zero +mise use -g ubi:Twigpine/zero ``` Pin a version the same way you would any other tool: ```bash -mise use -g ubi:Gitlawb/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 +201,7 @@ macOS and Windows need no extra helper. ## From Source ```bash -git clone https://github.com/Gitlawb/zero.git +git clone https://github.com/Twigpine/zero.git cd zero go run ./cmd/zero ``` @@ -213,7 +245,7 @@ seccomp filter on Android: pkg install golang # Build Zero for Android -git clone https://github.com/Gitlawb/zero.git +git clone https://github.com/Twigpine/zero.git cd zero CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero @@ -302,7 +334,10 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details. overwriting a binary a package manager owns leaves that manager describing a version that is no longer there: -- **npm**: runs `npm install -g @gitlawb/zero@latest` for you. +- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of + the old `@gitlawb/zero` package refuses instead and prints the steps in + [Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no + further releases, so npm would only reinstall the same version. - **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the keg binary directly would be reverted by the next `brew upgrade` or `brew reinstall`. diff --git a/docs/NPM_PACKAGING.md b/docs/NPM_PACKAGING.md index 7f857992b..50ddffa2d 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,80 @@ one is user-visible immediately. first would create a window where installs resolve aliases that 404. 3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform versions `X.Y.Z--` exactly — never ranges — so a wrapper - and its binaries can never skew. + and its binaries can never skew. The publish script refuses to start when + the wrapper's aliases do not match the platform payloads beside it. 4. All publishes go through the existing npm OIDC trusted-publishing flow and the `npm-publish` environment gate; no tokens. Because the platform payloads are versions of the same package, they reuse the single - trusted-publisher configuration. + trusted-publisher configuration. npm checks provenance against + `package.json` `repository`, so it must name the repository the workflow + runs in. +5. **Re-runs are safe.** A version already on the registry is skipped rather + than republished, so re-running a job that failed between the platform and + wrapper publishes finishes the release. Only npm's E404 counts as "not + published"; a lookup that fails any other way is retried and then stops the + job before anything is published. `zero update` keeps working unchanged: it detects an npm install by finding a -`package.json` named `@gitlawb/zero` next to the running binary — true inside -a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`. +`package.json` named `@twigpine/zero` next to the running binary — true inside +a platform payload too — and updates via `npm install -g @twigpine/zero@latest`. + +## The move from `@gitlawb/zero` + +Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero` +from 0.10.0. There is no in-place migration: both packages own the `zero` +bin, so npm refuses to install one over the other (`EEXIST`), and forcing it +lets a later uninstall of the old package delete the shared bin. The user +has to uninstall the old package and install the new one, in that order. + +Deprecating the old package alone does not reach existing users. 0.9.0's +`zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with nothing newer +under that name, npm reinstalls nothing, prints no deprecation warning (it +only warns when it installs something), and Zero reports the update as done. +So 0.10.0 is published once more under the old name, as a bridge: + +- The publish job assembles the same verified artifacts a second time with + `build-platform-packages.mjs --package-name @gitlawb/zero` and publishes + them with the same script, after the `@twigpine/zero` publish. A gate runs + it only while the old name's `latest` is still 0.9.0 (or is this version, + on a re-run), so it cannot happen twice. +- The bridge build knows its name from the `package.json` beside it. The Go + binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses + with the switch steps instead of reinstalling it, and `zero update --check` + adds them. `bin/zero.js` prints the same steps after `zero` exits, on a + terminal only. +- Updating from 0.9.0 now installs a real version, so npm also shows the + deprecation message. + +### Runbook + +Before the first release under the new name: + +1. Create the package, since npm only attaches a trusted publisher to a + package that exists: publish an empty `@twigpine/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 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..55e30595b 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,11 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied. `--apply` installs the update in place: -- npm installs delegate to `npm install -g @gitlawb/zero@latest`. +- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install + of the old `@gitlawb/zero` package refuses, without a release check, and + prints the steps to switch to `@twigpine/zero` (see + [INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them + too. - Standalone installs download the release archive, verify its checksum, extract it, and atomically replace the running binary plus any installed optional sandbox helpers. @@ -38,7 +42,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied. - `--target` cannot be combined with `--apply`; it only applies to `--check`, since applying always installs onto the current machine. - `--repo` and `--endpoint` are ignored when applying to an npm-managed - install: that path delegates to `npm install -g @gitlawb/zero@latest` and + install: that path delegates to `npm install -g @twigpine/zero@latest` and takes its release from the npm registry, not from GitHub. They still apply to `--check` there. - `--json` serializes Zero's final result. For npm-managed installs, npm may @@ -65,7 +69,7 @@ Endpoint resolution order: 1. `--endpoint` 2. `ZERO_UPDATE_RELEASE_URL` 3. `--repo` -4. `https://api.github.com/repos/Gitlawb/zero/releases/latest` +4. `https://api.github.com/repos/Twigpine/zero/releases/latest` Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, run `zero upgrade` 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..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":"@gitlawb/zero"}`, 0o644) - writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644) - writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644) - writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755) - // Vendored helpers tree with npm's symlink .bin shims and agent-browser's - // multi-platform binary set. - agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser") - writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644) - writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644) - for _, name := range []string{ - "agent-browser-linux-x64", - "agent-browser-linux-musl-x64", - "agent-browser-linux-arm64", - "agent-browser-darwin-arm64", - "agent-browser-darwin-x64", - "agent-browser-win32-x64.exe", - } { - writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755) - } - tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory") - writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644) - binDir := filepath.Join(staging, "helpers", "node_modules", ".bin") - mustMkdirAllFixture(t, binDir) - if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil { - t.Fatalf("Symlink agent-browser shim: %v", err) - } - if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil { - t.Fatalf("Symlink tuistory shim: %v", err) - } - - // Archive with the payload at the archive root, like zero-release's - // createArchive, plus the sha256 sidecar the script verifies against. - assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version) - archivePath := filepath.Join(root, assetName) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil { - t.Fatalf("tar failed: %v\n%s", err, output) - } - archiveBytes, err := os.ReadFile(archivePath) - if err != nil { - t.Fatalf("ReadFile archive: %v", err) - } - digest := sha256.Sum256(archiveBytes) - writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644) - + root := writePlatformArchiveFixture(t, tar, version) outDir := filepath.Join(root, "out") - script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs") - runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second) - defer runCancel() - command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64") - command.Env = append(os.Environ(), "NODE_OPTIONS=") - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("build-platform-packages failed: %v\n%s", err, output) - } + runBuildPlatformPackages(t, node, root, outDir) platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64") var platformPkg struct { @@ -101,8 +41,8 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) { Scripts map[string]string } unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg) - if platformPkg.Name != "@gitlawb/zero" { - t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name) + if platformPkg.Name != "@twigpine/zero" { + t.Fatalf("platform package name = %q, want @twigpine/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name) } if want := version + "-linux-x64"; platformPkg.Version != want { t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want) @@ -198,11 +138,11 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) { t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies) } wantAliases := map[string]string{ - "@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64", - "@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64", - "@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64", - "@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64", - "@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64", + "@twigpine/zero-darwin-arm64": "npm:@twigpine/zero@" + version + "-darwin-arm64", + "@twigpine/zero-darwin-x64": "npm:@twigpine/zero@" + version + "-darwin-x64", + "@twigpine/zero-linux-arm64": "npm:@twigpine/zero@" + version + "-linux-arm64", + "@twigpine/zero-linux-x64": "npm:@twigpine/zero@" + version + "-linux-x64", + "@twigpine/zero-win32-x64": "npm:@twigpine/zero@" + version + "-win32-x64", } if len(wrapperPkg.Optional) != len(wantAliases) { t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases) @@ -219,6 +159,160 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) { } } +// writePlatformArchiveFixture writes a linux-x64 release archive shaped like +// the output of `zero-release package`, with its .sha256 sidecar, into a new +// directory and returns that directory. +func writePlatformArchiveFixture(t *testing.T, tar string, version string) string { + t.Helper() + root := t.TempDir() + staging := filepath.Join(root, "staging") + + // Payload the platform package must keep. + writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755) + writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755) + writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755) + // Wrapper-owned files the platform package must exclude. + writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@twigpine/zero"}`, 0o644) + writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644) + writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644) + writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755) + // Vendored helpers tree with npm's symlink .bin shims and agent-browser's + // multi-platform binary set. + agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser") + writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644) + writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644) + for _, name := range []string{ + "agent-browser-linux-x64", + "agent-browser-linux-musl-x64", + "agent-browser-linux-arm64", + "agent-browser-darwin-arm64", + "agent-browser-darwin-x64", + "agent-browser-win32-x64.exe", + } { + writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755) + } + tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory") + writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644) + binDir := filepath.Join(staging, "helpers", "node_modules", ".bin") + mustMkdirAllFixture(t, binDir) + if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil { + t.Fatalf("Symlink agent-browser shim: %v", err) + } + if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil { + t.Fatalf("Symlink tuistory shim: %v", err) + } + + // Archive with the payload at the archive root, like zero-release's + // createArchive, plus the sha256 sidecar the script verifies against. + assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version) + archivePath := filepath.Join(root, assetName) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil { + t.Fatalf("tar failed: %v\n%s", err, output) + } + archiveBytes, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("ReadFile archive: %v", err) + } + digest := sha256.Sum256(archiveBytes) + writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644) + return root +} + +func runBuildPlatformPackages(t *testing.T, node string, artifactsDir string, outDir string, extraArgs ...string) { + t.Helper() + script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs") + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + args := append([]string{script, "--artifacts-dir", artifactsDir, "--out-dir", outDir, "--only", "linux-x64"}, extraArgs...) + command := exec.CommandContext(ctx, node, args...) + command.Env = append(os.Environ(), "NODE_OPTIONS=") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build-platform-packages failed: %v\n%s", err, output) + } +} + +// The final release under the pre-rename name is the same artifacts assembled +// with --package-name. Every place a payload names its package must follow it: +// a wrapper that kept the current name in one alias would resolve a package +// npm never installed. +func TestBuildPlatformPackagesAssemblesUnderAnotherPackageName(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fixture uses POSIX symlinks and shell scripts") + } + node := requireNode(t) + tar := requireCommand(t, "tar") + version := packageVersion(t) + root := writePlatformArchiveFixture(t, tar, version) + outDir := filepath.Join(root, "out") + runBuildPlatformPackages(t, node, root, outDir, "--package-name", "@gitlawb/zero") + + platformManifest := filepath.Join(outDir, "platforms", "zero-linux-x64", "package.json") + var platformPkg struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + } + unmarshalJSONFile(t, platformManifest, &platformPkg) + if platformPkg.Name != "@gitlawb/zero" || platformPkg.Version != version+"-linux-x64" { + t.Fatalf("platform package = %s@%s, want @gitlawb/zero@%s-linux-x64", platformPkg.Name, platformPkg.Version, version) + } + + wrapperManifest := filepath.Join(outDir, "wrapper", "package.json") + var wrapperPkg struct { + Name string `json:"name"` + Version string `json:"version"` + Optional map[string]string `json:"optionalDependencies"` + Repository struct { + URL string `json:"url"` + } `json:"repository"` + } + unmarshalJSONFile(t, wrapperManifest, &wrapperPkg) + if wrapperPkg.Name != "@gitlawb/zero" || wrapperPkg.Version != version { + t.Fatalf("wrapper = %s@%s, want @gitlawb/zero@%s", wrapperPkg.Name, wrapperPkg.Version, version) + } + if len(wrapperPkg.Optional) != 5 { + t.Fatalf("wrapper optionalDependencies = %v, want the full five-platform matrix", wrapperPkg.Optional) + } + for alias, spec := range wrapperPkg.Optional { + platform := strings.TrimPrefix(alias, "@gitlawb/zero-") + if platform == alias || spec != "npm:@gitlawb/zero@"+version+"-"+platform { + t.Fatalf("optionalDependencies[%q] = %q, want npm:@gitlawb/zero@%s- 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 bc1645633..c090ad51e 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,98 @@ func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) { } } +// bin/zero.js derives the platform payload alias from its own package.json +// name rather than a hardcoded one, so a wrapper assembled under any name +// finds the payload published under that same name +// (build-platform-packages.mjs names both from one value). +func TestNodeWrapperResolvesPlatformPackageUnderItsOwnName(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock executable fixture uses a POSIX shell script") + } + wrapperPath := copyWrapperFixture(t) + root := filepath.Dir(filepath.Dir(wrapperPath)) + writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@example/zero"}`, 0o644) + // A payload under the default name sits beside it and must lose. + writePlatformPackageFixture(t, root, "@twigpine/zero", "default-name-zero") + writePlatformPackageFixture(t, root, "@example/zero", "own-name-zero") + + stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "--version") + if exitCode != wrapperExitSuccess { + t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr) + } + if got := strings.TrimSpace(stdout); got != "own-name-zero --version" { + t.Fatalf("wrapper output = %q, want the payload aliased under its own name (own-name-zero --version)", got) + } +} + +// The bridge release published under the old name runs normally and, once +// zero exits, says where the package went and how to switch, in the one order +// npm accepts. +func TestNodeWrapperLegacyBuildTellsUsersToSwitchAfterExit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock executable fixture uses a POSIX shell script") + } + wrapperPath := copyWrapperFixture(t) + root := filepath.Dir(filepath.Dir(wrapperPath)) + writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@gitlawb/zero"}`, 0o644) + writePlatformPackageFixture(t, root, "@gitlawb/zero", "legacy-zero") + + stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, "--version") + if exitCode != wrapperExitSuccess { + t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr) + } + if got := strings.TrimSpace(stdout); got != "legacy-zero --version" { + t.Fatalf("stdout = %q, want the legacy build to run normally with the notice kept off stdout", got) + } + uninstall := strings.Index(stderr, "npm uninstall -g @gitlawb/zero") + install := strings.Index(stderr, "npm install -g @twigpine/zero") + if uninstall < 0 || install < 0 || uninstall > install { + t.Fatalf("stderr does not give the switch steps, uninstall first: %q", stderr) + } +} + +func TestNodeWrapperLegacyNoticeStaysOutOfTheWay(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock executable fixture uses a POSIX shell script") + } + for _, tc := range []struct { + name, packageName string + env []string + args []string + }{ + // Scripts capturing stderr must not see a new line in it. + {name: "not a terminal", packageName: "@gitlawb/zero", args: []string{"--version"}}, + // The Go side prints the same note for these; once is enough. + {name: "zero upgrade", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"upgrade"}}, + {name: "zero update", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"update", "--check"}}, + {name: "current package", packageName: "@twigpine/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"--version"}}, + } { + t.Run(tc.name, func(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + root := filepath.Dir(filepath.Dir(wrapperPath)) + writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"`+tc.packageName+`"}`, 0o644) + writePlatformPackageFixture(t, root, tc.packageName, "zero") + stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, tc.env, tc.args...) + if exitCode != wrapperExitSuccess { + t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr) + } + if strings.TrimSpace(stderr) != "" { + t.Fatalf("stderr = %q, want nothing", stderr) + } + }) + } +} + +// writePlatformPackageFixture installs a fake platform payload where npm puts +// the wrapper's optionalDependency alias --; its +// zero binary prints label followed by its arguments. +func writePlatformPackageFixture(t *testing.T, root string, packageName string, label string) { + t.Helper() + dir := filepath.Join(root, "node_modules", filepath.FromSlash(packageName+"-"+nodePlatformName()+"-"+nodeArchName())) + writeFixtureFile(t, filepath.Join(dir, "package.json"), `{"name":"`+packageName+`"}`, 0o644) + writeFixtureFile(t, filepath.Join(dir, "zero"), "#!/usr/bin/env sh\nprintf '"+label+"'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n", 0o755) +} + func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("mock executable fixture uses a POSIX shell script") @@ -393,7 +485,7 @@ func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) { if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil { t.Fatalf("WriteFile postinstall fixture: %v", err) } - if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@twigpine/zero","version":"0.0.0"}`), 0o644); err != nil { t.Fatalf("WriteFile package fixture: %v", err) } diff --git a/internal/npmwrapper/publish_test.go b/internal/npmwrapper/publish_test.go new file mode 100644 index 000000000..af0e2fc39 --- /dev/null +++ b/internal/npmwrapper/publish_test.go @@ -0,0 +1,327 @@ +package npmwrapper + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// fakeNpm stands in for npm on PATH when scripts/npm/publish-packages.sh runs. +// It keeps one package's registry state in FAKE_NPM_STATE and answers the way +// npm 11 does: a lookup of a missing package or a missing version fails with +// E404, and it refuses a version published twice or any lookup or publish for +// a package name other than the one it holds. Every call is appended to +// FAKE_NPM_STATE.log. +// +// FAKE_NPM_CLOBBER_LATEST=1 makes every publish take `latest`, the failure the +// script must stop on. FAKE_NPM_VERSION_LOOKUP_ERRORS= fails the first n +// version lookups with a network error instead of answering them. +const fakeNpm = `#!/usr/bin/env node +const fs = require('fs'); +const stateFile = process.env.FAKE_NPM_STATE; +const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); +const args = process.argv.slice(2); +fs.appendFileSync(stateFile + '.log', JSON.stringify({ args, cwd: process.cwd() }) + '\n'); +const save = () => fs.writeFileSync(stateFile, JSON.stringify(state)); +const notFound = (detail) => { process.stderr.write('npm error code E404\nnpm error 404 ' + detail + '\n'); process.exit(1); }; +if (args[0] === 'view') { + const [spec, field] = args.slice(1); + const at = spec.lastIndexOf('@'); + const name = at > 0 ? spec.slice(0, at) : spec; + if (field === 'version') { + const failures = Number(process.env.FAKE_NPM_VERSION_LOOKUP_ERRORS || 0); + state.versionLookups = (state.versionLookups || 0) + 1; + save(); + if (state.versionLookups <= failures) { + process.stderr.write('npm error code ECONNRESET\nnpm error network aborted\n'); + process.exit(1); + } + } + if (name !== state.name || state.versions.length === 0) notFound('Not Found - GET ' + name); + if (field === 'version') { + const version = spec.slice(at + 1); + if (!state.versions.includes(version)) notFound('No match found for version ' + version); + process.stdout.write(version + '\n'); + process.exit(0); + } + if (field === 'dist-tags.latest') { + if (state.distTags.latest) process.stdout.write(state.distTags.latest + '\n'); + process.exit(0); + } +} +if (args[0] === 'publish') { + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + if (pkg.name !== state.name) { + process.stderr.write('fake npm: refusing to publish ' + pkg.name + '\n'); + process.exit(1); + } + if (state.versions.includes(pkg.version)) { + process.stderr.write('npm error code EPUBLISHCONFLICT\n'); + process.exit(1); + } + const tagIndex = args.indexOf('--tag'); + state.versions.push(pkg.version); + state.distTags[tagIndex === -1 ? 'latest' : args[tagIndex + 1]] = pkg.version; + if (process.env.FAKE_NPM_CLOBBER_LATEST === '1') state.distTags.latest = pkg.version; + save(); + process.exit(0); +} +process.stderr.write('fake npm: unsupported command ' + args.join(' ') + '\n'); +process.exit(2); +` + +const ( + publishFixtureName = "@example/zero" + publishFixtureVersion = "1.2.3" +) + +type fakeRegistryState struct { + Name string `json:"name"` + Versions []string `json:"versions"` + DistTags map[string]string `json:"distTags"` + // VersionLookups counts `npm view @ version` calls. + VersionLookups int `json:"versionLookups,omitempty"` +} + +type npmCall struct { + Args []string `json:"args"` + Cwd string `json:"cwd"` +} + +type publishRun struct { + stdout, stderr string + exitCode int + state fakeRegistryState + publishes []npmCall +} + +// writePublishFixture lays out what build-platform-packages.mjs emits: a +// wrapper whose optionalDependencies alias the given platform payloads, and +// one directory per payload in platforms. +func writePublishFixture(t *testing.T, aliasedPlatforms []string, platformDirs []string) string { + t.Helper() + npmDir := filepath.Join(t.TempDir(), "npm") + aliases := map[string]string{} + for _, platform := range aliasedPlatforms { + aliases[publishFixtureName+"-"+platform] = "npm:" + publishFixtureName + "@" + publishFixtureVersion + "-" + platform + } + wrapper, err := json.Marshal(map[string]any{ + "name": publishFixtureName, + "version": publishFixtureVersion, + "optionalDependencies": aliases, + }) + if err != nil { + t.Fatal(err) + } + writeFixtureFile(t, filepath.Join(npmDir, "wrapper", "package.json"), string(wrapper), 0o644) + for _, platform := range platformDirs { + manifest := `{"name":"` + publishFixtureName + `","version":"` + publishFixtureVersion + "-" + platform + `"}` + writeFixtureFile(t, filepath.Join(npmDir, "platforms", "zero-"+platform, "package.json"), manifest, 0o644) + } + return npmDir +} + +func runPublishPackages(t *testing.T, npmDir string, registry fakeRegistryState, extraEnv ...string) publishRun { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("publish-packages.sh runs on the Linux release runner; the fake npm is a POSIX executable") + } + bash := requireCommand(t, "bash") + requireNode(t) + + binDir := t.TempDir() + writeFixtureFile(t, filepath.Join(binDir, "npm"), fakeNpm, 0o755) + if registry.DistTags == nil { + registry.DistTags = map[string]string{} + } + if registry.Versions == nil { + registry.Versions = []string{} + } + stateFile := filepath.Join(t.TempDir(), "registry.json") + initial, err := json.Marshal(registry) + if err != nil { + t.Fatal(err) + } + writeFixtureFile(t, stateFile, string(initial), 0o644) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, bash, filepath.Join(repoRoot(t), "scripts", "npm", "publish-packages.sh"), npmDir) + command.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "FAKE_NPM_STATE="+stateFile, + "ZERO_NPM_PUBLISH_RETRY_DELAY=0", + "NODE_OPTIONS=", + ) + command.Env = append(command.Env, extraEnv...) + var stdout, stderr strings.Builder + command.Stdout = &stdout + command.Stderr = &stderr + run := publishRun{} + if err := command.Run(); err != nil { + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("publish-packages.sh: %v; stderr=%s", err, stderr.String()) + } + run.exitCode = exitErr.ExitCode() + } + if ctx.Err() != nil { + t.Fatalf("publish-packages.sh timed out; stdout=%s stderr=%s", stdout.String(), stderr.String()) + } + run.stdout, run.stderr = stdout.String(), stderr.String() + unmarshalJSONFile(t, stateFile, &run.state) + + if log, err := os.Open(stateFile + ".log"); err == nil { + defer log.Close() + scanner := bufio.NewScanner(log) + for scanner.Scan() { + var call npmCall + if err := json.Unmarshal(scanner.Bytes(), &call); err != nil { + t.Fatalf("fake npm log line %q: %v", scanner.Text(), err) + } + if len(call.Args) > 0 && call.Args[0] == "publish" { + run.publishes = append(run.publishes, call) + } + } + } + return run +} + +// publishedDirs names each publish by the payload directory it ran in, in order. +func (run publishRun) publishedDirs() []string { + dirs := []string{} + for _, call := range run.publishes { + dirs = append(dirs, filepath.Base(call.Cwd)) + } + return dirs +} + +func TestPublishPackagesPublishesPlatformPayloadsBeforeTheWrapper(t *testing.T) { + platforms := []string{"darwin-arm64", "linux-x64"} + npmDir := writePublishFixture(t, platforms, platforms) + run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}) + if run.exitCode != 0 { + t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr) + } + + if got, want := strings.Join(run.publishedDirs(), ","), "zero-darwin-arm64,zero-linux-x64,wrapper"; got != want { + t.Fatalf("publish order = %s, want %s (the wrapper pins the platform versions, so it must go last)", got, want) + } + for index, call := range run.publishes { + args := strings.Join(call.Args, " ") + if !strings.Contains(args, "--access public") || !strings.Contains(args, "--ignore-scripts") { + t.Fatalf("publish %d args = %q, want --access public and --ignore-scripts", index, args) + } + isWrapper := filepath.Base(call.Cwd) == "wrapper" + if hasPlatformTag := strings.Contains(args, "--tag platform"); hasPlatformTag == isWrapper { + t.Fatalf("publish %d (%s) args = %q: platform payloads need --tag platform and the wrapper must take latest", index, filepath.Base(call.Cwd), args) + } + } + if latest := run.state.DistTags["latest"]; latest != publishFixtureVersion { + t.Fatalf("latest = %q, want %q", latest, publishFixtureVersion) + } +} + +// "Re-run failed jobs" after a partial publish must finish the release rather +// than fail on the versions that already went out. +func TestPublishPackagesSkipsVersionsAlreadyOnTheRegistry(t *testing.T) { + platforms := []string{"darwin-arm64", "linux-x64"} + npmDir := writePublishFixture(t, platforms, platforms) + run := runPublishPackages(t, npmDir, fakeRegistryState{ + Name: publishFixtureName, + Versions: []string{"1.2.2", publishFixtureVersion + "-darwin-arm64"}, + DistTags: map[string]string{"latest": "1.2.2", "platform": publishFixtureVersion + "-darwin-arm64"}, + }) + if run.exitCode != 0 { + t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr) + } + if got, want := strings.Join(run.publishedDirs(), ","), "zero-linux-x64,wrapper"; got != want { + t.Fatalf("published %s, want %s (darwin-arm64 was already on the registry)", got, want) + } + if !strings.Contains(run.stdout, publishFixtureName+"@"+publishFixtureVersion+"-darwin-arm64 is already on the registry") { + t.Fatalf("stdout does not report the skipped version: %s", run.stdout) + } + + // Re-running once everything is out publishes nothing and still succeeds. + rerun := runPublishPackages(t, npmDir, run.state) + if rerun.exitCode != 0 { + t.Fatalf("rerun exit = %d; stdout=%s stderr=%s", rerun.exitCode, rerun.stdout, rerun.stderr) + } + if len(rerun.publishes) != 0 { + t.Fatalf("rerun published %v, want nothing", rerun.publishedDirs()) + } +} + +func TestPublishPackagesStopsBeforeTheWrapperWhenAPlatformTookLatest(t *testing.T) { + platforms := []string{"linux-x64"} + npmDir := writePublishFixture(t, platforms, platforms) + run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_CLOBBER_LATEST=1") + if run.exitCode == 0 { + t.Fatalf("exit = 0 after a platform publish took latest; stdout=%s", run.stdout) + } + if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64" { + t.Fatalf("published %s, want only the platform payload (the wrapper must not follow a clobbered latest)", got) + } + if !strings.Contains(run.stderr, "clobbered") { + t.Fatalf("stderr does not explain the failure: %s", run.stderr) + } +} + +// A wrapper that aliases a platform version with no payload beside it would +// install an alias that 404s, so nothing may be published at all. +func TestPublishPackagesRefusesAWrapperWhoseAliasesDoNotMatchThePayloads(t *testing.T) { + npmDir := writePublishFixture(t, []string{"darwin-arm64", "linux-x64"}, []string{"linux-x64"}) + run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}) + if run.exitCode == 0 { + t.Fatalf("exit = 0 with a missing platform payload; stdout=%s", run.stdout) + } + if len(run.publishes) != 0 { + t.Fatalf("published %v before noticing the mismatch, want nothing", run.publishedDirs()) + } + if !strings.Contains(run.stderr, "do not match the platform payloads") { + t.Fatalf("stderr does not explain the failure: %s", run.stderr) + } +} + +// A lookup that fails for a reason other than E404 must not read as "not +// published": on the re-run this skip exists for, the version is usually out +// already, and publishing it again fails with EPUBLISHCONFLICT. +func TestPublishPackagesRetriesAFailedLookupInsteadOfPublishing(t *testing.T) { + platforms := []string{"linux-x64"} + npmDir := writePublishFixture(t, platforms, platforms) + run := runPublishPackages(t, npmDir, fakeRegistryState{ + Name: publishFixtureName, + Versions: []string{publishFixtureVersion + "-linux-x64"}, + DistTags: map[string]string{"platform": publishFixtureVersion + "-linux-x64"}, + }, "FAKE_NPM_VERSION_LOOKUP_ERRORS=2") + if run.exitCode != 0 { + t.Fatalf("exit = %d after two transient lookup failures; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr) + } + if got := strings.Join(run.publishedDirs(), ","); got != "wrapper" { + t.Fatalf("published %s, want only the wrapper (the platform version was already out)", got) + } +} + +// When the registry cannot answer at all, nothing may be published: the +// script cannot tell which versions are already out. +func TestPublishPackagesFailsWhenTheRegistryCannotBeReached(t *testing.T) { + platforms := []string{"linux-x64"} + npmDir := writePublishFixture(t, platforms, platforms) + run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=1000") + if run.exitCode == 0 { + t.Fatalf("exit = 0 with the registry unreachable; stdout=%s", run.stdout) + } + if len(run.publishes) != 0 { + t.Fatalf("published %v without being able to look anything up, want nothing", run.publishedDirs()) + } + if !strings.Contains(run.stderr, "could not look up") || !strings.Contains(run.stderr, "ECONNRESET") { + t.Fatalf("stderr does not explain the failure or show npm's error: %s", run.stderr) + } +} 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 ca737f520..d1e738c6b 100644 --- a/internal/update/installmethod.go +++ b/internal/update/installmethod.go @@ -9,17 +9,28 @@ import ( ) // npmPackageName is the published package name for the npm distribution of -// zero (see package.json). scripts/postinstall.mjs downloads the native -// binary into the same directory as package.json and leaves a -// ".zero-binary-version" marker file next to it — both are reliable signals -// that a given executable came from an npm install. -const npmPackageName = "@gitlawb/zero" +// zero, and must equal the name in the repository's package.json +// (TestNpmPackageNameMatchesPackageJSON holds the two together). +// scripts/postinstall.mjs downloads the native binary into the same directory +// as package.json and leaves a ".zero-binary-version" marker file next to it — +// both are reliable signals that a given executable came from an npm install. +const npmPackageName = "@twigpine/zero" + +// legacyNpmPackageName is the name zero was published under on npm up to +// 0.9.0. The 0.10.0 release is published once more under it (the legacy step +// in .github/workflows/release-artifacts.yml), so that 0.9.0's `zero upgrade` +// and anyone still installing the old name land on a build that says where +// the package went. Nothing is published under it after that, which is why an +// install of it is told to switch instead of offered an update. +const legacyNpmPackageName = "@gitlawb/zero" // InstallMethod identifies how the running zero binary was installed. type InstallMethod string const ( - InstallMethodNpm InstallMethod = "npm" + InstallMethodNpm InstallMethod = "npm" + // InstallMethodNpmLegacy is an npm install of legacyNpmPackageName. + InstallMethodNpmLegacy InstallMethod = "npm-legacy" InstallMethodHomebrew InstallMethod = "homebrew" InstallMethodStandalone InstallMethod = "standalone" ) @@ -53,32 +64,71 @@ func DetectInstallMethod(executablePath string) InstallMethod { return InstallMethodHomebrew } dir := filepath.Dir(executablePath) + pkg, hasManifest := readNpmManifest(dir) if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil { + // The first-run downloader put this binary next to the wrapper's own + // package.json, which names the package it was installed as. + if hasManifest && pkg.Name == legacyNpmPackageName { + return InstallMethodNpmLegacy + } return InstallMethodNpm } - data, err := os.ReadFile(filepath.Join(dir, "package.json")) - if err != nil { - return InstallMethodStandalone - } - var pkg struct { - Name string `json:"name"` - OS []string `json:"os"` - CPU []string `json:"cpu"` - Bin json.RawMessage `json:"bin"` - } - if err := json.Unmarshal(data, &pkg); err != nil { + if !hasManifest { return InstallMethodStandalone } // The native platform package is inert and constrained to one OS/CPU. The // repository's wrapper package has the same name but carries a bin entry and // broad platform lists, so name alone would misclassify `go build -o zero` // from the repository root as an npm-managed install. - if pkg.Name == npmPackageName && len(pkg.OS) == 1 && len(pkg.CPU) == 1 && len(pkg.Bin) == 0 { + if len(pkg.OS) != 1 || len(pkg.CPU) != 1 || len(pkg.Bin) != 0 { + return InstallMethodStandalone + } + switch pkg.Name { + case npmPackageName: return InstallMethodNpm + case legacyNpmPackageName: + return InstallMethodNpmLegacy } return InstallMethodStandalone } +type npmManifest struct { + Name string `json:"name"` + OS []string `json:"os"` + CPU []string `json:"cpu"` + Bin json.RawMessage `json:"bin"` +} + +// readNpmManifest reads the package.json in dir, reporting false when there is +// none or it does not parse. +func readNpmManifest(dir string) (npmManifest, bool) { + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return npmManifest{}, false + } + var pkg npmManifest + if err := json.Unmarshal(data, &pkg); err != nil { + return npmManifest{}, false + } + return pkg, true +} + +// legacyPackageSwitchMessage tells someone running the build published under +// legacyNpmPackageName how to move to npmPackageName. bin/zero.js prints the +// same note. +// +// There is no in-place migration. Both packages own the `zero` bin, so npm +// refuses to install the new one over the old (EEXIST), and forcing it lets a +// later uninstall of the old package delete the shared bin. Uninstalling first +// is the only safe order, and it has to be the user's own step: the running +// binary is inside the package it would remove. +func legacyPackageSwitchMessage() string { + return "Zero has moved to " + npmPackageName + " on npm, and " + legacyNpmPackageName + " gets no further releases. To keep getting updates, quit zero and run:\n" + + " npm uninstall -g " + legacyNpmPackageName + "\n" + + " npm install -g " + npmPackageName + "\n" + + "Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead." +} + // isHomebrewPath reports whether executablePath is inside a Homebrew keg. // // goos is a parameter rather than runtime.GOOS so the decision can be tested on diff --git a/internal/update/installmethod_test.go b/internal/update/installmethod_test.go index c28885a94..0d6f5e2de 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) } @@ -73,3 +93,57 @@ func TestDetectInstallMethodIgnoresUnrelatedPackageJSON(t *testing.T) { t.Fatalf("DetectInstallMethod = %q, want standalone", method) } } + +// The bridge release published under the old name must recognise itself, so +// `zero upgrade` can tell it to switch instead of reinstalling the same build. +func TestDetectInstallMethodRecognisesALegacyPlatformPayload(t *testing.T) { + dir := t.TempDir() + exePath := filepath.Join(dir, "zero") + if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"@gitlawb/zero","version":"0.10.0-linux-x64","os":["linux"],"cpu":["x64"]}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if method := DetectInstallMethod(exePath); method != InstallMethodNpmLegacy { + t.Fatalf("DetectInstallMethod = %q, want %q", method, InstallMethodNpmLegacy) + } +} + +// A binary the first-run downloader fetched sits next to the wrapper's own +// package.json, which is the only place the installed name is recorded. +func TestDetectInstallMethodRecognisesALegacyDownloadedBinary(t *testing.T) { + dir := t.TempDir() + exePath := filepath.Join(dir, "zero") + if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.10.0\n"), 0o644); err != nil { + t.Fatal(err) + } + manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if method := DetectInstallMethod(exePath); method != InstallMethodNpmLegacy { + t.Fatalf("DetectInstallMethod = %q, want %q", method, InstallMethodNpmLegacy) + } +} + +// Without the downloader's marker, a wrapper-shaped manifest under the old +// name is a checkout, not an install, exactly as for the current name. +func TestDetectInstallMethodDoesNotTreatALegacyWrapperManifestAsAnInstall(t *testing.T) { + dir := t.TempDir() + exePath := filepath.Join(dir, "zero") + if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if method := DetectInstallMethod(exePath); method != InstallMethodStandalone { + t.Fatalf("DetectInstallMethod = %q, want standalone", method) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 6d0e980f9..8fa298dbc 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 ) @@ -233,6 +233,10 @@ func Format(result Result) string { "Latest release: " + result.ReleaseURL, } lines = appendAssetLines(lines, result.ReleaseAsset) + if result.installMethod == InstallMethodNpmLegacy { + // Up to date today, but the next release will not reach this install. + lines = append(lines, legacyPackageSwitchMessage()) + } return strings.Join(lines, "\n") } @@ -322,6 +326,11 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM // `brew upgrade zero` would change this one instead. return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed." } + if installMethod == InstallMethodNpmLegacy { + // Placed like the Homebrew answer above, and for the same reason: it is a + // property of the install on this machine, and `zero upgrade` refuses it. + return legacyPackageSwitchMessage() + } if sourceFlag != "" { if installMethod == InstallMethodNpm { return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source." @@ -457,7 +466,7 @@ func releasePlatform(goos string) (string, error) { // self-updating release asset. Point users at `npm update` rather // than a source rebuild, since that's the documented Termux // install/upgrade path and doesn't require a Go toolchain. - return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos) //nolint:staticcheck // Preserve established user-facing error text. + return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g %s` to get the latest.", goos, npmPackageName) //nolint:staticcheck // Preserve established user-facing error text. } } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 692be2faa..51a0e91bc 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -587,11 +587,35 @@ func TestFormatNpmCustomSourceExplainsInstallSource(t *testing.T) { SourceFlag: "--repo someone/fork", installMethod: InstallMethodNpm, } output := Format(result) - if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @gitlawb/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") { + if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @twigpine/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") { t.Fatalf("npm custom-source guidance is misleading: %q", output) } } +// A legacy install is told to switch whether or not an update is out: up to +// date today, the next release will not reach it, and `zero upgrade` refuses. +func TestFormatTellsALegacyNpmInstallToSwitch(t *testing.T) { + asset := assetCheckForTest(t, "v0.11.0", runtime.GOOS, runtime.GOARCH) + for name, result := range map[string]Result{ + "up to date": { + CurrentVersion: "0.10.0", LatestVersion: "0.10.0", ReleaseURL: "https://example.test/release", + ReleaseAsset: asset, installMethod: InstallMethodNpmLegacy, + }, + "update available": { + CurrentVersion: "0.10.0", LatestVersion: "0.11.0", ReleaseURL: "https://example.test/release", + ReleaseAsset: asset, UpdateAvailable: true, installMethod: InstallMethodNpmLegacy, + }, + } { + output := Format(result) + if !strings.Contains(output, "npm uninstall -g @gitlawb/zero") || !strings.Contains(output, "npm install -g @twigpine/zero") { + t.Errorf("%s: output does not give the switch steps: %q", name, output) + } + if strings.Contains(output, "Run `zero upgrade`") { + t.Errorf("%s: output recommends `zero upgrade`, which refuses a legacy install: %q", name, output) + } + } +} + func TestFormatCrossTargetCustomSourceKeepsAccurateGuidance(t *testing.T) { goos, goarch := "linux", "amd64" if runtime.GOOS == goos && runtime.GOARCH == goarch { 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..d2cb34068 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, 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 // 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 @@ -24,9 +25,15 @@ // CI must always assemble the full matrix, and the wrapper's // optionalDependencies always reference the full matrix regardless). // +// --package-name assembles the same artifacts under another name. It exists +// for the one final release under the pre-rename name @gitlawb/zero (see +// docs/NPM_PACKAGING.md); everything in the payloads that names the package +// follows it, so the binary and wrapper know which name they were installed as. +// // Usage: // node scripts/npm/build-platform-packages.mjs \ -// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...] +// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...] \ +// [--package-name @scope/name] import { chmodSync, @@ -71,7 +78,7 @@ function fail(message) { } function parseArgs(argv) { - const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null }; + const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null, packageName: '' }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const next = () => { @@ -89,6 +96,14 @@ function parseArgs(argv) { case '--only': args.only = new Set(next().split(',').map((entry) => entry.trim()).filter(Boolean)); break; + case '--package-name': + args.packageName = next(); + // A scoped or unscoped npm name; anything else would also become a + // path segment in the alias npm installs. + if (!/^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/.test(args.packageName)) { + fail(`--package-name ${JSON.stringify(args.packageName)} is not an npm package name`); + } + break; default: fail(`unknown argument ${arg}`); } @@ -99,6 +114,7 @@ function parseArgs(argv) { function readWrapperPackage() { const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); + if (!pkg.name) fail('package.json name is empty'); if (!pkg.version) fail('package.json version is empty'); return pkg; } @@ -107,14 +123,17 @@ function platformVersion(version, entry) { return `${version}-${entry.platform}-${entry.arch}`; } -function aliasName(entry) { - return `@gitlawb/zero-${entry.platform}-${entry.arch}`; +// bin/zero.js derives the same alias from its own package name at runtime, so +// the wrapper name, the alias names, and the alias targets must all come from +// one value. +function aliasName(packageName, entry) { + return `${packageName}-${entry.platform}-${entry.arch}`; } -function optionalDependencies(version) { +function optionalDependencies(packageName, version) { const aliases = {}; for (const entry of MATRIX) { - aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`; + aliases[aliasName(packageName, entry)] = `npm:${packageName}@${platformVersion(version, entry)}`; } return aliases; } @@ -260,7 +279,7 @@ function verifyHelperShims(packageDir, entry, assetName) { } } -function buildPlatformPackage(entry, version, artifactsDir, outDir) { +function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir) { const assetName = `zero-v${version}-${entry.release}.${entry.ext}`; const archivePath = join(artifactsDir, assetName); if (!existsSync(archivePath)) fail(`missing release archive ${archivePath}`); @@ -295,9 +314,9 @@ function buildPlatformPackage(entry, version, artifactsDir, outDir) { const wrapperPkg = readWrapperPackage(); const manifest = { - name: '@gitlawb/zero', + name: packageName, version: platformVersion(version, entry), - description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`, + description: `zero native binary for ${entry.platform}-${entry.arch} (installed via ${packageName}@${version})`, os: [entry.platform], cpu: [entry.arch], license: wrapperPkg.license, @@ -312,7 +331,7 @@ function buildPlatformPackage(entry, version, artifactsDir, outDir) { } } -function buildWrapperPackage(version, outDir) { +function buildWrapperPackage(packageName, version, outDir) { const wrapperDir = join(outDir, 'wrapper'); rmSync(wrapperDir, { recursive: true, force: true }); mkdirSync(join(wrapperDir, 'bin'), { recursive: true }); @@ -333,14 +352,17 @@ function buildWrapperPackage(version, outDir) { // stages into the platform payloads — see internal/release/release.go). delete pkg.scripts; delete pkg.dependencies; - pkg.optionalDependencies = optionalDependencies(version); + pkg.name = packageName; + pkg.optionalDependencies = optionalDependencies(packageName, version); writeFileSync(join(wrapperDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); return wrapperDir; } function main() { const args = parseArgs(process.argv.slice(2)); - const version = readWrapperPackage().version; + const wrapperPkg = readWrapperPackage(); + const packageName = args.packageName || wrapperPkg.name; + const { version } = wrapperPkg; const selected = MATRIX.filter( (entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`), @@ -356,13 +378,14 @@ function main() { const built = []; for (const entry of selected) { - built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir)); + built.push(buildPlatformPackage(entry, packageName, version, args.artifactsDir, args.outDir)); } - const wrapperDir = buildWrapperPackage(version, args.outDir); + const wrapperDir = buildWrapperPackage(packageName, version, args.outDir); process.stdout.write( JSON.stringify( { + name: packageName, version, wrapperDir, platformDirs: built, diff --git a/scripts/npm/publish-packages.sh b/scripts/npm/publish-packages.sh new file mode 100755 index 000000000..84c88e4db --- /dev/null +++ b/scripts/npm/publish-packages.sh @@ -0,0 +1,137 @@ +#!/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 + +# npm reports every kind of "not on the registry" (no such package, no such +# version) as E404. Any other lookup failure (network, auth, a registry +# outage) is retried and then fails the job: reading it as "not published" +# would publish a version that may already be out, and npm would reject that +# with EPUBLISHCONFLICT on the very re-run this skip exists for. +already_published() { + local output status errors + errors="$(mktemp)" + for _ in 1 2 3; do + status=0 + output="$(npm view "${name}@$1" version 2>"$errors")" || status=$? + if [ "$status" -eq 0 ]; then + rm -f "$errors" + [ "$output" = "$1" ] + return + fi + if grep -q 'code E404' "$errors"; then + rm -f "$errors" + return 1 + fi + sleep "$delay" + done + cat "$errors" >&2 + rm -f "$errors" + fail "could not look up ${name}@$1 on the registry, so cannot tell whether it is already published" +} + +# publish [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