Compare commits

..
Author SHA1 Message Date
Kevin Codex b549f5928f refactor(config): drop the unreachable SetNotify wrapper
SetNotify was kept in #1001 as a thin replace-form wrapper over
UpdateNotify for existing tests, but every product surface (the CLI
`zero config notify` and the TUI /notify picker) calls UpdateNotify's
merge form directly, so the advisory deadcode job reports it as
unreachable. Move the replace helper into writer_test.go as the
unexported setNotify, keep every test it served unchanged in behaviour,
and point the two CLI doc comments at UpdateNotify.

`make deadcode` no longer lists internal/config; the other advisory
findings in that job are pre-existing and untouched.
2026-10-14 15:58:35 +08:00
56 changed files with 354 additions and 3296 deletions

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Twigpine/zero/discussions/categories/q-a
url: https://github.com/Gitlawb/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/Twigpine/zero/discussions/categories/ideas
url: https://github.com/Gitlawb/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/Twigpine/zero/security/advisories/new
url: https://github.com/Gitlawb/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/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
+49 -62
View File
@@ -100,21 +100,19 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm 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.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file + 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.
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
@@ -127,7 +125,7 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@twigpine/zero
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
@@ -203,58 +201,47 @@ jobs:
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/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
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: scripts/npm/publish-packages.sh dist/npm
# One final release under the pre-rename name @gitlawb/zero. Zero 0.9.0's
# `zero upgrade` runs `npm install -g @gitlawb/zero@latest`, and people
# reinstall the old name from shell history and old tutorials, so this
# release is published once more under it: the same verified artifacts,
# assembled as @gitlawb/zero, which the binary and wrapper recognise and
# answer with the switch steps (docs/NPM_PACKAGING.md). Runs after the
# @twigpine/zero publish, so the package it points at already exists.
#
# The gate keeps this to a single release: it is owed only while the old
# name's latest is still the last pre-rename version, or is this version
# (a re-run after it went out). After this release, `npm deprecate` the
# old package and delete these two steps.
- name: Check whether the final @gitlawb/zero release is owed
id: legacy-name
shell: bash
env:
LAST_PRE_RENAME_VERSION: 0.9.0
run: |
version="$(node -p "require('./package.json').version")"
latest=""
for _ in 1 2 3; do
latest="$(npm view @gitlawb/zero dist-tags.latest 2>/dev/null || true)"
if [ -n "$latest" ]; then
break
fi
sleep 10
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
if [ -z "$latest" ]; then
echo "::error::could not read the latest dist-tag of @gitlawb/zero from the registry" >&2
exit 1
fi
if [ "$latest" = "$LAST_PRE_RENAME_VERSION" ] || [ "$latest" = "$version" ]; then
owed=true
else
owed=false
fi
echo "@gitlawb/zero latest is ${latest}; final release under the old name owed: ${owed}"
echo "owed=${owed}" >> "$GITHUB_OUTPUT"
# 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 the final @gitlawb/zero release
if: ${{ steps.legacy-name.outputs.owed == 'true' }}
- name: Publish wrapper to npm
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
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+1 -1
View File
@@ -1 +1 @@
{".":"0.10.0"}
{".":"0.9.0"}
-44
View File
@@ -6,50 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
### Features
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
+5 -5
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Gitlawb/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/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/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/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/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/Twigpine/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Gitlawb/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.
+10 -22
View File
@@ -116,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -130,30 +130,18 @@ 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/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### From source
@@ -161,7 +149,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
Source builds require Go 1.26.9+.
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -538,19 +526,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/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [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 |
| [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 |
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/Twigpine/zero/issues/new/choose); security reports
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+9 -18
View File
@@ -105,33 +105,24 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/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/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
@@ -139,7 +130,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
源码构建需要 Go 1.26.9+。
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -367,14 +358,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [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) | 维护者发布的版本和项目动态 |
| [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) | 维护者发布的版本和项目动态 |
## 贡献
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Twigpine
author: Gitlawb
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Twigpine/zero
default: Gitlawb/zero
outputs:
exit-code:
+13 -63
View File
@@ -1,63 +1,11 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { existsSync } 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';
}
@@ -133,10 +81,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 ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\n` +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
` "trustedDependencies": ["${packageName}"]\n` +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -145,14 +93,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
@@ -165,7 +113,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
` npm install -g ${packageName}\n` +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -234,12 +182,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// 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
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -279,6 +227,8 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/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 | `Twigpine/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/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: Twigpine/zero@v1
- uses: Gitlawb/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 (`Twigpine/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Gitlawb/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
+16 -51
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| 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` |
| 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` |
| `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 @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -56,47 +56,15 @@ 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)/@twigpine/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@gitlawb/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/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -121,7 +89,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -132,7 +100,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -155,7 +123,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -166,13 +134,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Twigpine/zero
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Twigpine/[email protected]
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -201,7 +169,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -245,7 +213,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -334,10 +302,7 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @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.
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
+15 -92
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
@@ -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.
```
@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
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@twigpine/zero` package**,
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
@@ -103,22 +103,10 @@ 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. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
@@ -135,80 +123,15 @@ registry.
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration. 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.
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone does not reach existing users. 0.9.0's
`zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with nothing newer
under that name, npm reinstalls nothing, prints no deprecation warning (it
only warns when it installs something), and Zero reports the update as done.
So 0.10.0 is published once more under the old name, as a bridge:
- The publish job assembles the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and publishes
them with the same script, after the `@twigpine/zero` publish. A gate runs
it only while the old name's `latest` is still 0.9.0 (or is this version,
on a re-run), so it cannot happen twice.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only.
- Updating from 0.9.0 now installs a real version, so npm also shows the
deprecation message.
### Runbook
Before the first release under the new name:
1. Create the package, since npm only attaches a trusted publisher to a
package that exists: publish an empty `@twigpine/[email protected]` by hand with
`npm publish --access public --tag bootstrap`, then
`npm deprecate @twigpine/[email protected] "Placeholder; install @twigpine/zero@latest"`.
2. Trust this workflow for it:
`npm trust github @twigpine/zero --repo Twigpine/zero --file release-artifacts.yml --env npm-publish --allow-publish`.
3. Point the old package's trusted publisher at this repository too, for the
bridge publish: `npm trust list @gitlawb/zero`, revoke the existing entry
with `npm trust revoke @gitlawb/zero --id <id>`, then run the same
`npm trust github` command for `@gitlawb/zero`.
4. Confirm Settings → Environments → `npm-publish` has a required reviewer.
The environment exists either way; without a reviewer the job does not
wait for approval.
After the release, once both packages show 0.10.0 as `latest`:
1. `npm deprecate @gitlawb/zero "Zero moved to @twigpine/zero. Switch with: npm uninstall -g @gitlawb/zero && npm install -g @twigpine/zero"`
(no version range, so every version, including the platform payloads,
carries it). Never unpublish: existing installs resolve their platform
payload through these versions.
2. Revoke the old package's trusted publisher (`npm trust revoke`), so
nothing can publish under that name again.
3. Delete the two `@gitlawb/zero` steps from `release-artifacts.yml`. The Go
and wrapper handling of the old name can stay; it only activates in a
build installed under that name.
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
+9 -10
View File
@@ -2,8 +2,7 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -38,24 +37,24 @@ path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@twigpine/zero`), version,
- `package.json` has the expected package name (`@gitlawb/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 `@twigpine/zero@<version>-<platform>-<arch>` with matching
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
+4 -8
View File
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Twigpine/zero
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
@@ -28,11 +28,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @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.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
@@ -42,7 +38,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @twigpine/zero@latest` and
install: that path delegates to `npm install -g @gitlawb/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
@@ -69,7 +65,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
4. `https://api.github.com/repos/Gitlawb/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`
+7 -57
View File
@@ -23,7 +23,6 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -214,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false, agent.PermissionModeAuto)
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
@@ -330,7 +329,7 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
// Resume has already refused a non-resumable kind above, so only load can
// reach here with one, and it publishes that session for viewing only.
readOnly := !sessions.IsResumableKind(meta.SessionKind)
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly, restoredPermissionMode(meta.PermissionMode))
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
if existed {
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
if operation == persistedSessionResume && historyErr != nil {
@@ -568,12 +567,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
SupportsVision: supportsVision,
OnText: note.text,
OnReasoning: note.thought,
// Persist token usage the same way `zero exec` does, so `zero usage report`
// sees ACP sessions too. It joins the turn's buffered batch: a hard run
// failure that aborts the turn drops its usage together with its history.
OnUsage: func(u agent.Usage) {
queue(usageEvent(u))
},
OnToolCall: func(call agent.ToolCall) {
queue(toolCallEvent(call))
note.toolCall(call)
@@ -702,9 +695,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
@@ -740,9 +731,7 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
@@ -784,35 +773,6 @@ func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool)
return nil
}
// updateMode persists the mode BEFORE applying it, so a failed write fails the
// request instead of leaving disk behind memory; the same order updateModel
// uses. The caller holds sess.turnMu (both mode doors take it and it is not
// reentrant, so this must not lock it again); the write under turnMu briefly
// blocks that session's prompts, which is what makes the store authoritative.
func (a *Agent) updateMode(sess *acpSession, mode agent.PermissionMode) error {
if _, err := a.deps.Store.UpdateMode(sess.id, string(mode)); err != nil {
return RPCError(codeInternalError, "save mode selection: "+err.Error())
}
sess.setMode(mode)
return nil
}
// restoredPermissionMode maps a persisted mode onto the modes ACP offers. A
// session written before modes were persisted has none and keeps the historical
// auto default. A stored value outside the three is never allowed to come back
// weaker than ask, and unsafe can never be reached because ACP refuses to set
// it in the first place.
func restoredPermissionMode(saved string) agent.PermissionMode {
switch mode := agent.PermissionMode(strings.TrimSpace(saved)); mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
return mode
case "":
return agent.PermissionModeAuto
default:
return agent.PermissionModeAsk
}
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
var p CancelParams
if err := json.Unmarshal(params, &p); err != nil {
@@ -937,15 +897,6 @@ func messageEvent(role, content string) sessions.AppendEventInput {
}
}
// usageEvent records one provider usage report in the same persisted shape the
// exec runtime writes (usage.EventUsagePayload is the single writer).
func usageEvent(u agent.Usage) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventUsage,
Payload: usage.EventUsagePayload(u),
}
}
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolCall,
@@ -1259,15 +1210,14 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// session is returned rather than orphaning its turn or resetting its mode/model.
// The bool reports that case so persisted activation can re-read and apply disk
// history under turnMu. history is set BEFORE first publication so no concurrent
// prompt can read a half-initialized session. mode is the session's initial
// permission mode: auto for a new session, the restored value for load/resume.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool, mode agent.PermissionMode) (*acpSession, bool) {
// prompt can read a half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing, true
}
sess := &acpSession{id: id, cwd: cwd, mode: mode, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
a.sessions[id] = sess
return sess, false
}
-213
View File
@@ -1,213 +0,0 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
// with, so a test can assert the permission mode the next prompt actually runs
// with rather than only what the wire reports.
func captureAgentOptions(deps *Deps) *agent.Options {
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}
return &captured
}
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
t.Helper()
h := newHarness(t, deps)
t.Cleanup(h.stop)
return h
}
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
t.Helper()
if err := h.client.Call(ctx, method, params, result); err != nil {
t.Fatalf("%s: %v", method, err)
}
}
// restartHarness replaces the live agent with a fresh one over the same store,
// which is the process restart the persisted mode has to survive.
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
t.Helper()
h.stop()
return newHarnessT(t, deps)
}
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
t.Helper()
for _, option := range options {
if option.ID == configIDMode {
return option.CurrentValue
}
}
t.Fatalf("no mode config option in %+v", options)
return ""
}
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
t.Helper()
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
}
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
t.Fatalf("mode config option = %q, want %q", got, want)
}
}
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModePlan) {
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
}
// LOAD after a restart: the response must report the stored mode, and the
// next prompt must actually run with it.
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModePlan)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
// RESUME after another restart reports the same.
h = restartHarness(t, h, deps)
var resumed ResumeSessionResult
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
assertModeReported(t, resumed, agent.PermissionModePlan)
}
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
deps := testDeps(t)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
var configured SetSessionConfigOptionResult
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured)
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
t.Fatalf("configured mode = %q, want ask", got)
}
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModeAsk) {
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModeAsk)
}
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
// the historical auto default, and a stored value outside the three offered
// modes must never come back weaker than ask (and never unsafe).
func TestACPModeRestoreFallback(t *testing.T) {
cases := []struct {
name string
seed string // "" means metadata written before modes were persisted
want agent.PermissionMode
}{
{name: "unset keeps auto", want: agent.PermissionModeAuto},
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
if tc.seed != "" {
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
t.Fatalf("seed mode: %v", err)
}
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, tc.want)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
if captured.PermissionMode != tc.want {
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
}
})
}
}
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
// mode cannot be saved the request fails and the live session is untouched.
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
// Remove the metadata file so UpdateMode fails but the live session stays
// promptable (turn persistence only warns when the file is gone).
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
}
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModeAuto {
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
}
}
-309
View File
@@ -1,309 +0,0 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
// `zero usage report --session <id>` can total a run. The ACP agent did not set
// OnUsage, which dropped the token counts of every ACP session. A turn with two
// provider requests (a tool round trip) must leave two usage events, in the shape
// the usage report reads, next to the turn's messages.
func TestACPPromptPersistsProviderUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: "done"}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
return agent.Result{FinalAnswer: "done"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
}, &result); err != nil {
t.Fatalf("session/prompt: %v", err)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
type usagePayload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
}
var got []usagePayload
messages := 0
for _, event := range events {
switch event.Type {
case sessions.EventUsage:
var payload usagePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("usage payload %s: %v", event.Payload, err)
}
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
got = append(got, payload)
case sessions.EventMessage:
messages++
}
}
want := []usagePayload{
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
}
if len(got) != len(want) {
t.Fatalf("usage events = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
}
}
if messages != 2 {
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
}
}
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
// turn leaves nothing durable, exactly as it does for the turn's messages.
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, errors.New("injected hard run failure")
}
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
return agent.Result{}, errors.New("injected hard run failure")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Fatalf("aborted turn left durable events: %+v", events)
}
}
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
// provider already reported were spent, so they stay in the session log even
// though the client cancelled. A hard error (the test above) is the path that
// drops them.
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, context.Canceled
}
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
return agent.Result{FinalAnswer: "partial"}, context.Canceled
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
}, &result); err != nil {
t.Fatalf("cancelled turn returned an RPC error: %v", err)
}
if result.StopReason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
usageEvents := 0
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
usageEvents++
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
var payload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
}
}
if usageEvents != 1 {
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
}
}
// Usage events sit in the same log session/load and session/resume read, but
// history reconstruction skips them. The wire replay stays the conversation,
// and the next turn's prompt stays the conversation: token counts never become
// context and never cross the ACP wire.
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
const userLine = "unique-acp-user-line"
const answer = "unique-acp-answer"
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: answer}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
return agent.Result{FinalAnswer: answer}, nil
}
workspace := t.TempDir()
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
h.stop()
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
sawUsage := false
for _, event := range events {
if event.Type == sessions.EventUsage {
sawUsage = true
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
}
}
if !sawUsage {
t.Fatal("session log has no usage event to ignore")
}
loader := newHarness(t, deps)
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
var replayed []string
for {
select {
case update := <-loader.notifications:
replayed = append(replayed, update.Content.Text)
case <-time.After(150 * time.Millisecond):
goto drained
}
}
drained:
loader.stop()
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
}
for _, text := range replayed {
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
t.Fatalf("session/load replayed usage onto the wire: %q", text)
}
}
var gotPrompt string
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
gotPrompt = prompt
return agent.Result{FinalAnswer: "continued"}, nil
}
resumer := newHarness(t, deps)
defer resumer.stop()
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &ResumeSessionResult{}); err != nil {
t.Fatalf("session/resume: %v", err)
}
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err != nil {
t.Fatalf("resumed session/prompt: %v", err)
}
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
}
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
}
}
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
// has no model escalation, and the writer must not grow a prompt, tool
// argument, or other raw field.
var usagePayloadAllowedKeys = map[string]bool{
"promptTokens": true,
"completionTokens": true,
"totalTokens": true,
"cachedInputTokens": true,
"cacheWriteTokens": true,
"reasoningTokens": true,
}
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatalf("usage payload %s: %v", payload, err)
}
if len(raw) == 0 {
t.Fatalf("usage payload is empty: %s", payload)
}
for key := range raw {
if !usagePayloadAllowedKeys[key] {
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
}
}
}
-7
View File
@@ -49,15 +49,8 @@ func TestLocalAdoptionSavesAndReloadsEligibleModel(t *testing.T) {
}
}))
defer server.Close()
// A test-sized probe deadline. DetectLocalRuntimes defaults to 400ms and
// treats a timeout as "runtime not running", so under -race on a busy runner
// one slow loopback round trip can silently drop the candidate. Timeout
// enforcement is covered separately in provideronboarding:
// TestDetectLocalRuntimesAppliesDefaultTimeout pins it with an explicit
// 20ms deadline.
detected := provideronboarding.DetectLocalRuntimes(context.Background(), provideronboarding.LocalDetectOptions{
HTTPClient: server.Client(),
Timeout: 5 * time.Second,
Candidates: []provideronboarding.LocalRuntime{{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model", BaseURL: server.URL + "/v1"}},
})
if len(detected) != 1 {
+2 -88
View File
@@ -485,88 +485,6 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Pin before any t.TempDir so an ambient temp dir cannot reach the snapshot.
// Unix reads TMPDIR (and always considers /tmp). Windows reads TEMP and TMP.
// XDG_RUNTIME_DIR is not a temp write-root candidate.
pinSandboxPolicyGoldenTempEnv(t)
assertSandboxPolicyJSONGolden(t, "")
}
// TestRunSandboxPolicyJSONGoldenNestedTempDir stages the temp-dir shape from
// #1121 and then pins over it. On Unix that shape is a TMPDIR inside /tmp; on
// Windows it is TMP inside TEMP. NewScope drops the inner root, the policy
// JSON still lists it, and the golden normalizer leaves it behind. The pin
// replaces those variables before the snapshot, so the golden matches and the
// nested path does not survive. The directories come from t.TempDir, never
// from the package working directory, so a checkout under /tmp is not special.
func TestRunSandboxPolicyJSONGoldenNestedTempDir(t *testing.T) {
nested := stageNestedSandboxTempEnv(t)
pinSandboxPolicyGoldenTempEnv(t)
normalized := assertSandboxPolicyJSONGolden(t, "")
if strings.Contains(normalized, nested) || strings.Contains(normalized, filepath.ToSlash(nested)) {
t.Fatalf("normalized policy leaked nested temp root %q:\n%s", nested, normalized)
}
}
// stageNestedSandboxTempEnv installs the leaking temp-dir shape and returns
// the inner path. The caller pins afterward. Skip, rather than fail, when
// that shape cannot be built.
func stageNestedSandboxTempEnv(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
outer := t.TempDir()
inner := filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Skipf("create nested TEMP/TMP: %v", err)
}
t.Setenv("TEMP", outer)
t.Setenv("TMP", inner)
return inner
}
resolvedTmp, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Skipf("resolve /tmp: %v", err)
}
info, err := os.Stat(resolvedTmp)
if err != nil || !info.IsDir() {
t.Skip("/tmp is not a directory; cannot stage a nested TMPDIR")
}
// t.TempDir is a child of os.TempDir(). A further child is the ambient
// case: TMPDIR inside the hard-coded /tmp candidate.
nested := filepath.Join(t.TempDir(), "nested")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Skipf("create nested TMPDIR: %v", err)
}
resolvedNested, err := filepath.EvalSymlinks(nested)
if err != nil {
t.Skipf("resolve nested TMPDIR: %v", err)
}
rel, err := filepath.Rel(resolvedTmp, resolvedNested)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Skipf("temp dir %q is not inside /tmp; cannot stage the nested TMPDIR case", resolvedNested)
}
t.Setenv("TMPDIR", resolvedNested)
return resolvedNested
}
// pinSandboxPolicyGoldenTempEnv fixes the temp variables
// defaultTempWriteRootCandidatesForGOOS reads so the golden does not depend on
// the process environment. An empty TMPDIR leaves the Unix hard-coded /tmp as
// the only candidate. TEMP and TMP name one directory so neither is nested in
// the other.
func pinSandboxPolicyGoldenTempEnv(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
dir := t.TempDir()
t.Setenv("TEMP", dir)
t.Setenv("TMP", dir)
return
}
t.Setenv("TMPDIR", "")
}
func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
t.Helper()
// Point all user-config roots at an empty directory so the platform-specific
// credential deny baseline can be asserted without leaking host paths into
// the platform-neutral golden comparison.
@@ -608,8 +526,8 @@ func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
raw := stdout.String()
got := replacePathToken(raw, workspace, "$WORKSPACE")
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
@@ -619,10 +537,6 @@ func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
if !jsonValuesEqual(t, wantBytes, gotBytes) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
}
if mustList != "" && !strings.Contains(raw, mustList) {
t.Fatalf("policy JSON did not list pinned temp root %q\n%s", mustList, raw)
}
return string(gotBytes)
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
+5 -6
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
@@ -71,12 +72,10 @@ var lockConfigFileFn = lockConfigFile
func lockConfigFile(path string) (func() error, error) {
lockPath := path + ".lock"
// Every mutator locks before it writes, so this is where the config
// directory is normally created. ensureConfigDir also syncs each new entry
// into its parent before the lock file exists; writeConfigData would find
// nothing missing by then and could not run that barrier itself.
if err := ensureConfigDir(filepath.Dir(lockPath)); err != nil {
return nil, err
if dir := filepath.Dir(lockPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create config directory %s: %w", dir, err)
}
}
deadline := time.Now().Add(configLockTimeout)
for {
+4 -117
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
@@ -1097,116 +1096,12 @@ func writeConfigFile(path string, cfg FileConfig) error {
return writeConfigData(path, data)
}
// Sync seams allow tests to inject persistence failures at each barrier.
var syncConfigFileFn = (*os.File).Sync
var syncConfigDirFn = syncConfigDir
// Windows cannot sync directory handles, so rename durability is best effort
// there, matching the sessions store. Elsewhere a directory the user can write
// but not read cannot be opened for syncing either; that is also best effort,
// because the write it follows has already succeeded. Every other failure to
// open, sync, or close the directory is reported: the barrier was requested and
// not reached, so the caller must not acknowledge the save.
func syncConfigDir(dir string) error {
if runtime.GOOS == "windows" {
return nil
}
d, err := os.Open(dir)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return nil
}
return err
}
return errors.Join(d.Sync(), d.Close())
}
// ensureConfigDir is the ONE path that creates the config directory, shared by
// lockConfigFile and writeConfigData. Every exported mutator locks before it
// writes, and the lock file is a sibling of the config, so in practice the lock
// path is what first creates the directory; a barrier that lived only in
// writeConfigData would find nothing missing by then and never run.
//
// Each missing component is created with os.Mkdir rather than os.MkdirAll so
// the call knows exactly which entries it created, then each new entry is
// synced into its parent (deepest first) so a crash cannot lose the directory,
// and with it the config, after a successful write. A component that appeared
// between the lookup and the Mkdir belongs to another writer: it is not rolled
// back, but its parent is still synced, because this write depends on the
// entry being durable whoever created it.
//
// If a barrier fails, the directories this call created are removed again.
// Leaving them would let a retry find nothing missing, skip the barrier, and
// report a durability it never established. Only what this call created is
// removed, with os.Remove so a directory another process has since populated
// stays; a failed rollback is joined into the error, never swallowed.
func ensureConfigDir(dir string) error {
if dir == "." || dir == "" {
return nil
}
missing := missingConfigDirs(dir)
if len(missing) == 0 {
return nil
}
var created []string
for i := len(missing) - 1; i >= 0; i-- {
d := missing[i]
err := os.Mkdir(d, 0o700)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return errors.Join(fmt.Errorf("create config directory %s: %w", d, err), removeConfigDirs(created))
}
created = append(created, d)
}
for _, d := range missing {
parent := filepath.Dir(d)
if err := syncConfigDirFn(parent); err != nil {
return errors.Join(fmt.Errorf("sync config directory %s: %w", parent, err), removeConfigDirs(created))
}
}
return nil
}
// removeConfigDirs rolls back directories ensureConfigDir created in this
// call, deepest first. os.Remove refuses a non-empty directory, so an entry
// another process already populated survives and its error is reported.
func removeConfigDirs(created []string) error {
var errs []error
for i := len(created) - 1; i >= 0; i-- {
if err := os.Remove(created[i]); err != nil {
errs = append(errs, fmt.Errorf("roll back config directory %s: %w", created[i], err))
}
}
return errors.Join(errs...)
}
// missingConfigDirs lists dir and each ancestor that does not exist yet,
// deepest first, so ensureConfigDir can create them shallowest first and sync
// each new entry into its parent deepest first.
func missingConfigDirs(dir string) []string {
var missing []string
for d := dir; ; {
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
return missing
}
missing = append(missing, d)
parent := filepath.Dir(d)
if parent == d {
return missing
}
d = parent
}
}
func writeConfigData(path string, data []byte) error {
dir := filepath.Dir(path)
// Usually a no-op: the lock path has already created and synced the
// directory. It still matters for the rare unlocked caller and for a
// directory removed between the lock and the write.
if err := ensureConfigDir(dir); err != nil {
return err
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
}
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
@@ -1227,19 +1122,11 @@ func writeConfigData(path string, data []byte) error {
_ = tmp.Close()
return fmt.Errorf("write config %s: %w", path, err)
}
// Persist the complete contents before making the replacement visible.
if err := syncConfigFileFn(tmp); err != nil {
return fmt.Errorf("sync config %s: %w", path, errors.Join(err, tmp.Close()))
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
// The replacement is already visible if this fails; do not roll it back.
if err := syncConfigDirFn(dir); err != nil {
return fmt.Errorf("sync config directory %s: %w", dir, err)
}
return nil
}
-290
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
@@ -16,295 +15,6 @@ import (
"testing"
)
func TestWriteConfigDataSync(t *testing.T) {
failure := errors.New("injected sync failure")
for _, stage := range []string{"success", "file", "directory"} {
t.Run(stage, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
oldData, newData := "{\"old\":true}\n", "{\"new\":true}\n"
if err := os.WriteFile(path, []byte(oldData), 0o600); err != nil {
t.Fatal(err)
}
checkData := func(path, want string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil || string(got) != want {
t.Fatalf("read %s = %q, %v; want %q", path, got, err, want)
}
}
fileSync, dirSync := syncConfigFileFn, syncConfigDirFn
t.Cleanup(func() { syncConfigFileFn, syncConfigDirFn = fileSync, dirSync })
var tmp *os.File
var calls []string
syncConfigFileFn = func(f *os.File) error {
tmp = f
calls = append(calls, "file")
checkData(f.Name(), newData)
checkData(path, oldData)
if stage == "file" {
return failure
}
return fileSync(f)
}
syncConfigDirFn = func(got string) error {
calls = append(calls, "directory")
if got != dir || tmp == nil {
t.Fatalf("directory sync = %q, temp = %v", got, tmp)
}
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
t.Fatalf("temp must be closed before directory sync: %v", err)
}
checkData(path, newData)
if stage == "directory" {
return failure
}
return dirSync(got)
}
err := writeConfigData(path, []byte(strings.TrimSuffix(newData, "\n")))
if stage == "success" {
if err != nil {
t.Fatal(err)
}
} else if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
wantCalls := []string{"file", "directory"}
wantData := newData
if stage == "file" {
wantCalls, wantData = []string{"file"}, oldData
}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("sync calls = %v, want %v", calls, wantCalls)
}
checkData(path, wantData)
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
t.Fatalf("temp must be closed on return: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 || entries[0].Name() != "config.json" {
t.Fatalf("temporary file not cleaned up: %v, %v", entries, err)
}
})
}
}
func TestWriteConfigDataSyncsCreatedDirectories(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
for _, fail := range []bool{false, true} {
var calls []string
syncConfigDirFn = func(got string) error {
calls = append(calls, got)
if fail && got == root {
return failure
}
return dirSync(got)
}
if err := os.RemoveAll(outer); err != nil {
t.Fatal(err)
}
err := writeConfigData(path, []byte(`{}`))
if fail {
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config must not be written after a failed directory sync: %v", err)
}
continue
}
if err != nil {
t.Fatal(err)
}
// Each new directory's entry is synced into its parent, then the
// config's own directory after the rename.
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
}
}
func TestLockingMutatorSyncsCreatedDirectories(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
exists := func(p string) bool {
t.Helper()
_, err := os.Lstat(p)
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
return err == nil
}
// Every exported mutator acquires the config lock before it writes, and
// the lock file is a sibling of the config, so the lock path is what first
// creates the config directory. The barrier for those new entries has to
// run there, before the lock file exists: by the time writeConfigData
// looks, nothing is missing any more.
for _, fail := range []bool{true, false} {
var calls []string
syncConfigDirFn = func(got string) error {
calls = append(calls, got)
if got == outer || got == root {
if exists(path+".lock") || exists(path) {
t.Fatalf("parent sync of %s ran after the lock file or config was created", got)
}
}
if fail && got == root {
return failure
}
return dirSync(got)
}
_, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
return NotifyConfig{Mode: "off"}
})
if fail {
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
// The directories this attempt created are gone again, so the
// retry below re-creates them and re-runs the barrier instead of
// finding them present and reporting a durability it never had.
if exists(outer) {
t.Fatalf("%s must be rolled back after a failed parent sync", outer)
}
continue
}
if err != nil {
t.Fatal(err)
}
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
got, err := UserNotify(path)
if err != nil || got.Mode != "off" {
t.Fatalf("UserNotify = %+v, %v; want mode off", got, err)
}
}
}
func TestWriteConfigDataRollsBackOnlyCreatedDirectories(t *testing.T) {
root := t.TempDir()
existing := filepath.Join(root, "existing")
if err := os.Mkdir(existing, 0o700); err != nil {
t.Fatal(err)
}
keep := filepath.Join(existing, "keep")
if err := os.WriteFile(keep, []byte("keep\n"), 0o600); err != nil {
t.Fatal(err)
}
outer := filepath.Join(existing, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
stat := func(p string) error {
_, err := os.Lstat(p)
return err
}
// The barrier for "a" fails: both directories this call created go, the
// pre-existing directory and its file stay.
syncConfigDirFn = func(got string) error {
if got == existing {
return failure
}
return dirSync(got)
}
if err := writeConfigData(path, []byte(`{}`)); !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
for _, p := range []string{dir, outer} {
if err := stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s must be removed after a failed parent sync: %v", p, err)
}
}
for _, p := range []string{existing, keep} {
if err := stat(p); err != nil {
t.Fatalf("pre-existing %s must survive the rollback: %v", p, err)
}
}
// A directory another process populated in the meantime is not ours to
// remove: it stays, the failed rollback is reported with the barrier
// failure, and the write still does not acknowledge the save.
intruder := filepath.Join(dir, "other.lock")
syncConfigDirFn = func(got string) error {
if got == existing {
if err := os.WriteFile(intruder, nil, 0o600); err != nil {
t.Fatal(err)
}
return failure
}
return dirSync(got)
}
err := writeConfigData(path, []byte(`{}`))
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if err := stat(intruder); err != nil {
t.Fatalf("a populated directory must not be removed: %v", err)
}
if !strings.Contains(err.Error(), "roll back config directory") {
t.Fatalf("error must report the failed rollback: %v", err)
}
if err := stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config must not be written after a failed directory sync: %v", err)
}
}
func TestSyncConfigDir(t *testing.T) {
dir := t.TempDir()
if err := syncConfigDir(dir); err != nil {
t.Fatal(err)
}
err := syncConfigDir(filepath.Join(dir, "missing"))
if runtime.GOOS == "windows" {
// Directory handles cannot be synced on Windows, so every call is a
// no-op there, including one for a directory that does not exist.
if err != nil {
t.Fatalf("syncConfigDir must be a no-op on windows: %v", err)
}
return
}
// A directory that is gone means the persistence barrier was never
// reached, so the caller must not acknowledge the save.
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing directory: error = %v, want %v", err, os.ErrNotExist)
}
// A directory the user can write but not read cannot be opened for
// syncing; that is best effort, as in the sessions store, because the
// write it follows has already succeeded.
unreadable := filepath.Join(dir, "unreadable")
if err := os.Mkdir(unreadable, 0o300); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(unreadable, 0o700) })
if err := syncConfigDir(unreadable); err != nil {
t.Fatalf("write-only directory must be best effort: %v", err)
}
}
func TestSetActiveProviderSwitchesConfiguredProvider(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
+2 -2
View File
@@ -22,7 +22,7 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
}
containsAll(t, script, []string{
"set -euo pipefail",
`ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"`,
`ZERO_REPO="${ZERO_REPO:-Gitlawb/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=Twigpine/zero",
"ZERO_REPO=Gitlawb/zero",
)
var stdout bytes.Buffer
var stderr bytes.Buffer
+69 -163
View File
@@ -28,9 +28,69 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := writePlatformArchiveFixture(t, tar, version)
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
outDir := filepath.Join(root, "out")
runBuildPlatformPackages(t, node, root, outDir)
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
@@ -41,8 +101,8 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
Scripts map[string]string
}
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
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 platformPkg.Name != "@gitlawb/zero" {
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
}
if want := version + "-linux-x64"; platformPkg.Version != want {
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
@@ -138,11 +198,11 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
}
wantAliases := map[string]string{
"@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",
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
@@ -159,160 +219,6 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
}
}
// writePlatformArchiveFixture writes a linux-x64 release archive shaped like
// the output of `zero-release package`, with its .sha256 sidecar, into a new
// directory and returns that directory.
func writePlatformArchiveFixture(t *testing.T, tar string, version string) string {
t.Helper()
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@twigpine/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
return root
}
func runBuildPlatformPackages(t *testing.T, node string, artifactsDir string, outDir string, extraArgs ...string) {
t.Helper()
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
args := append([]string{script, "--artifacts-dir", artifactsDir, "--out-dir", outDir, "--only", "linux-x64"}, extraArgs...)
command := exec.CommandContext(ctx, node, args...)
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
}
// The final release under the pre-rename name is the same artifacts assembled
// with --package-name. Every place a payload names its package must follow it:
// a wrapper that kept the current name in one alias would resolve a package
// npm never installed.
func TestBuildPlatformPackagesAssemblesUnderAnotherPackageName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX symlinks and shell scripts")
}
node := requireNode(t)
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := writePlatformArchiveFixture(t, tar, version)
outDir := filepath.Join(root, "out")
runBuildPlatformPackages(t, node, root, outDir, "--package-name", "@gitlawb/zero")
platformManifest := filepath.Join(outDir, "platforms", "zero-linux-x64", "package.json")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
}
unmarshalJSONFile(t, platformManifest, &platformPkg)
if platformPkg.Name != "@gitlawb/zero" || platformPkg.Version != version+"-linux-x64" {
t.Fatalf("platform package = %s@%s, want @gitlawb/zero@%s-linux-x64", platformPkg.Name, platformPkg.Version, version)
}
wrapperManifest := filepath.Join(outDir, "wrapper", "package.json")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Optional map[string]string `json:"optionalDependencies"`
Repository struct {
URL string `json:"url"`
} `json:"repository"`
}
unmarshalJSONFile(t, wrapperManifest, &wrapperPkg)
if wrapperPkg.Name != "@gitlawb/zero" || wrapperPkg.Version != version {
t.Fatalf("wrapper = %s@%s, want @gitlawb/zero@%s", wrapperPkg.Name, wrapperPkg.Version, version)
}
if len(wrapperPkg.Optional) != 5 {
t.Fatalf("wrapper optionalDependencies = %v, want the full five-platform matrix", wrapperPkg.Optional)
}
for alias, spec := range wrapperPkg.Optional {
platform := strings.TrimPrefix(alias, "@gitlawb/zero-")
if platform == alias || spec != "npm:@gitlawb/zero@"+version+"-"+platform {
t.Fatalf("optionalDependencies[%q] = %q, want npm:@gitlawb/zero@%s-<platform> under an @gitlawb/zero-<platform> alias", alias, spec, version)
}
}
// Provenance is checked against the repository the workflow runs in, which
// is the same for both names.
if !strings.Contains(wrapperPkg.Repository.URL, "github.com/Twigpine/zero") {
t.Fatalf("wrapper repository = %q, want the Twigpine/zero repository npm checks provenance against", wrapperPkg.Repository.URL)
}
for _, manifest := range []string{platformManifest, wrapperManifest} {
data, err := os.ReadFile(manifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "@twigpine/zero") {
t.Fatalf("%s still names @twigpine/zero:\n%s", manifest, data)
}
}
}
func TestBuildPlatformPackagesRejectsAnInvalidPackageName(t *testing.T) {
node := requireNode(t)
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := exec.CommandContext(ctx, node, script, "--artifacts-dir", t.TempDir(), "--package-name", "../zero")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("build-platform-packages accepted --package-name ../zero:\n%s", output)
}
if !strings.Contains(string(output), "is not an npm package name") {
t.Fatalf("output does not explain the rejection: %s", output)
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
+9 -101
View File
@@ -24,9 +24,9 @@ const (
// by the generic missing-binary path and the doctor text fallback.
const (
bunRecoveryLead = "You installed with Bun, which does not run dependency lifecycle scripts"
bunPmTrustProject = "bun pm trust @twigpine/zero"
bunPmTrustGlobal = "bun pm -g trust @twigpine/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@twigpine/zero"]`
bunPmTrustProject = "bun pm trust @gitlawb/zero"
bunPmTrustGlobal = "bun pm -g trust @gitlawb/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@gitlawb/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 != "@twigpine/zero" {
t.Fatalf("name = %q, want @twigpine/zero", pkg.Name)
if pkg.Name != "@gitlawb/zero" {
t.Fatalf("name = %q, want @gitlawb/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/Twigpine/zero/releases/download/v" + version + "/" + tc.wantAsset
wantURL := "https://github.com/Gitlawb/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", "@twigpine", "zero-"+nodePlatformName()+"-"+nodeArchName())
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
if err := os.MkdirAll(platformDir, 0o755); err != nil {
t.Fatalf("MkdirAll platform package: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@twigpine/zero"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
t.Fatalf("WriteFile platform package.json: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "zero"), []byte("#!/usr/bin/env sh\nprintf 'platform-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
@@ -347,98 +347,6 @@ func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
}
}
// bin/zero.js derives the platform payload alias from its own package.json
// name rather than a hardcoded one, so a wrapper assembled under any name
// finds the payload published under that same name
// (build-platform-packages.mjs names both from one value).
func TestNodeWrapperResolvesPlatformPackageUnderItsOwnName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@example/zero"}`, 0o644)
// A payload under the default name sits beside it and must lose.
writePlatformPackageFixture(t, root, "@twigpine/zero", "default-name-zero")
writePlatformPackageFixture(t, root, "@example/zero", "own-name-zero")
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "--version")
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if got := strings.TrimSpace(stdout); got != "own-name-zero --version" {
t.Fatalf("wrapper output = %q, want the payload aliased under its own name (own-name-zero --version)", got)
}
}
// The bridge release published under the old name runs normally and, once
// zero exits, says where the package went and how to switch, in the one order
// npm accepts.
func TestNodeWrapperLegacyBuildTellsUsersToSwitchAfterExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@gitlawb/zero"}`, 0o644)
writePlatformPackageFixture(t, root, "@gitlawb/zero", "legacy-zero")
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, "--version")
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if got := strings.TrimSpace(stdout); got != "legacy-zero --version" {
t.Fatalf("stdout = %q, want the legacy build to run normally with the notice kept off stdout", got)
}
uninstall := strings.Index(stderr, "npm uninstall -g @gitlawb/zero")
install := strings.Index(stderr, "npm install -g @twigpine/zero")
if uninstall < 0 || install < 0 || uninstall > install {
t.Fatalf("stderr does not give the switch steps, uninstall first: %q", stderr)
}
}
func TestNodeWrapperLegacyNoticeStaysOutOfTheWay(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
for _, tc := range []struct {
name, packageName string
env []string
args []string
}{
// Scripts capturing stderr must not see a new line in it.
{name: "not a terminal", packageName: "@gitlawb/zero", args: []string{"--version"}},
// The Go side prints the same note for these; once is enough.
{name: "zero upgrade", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"upgrade"}},
{name: "zero update", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"update", "--check"}},
{name: "current package", packageName: "@twigpine/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"--version"}},
} {
t.Run(tc.name, func(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"`+tc.packageName+`"}`, 0o644)
writePlatformPackageFixture(t, root, tc.packageName, "zero")
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, tc.env, tc.args...)
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("stderr = %q, want nothing", stderr)
}
})
}
}
// writePlatformPackageFixture installs a fake platform payload where npm puts
// the wrapper's optionalDependency alias <packageName>-<platform>-<arch>; its
// zero binary prints label followed by its arguments.
func writePlatformPackageFixture(t *testing.T, root string, packageName string, label string) {
t.Helper()
dir := filepath.Join(root, "node_modules", filepath.FromSlash(packageName+"-"+nodePlatformName()+"-"+nodeArchName()))
writeFixtureFile(t, filepath.Join(dir, "package.json"), `{"name":"`+packageName+`"}`, 0o644)
writeFixtureFile(t, filepath.Join(dir, "zero"), "#!/usr/bin/env sh\nprintf '"+label+"'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n", 0o755)
}
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
@@ -485,7 +393,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":"@twigpine/zero","version":"0.0.0"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
t.Fatalf("WriteFile package fixture: %v", err)
}
-327
View File
@@ -1,327 +0,0 @@
package npmwrapper
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// fakeNpm stands in for npm on PATH when scripts/npm/publish-packages.sh runs.
// It keeps one package's registry state in FAKE_NPM_STATE and answers the way
// npm 11 does: a lookup of a missing package or a missing version fails with
// E404, and it refuses a version published twice or any lookup or publish for
// a package name other than the one it holds. Every call is appended to
// FAKE_NPM_STATE.log.
//
// FAKE_NPM_CLOBBER_LATEST=1 makes every publish take `latest`, the failure the
// script must stop on. FAKE_NPM_VERSION_LOOKUP_ERRORS=<n> fails the first n
// version lookups with a network error instead of answering them.
const fakeNpm = `#!/usr/bin/env node
const fs = require('fs');
const stateFile = process.env.FAKE_NPM_STATE;
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
const args = process.argv.slice(2);
fs.appendFileSync(stateFile + '.log', JSON.stringify({ args, cwd: process.cwd() }) + '\n');
const save = () => fs.writeFileSync(stateFile, JSON.stringify(state));
const notFound = (detail) => { process.stderr.write('npm error code E404\nnpm error 404 ' + detail + '\n'); process.exit(1); };
if (args[0] === 'view') {
const [spec, field] = args.slice(1);
const at = spec.lastIndexOf('@');
const name = at > 0 ? spec.slice(0, at) : spec;
if (field === 'version') {
const failures = Number(process.env.FAKE_NPM_VERSION_LOOKUP_ERRORS || 0);
state.versionLookups = (state.versionLookups || 0) + 1;
save();
if (state.versionLookups <= failures) {
process.stderr.write('npm error code ECONNRESET\nnpm error network aborted\n');
process.exit(1);
}
}
if (name !== state.name || state.versions.length === 0) notFound('Not Found - GET ' + name);
if (field === 'version') {
const version = spec.slice(at + 1);
if (!state.versions.includes(version)) notFound('No match found for version ' + version);
process.stdout.write(version + '\n');
process.exit(0);
}
if (field === 'dist-tags.latest') {
if (state.distTags.latest) process.stdout.write(state.distTags.latest + '\n');
process.exit(0);
}
}
if (args[0] === 'publish') {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
if (pkg.name !== state.name) {
process.stderr.write('fake npm: refusing to publish ' + pkg.name + '\n');
process.exit(1);
}
if (state.versions.includes(pkg.version)) {
process.stderr.write('npm error code EPUBLISHCONFLICT\n');
process.exit(1);
}
const tagIndex = args.indexOf('--tag');
state.versions.push(pkg.version);
state.distTags[tagIndex === -1 ? 'latest' : args[tagIndex + 1]] = pkg.version;
if (process.env.FAKE_NPM_CLOBBER_LATEST === '1') state.distTags.latest = pkg.version;
save();
process.exit(0);
}
process.stderr.write('fake npm: unsupported command ' + args.join(' ') + '\n');
process.exit(2);
`
const (
publishFixtureName = "@example/zero"
publishFixtureVersion = "1.2.3"
)
type fakeRegistryState struct {
Name string `json:"name"`
Versions []string `json:"versions"`
DistTags map[string]string `json:"distTags"`
// VersionLookups counts `npm view <name>@<version> version` calls.
VersionLookups int `json:"versionLookups,omitempty"`
}
type npmCall struct {
Args []string `json:"args"`
Cwd string `json:"cwd"`
}
type publishRun struct {
stdout, stderr string
exitCode int
state fakeRegistryState
publishes []npmCall
}
// writePublishFixture lays out what build-platform-packages.mjs emits: a
// wrapper whose optionalDependencies alias the given platform payloads, and
// one directory per payload in platforms.
func writePublishFixture(t *testing.T, aliasedPlatforms []string, platformDirs []string) string {
t.Helper()
npmDir := filepath.Join(t.TempDir(), "npm")
aliases := map[string]string{}
for _, platform := range aliasedPlatforms {
aliases[publishFixtureName+"-"+platform] = "npm:" + publishFixtureName + "@" + publishFixtureVersion + "-" + platform
}
wrapper, err := json.Marshal(map[string]any{
"name": publishFixtureName,
"version": publishFixtureVersion,
"optionalDependencies": aliases,
})
if err != nil {
t.Fatal(err)
}
writeFixtureFile(t, filepath.Join(npmDir, "wrapper", "package.json"), string(wrapper), 0o644)
for _, platform := range platformDirs {
manifest := `{"name":"` + publishFixtureName + `","version":"` + publishFixtureVersion + "-" + platform + `"}`
writeFixtureFile(t, filepath.Join(npmDir, "platforms", "zero-"+platform, "package.json"), manifest, 0o644)
}
return npmDir
}
func runPublishPackages(t *testing.T, npmDir string, registry fakeRegistryState, extraEnv ...string) publishRun {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("publish-packages.sh runs on the Linux release runner; the fake npm is a POSIX executable")
}
bash := requireCommand(t, "bash")
requireNode(t)
binDir := t.TempDir()
writeFixtureFile(t, filepath.Join(binDir, "npm"), fakeNpm, 0o755)
if registry.DistTags == nil {
registry.DistTags = map[string]string{}
}
if registry.Versions == nil {
registry.Versions = []string{}
}
stateFile := filepath.Join(t.TempDir(), "registry.json")
initial, err := json.Marshal(registry)
if err != nil {
t.Fatal(err)
}
writeFixtureFile(t, stateFile, string(initial), 0o644)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
command := exec.CommandContext(ctx, bash, filepath.Join(repoRoot(t), "scripts", "npm", "publish-packages.sh"), npmDir)
command.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_NPM_STATE="+stateFile,
"ZERO_NPM_PUBLISH_RETRY_DELAY=0",
"NODE_OPTIONS=",
)
command.Env = append(command.Env, extraEnv...)
var stdout, stderr strings.Builder
command.Stdout = &stdout
command.Stderr = &stderr
run := publishRun{}
if err := command.Run(); err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("publish-packages.sh: %v; stderr=%s", err, stderr.String())
}
run.exitCode = exitErr.ExitCode()
}
if ctx.Err() != nil {
t.Fatalf("publish-packages.sh timed out; stdout=%s stderr=%s", stdout.String(), stderr.String())
}
run.stdout, run.stderr = stdout.String(), stderr.String()
unmarshalJSONFile(t, stateFile, &run.state)
if log, err := os.Open(stateFile + ".log"); err == nil {
defer log.Close()
scanner := bufio.NewScanner(log)
for scanner.Scan() {
var call npmCall
if err := json.Unmarshal(scanner.Bytes(), &call); err != nil {
t.Fatalf("fake npm log line %q: %v", scanner.Text(), err)
}
if len(call.Args) > 0 && call.Args[0] == "publish" {
run.publishes = append(run.publishes, call)
}
}
}
return run
}
// publishedDirs names each publish by the payload directory it ran in, in order.
func (run publishRun) publishedDirs() []string {
dirs := []string{}
for _, call := range run.publishes {
dirs = append(dirs, filepath.Base(call.Cwd))
}
return dirs
}
func TestPublishPackagesPublishesPlatformPayloadsBeforeTheWrapper(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName})
if run.exitCode != 0 {
t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got, want := strings.Join(run.publishedDirs(), ","), "zero-darwin-arm64,zero-linux-x64,wrapper"; got != want {
t.Fatalf("publish order = %s, want %s (the wrapper pins the platform versions, so it must go last)", got, want)
}
for index, call := range run.publishes {
args := strings.Join(call.Args, " ")
if !strings.Contains(args, "--access public") || !strings.Contains(args, "--ignore-scripts") {
t.Fatalf("publish %d args = %q, want --access public and --ignore-scripts", index, args)
}
isWrapper := filepath.Base(call.Cwd) == "wrapper"
if hasPlatformTag := strings.Contains(args, "--tag platform"); hasPlatformTag == isWrapper {
t.Fatalf("publish %d (%s) args = %q: platform payloads need --tag platform and the wrapper must take latest", index, filepath.Base(call.Cwd), args)
}
}
if latest := run.state.DistTags["latest"]; latest != publishFixtureVersion {
t.Fatalf("latest = %q, want %q", latest, publishFixtureVersion)
}
}
// "Re-run failed jobs" after a partial publish must finish the release rather
// than fail on the versions that already went out.
func TestPublishPackagesSkipsVersionsAlreadyOnTheRegistry(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{
Name: publishFixtureName,
Versions: []string{"1.2.2", publishFixtureVersion + "-darwin-arm64"},
DistTags: map[string]string{"latest": "1.2.2", "platform": publishFixtureVersion + "-darwin-arm64"},
})
if run.exitCode != 0 {
t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got, want := strings.Join(run.publishedDirs(), ","), "zero-linux-x64,wrapper"; got != want {
t.Fatalf("published %s, want %s (darwin-arm64 was already on the registry)", got, want)
}
if !strings.Contains(run.stdout, publishFixtureName+"@"+publishFixtureVersion+"-darwin-arm64 is already on the registry") {
t.Fatalf("stdout does not report the skipped version: %s", run.stdout)
}
// Re-running once everything is out publishes nothing and still succeeds.
rerun := runPublishPackages(t, npmDir, run.state)
if rerun.exitCode != 0 {
t.Fatalf("rerun exit = %d; stdout=%s stderr=%s", rerun.exitCode, rerun.stdout, rerun.stderr)
}
if len(rerun.publishes) != 0 {
t.Fatalf("rerun published %v, want nothing", rerun.publishedDirs())
}
}
func TestPublishPackagesStopsBeforeTheWrapperWhenAPlatformTookLatest(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_CLOBBER_LATEST=1")
if run.exitCode == 0 {
t.Fatalf("exit = 0 after a platform publish took latest; stdout=%s", run.stdout)
}
if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64" {
t.Fatalf("published %s, want only the platform payload (the wrapper must not follow a clobbered latest)", got)
}
if !strings.Contains(run.stderr, "clobbered") {
t.Fatalf("stderr does not explain the failure: %s", run.stderr)
}
}
// A wrapper that aliases a platform version with no payload beside it would
// install an alias that 404s, so nothing may be published at all.
func TestPublishPackagesRefusesAWrapperWhoseAliasesDoNotMatchThePayloads(t *testing.T) {
npmDir := writePublishFixture(t, []string{"darwin-arm64", "linux-x64"}, []string{"linux-x64"})
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName})
if run.exitCode == 0 {
t.Fatalf("exit = 0 with a missing platform payload; stdout=%s", run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v before noticing the mismatch, want nothing", run.publishedDirs())
}
if !strings.Contains(run.stderr, "do not match the platform payloads") {
t.Fatalf("stderr does not explain the failure: %s", run.stderr)
}
}
// A lookup that fails for a reason other than E404 must not read as "not
// published": on the re-run this skip exists for, the version is usually out
// already, and publishing it again fails with EPUBLISHCONFLICT.
func TestPublishPackagesRetriesAFailedLookupInsteadOfPublishing(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{
Name: publishFixtureName,
Versions: []string{publishFixtureVersion + "-linux-x64"},
DistTags: map[string]string{"platform": publishFixtureVersion + "-linux-x64"},
}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=2")
if run.exitCode != 0 {
t.Fatalf("exit = %d after two transient lookup failures; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got := strings.Join(run.publishedDirs(), ","); got != "wrapper" {
t.Fatalf("published %s, want only the wrapper (the platform version was already out)", got)
}
}
// When the registry cannot answer at all, nothing may be published: the
// script cannot tell which versions are already out.
func TestPublishPackagesFailsWhenTheRegistryCannotBeReached(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=1000")
if run.exitCode == 0 {
t.Fatalf("exit = 0 with the registry unreachable; stdout=%s", run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v without being able to look anything up, want nothing", run.publishedDirs())
}
if !strings.Contains(run.stderr, "could not look up") || !strings.Contains(run.stderr, "ECONNRESET") {
t.Fatalf("stderr does not explain the failure or show npm's error: %s", run.stderr)
}
}
-1
View File
@@ -261,7 +261,6 @@ func redactMetadata(session sessions.Metadata, options redaction.Options) sessio
session.Title = redaction.RedactString(session.Title, options)
session.Cwd = redaction.RedactString(session.Cwd, options)
session.ModelID = redaction.RedactString(session.ModelID, options)
session.PermissionMode = redaction.RedactString(session.PermissionMode, options)
session.Provider = redaction.RedactString(session.Provider, options)
session.Tag = redaction.RedactString(session.Tag, options)
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
-102
View File
@@ -1,102 +0,0 @@
package sessions
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"testing"
"time"
)
// TestSessionFileLockBlocksAnotherProcessUntilItExits proves the OS session lock
// (flock on unix, LockFileEx on Windows) serializes mutations across processes,
// not just across Store instances in one process, and that it is released when
// the holder exits without unlocking.
func TestSessionFileLockBlocksAnotherProcessUntilItExits(t *testing.T) {
root := t.TempDir()
store := NewStore(StoreOptions{RootDir: root})
if _, err := store.Create(CreateInput{SessionID: "s"}); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestSessionFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_SESSIONS_LOCK_HELPER=1", "ZERO_SESSIONS_LOCK_ROOT="+root)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
exited := false
t.Cleanup(func() {
if !exited {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
})
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
t.Fatalf("helper readiness = %q, err %v", ready, err)
}
// The helper process holds the session lock, so this must block.
done := make(chan error, 1)
go func() {
_, appendErr := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
done <- appendErr
}()
select {
case err := <-done:
t.Fatalf("AppendEvent completed while another process held the session lock (err=%v)", err)
case <-time.After(300 * time.Millisecond):
// Expected: still blocked.
}
// The helper exits without unlocking; the OS must release the lock for us.
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
exited = true
select {
case err := <-done:
if err != nil {
t.Fatalf("AppendEvent after the holder exited: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("AppendEvent did not proceed after the lock holder exited")
}
}
func TestSessionFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_SESSIONS_LOCK_HELPER") != "1" {
return
}
store := NewStore(StoreOptions{RootDir: os.Getenv("ZERO_SESSIONS_LOCK_ROOT")})
unlock, err := store.lockSession("s")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
// The unlock closure is the only reference to the open lock file/handle.
// Keep it alive until stdin closes, or GC finalization of the file could
// release the OS lock while this helper is meant to be holding it.
runtime.KeepAlive(unlock) // deliberately exit without calling unlock
}
+16 -73
View File
@@ -143,15 +143,21 @@ func (store *Store) hold(sessionID string) (busy bool) {
if _, held := store.leases[sessionID]; held {
return false
}
path := store.leasePath(sessionID)
held, busy := takeProcessLease(path)
if !held {
return busy
// Creates the lease file, never the directory: a session that is gone stays
// gone.
file, err := openLeaseFile(store.leasePath(sessionID))
if err != nil {
return false
}
locked, err := tryLockLease(file, false)
if err != nil || !locked {
_ = file.Close()
return err == nil
}
if store.leases == nil {
store.leases = map[string]string{}
store.leases = map[string]*os.File{}
}
store.leases[sessionID] = path
store.leases[sessionID] = file
return false
}
@@ -159,80 +165,17 @@ func (store *Store) hold(sessionID string) (busy bool) {
// yet: a process holds every session it has touched until it exits, which
// releases every lease with it, and tests use Release to stand in for that
// exit. A long-lived process that switches sessions, like the TUI on /new, could
// call it for the session it leaves. The lease itself goes once no other Store
// in the process holds it either.
// call it for the session it leaves.
func (store *Store) Release(sessionID string) {
store.leasesMu.Lock()
defer store.leasesMu.Unlock()
path, held := store.leases[sessionID]
file, held := store.leases[sessionID]
if !held {
return
}
delete(store.leases, sessionID)
giveBackProcessLease(path)
}
// processLeases are the lease files this process holds, by path.
//
// A LEASE IS THE PROCESS'S, NOT THE STORE'S. A Store can go out of scope while
// the process goes on using what it holds: PrepareExec opens one for an exec run,
// takes the calling or forked session's lease on it, and the run never touches
// that Store again. Had the file stayed with the Store, the garbage collector
// would close it once the Store was unreachable, which drops the lock, and prune
// could remove a session the run still uses. So every Store holding a session
// shares one file here, and the file is closed only when the last of them
// releases it.
var processLeases = struct {
sync.Mutex
files map[string]*processLease
}{files: map[string]*processLease{}}
type processLease struct {
file *os.File
holders int
}
// takeProcessLease adds a holder to the process's lease on path, taking the lease
// first when nothing in the process holds it. It reports busy when the lease
// could not be taken because prune holds it exclusively right now.
func takeProcessLease(path string) (held bool, busy bool) {
processLeases.Lock()
defer processLeases.Unlock()
if lease := processLeases.files[path]; lease != nil {
lease.holders++
return true, false
}
// Creates the lease file, never the directory: a session that is gone stays
// gone.
file, err := openLeaseFile(path)
if err != nil {
return false, false
}
locked, err := tryLockLease(file, false)
if err != nil || !locked {
_ = file.Close()
return false, err == nil
}
processLeases.files[path] = &processLease{file: file, holders: 1}
return true, false
}
// giveBackProcessLease removes a holder from the process's lease on path, and
// unlocks and closes the file once no holder is left.
func giveBackProcessLease(path string) {
processLeases.Lock()
defer processLeases.Unlock()
lease := processLeases.files[path]
if lease == nil {
return
}
lease.holders--
if lease.holders > 0 {
return
}
delete(processLeases.files, path)
unlockLease(lease.file)
_ = lease.file.Close()
unlockLease(file)
_ = file.Close()
}
// HoldExclusive takes sessionID's lease exclusively without waiting, which is how
-97
View File
@@ -1,97 +0,0 @@
package sessions
import (
"runtime"
"testing"
"time"
)
// collectGarbage runs the collector and gives finalizers time to run, so a file
// left only to a Store that is gone would be closed by the time it returns.
func collectGarbage() {
for i := 0; i < 5; i++ {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
}
// A lease lasts for the life of the process, not of the Store that took it.
// PrepareExec takes the calling or forked session's lease on a Store the exec
// run never touches again, so a lease that went with the Store would be dropped
// once the garbage collector closed its file, and prune could remove a session
// the run still uses.
func TestLeaseOutlivesTheStoreThatTookIt(t *testing.T) {
for _, take := range []struct {
name string
do func(store *Store) error
}{
{"create with a parent", func(store *Store) error {
_, err := store.Create(CreateInput{SessionID: "new", ParentSessionID: "parent"})
return err
}},
{"exec calling session", func(store *Store) error {
_, err := PrepareExec(PrepareExecOptions{Store: store, SessionID: "new", CallingSessionID: "parent"})
return err
}},
{"hold", func(store *Store) error {
store.Hold("parent")
return nil
}},
} {
t.Run(take.name, func(t *testing.T) {
root := t.TempDir()
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
// The Store is unreachable once this returns, as PrepareExec's is in
// an exec run.
func() {
store := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-06-01T00:00:00Z")})
if err := take.do(store); err != nil {
t.Fatalf("%s: %v", take.name, err)
}
}()
collectGarbage()
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
if err != nil {
t.Fatalf("dry run: %v", err)
}
if reason := keptReason(report, "parent"); reason != PruneKeptOpen {
t.Fatalf("parent kept for %q once the Store that took its lease was collected, want %q", reason, PruneKeptOpen)
}
})
}
}
// Stores in one process share the lease on a session, and it lasts until the
// last of them releases it.
func TestLeaseHeldByTwoStoresLastsUntilBothRelease(t *testing.T) {
root := t.TempDir()
createFinishedSession(t, root, "shared", "2026-06-01T00:00:00Z", "")
first := NewStore(StoreOptions{RootDir: root})
second := NewStore(StoreOptions{RootDir: root})
first.Hold("shared")
second.Hold("shared")
// Releasing twice from one Store gives up only that Store's hold.
first.Release("shared")
first.Release("shared")
if release, locked, err := pruneStore(root).HoldExclusive("shared"); err != nil || locked {
release()
t.Fatalf("prune took a session another Store still holds: locked=%v, %v", locked, err)
}
second.Release("shared")
release, locked, err := pruneStore(root).HoldExclusive("shared")
if err != nil || !locked {
t.Fatalf("prune could not take the session after both Stores released it: locked=%v, %v", locked, err)
}
release()
// Held again after the last release, the lease is taken afresh.
first.Hold("shared")
if release, locked, err := pruneStore(root).HoldExclusive("shared"); err != nil || locked {
release()
t.Fatalf("prune took a session held again after it was released: locked=%v, %v", locked, err)
}
first.Release("shared")
}
+3
View File
@@ -353,6 +353,9 @@ func TestCaptureAndPruneConcurrent(t *testing.T) {
// separate Store instances on the same RootDir (e.g. CLI rewind vs TUI). While
// one Store holds the session lock, another Store's AppendEvent must block.
func TestSessionFileLockSerializesAcrossStores(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("flock semantics differ on windows")
}
root := t.TempDir()
storeA := NewStore(StoreOptions{RootDir: root})
if _, err := storeA.Create(CreateInput{SessionID: "s"}); err != nil {
-35
View File
@@ -129,38 +129,3 @@ func TestUpdateModel(t *testing.T) {
t.Fatalf("persisted model: metadata=%+v err=%v", persisted, err)
}
}
func TestUpdateMode(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{})
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"role": "user"}}); err != nil {
t.Fatalf("append: %v", err)
}
before, err := store.Get(session.SessionID)
if err != nil || before == nil {
t.Fatalf("get before: %v", err)
}
updated, err := store.UpdateMode(session.SessionID, " plan ")
if err != nil {
t.Fatalf("update mode: %v", err)
}
if updated.PermissionMode != "plan" {
t.Fatalf("mode = %q, want plan", updated.PermissionMode)
}
if updated.UpdatedAt != before.UpdatedAt || updated.EventCount != before.EventCount {
t.Fatalf("mode update changed activity metadata: before=%+v after=%+v", before, updated)
}
persisted, err := store.Get(session.SessionID)
if err != nil || persisted == nil || persisted.PermissionMode != "plan" {
t.Fatalf("persisted mode: metadata=%+v err=%v", persisted, err)
}
cleared, err := store.UpdateMode(session.SessionID, "")
if err != nil || cleared.PermissionMode != "" {
t.Fatalf("clearing mode: metadata=%+v err=%v", cleared, err)
}
}
+2 -33
View File
@@ -97,7 +97,6 @@ type Metadata struct {
Title string `json:"title,omitempty"`
Cwd string `json:"cwd,omitempty"`
ModelID string `json:"modelId,omitempty"`
PermissionMode string `json:"permissionMode,omitempty"`
Provider string `json:"provider,omitempty"`
Tag string `json:"tag,omitempty"`
Depth int `json:"depth,omitempty"`
@@ -235,10 +234,9 @@ type Store struct {
sessionLocks map[string]*sync.Mutex
idCounter atomic.Uint64
// leases are the sessions this Store holds open, with the path of each
// lease file. The files themselves belong to the process. See Hold.
// leases are the sessions this Store holds open. See Hold.
leasesMu sync.Mutex
leases map[string]string
leases map[string]*os.File
}
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
@@ -830,35 +828,6 @@ func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, err
return session, nil
}
// UpdateMode replaces a session's permission mode without changing its activity
// timestamp or event counters. An empty mode clears the session override; the
// stored value is not validated here because the reader that turns it back into
// a live mode is the boundary that decides what a stored string may become.
func (store *Store) UpdateMode(sessionID string, mode string) (Metadata, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
mode = strings.TrimSpace(mode)
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, err
}
if session.PermissionMode == mode {
return session, nil
}
session.PermissionMode = mode
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
return session, nil
}
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
events, _, err := store.ReadEventsWithPresence(sessionID)
return events, err
-96
View File
@@ -1,96 +0,0 @@
package testutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// setenv is os.Setenv, swappable so tests can fail a redirect part way through.
var setenv = os.Setenv
// userDirEnv lists every variable that os.UserHomeDir, os.UserConfigDir and
// os.UserCacheDir (and Zero's own config.UserConfigDir) read, across Linux,
// macOS and Windows. Setting only the XDG ones still leaves macOS resolving
// from HOME and Windows from USERPROFILE, APPDATA and LOCALAPPDATA.
var userDirEnv = []string{
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
}
// IsolateUserDirs points every per-user directory root at root so code under
// test cannot read the developer's real config, cache or home directory, or
// write to them. It is meant for a package's TestMain, which has no *testing.T
// (tests that need their own layout still use t.Setenv, which wins while it
// is active). The returned function restores the previous environment.
//
// It fails closed: if any variable cannot be redirected, the variables it had
// already changed are restored and an error is returned, so the caller must
// not run the tests against a partially isolated environment.
func IsolateUserDirs(root string) (restore func() error, err error) {
type saved struct {
name string
value string
set bool
}
previous := make([]saved, 0, len(userDirEnv))
restore = func() error {
var errs []error
for _, prior := range previous {
var err error
if prior.set {
err = os.Setenv(prior.name, prior.value)
} else {
err = os.Unsetenv(prior.name)
}
if err != nil {
errs = append(errs, fmt.Errorf("restore %s: %w", prior.name, err))
}
}
return errors.Join(errs...)
}
for _, name := range userDirEnv {
value, set := os.LookupEnv(name)
if err := setenv(name, root); err != nil {
setErr := fmt.Errorf("isolate %s: %w", name, err)
return nil, errors.Join(setErr, restore())
}
previous = append(previous, saved{name: name, value: value, set: set})
}
return restore, nil
}
// PathWithin reports whether path is root or lies beneath it, comparing the
// physical forms so symlinked temp dirs (/var vs /private/var on macOS) and
// Windows short names do not cause false results. Components that do not
// exist yet are kept as spelled under their nearest existing ancestor.
func PathWithin(root, path string) bool {
rel, err := filepath.Rel(physicalPath(root), physicalPath(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func physicalPath(path string) string {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
var missing []string
for current := path; ; {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
return filepath.Join(append([]string{resolved}, missing...)...)
}
parent := filepath.Dir(current)
if parent == current {
return path
}
missing = append([]string{filepath.Base(current)}, missing...)
current = parent
}
}
-141
View File
@@ -1,141 +0,0 @@
package testutil
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsolateUserDirsRedirectsAndRestores(t *testing.T) {
// Seed distinctive values so restoring is observable, then make sure the
// helper hands them back (set ones restored, unset ones unset again).
t.Setenv("HOME", "before-home")
t.Setenv("XDG_STATE_HOME", "before-state")
// t.Setenv registers restoration of the inherited value before it is
// cleared, so later tests in this process still see it.
t.Setenv("XDG_DATA_HOME", "")
os.Unsetenv("XDG_DATA_HOME")
root := t.TempDir()
restore, err := IsolateUserDirs(root)
if err != nil {
t.Fatal(err)
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
config, err := os.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": config, "cache": cache} {
if !PathWithin(root, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, root)
}
}
if err := restore(); err != nil {
t.Fatal(err)
}
if got := os.Getenv("HOME"); got != "before-home" {
t.Errorf("HOME after restore = %q, want before-home", got)
}
if got := os.Getenv("XDG_STATE_HOME"); got != "before-state" {
t.Errorf("XDG_STATE_HOME after restore = %q, want before-state", got)
}
if _, set := os.LookupEnv("XDG_DATA_HOME"); set {
t.Error("XDG_DATA_HOME was unset before and must be unset again after restore")
}
}
func TestIsolateUserDirsFailsClosed(t *testing.T) {
for _, name := range userDirEnv {
t.Setenv(name, "before-"+strings.ToLower(name))
}
// A NUL byte is rejected by Setenv on every supported platform, so the
// very first redirect fails.
restore, err := IsolateUserDirs("bad\x00root")
if err == nil {
if restore != nil {
_ = restore()
}
t.Fatal("IsolateUserDirs succeeded with a root Setenv must reject")
}
if restore != nil {
t.Error("IsolateUserDirs returned a restore func alongside an error")
}
for _, name := range userDirEnv {
if got, want := os.Getenv(name), "before-"+strings.ToLower(name); got != want {
t.Errorf("%s after failed isolation = %q, want %q", name, got, want)
}
}
}
func TestIsolateUserDirsRollsBackPartialRedirect(t *testing.T) {
for _, name := range userDirEnv {
t.Setenv(name, "before-"+strings.ToLower(name))
}
// Let the first redirect (HOME) succeed, then fail the second, so the
// rollback has to undo a variable that really was changed.
failing := userDirEnv[1]
t.Cleanup(func() { setenv = os.Setenv })
setenv = func(name, value string) error {
if name == failing {
return errors.New("injected setenv failure")
}
return os.Setenv(name, value)
}
restore, err := IsolateUserDirs(t.TempDir())
if err == nil {
if restore != nil {
_ = restore()
}
t.Fatalf("IsolateUserDirs succeeded although setting %s failed", failing)
}
if !strings.Contains(err.Error(), failing) {
t.Errorf("error %q does not name the failing variable %s", err, failing)
}
for _, name := range userDirEnv {
if got, want := os.Getenv(name), "before-"+strings.ToLower(name); got != want {
t.Errorf("%s after partial isolation = %q, want %q", name, got, want)
}
}
}
func TestPathWithinComparesPhysicalPaths(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "root")
if err := os.Mkdir(root, 0o755); err != nil {
t.Fatal(err)
}
alias := filepath.Join(base, "alias")
if err := os.Symlink(root, alias); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for _, tc := range []struct {
root, path string
want bool
}{
{root, root, true},
{root, filepath.Join(alias, "not", "created", "yet"), true},
{alias, filepath.Join(root, "child"), true},
{root, base, false},
{alias, filepath.Join(base, "sibling"), false},
} {
if got := PathWithin(tc.root, tc.path); got != tc.want {
t.Errorf("PathWithin(%q, %q) = %v, want %v", tc.root, tc.path, got, tc.want)
}
}
}
+5 -19
View File
@@ -373,34 +373,20 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
if err != nil {
t.Fatalf("foreground server should return session_id, meta=%#v output=%q", start.Meta, start.Output)
}
addr := parseListeningAddress(start.Output)
if addr == "" {
t.Fatalf("server output did not include listening address: %q", start.Output)
}
t.Cleanup(func() {
writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "\u0003",
})
})
// Process start-up and net.Listen can outlast the first yield on a loaded
// runner, so keep polling the session until the address shows up.
output := start.Output
addr := parseListeningAddress(output)
for deadline := time.Now().Add(20 * time.Second); addr == "" && time.Now().Before(deadline); {
poll := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"yield_time_ms": 250,
})
if poll.Status != StatusOK {
t.Fatalf("write_stdin poll status = %s: %s", poll.Status, poll.Output)
}
output += "\n" + poll.Output
addr = parseListeningAddress(output)
}
if addr == "" {
t.Fatalf("server output did not include listening address: %q", output)
}
response, err := http.Get("http://" + addr)
if err != nil {
t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, output)
t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, start.Output)
}
defer response.Body.Close()
bytes, err := io.ReadAll(response.Body)
-63
View File
@@ -1,63 +0,0 @@
package tui
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/testutil"
)
// Guards TestMain: the per-user directories every newModel reads (and the ones
// tests could write to) must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
if isolatedUserDirsRoot == "" {
t.Fatal("TestMain did not isolate the user directories")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
}
}
}
// newModel loads slash commands from <user config>/zero/commands. A fresh model
// must see none, and must see exactly what is planted in the isolated dir.
func TestNewModelLoadsUserCommandsOnlyFromIsolatedConfig(t *testing.T) {
if got := len(newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands); got != 0 {
t.Fatalf("fresh model loaded %d user command(s) from outside the test sandbox", got)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(userConfig, "zero", "commands")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "hermetic-probe.md")
if err := os.WriteFile(path, []byte("probe"), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(path) })
commands := newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands
if len(commands) != 1 || commands[0].Name != "hermetic-probe" {
t.Fatalf("user commands = %#v, want only the planted hermetic-probe", commands)
}
}
-40
View File
@@ -1,40 +0,0 @@
package tui
import (
"fmt"
"os"
"testing"
"github.com/Gitlawb/zero/internal/testutil"
)
// isolatedUserDirsRoot is where TestMain points every per-user directory.
var isolatedUserDirsRoot string
// TestMain keeps the whole package off the developer's real home, config and
// cache directories. newModel loads the user's slash commands from the config
// dir, so without this every test sees (and could write to) real user state.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "zero-tui-test-home-")
if err != nil {
fmt.Fprintln(os.Stderr, "tui tests: create isolated user dirs:", err)
os.Exit(1)
}
isolatedUserDirsRoot = root
restore, err := testutil.IsolateUserDirs(root)
if err != nil {
fmt.Fprintln(os.Stderr, "tui tests: isolate user dirs:", err)
_ = os.RemoveAll(root)
os.Exit(1)
}
code := m.Run()
if err := restore(); err != nil {
fmt.Fprintln(os.Stderr, "tui tests: restore user dirs:", err)
code = 1
}
if err := os.RemoveAll(root); err != nil {
fmt.Fprintln(os.Stderr, "tui tests: remove isolated user dirs:", err)
code = 1
}
os.Exit(code)
}
+6 -14
View File
@@ -860,11 +860,6 @@ type permissionRequestMsg struct {
type pendingPermissionPrompt struct {
request agent.PermissionRequest
decide func(agent.PermissionDecision)
// decideCmd is the Update-safe alternative to decide for prompts the TUI
// itself raises: decide forwards through runtimeMessageSink, which blocks on
// the program's unbuffered message channel when called from Update, so these
// prompts return a command that yields the follow-up message instead.
decideCmd func(agent.PermissionDecision) tea.Cmd
// cursor is the highlighted option index (into permissionOptions): 0 is the
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
// Hotkeys resolve the matching request-provided option directly.
@@ -4526,13 +4521,11 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
return m, nil
}
resolved := agent.PermissionDecision{Action: decision, Reason: reason}
if pending.decide != nil {
pending.decide(resolved)
}
var decideCmd tea.Cmd
if pending.decideCmd != nil {
decideCmd = pending.decideCmd(resolved)
pending.decide(agent.PermissionDecision{
Action: decision,
Reason: reason,
})
}
m.pendingPermission = nil
// Time spent at the prompt is user wait, not provider silence. Restart the
@@ -4542,10 +4535,9 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
if pending.request.ToolName == peerPermissionToolName {
// Receipt delivery completes asynchronously. That completion advances
// the peer queue after this prompt is fully settled.
return m, decideCmd
return m, nil
}
next, cmd := m.openNextPeerApproval()
return next, tea.Batch(decideCmd, cmd)
return m.openNextPeerApproval()
}
func permissionDecisionReason(decision permissionDecision) string {
+4 -5
View File
@@ -119,11 +119,10 @@ func (m model) openNextPeerApproval() (model, tea.Cmd) {
}
m.pendingPermission = &pendingPermissionPrompt{
request: request,
// Resolved from Update, so the decision must come back as a command:
// runtimeMessageSink blocks on the event loop that is running Update.
decideCmd: func(decision agent.PermissionDecision) tea.Cmd {
decided := peerDecisionMsg{message: message, allow: decision.Action == agent.PermissionDecisionAllow}
return func() tea.Msg { return decided }
decide: func(decision agent.PermissionDecision) {
if m.runtimeMessageSink != nil {
m.runtimeMessageSink(peerDecisionMsg{message: message, allow: decision.Action == agent.PermissionDecisionAllow})
}
},
}
return m, nil
+12 -73
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -126,82 +125,19 @@ func TestPermissionMismatchHoldsPeerMessageForExplicitDecision(t *testing.T) {
}
}
approvedModel, approveCmd := next.resolvePermission(permissionDecisionAllow)
approvedModel, _ := next.resolvePermission(permissionDecisionAllow)
approved := approvedModel.(model)
if approved.pendingPermission != nil {
t.Fatal("approval prompt did not close")
}
decision, ok := peerDecisionFromCmd(approveCmd)
if !ok || !decision.allow || decision.message.ID != "held-1" {
t.Fatalf("approval did not return a peer decision command: %#v", decision)
}
select {
case raw := <-messages:
t.Fatalf("approval sent %#v through the runtime sink from Update", raw)
default:
}
}
// peerDecisionFromCmd runs cmd (flattening tea.Batch) and returns the first
// peerDecisionMsg it produces.
func peerDecisionFromCmd(cmd tea.Cmd) (peerDecisionMsg, bool) {
if cmd == nil {
return peerDecisionMsg{}, false
}
switch msg := cmd().(type) {
case peerDecisionMsg:
return msg, true
case tea.BatchMsg:
for _, inner := range msg {
if decision, ok := peerDecisionFromCmd(inner); ok {
return decision, true
}
decision, ok := raw.(peerDecisionMsg)
if !ok || !decision.allow || decision.message.ID != "held-1" {
t.Fatalf("decision = %#v", raw)
}
}
return peerDecisionMsg{}, false
}
// The runtime sink forwards to program.Send, which blocks on the unbuffered
// message channel the event loop reads between Update calls. Resolving a held
// peer prompt must therefore never call the sink from Update.
func TestPeerApprovalDecisionDoesNotCallBlockingSinkFromUpdate(t *testing.T) {
for _, tc := range []struct {
name string
decision permissionDecision
allow bool
}{
{"allow", permissionDecisionAllow, true},
{"deny", permissionDecisionDeny, false},
} {
t.Run(tc.name, func(t *testing.T) {
release := make(chan struct{})
t.Cleanup(func() { close(release) })
m := newModel(context.Background(), Options{
PermissionMode: agent.PermissionModeAsk,
RuntimeMessageSink: func(tea.Msg) { <-release },
})
m, _ = m.handlePeerMessage(peermsg.InboundMessage{
ID: "held-blocking", From: peermsg.Peer{Ref: "11223344"}, Body: "hold me", RequiresApproval: true,
})
type result struct {
cmd tea.Cmd
}
done := make(chan result, 1)
go func() {
_, cmd := m.resolvePermission(tc.decision)
done <- result{cmd: cmd}
}()
select {
case res := <-done:
decision, ok := peerDecisionFromCmd(res.cmd)
if !ok || decision.allow != tc.allow || decision.message.ID != "held-blocking" {
t.Fatalf("decision = %#v ok=%v", decision, ok)
}
case <-time.After(5 * time.Second):
t.Fatal("resolving a held peer prompt blocked on the runtime sink")
}
})
default:
t.Fatal("approval did not enqueue a peer decision")
}
}
@@ -234,7 +170,7 @@ func TestPeerApprovalDecisionWaitsForCompletionBeforeOpeningNext(t *testing.T) {
second := peermsg.InboundMessage{ID: "second", From: peermsg.Peer{Ref: "22222222"}, Body: "second", RequiresApproval: true}
m, _ = m.handlePeerMessage(first)
m, _ = m.handlePeerMessage(second)
resolvedModel, resolveCmd := m.resolvePermission(permissionDecisionDeny)
resolvedModel, _ := m.resolvePermission(permissionDecisionDeny)
resolved := resolvedModel.(model)
if resolved.pendingPermission != nil {
t.Fatal("next approval opened before peer decision completed")
@@ -242,8 +178,11 @@ func TestPeerApprovalDecisionWaitsForCompletionBeforeOpeningNext(t *testing.T) {
if len(resolved.peerApprovalQueue) != 1 {
t.Fatalf("queue = %#v", resolved.peerApprovalQueue)
}
decision, ok := peerDecisionFromCmd(resolveCmd)
if !ok {
var decision peerDecisionMsg
select {
case raw := <-messages:
decision = raw.(peerDecisionMsg)
default:
t.Fatal("peer decision was not emitted")
}
next, _ := resolved.handlePeerDecision(decision.message, decision.allow)
+5 -12
View File
@@ -53,6 +53,11 @@ 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)
@@ -61,18 +66,6 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
executablePath = resolved
}
method := DetectInstallMethod(executablePath)
if method == InstallMethodNpmLegacy {
// Nothing newer is ever published under the old name, so npm would
// reinstall this same build and the update would report success. Refused
// before the release check because the answer does not depend on it, and
// it has to reach someone who is offline too.
return ApplyResult{}, errors.New(legacyPackageSwitchMessage())
}
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
// Only the standalone path rewrites the binary in place, so only it needs the
// recovery state. npm and Homebrew hand the work to the package manager.
if method == InstallMethodStandalone {
-47
View File
@@ -433,50 +433,3 @@ 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")
}
}
+18 -68
View File
@@ -9,28 +9,17 @@ import (
)
// npmPackageName is the published package name for the npm distribution of
// 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"
// 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"
// InstallMethod identifies how the running zero binary was installed.
type InstallMethod string
const (
InstallMethodNpm InstallMethod = "npm"
// InstallMethodNpmLegacy is an npm install of legacyNpmPackageName.
InstallMethodNpmLegacy InstallMethod = "npm-legacy"
InstallMethodNpm InstallMethod = "npm"
InstallMethodHomebrew InstallMethod = "homebrew"
InstallMethodStandalone InstallMethod = "standalone"
)
@@ -64,71 +53,32 @@ 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
}
if !hasManifest {
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 {
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 len(pkg.OS) != 1 || len(pkg.CPU) != 1 || len(pkg.Bin) != 0 {
return InstallMethodStandalone
}
switch pkg.Name {
case npmPackageName:
if pkg.Name == npmPackageName && len(pkg.OS) == 1 && len(pkg.CPU) == 1 && len(pkg.Bin) == 0 {
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
+2 -76
View File
@@ -1,31 +1,11 @@
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")
@@ -57,7 +37,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":"@twigpine/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":"@gitlawb/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 {
@@ -71,7 +51,7 @@ func TestDetectInstallMethodDoesNotTreatRepositoryManifestAsNpmBinary(t *testing
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@twigpine/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
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)
}
@@ -93,57 +73,3 @@ 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)
}
}
+2 -11
View File
@@ -15,7 +15,7 @@ import (
)
const (
DefaultRepository = "Twigpine/zero"
DefaultRepository = "Gitlawb/zero"
DefaultTimeout = 5 * time.Second
)
@@ -233,10 +233,6 @@ 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")
}
@@ -326,11 +322,6 @@ 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."
@@ -466,7 +457,7 @@ func releasePlatform(goos string) (string, error) {
// self-updating release asset. Point users at `npm update` rather
// than a source rebuild, since that's the documented Termux
// install/upgrade path and doesn't require a Go toolchain.
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g %s` to get the latest.", goos, npmPackageName) //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 @gitlawb/zero` to get the latest.", goos) //nolint:staticcheck // Preserve established user-facing error text.
}
}
+1 -25
View File
@@ -587,35 +587,11 @@ 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 @twigpine/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 @gitlawb/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 {
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@twigpine/zero",
"version": "0.10.0",
"name": "@gitlawb/zero",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@twigpine/zero",
"version": "0.10.0",
"name": "@gitlawb/zero",
"version": "0.9.0",
"cpu": [
"x64",
"arm64"
@@ -709,9 +709,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"license": "MIT",
"peer": true,
"engines": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@twigpine/zero",
"version": "0.10.0",
"name": "@gitlawb/zero",
"version": "0.9.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",
@@ -30,11 +30,11 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/Twigpine/zero.git"
"url": "git+https://github.com/Gitlawb/zero.git"
},
"homepage": "https://zero.twigpine.com",
"homepage": "https://github.com/Gitlawb/zero#readme",
"bugs": {
"url": "https://github.com/Twigpine/zero/issues"
"url": "https://github.com/Gitlawb/zero/issues"
},
"license": "MIT"
}
+1 -1
View File
@@ -5,7 +5,7 @@
"bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a",
"packages": {
".": {
"package-name": "@twigpine/zero"
"package-name": "@gitlawb/zero"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[CmdletBinding()]
param(
[string]$Version = $env:ZERO_VERSION,
[string]$Repository = $(if ($env:ZERO_REPO) { $env:ZERO_REPO } else { "Twigpine/zero" }),
[string]$Repository = $(if ($env:ZERO_REPO) { $env:ZERO_REPO } else { "Gitlawb/zero" }),
[string]$InstallDir = $env:ZERO_INSTALL_DIR,
[string]$GitHubApi = $(if ($env:ZERO_GITHUB_API) { $env:ZERO_GITHUB_API } else { "https://api.github.com" }),
[string]$GitHubBaseUrl = $(if ($env:ZERO_GITHUB_BASE_URL) { $env:ZERO_GITHUB_BASE_URL } else { "https://github.com" })
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"
ZERO_REPO="${ZERO_REPO:-Gitlawb/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 Twigpine/zero.
ZERO_REPO GitHub repository. Defaults to Gitlawb/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.
+20 -43
View File
@@ -2,22 +2,21 @@
// 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), where <name> is the
// package.json name, or --package-name when given:
// wrapper/ <name>@X.Y.Z — bin/zero.js, the
// Layout written under --out-dir (default dist/npm):
// wrapper/ @gitlawb/[email protected] — bin/zero.js, the
// first-run fallback downloader, and a
// package.json with NO scripts and NO
// dependencies, only optionalDependencies
// aliasing the platform versions below.
// platforms/zero-<platform>-<arch>/
// <name>@X.Y.Z-<platform>-<arch> —
// @gitlawb/zero@X.Y.Z-<platform>-<arch> —
// the zero binary, sandbox helpers, and the
// vendored local-control helpers/ tree,
// copied straight out of the release archive.
//
// The platform "packages" are versions of the SAME 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 @gitlawb/zero package at
// suffixed (semver-prerelease) versions, referenced from the wrapper through
// npm: aliases. One package name keeps a single npm trusted-publisher config.
// See docs/NPM_PACKAGING.md for the publishing rules this feeds.
//
// Archives are verified against their .sha256 files before extraction. All
@@ -25,15 +24,9 @@
// 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,...] \
// [--package-name @scope/name]
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...]
import {
chmodSync,
@@ -78,7 +71,7 @@ function fail(message) {
}
function parseArgs(argv) {
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null, packageName: '' };
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
@@ -96,14 +89,6 @@ 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}`);
}
@@ -114,7 +99,6 @@ 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;
}
@@ -123,17 +107,14 @@ function platformVersion(version, entry) {
return `${version}-${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 aliasName(entry) {
return `@gitlawb/zero-${entry.platform}-${entry.arch}`;
}
function optionalDependencies(packageName, version) {
function optionalDependencies(version) {
const aliases = {};
for (const entry of MATRIX) {
aliases[aliasName(packageName, entry)] = `npm:${packageName}@${platformVersion(version, entry)}`;
aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`;
}
return aliases;
}
@@ -279,7 +260,7 @@ function verifyHelperShims(packageDir, entry, assetName) {
}
}
function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir) {
function buildPlatformPackage(entry, version, artifactsDir, outDir) {
const assetName = `zero-v${version}-${entry.release}.${entry.ext}`;
const archivePath = join(artifactsDir, assetName);
if (!existsSync(archivePath)) fail(`missing release archive ${archivePath}`);
@@ -314,9 +295,9 @@ function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir)
const wrapperPkg = readWrapperPackage();
const manifest = {
name: packageName,
name: '@gitlawb/zero',
version: platformVersion(version, entry),
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via ${packageName}@${version})`,
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`,
os: [entry.platform],
cpu: [entry.arch],
license: wrapperPkg.license,
@@ -331,7 +312,7 @@ function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir)
}
}
function buildWrapperPackage(packageName, version, outDir) {
function buildWrapperPackage(version, outDir) {
const wrapperDir = join(outDir, 'wrapper');
rmSync(wrapperDir, { recursive: true, force: true });
mkdirSync(join(wrapperDir, 'bin'), { recursive: true });
@@ -352,17 +333,14 @@ function buildWrapperPackage(packageName, version, outDir) {
// stages into the platform payloads — see internal/release/release.go).
delete pkg.scripts;
delete pkg.dependencies;
pkg.name = packageName;
pkg.optionalDependencies = optionalDependencies(packageName, version);
pkg.optionalDependencies = optionalDependencies(version);
writeFileSync(join(wrapperDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
return wrapperDir;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const wrapperPkg = readWrapperPackage();
const packageName = args.packageName || wrapperPkg.name;
const { version } = wrapperPkg;
const version = readWrapperPackage().version;
const selected = MATRIX.filter(
(entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`),
@@ -378,14 +356,13 @@ function main() {
const built = [];
for (const entry of selected) {
built.push(buildPlatformPackage(entry, packageName, version, args.artifactsDir, args.outDir));
built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir));
}
const wrapperDir = buildWrapperPackage(packageName, version, args.outDir);
const wrapperDir = buildWrapperPackage(version, args.outDir);
process.stdout.write(
JSON.stringify(
{
name: packageName,
version,
wrapperDir,
platformDirs: built,
-137
View File
@@ -1,137 +0,0 @@
#!/usr/bin/env bash
# publish-packages.sh: publish the npm payloads that build-platform-packages.mjs
# assembled under <npm-dir> (wrapper/ plus platforms/*/), in the order and
# with the dist-tags docs/NPM_PACKAGING.md requires:
#
# 1. every platform payload, under the `platform` dist-tag, never `latest`;
# 2. check that `latest` was not moved onto a platform version;
# 3. the wrapper, which takes `latest`, then confirm `latest` is its version.
#
# The package name comes from the assembled wrapper's package.json, so the
# same script publishes whatever name the payloads were built under.
#
# A version that is already on the registry is skipped, which makes
# re-running a release job that failed halfway safe: the platform payloads
# that went out are not published twice, and the wrapper still follows them.
# Publishes pass --ignore-scripts because the payloads are already assembled
# and verified; nothing should run at pack time.
#
# Usage: scripts/npm/publish-packages.sh <npm-dir>
#
# Auth comes from the environment (npm OIDC trusted publishing in CI).
# ZERO_NPM_PUBLISH_RETRY_DELAY overrides the seconds between dist-tag lookups.
set -euo pipefail
fail() {
echo "::error::$*" >&2
exit 1
}
npm_dir="${1:-}"
[ -n "$npm_dir" ] || fail "usage: publish-packages.sh <npm-dir>"
wrapper_dir="${npm_dir%/}/wrapper"
[ -f "${wrapper_dir}/package.json" ] || fail "${wrapper_dir}/package.json not found; run build-platform-packages.mjs first"
manifest_field() {
node -e 'const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))[process.argv[2]]; process.stdout.write(value === undefined ? "" : String(value));' "$1/package.json" "$2"
}
name="$(manifest_field "$wrapper_dir" name)"
version="$(manifest_field "$wrapper_dir" version)"
[ -n "$name" ] && [ -n "$version" ] || fail "${wrapper_dir}/package.json has no name or version"
delay="${ZERO_NPM_PUBLISH_RETRY_DELAY:-5}"
# The wrapper pins every platform version exactly, so a wrapper whose aliases
# do not match the payloads beside it would install aliases that 404. Check
# before anything is published.
shopt -s nullglob
platform_dirs=("${npm_dir%/}"/platforms/*/)
shopt -u nullglob
[ "${#platform_dirs[@]}" -gt 0 ] || fail "no platform payloads under ${npm_dir%/}/platforms"
expected_platforms="$(node -e 'const pkg = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(Object.values(pkg.optionalDependencies || {}).sort().join("\n"));' "${wrapper_dir}/package.json")"
actual_platforms=""
for dir in "${platform_dirs[@]}"; do
dir="${dir%/}"
platform_name="$(manifest_field "$dir" name)"
platform_version="$(manifest_field "$dir" version)"
[ "$platform_name" = "$name" ] || fail "${dir} is ${platform_name}, not ${name}"
case "$platform_version" in
"${version}-"*) ;;
*) fail "${dir} version ${platform_version} is not a platform version of ${name}@${version}" ;;
esac
actual_platforms="${actual_platforms}npm:${name}@${platform_version}"$'\n'
done
actual_platforms="$(printf '%s' "$actual_platforms" | sort)"
if [ "$actual_platforms" != "$expected_platforms" ]; then
fail "the wrapper's optionalDependencies do not match the platform payloads under ${npm_dir%/}/platforms (wrapper: $(echo "$expected_platforms" | tr '\n' ' '); payloads: $(echo "$actual_platforms" | tr '\n' ' '))"
fi
# npm reports every kind of "not on the registry" (no such package, no such
# version) as E404. Any other lookup failure (network, auth, a registry
# outage) is retried and then fails the job: reading it as "not published"
# would publish a version that may already be out, and npm would reject that
# with EPUBLISHCONFLICT on the very re-run this skip exists for.
already_published() {
local output status errors
errors="$(mktemp)"
for _ in 1 2 3; do
status=0
output="$(npm view "${name}@$1" version 2>"$errors")" || status=$?
if [ "$status" -eq 0 ]; then
rm -f "$errors"
[ "$output" = "$1" ]
return
fi
if grep -q 'code E404' "$errors"; then
rm -f "$errors"
return 1
fi
sleep "$delay"
done
cat "$errors" >&2
rm -f "$errors"
fail "could not look up ${name}@$1 on the registry, so cannot tell whether it is already published"
}
# publish <dir> <version> [npm publish flags...]
publish() {
local dir="$1" package_version="$2"
shift 2
if already_published "$package_version"; then
echo "${name}@${package_version} is already on the registry; not publishing it again."
return 0
fi
echo "publishing ${name}@${package_version} from ${dir}"
(cd "$dir" && npm publish --access public --ignore-scripts "$@")
}
for dir in "${platform_dirs[@]}"; do
dir="${dir%/}"
publish "$dir" "$(manifest_field "$dir" version)" --tag platform
done
# Platform versions are semver prereleases of the wrapper version, so a
# platform publish that took `latest` would make a plain install deliver a
# payload with no bin. Stop before the wrapper goes out. A failed lookup is
# tolerated (registry lag, or no `latest` yet on a package's first release):
# failing here would strand the platform payloads without their wrapper.
latest="$(npm view "$name" dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
fail "dist-tag latest points at platform version ${latest}; a platform publish clobbered it. Fix the dist-tag before publishing the wrapper."
;;
esac
echo "dist-tag latest is ${latest:-unset} (unchanged by the platform publishes)."
publish "$wrapper_dir" "$version"
# Registry metadata can lag a publish by a few seconds.
for _ in 1 2 3 4 5; do
latest="$(npm view "$name" dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep "$delay"
done
fail "dist-tag latest of ${name} is ${latest:-unset}, expected ${version} after the wrapper publish."
+2 -2
View File
@@ -16,7 +16,7 @@
// ZERO_INSTALL_DRY_RUN=1 print the resolved plan as JSON, no network, exit 0
// ZERO_INSTALL_PLATFORM=… override process.platform (linux|darwin|win32|android)
// ZERO_INSTALL_ARCH=… override process.arch (x64|arm64)
// ZERO_REPO=owner/name override the GitHub repo (default Twigpine/zero)
// ZERO_REPO=owner/name override the GitHub repo (default Gitlawb/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 || 'Twigpine/zero';
const REPO = process.env.ZERO_REPO || 'Gitlawb/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