#!/usr/bin/env bun // Compiles the storebound CLI into single-file executables for the desktop app's sidecar (Tauri `externalBin`). // bun tools/build-sidecars.ts every target // bun tools/build-sidecars.ts --host only this machine's target (what `tauri dev` needs) // bun tools/build-sidecars.ts --target bun-linux-x64[,bun-windows-x64] // Output: apps/desktop/src-tauri/binaries/storebound-[.exe] (Tauri's sidecar naming; gitignored). // macOS outputs are re-signed ad hoc: the signature `bun build --compile` leaves is invalid once the payload is // appended, and the kernel kills such a binary on start (exit 137). Release builds re-sign with Developer ID (D5). // Every output is scanned for the build machine's home and checkout paths: nothing private may ship. import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; export const TARGETS: Record = { 'bun-darwin-arm64': 'aarch64-apple-darwin', 'bun-darwin-x64': 'x86_64-apple-darwin', 'bun-linux-x64': 'x86_64-unknown-linux-gnu', 'bun-linux-arm64': 'aarch64-unknown-linux-gnu', 'bun-windows-x64': 'x86_64-pc-windows-msvc', }; export const ROOT = join(import.meta.dir, '..'); /** Modules playwright-core references but the sidecar never loads (Electron apps, the WebDriver BiDi protocol). */ export const SIDECAR_EXTERNALS = ['electron', 'chromium-bidi']; export const OUT_DIR = join(ROOT, 'apps/desktop/src-tauri/binaries'); export function hostTarget(): string { const os = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux'; const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; return `bun-${os}-${arch}`; } export const outName = (target: string): string => `storebound-${TARGETS[target]}${target.includes('windows') ? '.exe' : ''}`; /** Home folders of build accounts that name nobody (a CI container runs as root). They are not searched for: "/root" * is a substring of the bun runtime itself (root_certs.cpp, ICU's root.res). privacy-scan still checks every home. */ export const GENERIC_HOME = /^(\/root|\/home\/(runner|ubuntu|build|builder|ci|docker|node|user))\/?$/; /** Paths of this machine that must not appear in a shipped binary. */ export function privateStrings(home = homedir(), root = ROOT): string[] { return [...new Set([GENERIC_HOME.test(home) ? '' : home, root].filter(s => s.length > 4))]; } export function leaks(file: string, needles = privateStrings()): string[] { const buf = readFileSync(file); return needles.filter(n => buf.includes(Buffer.from(n))); } /** Neutral prefix the checkout path is replaced with in the bundle (bundled packages carry their own paths). */ export const NEUTRAL_ROOT = '/storebound'; /** * Stage 1: one JavaScript bundle of the CLI (target bun, platform independent). playwright-core is bundled, so * screenshot renders work without playwright in the user's project (it drives the app-managed Chrome for Testing, * STOREBOUND_CHROME); its Electron and BiDi loaders are never used and stay out. Bundled CommonJS modules carry their * absolute source paths (__dirname, require.resolve): the checkout path becomes NEUTRAL_ROOT, so no build machine * path ships. Those paths only matter for features the sidecar never uses (Electron, trace viewer, app icon). */ export function bundle(): string { const dir = mkdtempSync(join(tmpdir(), 'sb-sidecar-')); const p = Bun.spawnSync(['bun', 'build', 'cli/index.ts', '--target=bun', ...SIDECAR_EXTERNALS.flatMap(e => ['--external', e]), '--outdir', dir], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }); if (p.exitCode !== 0) throw new Error(`bundle failed\n${p.stderr.toString()}${p.stdout.toString()}`); const entry = join(dir, 'index.js'); writeFileSync(entry, neutralize(readFileSync(entry, 'utf8'))); return entry; } /** The checkout path out of a bundle. `require.resolve("/...")` of a bundled file (playwright-core asks * for its own package.json, only to know its folder) becomes the plain path string, so it cannot throw at run time. */ export function neutralize(js: string, root = ROOT): string { const esc = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return js.replace(new RegExp(`__require\\.resolve\\(("${esc}[^"]*")\\)`, 'g'), '$1').split(root).join(NEUTRAL_ROOT); } let bundled: string | null = null; export async function buildOne(target: string, outDir = OUT_DIR): Promise { if (!TARGETS[target]) throw new Error(`unknown target ${target} (${Object.keys(TARGETS).join(', ')})`); mkdirSync(outDir, { recursive: true }); const out = join(outDir, outName(target)); rmSync(out, { force: true }); const t = Date.now(); bundled ??= bundle(); const p = Bun.spawnSync(['bun', 'build', '--compile', `--target=${target}`, '--minify-syntax', '--minify-whitespace', bundled, '--outfile', out], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }); if (p.exitCode !== 0) throw new Error(`${target}: bun build failed\n${p.stderr.toString()}${p.stdout.toString()}`); if (target.startsWith('bun-darwin') && process.platform === 'darwin') { const s = Bun.spawnSync(['codesign', '--force', '--sign', '-', out], { stdout: 'pipe', stderr: 'pipe' }); if (s.exitCode !== 0) throw new Error(`${target}: ad-hoc codesign failed: ${s.stderr.toString()}`); } const hits = leaks(out); if (hits.length) throw new Error(`${target}: ${out} contains private paths (${hits.length}); refusing to keep it`); console.log(`sidecar ${target} -> ${out} (${(statSync(out).size / 1e6).toFixed(1)} MB, ${((Date.now() - t) / 1000).toFixed(1)}s)`); return out; } if (import.meta.main) { const argv = process.argv.slice(2); const i = argv.indexOf('--target'); const targets = argv.includes('--host') ? [hostTarget()] : i >= 0 ? (argv[i + 1] ?? '').split(',').filter(Boolean) : Object.keys(TARGETS); let failed = 0; for (const t of targets) { try { await buildOne(t); } catch (e) { failed++; console.error(`sidecar ${t}: ${(e as Error).message}`); } } if (failed) process.exit(1); }