feat(desktop): pin and stage bundled ACP bridge tools

Buzz desktop spawned the claude-agent-acp and codex-acp bridges from
whatever the user had installed globally — an unpinned `npm install -g`
surface with no integrity checking, which produced stale-bridge drift
(the deprecated @zed-industries/codex-acp 0.16.x gate) and
missing-tool failures. Bundle both bridges as app resources instead,
pinned per target:

- desktop/acp-tools.lock.json pins @agentclientprotocol/claude-agent-acp
  0.58.1 and @agentclientprotocol/codex-acp 1.1.2 (npm `latest` at time
  of commit) for the four supported targets, with integrity hashes and
  Block Artifactory tarballs for the package, its claude-agent-sdk /
  @openai/codex dependency, and the per-target native package.
- desktop/scripts/update-acp-tools-lock.mjs regenerates the lock from
  the registry's `latest` dist-tags, failing loudly on any unresolvable
  package — never silently pinning an older version. Ranged
  dependencies (codex-acp's ^0.144.0) resolve to the highest matching
  version when `npm view` returns an array.
- desktop/scripts/ensure-acp-tools.sh installs the locked tools into a
  shared dev cache (~/Library/Caches/buzz-dev/acp-tools), validates
  versions + integrity against the lock, and stamps staged binaries
  next to the shared bin dir so any lock change — including a revert —
  forces a re-stage; binaries no longer in the lock are pruned.
- desktop/scripts/prepare-acp-tools-resource.sh stages the vendored npm
  trees + node wrapper shims into desktop/src-tauri/resources/acp,
  writes the node-runtime.json manifest for the app's Node.js doctor
  check, and ad-hoc signs every nested Mach-O (file(1) scan — the codex
  package vendors rg, zsh, and codex-code-mode-host beyond the main
  CLIs) so Gatekeeper doesn't kill them in local builds.
- desktop/scripts/lib/acp-node-wrapper.sh is the single wrapper-shim
  generator shared by both scripts, so the dev-cache and bundled
  wrappers (and the Node major they enforce) cannot drift.
- Wiring: `just dev` / `just staging` stage the resources and export
  BUZZ_ACP_TOOLS_DIR; `just desktop-release-build` stages per target
  before `tauri build`; `just bump-acp-tools` reruns the lock updater;
  tauri.conf.json bundles resources/acp; staged artifacts are
  gitignored with a .gitkeep placeholder.

Runtime resolution of the staged dir follows in the next commit. The
sprout-releases internal pipeline will need the same staging step
before its `tauri build`; that lives in a separate repo.

Ports the build-time half of the Staged implementation in
block/builderbot#876 (branch commits 16b115a7 bundle feature, f5c6bba9
re-stage after lock revert, 288fa7eb shared node wrapper lib, adea4017
node-runtime manifest, 5124302a sign all nested Mach-Os, de6a9782
ranged-dependency updater fix), itself ported from squareup/berd
f07df1d2 + 1db993fb + 24d7518b + 07087303 + 737e33a5.

Verified: update-acp-tools-lock.mjs regenerated the lock against the
Block registry (byte-identical pins to the donor lock; both packages
confirmed at npm `latest`); fresh stage installs both tools and the
staged wrappers report 0.58.1 / 1.1.2; no-op re-run performs zero npm
installs; a stamp/lock mismatch forces a re-stage and stray binaries
are pruned; all five staged Mach-Os pass `codesign --verify`; cargo
check on desktop/src-tauri passes with the new resources entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-07-13 13:08:27 +10:00
parent a2fd5f8139
commit a29a3da6fe
9 changed files with 1133 additions and 0 deletions

6
.gitignore vendored
View File

