From 7e556c28d848a0d183541c50c06601509b3706fe Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 19 Oct 2026 13:14:28 +0100 Subject: [PATCH] build(npm): declare libc on platform packages, drop linux-x64 AVX2 variants, add files allowlist Bun 1.4.3 ships the same binary for its baseline and AVX2 x64 targets, and the 0.5.1 AVX2 and -baseline platform packages hold byte-identical binaries on linux, linux-musl, darwin and windows. Build and publish only the -baseline package on each x64 OS (7 platform packages instead of 11), and declare libc on the linux packages so npm installs one of them instead of all four linux-x64 packages (459 MB -> 116 MB glibc, 110 MB musl). The wrapper drops AVX2 detection and reads the libc from /usr/bin/ldd, falling back to Node's diagnostic report, instead of spawning `ldd --version`. With no matching binary it names the expected package instead of trying `bun run src/index.ts`. A files allowlist limits @aictrl/cli to bin/aictrl, README.md, LICENSE and package.json. The publish smoke job now waits until every published platform package is visible on npm, with bounded retries, before the install check. Target: 0.5.3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015nwvfUHdM3bgMgD9Zpz7Mj --- .github/workflows/publish.yml | 47 +++ CHANGELOG.md | 7 + CONTRIBUTING.md | 6 +- RELEASING.md | 24 ++ packages/cli/bin/aictrl | 270 ++++++------------ packages/cli/package.json | 5 + packages/cli/script/build.ts | 95 ++---- .../cli/test/cli/publish-workflow.test.ts | 126 +++++++- .../cli/wrapper-binary-resolution.test.ts | 155 +++++++--- 9 files changed, 447 insertions(+), 288 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ce3bf577..ca93e26f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,8 @@ jobs: permissions: contents: read id-token: write + outputs: + platform_packages: ${{ steps.manifest.outputs.platform_packages }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -111,8 +113,12 @@ jobs: done - name: Strip bundled deps and refresh platform binary optionalDependencies + id: manifest working-directory: packages/cli run: | + # The package.json `files` allowlist ships LICENSE, which lives at + # the repository root. + cp ../../LICENSE LICENSE # All bundleable dependencies are compiled into the native binary, so # the published package only needs: # - platform binary packages (regenerated below from dist/ to match @@ -163,6 +169,10 @@ jobs: pkg.optionalDependencies = { ...preserved, ...platformDeps }; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); console.log('optionalDependencies:', pkg.optionalDependencies); + // The smoke job waits until every one of them is visible on npm. + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'platform_packages=' + Object.keys(platformDeps).join(' ') + '\n'); + } " - name: Publish @aictrl/cli @@ -179,6 +189,43 @@ jobs: with: node-version: 22 + - name: Wait for every platform package to be visible on npm + # npm can serve a new version of one package before another. When + # @aictrl/cli resolves before its platform package, npm skips the + # optional binary and the install below has no binary to run. The + # 0.5.1 install check failed on that lag. Ten attempts allow 8.5 + # minutes of backoff, bounded by the step timeout. + timeout-minutes: 15 + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + PLATFORM_PACKAGES: ${{ needs.publish.outputs.platform_packages }} + run: | + set -euo pipefail + AICTRL_VERSION="${RELEASE_TAG#v}" + read -r -a packages <<< "${PLATFORM_PACKAGES:-}" + if [ ${#packages[@]} -eq 0 ]; then + echo "::error::The publish job reported no platform packages to check" + exit 1 + fi + for attempt in {1..10}; do + missing=() + for name in "${packages[@]}"; do + visible=$(npm view "${name}@${AICTRL_VERSION}" version --prefer-online 2>/dev/null || true) + if [ "$visible" != "$AICTRL_VERSION" ]; then missing+=("$name"); fi + done + if [ ${#missing[@]} -eq 0 ]; then + echo "::notice::All ${#packages[@]} platform packages are visible at ${AICTRL_VERSION} on attempt ${attempt}" + exit 0 + fi + if [ "$attempt" -eq 10 ]; then break; fi + delay=60 + if [ "$attempt" -eq 1 ]; then delay=30; fi + echo "::warning::Not yet visible at ${AICTRL_VERSION}: ${missing[*]}. Retrying in ${delay}s..." + sleep "$delay" + done + echo "::error::Platform packages not visible at ${AICTRL_VERSION} after 10 attempts: ${missing[*]}" + exit 1 + - name: Smoke test - verify @aictrl/cli installs cleanly via npm # Catches the v0.3.3-class bug where the published manifest carries # Bun-only protocols (workspace:*, catalog:) that plain `npm install` diff --git a/CHANGELOG.md b/CHANGELOG.md index 9206753c..33ac96c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ - **A session runs one prompt at a time** — The prompt loop no longer queues a second prompt for a session that is already running one. The second prompt now fails with `Session is busy: it already has a running prompt` before it writes a user message. A headless run has one user turn, so the only reachable case is two parallel `task` calls that resume the same `task_id`: the second call now returns that error to the model instead of waiting. The unused `!` shell command path (`SessionPrompt.shell`) and `Session.initialize` are removed. (Part of #223) - **One Bun version for CI, releases and local builds** — The root `package.json` pins `packageManager` to `bun@1.4.3`, and the CI and publish workflows read it with `bun-version-file: package.json` instead of installing `latest`. 0.5.1 shipped Bun 1.4.3 while CI and local builds could use other versions; a local 1.3.10 build of the same commit was about 85 ms slower to first response and used about 85 MB more RSS. The pre-push hook and `@aictrl/script` (used by the build) check the local Bun against `packageManager`, so they now require Bun 1.4.3 or a later 1.x. The `bun-node` container image no longer has its own default `BUN_VERSION` and takes the `packageManager` version from its build script. +### Packaging + +- **One platform package per OS, CPU and libc; the AVX2 x64 packages are no longer published** — This changes what is published. `@aictrl/cli-linux-x64`, `@aictrl/cli-linux-x64-musl`, `@aictrl/cli-darwin-x64` and `@aictrl/cli-windows-x64` get no new versions; x64 hosts use `@aictrl/cli-linux-x64-baseline`, `@aictrl/cli-linux-x64-baseline-musl`, `@aictrl/cli-darwin-x64-baseline` and `@aictrl/cli-windows-x64-baseline`. arm64 packages keep their names. Bun 1.4.3 ships the same binary for its baseline and AVX2 targets, and the 0.5.1 AVX2 and `-baseline` packages hold byte-identical binaries on all three x64 OSes, so no CPU loses a faster build. The linux packages now declare `libc` (`glibc` or `musl`), so npm installs one of them instead of every linux package for the CPU: `npm install -g @aictrl/cli` on linux-x64 drops from four platform packages and about 450 MB to one package of about 115 MB (glibc) or 110 MB (musl). A package manager that ignores `libc` still installs both libc packages, and the wrapper picks the host's. If you install a dropped package directly, switch to its `-baseline` name; its published versions up to 0.5.2 are unchanged. +- **`@aictrl/cli` publishes only the wrapper** — A `files` allowlist limits the package to `bin/aictrl`, `README.md`, `LICENSE` and `package.json`. `src/`, `test/`, migrations and build scripts are no longer published (369 files and 6.1 MB unpacked in 0.5.1, now 4 files and 18 kB). The platform packages ship `LICENSE` too. +- **The npm wrapper no longer probes the CPU or runs `ldd`** — `bin/aictrl` drops AVX2 detection. It reads the libc from `/usr/bin/ldd` and falls back to Node's diagnostic report (as npm does) instead of running `ldd --version`, which saves about 2.5 ms per start on glibc. When no binary of its version is installed, it no longer tries `bun run src/index.ts`, which failed with a misleading `Cannot find package` error; it exits 1 and names the platform package it expected. +- **The release waits for every platform package before the install check** — The publish workflow's smoke job checks that each platform package it published is visible on npm at the release version, retrying for up to 8.5 minutes, before it installs `@aictrl/cli`. 0.5.1's install check failed while a platform package was not yet visible. + ### Security - **drizzle-orm 1.0.0-beta.20** — `drizzle-orm` and `drizzle-kit` move from `1.0.0-beta.12-a5629fb` to `1.0.0-beta.20`, the first release patched for GHSA-gpj5-g38j-94v9 (SQL injection through improperly escaped SQL identifiers; the CLI never passed untrusted identifiers to drizzle). Existing databases open as before: on first start drizzle adds `name` and `applied_at` columns to `__drizzle_migrations` and fills in the names of the migrations already applied, without running them again. The upgraded database still opens with 0.5.1. (closes #61) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03a183e6..d5dff009 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,16 +69,16 @@ bun dev . To compile a standalone executable: ```bash -./packages/aictrl/script/build.ts --single +./packages/cli/script/build.ts --single ``` Then run it with: ```bash -./packages/aictrl/dist/aictrl-/bin/aictrl +./packages/cli/dist/@aictrl/cli-/bin/aictrl ``` -Replace `` with your platform (e.g., `darwin-arm64`, `linux-x64`). +Replace `` with your platform (e.g., `darwin-arm64`, `linux-x64-baseline`). - Core pieces: - `packages/aictrl`: Aictrl core business logic & server. diff --git a/RELEASING.md b/RELEASING.md index 5b63f23f..0d7cea94 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,6 +37,30 @@ `sandbox` before `main`; verify a sandbox review persists MCP findings and a stalled stream reports a timeout rather than waiting for the job limit. +## Platform packages + +`packages/cli/script/build.ts` builds one binary per OS, CPU and libc, and the +publish workflow publishes each as a platform package. It then lists them in +the wrapper's `optionalDependencies`: + +| Package | `os` | `cpu` | `libc` | +| --- | --- | --- | --- | +| `@aictrl/cli-linux-x64-baseline` | linux | x64 | glibc | +| `@aictrl/cli-linux-x64-baseline-musl` | linux | x64 | musl | +| `@aictrl/cli-linux-arm64` | linux | arm64 | glibc | +| `@aictrl/cli-linux-arm64-musl` | linux | arm64 | musl | +| `@aictrl/cli-darwin-x64-baseline` | darwin | x64 | | +| `@aictrl/cli-darwin-arm64` | darwin | arm64 | | +| `@aictrl/cli-windows-x64-baseline` | win32 | x64 | | + +npm installs only the package that matches the host. Every x64 package is the +baseline (no AVX2) build: Bun ships the same binary for its baseline and AVX2 +targets. The AVX2 names (`@aictrl/cli-linux-x64`, `-linux-x64-musl`, +`-darwin-x64`, `-windows-x64`) were last published with 0.5.2. Before adding +or renaming a platform package, update `bin/aictrl`'s `platformPackageNames` +and its tests. The smoke job waits until every package in this table is visible +on npm before it installs `@aictrl/cli`. + ## Bun version The compiled binary embeds the Bun runtime that built it, so the Bun version is diff --git a/packages/cli/bin/aictrl b/packages/cli/bin/aictrl index 8912fab1..18c25ccf 100755 --- a/packages/cli/bin/aictrl +++ b/packages/cli/bin/aictrl @@ -17,138 +17,63 @@ function run(target) { process.exit(code) } -// An explicit binary path is the caller's choice, so it skips the platform -// package search and its version check below. -const envPath = process.env.AICTRL_BIN_PATH -if (envPath) { - run(envPath) -} - -const scriptPath = fs.realpathSync(__filename) -const scriptDir = path.dirname(scriptPath) - -// -const cached = path.join(scriptDir, ".aictrl") -if (fs.existsSync(cached)) { - run(cached) -} - -const platformMap = { +const platformNames = { darwin: "darwin", linux: "linux", win32: "windows", } -const archMap = { - x64: "x64", - arm64: "arm64", - arm: "arm", -} - -let platform = platformMap[os.platform()] -if (!platform) { - platform = os.platform() -} -let arch = archMap[os.arch()] -if (!arch) { - arch = os.arch() -} -const base = "@aictrl/cli-" + platform + "-" + arch -const binary = platform === "windows" ? "aictrl.exe" : "aictrl" - -function supportsAvx2() { - if (arch !== "x64") return false - - if (platform === "linux") { - try { - return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) - } catch { - return false - } - } - - if (platform === "darwin") { - try { - const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { - encoding: "utf8", - timeout: 1500, - }) - if (result.status !== 0) return false - return (result.stdout || "").trim() === "1" - } catch { - return false - } - } - - if (platform === "windows") { - const cmd = - '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' - - for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { - try { - const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], { - encoding: "utf8", - timeout: 3000, - windowsHide: true, - }) - if (result.status !== 0) continue - const out = (result.stdout || "").trim().toLowerCase() - if (out === "true" || out === "1") return true - if (out === "false" || out === "0") return false - } catch { - continue - } - } - return false - } +// The platform packages that can hold this host's binary, preferred first. +// Each x64 OS has one package, the -baseline build: Bun ships the same binary +// for its baseline and AVX2 targets, so it runs on every x64 CPU. Linux has a +// glibc and a musl package; npm installs only the one whose `libc` matches the +// host, and the other stays a candidate for package managers that ignore +// `libc`. +function platformPackageNames(platform, arch, libc) { + const variant = arch === "x64" ? "-baseline" : "" + const glibcPackage = `@aictrl/cli-${platformNames[platform] || platform}-${arch}${variant}` + if (platform !== "linux") return [glibcPackage] - return false + const muslPackage = `${glibcPackage}-musl` + if (libc === "musl") return [muslPackage, glibcPackage] + return [glibcPackage, muslPackage] } -const names = (() => { - const avx2 = supportsAvx2() - const baseline = arch === "x64" && !avx2 - - if (platform === "linux") { - const musl = (() => { - try { - if (fs.existsSync("/etc/alpine-release")) return true - } catch { - // ignore - } - - try { - const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) - const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() - if (text.includes("musl")) return true - } catch { - // ignore - } - - return false - })() +function isMuslLibrary(file) { + return file.includes("libc.musl-") || file.includes("ld-musl-") +} - if (musl) { - if (arch === "x64") { - if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] - return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] - } - return [`${base}-musl`, base] - } +// The libc that Node's diagnostic report names for this process. npm reads the +// same report to decide which `libc` package to install. +function libcFromReport(report) { + if (report && report.header && report.header.glibcVersionRuntime) return "glibc" + if (report && Array.isArray(report.sharedObjects) && report.sharedObjects.some(isMuslLibrary)) return "musl" + return undefined +} - if (arch === "x64") { - if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] - return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] - } - return [base, `${base}-musl`] +function processReport() { + try { + process.report.excludeNetwork = true + return process.report.getReport() + } catch { + return undefined } +} - if (arch === "x64") { - if (baseline) return [`${base}-baseline`, base] - return [base, `${base}-baseline`] +// Reading the ldd script is one file read; building a diagnostic report takes +// a few milliseconds, so it is only the fallback for hosts without ldd, such +// as distroless images. +function linuxLibc(lddPath = "/usr/bin/ldd") { + let ldd = "" + try { + ldd = fs.readFileSync(lddPath, "utf8") + } catch { + // no ldd on this host } - return [base] -})() + if (ldd.includes("musl")) return "musl" + if (ldd.includes("GNU C Library")) return "glibc" + return libcFromReport(processReport()) +} function readPackageVersion(packageDir) { try { @@ -158,16 +83,10 @@ function readPackageVersion(packageDir) { } } -// Only a platform package of this wrapper's own release may run. An older one -// left in an ancestor node_modules (CI images, npx caches) would otherwise run -// silently whenever npm skipped the optional platform package. -const wrapperPackageDir = path.resolve(scriptDir, "..") -const wrapperVersion = readPackageVersion(wrapperPackageDir) - // Every installed platform binary, nearest first. The walk starts in the // wrapper's own package, so its own install tree is searched before any -// ancestor. Within one node_modules, variants keep the order of `names`. -function findPlatformBinaries(startDir) { +// ancestor. Within one node_modules, packages keep the order of `names`. +function findPlatformBinaries(startDir, names, binary) { const found = [] let current = startDir for (;;) { @@ -187,17 +106,6 @@ function findPlatformBinaries(startDir) { } } -function matchesWrapperVersion(platformBinary) { - return wrapperVersion !== undefined && platformBinary.version === wrapperVersion -} - -// A package spec pinned to this wrapper's release, so following the advice -// installs a binary the version check accepts. Only called once -// wrapperVersion is known. -function pinnedSpec(name) { - return `${name}@${wrapperVersion}` -} - // Versions and paths come from arbitrary ancestor node_modules, so strip // control, bidi-override and zero-width characters before echoing them to // the terminal. @@ -205,7 +113,7 @@ function printable(text) { return String(text).replace(/[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g, "") } -function unknownWrapperVersionMessage() { +function unknownWrapperVersionMessage(wrapperPackageDir) { const manifest = printable(path.join(wrapperPackageDir, "package.json")) return [ `Could not read the @aictrl/cli version from ${manifest}, so no platform binary can be verified.`, @@ -213,26 +121,19 @@ function unknownWrapperVersionMessage() { ].join("\n") } -function bunMissingMessage() { - if (!wrapperVersion) return "Bun is not installed.\n" + unknownWrapperVersionMessage() - - return [ - `No platform binary matching @aictrl/cli ${wrapperVersion} was found and Bun is not installed.`, - "Either install Bun (https://bun.sh) or install the platform package: " + - names.map((n) => `"${pinnedSpec(n)}"`).join(" or "), - ].join("\n") -} - -function missingBinaryMessage(candidates) { - if (!wrapperVersion) return unknownWrapperVersionMessage() +// `pinnedSpec` pins a package to the wrapper's release, so following the +// advice installs a binary the version check accepts. +function missingBinaryMessage({ names, skipped, wrapperVersion, wrapperPackageDir }) { + if (!wrapperVersion) return unknownWrapperVersionMessage(wrapperPackageDir) + const pinnedSpec = (name) => `${name}@${wrapperVersion}` const lines = [ - "It seems that your package manager failed to install the right version of the aictrl CLI for your platform.", - `@aictrl/cli ${wrapperVersion} needs a platform package of the same version.`, + `@aictrl/cli ${wrapperVersion} found no aictrl binary of the same version for this platform.`, + `It runs the binary from the platform package ${pinnedSpec(names[0])}, an optional dependency that your package manager did not install (for example because optional dependencies were omitted).`, ] - if (candidates.length > 0) { + if (skipped.length > 0) { lines.push("Skipped platform packages with a different version:") - for (const entry of candidates) { + for (const entry of skipped) { lines.push(` ${entry.name}@${printable(entry.version || "unknown")} at ${printable(entry.packageDir)}`) } } @@ -247,30 +148,43 @@ function missingBinaryMessage(candidates) { return lines.join("\n") } -const installed = findPlatformBinaries(scriptDir) -const matching = installed.find(matchesWrapperVersion) -if (matching) { - run(matching.target) -} +function main() { + // An explicit binary path is the caller's choice, so it skips the platform + // package search and its version check below. + const envPath = process.env.AICTRL_BIN_PATH + if (envPath) { + run(envPath) + } -// No compiled binary of this version found — fall back to running from source with Bun -const bunEntry = path.join(scriptDir, "..", "src", "index.ts") -if (fs.existsSync(bunEntry)) { - const result = childProcess.spawnSync( - "bun", - ["run", "--conditions=browser", bunEntry, ...process.argv.slice(2)], - { stdio: "inherit" }, - ) - if (result.error) { - if (result.error.code === "ENOENT") { - console.error(bunMissingMessage()) - } else { - console.error(result.error.message) - } - process.exit(1) + const scriptDir = path.dirname(fs.realpathSync(__filename)) + + const cached = path.join(scriptDir, ".aictrl") + if (fs.existsSync(cached)) { + run(cached) } - process.exit(typeof result.status === "number" ? result.status : 0) + + const platform = os.platform() + const libc = platform === "linux" ? linuxLibc() : undefined + const names = platformPackageNames(platform, os.arch(), libc) + const binary = platform === "win32" ? "aictrl.exe" : "aictrl" + + // Only a platform package of this wrapper's own release may run. An older + // one left in an ancestor node_modules (CI images, npx caches) would + // otherwise run silently whenever npm skipped the optional platform package. + const wrapperPackageDir = path.resolve(scriptDir, "..") + const wrapperVersion = readPackageVersion(wrapperPackageDir) + const installed = findPlatformBinaries(scriptDir, names, binary) + const matching = wrapperVersion && installed.find((entry) => entry.version === wrapperVersion) + if (matching) { + run(matching.target) + } + + console.error(missingBinaryMessage({ names, skipped: installed, wrapperVersion, wrapperPackageDir })) + process.exit(1) } -console.error(missingBinaryMessage(installed)) -process.exit(1) +if (require.main === module) { + main() +} else { + module.exports = { platformPackageNames, libcFromReport, linuxLibc } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index def87b39..b97fdb26 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,6 +30,11 @@ "bin": { "aictrl": "./bin/aictrl" }, + "files": [ + "bin/aictrl", + "README.md", + "LICENSE" + ], "exports": { "./*": "./src/*.ts" }, diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index bdaaea3c..9bf55cda 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -15,90 +15,34 @@ import pkg from "../package.json" import { readMigrationJournal } from "../src/storage/migration-journal" const singleFlag = process.argv.includes("--single") || process.env.AICTRL_BUILD_SINGLE === "true" -const baselineFlag = process.argv.includes("--baseline") const liteFlag = process.argv.includes("--lite") const migrations = readMigrationJournal(path.join(dir, "migration")) console.log(`Loaded ${migrations.length} migrations`) +// One package per OS, CPU and libc. Every x64 target is the baseline (no AVX2) +// build: Bun ships the same binary for its baseline and AVX2 targets, so a +// second x64 package per OS only doubled the download. Linux targets declare +// their libc, so npm installs only the glibc or the musl package. const allTargets: { os: string arch: "arm64" | "x64" - abi?: "musl" - avx2?: false + libc?: "glibc" | "musl" }[] = [ - { - os: "linux", - arch: "arm64", - }, - { - os: "linux", - arch: "x64", - }, - { - os: "linux", - arch: "x64", - avx2: false, - }, - { - os: "linux", - arch: "arm64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - avx2: false, - }, - { - os: "darwin", - arch: "arm64", - }, - { - os: "darwin", - arch: "x64", - }, - { - os: "darwin", - arch: "x64", - avx2: false, - }, - { - os: "win32", - arch: "x64", - }, - { - os: "win32", - arch: "x64", - avx2: false, - }, + { os: "linux", arch: "arm64", libc: "glibc" }, + { os: "linux", arch: "x64", libc: "glibc" }, + { os: "linux", arch: "arm64", libc: "musl" }, + { os: "linux", arch: "x64", libc: "musl" }, + { os: "darwin", arch: "arm64" }, + { os: "darwin", arch: "x64" }, + { os: "win32", arch: "x64" }, ] +// A single build (CI, local) is the host's glibc package. const targets = singleFlag - ? allTargets.filter((item) => { - if (item.os !== process.platform || item.arch !== process.arch) { - return false - } - - // When building for the current platform, prefer a single native binary by default. - // Baseline binaries require additional Bun artifacts and can be flaky to download. - if (item.avx2 === false) { - return baselineFlag - } - - // also skip abi-specific builds for the same reason - if (item.abi !== undefined) { - return false - } - - return true - }) + ? allTargets.filter( + (item) => item.os === process.platform && item.arch === process.arch && item.libc !== "musl", + ) : allTargets await $`rm -rf dist` @@ -110,8 +54,8 @@ for (const item of targets) { // changing to win32 flags npm for some reason item.os === "win32" ? "windows" : item.os, item.arch, - item.avx2 === false ? "baseline" : undefined, - item.abi === undefined ? undefined : item.abi, + item.arch === "x64" ? "baseline" : undefined, + item.libc === "musl" ? "musl" : undefined, liteFlag ? "lite" : undefined, ] .filter(Boolean) @@ -148,6 +92,8 @@ for (const item of targets) { version: Script.version, os: [item.os], cpu: [item.arch], + ...(item.libc ? { libc: [item.libc] } : {}), + license: pkg.license, repository: { type: "git", url: "https://github.com/aictrl-dev/cli", @@ -157,6 +103,7 @@ for (const item of targets) { 2, ), ) + await $`cp ../../LICENSE dist/${name}/LICENSE` binaries[name] = Script.version } diff --git a/packages/cli/test/cli/publish-workflow.test.ts b/packages/cli/test/cli/publish-workflow.test.ts index a9d4b831..40ff156d 100644 --- a/packages/cli/test/cli/publish-workflow.test.ts +++ b/packages/cli/test/cli/publish-workflow.test.ts @@ -8,7 +8,12 @@ const workflow = Bun.YAML.parse( ) as { jobs: Record< string, - { steps: { name?: string; run?: string }[]; needs?: string; permissions?: Record } + { + steps: { name?: string; id?: string; run?: string; env?: Record }[] + needs?: string + permissions?: Record + outputs?: Record + } > } const steps = workflow.jobs.publish.steps @@ -16,6 +21,10 @@ const guard = steps.find((step) => step.name === "Verify release version before const smoke = workflow.jobs.smoke.steps.find( (step) => step.name === "Smoke test - verify @aictrl/cli installs cleanly via npm", )! +const manifest = steps.find((step) => step.id === "manifest")! +const visibility = workflow.jobs.smoke.steps.find( + (step) => step.name === "Wait for every platform package to be visible on npm", +)! describe("release version gate", () => { test.each([ @@ -118,3 +127,118 @@ echo "$1" >> "$FIXTURE_ROOT/sleeps" } }) }) + +describe("platform package manifest", () => { + test("lists the built platform packages in optionalDependencies and the job output", async () => { + await using tmp = await tmpdir() + const cli = path.join(tmp.path, "packages/cli") + await Bun.write(path.join(tmp.path, "LICENSE"), "MIT License\n") + await Bun.write( + path.join(cli, "package.json"), + JSON.stringify({ name: "@aictrl/cli", version: "0.5.3", dependencies: { zod: "catalog:" } }), + ) + const built = ["@aictrl/cli-linux-x64-baseline", "@aictrl/cli-linux-x64-baseline-musl", "@aictrl/cli-darwin-arm64"] + for (const name of built) { + await Bun.write(path.join(cli, "dist", name, "package.json"), JSON.stringify({ name, version: "0.5.3" })) + } + const output = path.join(tmp.path, "github-output") + const proc = Bun.spawn(["bash", "-c", manifest.run!], { + cwd: cli, + env: { PATH: process.env.PATH, GITHUB_OUTPUT: output }, + stdout: "pipe", + stderr: "pipe", + }) + const [stderr, exit] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + expect(exit, stderr).toBe(0) + + const pkg = await Bun.file(path.join(cli, "package.json")).json() + expect(Object.keys(pkg.optionalDependencies).sort()).toEqual([...built].sort()) + expect(pkg.dependencies).toBeUndefined() + expect(await Bun.file(path.join(cli, "LICENSE")).text()).toBe("MIT License\n") + const line = (await Bun.file(output).text()).trim() + expect(line).toStartWith("platform_packages=") + expect(line.slice("platform_packages=".length).split(" ").sort()).toEqual([...built].sort()) + expect(workflow.jobs.publish.outputs?.platform_packages).toBe("${{ steps.manifest.outputs.platform_packages }}") + }) +}) + +describe("platform package visibility gate", () => { + const packages = ["@aictrl/cli-linux-x64-baseline", "@aictrl/cli-linux-x64-baseline-musl", "@aictrl/cli-darwin-arm64"] + + test("runs before the install smoke and reads the publish job's package list", () => { + const smokeSteps = workflow.jobs.smoke.steps + expect(smokeSteps.indexOf(visibility)).toBeGreaterThanOrEqual(0) + expect(smokeSteps.indexOf(visibility)).toBeLessThan(smokeSteps.indexOf(smoke)) + expect(visibility.env?.PLATFORM_PACKAGES).toBe("${{ needs.publish.outputs.platform_packages }}") + }) + + test.each([ + { name: "every package visible at once", lag: {}, list: packages, code: 0, attempts: 1 }, + { name: "one package lags two attempts", lag: { "@aictrl/cli-darwin-arm64": 2 }, list: packages, code: 0, attempts: 3 }, + { name: "a package never appears", lag: { "@aictrl/cli-linux-x64-baseline-musl": 99 }, list: packages, code: 1, attempts: 10 }, + { name: "an older version is the only one visible", lag: {}, stale: true, list: packages, code: 1, attempts: 10 }, + { name: "no package list from the publish job", lag: {}, list: [], code: 1, attempts: 0 }, + ])("$name", async ({ lag, stale, list, code, attempts }) => { + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "bin") + await fs.mkdir(bin) + // `npm view @ version` prints the version once the + // package has been asked for more times than its lag. + await Bun.write( + path.join(bin, "npm"), + `#!/bin/bash +set -euo pipefail +spec="$2" +name="\${spec%@*}" +file="$FIXTURE_ROOT/calls-$(echo "$name" | tr '/@' '__')" +count=0 +if [ -f "$file" ]; then count=$(cat "$file"); fi +count=$((count + 1)) +echo "$count" > "$file" +echo "$spec" >> "$FIXTURE_ROOT/views" +lag=$(node -p "JSON.parse(process.env.FIXTURE_LAG)[process.argv[1]] ?? 0" "$name") +if [ "$count" -le "$lag" ]; then echo "npm error code E404" >&2; exit 1; fi +if [ -n "\${FIXTURE_STALE:-}" ]; then echo "0.5.2"; else echo "\${spec##*@}"; fi +`, + ) + await Bun.write(path.join(bin, "sleep"), `#!/bin/sh\necho "$1" >> "$FIXTURE_ROOT/sleeps"\n`) + await Promise.all([fs.chmod(path.join(bin, "npm"), 0o755), fs.chmod(path.join(bin, "sleep"), 0o755)]) + + const proc = Bun.spawn(["bash", "-c", visibility.run!], { + cwd: tmp.path, + env: { + PATH: `${bin}:${process.env.PATH}`, + RELEASE_TAG: "v0.5.3", + PLATFORM_PACKAGES: list.join(" "), + FIXTURE_ROOT: tmp.path, + FIXTURE_LAG: JSON.stringify(lag), + FIXTURE_STALE: stale ? "1" : "", + }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) + expect(exit, stdout).toBe(code) + + const views = Bun.file(path.join(tmp.path, "views")) + if (attempts === 0) { + expect(await views.exists()).toBe(false) + expect(stdout).toContain("::error::The publish job reported no platform packages") + return + } + const viewed = (await views.text()).trim().split("\n") + expect(viewed).toHaveLength(attempts * list.length) + expect(new Set(viewed)).toEqual(new Set(list.map((name) => `${name}@0.5.3`))) + const sleeps = Bun.file(path.join(tmp.path, "sleeps")) + if (attempts === 1) expect(await sleeps.exists()).toBe(false) + else { + const delays = (await sleeps.text()).trim().split("\n").map(Number) + expect(delays).toEqual([30, ...Array(attempts - 2).fill(60)]) + } + if (code === 0) expect(stdout).toContain(`All ${list.length} platform packages are visible at 0.5.3 on attempt ${attempts}`) + else { + const missing = stale ? list : Object.keys(lag) + expect(stdout).toContain(`::error::Platform packages not visible at 0.5.3 after 10 attempts: ${missing.join(" ")}`) + } + }) +}) diff --git a/packages/cli/test/cli/wrapper-binary-resolution.test.ts b/packages/cli/test/cli/wrapper-binary-resolution.test.ts index 5eaa5562..5f0b8cbc 100644 --- a/packages/cli/test/cli/wrapper-binary-resolution.test.ts +++ b/packages/cli/test/cli/wrapper-binary-resolution.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" -import os from "os" import path from "path" import { tmpdir } from "../fixture/fixture" @@ -12,13 +11,36 @@ const wrapperSource = path.resolve(import.meta.dir, "../../bin/aictrl") const wrapperVersion = "9.9.9-wrapper-test" const staleVersion = "0.0.1-stale-test" -// Mirrors the wrapper's platform/arch naming. The plain - -// package is a candidate on every host, whatever the musl/baseline variant. -const platformName = ({ darwin: "darwin", linux: "linux", win32: "windows" } as Record)[os.platform()] -const platformPackage = `@aictrl/cli-${platformName ?? os.platform()}-${os.arch()}` +// Calls one of the helpers the wrapper exports when it is required instead of +// run, in the same Node that runs it for users. +async function callWrapper(expression: string) { + await using tmp = await tmpdir() + const wrapper = path.join(tmp.path, "aictrl") + await fs.copyFile(wrapperSource, wrapper) + const script = `const wrapper = require(process.env.WRAPPER); console.log(JSON.stringify(${expression}) ?? "undefined")` + const proc = Bun.spawn([Bun.which("node")!, "-e", script], { + env: { PATH: process.env.PATH!, WRAPPER: wrapper }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + expect(exitCode, stderr).toBe(0) + return stdout.trim() === "undefined" ? undefined : JSON.parse(stdout) +} + +// The host's platform packages, preferred first, as the wrapper picks them. +// The table tests below pin the mapping itself. +const hostPackages: string[] = await callWrapper( + `wrapper.platformPackageNames(process.platform, process.arch, process.platform === "linux" ? wrapper.linuxLibc() : undefined)`, +) +const platformPackage = hostPackages[0] -// The package spec the error advises installing. It names the host's -// preferred variant (plain, -baseline or -musl). +// The package spec the error advises installing: the host's preferred +// package. function adviceSpec(stderr: string) { return stderr.match(/npm install "([^"]+)"/)?.[1] } @@ -33,32 +55,24 @@ async function installWrapper(projectDir: string, options: { versioned?: boolean JSON.stringify({ name: "@aictrl/cli", version: wrapperVersion }), ) } - // A source checkout, which makes the wrapper fall back to running with Bun. + // A source checkout. The wrapper never runs it: it needs a platform binary. if (options.source) await Bun.write(path.join(packageDir, "src", "index.ts"), "") return path.join(packageDir, "bin", "aictrl") } // A stub binary that prints which install it came from, so a test can tell // which candidate the wrapper executed. -async function installPlatformBinary(modulesDir: string, version: unknown, label: string) { - const packageDir = path.join(modulesDir, platformPackage) +async function installPlatformBinary(modulesDir: string, version: unknown, label: string, name = platformPackage) { + const packageDir = path.join(modulesDir, name) const binary = path.join(packageDir, "bin", "aictrl") await fs.mkdir(path.dirname(binary), { recursive: true }) await Bun.write(binary, `#!/bin/sh\necho "ran ${label} $@"\n`) await fs.chmod(binary, 0o755) if (version) - await Bun.write(path.join(packageDir, "package.json"), JSON.stringify({ name: platformPackage, version })) + await Bun.write(path.join(packageDir, "package.json"), JSON.stringify({ name, version })) return packageDir } -// A PATH holding only node, so the wrapper's Bun fallback fails with ENOENT. -async function nodeOnlyPath(dir: string) { - const bin = path.join(dir, "node-only-bin") - await fs.mkdir(bin) - await fs.symlink(Bun.which("node")!, path.join(bin, "node")) - return bin -} - async function runWrapper(wrapper: string, env: Record = {}) { const proc = Bun.spawn([Bun.which("node")!, wrapper, "--version"], { env: { PATH: process.env.PATH!, ...env }, @@ -117,29 +131,27 @@ describe.skipIf(process.platform === "win32")("npm wrapper platform binary resol expect(result.stderr).not.toContain("npm install") }) - test("the Bun-missing hint of a source checkout pins the platform packages", async () => { + test("names the expected platform package when no binary is installed", async () => { await using tmp = await tmpdir() - const wrapper = await installWrapper(path.join(tmp.path, "project"), { source: true }) + const wrapper = await installWrapper(path.join(tmp.path, "project")) - const result = await runWrapper(wrapper, { PATH: await nodeOnlyPath(tmp.path) }) + const result = await runWrapper(wrapper) expect(result.exitCode).toBe(1) - expect(result.stderr).toContain(`No platform binary matching @aictrl/cli ${wrapperVersion} was found`) - expect(result.stderr).toContain("Bun is not installed") - expect(result.stderr).toContain(`"${platformPackage}@${wrapperVersion}"`) - expect(result.stderr).not.toMatch(/"@aictrl\/cli-[^"@]+"/) + expect(result.stderr).toContain(`@aictrl/cli ${wrapperVersion} found no aictrl binary of the same version`) + expect(result.stderr).toContain(`platform package ${platformPackage}@${wrapperVersion}`) + expect(adviceSpec(result.stderr)).toBe(`${platformPackage}@${wrapperVersion}`) }) - test("the Bun-missing hint gives no install advice when the wrapper's own version is unreadable", async () => { + test("does not run a source checkout with Bun when no binary is installed", async () => { await using tmp = await tmpdir() - const wrapper = await installWrapper(path.join(tmp.path, "project"), { versioned: false, source: true }) + const wrapper = await installWrapper(path.join(tmp.path, "project"), { source: true }) - const result = await runWrapper(wrapper, { PATH: await nodeOnlyPath(tmp.path) }) + const result = await runWrapper(wrapper) expect(result.exitCode).toBe(1) - expect(result.stderr).toContain("Bun is not installed") - expect(result.stderr).toContain("Could not read the @aictrl/cli version from") - expect(result.stderr).not.toContain("@aictrl/cli-") + expect(result.stderr).toContain(`platform package ${platformPackage}@${wrapperVersion}`) + expect(result.stderr).not.toContain("Cannot find package") }) test("refuses a platform package without a readable version", async () => { @@ -189,6 +201,30 @@ describe.skipIf(process.platform === "win32")("npm wrapper platform binary resol expect(result.exitCode).toBe(0) }) + test.skipIf(process.platform !== "linux")("prefers the package for the host's libc when both are installed", async () => { + await using tmp = await tmpdir() + const wrapper = await installWrapper(path.join(tmp.path, "project")) + const modules = path.join(tmp.path, "project", "node_modules") + await installPlatformBinary(modules, wrapperVersion, "other-libc", hostPackages[1]) + await installPlatformBinary(modules, wrapperVersion, "host-libc", hostPackages[0]) + + const result = await runWrapper(wrapper) + + expect(result.stdout).toBe("ran host-libc --version\n") + expect(result.exitCode).toBe(0) + }) + + test.skipIf(process.platform !== "linux")("runs the other libc package when it is the only one installed", async () => { + await using tmp = await tmpdir() + const wrapper = await installWrapper(path.join(tmp.path, "project")) + await installPlatformBinary(path.join(tmp.path, "project", "node_modules"), wrapperVersion, "other-libc", hostPackages[1]) + + const result = await runWrapper(wrapper) + + expect(result.stdout).toBe("ran other-libc --version\n") + expect(result.exitCode).toBe(0) + }) + test("AICTRL_BIN_PATH runs the chosen binary without a version check", async () => { await using tmp = await tmpdir() const wrapper = await installWrapper(path.join(tmp.path, "project")) @@ -200,3 +236,58 @@ describe.skipIf(process.platform === "win32")("npm wrapper platform binary resol expect(result.exitCode).toBe(0) }) }) + +describe("npm wrapper platform package selection", () => { + test.each([ + { platform: "linux", arch: "x64", libc: "glibc", names: ["linux-x64-baseline", "linux-x64-baseline-musl"] }, + { platform: "linux", arch: "x64", libc: "musl", names: ["linux-x64-baseline-musl", "linux-x64-baseline"] }, + { platform: "linux", arch: "x64", libc: undefined, names: ["linux-x64-baseline", "linux-x64-baseline-musl"] }, + { platform: "linux", arch: "arm64", libc: "glibc", names: ["linux-arm64", "linux-arm64-musl"] }, + { platform: "linux", arch: "arm64", libc: "musl", names: ["linux-arm64-musl", "linux-arm64"] }, + { platform: "darwin", arch: "x64", libc: undefined, names: ["darwin-x64-baseline"] }, + { platform: "darwin", arch: "arm64", libc: undefined, names: ["darwin-arm64"] }, + { platform: "win32", arch: "x64", libc: undefined, names: ["windows-x64-baseline"] }, + ])("$platform $arch $libc", async ({ platform, arch, libc, names }) => { + const args = [platform, arch, libc].map((value) => JSON.stringify(value) ?? "undefined").join(", ") + + expect(await callWrapper(`wrapper.platformPackageNames(${args})`)).toEqual( + names.map((name) => `@aictrl/cli-${name}`), + ) + }) +}) + +describe("npm wrapper libc detection", () => { + test.each([ + { name: "Alpine ldd", ldd: "#!/bin/sh\nexec /lib/ld-musl-x86_64.so.1 --list \"$@\"\n", libc: "musl" }, + { name: "glibc ldd", ldd: "#! /bin/bash\n# This file is part of the GNU C Library.\nTEXTDOMAIN=libc\n", libc: "glibc" }, + ])("reads the libc from $name", async ({ ldd, libc }) => { + await using tmp = await tmpdir() + const lddPath = path.join(tmp.path, "ldd") + await Bun.write(lddPath, ldd) + + expect(await callWrapper(`wrapper.linuxLibc(${JSON.stringify(lddPath)})`)).toBe(libc) + }) + + test.skipIf(process.platform !== "linux")("falls back to Node's diagnostic report without ldd", async () => { + await using tmp = await tmpdir() + const missing = JSON.stringify(path.join(tmp.path, "no-ldd")) + + const detected = await callWrapper(`wrapper.linuxLibc(${missing})`) + + expect(detected).toBe(await callWrapper("wrapper.libcFromReport(process.report.getReport())")) + expect(["glibc", "musl"]).toContain(detected) + }) + + test.each([ + { name: "glibc runtime", report: { header: { glibcVersionRuntime: "2.36" }, sharedObjects: [] }, libc: "glibc" }, + { + name: "musl loader", + report: { header: {}, sharedObjects: ["/usr/local/bin/node", "/lib/ld-musl-x86_64.so.1"] }, + libc: "musl", + }, + { name: "neither", report: { header: {}, sharedObjects: ["/usr/local/bin/node"] }, libc: undefined }, + { name: "no report", report: undefined, libc: undefined }, + ])("reads $name from a diagnostic report", async ({ report, libc }) => { + expect(await callWrapper(`wrapper.libcFromReport(${JSON.stringify(report) ?? "undefined"})`)).toBe(libc) + }) +})