#!/usr/bin/env bun // Build orchestrator for the dev-server. // // Three steps (run on demand): // (a) Client JSX bundle: client/app.jsx + React 19 -> dist/client.bundle.js (IIFE, tree-shaken). // (b) CSS bundle: client/styles/_index.css (Lightning CSS, @layer, OKLCH fallback). // (c) Server binary: server.ts -> dist/maude- (bun build --compile, per-platform). // // Modes: // bun run build.ts -> dev build for current platform (no compile, no minify) // bun run build.ts --release -> release build for all platforms in the matrix // bun run build.ts --release --target=bun-darwin-arm64 // bun run build.ts --watch -> watch client + CSS; broadcast over HMR socket // bun run build.ts --dry-run -> exit 0 without writing files (smoke for CI / Task 2 validation) // // Per DDR-009 (Bun runtime authoritative) + DDR-012 (React 19 unified) + DDR-014 (Lightning CSS). import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { browserslistToTargets, bundle as lcssBundle } from 'lightningcss'; import { cloudStubPlugin } from './cloud-build.ts'; const ROOT = dirname(fileURLToPath(import.meta.url)); const ARGS = new Set(process.argv.slice(2)); const FLAG_TARGET = process.argv.find((a) => a.startsWith('--target='))?.slice('--target='.length); const MODE: 'dev' | 'release' | 'dry' = ARGS.has('--dry-run') ? 'dry' : ARGS.has('--release') ? 'release' : 'dev'; // DEV-ONLY override, so a test can build somewhere disposable. // // `test/bundle-smoke.test.ts` shells out to this script to prove the bundle // still parses — and, until this existed, wrote its unminified DEV output // straight over the COMMITTED release artifacts (14 MB where 2 MB ships). What // is committed is what ships, so a suite run silently staged a broken release. // CLAUDE.md recorded this as "observed, root cause unconfirmed"; it is this. // // REFUSED OUTSIDE DEV, and that guard is the whole point of the variable being // safe. DIST is the write target for every artifact — client bundle, styles, // dist/runtime/*, and the compiled server binary. One environment variable // reaching a release runner would send all of them to a scratch directory, exit // 0 with normal-looking size lines, and leave packaging to ship the COMMITTED // artifacts instead. `check-runtime-bundles.sh` would validate those stale // files and pass; `check-client-boots.mjs` would boot them and pass. Every gate // green, nothing built actually shipped — a silent downgrade primitive with no // diff and no red CI. This repo's doctrine is "what is committed is what ships" // AND "verify the built artifact"; an unguarded override severs the two. if (process.env.MAUDE_DIST_DIR && MODE !== 'dev') { console.error( `[build] MAUDE_DIST_DIR is a dev/test-only override and must not be set for a --${MODE} build.\n` + ` Refusing to build: a release that writes elsewhere ships the previously committed\n` + ` artifacts while every packaging gate stays green.` ); process.exit(2); } const DIST = process.env.MAUDE_DIST_DIR || join(ROOT, 'dist'); const WATCH = ARGS.has('--watch'); /** Cloud Phase 27 D1 — build the CELL's variant (secret-bearing surfaces * removed from the binary, not merely un-routed). `--cloud` with `--release`. */ const CLOUD = ARGS.has('--cloud'); const PLATFORM_MATRIX = [ 'bun-darwin-arm64', 'bun-darwin-x64', 'bun-linux-x64', 'bun-linux-arm64', 'bun-linux-x64-musl', 'bun-linux-arm64-musl', 'bun-windows-x64', ] as const; type PlatformTarget = (typeof PLATFORM_MATRIX)[number]; function platformSlug(target: PlatformTarget): string { // bun calls Windows "windows"; npm and process.platform call it "win32". // Sub-package directories + the build-binaries.yml matrix slug use win32-x64. const s = target.replace(/^bun-/, ''); return s === 'windows-x64' ? 'win32-x64' : s; } function currentTarget(): PlatformTarget { const p = process.platform; const a = process.arch; if (p === 'darwin') return a === 'arm64' ? 'bun-darwin-arm64' : 'bun-darwin-x64'; if (p === 'linux') return a === 'arm64' ? 'bun-linux-arm64' : 'bun-linux-x64'; if (p === 'win32') return 'bun-windows-x64'; throw new Error(`Unsupported host platform: ${p}-${a}`); } function ensureDist() { if (!existsSync(DIST)) mkdirSync(DIST, { recursive: true }); } async function readPluginVersion(): Promise<{ version: string }> { // apps/studio/ → ../../plugins/design/.claude-plugin/plugin.json (DDR-095) const manifest = join(ROOT, '..', '..', 'plugins', 'design', '.claude-plugin', 'plugin.json'); try { const parsed = JSON.parse(await Bun.file(manifest).text()) as { version?: unknown }; if (typeof parsed.version === 'string') return { version: parsed.version }; } catch { /* fall through to dev default */ } return { version: 'dev' }; } // ---------- (a) Client JSX bundle ---------- async function buildClient(): Promise<{ outBytes: number; outPath: string }> { ensureDist(); const outPath = join(DIST, 'client.bundle.js'); // Wordmark sub-line version. Read from plugins/design/.claude-plugin/plugin.json — // that file ships in both npm installs AND marketplace-cache clones (it's the // plugin manifest, always present). The old `../../../package.json` hop // resolved to the repo root in dev but ENOENT'd in marketplace caches where // the maude bundle isn't installed as a package. DDR-044, Phase 19. const pkg = await readPluginVersion(); const result = await Bun.build({ entrypoints: [join(ROOT, 'client/app.jsx')], outdir: DIST, target: 'browser', // ESM — not IIFE. Bun.build's IIFE + full minify + React 19 hits a TDZ // bug (`Cannot access 'VZ' before initialization`) because top-level // `const`s get rotated past their declaration. ESM has stable hoisting // semantics the minifier respects. Cost: `