storebound/tools/build-sidecars.ts

107 lines
6 KiB
TypeScript

#!/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-<rust target triple>[.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<string, string> = {
'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("<checkout>/...")` 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<string> {
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);
}