From 8037ac876749c298a098f90b0ac583c6229f1ac5 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Wed, 6 May 2026 16:17:53 -0700 Subject: [PATCH] fix: bundle macos on npm publish --- .github/workflows/publish-node-sdk.yml | 76 ++++++++++++++++--- .github/workflows/publish-python-sdk.yml | 21 +++++- sdk/node/scripts/build-binaries.sh | 40 +++++----- sdk/node/src/ffi.ts | 14 +++- sdk/node/src/runtime.ts | 38 +++++++--- sdk/node/tests/runtime.test.ts | 93 +++++++++++++++++------- 6 files changed, 211 insertions(+), 71 deletions(-) diff --git a/.github/workflows/publish-node-sdk.yml b/.github/workflows/publish-node-sdk.yml index 559418ba..84090916 100644 --- a/.github/workflows/publish-node-sdk.yml +++ b/.github/workflows/publish-node-sdk.yml @@ -3,14 +3,28 @@ name: Publish Node SDK on: release: types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version to publish (without leading v, e.g. 1.9.1)' + required: true + type: string + prerelease: + description: 'Treat as prerelease (skip publish; build only)' + required: false + type: boolean + default: false permissions: contents: write id-token: write +env: + PILOT_VERSION: ${{ inputs.version || github.event.release.tag_name }} + PILOT_PRERELEASE: ${{ inputs.prerelease || github.event.release.prerelease }} + jobs: test-sdk: - if: "!github.event.release.prerelease" runs-on: ubuntu-latest defaults: run: @@ -38,10 +52,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set version from release tag + - name: Set version from release tag or workflow input shell: bash run: | - VERSION="${{ github.event.release.tag_name }}" + VERSION="${PILOT_VERSION}" VERSION="${VERSION#v}" echo "version=$VERSION" >> $GITHUB_ENV cd sdk/node @@ -82,25 +96,63 @@ jobs: echo "Package contents:" npm pack --dry-run echo "" - echo "Binary sizes:" + echo "Built binary subdirs:" ls -lh bin/ - - name: Upload package artifact + # Upload only the platform-specific binary subdir; the publish job + # downloads both and merges them into a single multi-platform npm package. + - name: Upload platform binaries + uses: actions/upload-artifact@v4 + with: + name: node-bin-${{ matrix.platform }} + path: sdk/node/bin/ + retention-days: 7 + + # Upload the full package layout (JS + bin/ + bin-stubs) once — the + # Linux runner's copy is the publish base; macOS just contributes its + # bin/ subdir to the merge. + - name: Upload full package (Linux base) + if: matrix.platform == 'linux' uses: actions/upload-artifact@v4 with: - name: npm-package-${{ matrix.platform }} + name: npm-package-base path: sdk/node/ retention-days: 7 publish: needs: build-packages + if: ${{ !(inputs.prerelease || github.event.release.prerelease) }} runs-on: ubuntu-latest steps: - - name: Download Linux package + - name: Download package base (Linux build) + uses: actions/download-artifact@v4 + with: + name: npm-package-base + path: sdk/node-pkg + + - name: Download macOS binaries uses: actions/download-artifact@v4 with: - name: npm-package-linux - path: sdk/node-linux + name: node-bin-macos + path: sdk/node-bin-macos + + - name: Merge macOS binaries into package + run: | + # Linux base already contains bin//. Layer in the + # macOS subdirs so the published package is multi-platform. + for sub in sdk/node-bin-macos/*/; do + name=$(basename "$sub") + # Skip the .pilot-version file which is at the root, not a subdir. + [ "$name" = ".pilot-version" ] && continue + echo " merging bin/$name" + cp -R "$sub" "sdk/node-pkg/bin/$name" + done + echo "" + echo "Final bin/ layout:" + find sdk/node-pkg/bin -maxdepth 2 -type d | sort + echo "" + echo "Files included by npm pack:" + cd sdk/node-pkg && npm pack --dry-run 2>&1 | grep -E "\.(js|so|dylib)" | head -30 - name: Set up Node.js uses: actions/setup-node@v4 @@ -111,7 +163,7 @@ jobs: - name: Extract version id: extract-version run: | - VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('sdk/node-linux/package.json', 'utf8')).version)") + VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('sdk/node-pkg/package.json', 'utf8')).version)") echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" @@ -129,7 +181,7 @@ jobs: - name: Publish to npm if: steps.check-npm.outputs.exists == 'false' - working-directory: sdk/node-linux + working-directory: sdk/node-pkg env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | @@ -147,9 +199,11 @@ jobs: echo "**Version:** ${{ steps.extract-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "**Install:** \`npm install pilotprotocol\`" >> $GITHUB_STEP_SUMMARY echo "**npm:** https://www.npmjs.com/package/pilotprotocol" >> $GITHUB_STEP_SUMMARY + echo "**Platforms bundled:** linux-amd64, darwin-arm64" >> $GITHUB_STEP_SUMMARY test-install: needs: publish + if: ${{ !(inputs.prerelease || github.event.release.prerelease) }} strategy: matrix: os: [ubuntu-latest, macos-latest] diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml index 06804d57..eaeebd04 100644 --- a/.github/workflows/publish-python-sdk.yml +++ b/.github/workflows/publish-python-sdk.yml @@ -3,14 +3,28 @@ name: Publish Python SDK on: release: types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version to publish (without leading v, e.g. 1.9.1)' + required: true + type: string + prerelease: + description: 'Treat as prerelease (skip publish; build only)' + required: false + type: boolean + default: false permissions: contents: write id-token: write +env: + PILOT_VERSION: ${{ inputs.version || github.event.release.tag_name }} + PILOT_PRERELEASE: ${{ inputs.prerelease || github.event.release.prerelease }} + jobs: test-sdk: - if: "!github.event.release.prerelease" runs-on: ubuntu-latest steps: - name: Checkout repository @@ -39,10 +53,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set version from release tag + - name: Set version from release tag or workflow input shell: bash run: | - VERSION="${{ github.event.release.tag_name }}" + VERSION="${PILOT_VERSION}" VERSION="${VERSION#v}" echo "version=$VERSION" >> $GITHUB_ENV cd sdk/python @@ -114,6 +128,7 @@ jobs: publish: needs: build-wheels + if: ${{ !(inputs.prerelease || github.event.release.prerelease) }} runs-on: ubuntu-latest steps: - name: Download all artifacts diff --git a/sdk/node/scripts/build-binaries.sh b/sdk/node/scripts/build-binaries.sh index 46eef5ea..08d8de2e 100755 --- a/sdk/node/scripts/build-binaries.sh +++ b/sdk/node/scripts/build-binaries.sh @@ -31,45 +31,47 @@ echo "Building Pilot Protocol Suite for ${OS}/${ARCH}" echo "================================================================" echo "" -OUTPUT_DIR="sdk/node/bin" -mkdir -p "$OUTPUT_DIR" +BIN_ROOT="sdk/node/bin" +PLATFORM_DIR="$BIN_ROOT/${OS}-${ARCH}" +mkdir -p "$PLATFORM_DIR" # 1. Build daemon echo "1. Building pilot-daemon..." -CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$OUTPUT_DIR/pilot-daemon" ./cmd/daemon -echo " ✓ Built: $OUTPUT_DIR/pilot-daemon" +CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$PLATFORM_DIR/pilot-daemon" ./cmd/daemon +echo " ✓ Built: $PLATFORM_DIR/pilot-daemon" echo "" # 2. Build pilotctl echo "2. Building pilotctl..." -CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$OUTPUT_DIR/pilotctl" ./cmd/pilotctl -echo " ✓ Built: $OUTPUT_DIR/pilotctl" +CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$PLATFORM_DIR/pilotctl" ./cmd/pilotctl +echo " ✓ Built: $PLATFORM_DIR/pilotctl" echo "" # 3. Build gateway echo "3. Building pilot-gateway..." -CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$OUTPUT_DIR/pilot-gateway" ./cmd/gateway -echo " ✓ Built: $OUTPUT_DIR/pilot-gateway" +CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$PLATFORM_DIR/pilot-gateway" ./cmd/gateway +echo " ✓ Built: $PLATFORM_DIR/pilot-gateway" echo "" # 4. Build updater echo "4. Building pilot-updater..." -CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$OUTPUT_DIR/pilot-updater" ./cmd/updater -echo " ✓ Built: $OUTPUT_DIR/pilot-updater" +CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" go build -ldflags="-s -w" -o "$PLATFORM_DIR/pilot-updater" ./cmd/updater +echo " ✓ Built: $PLATFORM_DIR/pilot-updater" echo "" # 5. Build CGO bindings echo "5. Building libpilot CGO bindings..." cd sdk/cgo -CGO_ENABLED=1 GOOS="$OS" GOARCH="$ARCH" go build -buildmode=c-shared -ldflags="-s -w" -o "../../$OUTPUT_DIR/libpilot.$EXT" . +CGO_ENABLED=1 GOOS="$OS" GOARCH="$ARCH" go build -buildmode=c-shared -ldflags="-s -w" -o "../../$PLATFORM_DIR/libpilot.$EXT" . cd ../.. -echo " ✓ Built: $OUTPUT_DIR/libpilot.$EXT" +echo " ✓ Built: $PLATFORM_DIR/libpilot.$EXT" echo "" -# 6. Write .pilot-version marker so the runtime seeder can compare against -# whatever's already installed at ~/.pilot/bin/. -echo "$SDK_VERSION" > "$OUTPUT_DIR/.pilot-version" -echo "6. Wrote $OUTPUT_DIR/.pilot-version → $SDK_VERSION" +# 6. Write .pilot-version marker at the bin/ root (shared across all platform +# subdirs). The runtime seeder reads this to compare against whatever's +# already installed at ~/.pilot/bin/. +echo "$SDK_VERSION" > "$BIN_ROOT/.pilot-version" +echo "6. Wrote $BIN_ROOT/.pilot-version → $SDK_VERSION" echo "" # 7. macOS ad-hoc codesign + strip quarantine. Mirrors the main release @@ -78,7 +80,7 @@ echo "" # software" when downloaded via npm. if [ "$OS" = "darwin" ]; then echo "7. macOS ad-hoc codesign + strip quarantine..." - for bin in "$OUTPUT_DIR/pilot-daemon" "$OUTPUT_DIR/pilotctl" "$OUTPUT_DIR/pilot-gateway" "$OUTPUT_DIR/pilot-updater" "$OUTPUT_DIR/libpilot.$EXT"; do + for bin in "$PLATFORM_DIR/pilot-daemon" "$PLATFORM_DIR/pilotctl" "$PLATFORM_DIR/pilot-gateway" "$PLATFORM_DIR/pilot-updater" "$PLATFORM_DIR/libpilot.$EXT"; do codesign --force --deep --sign - "$bin" xattr -cr "$bin" || true codesign -dv "$bin" 2>&1 | grep -E "Signature|Authority|TeamIdentifier" | head -1 || true @@ -91,10 +93,10 @@ fi echo "================================================================" echo "Build Summary:" echo "================================================================" -du -h "$OUTPUT_DIR"/* | awk '{printf " %-30s %s\n", $2, $1}' +du -h "$PLATFORM_DIR"/* | awk '{printf " %-30s %s\n", $2, $1}' echo "" echo "Total size:" -du -sh "$OUTPUT_DIR" | awk '{printf " %s\n", $1}' +du -sh "$PLATFORM_DIR" | awk '{printf " %s\n", $1}' echo "" echo "✓ All binaries built successfully for ${OS}/${ARCH}" echo "" diff --git a/sdk/node/src/ffi.ts b/sdk/node/src/ffi.ts index f0afccdc..4154c585 100644 --- a/sdk/node/src/ffi.ts +++ b/sdk/node/src/ffi.ts @@ -18,11 +18,16 @@ import koffi from 'koffi'; import { existsSync } from 'node:fs'; -import { homedir, platform } from 'node:os'; +import { homedir, arch, platform } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runtimeLibraryPath } from './runtime.js'; +function platformSubdir(): string { + const goArch = arch() === 'x64' ? 'amd64' : arch(); + return `${platform()}-${goArch}`; +} + // --------------------------------------------------------------------------- // Error class (defined here to avoid circular deps with client.ts) // --------------------------------------------------------------------------- @@ -69,9 +74,10 @@ export function findLibrary(): string { const pilotBin = join(homedir(), '.pilot', 'bin', libName); if (existsSync(pilotBin)) return pilotBin; - // 4. /bin/ (npm package layout: dist/ffi.js → ../bin/). + // 4. /bin/-/ (npm package layout: dist/ffi.js → ../bin/). const thisDir = resolve(fileURLToPath(import.meta.url), '..'); - const pkgBin = resolve(thisDir, '..', 'bin', libName); + const sub = platformSubdir(); + const pkgBin = resolve(thisDir, '..', 'bin', sub, libName); if (existsSync(pkgBin)) return pkgBin; // 5. Same directory as this file. @@ -87,7 +93,7 @@ export function findLibrary(): string { '\n' + 'Expected locations:\n' + ` - ~/.pilot/bin/${libName}\n` + - ` - ${pkgBin} (npm package)\n` + + ` - ${pkgBin} (npm package, ${sub})\n` + ` - ${colocated} (colocated)\n` + ` - ${repoBin} (development)\n` + '\n' + diff --git a/sdk/node/src/runtime.ts b/sdk/node/src/runtime.ts index aeacf67a..15c24c2f 100644 --- a/sdk/node/src/runtime.ts +++ b/sdk/node/src/runtime.ts @@ -30,7 +30,7 @@ import { accessSync, constants as fsConstants, } from 'node:fs'; -import { homedir, platform as osPlatform } from 'node:os'; +import { homedir, arch as osArch, platform as osPlatform } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -42,6 +42,13 @@ const LIB_NAMES: Record = { win32: 'libpilot.dll', }; +// node arch ("x64", "arm64") → go arch ("amd64", "arm64") used in bin/-/ +function platformDirName(): string { + const goOS = osPlatform(); + const goArch = osArch() === 'x64' ? 'amd64' : osArch(); + return `${goOS}-${goArch}`; +} + export const DEFAULT_REGISTRY = '34.71.57.205:9000'; export const DEFAULT_BEACON = '34.71.57.205:9001'; export const DEFAULT_SOCKET = '/tmp/pilot.sock'; @@ -51,28 +58,39 @@ export const DEFAULT_SOCKET = '/tmp/pilot.sock'; // --------------------------------------------------------------------------- /** - * Where the npm package ships its bundled binaries (the seed cache). + * Where the npm package keeps its top-level bin/ directory (containing + * platform subdirs and the shared `.pilot-version` marker). * * dist/runtime.js → ../bin/ (npm package layout) * src/runtime.ts → ../../bin/ (development layout, run via tsx) */ -function pkgBinDir(): string { - // Test override: a one-shot way to point at a fake bundled bin/ without - // resorting to vi.spyOn on a live binding. Honored only when set. - const override = process.env['PILOT_PKG_BIN_DIR']; +function pkgBinRoot(): string { + const override = process.env['PILOT_PKG_BIN_ROOT']; if (override) return override; const thisDir = resolve(fileURLToPath(import.meta.url), '..'); - // Compiled (dist/runtime.js) const compiledBin = resolve(thisDir, '..', 'bin'); if (existsSync(compiledBin)) return compiledBin; - // Source (src/runtime.ts) — sdk/node/bin const sourceBin = resolve(thisDir, '..', '..', 'bin'); return sourceBin; } +/** + * Where the npm package ships THIS host's bundled binaries (the seed cache): + * `bin/-/`. Each platform subdir holds the four binaries and + * `libpilot.{so|dylib}`. May not exist if the package was built for a + * different platform — callers must handle missing files. + */ +function pkgBinDir(): string { + // Test override: a one-shot way to point at a fake bundled bin/ without + // resorting to vi.spyOn on a live binding. Honored only when set. + const override = process.env['PILOT_PKG_BIN_DIR']; + if (override) return override; + return join(pkgBinRoot(), platformDirName()); +} + function runtimeRoot(): string { const override = process.env['PILOT_HOME']; if (override) return override; @@ -117,7 +135,7 @@ function compareSemver(a: number[] | null, b: number[] | null): number { } function bundledVersion(): string { - const f = join(pkgBinDir(), '.pilot-version'); + const f = join(pkgBinRoot(), '.pilot-version'); if (existsSync(f)) { try { return readFileSync(f, 'utf8').trim(); @@ -471,6 +489,8 @@ export async function isDaemonLive(): Promise { /** For tests: expose the raw paths. */ export const _internals = { pkgBinDir, + pkgBinRoot, + platformDirName, runtimeRoot, runtimeBin, platformLibName, diff --git a/sdk/node/tests/runtime.test.ts b/sdk/node/tests/runtime.test.ts index fe2a4459..23a27be9 100644 --- a/sdk/node/tests/runtime.test.ts +++ b/sdk/node/tests/runtime.test.ts @@ -19,7 +19,7 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { platform as osPlatform, tmpdir } from 'node:os'; import { join } from 'node:path'; // Import under test. @@ -31,38 +31,60 @@ function platformLib(): string { return runtime._internals.platformLibName(); } -function makeFakePkgBin(parentTmp: string, version: string): string { - const pkg = join(parentTmp, 'pkg-bin'); - mkdirSync(pkg, { recursive: true }); +/** + * Build a fake bundled bin/ that mirrors the real npm package layout: + * + * / + * .pilot-version + * / + * pilotctl, pilot-daemon, pilot-gateway, pilot-updater, libpilot.{so|dylib} + * + * Returns { root, platformDir }. Tests set PILOT_PKG_BIN_ROOT to root and + * PILOT_PKG_BIN_DIR to platformDir. + */ +function makeFakePkg(parentTmp: string, name: string, version: string): { + root: string; + platformDir: string; +} { + const root = join(parentTmp, name); + const platformDir = join(root, runtime._internals.platformDirName()); + mkdirSync(platformDir, { recursive: true }); for (const n of BIN_NAMES) { - const p = join(pkg, n); + const p = join(platformDir, n); writeFileSync(p, `#!/bin/sh\necho ${n} ${version}\n`); chmodSync(p, 0o755); } - const lib = join(pkg, platformLib()); + const lib = join(platformDir, platformLib()); writeFileSync(lib, `LIB ${version}\n`); chmodSync(lib, 0o755); - writeFileSync(join(pkg, '.pilot-version'), version + '\n'); - return pkg; + writeFileSync(join(root, '.pilot-version'), version + '\n'); + return { root, platformDir }; } let tmpRoot: string; let fakeHome: string; +let pkgRoot: string; let pkgBin: string; -let restoreEnv: { home: string | undefined; pkg: string | undefined }; +let restoreEnv: { + home: string | undefined; + pkgRoot: string | undefined; + pkgBin: string | undefined; +}; beforeEach(() => { // Use a *short* tmp root so AF_UNIX paths fit in 104 chars on macOS. tmpRoot = mkdtempSync(join('/tmp', 'pilot-rt-')); fakeHome = join(tmpRoot, 'home', '.pilot'); mkdirSync(fakeHome, { recursive: true }); - pkgBin = makeFakePkgBin(tmpRoot, '1.9.1'); + ({ root: pkgRoot, platformDir: pkgBin } = makeFakePkg(tmpRoot, 'pkg-bin', '1.9.1')); restoreEnv = { home: process.env['PILOT_HOME'], - pkg: process.env['PILOT_PKG_BIN_DIR'], + pkgRoot: process.env['PILOT_PKG_BIN_ROOT'], + pkgBin: process.env['PILOT_PKG_BIN_DIR'], }; process.env['PILOT_HOME'] = fakeHome; + process.env['PILOT_PKG_BIN_ROOT'] = pkgRoot; process.env['PILOT_PKG_BIN_DIR'] = pkgBin; runtime._resetSeededMarker(); @@ -72,14 +94,17 @@ afterEach(() => { vi.restoreAllMocks(); if (restoreEnv.home === undefined) delete process.env['PILOT_HOME']; else process.env['PILOT_HOME'] = restoreEnv.home; - if (restoreEnv.pkg === undefined) delete process.env['PILOT_PKG_BIN_DIR']; - else process.env['PILOT_PKG_BIN_DIR'] = restoreEnv.pkg; + if (restoreEnv.pkgRoot === undefined) delete process.env['PILOT_PKG_BIN_ROOT']; + else process.env['PILOT_PKG_BIN_ROOT'] = restoreEnv.pkgRoot; + if (restoreEnv.pkgBin === undefined) delete process.env['PILOT_PKG_BIN_DIR']; + else process.env['PILOT_PKG_BIN_DIR'] = restoreEnv.pkgBin; rmSync(tmpRoot, { recursive: true, force: true }); runtime._resetSeededMarker(); }); -function setPkgBin(p: string): void { - process.env['PILOT_PKG_BIN_DIR'] = p; +function usePkg(p: { root: string; platformDir: string }): void { + process.env['PILOT_PKG_BIN_ROOT'] = p.root; + process.env['PILOT_PKG_BIN_DIR'] = p.platformDir; } // --------------------------------------------------------------------------- @@ -114,8 +139,7 @@ describe('seeder state machine', () => { runtime._resetSeededMarker(); // Replace the package with an older version. - const olderPkg = makeFakePkgBin(join(tmpRoot, 'older'), '1.8.0'); - setPkgBin(olderPkg); + usePkg(makeFakePkg(tmpRoot, 'older', '1.8.0')); const r = runtime.runSeeder(); expect(r.action).toBe('noop'); @@ -128,8 +152,7 @@ describe('seeder state machine', () => { runtime.runSeeder(); runtime._resetSeededMarker(); - const newerPkg = makeFakePkgBin(join(tmpRoot, 'newer'), '2.0.0'); - setPkgBin(newerPkg); + usePkg(makeFakePkg(tmpRoot, 'newer', '2.0.0')); const r = runtime.runSeeder(); expect(r.action).toBe('upgrade'); @@ -277,16 +300,17 @@ describe('semver compare', () => { describe('wrong-platform package', () => { it('seeder skips missing files cleanly', () => { - // Build a pkg without the platform lib. - const incomplete = join(tmpRoot, 'incomplete'); - mkdirSync(incomplete, { recursive: true }); + // Build a pkg with proper layout but missing the platform lib. + const incompleteRoot = join(tmpRoot, 'incomplete'); + const incompletePlatform = join(incompleteRoot, runtime._internals.platformDirName()); + mkdirSync(incompletePlatform, { recursive: true }); for (const n of BIN_NAMES) { - const p = join(incomplete, n); + const p = join(incompletePlatform, n); writeFileSync(p, '#!/bin/sh\n'); chmodSync(p, 0o755); } - writeFileSync(join(incomplete, '.pilot-version'), '1.9.1\n'); - setPkgBin(incomplete); + writeFileSync(join(incompleteRoot, '.pilot-version'), '1.9.1\n'); + usePkg({ root: incompleteRoot, platformDir: incompletePlatform }); const r = runtime.runSeeder(); expect(r.copied).not.toContain(platformLib()); @@ -295,4 +319,23 @@ describe('wrong-platform package', () => { // from both runtime dir and package. expect(() => runtime.runtimeLibraryPath()).toThrow(/libpilot/); }); + + it('seeder is a no-op when the platform subdir is entirely absent', () => { + // Simulate an npm package built only for the *other* platform. + const otherRoot = join(tmpRoot, 'wrongplatform'); + const otherPlatform = osPlatform() === 'linux' ? 'darwin-arm64' : 'linux-amd64'; + const otherDir = join(otherRoot, otherPlatform); + mkdirSync(otherDir, { recursive: true }); + writeFileSync(join(otherDir, 'pilotctl'), '#!/bin/sh\n'); + writeFileSync(join(otherRoot, '.pilot-version'), '1.9.1\n'); + // Point at the missing subdir for THIS platform. + usePkg({ + root: otherRoot, + platformDir: join(otherRoot, runtime._internals.platformDirName()), + }); + + // Should not throw — just nothing copied. + const r = runtime.runSeeder(); + expect(r.copied).toEqual([]); + }); });