mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b549f5928f |
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
{".":"0.10.0"}
|
||||
{".":"0.9.0"}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
(¬ifier{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)
|
||||
(¬ifier{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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+7
-7
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a",
|
||||
"packages": {
|
||||
".": {
|
||||
"package-name": "@twigpine/zero"
|
||||
"package-name": "@gitlawb/zero"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user