mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
The windows-x86_64 build was the one supported target left out of the
ACP bundling series: no lock entries, no staged resources, and — since
the series made the claude/codex catalog entries platform-unconditional
(empty install commands, "ships with the Buzz desktop app" hint) — a
Windows user without their own claude-agent-acp hit a dead end that
reinstalling could never fix. Everything upstream already exists (both
bridges publish win32-x64 native packages; the release job already runs
under Git Bash; the desktop's resolution/spawn layer is Windows-aware),
so extend the bundle to Windows. The one genuinely Windows-shaped
problem is the runtime shim: the staged bridges are bash wrappers that
Windows cannot execute — and the resolver only looks for <binary>.exe
anyway. Solve it with a tiny compiled launcher instead of .cmd shims,
which would have rippled a two-candidate list (.exe, .cmd) through the
shared resolution code and left an intermediate cmd.exe in the spawn
chain that kill_on_drop cannot reap through.
- New root-workspace crate buzz-acp-node-launcher: reads the sibling
<binary>.shim.json ({entrypoint, nodeEngine, requiredNodeMajor})
written by the staging scripts, resolves node from PATH, enforces the
lock's Node major with the exact wrapper-shim error message and exit
codes (127 missing / 1 too old), then runs node on the vendored
entrypoint, proxying stdio and the exit code. On Windows the child
joins a Job Object with KILL_ON_JOB_CLOSE (mirroring buzz-dev-mcp's
KillGroup) so terminating the launcher — as buzz-acp's kill_on_drop
does — takes the node tree with it; on Unix it execs node like the
bash shim, existing there so workspace clippy/tests pass everywhere.
Unsafe is confined to the Win32 FFI, cfg-forbidden elsewhere, per the
buzz-dev-mcp precedent.
- update-acp-tools-lock.mjs gains x86_64-pc-windows-msvc (npmOs win32,
npmCpu x64, no libc; native executables claude.exe and
vendor/x86_64-pc-windows-msvc/bin/codex.exe). The committed lock
grows 8 -> 10 entries via a partial --target run: the two new
Windows pins match the other targets' versions exactly (0.58.1 /
1.1.2, sdk 0.3.205, codex 0.144.1-win32-x64) and the existing 8
entries are preserved byte-identically.
- The staging scripts branch per target family through the shared
wrapper lib: Unix targets keep the bash wrapper; Windows targets
stage the launcher as <binary>.exe next to <binary>.shim.json
(write_windows_node_launcher). The launcher builds via cargo on
first use (ACP_NODE_LAUNCHER_EXE overrides; target dir resolved via
cargo metadata, never ./target), a target with no locked tools still
stages nothing without needing cargo, and dev-cache shims embed
bin-dir-relative entrypoints because Git Bash absolute paths
(/c/Users/...) are unresolvable to a native exe. The freshness path
re-copies the launcher when the built binary changes (cmp-gated: a
running agent's open .exe is never rewritten), the prune reduces
<binary>[.exe][.stamp] and <binary>.shim.json to the lock's bare
binary name, and harness-clis.json drops the .exe suffix from its
cli keys so the app's bare-name auth probes ("claude", "codex")
resolve the vendored CLIs on Windows too.
- release.yml's Windows job needs no extra wiring: the staging step
added with the previous commit now finds lock entries and builds the
launcher with the job's already-installed MSVC toolchain. The
windows-rust CI job gains a cargo test step for the launcher so its
spawn path gates on a real Windows runner.
Per the plan's risk note, codex-on-Windows maturity is a validate-
before-release concern: the lock's per-tool-per-target shape allows
dropping the codex-acp windows entry if a real session shakes out
badly. win32-arm64 packages exist but there is no arm64 Windows
release job; deferred until one exists. Node.js stays a user
prerequisite, surfaced by the existing node-runtime Doctor section.
Verification (macOS host):
- cargo test -p buzz-acp-node-launcher: 9 passed — manifest parsing,
version parsing, path resolution, plus end-to-end launcher runs
(arg/stdio/exit-code proxying via real node, missing-manifest,
missing-entrypoint, and too-old-Node failures with the wrapper-shim
message). cargo clippy --all-targets -D warnings and
cargo fmt --all --check: clean.
- Cross-staged the Windows target end-to-end with
ACP_NODE_LAUNCHER_EXE standing in for the MSVC launcher: both win32
npm trees install and validate against the lock (integrity + X_OK on
the vendored claude.exe/codex.exe, confirming the locked vendor
paths), bin dir stages <binary>.exe + .exe.stamp + .shim.json with
relative entrypoints, re-run performs zero installs, stray
.exe/.shim.json artifacts are pruned, and prepare writes
harness-clis.json with bare "claude"/"codex" keys mapping to the
vendored .exe paths.
- Empty-target Windows staging (aarch64-pc-windows-msvc) exits 0 with
the notice, without cargo on PATH.
- Darwin re-stage after the cross-stage: bash wrappers report 0.58.1 /
1.1.2, manifests back to darwin shape — Unix staging unregressed.
- biome check on update-acp-tools-lock.mjs: clean.
Windows-runner validation (NSIS install, Doctor states, live claude +
codex sessions, auth probes against the vendored CLIs) needs a real
Windows machine and rides the first release train with these entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
457 lines
14 KiB
JavaScript
Executable File
457 lines
14 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { execFile } from "node:child_process";
|
|
import { mkdir, readFile, 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",
|
|
"x86_64-pc-windows-msvc",
|
|
];
|
|
|
|
// 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",
|
|
},
|
|
},
|
|
"x86_64-pc-windows-msvc": {
|
|
npmOs: "win32",
|
|
npmCpu: "x64",
|
|
nativePackages: {
|
|
claudeAgentSdk: "@anthropic-ai/claude-agent-sdk-win32-x64",
|
|
openaiCodex: "@openai/codex-win32-x64",
|
|
},
|
|
nativeExecutables: {
|
|
claudeAgentSdk: "claude.exe",
|
|
openaiCodex: "vendor/x86_64-pc-windows-msvc/bin/codex.exe",
|
|
},
|
|
},
|
|
};
|
|
|
|
// Tarball URLs in the lock are informational — ensure-acp-tools.sh installs
|
|
// via the ambient npm registry and validates the registry-agnostic sha512
|
|
// integrity. Normalize them to the public registry so regenerating against an
|
|
// internal mirror (e.g. Artifactory) neither leaks internal hosts into the
|
|
// committed lock nor churns every URL in the diff.
|
|
const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
|
|
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.
|
|
|
|
A --target run regenerates only the selected targets; the existing lock's
|
|
entries for every other target are preserved verbatim, so a partial bump
|
|
never drops another target's pins.
|
|
|
|
Tarball URLs are normalized to ${PUBLIC_NPM_REGISTRY} regardless of the
|
|
registry that resolved the metadata, so regeneration is deterministic across
|
|
environments and internal mirror hosts never land in the committed lock.
|
|
|
|
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;
|
|
}
|
|
|
|
// Rebuild the tarball URL on PUBLIC_NPM_REGISTRY, keeping the registry's
|
|
// `<package>/-/<basename>.tgz` path (the basename is not always derivable
|
|
// from name@version — @openai/codex native tarballs carry a platform suffix).
|
|
function normalizeTarballUrl(tarball, packageName, label) {
|
|
const separator = "/-/";
|
|
const separatorIndex = tarball.lastIndexOf(separator);
|
|
if (separatorIndex === -1) {
|
|
throw new Error(`Cannot normalize ${label} tarball URL: ${tarball}`);
|
|
}
|
|
const basename = tarball.slice(separatorIndex + separator.length);
|
|
return `${PUBLIC_NPM_REGISTRY}/${packageName}/-/${basename}`;
|
|
}
|
|
|
|
function packageDist(metadata, label) {
|
|
const dist = metadata?.dist;
|
|
return {
|
|
tarball: normalizeTarballUrl(
|
|
requireString(dist?.tarball, `${label} dist.tarball`),
|
|
requireString(metadata?.name, `${label} name`),
|
|
label,
|
|
),
|
|
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,
|
|
};
|
|
}
|
|
|
|
// Entries preserved from the existing lock when --target selects a subset.
|
|
// A missing lock preserves nothing (there is nothing to drop); an unreadable
|
|
// or malformed one aborts the run rather than clobbering pins we cannot see.
|
|
async function preservedLockEntries(lockFile, selectedTargets) {
|
|
let raw;
|
|
try {
|
|
raw = await readFile(lockFile, "utf8");
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return [];
|
|
throw new Error(
|
|
`Cannot read existing lock ${lockFile} to preserve unselected targets: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Existing lock ${lockFile} is not valid JSON — refusing to overwrite it with a partial --target run: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
if (!Array.isArray(parsed?.tools)) {
|
|
throw new Error(
|
|
`Existing lock ${lockFile} has no tools array — refusing to overwrite it with a partial --target run`,
|
|
);
|
|
}
|
|
return parsed.tools.filter((entry) => !selectedTargets.has(entry?.target));
|
|
}
|
|
|
|
async function main() {
|
|
const { targets, lockFile } = parseArgs(process.argv.slice(2));
|
|
const selectedTargets = new Set(targets);
|
|
const fullRun = SUPPORTED_TARGETS.every((target) =>
|
|
selectedTargets.has(target),
|
|
);
|
|
const preserved = fullRun
|
|
? []
|
|
: await preservedLockEntries(lockFile, selectedTargets);
|
|
const tools = [];
|
|
for (const tool of TOOL_SPECS) {
|
|
for (const target of targets) {
|
|
tools.push(await lockToolForTarget(tool, target));
|
|
}
|
|
}
|
|
if (preserved.length) {
|
|
console.log(
|
|
`Preserved ${preserved.length} existing lock entr${
|
|
preserved.length === 1 ? "y" : "ies"
|
|
} for unselected targets`,
|
|
);
|
|
}
|
|
tools.push(...preserved);
|
|
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);
|
|
});
|