@@ -38,6 +38,12 @@ node_modules/
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
/package-lock.json
# Bundled ACP bridge tools (regenerated by desktop/scripts/prepare-acp-tools-resource.sh)
desktop/src-tauri/resources/acp/bin/*
!desktop/src-tauri/resources/acp/bin/.gitkeep
desktop/src-tauri/resources/acp/node/
desktop/src-tauri/resources/acp/node-runtime.json
# sqlx offline query data (generated, not portable)
.sqlx/

View File

@@ -121,6 +121,10 @@ desktop-test:
desktop-typecheck:
cd {{desktop_dir}} && pnpm typecheck
# Query the latest npm releases of the bundled ACP bridge tools and update desktop/acp-tools.lock.json
bump-acp-tools *ARGS:
node desktop/scripts/update-acp-tools-lock.mjs {{ARGS}}
# Build desktop frontend assets
desktop-build:
cd {{desktop_dir}} && pnpm build
@@ -206,6 +210,7 @@ desktop-release-build target="aarch64-apple-darwin":
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
./desktop/scripts/prepare-acp-tools-resource.sh "$TARGET"
pnpm install
cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
@@ -330,6 +335,11 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
done
fi
cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
# Stage the pinned ACP bridge tools and point the app at the staged dir so
# dev builds resolve the same bundled bridges as packaged builds.
./desktop/scripts/prepare-acp-tools-resource.sh
export BUZZ_ACP_TOOLS_DIR="{{justfile_directory()}}/desktop/src-tauri/resources/acp/bin"
echo "Using ACP tools dir: ${BUZZ_ACP_TOOLS_DIR}"
./target/debug/buzz-relay &
RELAY_PID=$!
sleep 1
@@ -358,6 +368,11 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: staging must always start with a clean dep tree
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
# Stage the pinned ACP bridge tools and point the app at the staged dir so
# staging builds resolve the same bundled bridges as packaged builds.
./desktop/scripts/prepare-acp-tools-resource.sh
export BUZZ_ACP_TOOLS_DIR="{{justfile_directory()}}/desktop/src-tauri/resources/acp/bin"
echo "Using ACP tools dir: ${BUZZ_ACP_TOOLS_DIR}"
FEATURES=()
if [[ -n "{{mesh}}" ]]; then
FEATURES=(--features mesh-llm)

196
desktop/acp-tools.lock.json Normal file
View File

@@ -0,0 +1,196 @@
{
"tools": [
{
"id": "claude-acp",
"binary": "claude-agent-acp",
"source": "npm",
"package": "@agentclientprotocol/claude-agent-acp",
"version": "0.58.1",
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz",
"target": "aarch64-apple-darwin",
"npmOs": "darwin",
"npmCpu": "arm64",
"nodeEngine": ">=22",
"dependencyPackage": "@anthropic-ai/claude-agent-sdk",
"dependencyVersion": "0.3.205",
"dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz",
"claudeCodeVersion": "2.1.205",
"nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-arm64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz",
"nativeExecutable": "claude"
},
{
"id": "claude-acp",
"binary": "claude-agent-acp",
"source": "npm",
"package": "@agentclientprotocol/claude-agent-acp",
"version": "0.58.1",
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz",
"target": "aarch64-unknown-linux-gnu",
"npmOs": "linux",
"npmCpu": "arm64",
"npmLibc": "glibc",
"nodeEngine": ">=22",
"dependencyPackage": "@anthropic-ai/claude-agent-sdk",
"dependencyVersion": "0.3.205",
"dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz",
"claudeCodeVersion": "2.1.205",
"nativePackage": "@anthropic-ai/claude-agent-sdk-linux-arm64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-arm64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz",
"nativeExecutable": "claude"
},
{
"id": "claude-acp",
"binary": "claude-agent-acp",
"source": "npm",
"package": "@agentclientprotocol/claude-agent-acp",
"version": "0.58.1",
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz",
"target": "x86_64-apple-darwin",
"npmOs": "darwin",
"npmCpu": "x64",
"nodeEngine": ">=22",
"dependencyPackage": "@anthropic-ai/claude-agent-sdk",
"dependencyVersion": "0.3.205",
"dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz",
"claudeCodeVersion": "2.1.205",
"nativePackage": "@anthropic-ai/claude-agent-sdk-darwin-x64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-darwin-x64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz",
"nativeExecutable": "claude"
},
{
"id": "claude-acp",
"binary": "claude-agent-acp",
"source": "npm",
"package": "@agentclientprotocol/claude-agent-acp",
"version": "0.58.1",
"integrity": "sha512-F1/W6EJdoYbrEUluRUknx0Nn0MAKDOkn2C/9YcP/joVkmdFUGTAxlGDpwdYu239TOkpc8Qm4+ffGsQjPZdryTg==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/claude-agent-acp/-/claude-agent-acp-0.58.1.tgz",
"target": "x86_64-unknown-linux-gnu",
"npmOs": "linux",
"npmCpu": "x64",
"npmLibc": "glibc",
"nodeEngine": ">=22",
"dependencyPackage": "@anthropic-ai/claude-agent-sdk",
"dependencyVersion": "0.3.205",
"dependencyIntegrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz",
"claudeCodeVersion": "2.1.205",
"nativePackage": "@anthropic-ai/claude-agent-sdk-linux-x64",
"nativePackageName": "@anthropic-ai/claude-agent-sdk-linux-x64",
"nativeVersion": "0.3.205",
"nativeIntegrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz",
"nativeExecutable": "claude"
},
{
"id": "codex-acp",
"binary": "codex-acp",
"source": "npm",
"package": "@agentclientprotocol/codex-acp",
"version": "1.1.2",
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz",
"target": "aarch64-apple-darwin",
"npmOs": "darwin",
"npmCpu": "arm64",
"nodeEngine": ">=22",
"dependencyPackage": "@openai/codex",
"dependencyVersion": "0.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-darwin-arm64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-darwin-arm64",
"nativeIntegrity": "sha512-dABeDK+ATqMG54MGBd3VjpKfh5EOoqx9PKVQB2QYDaEXx3F6CdUCXue5QIMfr4OxziUj8pUcLAQyd+KFqiTUFw==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-darwin-arm64.tgz",
"nativeExecutable": "vendor/aarch64-apple-darwin/bin/codex"
},
{
"id": "codex-acp",
"binary": "codex-acp",
"source": "npm",
"package": "@agentclientprotocol/codex-acp",
"version": "1.1.2",
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz",
"target": "aarch64-unknown-linux-gnu",
"npmOs": "linux",
"npmCpu": "arm64",
"npmLibc": "glibc",
"nodeEngine": ">=22",
"dependencyPackage": "@openai/codex",
"dependencyVersion": "0.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-linux-arm64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-linux-arm64",
"nativeIntegrity": "sha512-451o15+XtaXCCb35t/KCyyPqXHnTPxPxtdqEYOnE3e4sH5AfnI/uVJwfdjOksMG6vRLy6R+fLvSDOMguRFLmQw==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-linux-arm64.tgz",
"nativeExecutable": "vendor/aarch64-unknown-linux-musl/bin/codex"
},
{
"id": "codex-acp",
"binary": "codex-acp",
"source": "npm",
"package": "@agentclientprotocol/codex-acp",
"version": "1.1.2",
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz",
"target": "x86_64-apple-darwin",
"npmOs": "darwin",
"npmCpu": "x64",
"nodeEngine": ">=22",
"dependencyPackage": "@openai/codex",
"dependencyVersion": "0.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-darwin-x64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-darwin-x64",
"nativeIntegrity": "sha512-K2g3Q3tNxzFhV0SuzO6HcsYK7EQrp/o4HyeReyhkwVrwwUPoYwyIbB0IRjHIiDzRhbKriDccid2iyF5aPqdTcg==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-darwin-x64.tgz",
"nativeExecutable": "vendor/x86_64-apple-darwin/bin/codex"
},
{
"id": "codex-acp",
"binary": "codex-acp",
"source": "npm",
"package": "@agentclientprotocol/codex-acp",
"version": "1.1.2",
"integrity": "sha512-qE/R1WdqJJ9OFHsHGvbmVmS2j9iCMZzpWT3g2XIViXrGHu1fLOALLINBIlW+WzKDllCh131aB6cqcIWSt0otbw==",
"tarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@agentclientprotocol/codex-acp/-/codex-acp-1.1.2.tgz",
"target": "x86_64-unknown-linux-gnu",
"npmOs": "linux",
"npmCpu": "x64",
"npmLibc": "glibc",
"nodeEngine": ">=22",
"dependencyPackage": "@openai/codex",
"dependencyVersion": "0.144.1",
"dependencyIntegrity": "sha512-Xir1zqPfpenhdoAoshN53uonzbBXj18COyzRkFlVZpSNyEl5XtkuYu9oddELePFN7K/0sXUcSO34Ad5IeCXPbw==",
"dependencyTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1.tgz",
"nativePackage": "@openai/codex-linux-x64",
"nativePackageName": "@openai/codex",
"nativeVersion": "0.144.1-linux-x64",
"nativeIntegrity": "sha512-HNGVI+BulrOaC/0IzBvd6EL62j7LrlbFKibrhw6hZjjCjAeUYzRB2jB4qDzXN1NfqDi6Xrvniof3kwbwab24lg==",
"nativeTarball": "https://global.block-artifacts.com/artifactory/api/npm/square-npm/@openai/codex/-/codex-0.144.1-linux-x64.tgz",
"nativeExecutable": "vendor/x86_64-unknown-linux-musl/bin/codex"
}
]
}

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
app_root="$(cd "$script_dir/.." && pwd)"
lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}"
# shellcheck source=lib/acp-node-wrapper.sh
source "$script_dir/lib/acp-node-wrapper.sh"
usage() {
cat <<'USAGE'
Usage: desktop/scripts/ensure-acp-tools.sh [--target <target-triple>] [--print-bin-dir]
Installs the ACP bridge tools pinned in acp-tools.lock.json into the shared
Buzz dev cache. The lockfile is target-specific; only entries matching the
requested target are prepared. Each tool is installed as a vendored npm
package tree with a small executable wrapper, validated against the locked
versions and integrity hashes.
Environment variables:
ACP_TOOLS_LOCK_FILE lockfile path (default: desktop/acp-tools.lock.json)
ACP_TOOLS_CACHE_DIR cache dir override
USAGE
}
default_cache_root() {
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
printf '%s/buzz-dev/acp-tools\n' "$XDG_CACHE_HOME"
return
fi
case "$(uname -s)" in
Darwin) printf '%s/Library/Caches/buzz-dev/acp-tools\n' "$HOME" ;;
*) printf '%s/.cache/buzz-dev/acp-tools\n' "$HOME" ;;
esac
}
target=""
print_bin_dir=0
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
target="${2:-}"
[[ -n "$target" ]] || { echo "--target requires a value" >&2; exit 1; }
shift 2
;;
--print-bin-dir)
print_bin_dir=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$target" ]]; then
target="$(rustc -vV | sed -n 's|host: ||p')"
fi
if [[ -z "$target" ]]; then
echo "Could not determine rust host target. Pass --target explicitly." >&2
exit 1
fi
cache_root="${ACP_TOOLS_CACHE_DIR:-$(default_cache_root)}"
bin_dir="$cache_root/bin/$target"
if [[ ! -f "$lock_file" ]]; then
echo "ACP tools lockfile not found: $lock_file" >&2
exit 1
fi
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Required tool missing: $1" >&2
exit 1
fi
}
require_tool node
require_tool npm
lock_entries="$(node - "$lock_file" "$target" <<'NODE'
const fs = require("node:fs");
const [lockFile, target] = process.argv.slice(2);
const data = JSON.parse(fs.readFileSync(lockFile, "utf8"));
const entries = (data.tools ?? []).filter((tool) => tool.target === target);
function requireString(entry, field) {
if (typeof entry[field] !== "string" || entry[field].trim() === "") {
throw new Error(`Invalid ACP tool lock entry for ${entry.id ?? "(unknown)"}: missing ${field}`);
}
}
for (const entry of entries) {
if (entry.source !== "npm") {
throw new Error(`Invalid ACP tool lock entry for ${entry.id}: unsupported source ${entry.source}`);
}
for (const field of [
"id",
"binary",
"target",
"package",
"version",
"integrity",
"tarball",
"npmOs",
"npmCpu",
"dependencyPackage",
"dependencyVersion",
"dependencyIntegrity",
"dependencyTarball",
"nativePackage",
"nativePackageName",
"nativeVersion",
"nativeIntegrity",
"nativeTarball",
"nativeExecutable",
]) {
requireString(entry, field);
}
}
process.stdout.write(JSON.stringify(entries));
NODE
)"
entry_count="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).length))' "$lock_entries")"
mkdir -p "$bin_dir"
if [[ "$entry_count" == "0" ]]; then
find "$bin_dir" -type f -delete
if [[ "$print_bin_dir" == "1" ]]; then
printf '%s\n' "$bin_dir"
else
echo "No ACP tools locked for target $target."
fi
exit 0
fi
validate_npm_install() {
local install_dir="$1"
local package="$2"
local version="$3"
local integrity="$4"
local dependency_package="$5"
local dependency_version="$6"
local dependency_integrity="$7"
local native_package="$8"
local native_package_name="$9"
local native_version="${10}"
local native_integrity="${11}"
local native_executable="${12}"
local claude_code_version="${13}"
node - "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const [
installDir,
packageName,
expectedVersion,
expectedIntegrity,
dependencyPackageName,
expectedDependencyVersion,
expectedDependencyIntegrity,
nativePackageName,
expectedNativePackageName,
expectedNativeVersion,
expectedNativeIntegrity,
nativeExecutable,
expectedClaudeCodeVersion,
] = process.argv.slice(2);
function packagePath(name, ...segments) {
return path.join(installDir, "node_modules", ...name.split("/"), ...segments);
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
}
function packageLockEntry(lock, packageName) {
const suffix = `node_modules/${packageName}`;
const match = Object.entries(lock.packages ?? {}).find(([key]) => key === suffix || key.endsWith(`/${suffix}`));
if (!match) {
throw new Error(`package-lock entry not found for ${packageName}`);
}
return match[1];
}
const packageJson = readJson(packagePath(packageName, "package.json"));
assertEqual(packageJson.name, packageName, `${packageName} name`);
assertEqual(packageJson.version, expectedVersion, `${packageName} version`);
const lock = readJson(path.join(installDir, "package-lock.json"));
assertEqual(packageLockEntry(lock, packageName).integrity, expectedIntegrity, `${packageName} integrity`);
const dependencyPackageJson = readJson(packagePath(dependencyPackageName, "package.json"));
assertEqual(
dependencyPackageJson.name,
dependencyPackageName,
`${dependencyPackageName} name`,
);
assertEqual(
dependencyPackageJson.version,
expectedDependencyVersion,
`${dependencyPackageName} version`,
);
if (expectedClaudeCodeVersion && expectedClaudeCodeVersion !== "null") {
assertEqual(
dependencyPackageJson.claudeCodeVersion,
expectedClaudeCodeVersion,
`${dependencyPackageName} claudeCodeVersion`,
);
}
assertEqual(
packageLockEntry(lock, dependencyPackageName).integrity,
expectedDependencyIntegrity,
`${dependencyPackageName} integrity`,
);
const nativePackageJson = readJson(packagePath(nativePackageName, "package.json"));
assertEqual(
nativePackageJson.name,
expectedNativePackageName,
`${nativePackageName} package name`,
);
assertEqual(
nativePackageJson.version,
expectedNativeVersion,
`${nativePackageName} version`,
);
fs.accessSync(packagePath(nativePackageName, nativeExecutable), fs.constants.X_OK);
assertEqual(
packageLockEntry(lock, nativePackageName).integrity,
expectedNativeIntegrity,
`${nativePackageName} integrity`,
);
NODE
}
node -e '
const entries = JSON.parse(process.argv[1]);
for (const entry of entries) {
console.log([
entry.id,
entry.binary,
entry.package,
entry.version,
entry.integrity,
entry.tarball,
entry.npmOs,
entry.npmCpu,
entry.npmLibc ?? "",
entry.nodeEngine ?? ">=22",
entry.dependencyPackage,
entry.dependencyVersion,
entry.dependencyIntegrity,
entry.dependencyTarball,
entry.nativePackage,
entry.nativePackageName,
entry.nativeVersion,
entry.nativeIntegrity,
entry.nativeTarball,
entry.nativeExecutable,
entry.claudeCodeVersion ?? "",
].join("\x1f"));
}
' "$lock_entries" | while IFS=$'\x1f' read -r id binary package version integrity tarball npm_os npm_cpu npm_libc node_engine dependency_package dependency_version dependency_integrity dependency_tarball native_package native_package_name native_version native_integrity native_tarball native_executable claude_code_version; do
[[ -n "$id" ]] || continue
tool_dir="$cache_root/$target/$id/$version"
install_dir="$tool_dir/npm"
package_dir="$install_dir/node_modules/$package"
entrypoint="$package_dir/dist/index.js"
native_binary="$install_dir/node_modules/$native_package/$native_executable"
staged_bin="$bin_dir/$binary"
# The staged output is shared across lock versions, so its freshness stamp
# must live next to it, not in the per-version tool_dir: a per-version stamp
# stays self-consistent after a lock revert and would skip re-staging.
stamp="$staged_bin.stamp"
if [[ -x "$staged_bin" && -f "$stamp" && -f "$entrypoint" && -x "$native_binary" ]]; then
# shellcheck disable=SC1090
source "$stamp"
if [[ "${STAMP_PACKAGE:-}" == "$package" && "${STAMP_VERSION:-}" == "$version" && "${STAMP_INTEGRITY:-}" == "$integrity" && "${STAMP_DEPENDENCY_PACKAGE:-}" == "$dependency_package" && "${STAMP_DEPENDENCY_VERSION:-}" == "$dependency_version" && "${STAMP_DEPENDENCY_INTEGRITY:-}" == "$dependency_integrity" && "${STAMP_NATIVE_PACKAGE:-}" == "$native_package" && "${STAMP_NATIVE_PACKAGE_NAME:-}" == "$native_package_name" && "${STAMP_NATIVE_VERSION:-}" == "$native_version" && "${STAMP_NATIVE_INTEGRITY:-}" == "$native_integrity" && "${STAMP_NATIVE_EXECUTABLE:-}" == "$native_executable" ]]; then
continue
fi
fi
echo "Installing ACP tool $id $version from npm for $target..." >&2
rm -rf "$install_dir"
mkdir -p "$install_dir" "$bin_dir"
npm_args=(
install
--prefix "$install_dir"
--omit=dev
--include=optional
--ignore-scripts
--no-audit
--no-fund
--os "$npm_os"
--cpu "$npm_cpu"
)
if [[ -n "$npm_libc" ]]; then
npm_args+=(--libc "$npm_libc")
fi
npm_args+=("$package@$version")
npm "${npm_args[@]}" >&2
validate_npm_install "$install_dir" "$package" "$version" "$integrity" "$dependency_package" "$dependency_version" "$dependency_integrity" "$native_package" "$native_package_name" "$native_version" "$native_integrity" "$native_executable" "$claude_code_version"
write_node_wrapper "$staged_bin" "$entrypoint" "$node_engine"
{
printf 'STAMP_TARGET=%q\n' "$target"
printf 'STAMP_PACKAGE=%q\n' "$package"
printf 'STAMP_VERSION=%q\n' "$version"
printf 'STAMP_INTEGRITY=%q\n' "$integrity"
printf 'STAMP_TARBALL=%q\n' "$tarball"
printf 'STAMP_NPM_OS=%q\n' "$npm_os"
printf 'STAMP_NPM_CPU=%q\n' "$npm_cpu"
printf 'STAMP_NPM_LIBC=%q\n' "$npm_libc"
printf 'STAMP_NODE_ENGINE=%q\n' "$node_engine"
printf 'STAMP_DEPENDENCY_PACKAGE=%q\n' "$dependency_package"
printf 'STAMP_DEPENDENCY_VERSION=%q\n' "$dependency_version"
printf 'STAMP_DEPENDENCY_INTEGRITY=%q\n' "$dependency_integrity"
printf 'STAMP_DEPENDENCY_TARBALL=%q\n' "$dependency_tarball"
printf 'STAMP_CLAUDE_CODE_VERSION=%q\n' "$claude_code_version"
printf 'STAMP_NATIVE_PACKAGE=%q\n' "$native_package"
printf 'STAMP_NATIVE_PACKAGE_NAME=%q\n' "$native_package_name"
printf 'STAMP_NATIVE_VERSION=%q\n' "$native_version"
printf 'STAMP_NATIVE_INTEGRITY=%q\n' "$native_integrity"
printf 'STAMP_NATIVE_TARBALL=%q\n' "$native_tarball"
printf 'STAMP_NATIVE_EXECUTABLE=%q\n' "$native_executable"
printf 'STAMP_BINARY=%q\n' "$binary"
} > "$stamp"
done
# bin_dir is prepended to the agent spawn PATH and the desktop's command
# resolution sweep, so binaries (and stamps) for tools no longer in the lock
# must be pruned, not just left behind.
locked_binaries="$(node -e '
const entries = JSON.parse(process.argv[1]);
for (const entry of entries) console.log(entry.binary);
' "$lock_entries" | sort -u)"
find "$bin_dir" -type f -print0 | while IFS= read -r -d '' staged_file; do
name="$(basename "$staged_file")"
if ! printf '%s\n' "$locked_binaries" | grep -Fxq -- "${name%.stamp}"; then
rm -f -- "$staged_file"
fi
done
if [[ "$print_bin_dir" == "1" ]]; then
printf '%s\n' "$bin_dir"
fi

View File

@@ -0,0 +1,61 @@
# Shared Node wrapper generation for ACP bridge tools staged from npm.
# Sourced by ensure-acp-tools.sh and prepare-acp-tools-resource.sh so the
# wrapper staged into the dev cache and the wrapper bundled into app
# resources cannot drift (a drift would make dev and bundled installs fail
# differently on the same missing/old Node runtime).
#
# acp_required_node_major <node-engine>
# Prints the minimum Node.js major version implied by a ">=N..." engine
# range, defaulting to 22 when the range is not in that form. Shared by
# the wrapper shim below and the node-runtime.json manifest consumed by
# the app's Node.js runtime doctor check, so the version the wrapper
# enforces at spawn time and the version the doctor reports at setup
# time cannot disagree.
acp_required_node_major() {
local node_engine="$1"
local major
major="$(printf '%s\n' "$node_engine" | sed -n 's/^>=\([0-9][0-9]*\).*$/\1/p')"
if [[ -z "$major" ]]; then
major=22
fi
printf '%s\n' "$major"
}
# write_node_wrapper <wrapper> <entrypoint> [node-engine]
# Writes an executable bash shim at <wrapper> that verifies a Node.js
# runtime satisfying <node-engine> (default ">=22") is on PATH, then
# execs node on <entrypoint>. An absolute <entrypoint> is embedded
# verbatim; a relative one is resolved against the wrapper's directory
# at run time.
write_node_wrapper() {
local wrapper="$1"
local entrypoint="$2"
local node_engine="${3:->=22}"
local required_node_major
required_node_major="$(acp_required_node_major "$node_engine")"
mkdir -p "$(dirname "$wrapper")"
{
printf '#!/usr/bin/env bash\n'
printf 'set -euo pipefail\n'
printf 'if ! command -v node >/dev/null 2>&1; then\n'
printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine"
printf ' exit 127\n'
printf 'fi\n'
printf 'required_node_major=%q\n' "$required_node_major"
printf 'node_major="$(node -p '\''process.versions.node.split(".")[0]'\'' 2>/dev/null || true)"\n'
printf 'if [[ -z "$node_major" || "$node_major" -lt "$required_node_major" ]]; then\n'
printf ' echo "%s requires Node.js %s on PATH." >&2\n' "$(basename "$wrapper")" "$node_engine"
printf ' exit 1\n'
printf 'fi\n'
if [[ "$entrypoint" == /* ]]; then
printf 'entrypoint=%q\n' "$entrypoint"
else
printf 'wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
printf 'entrypoint="$wrapper_dir"/%q\n' "$entrypoint"
fi
printf 'exec node "$entrypoint" "$@"\n'
} > "$wrapper"
chmod +x "$wrapper"
}

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
app_root="$(cd "$script_dir/.." && pwd)"
lock_file="${ACP_TOOLS_LOCK_FILE:-$app_root/acp-tools.lock.json}"
# shellcheck source=lib/acp-node-wrapper.sh
source "$script_dir/lib/acp-node-wrapper.sh"
usage() {
cat <<'USAGE'
Usage: desktop/scripts/prepare-acp-tools-resource.sh [target-triple]
Stages the locked ACP bridge tools into src-tauri/resources/acp so Tauri can
bundle them as application resources: vendored npm package trees under
resources/acp/node and executable wrappers under resources/acp/bin. The
optional target triple defaults to the Rust host target.
Note: resources/acp/bin holds a single target at a time, so staging must stay
tied to the build target.
USAGE
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
target="${1:-}"
ensure_args=()
if [[ -n "$target" ]]; then
ensure_args+=(--target "$target")
else
target="$(rustc -vV | sed -n 's|host: ||p')"
fi
if [[ -z "$target" ]]; then
echo "Could not determine rust host target." >&2
exit 1
fi
cache_bin_dir="$("$script_dir/ensure-acp-tools.sh" ${ensure_args[@]+"${ensure_args[@]}"} --print-bin-dir)"
cache_root="$(dirname "$(dirname "$cache_bin_dir")")"
resource_root="$app_root/src-tauri/resources/acp"
resource_bin_dir="$resource_root/bin"
resource_node_dir="$resource_root/node"
mkdir -p "$resource_bin_dir"
# Keep .gitkeep but refresh any staged tools from the lock.
find "$resource_bin_dir" -type f ! -name ".gitkeep" -delete
rm -rf "$resource_node_dir"
mkdir -p "$resource_node_dir"
# Manifest for the app's Node.js runtime doctor check, staged next to the
# bin dir so the app can resolve it as the bin dir's parent. Removed up
# front so locks with no npm-sourced tools ship no manifest and the doctor
# check stays silent.
node_runtime_manifest="$resource_root/node-runtime.json"
rm -f "$node_runtime_manifest"
node_runtime_entries=()
codesign_if_darwin() {
local file="$1"
if [[ "$(uname -s)" == "Darwin" ]] && command -v codesign >/dev/null 2>&1; then
codesign --force --sign - "$file" >/dev/null 2>&1 || true
fi
}
while IFS=$'\t' read -r id binary package version node_engine; do
[[ -n "$id" ]] || continue
install_dir="$cache_root/$target/$id/$version/npm"
entrypoint="$install_dir/node_modules/$package/dist/index.js"
if [[ ! -f "$entrypoint" ]]; then
echo "Locked npm ACP tool missing from cache: $package@$version" >&2
exit 1
fi
resource_package_dir="$resource_node_dir/$id"
mkdir -p "$resource_package_dir"
cp -R "$install_dir/." "$resource_package_dir/"
resource_entrypoint="$resource_package_dir/node_modules/$package/dist/index.js"
if [[ ! -f "$resource_entrypoint" ]]; then
echo "Failed to stage npm ACP tool: $package@$version" >&2
exit 1
fi
write_node_wrapper "$resource_bin_dir/$binary" "../node/$id/node_modules/$package/dist/index.js" "$node_engine"
node_runtime_entries+=("$id"$'\t'"$binary"$'\t'"$node_engine"$'\t'"$(acp_required_node_major "$node_engine")")
# Ad-hoc sign every Mach-O in the staged package, not just the main CLIs:
# the codex native package also vendors executables like rg and zsh, and
# unsigned nested Mach-Os are killed by Gatekeeper. Darwin only, so Linux
# staging skips the file(1) scan.
if [[ "$(uname -s)" == "Darwin" ]]; then
while IFS= read -r -d '' candidate; do
if file -b "$candidate" | grep -q "Mach-O"; then
codesign_if_darwin "$candidate"
fi
done < <(find "$resource_package_dir" -type f -print0)
fi
done < <(node - "$lock_file" "$target" <<'NODE'
const fs = require("node:fs");
const [lockFile, target] = process.argv.slice(2);
const data = JSON.parse(fs.readFileSync(lockFile, "utf8"));
for (const entry of data.tools ?? []) {
if (entry.target !== target || typeof entry.binary !== "string") continue;
if (entry.source !== "npm") {
throw new Error(`Unsupported ACP tool source: ${entry.source}`);
}
console.log([entry.id, entry.binary, entry.package, entry.version, entry.nodeEngine ?? ">=22"].join("\t"));
}
NODE
)
# One manifest entry per npm-sourced bridge, each carrying its own required
# Node major, so bridges with different engine ranges surface distinct
# requirements in the doctor check.
if ((${#node_runtime_entries[@]} > 0)); then
node -e '
const fs = require("node:fs");
const [manifestFile, ...entries] = process.argv.slice(1);
const tools = entries.map((line) => {
const [id, binary, nodeEngine, requiredNodeMajor] = line.split("\t");
return { id, binary, nodeEngine, requiredNodeMajor: Number(requiredNodeMajor) };
});
fs.writeFileSync(manifestFile, `${JSON.stringify({ tools }, null, 2)}\n`);
' "$node_runtime_manifest" ${node_runtime_entries[@]+"${node_runtime_entries[@]}"}
echo "Wrote ACP Node runtime manifest: $node_runtime_manifest"
fi
echo "Staged ACP tools resource: $resource_bin_dir"

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { promisify } from "node:util";
const appRoot = path.resolve(import.meta.dirname, "..");
const defaultLockFile = path.join(appRoot, "acp-tools.lock.json");
const SUPPORTED_TARGETS = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
];
// The Codex ACP executable stays `codex-acp`, but bundled installs must come
// from the maintained Agent Client Protocol package rather than the stale
// Zed package.
const CODEX_ACP_PACKAGE = "@agentclientprotocol/codex-acp";
const TOOL_SPECS = [
{
id: "claude-acp",
binary: "claude-agent-acp",
package: "@agentclientprotocol/claude-agent-acp",
dependencyPackage: "@anthropic-ai/claude-agent-sdk",
nativePackageKey: "claudeAgentSdk",
includeClaudeCodeVersion: true,
},
{
id: "codex-acp",
binary: "codex-acp",
package: CODEX_ACP_PACKAGE,
dependencyPackage: "@openai/codex",
nativePackageKey: "openaiCodex",
},
];
const NPM_TARGET_CONFIG = {
"aarch64-apple-darwin": {
npmOs: "darwin",
npmCpu: "arm64",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-arm64",
openaiCodex: "@openai/codex-darwin-arm64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/aarch64-apple-darwin/bin/codex",
},
},
"x86_64-apple-darwin": {
npmOs: "darwin",
npmCpu: "x64",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-darwin-x64",
openaiCodex: "@openai/codex-darwin-x64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/x86_64-apple-darwin/bin/codex",
},
},
"aarch64-unknown-linux-gnu": {
npmOs: "linux",
npmCpu: "arm64",
npmLibc: "glibc",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-arm64",
openaiCodex: "@openai/codex-linux-arm64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/aarch64-unknown-linux-musl/bin/codex",
},
},
"x86_64-unknown-linux-gnu": {
npmOs: "linux",
npmCpu: "x64",
npmLibc: "glibc",
nativePackages: {
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-linux-x64",
openaiCodex: "@openai/codex-linux-x64",
},
nativeExecutables: {
claudeAgentSdk: "claude",
openaiCodex: "vendor/x86_64-unknown-linux-musl/bin/codex",
},
},
};
const npmViewCache = new Map();
const execFileAsync = promisify(execFile);
function usage() {
console.log(`Usage: desktop/scripts/update-acp-tools-lock.mjs [--target <triple>]... [--lock-file <path>]
Queries npm for the latest release of each supported ACP bridge tool and
writes acp-tools.lock.json. Fails loudly when a package or one of its
per-target native dependencies cannot be resolved — never silently pins an
older version.
Supported targets:
${SUPPORTED_TARGETS.join("\n ")}
Environment:
npm registry config used to resolve packages
ACP_TOOLS_LOCK_FILE lockfile path override
`);
}
function parseArgs(argv) {
const targets = [];
let lockFile = process.env.ACP_TOOLS_LOCK_FILE ?? defaultLockFile;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
}
if (arg === "--target") {
const value = argv[++i];
if (!value) throw new Error("--target requires a value");
targets.push(value);
continue;
}
if (arg === "--lock-file") {
const value = argv[++i];
if (!value) throw new Error("--lock-file requires a value");
lockFile = path.resolve(value);
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
const selectedTargets = targets.length ? targets : SUPPORTED_TARGETS;
for (const target of selectedTargets) {
if (!SUPPORTED_TARGETS.includes(target)) {
throw new Error(`Unsupported target '${target}'`);
}
}
return { targets: selectedTargets, lockFile };
}
async function npmView(spec, fields) {
const cacheKey = `${spec}\0${fields.join("\0")}`;
if (!npmViewCache.has(cacheKey)) {
npmViewCache.set(
cacheKey,
execFileAsync("npm", ["view", spec, ...fields, "--json"], {
maxBuffer: 10 * 1024 * 1024,
}).then(({ stdout }) => {
try {
return JSON.parse(stdout);
} catch (error) {
throw new Error(
`npm view ${spec} returned invalid JSON: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}),
);
}
return npmViewCache.get(cacheKey);
}
function requireString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing ${label}`);
}
return value;
}
function packageDist(metadata, label) {
const dist = metadata?.dist;
return {
tarball: requireString(dist?.tarball, `${label} dist.tarball`),
integrity: requireString(dist?.integrity, `${label} dist.integrity`),
};
}
function compareSemver(left, right) {
const leftCore = left.split("-", 1)[0].split(".").map(Number);
const rightCore = right.split("-", 1)[0].split(".").map(Number);
for (let i = 0; i < 3; i += 1) {
if ((leftCore[i] ?? 0) !== (rightCore[i] ?? 0)) {
return (leftCore[i] ?? 0) - (rightCore[i] ?? 0);
}
}
// A release outranks any prerelease of the same core version.
return (left.includes("-") ? 0 : 1) - (right.includes("-") ? 0 : 1);
}
// npm view returns a single object when a spec matches one version, but an
// array of per-version objects when a range matches several. Pick the highest
// matching version so a ranged dependency still pins the newest release.
function pickLatestMatch(metadata, label) {
if (!Array.isArray(metadata)) return metadata;
if (metadata.length === 0) {
throw new Error(`No versions match ${label}`);
}
return metadata.reduce((best, candidate) =>
compareSemver(
requireString(candidate?.version, `${label} version`),
requireString(best?.version, `${label} version`),
) > 0
? candidate
: best,
);
}
function parseNpmAliasSpec(spec, fallbackPackage) {
if (!spec.startsWith("npm:")) {
return { packageName: fallbackPackage, version: spec };
}
const aliased = spec.slice("npm:".length);
const versionSeparator = aliased.lastIndexOf("@");
if (versionSeparator <= 0) {
throw new Error(`Unsupported npm alias spec: ${spec}`);
}
return {
packageName: aliased.slice(0, versionSeparator),
version: aliased.slice(versionSeparator + 1),
};
}
async function lockToolForTarget(tool, target) {
const npmTarget = NPM_TARGET_CONFIG[target];
if (!npmTarget) {
throw new Error(`No npm target mapping for ${target}`);
}
const packageName = tool.package;
const packageMetadata = await npmView(`${packageName}@latest`, [
"name",
"version",
"dist",
"dependencies",
"engines",
"bin",
]);
if (packageMetadata.name !== packageName) {
throw new Error(
`npm package ${packageName} resolved to ${packageMetadata.name}`,
);
}
const version = requireString(
packageMetadata.version,
`${packageName} version`,
);
const packageInfo = packageDist(packageMetadata, `${packageName}@${version}`);
const entry = {
id: tool.id,
binary: tool.binary,
source: "npm",
package: packageName,
version,
integrity: packageInfo.integrity,
tarball: packageInfo.tarball,
target,
npmOs: npmTarget.npmOs,
npmCpu: npmTarget.npmCpu,
...(npmTarget.npmLibc ? { npmLibc: npmTarget.npmLibc } : {}),
nodeEngine: packageMetadata.engines?.node ?? ">=22",
};
const dependencyRange = requireString(
packageMetadata.dependencies?.[tool.dependencyPackage],
`${packageName} dependency ${tool.dependencyPackage}`,
);
const dependencyMetadata = pickLatestMatch(
await npmView(`${tool.dependencyPackage}@${dependencyRange}`, [
"name",
"version",
"dist",
"optionalDependencies",
"claudeCodeVersion",
]),
`${tool.dependencyPackage}@${dependencyRange}`,
);
const dependencyVersion = requireString(
dependencyMetadata.version,
`${tool.dependencyPackage}@${dependencyRange} version`,
);
const dependencyInfo = packageDist(
dependencyMetadata,
`${tool.dependencyPackage}@${dependencyVersion}`,
);
entry.dependencyPackage = tool.dependencyPackage;
entry.dependencyVersion = dependencyVersion;
entry.dependencyIntegrity = dependencyInfo.integrity;
entry.dependencyTarball = dependencyInfo.tarball;
if (tool.includeClaudeCodeVersion) {
entry.claudeCodeVersion = dependencyMetadata.claudeCodeVersion ?? null;
}
const nativePackage = requireString(
npmTarget.nativePackages?.[tool.nativePackageKey],
`${target} native package for ${tool.nativePackageKey}`,
);
const nativeExecutable = requireString(
npmTarget.nativeExecutables?.[tool.nativePackageKey],
`${target} native executable for ${tool.nativePackageKey}`,
);
const nativeSpec = requireString(
dependencyMetadata.optionalDependencies?.[nativePackage],
`${tool.dependencyPackage}@${dependencyVersion} optional dependency ${nativePackage}`,
);
const nativeAlias = parseNpmAliasSpec(nativeSpec, nativePackage);
const nativeMetadata = await npmView(
`${nativeAlias.packageName}@${nativeAlias.version}`,
["name", "version", "dist"],
);
const nativeVersion = requireString(
nativeMetadata.version,
`${nativeAlias.packageName}@${nativeAlias.version} version`,
);
if (nativeVersion !== nativeAlias.version) {
throw new Error(
`${nativeAlias.packageName}@${nativeAlias.version} resolved to ${nativeVersion}`,
);
}
const nativeInfo = packageDist(
nativeMetadata,
`${nativeAlias.packageName}@${nativeVersion}`,
);
return {
...entry,
nativePackage,
nativePackageName: nativeMetadata.name ?? nativePackage,
nativeVersion,
nativeIntegrity: nativeInfo.integrity,
nativeTarball: nativeInfo.tarball,
nativeExecutable,
};
}
async function main() {
const { targets, lockFile } = parseArgs(process.argv.slice(2));
const tools = [];
for (const tool of TOOL_SPECS) {
for (const target of targets) {
tools.push(await lockToolForTarget(tool, target));
}
}
tools.sort((left, right) =>
`${left.id}:${left.target}`.localeCompare(`${right.id}:${right.target}`),
);
await mkdir(path.dirname(lockFile), { recursive: true });
await writeFile(lockFile, `${JSON.stringify({ tools }, null, 2)}\n`);
console.log(`Updated ${path.relative(process.cwd(), lockFile)}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View File

@@ -59,6 +59,7 @@
"binaries/git-credential-nostr",
"binaries/buzz"
],
"resources": ["resources/acp"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",