mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -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,13 +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`). npm applies publishes asynchronously, so the script waits
|
||||
# until npm shows every platform version before the wrapper goes out,
|
||||
# and until `latest` moves after it. 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
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
for dir in dist/npm/platforms/*/; do
|
||||
echo "publishing ${dir}"
|
||||
(cd "$dir" && npm publish --access public --tag platform)
|
||||
done
|
||||
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
|
||||
# bootstrap publish): under set -e a bare failure here would abort
|
||||
# AFTER the platform payloads went out, leaving no wrapper and a
|
||||
# re-run that fails on republishing existing versions. An empty
|
||||
# value falls through the guard below.
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
case "$latest" in
|
||||
"${version}-"*)
|
||||
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
|
||||
|
||||
- name: Publish wrapper to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
(cd dist/npm/wrapper && npm publish --access public)
|
||||
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
|
||||
# a publish by a few seconds).
|
||||
for attempt in 1 2 3 4 5; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
if [ "$latest" = "$version" ]; then
|
||||
echo "dist-tag latest is ${version}."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
|
||||
exit 1
|
||||
@@ -1 +1 @@
|
||||
{".":"0.11.0"}
|
||||
{".":"0.9.0"}
|
||||
@@ -6,70 +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.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **providers:** add Concentrate provider preset ([#1147](https://github.com/Twigpine/zero/issues/1147)) ([61b0936](https://github.com/Twigpine/zero/commit/61b09364f0f45b0fa8aebaf0a6c3d1c55dbe01c3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
|
||||
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
|
||||
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
|
||||
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
|
||||
|
||||
## [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
|
||||
```
|
||||
@@ -216,7 +204,6 @@ export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export CONCENTRATE_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -245,12 +232,6 @@ To configure GMI Cloud directly, run:
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
To configure Concentrate directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup concentrate --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -545,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
-25
@@ -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
|
||||
```
|
||||
@@ -186,7 +177,6 @@ export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export CONCENTRATE_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
@@ -201,12 +191,6 @@ zero providers setup longcat --set-active
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
要直接配置 Concentrate,运行:
|
||||
|
||||
```bash
|
||||
zero providers setup concentrate --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
@@ -374,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`.
|
||||
|
||||
+18
-77
@@ -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
|
||||
@@ -130,67 +118,20 @@ registry.
|
||||
bootstrap publish) — a missing value must not abort the job between the
|
||||
platform and wrapper publishes, which would strand a half-published
|
||||
release.
|
||||
2. **Platform versions are on the registry before the wrapper.** The
|
||||
wrapper's `optionalDependencies` pin exact suffixed versions; publishing
|
||||
the wrapper first would create a window where installs resolve aliases
|
||||
that 404. Ordering the uploads is not enough: npm applies a publish
|
||||
asynchronously, and in 0.10.0 a platform version appeared ten minutes
|
||||
after its upload returned, after the wrapper. The publish script waits
|
||||
until npm shows every platform version before it publishes the wrapper,
|
||||
and waits the same way for `latest` afterwards (up to 30 minutes in all).
|
||||
2. **Platform versions publish before the wrapper.** The wrapper's
|
||||
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
|
||||
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 would not have reached 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 was published once more under the old name, as a
|
||||
bridge:
|
||||
|
||||
- The release job assembled the same verified artifacts a second time with
|
||||
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
|
||||
them with the same script, after the `@twigpine/zero` publish. The steps
|
||||
that did this ran once and have been removed from `release-artifacts.yml`.
|
||||
- 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. This handling stays in the code, since only a build
|
||||
installed under the old name ever triggers it.
|
||||
- Updating from 0.9.0 installs that bridge build, so npm also shows the
|
||||
deprecation message.
|
||||
|
||||
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
|
||||
publisher, so this release workflow cannot publish it again; another version
|
||||
would take a maintainer publishing by hand. Its versions stay on the
|
||||
registry: existing installs resolve their platform payload through them, so
|
||||
they must never be unpublished.
|
||||
`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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
@@ -145,7 +144,7 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode
|
||||
}
|
||||
@@ -218,45 +217,12 @@ func runModels(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
func resolveCommandCenterConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
|
||||
return resolveCommandCenterConfigFor(stderr, deps, false)
|
||||
}
|
||||
|
||||
// resolveProviderManagementConfig resolves for the `providers` subcommands that
|
||||
// only need the provider LIST (`list`, `current`, `use`, `remove`, `rename`).
|
||||
// Those keep working when nothing is selected (issue #1130). It is deliberately
|
||||
// NOT shared with the commands that REPORT the configuration — `zero config`
|
||||
// above all: per the resolver's headless contract, `zero config` / `zero exec`
|
||||
// still fail with the actionable resolve error, and only the interactive TUI
|
||||
// recovers from a missing active provider.
|
||||
func resolveProviderManagementConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
|
||||
return resolveCommandCenterConfigFor(stderr, deps, true)
|
||||
}
|
||||
|
||||
func resolveCommandCenterConfigFor(stderr io.Writer, deps appDeps, allowMissingActiveProvider bool) (config.ResolvedConfig, int) {
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return config.ResolvedConfig{}, writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
// A missing active provider must not stop the `providers` subcommands
|
||||
// that answer for profiles coming from the environment (OPENAI_API_KEY,
|
||||
// GEMINI_API_KEY, ...), where an env-derived profile is never marked
|
||||
// active in config.json. The resolver elects an active provider by
|
||||
// itself only when there is exactly one, so two env providers (or one
|
||||
// key plus one base URL) made every subcommand exit with `no active
|
||||
// provider configured: active provider "" not found` (issue #1130). It
|
||||
// still returns the successfully normalized list alongside the error,
|
||||
// so hand that partial config back WITHOUT electing an active provider:
|
||||
// these commands report what is configured, not what a run would use.
|
||||
// Adopting firstUsableProvider here made `providers current`/`list`
|
||||
// present a provider that `zero exec` would still refuse to run with,
|
||||
// and made a base-URL-only profile (no API key) fail `providers list`
|
||||
// because the election demands a credential. Electing a provider to RUN
|
||||
// with stays in the interactive TUI's recovery path (app.go).
|
||||
if allowMissingActiveProvider && errors.Is(err, config.ErrNoActiveProvider) {
|
||||
return resolved, exitSuccess
|
||||
}
|
||||
return config.ResolvedConfig{}, writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
return resolved, exitSuccess
|
||||
|
||||
@@ -880,40 +880,6 @@ func providerSetupDeps(configPath string) appDeps {
|
||||
}
|
||||
}
|
||||
|
||||
// providerEnvSetupDeps is providerSetupDeps plus a REAL resolver pinned to a
|
||||
// temporary user config and a fixed environment. providerSetupDeps only
|
||||
// redirects WRITES, so without this the `providers` subcommands resolve against
|
||||
// the developer's own config.json and ambient provider variables: a test that
|
||||
// exports OPENAI_API_KEY passes on a machine whose real config has an active
|
||||
// provider and fails on one that also has GEMINI_API_KEY exported (issue
|
||||
// #1130). Passing Env makes resolution fully hermetic — config.envValue prefers
|
||||
// the map over os.Getenv whenever it is non-nil.
|
||||
func providerEnvSetupDeps(t *testing.T, configPath string, env map[string]string) appDeps {
|
||||
t.Helper()
|
||||
|
||||
cwd := t.TempDir()
|
||||
deps := providerSetupDeps(configPath)
|
||||
deps.getwd = func() (string, error) { return cwd, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{
|
||||
UserConfigPath: configPath,
|
||||
Env: env,
|
||||
Overrides: overrides,
|
||||
})
|
||||
}
|
||||
// summarizeConfig reads OAuth LOGIN state through the default store, which
|
||||
// resolves the developer's own ZERO_OAUTH_TOKENS_PATH (or XDG keyring root)
|
||||
// — a machine with a stored ChatGPT login would make `providers list`
|
||||
// assertions flip. Pin the file and the backend per the coding guideline
|
||||
// that no test may read the developer's real config, cache or state dirs:
|
||||
// forcing "file" also prevents an ambient ZERO_OAUTH_STORAGE=keyring from
|
||||
// pulling the OS keychain into these tests.
|
||||
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
return deps
|
||||
}
|
||||
|
||||
func readFileConfig(t *testing.T, path string) config.FileConfig {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,7 +21,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
|
||||
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
|
||||
{id: "concentrate", name: "Concentrate", transport: "openai-compatible", defaultModel: "deepseek-v4-flash-0731"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
|
||||
@@ -542,9 +542,9 @@ func providerResolvedByName(providers []config.ProviderProfile, name string) boo
|
||||
// reports the situation plainly instead of that confusing error (issue
|
||||
// #707).
|
||||
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
// resolveProviderManagementConfig already wrote its own error to stderr;
|
||||
// resolveCommandCenterConfig already wrote its own error to stderr;
|
||||
// stop here instead of letting the caller try SetActiveProvider too.
|
||||
return exitCode, true
|
||||
}
|
||||
@@ -578,7 +578,7 @@ func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, option
|
||||
// name isn't resolvable at all, it returns handled=false so the caller falls
|
||||
// through to RemoveProvider's real "not found" error.
|
||||
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
@@ -609,7 +609,7 @@ func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, nam
|
||||
}
|
||||
|
||||
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
|
||||
@@ -189,11 +189,12 @@ func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
@@ -211,11 +212,12 @@ func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
|
||||
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
@@ -235,11 +237,12 @@ func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
@@ -250,11 +253,12 @@ func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
@@ -272,229 +276,6 @@ func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two env-derived providers and no saved active provider is the case issue
|
||||
// #1130 reports: the resolver only elects an active provider by itself when
|
||||
// there is exactly one, so `providers list/use/remove/rename` used to exit 3
|
||||
// with `active provider "" not found` before doing anything.
|
||||
func TestRunProvidersSubcommandsWithMultipleEnvDerivedProviders(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"OPENAI_API_KEY": "sk-env",
|
||||
"GEMINI_API_KEY": "gem-env",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "list", args: []string{"providers", "list"}, want: "openai"},
|
||||
{name: "list json", args: []string{"providers", "list", "--json"}, want: `"name": "openai"`},
|
||||
{name: "use", args: []string{"providers", "use", "openai"}, want: "is not saved in config.json"},
|
||||
{name: "remove", args: []string{"providers", "remove", "openai"}, want: "nothing to remove there"},
|
||||
{name: "rename", args: []string{"providers", "rename", "openai", "renamed"}, want: "no saved profile to rename"},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(tt.args, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), tt.want) {
|
||||
t.Fatalf("stdout = %q, want it to contain %q", stdout.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersListShowsEveryEnvDerivedProvider(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"OPENAI_API_KEY": "sk-env",
|
||||
"GEMINI_API_KEY": "gem-env",
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Providers []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
names := make(map[string]bool, len(payload.Providers))
|
||||
for _, provider := range payload.Providers {
|
||||
names[provider.Name] = true
|
||||
}
|
||||
for _, want := range []string{"openai", "google"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("providers = %v, want %q listed", names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The provider-management commands report what is CONFIGURED, not what a run
|
||||
// would use: with env-derived providers and no saved active provider they must
|
||||
// not present any provider as selected. Electing firstUsableProvider in
|
||||
// resolveCommandCenterConfig made `providers current` claim `openai` while
|
||||
// `zero exec` in the same environment still exited 3 with `no active provider
|
||||
// configured` — and it read the OAuth store on this path (maintainer review of
|
||||
// this PR).
|
||||
func TestRunProvidersSubcommandsElectNoActiveProvider(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"OPENAI_API_KEY": "sk-env",
|
||||
"GEMINI_API_KEY": "gem-env",
|
||||
}
|
||||
deps := func(t *testing.T) appDeps {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
return providerEnvSetupDeps(t, configPath, env)
|
||||
}
|
||||
|
||||
t.Run("current shows nothing selected", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "current"}, &stdout, &stderr, deps(t))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "(none)") {
|
||||
t.Fatalf("stdout = %q, want an empty selection", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "openai") {
|
||||
t.Fatalf("stdout = %q, want no provider elected as current", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list marks nothing active", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, deps(t))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Providers []struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.Providers) < 2 {
|
||||
t.Fatalf("providers = %+v, want both env-derived profiles listed", payload.Providers)
|
||||
}
|
||||
for _, provider := range payload.Providers {
|
||||
if provider.Active {
|
||||
t.Fatalf("provider %q marked active, want nothing active", provider.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("use json leaves activeProvider empty", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, deps(t))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ActiveProvider != "" {
|
||||
t.Fatalf("activeProvider = %q, want it empty for an unpersisted selection", payload.ActiveProvider)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A base-URL-only environment (OPENAI_BASE_URL / GEMINI_BASE_URL set, no API
|
||||
// keys) is the other half of issue #1130: electing firstUsableProvider made
|
||||
// `providers list` exit 3 because the election demands a credential. The list
|
||||
// must succeed and still mark nothing active. The resolver itself keeps only
|
||||
// the keyless openai-compatible profile here (a google profile without a
|
||||
// credential is dropped during normalization), so `openai` is the expected
|
||||
// entry — pinning that as-is keeps this test about the COMMAND, not the
|
||||
// resolver.
|
||||
func TestRunProvidersListWithBaseURLOnlyProviders(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"OPENAI_BASE_URL": "https://openai.proxy.example.com/v1",
|
||||
"GEMINI_BASE_URL": "https://gemini.proxy.example.com",
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Providers []struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
names := make(map[string]bool, len(payload.Providers))
|
||||
for _, provider := range payload.Providers {
|
||||
if provider.Active {
|
||||
t.Fatalf("provider %q marked active, want nothing active", provider.Name)
|
||||
}
|
||||
names[provider.Name] = true
|
||||
}
|
||||
if !names["openai"] {
|
||||
t.Fatalf("providers = %v, want the base-URL-only openai profile listed", names)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config` is NOT one of the provider-management commands: it reports the
|
||||
// resolved configuration, so a missing active provider has to stay actionable
|
||||
// (the headless contract the resolver documents for `zero config` / `zero
|
||||
// exec`). Only the interactive TUI recovers from that state.
|
||||
func TestRunConfigSummaryStillFailsWithNoActiveProvider(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"OPENAI_API_KEY": "sk-env",
|
||||
"GEMINI_API_KEY": "gem-env",
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("exit code = %d, want the actionable resolve error: %s", exitCode, stdout.String())
|
||||
}
|
||||
message := stderr.String()
|
||||
if message == "" {
|
||||
message = stdout.String()
|
||||
}
|
||||
if !strings.Contains(message, "no active provider configured") {
|
||||
t.Fatalf("output = %q / %q, want the actionable resolve error", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/gitsafe"
|
||||
)
|
||||
|
||||
type scratchFileBaseline map[string]bool
|
||||
@@ -80,13 +78,7 @@ func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
// gitsafe keeps the workspace's own git config from running a program, and
|
||||
// --ignore-submodules=dirty keeps git out of submodule work trees.
|
||||
command, err := gitsafe.Command(ctx, workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all", "--ignore-submodules=dirty")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
output, err := command.Output()
|
||||
output, err := exec.CommandContext(ctx, "git", "-C", workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all").Output()
|
||||
if err != nil {
|
||||
// Not a git repo (or git failed for some other reason) — nothing
|
||||
// reliable to report against, so stay silent rather than guess.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package gitsafe
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The packages whose git calls Zero makes on its own. The CLI runs git for the
|
||||
// scratch-file warning and the TUI for its files sweep and pull request
|
||||
// diffstat; each call has to come through Command.
|
||||
var automaticGitPackages = []string{
|
||||
filepath.Join("..", "cli"),
|
||||
filepath.Join("..", "tui"),
|
||||
}
|
||||
|
||||
// parsePackage parses the non-test Go files in dir.
|
||||
func parsePackage(t *testing.T, dir string) (*token.FileSet, []*ast.File) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no Go files in %s", dir)
|
||||
}
|
||||
return fset, files
|
||||
}
|
||||
|
||||
// importName is the name file uses for the package at path, or "" when the
|
||||
// file does not import it.
|
||||
func importName(file *ast.File, path string) string {
|
||||
for _, spec := range file.Imports {
|
||||
if value, err := strconv.Unquote(spec.Path.Value); err != nil || value != path {
|
||||
continue
|
||||
}
|
||||
if spec.Name != nil {
|
||||
return spec.Name.Name
|
||||
}
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// directGitExecs lists the exec.Command and exec.CommandContext calls in files
|
||||
// whose program is the literal "git".
|
||||
func directGitExecs(fset *token.FileSet, files []*ast.File) []string {
|
||||
var found []string
|
||||
for _, file := range files {
|
||||
execName := importName(file, "os/exec")
|
||||
if execName == "" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if pkg, ok := selector.X.(*ast.Ident); !ok || pkg.Name != execName {
|
||||
return true
|
||||
}
|
||||
program := 0
|
||||
switch selector.Sel.Name {
|
||||
case "Command":
|
||||
case "CommandContext":
|
||||
program = 1
|
||||
default:
|
||||
return true
|
||||
}
|
||||
if len(call.Args) <= program {
|
||||
return true
|
||||
}
|
||||
if literal, ok := call.Args[program].(*ast.BasicLit); ok && literal.Kind == token.STRING {
|
||||
if value, err := strconv.Unquote(literal.Value); err == nil && value == "git" {
|
||||
found = append(found, fset.Position(call.Pos()).String())
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func TestAutomaticGitCallsDoNotExecGitDirectly(t *testing.T) {
|
||||
for _, dir := range automaticGitPackages {
|
||||
fset, files := parsePackage(t, dir)
|
||||
for _, position := range directGitExecs(fset, files) {
|
||||
t.Errorf("%s runs git directly; go through gitsafe.Command so the repository's config cannot make it run a program", position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitArguments returns the git arguments of call when it is gitsafe.Command or a
|
||||
// runner called with the program "git" (the TUI's runner(ctx, cwd, "git", ...)),
|
||||
// as far as they are string literals.
|
||||
func gitArguments(call *ast.CallExpr, gitsafeName string) ([]string, bool) {
|
||||
start := -1
|
||||
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && gitsafeName != "" && selector.Sel.Name == "Command" {
|
||||
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
|
||||
start = 2
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
for index, argument := range call.Args {
|
||||
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING && literal.Value == `"git"` {
|
||||
start = index + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if start < 0 || start > len(call.Args) {
|
||||
return nil, false
|
||||
}
|
||||
var arguments []string
|
||||
for _, argument := range call.Args[start:] {
|
||||
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING {
|
||||
if value, err := strconv.Unquote(literal.Value); err == nil {
|
||||
arguments = append(arguments, value)
|
||||
continue
|
||||
}
|
||||
}
|
||||
arguments = append(arguments, "")
|
||||
}
|
||||
return arguments, true
|
||||
}
|
||||
|
||||
// A submodule has its own config, which Command does not check, so the
|
||||
// automatic status and diff calls stay out of submodule work trees.
|
||||
func TestAutomaticStatusAndDiffSkipSubmoduleWorkTrees(t *testing.T) {
|
||||
checked := 0
|
||||
for _, dir := range automaticGitPackages {
|
||||
fset, files := parsePackage(t, dir)
|
||||
for _, file := range files {
|
||||
gitsafeName := importName(file, "github.com/Gitlawb/zero/internal/gitsafe")
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
arguments, ok := gitArguments(call, gitsafeName)
|
||||
if !ok || len(arguments) == 0 || (arguments[0] != "status" && arguments[0] != "diff") {
|
||||
return true
|
||||
}
|
||||
checked++
|
||||
for _, argument := range arguments {
|
||||
if argument == "--ignore-submodules=dirty" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
t.Errorf("%s runs git %s without --ignore-submodules=dirty", fset.Position(call.Pos()), arguments[0])
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
// The scratch-file status, the files sweep's status and diff, and the pull
|
||||
// request diffstat.
|
||||
if checked != 4 {
|
||||
t.Fatalf("checked %d automatic status or diff calls, want 4; update this test when they change", checked)
|
||||
}
|
||||
}
|
||||
|
||||
// The TUI runs every git command through one runner, whose program name is a
|
||||
// parameter, so the check above cannot see it. The runner has to hand git to
|
||||
// Command itself.
|
||||
func TestTUICommandRunnerSendsGitThroughCommand(t *testing.T) {
|
||||
_, files := parsePackage(t, filepath.Join("..", "tui"))
|
||||
var runner *ast.FuncDecl
|
||||
var runnerFile *ast.File
|
||||
for _, file := range files {
|
||||
for _, declaration := range file.Decls {
|
||||
if function, ok := declaration.(*ast.FuncDecl); ok && function.Recv == nil && function.Name.Name == "defaultPRCommandRunner" {
|
||||
runner, runnerFile = function, file
|
||||
}
|
||||
}
|
||||
}
|
||||
if runner == nil {
|
||||
t.Fatal("defaultPRCommandRunner not found in internal/tui")
|
||||
}
|
||||
gitsafeName := importName(runnerFile, "github.com/Gitlawb/zero/internal/gitsafe")
|
||||
calls := false
|
||||
if gitsafeName != "" {
|
||||
ast.Inspect(runner.Body, func(node ast.Node) bool {
|
||||
if call, ok := node.(*ast.CallExpr); ok {
|
||||
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Command" {
|
||||
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
|
||||
calls = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return !calls
|
||||
})
|
||||
}
|
||||
if !calls {
|
||||
t.Fatal("defaultPRCommandRunner does not call gitsafe.Command, so the TUI's git calls skip it")
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Package gitsafe builds the read-only git commands Zero runs on its own, as
|
||||
// opposed to a git command the user or the model asked for: the status and diff
|
||||
// calls behind the TUI's changed-files sweep, its pull request diffstat and the
|
||||
// scratch-file warning, which run without anyone asking for git.
|
||||
//
|
||||
// A REPOSITORY CAN MAKE THOSE COMMANDS RUN A PROGRAM. git status and git diff read
|
||||
// the repository's own .git/config, and several keys there name programs git then
|
||||
// runs: core.fsmonitor on every status, a clean filter or diff driver assigned
|
||||
// through .gitattributes whenever a file's content has to be read, and hooks such
|
||||
// as post-index-change when the index is rewritten. A repository unpacked from an
|
||||
// archive or handed over as a directory carries that config, and Zero would run it
|
||||
// on the first turn, although it refuses the same workspace's project hooks and
|
||||
// plugins until the user trusts it.
|
||||
//
|
||||
// The single-key cases are switched off on the command line. Filter and diff
|
||||
// driver commands are named per driver, so they cannot be switched off without
|
||||
// knowing the names; a repository whose own config defines any is skipped instead.
|
||||
// Commands defined in the user's own global config, such as git-lfs, are the
|
||||
// user's choice and are not affected.
|
||||
package gitsafe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrRepositoryDefinesCommands is returned for a repository whose own config
|
||||
// defines a filter or diff driver command. The caller goes without the git
|
||||
// information rather than run it.
|
||||
var ErrRepositoryDefinesCommands = errors.New("the repository's own git config defines a filter or diff command, so Zero does not run git there on its own")
|
||||
|
||||
// commandKeys matches the config keys whose value is a program git can run while
|
||||
// computing status or a diff, and which are named per driver. Git prints section
|
||||
// and variable names in lower case, so the patterns do not need to fold case.
|
||||
const commandKeys = `filter\..+\.(clean|smudge|process)|diff\..+\.(command|textconv)|diff\.external`
|
||||
|
||||
// worktreeConfigKey makes git also read per-worktree config (config.worktree),
|
||||
// which a --local read leaves out. Without it git ignores that file.
|
||||
const worktreeConfigKey = "extensions.worktreeconfig"
|
||||
|
||||
const (
|
||||
localConfigPattern = `^(` + commandKeys + `|extensions\.worktreeconfig)$`
|
||||
worktreeConfigPattern = `^(` + commandKeys + `)$`
|
||||
)
|
||||
|
||||
// Command returns git with args, run in dir, with the programs a repository can
|
||||
// configure turned off. It fails with ErrRepositoryDefinesCommands when the
|
||||
// repository's own config defines a filter or diff driver command, and with git's
|
||||
// own error when dir is not inside a repository.
|
||||
//
|
||||
// Callers that inspect a work tree that may contain submodules should also pass
|
||||
// --ignore-submodules=dirty: a submodule has its own config, which this does not
|
||||
// read, and git only runs commands there when it looks inside the submodule.
|
||||
func Command(ctx context.Context, dir string, args ...string) (*exec.Cmd, error) {
|
||||
env := environment()
|
||||
defines, err := repositoryDefinesCommands(ctx, dir, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if defines {
|
||||
return nil, ErrRepositoryDefinesCommands
|
||||
}
|
||||
command := exec.CommandContext(ctx, "git", append(disabledPrograms(), args...)...)
|
||||
command.Dir = dir
|
||||
// No opportunistic index refresh: a read-only call never rewrites the index,
|
||||
// so it never triggers the hooks that run when the index is written. Git
|
||||
// before 2.15 does not know the variable and ignores it.
|
||||
command.Env = append(env, "GIT_OPTIONAL_LOCKS=0")
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// disabledPrograms turns off the single-key ways a repository runs a program
|
||||
// during status or diff. Git ignores a key it does not know, so an older git
|
||||
// without hooksPath (2.9) or fsmonitor (2.16) accepts these unchanged.
|
||||
func disabledPrograms() []string {
|
||||
return []string{"-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull}
|
||||
}
|
||||
|
||||
// repositoryLocationVariables make git use a given repository, work tree or
|
||||
// index instead of discovering them from the working directory. Git exports
|
||||
// them to hooks, so a Zero started from a hook inherits the hook's repository.
|
||||
var repositoryLocationVariables = []string{
|
||||
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
|
||||
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX",
|
||||
}
|
||||
|
||||
// environment is Zero's environment without the repository-location variables,
|
||||
// so git finds the repository from dir. The config check and the command share
|
||||
// it: checking one repository's config and then running git in another would
|
||||
// let the second one's commands through.
|
||||
func environment() []string {
|
||||
parent := os.Environ()
|
||||
env := make([]string, 0, len(parent)+1)
|
||||
for _, entry := range parent {
|
||||
name, _, _ := strings.Cut(entry, "=")
|
||||
if !isRepositoryLocationVariable(name) {
|
||||
env = append(env, entry)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func isRepositoryLocationVariable(name string) bool {
|
||||
for _, variable := range repositoryLocationVariables {
|
||||
// Windows environment names are case-insensitive.
|
||||
if name == variable || (runtime.GOOS == "windows" && strings.EqualFold(name, variable)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// repositoryDefinesCommands reports whether the repository's own config, with
|
||||
// includes followed, defines a filter or diff driver command. The local config is
|
||||
// read once, together with the key that turns on per-worktree config, and that
|
||||
// file is read only when it is on, so most repositories cost one git process.
|
||||
// Reading config runs nothing.
|
||||
func repositoryDefinesCommands(ctx context.Context, dir string, env []string) (bool, error) {
|
||||
entries, err := readConfig(ctx, dir, env, "--local", localConfigPattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
worktreeConfig := false
|
||||
for _, entry := range entries {
|
||||
if entry.key != worktreeConfigKey {
|
||||
return true, nil
|
||||
}
|
||||
worktreeConfig = !configFalse(entry.value)
|
||||
}
|
||||
if !worktreeConfig {
|
||||
return false, nil
|
||||
}
|
||||
// The extension needs git 2.20, which also has --worktree, so a failure here
|
||||
// is real and the caller goes without git.
|
||||
entries, err = readConfig(ctx, dir, env, "--worktree", worktreeConfigPattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(entries) > 0, nil
|
||||
}
|
||||
|
||||
// configFalse reports whether a config value is one of git's spellings of false.
|
||||
// Anything else, including a key with no value, counts as on, so an unexpected
|
||||
// spelling costs one more read rather than skipping the file.
|
||||
func configFalse(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "false", "no", "off", "0":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
// readConfig returns the entries in one config scope whose key matches pattern.
|
||||
// It is a variable so tests can answer for git.
|
||||
var readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
|
||||
command := exec.CommandContext(ctx, "git", "config", "-z", scope, "--includes", "--get-regexp", pattern)
|
||||
command.Dir = dir
|
||||
command.Env = env
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) && exit.ExitCode() == 1 {
|
||||
return nil, nil // no key matched
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return parseConfigEntries(output), nil
|
||||
}
|
||||
|
||||
// parseConfigEntries splits git config -z output: each entry ends with a NUL,
|
||||
// and a newline separates the key from its value when there is one.
|
||||
func parseConfigEntries(output []byte) []configEntry {
|
||||
var entries []configEntry
|
||||
for _, record := range bytes.Split(output, []byte{0}) {
|
||||
if len(record) == 0 {
|
||||
continue
|
||||
}
|
||||
key, value, _ := bytes.Cut(record, []byte{'\n'})
|
||||
entries = append(entries, configEntry{key: string(key), value: string(value)})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package gitsafe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type configCall struct {
|
||||
scope string
|
||||
env []string
|
||||
}
|
||||
|
||||
// answerConfig stands in for git's config read and records each call.
|
||||
func answerConfig(t *testing.T, answer func(scope string) ([]configEntry, error)) *[]configCall {
|
||||
t.Helper()
|
||||
var calls []configCall
|
||||
previous := readConfig
|
||||
readConfig = func(_ context.Context, _ string, env []string, scope string, _ string) ([]configEntry, error) {
|
||||
calls = append(calls, configCall{scope: scope, env: env})
|
||||
return answer(scope)
|
||||
}
|
||||
t.Cleanup(func() { readConfig = previous })
|
||||
return &calls
|
||||
}
|
||||
|
||||
func noEntries(string) ([]configEntry, error) { return nil, nil }
|
||||
|
||||
func scopes(calls *[]configCall) []string {
|
||||
var read []string
|
||||
for _, call := range *calls {
|
||||
read = append(read, call.scope)
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
// effectiveEnv returns the value a child process gets for name: os/exec keeps
|
||||
// the last entry for a name, compared without case on Windows.
|
||||
func effectiveEnv(env []string, name string) (string, bool) {
|
||||
value, found := "", false
|
||||
for _, entry := range env {
|
||||
key, rest, ok := strings.Cut(entry, "=")
|
||||
if ok && (key == name || (runtime.GOOS == "windows" && strings.EqualFold(key, name))) {
|
||||
value, found = rest, true
|
||||
}
|
||||
}
|
||||
return value, found
|
||||
}
|
||||
|
||||
func TestCommandTurnsOffRepositoryPrograms(t *testing.T) {
|
||||
answerConfig(t, noEntries)
|
||||
t.Setenv("GIT_OPTIONAL_LOCKS", "1")
|
||||
dir := t.TempDir()
|
||||
|
||||
command, err := Command(context.Background(), dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
want := []string{"git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull, "status", "--porcelain"}
|
||||
if !reflect.DeepEqual(command.Args, want) {
|
||||
t.Fatalf("args = %q, want %q", command.Args, want)
|
||||
}
|
||||
if command.Dir != dir {
|
||||
t.Fatalf("dir = %q, want %q", command.Dir, dir)
|
||||
}
|
||||
// An inherited GIT_OPTIONAL_LOCKS must not turn the index refresh back on.
|
||||
if value, _ := effectiveEnv(command.Env, "GIT_OPTIONAL_LOCKS"); value != "0" {
|
||||
t.Fatalf("effective GIT_OPTIONAL_LOCKS = %q, want 0", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandDropsRepositoryLocationVariables(t *testing.T) {
|
||||
// Turn per-worktree config on so both reads happen and both are checked.
|
||||
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
|
||||
if scope == "--local" {
|
||||
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
elsewhere := filepath.Join(t.TempDir(), "elsewhere")
|
||||
for _, name := range repositoryLocationVariables {
|
||||
t.Setenv(name, elsewhere)
|
||||
}
|
||||
t.Setenv("ZERO_GITSAFE_TEST_KEPT", "kept")
|
||||
|
||||
command, err := Command(context.Background(), t.TempDir(), "status")
|
||||
if err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
for _, name := range repositoryLocationVariables {
|
||||
if value, found := effectiveEnv(command.Env, name); found {
|
||||
t.Fatalf("%s=%q reached git", name, value)
|
||||
}
|
||||
}
|
||||
if value, _ := effectiveEnv(command.Env, "ZERO_GITSAFE_TEST_KEPT"); value != "kept" {
|
||||
t.Fatalf("an unrelated variable was dropped: %q", value)
|
||||
}
|
||||
// The config check has to look at the repository the command runs in, so it
|
||||
// gets the same environment, minus only the lock variable added afterwards.
|
||||
if got := scopes(calls); !reflect.DeepEqual(got, []string{"--local", "--worktree"}) {
|
||||
t.Fatalf("config reads = %q, want --local then --worktree", got)
|
||||
}
|
||||
for _, call := range *calls {
|
||||
if !reflect.DeepEqual(call.env, command.Env[:len(command.Env)-1]) {
|
||||
t.Fatalf("the %s config read ran with a different environment than the command", call.scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocationVariableNamesFoldCaseOnlyOnWindows(t *testing.T) {
|
||||
if !isRepositoryLocationVariable("GIT_DIR") {
|
||||
t.Fatal("GIT_DIR not recognised")
|
||||
}
|
||||
if got, want := isRepositoryLocationVariable("Git_Dir"), runtime.GOOS == "windows"; got != want {
|
||||
t.Fatalf("Git_Dir recognised = %v, want %v on %s", got, want, runtime.GOOS)
|
||||
}
|
||||
for _, name := range []string{"GIT_DIRECTORY", "MY_GIT_DIR", "GIT_CONFIG_GLOBAL", ""} {
|
||||
if isRepositoryLocationVariable(name) {
|
||||
t.Fatalf("%q treated as a repository-location variable", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRefusesRepositoryThatDefinesCommands(t *testing.T) {
|
||||
enabled := configEntry{key: worktreeConfigKey, value: "true"}
|
||||
command := configEntry{key: "filter.x.clean", value: "x"}
|
||||
cases := map[string]map[string][]configEntry{
|
||||
"local": {"--local": {command}},
|
||||
"local after the extension": {"--local": {enabled, command}},
|
||||
"per-worktree": {"--local": {enabled}, "--worktree": {{key: "diff.x.textconv", value: "x"}}},
|
||||
}
|
||||
for name, answers := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
answerConfig(t, func(scope string) ([]configEntry, error) { return answers[scope], nil })
|
||||
got, err := Command(context.Background(), t.TempDir(), "status")
|
||||
if !errors.Is(err, ErrRepositoryDefinesCommands) {
|
||||
t.Fatalf("err = %v, want ErrRepositoryDefinesCommands", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatal("a command came back for a repository that defines commands")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandReadsPerWorktreeConfigOnlyWhenItIsOn(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
local []configEntry
|
||||
want []string
|
||||
}{
|
||||
"no extension": {nil, []string{"--local"}},
|
||||
"extension off": {[]configEntry{{key: worktreeConfigKey, value: "false"}}, []string{"--local"}},
|
||||
"extension off as no": {[]configEntry{{key: worktreeConfigKey, value: "No"}}, []string{"--local"}},
|
||||
"extension on": {[]configEntry{{key: worktreeConfigKey, value: "true"}}, []string{"--local", "--worktree"}},
|
||||
"extension with no value": {[]configEntry{{key: worktreeConfigKey}}, []string{"--local", "--worktree"}},
|
||||
"turned off again": {[]configEntry{{key: worktreeConfigKey, value: "true"}, {key: worktreeConfigKey, value: "off"}}, []string{"--local"}},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
|
||||
if scope == "--local" {
|
||||
return tc.local, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if _, err := Command(context.Background(), t.TempDir(), "status"); err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
if got := scopes(calls); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("config reads = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFailsWhenConfigCannotBeRead(t *testing.T) {
|
||||
unreadable := errors.New("not a git repository")
|
||||
for _, failing := range []string{"--local", "--worktree"} {
|
||||
t.Run(failing, func(t *testing.T) {
|
||||
answerConfig(t, func(scope string) ([]configEntry, error) {
|
||||
if scope == failing {
|
||||
return nil, unreadable
|
||||
}
|
||||
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
|
||||
})
|
||||
if _, err := Command(context.Background(), t.TempDir(), "status"); !errors.Is(err, unreadable) {
|
||||
t.Fatalf("err = %v, want the config read error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Go's regexp stands in for git's extended regex here; the patterns use nothing
|
||||
// the two treat differently.
|
||||
func TestConfigPatterns(t *testing.T) {
|
||||
local := regexp.MustCompile(localConfigPattern)
|
||||
worktree := regexp.MustCompile(worktreeConfigPattern)
|
||||
for _, key := range []string{
|
||||
"filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.a.b.clean",
|
||||
"diff.word.textconv", "diff.tool.command", "diff.external",
|
||||
} {
|
||||
if !local.MatchString(key) || !worktree.MatchString(key) {
|
||||
t.Errorf("%s is not matched in both scopes", key)
|
||||
}
|
||||
}
|
||||
if !local.MatchString(worktreeConfigKey) || worktree.MatchString(worktreeConfigKey) {
|
||||
t.Errorf("%s belongs to the local read only", worktreeConfigKey)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"filter.lfs.required", "diff.word.cachetextconv", "diff.word.binary",
|
||||
"core.fsmonitor", "diff.external.x", "diff.renames", "extensions.worktreeconfigx",
|
||||
} {
|
||||
if local.MatchString(key) || worktree.MatchString(key) {
|
||||
t.Errorf("%s is matched", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigEntries(t *testing.T) {
|
||||
got := parseConfigEntries([]byte("filter.x.clean\nrun this\nand that\x00extensions.worktreeconfig\x00"))
|
||||
want := []configEntry{
|
||||
{key: "filter.x.clean", value: "run this\nand that"},
|
||||
{key: "extensions.worktreeconfig"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("entries = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Real git: a fresh repository with nothing configured, the same repository with
|
||||
// per-worktree config turned on, and a directory that is not a repository. This
|
||||
// pins the output format and exit codes readConfig relies on; the command keys
|
||||
// themselves are covered above.
|
||||
func TestCommandAgainstRealGit(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
repo := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
// Both share a parent; stop git from finding an enclosing repository there.
|
||||
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(repo))
|
||||
// Run as if from a hook in another repository. The setup below has to reach
|
||||
// repo, as Command does, and never the repository these name.
|
||||
decoy := t.TempDir()
|
||||
t.Setenv("GIT_DIR", filepath.Join(decoy, ".git"))
|
||||
t.Setenv("GIT_WORK_TREE", decoy)
|
||||
decoyUntouched := func(step string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(decoy, ".git")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s reached the repository GIT_DIR names instead of repo: %v", step, err)
|
||||
}
|
||||
}
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
command := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
command.Env = environment()
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, output)
|
||||
}
|
||||
}
|
||||
git("init", "-q")
|
||||
decoyUntouched("git init")
|
||||
gitRead := readConfig
|
||||
var read []string
|
||||
readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
|
||||
read = append(read, scope)
|
||||
return gitRead(ctx, dir, env, scope, pattern)
|
||||
}
|
||||
t.Cleanup(func() { readConfig = gitRead })
|
||||
|
||||
command, err := Command(context.Background(), repo, "status", "--porcelain")
|
||||
if err != nil {
|
||||
t.Fatalf("Command in a plain repository: %v", err)
|
||||
}
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git status: %v: %s", err, output)
|
||||
}
|
||||
if !reflect.DeepEqual(read, []string{"--local"}) {
|
||||
t.Fatalf("config reads in a plain repository = %q, want --local only", read)
|
||||
}
|
||||
|
||||
if !gitAtLeast(t, 2, 20) {
|
||||
t.Log("skipping the per-worktree case: it needs git 2.20 for extensions.worktreeConfig")
|
||||
} else {
|
||||
git("config", "extensions.worktreeConfig", "true")
|
||||
decoyUntouched("git config")
|
||||
read = nil
|
||||
if _, err := Command(context.Background(), repo, "status"); err != nil {
|
||||
t.Fatalf("Command with per-worktree config on: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(read, []string{"--local", "--worktree"}) {
|
||||
t.Fatalf("config reads with per-worktree config on = %q, want --local then --worktree", read)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := Command(context.Background(), outside, "status"); err == nil {
|
||||
t.Fatal("Command outside a repository succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// gitAtLeast reports whether the git on PATH is at least major.minor.
|
||||
func gitAtLeast(t *testing.T, major, minor int) bool {
|
||||
t.Helper()
|
||||
output, err := exec.Command("git", "version").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git version: %v", err)
|
||||
}
|
||||
var gotMajor, gotMinor int
|
||||
if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(output)), "git version "), "%d.%d", &gotMajor, &gotMinor); err != nil {
|
||||
t.Fatalf("parse %q: %v", output, err)
|
||||
}
|
||||
return gotMajor > major || (gotMajor == major && gotMinor >= minor)
|
||||
}
|
||||
@@ -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,433 +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.
|
||||
// FAKE_NPM_APPLY_AFTER=<n> applies each publish only after n further lookups,
|
||||
// the way npm accepts an upload and shows the version minutes later; until
|
||||
// then the version and its dist-tag are invisible. When a wrapper is published,
|
||||
// the aliased versions not yet visible are recorded in missingAtWrapperPublish.
|
||||
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); };
|
||||
state.pending = state.pending || [];
|
||||
const apply = (publish) => {
|
||||
state.versions.push(publish.version);
|
||||
state.distTags[publish.tag] = publish.version;
|
||||
if (process.env.FAKE_NPM_CLOBBER_LATEST === '1') state.distTags.latest = publish.version;
|
||||
};
|
||||
if (args[0] === 'view') {
|
||||
for (const publish of state.pending) publish.wait -= 1;
|
||||
state.pending.filter((publish) => publish.wait <= 0).forEach(apply);
|
||||
state.pending = state.pending.filter((publish) => publish.wait > 0);
|
||||
save();
|
||||
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) || state.pending.some((publish) => publish.version === pkg.version)) {
|
||||
process.stderr.write('npm error code EPUBLISHCONFLICT\n');
|
||||
process.exit(1);
|
||||
}
|
||||
if (pkg.optionalDependencies) {
|
||||
state.missingAtWrapperPublish = Object.values(pkg.optionalDependencies)
|
||||
.map((spec) => spec.slice(spec.lastIndexOf('@') + 1))
|
||||
.filter((version) => !state.versions.includes(version));
|
||||
}
|
||||
const tagIndex = args.indexOf('--tag');
|
||||
const publish = { version: pkg.version, tag: tagIndex === -1 ? 'latest' : args[tagIndex + 1], wait: Number(process.env.FAKE_NPM_APPLY_AFTER || 0) };
|
||||
if (publish.wait > 0) state.pending.push(publish);
|
||||
else apply(publish);
|
||||
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"`
|
||||
// MissingAtWrapperPublish lists the platform versions the wrapper aliased
|
||||
// that npm did not show yet when the wrapper was published.
|
||||
MissingAtWrapperPublish []string `json:"missingAtWrapperPublish,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)
|
||||
}
|
||||
}
|
||||
|
||||
// npm accepts an upload and shows the version minutes later (ten minutes for a
|
||||
// 0.10.0 platform payload). Publishing the wrapper as soon as the platform
|
||||
// uploads return would leave installs resolving aliases that 404, and checking
|
||||
// `latest` right after the wrapper upload reports a failure that is not one.
|
||||
func TestPublishPackagesWaitsForNpmToApplyEachPublish(t *testing.T) {
|
||||
platforms := []string{"darwin-arm64", "linux-x64"}
|
||||
npmDir := writePublishFixture(t, platforms, platforms)
|
||||
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_APPLY_AFTER=8")
|
||||
if run.exitCode != 0 {
|
||||
t.Fatalf("exit = %d while npm applied the publishes late; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
|
||||
}
|
||||
if len(run.state.MissingAtWrapperPublish) != 0 {
|
||||
t.Fatalf("the wrapper went out while npm did not show %v yet; installs would resolve aliases that 404", run.state.MissingAtWrapperPublish)
|
||||
}
|
||||
if latest := run.state.DistTags["latest"]; latest != publishFixtureVersion {
|
||||
t.Fatalf("latest = %q, want %q", latest, publishFixtureVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// A publish npm never applies must fail the job at the deadline, before the
|
||||
// wrapper goes out, instead of waiting forever.
|
||||
func TestPublishPackagesGivesUpWhenNpmNeverAppliesAPublish(t *testing.T) {
|
||||
platforms := []string{"linux-x64"}
|
||||
npmDir := writePublishFixture(t, platforms, platforms)
|
||||
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName},
|
||||
"FAKE_NPM_APPLY_AFTER=1000000", "ZERO_NPM_PUBLISH_WAIT_TIMEOUT=1")
|
||||
if run.exitCode == 0 {
|
||||
t.Fatalf("exit = 0 although npm never showed the platform version; 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 wait for it)", got)
|
||||
}
|
||||
if !strings.Contains(run.stderr, "still does not show "+publishFixtureName+"@"+publishFixtureVersion+"-linux-x64") {
|
||||
t.Fatalf("stderr does not say what npm never showed: %s", run.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Bash arithmetic reads a leading zero as octal and rejects 08 outright, so
|
||||
// the timeout has to be converted explicitly rather than used as typed. The
|
||||
// rejection does not stop a script: it prints an error, leaves the deadline
|
||||
// unset and carries on, so the failure only shows once the script has to wait,
|
||||
// after the platform payloads are out. npm applying late makes it wait here.
|
||||
func TestPublishPackagesReadsTheWaitTimeoutAsDecimal(t *testing.T) {
|
||||
platforms := []string{"linux-x64"}
|
||||
npmDir := writePublishFixture(t, platforms, platforms)
|
||||
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName},
|
||||
"ZERO_NPM_PUBLISH_WAIT_TIMEOUT=08", "FAKE_NPM_APPLY_AFTER=3")
|
||||
if run.exitCode != 0 {
|
||||
t.Fatalf("exit = %d with ZERO_NPM_PUBLISH_WAIT_TIMEOUT=08; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
|
||||
}
|
||||
if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64,wrapper" {
|
||||
t.Fatalf("published %s, want zero-linux-x64,wrapper", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A setting the script cannot use must stop it before anything is published:
|
||||
// a negative timeout would otherwise end the wait before it starts, and a bad
|
||||
// delay would only fail at the first sleep, after the platform payloads.
|
||||
func TestPublishPackagesRejectsAnInvalidWaitSetting(t *testing.T) {
|
||||
for _, tc := range []struct{ variable, value string }{
|
||||
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "-5"},
|
||||
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "abc"},
|
||||
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "1e3"},
|
||||
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "86401"},
|
||||
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "9999999999999999999999"},
|
||||
{"ZERO_NPM_PUBLISH_RETRY_DELAY", "soon"},
|
||||
} {
|
||||
t.Run(tc.variable+"="+tc.value, func(t *testing.T) {
|
||||
platforms := []string{"linux-x64"}
|
||||
npmDir := writePublishFixture(t, platforms, platforms)
|
||||
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, tc.variable+"="+tc.value)
|
||||
if run.exitCode == 0 {
|
||||
t.Fatalf("exit = 0 with %s=%s; stdout=%s", tc.variable, tc.value, run.stdout)
|
||||
}
|
||||
if len(run.publishes) != 0 {
|
||||
t.Fatalf("published %v before rejecting %s=%s, want nothing", run.publishedDirs(), tc.variable, tc.value)
|
||||
}
|
||||
if !strings.Contains(run.stderr, tc.variable) {
|
||||
t.Fatalf("stderr does not name %s: %s", tc.variable, run.stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -147,14 +147,6 @@ var descriptors = []Descriptor{
|
||||
// provider id so the remote catalog resolves without a mapping; GMI's own
|
||||
// docs name the key GMI_API_KEY, models.dev names it GMICLOUD_API_KEY.
|
||||
openAICompat("gmicloud", "GMI Cloud", "https://api.gmi-serving.com/v1", "Qwen/Qwen3.8-Max", []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}, "gmi cloud", "gmi"),
|
||||
// Concentrate AI — OpenAI-compatible gateway to models from many labs.
|
||||
// models.dev has no Concentrate provider, so the id and key name match
|
||||
// OpenClaude's Concentrate integration. The default is the dated id
|
||||
// deepseek-v4-flash-0731: Concentrate's /v1/models keeps the rolling name
|
||||
// deepseek-v4-flash only as an alias, and onboarding matches the default
|
||||
// against live ids. "concentrate ai" also covers "concentrate.ai" and
|
||||
// "concentrate-ai" once normalized.
|
||||
openAICompat("concentrate", "Concentrate", "https://api.concentrate.ai/v1", "deepseek-v4-flash-0731", []string{"CONCENTRATE_API_KEY"}, "concentrate ai"),
|
||||
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
|
||||
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
||||
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
||||
|
||||
@@ -28,7 +28,6 @@ var expectedCatalogIDs = []string{
|
||||
"moonshot",
|
||||
"atlascloud",
|
||||
"gmicloud",
|
||||
"concentrate",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
@@ -228,31 +227,6 @@ func TestGMICloudDescriptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcentrateDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("concentrate")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(concentrate) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "Concentrate" {
|
||||
t.Fatalf("Name = %q, want Concentrate", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.concentrate.ai/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want Concentrate OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "deepseek-v4-flash-0731" {
|
||||
t.Fatalf("DefaultModel = %q, want deepseek-v4-flash-0731", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !descriptor.RequiresAuth {
|
||||
t.Fatal("RequiresAuth = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"CONCENTRATE_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want CONCENTRATE_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -376,10 +350,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
|
||||
"GMI Cloud": "gmicloud",
|
||||
"gmi-cloud": "gmicloud",
|
||||
"gmi": "gmicloud",
|
||||
"Concentrate": "concentrate",
|
||||
"concentrate ai": "concentrate",
|
||||
"concentrate.ai": "concentrate",
|
||||
"concentrate-ai": "concentrate",
|
||||
"nvidia nim": "nvidia-nim",
|
||||
"xiaomi mimo": "xiaomi-mimo",
|
||||
"custom_openai_compatible": "custom-openai-compatible",
|
||||
@@ -430,7 +400,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "concentrate", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -27,8 +27,6 @@ type Model struct {
|
||||
|
||||
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
|
||||
|
||||
const concentrateModelSource = "https://api.concentrate.ai/v1/models"
|
||||
|
||||
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
|
||||
// per call, so the shared backing slice cannot be mutated by callers.
|
||||
// Flat cost fields stay unset because MiniMax-M3 pricing is tiered and this
|
||||
@@ -157,18 +155,6 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Description: "reasoning model"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Description: "fast coding model"},
|
||||
},
|
||||
// Context windows, tool calling and thinking support come from
|
||||
// concentrateModelSource (max_input_tokens, function_calling, thinking).
|
||||
// Max output: 393,216 for both DeepSeek V4 models, 262,144 for Kimi K3 and
|
||||
// GLM-5.3. The default is the dated id deepseek-v4-flash-0731, the model
|
||||
// behind the deepseek-v4-flash alias (OpenClaude's Concentrate default): the
|
||||
// live list carries ids only, so an alias would not survive discovery.
|
||||
"concentrate": {
|
||||
{ID: "deepseek-v4-flash-0731", Description: "catalog default", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
|
||||
{ID: "deepseek-v4-pro", Description: "reasoning model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
|
||||
{ID: "kimi-k3", Description: "coding model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
|
||||
{ID: "glm-5.3", Description: "coding model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
|
||||
},
|
||||
"nvidia-nim": {
|
||||
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
|
||||
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
||||
|
||||
@@ -53,11 +53,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"Qwen/Qwen3.8-Max", "Qwen/Qwen3.8-Flash", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/deepseek-v4-pro"},
|
||||
},
|
||||
{
|
||||
provider: "concentrate",
|
||||
want: []string{"deepseek-v4-flash-0731", "deepseek-v4-pro", "kimi-k3", "glm-5.3"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/DeepSeek-V4-Pro"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
@@ -188,28 +183,6 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcentrateModelsExposeCatalogLimits(t *testing.T) {
|
||||
descriptor, ok := providercatalog.Get("concentrate")
|
||||
if !ok {
|
||||
t.Fatal("provider concentrate missing from catalog")
|
||||
}
|
||||
models := Models(descriptor)
|
||||
if got, want := modelIDs(models), []string{"deepseek-v4-flash-0731", "deepseek-v4-pro", "kimi-k3", "glm-5.3"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Concentrate models = %#v, want %#v", got, want)
|
||||
}
|
||||
if models[0].Description != "catalog default" {
|
||||
t.Fatalf("first Concentrate model description = %q, want catalog default", models[0].Description)
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.ContextWindow != 1_048_576 || !model.ToolCall || !model.Reasoning {
|
||||
t.Fatalf("%s capabilities = %#v, want 1,048,576 context with tools and reasoning", model.ID, model)
|
||||
}
|
||||
if model.Source != concentrateModelSource {
|
||||
t.Fatalf("%s source = %q, want %q", model.ID, model.Source, concentrateModelSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
|
||||
// international provider; a divergence here is almost always a mistake.
|
||||
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -39,54 +38,3 @@ func TestDiscoverCatalogFallsBackWhenLiveIDsMismatch(t *testing.T) {
|
||||
t.Fatalf("models = %q, want curated catalog fallback (gpt-4.1) instead of an empty list", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Concentrate's /v1/models lists dated snapshots as ids and keeps rolling names
|
||||
// such as deepseek-v4-flash only in "aliases", which discovery does not read.
|
||||
// Onboarding keeps the default selected only when it matches a live id, so the
|
||||
// preset's default must be an id, or setup lands on whatever model sorts first.
|
||||
func TestDiscoverCatalogConcentrateKeepsDefaultModelInLiveList(t *testing.T) {
|
||||
var liveHits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api.json":
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
case "/v1/models":
|
||||
liveHits.Add(1)
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[
|
||||
{"id":"claude-fable-5","slug":"claude-fable-5","max_input_tokens":1000000},
|
||||
{"id":"deepseek-v4-flash-0731","slug":"deepseek-v4-flash-0731","aliases":["deepseek-v4-flash"],"max_input_tokens":1048576},
|
||||
{"id":"deepseek-v4-pro","slug":"deepseek-v4-pro","max_input_tokens":1048576},
|
||||
{"id":"kimi-k3","slug":"kimi-k3","max_input_tokens":1048576},
|
||||
{"id":"glm-5.3","slug":"glm-5.3","max_input_tokens":1048576}
|
||||
]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected request path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, ok := providercatalog.Get("concentrate")
|
||||
if !ok {
|
||||
t.Fatal("provider concentrate missing from catalog")
|
||||
}
|
||||
provider.DefaultBaseURL = server.URL + "/v1"
|
||||
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
|
||||
CatalogID: "concentrate", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: server.URL + "/v1", APIKey: "sk-test",
|
||||
}, Options{HTTPClient: server.Client(), ModelsDevURL: server.URL + "/api.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverCatalog returned error: %v", err)
|
||||
}
|
||||
if liveHits.Load() == 0 {
|
||||
t.Fatal("DiscoverCatalog never requested /v1/models")
|
||||
}
|
||||
// claude-fable-5 is only in the live response, never in the curated list:
|
||||
// finding it proves the result is the live list, not the curated fallback
|
||||
// (which also contains the default and would pass on its own).
|
||||
got := "," + strings.Join(modelIDs(models), ",") + ","
|
||||
if !strings.Contains(got, ",claude-fable-5,") {
|
||||
t.Fatalf("models = %q, want the live Concentrate list (with live-only claude-fable-5), not the curated fallback", strings.Trim(got, ","))
|
||||
}
|
||||
if !strings.Contains(got, ","+provider.DefaultModel+",") {
|
||||
t.Fatalf("default model %q missing from discovered models %q", provider.DefaultModel, strings.Trim(got, ","))
|
||||
}
|
||||
}
|
||||
+47
-157
@@ -9,7 +9,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -97,21 +96,6 @@ var textSecretPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`),
|
||||
}
|
||||
|
||||
// textSecretPrefixes lists the mandatory literal prefixes for each entry in
|
||||
// textSecretPatterns so RedactString can skip the regex scan when none of the
|
||||
// trigger prefixes appear in the input.
|
||||
var textSecretPrefixes = [][]string{
|
||||
{"sk-ant-"},
|
||||
{"github_pat_"},
|
||||
{"ghp_", "gho_", "ghu_", "ghs_", "ghr_"},
|
||||
{"glpat-"},
|
||||
{"AIza"},
|
||||
{"xox"},
|
||||
{"AKIA", "ASIA"},
|
||||
{"eyJ"},
|
||||
{"eyJ"},
|
||||
}
|
||||
|
||||
// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b
|
||||
// replaced by \A, so each shape can be tested at an exact byte offset. A
|
||||
// credential that starts exactly where a redacted span ends (two AWS keys
|
||||
@@ -264,71 +248,55 @@ func RedactString(value string, options Options) string {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") {
|
||||
redacted = privateKeyPattern.ReplaceAllString(redacted, replacement)
|
||||
}
|
||||
if strings.Contains(redacted, ":") && strings.Contains(redacted, "\"") {
|
||||
redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := jsonStringPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 3 || !IsSensitiveKey(parts[2], options) {
|
||||
return match
|
||||
}
|
||||
return parts[1] + `"` + replacement + `"`
|
||||
})
|
||||
}
|
||||
if strings.Contains(redacted, "=") {
|
||||
redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := assignPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 6 || !IsSensitiveKey(parts[1], options) {
|
||||
return match
|
||||
}
|
||||
if parts[3] != "" {
|
||||
return parts[1] + parts[2] + `"` + replacement + `"`
|
||||
}
|
||||
if parts[4] != "" {
|
||||
return parts[1] + parts[2] + `'` + replacement + `'`
|
||||
}
|
||||
return parts[1] + parts[2] + replacement
|
||||
})
|
||||
}
|
||||
if strings.Contains(redacted, ":") {
|
||||
if containsCaseInsensitive(redacted, "authorization") {
|
||||
redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
groups := headerPattern.FindStringSubmatch(match)
|
||||
if groups[2] != "" {
|
||||
return groups[1] + ": " + groups[2] + " " + replacement
|
||||
}
|
||||
return groups[1] + ": " + replacement
|
||||
})
|
||||
redacted = privateKeyPattern.ReplaceAllString(redacted, replacement)
|
||||
redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := jsonStringPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 3 || !IsSensitiveKey(parts[2], options) {
|
||||
return match
|
||||
}
|
||||
redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement)
|
||||
}
|
||||
if strings.Contains(redacted, "://") && strings.Contains(redacted, "@") {
|
||||
redacted = redactURLPasswords(redacted, replacement)
|
||||
}
|
||||
if strings.Contains(redacted, "?") || strings.Contains(redacted, "&") {
|
||||
redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := queryPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 4 || !IsSensitiveKey(parts[2], options) {
|
||||
return match
|
||||
}
|
||||
return parts[1] + parts[2] + "=" + replacement
|
||||
})
|
||||
}
|
||||
return parts[1] + `"` + replacement + `"`
|
||||
})
|
||||
redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := assignPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 6 || !IsSensitiveKey(parts[1], options) {
|
||||
return match
|
||||
}
|
||||
if parts[3] != "" {
|
||||
return parts[1] + parts[2] + `"` + replacement + `"`
|
||||
}
|
||||
if parts[4] != "" {
|
||||
return parts[1] + parts[2] + `'` + replacement + `'`
|
||||
}
|
||||
return parts[1] + parts[2] + replacement
|
||||
})
|
||||
redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
groups := headerPattern.FindStringSubmatch(match)
|
||||
// groups[2] is the known scheme (kept for readability) or "" for an opaque /
|
||||
// custom-scheme credential — in which case the whole value is redacted (M12).
|
||||
if groups[2] != "" {
|
||||
return groups[1] + ": " + groups[2] + " " + replacement
|
||||
}
|
||||
return groups[1] + ": " + replacement
|
||||
})
|
||||
redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement)
|
||||
redacted = redactURLPasswords(redacted, replacement)
|
||||
redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
parts := queryPattern.FindStringSubmatch(match)
|
||||
if len(parts) < 4 || !IsSensitiveKey(parts[2], options) {
|
||||
return match
|
||||
}
|
||||
return parts[1] + parts[2] + "=" + replacement
|
||||
})
|
||||
// openai keys first so the filter can drop kebab-case false positives
|
||||
// before any other pattern rewrites nearby text.
|
||||
if strings.Contains(redacted, "sk-") {
|
||||
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
if !openAIShapeIsSecret(match) {
|
||||
return match
|
||||
}
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
for i, pattern := range textSecretPatterns {
|
||||
if hasAnyPrefix(redacted, textSecretPrefixes[i]) {
|
||||
redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement)
|
||||
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
||||
if !openAIShapeIsSecret(match) {
|
||||
return match
|
||||
}
|
||||
return replacement
|
||||
})
|
||||
for i, pattern := range textSecretPatterns {
|
||||
redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement)
|
||||
}
|
||||
// Credentials broken up by invisible characters last, over what the
|
||||
// contiguous matchers left: a whole credential is already claimed by its own
|
||||
@@ -338,76 +306,6 @@ func RedactString(value string, options Options) string {
|
||||
return redacted
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if strings.Contains(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsCaseInsensitive(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if hasFoldedASCIIPrefix(s[i:], substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasFoldedASCIIPrefix reports whether s starts with substr under Unicode
|
||||
// simple case folding (assuming substr is lowercase ASCII). This ensures that
|
||||
// non-ASCII runes that fold to ASCII in (?i) regular expressions (U+017F 'ſ' ->
|
||||
// 's' and U+212A 'K' -> 'k') pass the fast-path gate with zero allocations.
|
||||
func hasFoldedASCIIPrefix(s, substr string) bool {
|
||||
si := 0
|
||||
for j := 0; j < len(substr); j++ {
|
||||
if si >= len(s) {
|
||||
return false
|
||||
}
|
||||
c2 := toLowerASCII(substr[j])
|
||||
c1 := s[si]
|
||||
if c1 < utf8.RuneSelf {
|
||||
if c1 != c2 && toLowerASCII(c1) != c2 {
|
||||
return false
|
||||
}
|
||||
si++
|
||||
continue
|
||||
}
|
||||
r, width := utf8.DecodeRuneInString(s[si:])
|
||||
if foldRuneToLowerASCII(r) != c2 {
|
||||
return false
|
||||
}
|
||||
si += width
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func foldRuneToLowerASCII(r rune) byte {
|
||||
switch r {
|
||||
case '\u017f': // LATIN SMALL LETTER LONG S
|
||||
return 's'
|
||||
case '\u212a': // KELVIN SIGN
|
||||
return 'k'
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func toLowerASCII(c byte) byte {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
return c + ('a' - 'A')
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// knownOpenAIKeyPrefix is the redaction-side twin of secrets.knownOpenAIKeyPrefix:
|
||||
// known OpenAI-issued forms redact even with an alphabet-only body.
|
||||
func knownOpenAIKeyPrefix(match string) bool {
|
||||
@@ -659,15 +557,7 @@ func redactURLPasswords(value string, replacement string) string {
|
||||
if _, hasPassword := parsed.User.Password(); !hasPassword {
|
||||
return candidate
|
||||
}
|
||||
username := parsed.User.Username()
|
||||
userinfo := url.UserPassword(username, replacement)
|
||||
parsed.User = userinfo
|
||||
out := parsed.String()
|
||||
encodedUserinfo := userinfo.String()
|
||||
literalUserinfo := url.User(username).String() + ":" + replacement
|
||||
if encodedUserinfo != literalUserinfo {
|
||||
out = strings.Replace(out, encodedUserinfo, literalUserinfo, 1)
|
||||
}
|
||||
return out
|
||||
parsed.User = url.UserPassword(parsed.User.Username(), replacement)
|
||||
return parsed.String()
|
||||
})
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestRedactStringRedactsAdjacentAWSKeys(t *testing.T) {
|
||||
func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`,
|
||||
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
"https://zero:[email protected]/path?token=glpat-abcdefghijklmnopqrstuvwxyz",
|
||||
"-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
|
||||
}, "\n")
|
||||
@@ -42,7 +42,7 @@ func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
|
||||
|
||||
for _, leaked := range []string{
|
||||
"sk-proj-abcdefghijklmnopqrstuvwxyz",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
"super-secret",
|
||||
"glpat-abcdefghijklmnopqrstuvwxyz",
|
||||
"abc123",
|
||||
@@ -81,7 +81,7 @@ func TestRedactValueHandlesSensitiveKeysAndCycles(t *testing.T) {
|
||||
func TestRedactErrorRedactsMessageStackAndFields(t *testing.T) {
|
||||
err := withFieldsError{
|
||||
err: errors.New("request failed with api_key=sk-test-secret1234567890"),
|
||||
Token: "ghp_abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
Token: "ghp_abcdefghijklmnopqrstuvwxyz123456",
|
||||
Detail: "safe",
|
||||
}
|
||||
|
||||
@@ -185,418 +185,3 @@ func TestRedactStringLongAdjacentKeyRunCompletesPromptly(t *testing.T) {
|
||||
t.Fatalf("expected %d redaction markers, got %d", count, strings.Count(got, RedactedSecret))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRedactString(b *testing.B) {
|
||||
sample := strings.Join([]string{
|
||||
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"}`,
|
||||
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
"https://zero:[email protected]/path?token=glpat-abcdefghijklmnopqrstuvwxyz",
|
||||
"export AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE",
|
||||
"normal line with no secrets just log messages and numbers 123456789",
|
||||
"another normal line about git commit status and file diff output",
|
||||
}, "\n")
|
||||
opts := Options{ExtraSecretValues: []string{"super-secret"}}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = RedactString(sample, opts)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRedactStringClean(b *testing.B) {
|
||||
cleanText := "normal line with no secrets just log messages and numbers 123456789\nanother line without secrets"
|
||||
opts := Options{}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = RedactString(cleanText, opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactStringConditionalGates(t *testing.T) {
|
||||
const ghp = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
const gho = "gho_abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
const pat = "github_pat_abcdefghijklmnopqrstuv"
|
||||
const ant = "sk-ant-api03-abcdefghijklmnopqrst"
|
||||
const glpat = "glpat-abcdefghijkl"
|
||||
const aiza = "AIza" + "01234567890123456789012345678901234"
|
||||
const slack = "xoxb-1234567890-abcdefghij"
|
||||
const akia = "AKIAIOSFODNN7EXAMPLE"
|
||||
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturexx"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
leaked []string
|
||||
keep []string
|
||||
wantHit bool
|
||||
}{
|
||||
{
|
||||
name: "private key match",
|
||||
in: "-----BEGIN RSA PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----",
|
||||
leaked: []string{"abc123"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "private key near-miss",
|
||||
in: "-----BEGIN PUBLIC KEY-----\nabc123\n-----END PUBLIC KEY-----",
|
||||
keep: []string{"abc123"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "json sensitive key",
|
||||
in: `{"apiKey":"hunter2secret"}`,
|
||||
leaked: []string{"hunter2secret"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "json non-sensitive key",
|
||||
in: `{"name":"hunter2secret"}`,
|
||||
keep: []string{"hunter2secret"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "assign sensitive",
|
||||
in: "AWS_SECRET_ACCESS_KEY=supersecretvalue",
|
||||
leaked: []string{"supersecretvalue"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "assign non-sensitive",
|
||||
in: "PATH=/usr/bin",
|
||||
keep: []string{"/usr/bin"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "authorization header case-insensitive",
|
||||
in: "AUTHORIZATION: Bearer " + ghp,
|
||||
leaked: []string{ghp},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "authorization near-miss not a header name",
|
||||
in: "X-Request-Id: Bearer not-a-token-value",
|
||||
keep: []string{"not-a-token-value"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "query token",
|
||||
in: "https://example.test/x?token=" + glpat,
|
||||
leaked: []string{glpat},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "query near-miss",
|
||||
in: "https://example.test/x?page=42",
|
||||
keep: []string{"page=42"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "openai sk-proj",
|
||||
in: "key=" + "sk-proj-abcdefghijklmnopqrstuvwxyz1234",
|
||||
leaked: []string{"sk-proj-abcdefghijklmnopqrstuvwxyz1234"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "sk-ant",
|
||||
in: ant,
|
||||
leaked: []string{ant},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "github_pat",
|
||||
in: pat,
|
||||
leaked: []string{pat},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "ghp classic",
|
||||
in: ghp,
|
||||
leaked: []string{ghp},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "gho oauth",
|
||||
in: gho,
|
||||
leaked: []string{gho},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "ghp too short unchanged",
|
||||
in: "ghp_shorttoken",
|
||||
keep: []string{"ghp_shorttoken"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "unsupported prefix ghx",
|
||||
in: "ghx_abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
keep: []string{"ghx_abcdefghijklmnopqrstuvwxyz1234567890"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "glpat",
|
||||
in: glpat,
|
||||
leaked: []string{glpat},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "google aiza",
|
||||
in: aiza,
|
||||
leaked: []string{aiza},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "slack xoxb",
|
||||
in: slack,
|
||||
leaked: []string{slack},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "aws akia",
|
||||
in: akia,
|
||||
leaked: []string{akia},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "jwt",
|
||||
in: jwt,
|
||||
leaked: []string{jwt},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "secret header x-api-key",
|
||||
in: "X-API-Key: hunter2secret",
|
||||
leaked: []string{"hunter2secret"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "non-secret header unchanged",
|
||||
in: "X-Request-Id: hunter2secret",
|
||||
keep: []string{"hunter2secret"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "url password",
|
||||
in: "https://user:[email protected]/x",
|
||||
leaked: []string{"hunter2secret"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "url user without password unchanged",
|
||||
in: "https://[email protected]/x",
|
||||
keep: []string{"https://[email protected]/x"},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "url password does not unescape path marker",
|
||||
in: "https://user:[email protected]/%5BREDACTED%5D",
|
||||
leaked: []string{"hunter2secret"},
|
||||
keep: []string{"/%5BREDACTED%5D"},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "url encoded username does not inject header",
|
||||
in: "https://%0AAuthorization%3A%20Bearer%20opaque-token:[email protected]/x",
|
||||
leaked: []string{
|
||||
"hunter2secret",
|
||||
"Authorization: Bearer",
|
||||
"\nAuthorization",
|
||||
},
|
||||
keep: []string{"Authorization"},
|
||||
wantHit: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := RedactString(tc.in, Options{})
|
||||
for _, leak := range tc.leaked {
|
||||
if strings.Contains(got, leak) {
|
||||
t.Fatalf("leaked %q in %q", leak, got)
|
||||
}
|
||||
}
|
||||
for _, keep := range tc.keep {
|
||||
if !strings.Contains(got, keep) {
|
||||
t.Fatalf("near-miss changed: want %q still in %q", keep, got)
|
||||
}
|
||||
}
|
||||
if tc.wantHit && !strings.Contains(got, RedactedSecret) {
|
||||
t.Fatalf("expected %q, got %q", RedactedSecret, got)
|
||||
}
|
||||
if !tc.wantHit && strings.Contains(got, RedactedSecret) {
|
||||
t.Fatalf("unexpected redaction: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURLPasswords_HostlessFailClosed(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
leaked string
|
||||
keep []string
|
||||
}{
|
||||
{in: "http://admin:secret@", leaked: "secret", keep: []string{"admin"}},
|
||||
{in: "http://admin:secret@?q=1", leaked: "secret", keep: []string{"?q=1"}},
|
||||
{in: "http://admin:secret@#fragment", leaked: "secret", keep: []string{"#fragment"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := redactURLPasswords(tc.in, RedactedSecret)
|
||||
if strings.Contains(got, tc.leaked) {
|
||||
t.Fatalf("leaked %q in %q", tc.leaked, got)
|
||||
}
|
||||
if !strings.Contains(got, RedactedSecret) {
|
||||
t.Fatalf("expected literal %q in %q", RedactedSecret, got)
|
||||
}
|
||||
if strings.Contains(got, "%5BREDACTED%5D") {
|
||||
t.Fatalf("marker was percent-encoded in %q", got)
|
||||
}
|
||||
for _, keep := range tc.keep {
|
||||
if !strings.Contains(got, keep) {
|
||||
t.Fatalf("want %q still in %q", keep, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactString_URLPasswordHostlessFailClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
leaked []string
|
||||
keep []string
|
||||
}{
|
||||
{
|
||||
name: "hostless trailing at",
|
||||
in: "http://admin:secret@",
|
||||
leaked: []string{"secret"},
|
||||
keep: []string{RedactedSecret, "admin"},
|
||||
},
|
||||
{
|
||||
name: "hostless query",
|
||||
in: "http://admin:secret@?q=1",
|
||||
leaked: []string{"secret"},
|
||||
keep: []string{RedactedSecret, "?q=1"},
|
||||
},
|
||||
{
|
||||
name: "hostless fragment",
|
||||
in: "http://admin:secret@#fragment",
|
||||
leaked: []string{"secret"},
|
||||
keep: []string{RedactedSecret, "#fragment"},
|
||||
},
|
||||
{
|
||||
name: "embedded hostless",
|
||||
in: "connecting to http://admin:hunter2@ now",
|
||||
leaked: []string{"hunter2"},
|
||||
keep: []string{RedactedSecret, "connecting to ", " now"},
|
||||
},
|
||||
{
|
||||
name: "https proxy env hostless",
|
||||
in: "HTTPS_PROXY=http://proxyuser:pr0xyp4ss@",
|
||||
leaked: []string{"pr0xyp4ss"},
|
||||
keep: []string{RedactedSecret, "HTTPS_PROXY=", "proxyuser"},
|
||||
},
|
||||
{
|
||||
name: "host present keeps encoded path and query",
|
||||
in: "https://user:[email protected]/%5BREDACTED%5D?q=a%20b",
|
||||
leaked: []string{"secret"},
|
||||
keep: []string{RedactedSecret, "/%5BREDACTED%5D", "q=a%20b"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := RedactString(tc.in, Options{})
|
||||
if strings.Contains(got, "%5BREDACTED%5D") && !strings.Contains(tc.in, "%5BREDACTED%5D") {
|
||||
t.Fatalf("marker was percent-encoded: %q", got)
|
||||
}
|
||||
for _, leak := range tc.leaked {
|
||||
if strings.Contains(got, leak) {
|
||||
t.Fatalf("leaked %q in %q", leak, got)
|
||||
}
|
||||
}
|
||||
for _, keep := range tc.keep {
|
||||
if !strings.Contains(got, keep) {
|
||||
t.Fatalf("want %q still in %q", keep, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactString_QueryGateIsolatedFromAssignAndTextSecrets(t *testing.T) {
|
||||
const opaque = "opaque-query-fixture-value"
|
||||
in := "https://example.test/x?[password]=" + opaque
|
||||
|
||||
if !IsSensitiveKey("[password]", Options{}) {
|
||||
t.Fatal("fixture key [password] must normalize to a sensitive key")
|
||||
}
|
||||
if assignPattern.MatchString(in) {
|
||||
t.Fatal("fixture must not satisfy assignPattern; otherwise the query gate is not isolated")
|
||||
}
|
||||
if kept := RedactString(opaque, Options{}); kept != opaque {
|
||||
t.Fatalf("opaque value must not match text-secret patterns, got %q", kept)
|
||||
}
|
||||
|
||||
got := RedactString(in, Options{})
|
||||
if strings.Contains(got, opaque) {
|
||||
t.Fatalf("query gate missed the value: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, RedactedSecret) {
|
||||
t.Fatalf("query gate did not insert marker: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactString_UnicodeFoldedHeaderNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
leaked string
|
||||
}{
|
||||
{
|
||||
name: "x-api-key with kelvin sign U+212A",
|
||||
in: "X-API-\u212aey: opaque-header-secret-1",
|
||||
leaked: "opaque-header-secret-1",
|
||||
},
|
||||
{
|
||||
name: "set-cookie with long s U+017F",
|
||||
in: "\u017fet-cookie: session=opaque-cookie-secret-2",
|
||||
leaked: "opaque-cookie-secret-2",
|
||||
},
|
||||
{
|
||||
name: "containsCaseInsensitive kelvin sign U+212A",
|
||||
in: "Authori\u212aation",
|
||||
leaked: "",
|
||||
},
|
||||
{
|
||||
name: "containsCaseInsensitive long s U+017F",
|
||||
in: "\u017fet-cookie",
|
||||
leaked: "",
|
||||
},
|
||||
}
|
||||
|
||||
if !containsCaseInsensitive("Authori\u212aation", "authorikation") {
|
||||
t.Fatal("containsCaseInsensitive failed to fold U+212A KELVIN SIGN to 'k'")
|
||||
}
|
||||
if !containsCaseInsensitive("\u017fet-cookie", "set-cookie") {
|
||||
t.Fatal("containsCaseInsensitive failed to fold U+017F LONG S to 's'")
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if tc.leaked == "" {
|
||||
continue
|
||||
}
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := RedactString(tc.in, Options{})
|
||||
if strings.Contains(got, tc.leaked) {
|
||||
t.Fatalf("leaked %q in %q", tc.leaked, got)
|
||||
}
|
||||
if !strings.Contains(got, RedactedSecret) {
|
||||
t.Fatalf("expected %q in %q", RedactedSecret, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,7 @@ func TestScanBuildsDeterministicSnapshot(t *testing.T) {
|
||||
func TestScanDoesNotFollowSymlinkedDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "main.go", "package main\n")
|
||||
// Not named "target": outside a git work tree Scan skips that name as Cargo build
|
||||
// output, and the real directory must be scanned for this test to exercise the link.
|
||||
target := filepath.Join(root, "real-dir")
|
||||
target := filepath.Join(root, "target")
|
||||
writeFile(t, target, "hidden.go", "package hidden\n")
|
||||
link := filepath.Join(root, "linked")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
@@ -103,7 +101,7 @@ func TestScanDoesNotFollowSymlinkedDirectories(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
want := []string{"main.go", "real-dir/hidden.go"}
|
||||
want := []string{"main.go", "target/hidden.go"}
|
||||
if !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,9 +54,7 @@ func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
|
||||
defer cancel()
|
||||
// --untracked-files=all enumerates files inside new directories; plain
|
||||
// porcelain collapses them to "dir/", which is useless as a FILES row.
|
||||
// --ignore-submodules=dirty keeps git out of submodule work trees, whose
|
||||
// own config gitsafe does not check.
|
||||
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=dirty")
|
||||
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all")
|
||||
if err != nil {
|
||||
return gitSweepMsg{baseline: baseline, ok: false}
|
||||
}
|
||||
@@ -68,7 +66,7 @@ func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
|
||||
// would never match a porcelain path). Best-effort: a failure (e.g. unborn
|
||||
// HEAD in a fresh repo) keeps the file list with zero counts rather than
|
||||
// dropping the sweep.
|
||||
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z", "--ignore-submodules=dirty"); err == nil {
|
||||
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z"); err == nil {
|
||||
stats := parseGitNumstat(numstat)
|
||||
for i := range files {
|
||||
if counts, ok := stats[files[i].path]; ok {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/gitsafe"
|
||||
)
|
||||
|
||||
type PrStatus string
|
||||
@@ -245,7 +243,7 @@ func getLocalDiffStats(ctx context.Context, cwd string, baseBranch string, run p
|
||||
if err != nil {
|
||||
mergeBase = originBase
|
||||
}
|
||||
output, err := run(ctx, cwd, "git", "diff", "--numstat", "--ignore-submodules=dirty", strings.TrimSpace(mergeBase))
|
||||
output, err := run(ctx, cwd, "git", "diff", "--numstat", strings.TrimSpace(mergeBase))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
@@ -292,21 +290,9 @@ func jsonScalarString(raw json.RawMessage) string {
|
||||
}
|
||||
|
||||
func defaultPRCommandRunner(ctx context.Context, cwd string, name string, args ...string) (string, error) {
|
||||
var command *exec.Cmd
|
||||
if name == "git" {
|
||||
// Every git call the TUI makes on its own comes through here (the files
|
||||
// sweep and the pull request diffstat), so the repository being shown
|
||||
// cannot make them run a program.
|
||||
safe, err := gitsafe.Command(ctx, cwd, args...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
command = safe
|
||||
} else {
|
||||
command = exec.CommandContext(ctx, name, args...)
|
||||
if strings.TrimSpace(cwd) != "" {
|
||||
command.Dir = cwd
|
||||
}
|
||||
command := exec.CommandContext(ctx, name, args...)
|
||||
if strings.TrimSpace(cwd) != "" {
|
||||
command.Dir = cwd
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestPrServiceDetectsGitHubPRAndDiffStats(t *testing.T) {
|
||||
"git merge-base origin/main HEAD": {
|
||||
output: "abc123\n",
|
||||
},
|
||||
"git diff --numstat --ignore-submodules=dirty abc123": {
|
||||
"git diff --numstat abc123": {
|
||||
output: "42\t7\tinternal/tui/view.go\n",
|
||||
},
|
||||
}}
|
||||
@@ -49,7 +49,7 @@ func TestPrServiceFallsBackToGitLabMR(t *testing.T) {
|
||||
"git merge-base origin/develop HEAD": {
|
||||
output: "def456\n",
|
||||
},
|
||||
"git diff --numstat --ignore-submodules=dirty def456": {
|
||||
"git diff --numstat def456": {
|
||||
output: "4\t5\ta.go\n5\t6\tb.go\n",
|
||||
},
|
||||
}}
|
||||
@@ -71,7 +71,7 @@ func TestGetLocalDiffStatsFallsBackToOriginBase(t *testing.T) {
|
||||
"git merge-base origin/main HEAD": {
|
||||
err: errors.New("unknown revision"),
|
||||
},
|
||||
"git diff --numstat --ignore-submodules=dirty origin/main": {
|
||||
"git diff --numstat origin/main": {
|
||||
output: "3\t0\tREADME.md\n",
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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 was published once more under it, 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 (docs/NPM_PACKAGING.md). 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 {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package workspaceindex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gitIgnoredPathsTimeout bounds how long Scan waits on git before giving up and falling
|
||||
// back to the non-git behavior. Scan also runs for the per-turn workspace seed, so a slow or
|
||||
// degenerate tree (network filesystem, huge monorepo) must not stall a turn.
|
||||
const gitIgnoredPathsTimeout = 5 * time.Second
|
||||
|
||||
// gitIgnoredPaths asks git which paths under root it considers ignored, honoring every
|
||||
// source git itself resolves (nested .gitignore files, .git/info/exclude,
|
||||
// core.excludesFile). Returns a set of paths relative to root: whole ignored directories
|
||||
// collapsed to one entry plus individually ignored files, using forward slashes and no
|
||||
// trailing slash (the `rel` form Scan computes).
|
||||
//
|
||||
// Returns (nil, false) when root is not inside a git work tree, git is missing or older
|
||||
// than the floor below, or a command fails or times out. Scan then behaves as it does
|
||||
// outside git. A scan must never fail or hang because of this lookup.
|
||||
//
|
||||
// Git version floor: 2.16. `status --ignored=<mode>` landed in 2.16 and `--porcelain=v1`
|
||||
// in 2.11; an older git rejects the arguments, which lands on the (nil, false) path.
|
||||
// GIT_OPTIONAL_LOCKS (2.15) is an environment variable, so an older git ignores it.
|
||||
//
|
||||
// Why `status --ignored=matching` and not `ls-files --others --ignored --directory`:
|
||||
// ls-files stops descending at the first untracked directory, so an ignored directory or
|
||||
// file nested inside one (svc/build-out/, app/cache.db) is never reported. status descends,
|
||||
// collapses a fully ignored directory to a single entry, and lists partially ignored files
|
||||
// individually. `-uno` is not usable as a speedup: with it, status reports no ignored
|
||||
// entries at all.
|
||||
func gitIgnoredPaths(root string) (map[string]bool, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gitIgnoredPathsTimeout)
|
||||
defer cancel()
|
||||
|
||||
// status reports paths relative to the repository top level even when run from a
|
||||
// subdirectory, while Scan's `rel` is relative to root. show-prefix is root's own
|
||||
// location inside the repo ("" at the top level, "svc/" for <repo>/svc), and also the
|
||||
// cheapest way to learn whether this is a git work tree at all.
|
||||
prefixOut, err := gitCommand(ctx, root, "rev-parse", "--show-prefix").Output()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
prefix := strings.TrimSpace(string(prefixOut))
|
||||
|
||||
// -z: NUL-terminated and unquoted. Without it git C-quotes non-ASCII bytes
|
||||
// (core.quotePath), e.g. "caf\303\251/", which would never match the raw `rel`.
|
||||
// The trailing "." limits the report to root when root is a repository subdirectory.
|
||||
// --untracked-files=normal: status.showUntrackedFiles=no makes git reject
|
||||
// --ignored=matching; without an explicit override Scan falls back to the fixed list.
|
||||
out, err := gitCommand(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--ignored=matching", ".").Output()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return parseIgnoredStatus(out, prefix), true
|
||||
}
|
||||
|
||||
// parseIgnoredStatus extracts the ignored paths from `git status --porcelain=v1 -z` output,
|
||||
// made relative to prefix (root's location inside the repository).
|
||||
//
|
||||
// Each record is "XY <path>". A rename or copy (R or C in either status column) is followed
|
||||
// by one more NUL field holding the original path with no XY prefix. That field is consumed,
|
||||
// not inspected: an original name that itself begins with "!! " would otherwise read as an
|
||||
// ignored entry and hide a real file from Scan.
|
||||
func parseIgnoredStatus(out []byte, prefix string) map[string]bool {
|
||||
paths := make(map[string]bool)
|
||||
skipOrigPath := false
|
||||
for entry := range bytes.SplitSeq(out, []byte{0}) {
|
||||
if skipOrigPath {
|
||||
skipOrigPath = false
|
||||
continue
|
||||
}
|
||||
if len(entry) < 3 {
|
||||
continue
|
||||
}
|
||||
if x, y := entry[0], entry[1]; x == 'R' || x == 'C' || y == 'R' || y == 'C' {
|
||||
skipOrigPath = true
|
||||
continue
|
||||
}
|
||||
// Ignored entries are "!! <path>"; untracked "??" and changed entries are skipped.
|
||||
rest, ok := bytes.CutPrefix(entry, []byte("!! "))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rel, ok := strings.CutPrefix(string(rest), prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rel = path.Clean(strings.TrimSuffix(rel, "/"))
|
||||
if rel == "." || rel == "" {
|
||||
continue
|
||||
}
|
||||
paths[rel] = true
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// gitCommand builds a git invocation with a fixed argv (no shell) rooted at dir.
|
||||
//
|
||||
// GIT_OPTIONAL_LOCKS=0 stops `git status` from taking .git/index.lock to refresh the
|
||||
// index stat cache. Scan runs on every turn, often while the user or the agent is running
|
||||
// git in the same repository; an opportunistic lock there can make a concurrent
|
||||
// `git commit` or `git add` fail with "index.lock exists".
|
||||
func gitCommand(ctx context.Context, dir string, args ...string) *exec.Cmd {
|
||||
// #nosec G204 -- fixed argv, no shell; dir is the caller's own absolute path.
|
||||
// core.fsmonitor in .git/config can point at a command git status runs; disable it so
|
||||
// Scan never executes foreign hooks from the repository under scan.
|
||||
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir, "-c", "core.fsmonitor=false"}, args...)...)
|
||||
cmd.Env = append(withoutRepoLocationEnv(os.Environ()), "GIT_OPTIONAL_LOCKS=0")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// withoutRepoLocationEnv drops the repository path overrides from git(1),
|
||||
// "The Git Repository": GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_COMMON_DIR,
|
||||
// GIT_OBJECT_DIRECTORY, and GIT_ALTERNATE_OBJECT_DIRECTORIES. Hooks export
|
||||
// them (githooks(5)) so git commands inside the hook find that repository.
|
||||
// Inherited, -C dir still describes the hook's repository, and Scan applies
|
||||
// its ignore rules instead of the tree under dir. GIT_CEILING_DIRECTORIES is
|
||||
// not one of these; it only bounds discovery and must be inherited.
|
||||
func withoutRepoLocationEnv(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
key, _, _ := strings.Cut(kv, "=")
|
||||
switch key {
|
||||
case "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
|
||||
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES":
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
package workspaceindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isolateGitConfig keeps the git child processes away from the developer's real global and
|
||||
// system config: a global core.excludesFile would change what counts as ignored and make
|
||||
// these tests depend on the machine. GIT_CONFIG_GLOBAL needs git >= 2.32; HOME and
|
||||
// XDG_CONFIG_HOME cover older git and macOS/Windows home resolution.
|
||||
func isolateGitConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
}
|
||||
|
||||
// stopGitDiscoveryAbove keeps git from finding an enclosing repository above root, so a
|
||||
// TMPDIR that happens to live inside a git work tree cannot turn a "not a repo" fixture
|
||||
// into one.
|
||||
func stopGitDiscoveryAbove(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(root))
|
||||
}
|
||||
|
||||
// requireGitIgnoredMatching skips below git 2.16, where `status --ignored=matching` landed.
|
||||
// Older git takes Scan's non-git path, which the fallback tests cover separately.
|
||||
func requireGitIgnoredMatching(t *testing.T) {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "version").Output()
|
||||
if err != nil {
|
||||
t.Skipf("git unavailable: %v", err)
|
||||
}
|
||||
var major, minor int
|
||||
if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(out)), "git version "), "%d.%d", &major, &minor); err != nil {
|
||||
t.Skipf("cannot parse %q: %v", out, err)
|
||||
}
|
||||
if major < 2 || (major == 2 && minor < 16) {
|
||||
t.Skipf("needs git >= 2.16 for status --ignored=matching, have %d.%d", major, minor)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=zero-test", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func initGitRepo(t *testing.T, root, gitignore string) {
|
||||
t.Helper()
|
||||
isolateGitConfig(t)
|
||||
requireGitIgnoredMatching(t)
|
||||
stopGitDiscoveryAbove(t, root)
|
||||
runGit(t, root, "init", "-q")
|
||||
if gitignore != "" {
|
||||
writeFile(t, root, ".gitignore", gitignore)
|
||||
}
|
||||
}
|
||||
|
||||
// A virtualenv or cache directory whose name no fixed list anticipates must still be
|
||||
// skipped when the repository's own .gitignore excludes it. Reported against a real repo
|
||||
// where `zero repo-map` spent its whole file budget inside a `.locust_env/` virtualenv and
|
||||
// never reached the repo's own README.md.
|
||||
func TestScanHonorsRepoGitignore(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, ".locust_env/\n*.db\ncafé-cache/\n")
|
||||
|
||||
writeFile(t, root, "README.md", "# Example\n")
|
||||
writeFile(t, root, "app/main.py", "print('hi')\n")
|
||||
writeFile(t, root, "app/café.py", "x = 1\n")
|
||||
writeFile(t, root, ".locust_env/lib/python3.13/site-packages/flask/sansio/README.md", "vendored")
|
||||
writeFile(t, root, "audit_results.db", "sqlite")
|
||||
// Ignored file inside an untracked directory: `ls-files --directory` would miss it.
|
||||
writeFile(t, root, "app/cache.db", "sqlite")
|
||||
// Non-ASCII ignored directory: only matches because the lookup uses -z (no C-quoting).
|
||||
writeFile(t, root, "café-cache/blob.json", "{}")
|
||||
|
||||
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
want := []string{".gitignore", "README.md", "app/café.py", "app/main.py"}
|
||||
if !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Scanning a repository subdirectory must resolve ignore rules relative to that
|
||||
// subdirectory (Scan's `rel`), not relative to the repository top level.
|
||||
func TestScanHonorsRepoGitignoreFromSubdirectoryRoot(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
initGitRepo(t, repo, "build-out/\n")
|
||||
writeFile(t, repo, "svc/handler.go", "package svc\n")
|
||||
writeFile(t, repo, "svc/build-out/gen.go", "package gen\n")
|
||||
|
||||
got, err := Scan(filepath.Join(repo, "svc"), Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if want := []string{"handler.go"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Inside a git work tree, git decides. A tracked directory that merely shares a name with
|
||||
// a build-cache directory (a Go package called "target", say) is real source and must be
|
||||
// scanned; the fixed list is only a stand-in for when git cannot be asked.
|
||||
func TestScanKeepsTrackedBuildCacheNamedDirInsideGitRepo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, "")
|
||||
writeFile(t, root, "main.go", "package main\n")
|
||||
writeFile(t, root, "target/target.go", "package target\n")
|
||||
runGit(t, root, "add", ".")
|
||||
runGit(t, root, "commit", "-q", "-m", "fixture")
|
||||
|
||||
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if want := []string{"main.go", "target/target.go"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Scanning a directory that the enclosing repository ignores as a whole must still list
|
||||
// its contents: git reports the root itself as ignored, which is not a reason to skip
|
||||
// everything under it.
|
||||
func TestScanOfIgnoredRootStillListsItsFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
initGitRepo(t, repo, "out/\n")
|
||||
writeFile(t, repo, "out/report.md", "# report\n")
|
||||
writeFile(t, repo, "out/data/rows.csv", "a,b\n")
|
||||
|
||||
got, err := Scan(filepath.Join(repo, "out"), Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if want := []string{"data/rows.csv", "report.md"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Outside a git work tree, Scan falls back to the fixed build/cache list so the file
|
||||
// budget is not spent on build output. Reported against a repo where
|
||||
// rust/*/target/debug/.fingerprint alone held hundreds of files: with a small budget the
|
||||
// real source must still be reached and the scan must not report truncation.
|
||||
func TestScanSkipsBuildCacheDirsOutsideGit(t *testing.T) {
|
||||
isolateGitConfig(t)
|
||||
root := t.TempDir()
|
||||
stopGitDiscoveryAbove(t, root)
|
||||
if _, ok := gitIgnoredPaths(root); ok {
|
||||
t.Fatalf("gitIgnoredPaths(non-repo) reported a work tree")
|
||||
}
|
||||
|
||||
writeFile(t, root, "src/lib.rs", "pub fn f() {}\n")
|
||||
writeFile(t, root, "app/main.py", "print('hi')\n")
|
||||
for i := range 10 {
|
||||
writeFile(t, root, fmt.Sprintf("rust/crate/target/debug/.fingerprint/foo-%d/lib-foo.d", i), "x")
|
||||
}
|
||||
writeFile(t, root, "app/__pycache__/main.cpython-312.pyc", "x")
|
||||
writeFile(t, root, ".venv/lib/site-packages/pkg.py", "x")
|
||||
writeFile(t, root, "Venv/lib/pkg.py", "x")
|
||||
writeFile(t, root, ".terraform/providers/provider.json", "x")
|
||||
writeFile(t, root, ".pytest_cache/v/cache/nodeids", "x")
|
||||
writeFile(t, root, ".mypy_cache/3.12/mod.json", "x")
|
||||
writeFile(t, root, ".ruff_cache/content", "x")
|
||||
|
||||
got, err := Scan(root, Options{MaxFiles: 3, MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if want := []string{"app/main.py", "src/lib.rs"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
if got.Truncated {
|
||||
t.Fatalf("Truncated=true: build-cache files consumed the file budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When git cannot run at all, Scan must not error and must behave exactly as outside a
|
||||
// repository, even inside one: .gitignore is not consulted, the fixed list applies.
|
||||
func TestScanFallsBackWhenGitUnavailable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, ".locust_env/\n")
|
||||
writeFile(t, root, "main.go", "package main\n")
|
||||
writeFile(t, root, ".locust_env/lib/x.py", "kept: git is not available to honor .gitignore")
|
||||
writeFile(t, root, "target/debug/app.d", "skipped by the fixed list")
|
||||
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
if paths, ok := gitIgnoredPaths(root); ok || paths != nil {
|
||||
t.Fatalf("gitIgnoredPaths without git = (%v, %v), want (nil, false)", paths, ok)
|
||||
}
|
||||
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if want := []string{".gitignore", ".locust_env/lib/x.py", "main.go"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldSkipDir is shared with glob, grep, list_directory and path autocomplete. The
|
||||
// build/cache names Scan skips must stay visible there, so a question about a dependency in
|
||||
// .venv or generated code under target can still be answered.
|
||||
func TestShouldSkipDirLeavesScanOnlyDirsVisibleToTools(t *testing.T) {
|
||||
for _, name := range []string{"target", "__pycache__", ".venv", "venv", ".pytest_cache", ".terraform", ".mypy_cache", ".ruff_cache"} {
|
||||
if ShouldSkipDir(name) {
|
||||
t.Fatalf("ShouldSkipDir(%q)=true: tools would stop seeing it", name)
|
||||
}
|
||||
if !isScanOnlySkipDir(name) {
|
||||
t.Fatalf("isScanOnlySkipDir(%q)=false want true", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"Target", " target ", "__PYCACHE__", ".VENV"} {
|
||||
if !isScanOnlySkipDir(name) {
|
||||
t.Fatalf("isScanOnlySkipDir(%q)=false want true (case/whitespace-insensitive)", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"src", "targets", "venv2", "internal"} {
|
||||
if isScanOnlySkipDir(name) {
|
||||
t.Fatalf("isScanOnlySkipDir(%q)=true want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A repository's core.fsmonitor can run an external command during status; the
|
||||
// ignored-path lookup must not trigger it. Git for Windows hands both that
|
||||
// command and the hook body to sh, which consumes backslashes. The script path
|
||||
// stored in core.fsmonitor and the marker path inside the hook are quoted and
|
||||
// use forward slashes: an unquoted backslash script path never starts the
|
||||
// hook, and an unquoted backslash marker (touch C:\tmp\run\marker) creates
|
||||
// C:tmprunmarker, so the file this test stats is never created. A status
|
||||
// without the override must create the marker first; otherwise a hook that
|
||||
// does not run would still pass.
|
||||
func TestGitIgnoredPathsDisablesRepoFsmonitor(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, ".locust_env/\n")
|
||||
writeFile(t, root, "README.md", "# x\n")
|
||||
writeFile(t, root, ".locust_env/lib/x.py", "x\n")
|
||||
|
||||
marker := filepath.Join(root, ".fsmonitor-ran")
|
||||
script := filepath.Join(root, "fsmonitor-hook.sh")
|
||||
hook := "#!/bin/sh\ntouch '" + filepath.ToSlash(marker) + "'\n"
|
||||
if err := os.WriteFile(script, []byte(hook), 0o755); err != nil {
|
||||
t.Fatalf("write fsmonitor hook: %v", err)
|
||||
}
|
||||
runGit(t, root, "config", "core.fsmonitor", "'"+filepath.ToSlash(script)+"'")
|
||||
|
||||
runGit(t, root, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--ignored=matching", ".")
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("fsmonitor hook did not run without the override: %v", err)
|
||||
}
|
||||
if err := os.Remove(marker); err != nil {
|
||||
t.Fatalf("remove marker: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := gitIgnoredPaths(root); !ok {
|
||||
t.Fatal("gitIgnoredPaths failed")
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatalf("fsmonitor hook ran during gitIgnoredPaths (marker %q exists)", marker)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// status.showUntrackedFiles=no makes git reject --ignored=matching unless
|
||||
// --untracked-files=normal is passed explicitly; ignored paths must still come from git.
|
||||
func TestScanHonorsRepoGitignoreWhenShowUntrackedFilesNo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, ".locust_env/\n")
|
||||
runGit(t, root, "config", "status.showUntrackedFiles", "no")
|
||||
writeFile(t, root, "README.md", "# Example\n")
|
||||
writeFile(t, root, "app/main.py", "print('hi')\n")
|
||||
writeFile(t, root, ".locust_env/lib/x.py", "vendored\n")
|
||||
|
||||
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
want := []string{".gitignore", "README.md", "app/main.py"}
|
||||
if !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Git exports repository-location variables to hooks (githooks(5)). With them
|
||||
// pointing at another repository, Scan must still apply root's own .gitignore
|
||||
// and match a lookup made without those variables.
|
||||
func TestScanIgnoresInheritedRepositoryLocation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, ".locust_env/\n")
|
||||
writeFile(t, root, "README.md", "# Example\n")
|
||||
writeFile(t, root, ".locust_env/lib/x.py", "vendored\n")
|
||||
|
||||
clean, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
other := t.TempDir()
|
||||
initGitRepo(t, other, "")
|
||||
writeFile(t, other, "README.md", "other\n")
|
||||
gitDir := filepath.Join(other, ".git")
|
||||
t.Setenv("GIT_DIR", gitDir)
|
||||
t.Setenv("GIT_WORK_TREE", other)
|
||||
t.Setenv("GIT_INDEX_FILE", filepath.Join(gitDir, "index"))
|
||||
t.Setenv("GIT_COMMON_DIR", gitDir)
|
||||
t.Setenv("GIT_OBJECT_DIRECTORY", filepath.Join(gitDir, "objects"))
|
||||
t.Setenv("GIT_ALTERNATE_OBJECT_DIRECTORIES", filepath.Join(gitDir, "objects"))
|
||||
|
||||
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan with inherited repository location: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(pathsOf(got.Files), pathsOf(clean.Files)) {
|
||||
t.Fatalf("Files=%v want clean environment %v", pathsOf(got.Files), pathsOf(clean.Files))
|
||||
}
|
||||
}
|
||||
|
||||
// The ignored-path lookup runs every turn, often next to a git command the user or agent
|
||||
// is running in the same repository. It must not take the optional index lock, and a
|
||||
// GIT_OPTIONAL_LOCKS value inherited from the environment must not re-enable it.
|
||||
func TestGitCommandDisablesOptionalLocks(t *testing.T) {
|
||||
t.Setenv("GIT_OPTIONAL_LOCKS", "1")
|
||||
cmd := gitCommand(context.Background(), t.TempDir(), "status")
|
||||
value := ""
|
||||
for _, entry := range cmd.Env {
|
||||
if after, ok := strings.CutPrefix(entry, "GIT_OPTIONAL_LOCKS="); ok {
|
||||
value = after
|
||||
}
|
||||
}
|
||||
if value != "0" {
|
||||
t.Fatalf("effective GIT_OPTIONAL_LOCKS=%q want \"0\"", value)
|
||||
}
|
||||
}
|
||||
|
||||
// A rename or copy record carries its original path in the next NUL field, with no XY
|
||||
// prefix. An original name that starts with "!! " must not be read as an ignored entry.
|
||||
func TestParseIgnoredStatusSkipsRenameAndCopyOrigPaths(t *testing.T) {
|
||||
out := []byte("R b\x00!! a\x00" +
|
||||
"C d\x00!! c\x00" +
|
||||
" R e\x00!! f\x00" +
|
||||
"!! out/\x00" +
|
||||
"?? new.txt\x00" +
|
||||
" M main.go\x00" +
|
||||
"!! svc/build/\x00")
|
||||
|
||||
if got, want := parseIgnoredStatus(out, ""), map[string]bool{"out": true, "svc/build": true}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("prefix \"\": got %v want %v", got, want)
|
||||
}
|
||||
if got, want := parseIgnoredStatus(out, "svc/"), map[string]bool{"build": true}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("prefix \"svc/\": got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: after `git mv "!! a" b`, status reports "R b\0!! a\0". The tracked file "a"
|
||||
// must still be scanned.
|
||||
func TestScanKeepsFileNamedLikeRenameOrigPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
initGitRepo(t, root, "")
|
||||
writeFile(t, root, "!! a", "moved\n")
|
||||
writeFile(t, root, "a", "kept\n")
|
||||
runGit(t, root, "add", ".")
|
||||
runGit(t, root, "commit", "-q", "-m", "fixture")
|
||||
runGit(t, root, "mv", "!! a", "b")
|
||||
|
||||
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if want := []string{"a", "b"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
|
||||
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
|
||||
}
|
||||
}
|
||||
@@ -63,18 +63,6 @@ func Scan(root string, options Options) (Summary, error) {
|
||||
maxDepth = DefaultMaxDepth
|
||||
}
|
||||
|
||||
// Inside a git work tree, git's own ignore rules decide what else to skip; outside one
|
||||
// (or when git is unavailable or too old), a fixed list of build/cache directory names
|
||||
// stands in. Both apply to Scan only: ShouldSkipDir is shared with glob, grep,
|
||||
// list_directory and path autocomplete, which must keep seeing these directories.
|
||||
ignored, hasIgnored := gitIgnoredPaths(cleanRoot)
|
||||
skipScanDir := func(rel, name string) bool {
|
||||
if hasIgnored {
|
||||
return ignored[rel]
|
||||
}
|
||||
return isScanOnlySkipDir(name)
|
||||
}
|
||||
|
||||
files := []File{}
|
||||
dirs := map[string]struct{}{}
|
||||
maxDepthSeen := 0
|
||||
@@ -95,7 +83,7 @@ func Scan(root string, options Options) (Summary, error) {
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
if entry.IsDir() {
|
||||
if ShouldSkipDir(entry.Name()) || isSymlink(entry) || skipScanDir(rel, entry.Name()) {
|
||||
if ShouldSkipDir(entry.Name()) || isSymlink(entry) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
depth := pathDepth(rel)
|
||||
@@ -110,7 +98,7 @@ func Scan(root string, options Options) (Summary, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isSymlink(entry) || ShouldSkipFile(rel) || (hasIgnored && ignored[rel]) {
|
||||
if isSymlink(entry) || ShouldSkipFile(rel) {
|
||||
return nil
|
||||
}
|
||||
depth := FileDepth(rel)
|
||||
@@ -187,18 +175,6 @@ func ShouldSkipDir(name string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// isScanOnlySkipDir names build and cache directories Scan skips when it cannot ask git
|
||||
// what is ignored. It is deliberately separate from ShouldSkipDir: a tracked package named
|
||||
// "target" or a question about a dependency inside .venv must stay visible to the tools.
|
||||
func isScanOnlySkipDir(name string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "target", "__pycache__", ".venv", "venv", ".pytest_cache", ".terraform", ".mypy_cache", ".ruff_cache":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldSkipFile(rel string) bool {
|
||||
base := strings.ToLower(path.Base(filepath.ToSlash(rel)))
|
||||
switch base {
|
||||
|
||||
@@ -83,9 +83,7 @@ func Build(input Input) Seed {
|
||||
}
|
||||
|
||||
// BuildFromWorkspace scans the local filesystem with workspaceindex and builds
|
||||
// a seed from the resulting file list. Its only git operation is the read-only
|
||||
// ignored-path lookup inside workspaceindex.Scan; branch and dirty state come
|
||||
// from the caller's GitInfo.
|
||||
// a seed from the resulting file list. It performs no git operations.
|
||||
func BuildFromWorkspace(root string, git GitInfo) (Seed, error) {
|
||||
summary, err := workspaceindex.Scan(root, workspaceindex.Options{
|
||||
MaxFiles: workspaceindex.DefaultMaxFiles,
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.11.0",
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@twigpine/zero",
|
||||
"version": "0.11.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.11.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,16 +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 was
|
||||
// added for the one release (0.10.0) also published 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,
|
||||
@@ -79,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 = () => {
|
||||
@@ -97,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}`);
|
||||
}
|
||||
@@ -115,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;
|
||||
}
|
||||
@@ -124,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;
|
||||
}
|
||||
@@ -280,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}`);
|
||||
@@ -315,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,
|
||||
@@ -332,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 });
|
||||
@@ -353,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}`),
|
||||
@@ -379,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,188 +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. wait until npm shows every platform version, then check that `latest`
|
||||
# was not moved onto one;
|
||||
# 3. the wrapper, which takes `latest`, then wait until `latest` is its
|
||||
# version.
|
||||
#
|
||||
# The waits exist because npm applies a publish asynchronously: `npm publish`
|
||||
# returns once the upload is accepted, and the version can appear minutes
|
||||
# later (ten minutes for a platform payload of 0.10.0). Ordering the uploads
|
||||
# alone would still let the wrapper appear first, with aliases that 404.
|
||||
#
|
||||
# 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 registry lookups,
|
||||
# and ZERO_NPM_PUBLISH_WAIT_TIMEOUT the seconds to wait for npm to apply the
|
||||
# publishes (default 1800).
|
||||
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"
|
||||
# whole_seconds <variable> <value> <max>: the value as a decimal number of
|
||||
# seconds. Checked here, before anything is published: Bash arithmetic reads a
|
||||
# leading zero as octal (0600 would wait 384 seconds, and 08 is an error), a
|
||||
# negative timeout would end the wait before it starts, and a bad delay would
|
||||
# only fail at the first sleep, possibly after the platform payloads went out.
|
||||
whole_seconds() {
|
||||
case "$2" in
|
||||
'' | *[!0-9]*) fail "$1 must be a whole number of seconds, got '$2'" ;;
|
||||
esac
|
||||
# Bounded before the conversion so it cannot overflow.
|
||||
[ "${#2}" -le 18 ] || fail "$1 must be at most $3 seconds, got '$2'"
|
||||
local seconds=$((10#$2))
|
||||
[ "$seconds" -le "$3" ] || fail "$1 must be at most $3 seconds, got '$2'"
|
||||
echo "$seconds"
|
||||
}
|
||||
|
||||
delay="$(whole_seconds ZERO_NPM_PUBLISH_RETRY_DELAY "${ZERO_NPM_PUBLISH_RETRY_DELAY:-5}" 600)"
|
||||
wait_timeout="$(whole_seconds ZERO_NPM_PUBLISH_WAIT_TIMEOUT "${ZERO_NPM_PUBLISH_WAIT_TIMEOUT:-1800}" 86400)"
|
||||
|
||||
# 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 "$@")
|
||||
}
|
||||
|
||||
# Unlike already_published, these lookups may fail without stopping the job:
|
||||
# while npm has not applied a publish, an error or an E404 both mean "not
|
||||
# yet", and the deadline bounds the wait.
|
||||
version_visible() {
|
||||
[ "$(npm view "${name}@$1" version 2>/dev/null || true)" = "$1" ]
|
||||
}
|
||||
|
||||
latest_is() {
|
||||
[ "$(npm view "$name" dist-tags.latest 2>/dev/null || true)" = "$1" ]
|
||||
}
|
||||
|
||||
# One deadline covers every wait in this run, so the job cannot wait
|
||||
# wait_timeout once per version.
|
||||
deadline=$((SECONDS + wait_timeout))
|
||||
|
||||
# wait_until <what we are waiting for> <command...>
|
||||
wait_until() {
|
||||
local what="$1"
|
||||
shift
|
||||
until "$@"; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
fail "npm accepted the upload but still does not show ${what} after ${wait_timeout}s. Re-run once it does: versions already on the registry are skipped."
|
||||
fi
|
||||
sleep "$delay"
|
||||
done
|
||||
}
|
||||
|
||||
for dir in "${platform_dirs[@]}"; do
|
||||
dir="${dir%/}"
|
||||
publish "$dir" "$(manifest_field "$dir" version)" --tag platform
|
||||
done
|
||||
|
||||
for dir in "${platform_dirs[@]}"; do
|
||||
platform_version="$(manifest_field "${dir%/}" version)"
|
||||
wait_until "${name}@${platform_version}" version_visible "$platform_version"
|
||||
echo "${name}@${platform_version} is on the registry."
|
||||
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 (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"
|
||||
|
||||
wait_until "${name}@${version} as the latest dist-tag" latest_is "$version"
|
||||
echo "dist-tag latest is ${version}."
|
||||
@@ -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