mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb753bd40 | ||
|
|
1e31e98087 | ||
|
|
fdddb05ba8 | ||
|
|
b6117af86d |
No files matched your search
@@ -97,16 +97,21 @@ jobs:
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
|
||||
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
|
||||
# assets are live — the package's postinstall downloads its binary from the
|
||||
# GitHub Release, so publishing before the assets exist would ship a package
|
||||
# that fails to install.
|
||||
# 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 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:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
@@ -156,10 +161,11 @@ jobs:
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs postinstall will build (internal/release/release.go
|
||||
# asset-name scheme) as an anonymous client. This also blocks publishing
|
||||
# while the repo is still private — private release assets 404 without
|
||||
# auth, exactly as they would for an installing user.
|
||||
# Probe the exact URLs the wrapper's first-run downloader builds
|
||||
# (internal/release/release.go asset-name scheme) as an anonymous client
|
||||
# — install.sh/install.ps1 and `zero update` read them too. This also
|
||||
# blocks publishing while the repo is still private: private release
|
||||
# assets 404 without auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -173,12 +179,64 @@ jobs:
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: zero-*
|
||||
path: dist/artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Assemble npm packages
|
||||
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
|
||||
|
||||
# Platform versions are semver PRERELEASES of the wrapper version
|
||||
# (X.Y.Z-linux-x64), so they must never publish under the default
|
||||
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
|
||||
# deliver a platform payload as the CLI. Publish them under a dedicated
|
||||
# tag and assert `latest` survived before the wrapper goes out.
|
||||
- name: Publish platform packages to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
for dir in dist/npm/platforms/*/; do
|
||||
echo "publishing ${dir}"
|
||||
(cd "$dir" && npm publish --access public --tag platform)
|
||||
done
|
||||
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
|
||||
# bootstrap publish): under set -e a bare failure here would abort
|
||||
# AFTER the platform payloads went out, leaving no wrapper and a
|
||||
# re-run that fails on republishing existing versions. An empty
|
||||
# value falls through the guard below.
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
case "$latest" in
|
||||
"${version}-"*)
|
||||
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
|
||||
|
||||
- name: Publish wrapper to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
(cd dist/npm/wrapper && npm publish --access public)
|
||||
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
|
||||
# a publish by a few seconds).
|
||||
for attempt in 1 2 3 4 5; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
if [ "$latest" = "$version" ]; then
|
||||
echo "dist-tag latest is ${version}."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
|
||||
exit 1
|
||||
@@ -235,6 +235,13 @@ Servers are merged from user and project configs (project wins on conflicts). To
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
Project MCP config remains supported for shared team servers, but a project layer
|
||||
that changes an existing server's target (`url`, `command`, or `args`) does not
|
||||
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
|
||||
or use a new server name. OAuth tokens are bound to the resolved server identity,
|
||||
so changing a server target can require `zero mcp oauth login <server>` again.
|
||||
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
|
||||
@@ -49,34 +49,15 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
The npm package is a small wrapper whose platform build (Linux and macOS on
|
||||
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
|
||||
installs as an optional dependency straight from the npm registry — no install
|
||||
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
|
||||
no trust or approval steps. Installs that skip optional dependencies
|
||||
(`--omit=optional`) still work: the wrapper fetches the binary from the
|
||||
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.
|
||||
|
||||
### Install scripts
|
||||
|
||||
|
||||
+1
-19
@@ -38,25 +38,7 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||
|
||||
### Bun
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
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)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
|
||||
+76
-23
@@ -2,6 +2,7 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -39,6 +40,11 @@ function resolveHelper(packageRoot, name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
|
||||
// installs vendor the helpers inside the platform package instead (a helpers/
|
||||
// directory next to the binary, found by the Go side on its own), so this
|
||||
// manifest is usually empty — it only matters for a downloaded-binary install
|
||||
// whose wrapper still carries helper dependencies.
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
@@ -49,38 +55,85 @@ function localControlHelperManifest(packageRoot) {
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
// 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 = `@gitlawb/zero-${process.platform}-${process.arch}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidate = join(dirname(manifestPath), zeroBinaryName());
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
// Mirrors the release matrix (internal/release/release.go): the platforms a
|
||||
// GitHub Release asset exists for. android maps to the linux asset; there is
|
||||
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
|
||||
function releaseAssetAvailable() {
|
||||
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
|
||||
process.platform
|
||||
];
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
|
||||
if (!platform || !arch) return false;
|
||||
return !(platform === 'windows' && arch === 'arm64');
|
||||
}
|
||||
|
||||
// Fallback for installs without a platform package (--omit=optional, package
|
||||
// managers that skip optionalDependencies): fetch the binary next to the
|
||||
// wrapper with the same checksum-verified downloader that used to run as
|
||||
// postinstall. Deliberately NOT cached on failure — a transient network error
|
||||
// self-heals on the next run — so this retries on every invocation until a
|
||||
// binary is in place. Platforms with no release asset skip the attempt.
|
||||
function downloadMissingBinary(packageRoot) {
|
||||
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
|
||||
);
|
||||
spawnSync(process.execPath, [downloadScript], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
const downloaded = join(packageRoot, zeroBinaryName());
|
||||
if (existsSync(downloaded)) return downloaded;
|
||||
downloadMissingBinary(packageRoot);
|
||||
return existsSync(downloaded) ? downloaded : null;
|
||||
}
|
||||
|
||||
const nativePath = resolveNativeBinary();
|
||||
|
||||
if (!nativePath) {
|
||||
console.error(
|
||||
'[zero] No native binary is available for this install.\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.\n' +
|
||||
'\n' +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'Things to try:\n' +
|
||||
' - reinstall without omitting optional dependencies:\n' +
|
||||
' npm install -g @gitlawb/zero\n' +
|
||||
' - run the downloader manually (needs write access to the package dir):\n' +
|
||||
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
'If this platform has no prebuilt binary, build from source:\n' +
|
||||
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
|
||||
+21
-48
@@ -6,8 +6,9 @@ Zero is distributed as:
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.25+
|
||||
|
||||
The npm package and install scripts download a platform-specific release archive.
|
||||
They require a published GitHub Release for the requested version.
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
|
||||
## npm
|
||||
|
||||
@@ -16,59 +17,32 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
|
||||
`zero` command and downloads the matching release binary during `postinstall`.
|
||||
The package supports Linux and macOS on x64 and arm64, and Windows on x64
|
||||
(Windows on ARM runs the x64 build under emulation). It installs a small
|
||||
`zero` wrapper plus, as an optional dependency, a platform payload with the
|
||||
native binary and the bundled browser/terminal control helpers. There are no
|
||||
install scripts and nothing is downloaded from outside the npm registry, so
|
||||
the install is silent and works identically under npm, Bun, pnpm, and yarn —
|
||||
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
|
||||
package architecture.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
- network access to npm
|
||||
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
If the install skipped optional dependencies (`npm install --omit=optional`,
|
||||
or a package manager configured to do so), the wrapper falls back to
|
||||
downloading the binary from the matching GitHub Release, with checksum
|
||||
verification. The fetch is retried on each run until a binary is in place (a
|
||||
transient network failure heals itself; an install directory the current user
|
||||
cannot write to keeps failing — rerun the install or the command below with
|
||||
sufficient permissions). To trigger the fetch manually:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
|
||||
```
|
||||
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
@@ -252,8 +226,7 @@ Supported targets:
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `windows-x64`
|
||||
- `windows-arm64`
|
||||
- `windows-x64` (Windows on ARM runs this build under emulation)
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download
|
||||
both files, verify the checksum, and then copy the binary into the install
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# npm packaging
|
||||
|
||||
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
|
||||
`release-artifacts.yml`.
|
||||
|
||||
## Goals
|
||||
|
||||
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,
|
||||
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
|
||||
install zero without prompts, trust ceremonies, or broken binaries.
|
||||
- No network fetches outside the npm registry at install time. GitHub being
|
||||
down or rate-limited must not break `npm install`.
|
||||
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
|
||||
of the box.
|
||||
|
||||
## Architecture
|
||||
|
||||
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
|
||||
plus per-platform payloads carrying the native binaries.
|
||||
|
||||
```
|
||||
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
|
||||
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
|
||||
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
|
||||
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
|
||||
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
|
||||
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
|
||||
```
|
||||
|
||||
- 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.
|
||||
The alias suffixes use Node's `process.platform`/`process.arch` names so
|
||||
`bin/zero.js` can derive its platform package directly.
|
||||
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
|
||||
them and skips the rest.
|
||||
- A platform payload is assembled from the platform's release archive by
|
||||
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
|
||||
the platform's sandbox helpers, and the vendored `helpers/` tree (see
|
||||
below). It has no `bin`, no scripts, and no dependencies — it is inert on
|
||||
its own; the wrapper execs the binary out of it.
|
||||
- The published wrapper has **no scripts and no dependencies** — the
|
||||
assembly script strips both from the repo `package.json` and injects the
|
||||
exact-version `optionalDependencies`. (The repo `package.json` keeps
|
||||
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
|
||||
the vendored helpers tree; they are never installed by consumers.)
|
||||
- There is **no windows-arm64 build** (matches the release matrix); Windows on
|
||||
ARM runs the x64 build under emulation via the first-run fallback.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
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.
|
||||
3. If neither exists (`--omit=optional`, package managers that skip optional
|
||||
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
|
||||
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
|
||||
verified against the release's own checksum file, no zip-slip), invoked by
|
||||
the wrapper itself. Failures are deliberately not cached — the fetch
|
||||
retries on every run until a binary is in place, so a transient network
|
||||
error self-heals. Platforms with no release asset (anything outside the
|
||||
matrix above, including windows-arm64) skip the attempt entirely rather
|
||||
than probing the network each run.
|
||||
4. If the download is impossible too, print build-from-source guidance.
|
||||
|
||||
There is deliberately **no `scripts.postinstall`** in any published
|
||||
`package.json`. The downloader exists only as a first-run fallback.
|
||||
|
||||
### Vendored helpers (`helpers/`)
|
||||
|
||||
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
|
||||
**vendored binaries/packages inside the platform payload**, not npm
|
||||
dependencies of the wrapper:
|
||||
|
||||
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
|
||||
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
|
||||
every installer. As a vendored tree it produces none, because npm never
|
||||
resolves it.
|
||||
- `zero-release package` already stages the helpers tree into every release
|
||||
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
|
||||
`package.json` pins + lockfile on the native builder). The assembly script
|
||||
reuses that staged tree, so npm installs and `install.sh` installs get
|
||||
identical helpers.
|
||||
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
|
||||
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
|
||||
wrapper involvement, no configuration.
|
||||
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
|
||||
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
|
||||
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
|
||||
other symlink, so the vendored tree survives publishing. (Verified
|
||||
empirically; nested `node_modules` under `helpers/` IS packed — only the
|
||||
package-root `node_modules` is always ignored.)
|
||||
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
|
||||
platform (7 total). The assembly script keeps only the payload's own
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
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
|
||||
non-`latest` dist-tag would clobber `latest` and users would install a
|
||||
platform payload as the CLI. The workflow publishes platform versions with
|
||||
`--tag platform` and asserts `latest` survived before the wrapper publish,
|
||||
then asserts `latest` equals the wrapper version after it. Both lookups
|
||||
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
|
||||
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 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.
|
||||
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.
|
||||
|
||||
`zero update` keeps working unchanged: it detects an npm install by finding a
|
||||
`package.json` named `@gitlawb/zero` next to the running binary — true inside
|
||||
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
|
||||
|
||||
## Runbook: bumping the vendored helpers
|
||||
|
||||
The vendored helper versions are pinned by the repo `package.json`
|
||||
`dependencies` + `package-lock.json`; the release build vendors exactly what
|
||||
the lockfile resolves. No `^range` surprises ship: a release carries what its
|
||||
lockfile pinned at build time.
|
||||
|
||||
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
|
||||
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
|
||||
ship with the next release.
|
||||
- Security response: if a vendored helper publishes a security fix, the bump
|
||||
is release-worthy on its own — cut a patch release of zero carrying only the
|
||||
pin change.
|
||||
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
|
||||
vendored package directory (`helpers/node_modules/agent-browser/`), which
|
||||
satisfies redistribution attribution.
|
||||
|
||||
## History / rationale
|
||||
|
||||
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
|
||||
`agent-browser`/`tuistory` as regular dependencies. That produced three
|
||||
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
|
||||
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
|
||||
silently under Bun's default script blocking, and coupled `npm install` to
|
||||
GitHub Releases availability. The platform-package model removes the warnings
|
||||
structurally — there is nothing left in the tree for a package manager to warn
|
||||
about — rather than asking users to approve or suppress them.
|
||||
+45
-10
@@ -1,8 +1,8 @@
|
||||
# npm Wrapper Smoke Checklist
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
|
||||
wrapper.
|
||||
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
|
||||
Go build or release tooling, or the npm `bin` wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -21,17 +21,52 @@ go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
|
||||
## End-To-End Assembly Smoke
|
||||
|
||||
Build a real archive for the host platform and assemble the npm payloads from
|
||||
it (this is exactly what the publish job does):
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release package
|
||||
node scripts/npm/build-platform-packages.mjs \
|
||||
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
|
||||
```
|
||||
|
||||
Then simulate an installed layout and run the wrapper through each resolution
|
||||
path:
|
||||
|
||||
```bash
|
||||
# platform-package path
|
||||
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
|
||||
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
|
||||
cp -R dist/npm/platforms/zero-<platform>-<arch> \
|
||||
"/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/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
|
||||
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
|
||||
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
|
||||
archive's SHA-256, and extracts only the known binary basenames into place.
|
||||
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
|
||||
platform/arch is a non-fatal skip.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
`bin.zero` entry, and NO `scripts` entries — the published package must be
|
||||
free of lifecycle scripts (see NPM_PACKAGING.md).
|
||||
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
|
||||
`package.json` is `@gitlawb/zero@<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`
|
||||
matrix.
|
||||
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
|
||||
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
|
||||
plan), verifies the downloaded archive's SHA-256, and extracts only the
|
||||
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
|
||||
(exit 0) and an unsupported platform/arch is a non-fatal skip.
|
||||
- The wrapper prefers the platform package binary over a previously downloaded
|
||||
one, and `agent-browser`/`tuistory` run from the platform package's
|
||||
`helpers/node_modules/.bin` shims.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
- `zero --version` reports `zero <package.json version>`.
|
||||
- Release packaging still emits the expected archive and checksum names when
|
||||
|
||||
@@ -72,6 +72,26 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
// real false positive (conversational recap): retelling a PAST exchange is
|
||||
// not an admission about the current objective — the sentence carries its
|
||||
// own narrative context ("You asked …", "at the time"):
|
||||
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
|
||||
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
|
||||
{"Last session I was unable to build the project; that is what we should retry now.", false},
|
||||
// quoted admissions (the model citing its own earlier message or a log
|
||||
// line) must NOT be flagged:
|
||||
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
|
||||
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
|
||||
// current-objective admissions must STILL be flagged, including past tense
|
||||
// and multi-sentence finals whose other sentences are narrative:
|
||||
{"I couldn't complete the refactor.", true},
|
||||
{"I hit an error I could not resolve, so the migration is unfinished.", true},
|
||||
{"You asked me to port the parser. I couldn't finish the second half.", true},
|
||||
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
|
||||
// spans are stripped), so an admission after a stray quote still fires:
|
||||
{"The log ends with a stray \" and I couldn't finish the migration.", true},
|
||||
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
|
||||
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
|
||||
@@ -183,35 +183,112 @@ var successNegationTails = []string{
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
|
||||
// reporting the outcome of the current objective, so an inability admission in
|
||||
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
|
||||
// conversational recap ("You asked if I could work autonomously … I was honest
|
||||
// that my sandbox had no repo … so I couldn't actually do it at the time") was
|
||||
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
|
||||
// current turn's objective (summarize where we left off) was fully met. Every
|
||||
// marker is second-person or explicitly past-referential, so a first-person
|
||||
// admission about the current task ("I couldn't complete the refactor") is
|
||||
// never masked.
|
||||
var narrativeMarkers = []string{
|
||||
"you asked", "you said", "you wanted", "you mentioned",
|
||||
"we discussed", "we talked", "we covered",
|
||||
"when we last", "last time", "last session", "previous session",
|
||||
"previous conversation", "earlier session", "earlier conversation",
|
||||
"at the time", "back then",
|
||||
}
|
||||
|
||||
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
|
||||
// backticks, so an admission the model merely QUOTES — its own earlier message,
|
||||
// a log line, an error string — cannot fire the detector. Only BALANCED spans
|
||||
// are removed: an opening delimiter that never closes is kept as literal text,
|
||||
// so a stray quote cannot swallow the rest of the message (and with it a
|
||||
// genuine admission the detector must see). Single quotes are left alone: they
|
||||
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
|
||||
// delimiters would swallow the text between two contractions.
|
||||
func stripQuoted(s string) string {
|
||||
var b strings.Builder
|
||||
var span strings.Builder // pending text since the open delimiter, kept if it never closes
|
||||
open := rune(0)
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case open != 0:
|
||||
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
|
||||
open = 0
|
||||
span.Reset()
|
||||
continue
|
||||
}
|
||||
span.WriteRune(r)
|
||||
case r == '"' || r == '“' || r == '`':
|
||||
open = r
|
||||
span.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(lower[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// admissionSentences splits lowered text into sentence-ish fragments so the
|
||||
// detector judges each claim in its own context. Newlines split too (markdown
|
||||
// bullets are separate claims); the exact boundaries only need to keep an
|
||||
// admission next to its own narrative/negation context, not be grammatical.
|
||||
func admissionSentences(lower string) []string {
|
||||
return strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '.' || r == '!' || r == '?' || r == '\n'
|
||||
})
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
// Matching is per sentence, after dropping quoted spans, and a sentence that
|
||||
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
|
||||
// must be the model's own report about the CURRENT objective, not general
|
||||
// language that merely resembles one.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
|
||||
if containsAny(sentence, narrativeMarkers) {
|
||||
continue
|
||||
}
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(sentence, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(lower[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(sentence[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(sentence[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
|
||||
@@ -1347,6 +1347,8 @@ Flags:
|
||||
--notify <off|bell|notify|both>
|
||||
Override notification mode for this run
|
||||
--no-notify Disable notifications for this run
|
||||
--no-completion-gate Accept a no-tool-call reply as final without the INCOMPLETE
|
||||
downgrade (for conversational callers with an operator present)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
+14
-2
@@ -110,6 +110,16 @@ type execOptions struct {
|
||||
notifyMode string
|
||||
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
|
||||
noNotify bool
|
||||
// noCompletionGate turns off the headless completion gate
|
||||
// (agent.Options.RequireCompletionSignal) for this run. It exists for
|
||||
// CONVERSATIONAL exec callers — a chat frontend driving exec with an operator
|
||||
// present to reply (e.g. zeroclaw chat). There, a final message that hands
|
||||
// the turn back ("the sandbox blocks network egress, approve it and I'll
|
||||
// continue") is a COMPLETE answer, not an unfinished task, so the gate's
|
||||
// self-report admission check and INCOMPLETE downgrade (exit 4) would only
|
||||
// mislabel honest blocker reports. Default off: plain `zero exec` keeps the
|
||||
// gate, preserving CI/cron semantics.
|
||||
noCompletionGate bool
|
||||
// addDirs holds directories passed via --add-dir that should be allowed as
|
||||
// additional write roots for this run. Unioned with
|
||||
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
|
||||
@@ -556,8 +566,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
// if the model keeps stalling. The interactive TUI leaves this off.
|
||||
RequireCompletionSignal: true,
|
||||
// if the model keeps stalling. The interactive TUI leaves this off, and
|
||||
// --no-completion-gate lets conversational exec callers (a chat frontend
|
||||
// with an operator present) opt out the same way.
|
||||
RequireCompletionSignal: !options.noCompletionGate,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: fileTracker,
|
||||
Hooks: hookDispatcher,
|
||||
|
||||
@@ -30,6 +30,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
options.selfCorrect = true
|
||||
case arg == "--no-notify":
|
||||
options.noNotify = true
|
||||
case arg == "--no-completion-gate":
|
||||
options.noCompletionGate = true
|
||||
case arg == "--notify":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -402,6 +404,11 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
// rather than pretend it took effect.
|
||||
return options, false, execUsageError{"--self-correct cannot be combined with --use-spec."}
|
||||
}
|
||||
if options.useSpec && options.noCompletionGate {
|
||||
// Same reasoning as --self-correct above: the spec-draft path never consults
|
||||
// the completion gate, so the flag would be silently ignored.
|
||||
return options, false, execUsageError{"--no-completion-gate cannot be combined with --use-spec."}
|
||||
}
|
||||
if !options.useSpec && options.specModel != "" {
|
||||
return options, false, execUsageError{"--spec-model requires --use-spec."}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
// --no-completion-gate exists for conversational exec callers (a chat frontend
|
||||
// with an operator present): an honest blocker report that hands the turn back
|
||||
// must not be downgraded to INCOMPLETE there. The flag maps to
|
||||
// agent.Options.RequireCompletionSignal = false; default keeps the gate on.
|
||||
func TestParseExecArgsNoCompletionGate(t *testing.T) {
|
||||
options, _, err := parseExecArgs([]string{"--prompt", "hi", "--no-completion-gate"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if !options.noCompletionGate {
|
||||
t.Fatal("--no-completion-gate must set noCompletionGate")
|
||||
}
|
||||
|
||||
options, _, err = parseExecArgs([]string{"--prompt", "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if options.noCompletionGate {
|
||||
t.Fatal("noCompletionGate must default to false (gate on)")
|
||||
}
|
||||
}
|
||||
|
||||
// The spec-draft path never consults the completion gate, so the combination
|
||||
// must be rejected up front instead of silently ignoring the flag (mirrors the
|
||||
// existing --self-correct + --use-spec check).
|
||||
func TestParseExecArgsNoCompletionGateRejectsUseSpec(t *testing.T) {
|
||||
if _, _, err := parseExecArgs([]string{"--prompt", "hi", "--use-spec", "--no-completion-gate"}); err == nil {
|
||||
t.Fatal("--no-completion-gate with --use-spec must error")
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := store.Save(serverName, token); err != nil {
|
||||
if err := store.SaveForServer(server, token); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func runMCPOAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
removed, err := store.Delete(serverName)
|
||||
removed, err := store.DeleteForServerName(serverName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
|
||||
if err := store.Save("remote", mcp.StoredToken{AccessToken: "a", RefreshToken: "r"}); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(mcp.Server{Name: "remote", Identity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, mcp.StoredToken{AccessToken: "identity", RefreshToken: "ir"}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -101,6 +104,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
|
||||
if _, ok, _ := store.Load("remote"); ok {
|
||||
t.Fatal("token still present after logout")
|
||||
}
|
||||
if statuses, err := store.Status(); err != nil || len(statuses) != 0 {
|
||||
t.Fatalf("status after logout = %#v err=%v, want no tokens", statuses, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
@@ -122,23 +128,24 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
cwd := t.TempDir()
|
||||
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://remote.invalid/mcp",
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://remote.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://remote.invalid/mcp",
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://remote.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
},
|
||||
}}, nil
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return mcpConfig, nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
@@ -173,9 +180,13 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
token, ok, err := store.Load("remote")
|
||||
servers, err := mcp.NormalizeConfig(mcpConfig)
|
||||
if err != nil || len(servers) != 1 {
|
||||
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
|
||||
}
|
||||
token, ok, err := store.LoadForServer(servers[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load() ok=%v err=%v", ok, err)
|
||||
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
|
||||
}
|
||||
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
|
||||
t.Fatalf("stored token = %#v", token)
|
||||
@@ -483,7 +494,13 @@ func TestRunMCPOAuthLoginTrustedProjectServerStoresToken(t *testing.T) {
|
||||
if gotExclude {
|
||||
t.Fatal("trusted workspace must resolve MCP config with excludeProject=false")
|
||||
}
|
||||
token, ok, err := store.Load("remote")
|
||||
servers, err := mcp.NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": projectServer,
|
||||
}})
|
||||
if err != nil || len(servers) != 1 {
|
||||
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
|
||||
}
|
||||
token, ok, err := store.LoadForServer(servers[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("token must be stored on trusted login; ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.Servers == nil {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
if len(src.Servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
if dst.Servers == nil {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
base := dst.Servers[name]
|
||||
candidate := mergeMCPServer(base, server)
|
||||
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
|
||||
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
|
||||
}
|
||||
candidate.ProjectConfigured = true
|
||||
dst.Servers[name] = candidate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
|
||||
if strings.TrimSpace(next.Type) != "" {
|
||||
base.Type = next.Type
|
||||
}
|
||||
if strings.TrimSpace(next.Command) != "" {
|
||||
base.Command = next.Command
|
||||
}
|
||||
if next.Args != nil {
|
||||
base.Args = append([]string{}, next.Args...)
|
||||
}
|
||||
if next.Env != nil {
|
||||
base.Env = copyMCPStringMap(next.Env)
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" {
|
||||
base.URL = next.URL
|
||||
}
|
||||
if next.Headers != nil {
|
||||
base.Headers = copyMCPStringMap(next.Headers)
|
||||
}
|
||||
if strings.TrimSpace(next.Auth) != "" {
|
||||
base.Auth = next.Auth
|
||||
}
|
||||
if next.OAuth != nil {
|
||||
base.OAuth = next.OAuth
|
||||
}
|
||||
if next.disabledSet || next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
if next.configured {
|
||||
base.configured = true
|
||||
}
|
||||
if next.ProjectConfigured {
|
||||
base.ProjectConfigured = true
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
|
||||
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(next.Command) != "" && strings.TrimSpace(next.Command) != strings.TrimSpace(base.Command) {
|
||||
return true
|
||||
}
|
||||
if next.Args != nil && !slices.Equal(trimMCPArgs(next.Args), trimMCPArgs(base.Args)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mcpServerTransportKind(server MCPServerConfig) string {
|
||||
if typ := strings.TrimSpace(strings.ToLower(server.Type)); typ != "" {
|
||||
return typ
|
||||
}
|
||||
if strings.TrimSpace(server.URL) != "" {
|
||||
return "http"
|
||||
}
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
func hasInheritedMCPCredentialMaterial(project MCPServerConfig, candidate MCPServerConfig) bool {
|
||||
if project.Headers == nil && hasMCPStringMapMaterial(candidate.Headers) {
|
||||
return true
|
||||
}
|
||||
if project.Env == nil && hasMCPStringMapMaterial(candidate.Env) {
|
||||
return true
|
||||
}
|
||||
if project.OAuth == nil && hasMCPOAuthMaterial(candidate.OAuth) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasMCPStringMapMaterial(values map[string]string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasMCPOAuthMaterial(oauth *MCPOAuthConfig) bool {
|
||||
if oauth == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(oauth.ClientID) != "" ||
|
||||
strings.TrimSpace(oauth.ClientSecret) != "" ||
|
||||
strings.TrimSpace(oauth.AuthorizationEndpoint) != "" ||
|
||||
strings.TrimSpace(oauth.TokenEndpoint) != "" ||
|
||||
strings.TrimSpace(oauth.RegistrationEndpoint) != "" ||
|
||||
strings.TrimSpace(oauth.IssuerURL) != "" {
|
||||
return true
|
||||
}
|
||||
for _, scope := range oauth.Scopes {
|
||||
if strings.TrimSpace(scope) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimMCPArgs(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
trimmed = append(trimmed, value)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func copyMCPStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveRejectsProjectMCPURLOverrideWithInheritedHeaders(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {"url": "https://attacker.example/mcp"}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want inherited MCP credential rejection")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user-secret") {
|
||||
t.Fatalf("error leaked header secret: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
|
||||
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPRejectsProjectURLOverrideWithInheritedHeaders(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
"docs": {"url": "https://attacker.example/mcp"}
|
||||
}
|
||||
}`)
|
||||
|
||||
_, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveMCP() error = nil, want inherited MCP credential rejection")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user-secret") {
|
||||
t.Fatalf("error leaked header secret: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
|
||||
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsProjectMCPStdioOverrideWithInheritedEnv(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--user"],
|
||||
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {"args": ["--project"]}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want inherited MCP env rejection")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user-secret") {
|
||||
t.Fatalf("error leaked env secret: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsProjectMCPTargetOverrideWhenCredentialsCleared(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"web": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
},
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--user"],
|
||||
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"web": {"url": "https://project.example/mcp", "headers": {}},
|
||||
"docs": {"args": ["--project"], "env": {}}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
web := resolved.MCP.Servers["web"]
|
||||
if web.URL != "https://project.example/mcp" || len(web.Headers) != 0 || !web.ProjectConfigured {
|
||||
t.Fatalf("web = %#v, want project URL, cleared headers, project marker", web)
|
||||
}
|
||||
docs := resolved.MCP.Servers["docs"]
|
||||
if got := strings.Join(docs.Args, " "); got != "--project" {
|
||||
t.Fatalf("docs.Args = %q, want --project", got)
|
||||
}
|
||||
if len(docs.Env) != 0 || !docs.ProjectConfigured {
|
||||
t.Fatalf("docs = %#v, want cleared env and project marker", docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsTrustedMCPOverridesToInheritCredentials(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{
|
||||
"docs": {URL: "https://cli.example/mcp"},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
docs := resolved.MCP.Servers["docs"]
|
||||
if docs.URL != "https://cli.example/mcp" || docs.Headers["Authorization"] != "Bearer user-secret" || docs.ProjectConfigured {
|
||||
t.Fatalf("docs = %#v, want trusted override to inherit headers without project marker", docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPProjectArgsTargetCompareUsesNormalizedArgs(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"],
|
||||
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {"args": [" --port ", "7777", ""]}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if !resolved.MCP.Servers["docs"].ProjectConfigured {
|
||||
t.Fatalf("docs = %#v, want project marker", resolved.MCP.Servers["docs"])
|
||||
}
|
||||
}
|
||||
+17
-69
@@ -163,22 +163,25 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
// can override any field or disable a default by writing over it.
|
||||
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
|
||||
|
||||
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
// Drop the project layer when the workspace is untrusted, so a cloned repo's
|
||||
// ./.zero/config.json cannot register (and spawn) stdio MCP servers. Fail-closed:
|
||||
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
|
||||
if options.ExcludeProject && path == options.ProjectConfigPath {
|
||||
continue
|
||||
}
|
||||
fileConfig, err := loadConfigFile(path)
|
||||
if options.UserConfigPath != "" {
|
||||
fileConfig, err := loadConfigFile(options.UserConfigPath)
|
||||
if err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
|
||||
}
|
||||
// Drop the project layer when the workspace is untrusted, so a cloned repo's
|
||||
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
|
||||
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
|
||||
if options.ProjectConfigPath != "" && !options.ExcludeProject {
|
||||
fileConfig, err := loadConfigFile(options.ProjectConfigPath)
|
||||
if err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
if err := mergeProjectMCPConfig(&cfg.MCP, fileConfig.MCP); err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
|
||||
return cfg.MCP, nil
|
||||
}
|
||||
@@ -261,7 +264,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
}
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
@@ -834,63 +839,6 @@ func validateSTTConfig(cfg STTConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.Servers == nil {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
|
||||
if strings.TrimSpace(next.Type) != "" {
|
||||
base.Type = next.Type
|
||||
}
|
||||
if strings.TrimSpace(next.Command) != "" {
|
||||
base.Command = next.Command
|
||||
}
|
||||
if next.Args != nil {
|
||||
base.Args = append([]string{}, next.Args...)
|
||||
}
|
||||
if next.Env != nil {
|
||||
base.Env = copyMCPStringMap(next.Env)
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" {
|
||||
base.URL = next.URL
|
||||
}
|
||||
if next.Headers != nil {
|
||||
base.Headers = copyMCPStringMap(next.Headers)
|
||||
}
|
||||
if strings.TrimSpace(next.Auth) != "" {
|
||||
base.Auth = next.Auth
|
||||
}
|
||||
if next.OAuth != nil {
|
||||
base.OAuth = next.OAuth
|
||||
}
|
||||
if next.disabledSet || next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
if next.configured {
|
||||
base.configured = true
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func copyMCPStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func hasProviderFields(profile ProviderProfile) bool {
|
||||
return HasProviderProfile(profile)
|
||||
}
|
||||
|
||||
+13
-10
@@ -440,16 +440,19 @@ type MCPConfig struct {
|
||||
}
|
||||
|
||||
type MCPServerConfig struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
disabledSet bool
|
||||
Type string `json:"type,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// ProjectConfigured marks servers touched by project config. It is runtime
|
||||
// metadata, not persisted config.
|
||||
ProjectConfigured bool `json:"-"`
|
||||
disabledSet bool
|
||||
// configured is true when the user's config JSON declared an object for
|
||||
// this server at all (i.e. UnmarshalJSON ran for it), regardless of which
|
||||
// fields it set or what values they hold. A built-in default seeded by
|
||||
|
||||
@@ -31,6 +31,9 @@ type Server struct {
|
||||
Auth string
|
||||
OAuth *OAuthConfig
|
||||
Identity string
|
||||
// ProjectConfigured is true when project config touched this server. Runtime
|
||||
// credential lookup uses it to avoid reusing legacy user tokens by name.
|
||||
ProjectConfigured bool
|
||||
// UnconfiguredDefault is true when this server is one of Zero's built-in
|
||||
// defaults (e.g. keyless Firecrawl) that the user never touched in their
|
||||
// config — no credentials, no overrides. Callers use it to avoid warning
|
||||
@@ -88,6 +91,7 @@ func normalizeServer(name string, raw config.MCPServerConfig) (Server, error) {
|
||||
Headers: copyStringMap(raw.Headers),
|
||||
Auth: auth,
|
||||
OAuth: normalizeOAuthConfig(raw.OAuth),
|
||||
ProjectConfigured: raw.ProjectConfigured,
|
||||
UnconfiguredDefault: config.IsUnconfiguredDefault(name, raw),
|
||||
}
|
||||
|
||||
|
||||
@@ -847,7 +847,7 @@ func (source *storeTokenSource) config() OAuthConfig {
|
||||
}
|
||||
|
||||
func (source *storeTokenSource) AccessToken(ctx context.Context) (string, error) {
|
||||
token, ok, err := source.store.Load(source.server.Name)
|
||||
token, ok, err := source.store.LoadForServer(source.server)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -858,7 +858,7 @@ func (source *storeTokenSource) AccessToken(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
token, ok, err := source.store.Load(source.server.Name)
|
||||
token, ok, err := source.store.LoadForServer(source.server)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -869,7 +869,7 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := source.store.Save(source.server.Name, refreshed); err != nil {
|
||||
if err := source.store.SaveForServer(source.server, refreshed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return refreshed.AccessToken, nil
|
||||
|
||||
+132
-3
@@ -15,6 +15,9 @@ import (
|
||||
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
|
||||
// retained so migration can recognize a file it understands.
|
||||
const tokenStoreSchemaVersion = 1
|
||||
const mcpServerIdentityLength = 32
|
||||
const maxOAuthTokenKeySegmentLength = 128
|
||||
const maxMCPServerNameForIdentityToken = maxOAuthTokenKeySegmentLength - 1 - mcpServerIdentityLength
|
||||
|
||||
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
|
||||
// for a single MCP server. The token fields are sensitive: they are tagged so
|
||||
@@ -32,6 +35,7 @@ type StoredToken struct {
|
||||
// omits the access and refresh token material so it can be printed by the CLI.
|
||||
type TokenStatus struct {
|
||||
ServerName string `json:"serverName"`
|
||||
ServerIdentity string `json:"serverIdentity,omitempty"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
HasRefreshToken bool `json:"hasRefreshToken"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
@@ -148,6 +152,17 @@ func (store *TokenStore) Save(serverName string, token StoredToken) error {
|
||||
return store.store.Save(key, storedToOAuth(token))
|
||||
}
|
||||
|
||||
// SaveForServer persists a token under the server's target identity. Identity
|
||||
// binding prevents a project config with the same server name from reusing a
|
||||
// token issued for a different MCP endpoint.
|
||||
func (store *TokenStore) SaveForServer(server Server, token StoredToken) error {
|
||||
key, err := mcpIdentityKey(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.store.Save(key, storedToOAuth(token))
|
||||
}
|
||||
|
||||
// Load returns the stored token for a server. The second return value is false
|
||||
// when no token has been stored for the server.
|
||||
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
|
||||
@@ -162,6 +177,30 @@ func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
|
||||
return tokenToStored(token), true, nil
|
||||
}
|
||||
|
||||
// LoadForServer returns the token matching the server's target identity. Legacy
|
||||
// name-only tokens are migrated only for servers untouched by project config.
|
||||
func (store *TokenStore) LoadForServer(server Server) (StoredToken, bool, error) {
|
||||
key, err := mcpIdentityKey(server)
|
||||
if err != nil {
|
||||
return StoredToken{}, false, err
|
||||
}
|
||||
token, ok, err := store.store.Load(key)
|
||||
if err != nil || ok {
|
||||
return tokenToStored(token), ok, err
|
||||
}
|
||||
if server.ProjectConfigured {
|
||||
return StoredToken{}, false, nil
|
||||
}
|
||||
legacy, ok, err := store.Load(server.Name)
|
||||
if err != nil || !ok {
|
||||
return StoredToken{}, ok, err
|
||||
}
|
||||
if err := store.store.Save(key, storedToOAuth(legacy)); err != nil {
|
||||
return StoredToken{}, false, err
|
||||
}
|
||||
return legacy, true, nil
|
||||
}
|
||||
|
||||
// Delete removes the stored token for a server. It reports whether an entry was
|
||||
// present before deletion.
|
||||
func (store *TokenStore) Delete(serverName string) (bool, error) {
|
||||
@@ -172,6 +211,36 @@ func (store *TokenStore) Delete(serverName string) (bool, error) {
|
||||
return store.store.Delete(key)
|
||||
}
|
||||
|
||||
// DeleteForServerName removes both legacy name-only and identity-bound tokens
|
||||
// for a server name.
|
||||
func (store *TokenStore) DeleteForServerName(serverName string) (bool, error) {
|
||||
name := strings.TrimSpace(serverName)
|
||||
if err := ValidateServerName(name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
removed, err := store.Delete(name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
for _, status := range statuses {
|
||||
segment := strings.TrimPrefix(status.Key, oauth.KeyPrefixMCP)
|
||||
parsedName, _, ok := splitMCPIdentityKeySegment(segment)
|
||||
if !ok || parsedName != name {
|
||||
continue
|
||||
}
|
||||
deleted, err := store.store.Delete(status.Key)
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed = removed || deleted
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// Status returns a redaction-safe summary of every stored MCP token, sorted by
|
||||
// server name. It never includes the token material.
|
||||
func (store *TokenStore) Status() ([]TokenStatus, error) {
|
||||
@@ -181,8 +250,10 @@ func (store *TokenStore) Status() ([]TokenStatus, error) {
|
||||
}
|
||||
out := make([]TokenStatus, 0, len(statuses))
|
||||
for _, s := range statuses {
|
||||
serverName, serverIdentity, _ := splitMCPIdentityKeySegment(strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP))
|
||||
out = append(out, TokenStatus{
|
||||
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
|
||||
ServerName: serverName,
|
||||
ServerIdentity: serverIdentity,
|
||||
HasToken: s.HasToken,
|
||||
HasRefreshToken: s.HasRefreshToken,
|
||||
TokenType: s.TokenType,
|
||||
@@ -250,16 +321,69 @@ func (store *TokenStore) migrateLegacy(legacyPath string) error {
|
||||
|
||||
// mcpKey builds and validates the unified store key for an MCP server token.
|
||||
func mcpKey(serverName string) (string, error) {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
name := strings.TrimSpace(serverName)
|
||||
if err := ValidateServerName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
|
||||
if hasMCPIdentitySuffix(name) {
|
||||
return "", fmt.Errorf("MCP OAuth server name %q cannot end with .<32 hex chars> because it collides with identity-bound token storage", name)
|
||||
}
|
||||
key := oauth.KeyPrefixMCP + name
|
||||
if err := oauth.ValidateKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func mcpIdentityKey(server Server) (string, error) {
|
||||
name := strings.TrimSpace(server.Name)
|
||||
if err := ValidateServerName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hasMCPIdentitySuffix(name) {
|
||||
return "", fmt.Errorf("MCP OAuth server name %q cannot end with .<32 hex chars> because it collides with identity-bound token storage", name)
|
||||
}
|
||||
identity := strings.TrimSpace(server.Identity)
|
||||
if len(name) > maxMCPServerNameForIdentityToken {
|
||||
return "", fmt.Errorf("MCP OAuth server name %q is too long for identity-bound token storage", name)
|
||||
}
|
||||
if len(identity) != mcpServerIdentityLength || !isMCPServerIdentity(identity) {
|
||||
return "", fmt.Errorf("MCP OAuth server %s has invalid identity %q", name, identity)
|
||||
}
|
||||
key := oauth.KeyPrefixMCP + name + "." + identity
|
||||
if err := oauth.ValidateKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func splitMCPIdentityKeySegment(segment string) (string, string, bool) {
|
||||
index := strings.LastIndex(segment, ".")
|
||||
if index <= 0 || index == len(segment)-1 {
|
||||
return segment, "", false
|
||||
}
|
||||
identity := segment[index+1:]
|
||||
if len(identity) != mcpServerIdentityLength || !isMCPServerIdentity(identity) {
|
||||
return segment, "", false
|
||||
}
|
||||
return segment[:index], identity, true
|
||||
}
|
||||
|
||||
func hasMCPIdentitySuffix(value string) bool {
|
||||
_, _, ok := splitMCPIdentityKeySegment(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isMCPServerIdentity(value string) bool {
|
||||
for _, ch := range value {
|
||||
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
|
||||
// inverse of tokenToStored). MCP never sets the oauth Account field.
|
||||
func storedToOAuth(s StoredToken) oauth.Token {
|
||||
@@ -284,6 +408,11 @@ func FormatTokenStatuses(statuses []TokenStatus) string {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString(status.ServerName)
|
||||
if status.ServerIdentity != "" {
|
||||
builder.WriteString(" [")
|
||||
builder.WriteString(status.ServerIdentity)
|
||||
builder.WriteString("]")
|
||||
}
|
||||
builder.WriteString(": ")
|
||||
if !status.HasToken {
|
||||
builder.WriteString("no token")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -43,6 +45,191 @@ func TestTokenStoreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreIdentityBoundRoundTrip(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
|
||||
otherTarget := testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false)
|
||||
if err := store.SaveForServer(server, StoredToken{AccessToken: "identity-token"}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, ok, err := store.LoadForServer(server)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.AccessToken != "identity-token" {
|
||||
t.Fatalf("access token = %q", loaded.AccessToken)
|
||||
}
|
||||
if _, ok, err := store.LoadForServer(otherTarget); err != nil || ok {
|
||||
t.Fatalf("LoadForServer(other target) ok=%v err=%v, want no token", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTokenSourceRequiresMatchingIdentity(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
|
||||
otherTarget := testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false)
|
||||
if err := store.SaveForServer(server, StoredToken{AccessToken: "access-a"}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
|
||||
source := &storeTokenSource{server: server, store: store}
|
||||
token, err := source.AccessToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("AccessToken() error = %v", err)
|
||||
}
|
||||
if token != "access-a" {
|
||||
t.Fatalf("token = %q, want access-a", token)
|
||||
}
|
||||
|
||||
source.server = otherTarget
|
||||
if _, err := source.AccessToken(context.Background()); err == nil {
|
||||
t.Fatal("AccessToken() error = nil for mismatched identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreProjectConfiguredDoesNotLoadLegacyToken(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.Save("demo", StoredToken{AccessToken: "legacy-token"}); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)
|
||||
|
||||
_, ok, err := store.LoadForServer(server)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadForServer() error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("LoadForServer() ok = true for project-configured legacy token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreMigratesLegacyForUserOnlyServer(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.Save("demo", StoredToken{AccessToken: "legacy-token"}); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
server := testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false)
|
||||
|
||||
loaded, ok, err := store.LoadForServer(server)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.AccessToken != "legacy-token" {
|
||||
t.Fatalf("access token = %q", loaded.AccessToken)
|
||||
}
|
||||
if _, err := store.Delete("demo"); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
loaded, ok, err = store.LoadForServer(server)
|
||||
if err != nil || !ok || loaded.AccessToken != "legacy-token" {
|
||||
t.Fatalf("identity-bound migrated token missing: loaded=%#v ok=%v err=%v", loaded, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreRejectsLongIdentityBoundServerName(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
name := strings.Repeat("a", maxMCPServerNameForIdentityToken+1)
|
||||
err = store.SaveForServer(testOAuthServer(name, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false), StoredToken{AccessToken: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("SaveForServer() error = nil for long server name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `MCP OAuth server name "`+name+`" is too long for identity-bound token storage`) {
|
||||
t.Fatalf("error = %q, want long-name message", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreRejectsAmbiguousIdentitySuffixServerName(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
name := "demo.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
if err := store.SaveForServer(testOAuthServer(name, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false), StoredToken{AccessToken: "x"}); err == nil {
|
||||
t.Fatal("SaveForServer() error = nil for ambiguous server name")
|
||||
} else if !strings.Contains(err.Error(), `MCP OAuth server name "`+name+`" cannot end with .<32 hex chars>`) {
|
||||
t.Fatalf("SaveForServer() error = %q, want ambiguous-name rejection", err.Error())
|
||||
}
|
||||
if err := store.Save(name, StoredToken{AccessToken: "legacy"}); err == nil {
|
||||
t.Fatal("Save() error = nil for ambiguous legacy server name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreDeleteForServerNameRemovesLegacyAndIdentityTokens(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.Save("demo", StoredToken{AccessToken: "legacy"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveForServer(testOAuthServer("demo", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false), StoredToken{AccessToken: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveForServer(testOAuthServer("demo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false), StoredToken{AccessToken: "b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveForServer(testOAuthServer("other", "cccccccccccccccccccccccccccccccc", false), StoredToken{AccessToken: "c"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed, err := store.DeleteForServerName("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteForServerName() error = %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Fatal("DeleteForServerName() removed = false")
|
||||
}
|
||||
statuses, err := store.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].ServerName != "other" {
|
||||
t.Fatalf("statuses after delete = %#v, want only other", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreStatusParsesIdentityAndRedacts(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
identity := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
if err := store.SaveForServer(testOAuthServer("demo", identity, false), StoredToken{AccessToken: "secret-access", RefreshToken: "secret-refresh"}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
statuses, err := store.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].ServerName != "demo" || statuses[0].ServerIdentity != identity {
|
||||
t.Fatalf("statuses = %#v, want parsed identity", statuses)
|
||||
}
|
||||
output := FormatTokenStatuses(statuses)
|
||||
if !strings.Contains(output, "demo ["+identity+"]") {
|
||||
t.Fatalf("status output = %q, want identity label", output)
|
||||
}
|
||||
if contains(output, "secret-access") || contains(output, "secret-refresh") {
|
||||
t.Fatalf("status output leaked token: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreFileIs0600(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX file permissions are not enforced on Windows")
|
||||
@@ -268,6 +455,17 @@ func TestResolveTokenStorePathUsesXDG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testOAuthServer(name string, identity string, projectConfigured bool) Server {
|
||||
return Server{
|
||||
Name: name,
|
||||
Type: ServerTypeHTTP,
|
||||
URL: "https://example.com/mcp",
|
||||
Auth: ServerAuthOAuth,
|
||||
Identity: identity,
|
||||
ProjectConfigured: projectConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(haystack) >= len(needle) && (haystack == needle || indexOf(haystack, needle) >= 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package npmwrapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBuildPlatformPackagesAssemblesPublishPayloads runs the release-time
|
||||
// assembly script against a fixture linux-x64 archive shaped like the output
|
||||
// of `zero-release package` and checks the npm publish payloads it emits:
|
||||
// the platform payload (suffixed version, os/cpu gate, pack-safe helper
|
||||
// shims, pruned agent-browser binaries) and the wrapper (no scripts, no
|
||||
// dependencies, full optionalDependencies alias matrix).
|
||||
func TestBuildPlatformPackagesAssemblesPublishPayloads(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 := 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")
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
OS []string `json:"os"`
|
||||
CPU []string `json:"cpu"`
|
||||
Scripts map[string]string
|
||||
}
|
||||
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
|
||||
if platformPkg.Name != "@gitlawb/zero" {
|
||||
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
|
||||
}
|
||||
if want := version + "-linux-x64"; platformPkg.Version != want {
|
||||
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
|
||||
}
|
||||
if len(platformPkg.OS) != 1 || platformPkg.OS[0] != "linux" || len(platformPkg.CPU) != 1 || platformPkg.CPU[0] != "x64" {
|
||||
t.Fatalf("platform package os/cpu = %v/%v, want [linux]/[x64]", platformPkg.OS, platformPkg.CPU)
|
||||
}
|
||||
if len(platformPkg.Scripts) != 0 {
|
||||
t.Fatalf("platform package has lifecycle scripts: %v", platformPkg.Scripts)
|
||||
}
|
||||
|
||||
info, err := os.Stat(filepath.Join(platformDir, "zero"))
|
||||
if err != nil {
|
||||
t.Fatalf("platform package binary missing: %v", err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatalf("platform package binary is not executable: %v", info.Mode())
|
||||
}
|
||||
for _, excluded := range []string{"README.md", "VERSION", "bin"} {
|
||||
if _, err := os.Stat(filepath.Join(platformDir, excluded)); !os.IsNotExist(err) {
|
||||
t.Fatalf("platform package still contains wrapper-owned %q", excluded)
|
||||
}
|
||||
}
|
||||
// npm only ships a license file it finds in the packed directory, so the
|
||||
// assembly must place the repo LICENSE into every payload.
|
||||
if _, err := os.Stat(filepath.Join(platformDir, "LICENSE")); err != nil {
|
||||
t.Fatalf("platform package missing LICENSE: %v", err)
|
||||
}
|
||||
|
||||
// npm pack drops symlinks, so the shim must now be a regular executable file.
|
||||
shimPath := filepath.Join(platformDir, "helpers", "node_modules", ".bin", "agent-browser")
|
||||
shimInfo, err := os.Lstat(shimPath)
|
||||
if err != nil {
|
||||
t.Fatalf("agent-browser shim missing: %v", err)
|
||||
}
|
||||
if shimInfo.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("agent-browser shim is still a symlink; npm pack would drop it")
|
||||
}
|
||||
if shimInfo.Mode()&0o111 == 0 {
|
||||
t.Fatalf("agent-browser shim is not executable: %v", shimInfo.Mode())
|
||||
}
|
||||
shim, err := os.ReadFile(shimPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile shim: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(shim), "../agent-browser/bin/agent-browser.js") {
|
||||
t.Fatalf("shim does not exec the vendored helper: %q", string(shim))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(platformDir, "helpers", "node_modules", ".bin", "tuistory")); err != nil {
|
||||
t.Fatalf("tuistory shim missing: %v", err)
|
||||
}
|
||||
|
||||
// Pruning: only the linux-x64 (glibc + musl) agent-browser binaries survive.
|
||||
prunedBinDir := filepath.Join(platformDir, "helpers", "node_modules", "agent-browser", "bin")
|
||||
entries, err := os.ReadDir(prunedBinDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir pruned bin: %v", err)
|
||||
}
|
||||
got := []string{}
|
||||
for _, entry := range entries {
|
||||
got = append(got, entry.Name())
|
||||
}
|
||||
want := map[string]bool{
|
||||
"agent-browser.js": true,
|
||||
"agent-browser-linux-x64": true,
|
||||
"agent-browser-linux-musl-x64": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("pruned agent-browser bin = %v, want exactly %v", got, want)
|
||||
}
|
||||
for _, name := range got {
|
||||
if !want[name] {
|
||||
t.Fatalf("pruning left unexpected %q (full set: %v)", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
wrapperDir := filepath.Join(outDir, "wrapper")
|
||||
var wrapperPkg struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
Optional map[string]string `json:"optionalDependencies"`
|
||||
}
|
||||
unmarshalJSONFile(t, filepath.Join(wrapperDir, "package.json"), &wrapperPkg)
|
||||
if wrapperPkg.Version != version {
|
||||
t.Fatalf("wrapper version = %q, want %q", wrapperPkg.Version, version)
|
||||
}
|
||||
if len(wrapperPkg.Scripts) != 0 {
|
||||
t.Fatalf("published wrapper has lifecycle scripts: %v", wrapperPkg.Scripts)
|
||||
}
|
||||
if len(wrapperPkg.Dependencies) != 0 {
|
||||
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
|
||||
}
|
||||
wantAliases := map[string]string{
|
||||
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
|
||||
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
|
||||
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
|
||||
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
|
||||
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
|
||||
}
|
||||
if len(wrapperPkg.Optional) != len(wantAliases) {
|
||||
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
|
||||
}
|
||||
for alias, spec := range wantAliases {
|
||||
if wrapperPkg.Optional[alias] != spec {
|
||||
t.Fatalf("wrapper optionalDependencies[%q] = %q, want %q", alias, wrapperPkg.Optional[alias], spec)
|
||||
}
|
||||
}
|
||||
for _, file := range []string{"bin/zero.js", "scripts/postinstall.mjs", "README.md", "LICENSE"} {
|
||||
if _, err := os.Stat(filepath.Join(wrapperDir, filepath.FromSlash(file))); err != nil {
|
||||
t.Fatalf("wrapper payload missing %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshalJSONFile(t *testing.T, path string, target any) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile %s: %v", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
t.Fatalf("Unmarshal %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixtureFile(t *testing.T, path string, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
mustMkdirAllFixture(t, filepath.Dir(path))
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatalf("WriteFile %s: %v", path, err)
|
||||
}
|
||||
if mode&0o111 != 0 {
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
t.Fatalf("Chmod %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAllFixture(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireCommand(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
t.Skipf("%s is not available", name)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -41,16 +41,13 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
|
||||
if pkg.Module != "bin/zero.js" {
|
||||
t.Fatalf("module = %q, want bin/zero.js", pkg.Module)
|
||||
}
|
||||
// Only a postinstall hook (which downloads the prebuilt binary) is allowed.
|
||||
// Repository build scripts (build/prepare/prepack/…) must not ship in the
|
||||
// published package — the tarball has no Go source to build from.
|
||||
if pkg.Scripts["postinstall"] != "node scripts/postinstall.mjs" {
|
||||
t.Fatalf("scripts.postinstall = %q, want node scripts/postinstall.mjs", pkg.Scripts["postinstall"])
|
||||
}
|
||||
// The published package must carry NO lifecycle scripts at all: the native
|
||||
// binary arrives as a platform optionalDependency, with scripts/postinstall.mjs
|
||||
// kept only as a first-run fallback the wrapper invokes itself. Any script
|
||||
// here would resurrect the npm/Bun/pnpm install-script warnings the platform
|
||||
//-package model exists to eliminate (see docs/NPM_PACKAGING.md).
|
||||
for name := range pkg.Scripts {
|
||||
if name != "postinstall" {
|
||||
t.Fatalf("package.json scripts contains %q; only a postinstall hook is allowed (no repository build scripts)", name)
|
||||
}
|
||||
t.Fatalf("package.json scripts contains %q; the published package must have no lifecycle scripts", name)
|
||||
}
|
||||
if pkg.License == "" {
|
||||
t.Fatalf("package.json license is empty; set it (ties to the pending LICENSE file) so npm publish is not unlicensed")
|
||||
@@ -256,11 +253,146 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
|
||||
if !ok || exitErr.ExitCode() != 1 {
|
||||
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "No native binary found next to the npm wrapper") {
|
||||
if !strings.Contains(string(output), "No native binary is available for this install") {
|
||||
t.Fatalf("missing-native output = %q", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
}
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
|
||||
// A stale downloaded binary next to the wrapper must lose to the platform
|
||||
// package: the platform version is pinned to the wrapper release, the
|
||||
// downloaded copy is whatever a previous fallback fetched.
|
||||
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile downloaded fixture: %v", err)
|
||||
}
|
||||
|
||||
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":"@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 {
|
||||
t.Fatalf("WriteFile platform binary: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out launching platform binary: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
||||
}
|
||||
if got := strings.TrimSpace(string(output)); got != "platform-zero --version" {
|
||||
t.Fatalf("wrapper output = %q, want platform-zero --version", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
}
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
if err := os.WriteFile(filepath.Join(root, "zero"), []byte("#!/usr/bin/env sh\nprintf 'downloaded-zero\\n'\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile downloaded fixture: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out launching downloaded binary: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
||||
}
|
||||
if got := strings.TrimSpace(string(output)); got != "downloaded-zero" {
|
||||
t.Fatalf("wrapper output = %q, want downloaded-zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
|
||||
// Give the fixture the real downloader so the wrapper's first-run path
|
||||
// executes it; ZERO_SKIP_DOWNLOAD keeps the test offline, so the download
|
||||
// "succeeds" without producing a binary and the wrapper must exit 1 with
|
||||
// guidance.
|
||||
scriptsDir := filepath.Join(root, "scripts")
|
||||
if err := os.MkdirAll(scriptsDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll scripts: %v", err)
|
||||
}
|
||||
postinstall, err := os.ReadFile(filepath.Join(repoRoot(t), "scripts", "postinstall.mjs"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile postinstall: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile postinstall fixture: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile package fixture: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
command.Env = append(command.Env, "ZERO_SKIP_DOWNLOAD=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out on first-run download path: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 1 {
|
||||
t.Fatalf("wrapper err = %v, want exit 1; output: %s", err, output)
|
||||
}
|
||||
text := string(output)
|
||||
if !strings.Contains(text, "fetching the native binary from the GitHub Release") {
|
||||
t.Fatalf("output missing fallback download notice: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "skipping native binary download") {
|
||||
t.Fatalf("output shows the downloader did not run: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "No native binary is available for this install") {
|
||||
t.Fatalf("output missing guidance: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func nodePlatformName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "darwin"
|
||||
case "windows":
|
||||
return "win32"
|
||||
default:
|
||||
return runtime.GOOS
|
||||
}
|
||||
}
|
||||
|
||||
func nodeArchName() string {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "x64"
|
||||
default:
|
||||
return runtime.GOARCH
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"bin": {
|
||||
"zero": "bin/zero.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin/zero.js",
|
||||
"scripts/postinstall.mjs"
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env node
|
||||
// build-platform-packages.mjs: assemble the npm publish payloads for a release
|
||||
// from the per-platform archives built by `zero-release package`.
|
||||
//
|
||||
// Layout written under --out-dir (default dist/npm):
|
||||
// wrapper/ @gitlawb/[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>/
|
||||
// @gitlawb/[email protected]<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 @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
|
||||
// five platforms are required unless --only limits the set (testing only —
|
||||
// CI must always assemble the full matrix, and the wrapper's
|
||||
// optionalDependencies always reference the full matrix regardless).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/npm/build-platform-packages.mjs \
|
||||
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...]
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repoRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
||||
|
||||
// Node platform/arch names (they name the alias suffixes so bin/zero.js can
|
||||
// derive its platform package from process.platform/process.arch) mapped to
|
||||
// the release asset naming from internal/release/release.go. windows-arm64 is
|
||||
// deliberately absent — the release matrix does not build it.
|
||||
const MATRIX = [
|
||||
{ platform: 'darwin', arch: 'arm64', release: 'macos-arm64', ext: 'tar.gz' },
|
||||
{ platform: 'darwin', arch: 'x64', release: 'macos-x64', ext: 'tar.gz' },
|
||||
{ platform: 'linux', arch: 'arm64', release: 'linux-arm64', ext: 'tar.gz' },
|
||||
{ platform: 'linux', arch: 'x64', release: 'linux-x64', ext: 'tar.gz' },
|
||||
{ platform: 'win32', arch: 'x64', release: 'windows-x64', ext: 'zip' },
|
||||
];
|
||||
|
||||
// Files staged into release archives for the shell installers / humans that
|
||||
// must NOT ship in a platform npm package (the wrapper package owns them).
|
||||
const PLATFORM_PACKAGE_EXCLUDES = ['package.json', 'README.md', 'VERSION', 'bin'];
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[build-platform-packages] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
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 = () => {
|
||||
i += 1;
|
||||
if (i >= argv.length) fail(`missing value for ${arg}`);
|
||||
return argv[i];
|
||||
};
|
||||
switch (arg) {
|
||||
case '--artifacts-dir':
|
||||
args.artifactsDir = next();
|
||||
break;
|
||||
case '--out-dir':
|
||||
args.outDir = next();
|
||||
break;
|
||||
case '--only':
|
||||
args.only = new Set(next().split(',').map((entry) => entry.trim()).filter(Boolean));
|
||||
break;
|
||||
default:
|
||||
fail(`unknown argument ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!args.artifactsDir) fail('--artifacts-dir is required');
|
||||
return args;
|
||||
}
|
||||
|
||||
function readWrapperPackage() {
|
||||
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
|
||||
if (!pkg.version) fail('package.json version is empty');
|
||||
return pkg;
|
||||
}
|
||||
|
||||
function platformVersion(version, entry) {
|
||||
return `${version}-${entry.platform}-${entry.arch}`;
|
||||
}
|
||||
|
||||
function aliasName(entry) {
|
||||
return `@gitlawb/zero-${entry.platform}-${entry.arch}`;
|
||||
}
|
||||
|
||||
function optionalDependencies(version) {
|
||||
const aliases = {};
|
||||
for (const entry of MATRIX) {
|
||||
aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`;
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
// Same anchored sha256sum parsing as scripts/postinstall.mjs: when a line
|
||||
// carries a filename it must match, so a checksum file cannot misattribute a
|
||||
// digest.
|
||||
function parseSha256(text, wantName) {
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(/^([a-fA-F0-9]{64})(?:\s+\*?(.+))?$/);
|
||||
if (!match) continue;
|
||||
const [, hex, name] = match;
|
||||
if (!name || name.trim() === wantName) {
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
}
|
||||
fail(`could not find a SHA-256 digest for ${wantName} in its .sha256 file`);
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, assetName) {
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
if (!existsSync(checksumPath)) fail(`missing checksum file ${checksumPath}`);
|
||||
const expected = parseSha256(readFileSync(checksumPath, 'utf8'), assetName);
|
||||
const actual = createHash('sha256').update(readFileSync(archivePath)).digest('hex');
|
||||
if (actual !== expected) {
|
||||
fail(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, ext, destDir) {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
const commands =
|
||||
ext === 'zip'
|
||||
? [
|
||||
['unzip', ['-q', archivePath, '-d', destDir]],
|
||||
// bsdtar (macOS / Windows tar.exe) extracts zips; GNU tar does not,
|
||||
// hence unzip first.
|
||||
['tar', ['-xf', archivePath, '-C', destDir]],
|
||||
]
|
||||
: [['tar', ['-xzf', archivePath, '-C', destDir]]];
|
||||
let lastError = '';
|
||||
for (const [command, commandArgs] of commands) {
|
||||
const result = spawnSync(command, commandArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
if (result.status === 0) return;
|
||||
lastError = result.error ? result.error.message : (result.stderr || '').toString().trim();
|
||||
}
|
||||
fail(`failed to extract ${archivePath}: ${lastError}`);
|
||||
}
|
||||
|
||||
// zero-release archives carry their payload at the archive root; also accept
|
||||
// a single zero-vX.Y.Z-<platform>-<arch>/ prefix directory (the shape
|
||||
// install.sh tolerates) so fixtures and future layout changes both work.
|
||||
function extractedPackageDir(extractDir, packageName, binaryName) {
|
||||
if (existsSync(join(extractDir, binaryName))) return extractDir;
|
||||
const exact = join(extractDir, packageName);
|
||||
if (existsSync(join(exact, binaryName))) return exact;
|
||||
const entries = readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
||||
if (entries.length === 1 && existsSync(join(extractDir, entries[0].name, binaryName))) {
|
||||
return join(extractDir, entries[0].name);
|
||||
}
|
||||
fail(`archive ${packageName} did not contain the ${binaryName} binary at a recognizable root`);
|
||||
}
|
||||
|
||||
// npm pack silently drops symlinks from the tarball, and the staged helpers
|
||||
// tree relies on them: npm's own .bin shims are symlinks on POSIX. Replace
|
||||
// every symlink with something pack-safe — .bin links become relocatable
|
||||
// `sh` exec shims, anything else becomes a dereferenced copy — so the
|
||||
// vendored helpers survive publishing intact.
|
||||
function materializeSymlinks(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
let target;
|
||||
try {
|
||||
target = realpathSync(full);
|
||||
} catch {
|
||||
fail(`broken symlink in staged helpers tree: ${full}`);
|
||||
}
|
||||
const targetInfo = statSync(target);
|
||||
rmSync(full);
|
||||
if (basename(dir) === '.bin' && !targetInfo.isDirectory()) {
|
||||
// relative() must compare like with like: target came out of
|
||||
// realpathSync, so resolve dir the same way (macOS tmp dirs reach the
|
||||
// same tree via /var and /private/var, which would otherwise produce a
|
||||
// ../..-through-the-root monstrosity).
|
||||
const relTarget = relative(realpathSync(dir), target).split(sep).join('/');
|
||||
writeFileSync(
|
||||
full,
|
||||
'#!/bin/sh\n' +
|
||||
'here="$(cd "$(dirname "$0")" && pwd)"\n' +
|
||||
`exec "$here/${relTarget}" "$@"\n`,
|
||||
);
|
||||
chmodSync(full, 0o755);
|
||||
// Direct exec relies on the target's own shebang and executable bit;
|
||||
// npm pack preserves modes, so set it here.
|
||||
chmodSync(target, 0o755);
|
||||
} else {
|
||||
cpSync(target, full, { recursive: true });
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
materializeSymlinks(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// agent-browser's npm package ships one native binary per supported platform
|
||||
// (~11 MB each); its bin/agent-browser.js launcher picks the right one from
|
||||
// process.platform/process.arch at runtime. A platform payload only ever runs
|
||||
// on its own platform, so keep just the matching binary (plus the musl
|
||||
// variant on linux — the launcher detects musl at runtime) and drop the rest.
|
||||
function pruneAgentBrowserBinaries(packageDir, entry) {
|
||||
const binDir = join(packageDir, 'helpers', 'node_modules', 'agent-browser', 'bin');
|
||||
if (!existsSync(binDir)) return;
|
||||
const keep = new Set(
|
||||
entry.platform === 'linux'
|
||||
? [`linux-${entry.arch}`, `linux-musl-${entry.arch}`]
|
||||
: [`${entry.platform}-${entry.arch}`],
|
||||
);
|
||||
for (const name of readdirSync(binDir)) {
|
||||
const match = name.match(/^agent-browser-(.+?)(\.exe)?$/);
|
||||
if (!match || match[1] === '') continue;
|
||||
if (!keep.has(match[1])) rmSync(join(binDir, name));
|
||||
}
|
||||
const kept = readdirSync(binDir).filter((name) => /^agent-browser-.+/.test(name));
|
||||
if (kept.length === 0) {
|
||||
fail(`pruning agent-browser binaries for ${entry.platform}-${entry.arch} left none behind`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors verifyStagedLocalControlHelpers in internal/release/release.go: the
|
||||
// payload must carry runnable shims for both vendored helpers, or browser /
|
||||
// terminal control silently degrades to a PATH lookup for every npm user.
|
||||
function verifyHelperShims(packageDir, entry, assetName) {
|
||||
const binDir = join(packageDir, 'helpers', 'node_modules', '.bin');
|
||||
for (const helper of ['agent-browser', 'tuistory']) {
|
||||
const shimNames =
|
||||
entry.platform === 'win32' ? [`${helper}.cmd`, `${helper}.exe`, helper] : [helper];
|
||||
if (!shimNames.some((name) => existsSync(join(binDir, name)))) {
|
||||
fail(`${assetName} helpers tree is missing an executable ${helper} shim`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
verifyChecksum(archivePath, assetName);
|
||||
|
||||
const binaryName = entry.platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'zero-npm-'));
|
||||
try {
|
||||
extractArchive(archivePath, entry.ext, tempDir);
|
||||
const sourceDir = extractedPackageDir(tempDir, `zero-v${version}-${entry.release}`, binaryName);
|
||||
const packageDir = join(outDir, 'platforms', `zero-${entry.platform}-${entry.arch}`);
|
||||
rmSync(packageDir, { recursive: true, force: true });
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
for (const item of readdirSync(sourceDir)) {
|
||||
if (PLATFORM_PACKAGE_EXCLUDES.includes(item)) continue;
|
||||
// verbatimSymlinks keeps relative link targets relative; the default
|
||||
// rewrites them to absolute paths into this soon-to-be-deleted temp dir.
|
||||
cpSync(join(sourceDir, item), join(packageDir, item), { recursive: true, verbatimSymlinks: true });
|
||||
}
|
||||
|
||||
const binaryPath = join(packageDir, binaryName);
|
||||
if (!existsSync(binaryPath)) fail(`${assetName} did not contain ${binaryName}`);
|
||||
if (entry.platform !== 'win32') chmodSync(binaryPath, 0o755);
|
||||
materializeSymlinks(packageDir);
|
||||
pruneAgentBrowserBinaries(packageDir, entry);
|
||||
verifyHelperShims(packageDir, entry, assetName);
|
||||
|
||||
// npm only ships a license file it finds in the packed directory;
|
||||
// package.json's license field alone does not include the text.
|
||||
cpSync(join(repoRoot, 'LICENSE'), join(packageDir, 'LICENSE'));
|
||||
|
||||
const wrapperPkg = readWrapperPackage();
|
||||
const manifest = {
|
||||
name: '@gitlawb/zero',
|
||||
version: platformVersion(version, entry),
|
||||
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`,
|
||||
os: [entry.platform],
|
||||
cpu: [entry.arch],
|
||||
license: wrapperPkg.license,
|
||||
repository: wrapperPkg.repository,
|
||||
// No bin, no scripts, no dependencies: this payload is inert on its own;
|
||||
// the wrapper package execs the binary out of it.
|
||||
};
|
||||
writeFileSync(join(packageDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
return packageDir;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildWrapperPackage(version, outDir) {
|
||||
const wrapperDir = join(outDir, 'wrapper');
|
||||
rmSync(wrapperDir, { recursive: true, force: true });
|
||||
mkdirSync(join(wrapperDir, 'bin'), { recursive: true });
|
||||
mkdirSync(join(wrapperDir, 'scripts'), { recursive: true });
|
||||
|
||||
cpSync(join(repoRoot, 'bin', 'zero.js'), join(wrapperDir, 'bin', 'zero.js'));
|
||||
cpSync(join(repoRoot, 'scripts', 'postinstall.mjs'), join(wrapperDir, 'scripts', 'postinstall.mjs'));
|
||||
cpSync(join(repoRoot, 'README.md'), join(wrapperDir, 'README.md'));
|
||||
// npm auto-includes LICENSE in the tarball only when the file is present in
|
||||
// the directory being packed (the files whitelist cannot exclude it).
|
||||
cpSync(join(repoRoot, 'LICENSE'), join(wrapperDir, 'LICENSE'));
|
||||
chmodSync(join(wrapperDir, 'bin', 'zero.js'), 0o755);
|
||||
|
||||
const pkg = readWrapperPackage();
|
||||
// The published wrapper must stay warning-free on every package manager:
|
||||
// no lifecycle scripts and no regular dependencies (the repo's dependencies
|
||||
// entries exist only to pin the vendored helpers/ tree that zero-release
|
||||
// stages into the platform payloads — see internal/release/release.go).
|
||||
delete pkg.scripts;
|
||||
delete pkg.dependencies;
|
||||
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 version = readWrapperPackage().version;
|
||||
|
||||
const selected = MATRIX.filter(
|
||||
(entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`),
|
||||
);
|
||||
if (selected.length === 0) fail('--only matched no platforms');
|
||||
if (args.only) {
|
||||
for (const key of args.only) {
|
||||
if (!MATRIX.some((entry) => `${entry.platform}-${entry.arch}` === key)) {
|
||||
fail(`--only lists unknown platform ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const built = [];
|
||||
for (const entry of selected) {
|
||||
built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir));
|
||||
}
|
||||
const wrapperDir = buildWrapperPackage(version, args.outDir);
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
version,
|
||||
wrapperDir,
|
||||
platformDirs: built,
|
||||
platformVersions: selected.map((entry) => platformVersion(version, entry)),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in new issue
Block a user