From 87db1a4ccb7d59520e8c1267a113e26b6d18bee4 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Jan 2026 00:34:27 -0600 Subject: [PATCH] refactor: extract Justfile inline scripts into scripts/ directory Move all [script("bun")] blocks into standalone TypeScript files under scripts/ with shared utilities in scripts/lib/. The Justfile is now ~40 lines of thin `bun scripts/*.ts` wrappers. Shared code consolidated into two lib files: - lib/proc.ts: process spawning (run, spawnCollect, raceInOrder, ProcessGroup) - lib/fmt.ts: color output, elapsed timers, reusable flag parser --- .gitignore | 1 + Justfile | 548 +----------------------------------------- scripts/bindings.ts | 32 +++ scripts/build.ts | 45 ++++ scripts/bun.lock | 21 ++ scripts/check.ts | 241 +++++++++++++++++++ scripts/db.ts | 79 ++++++ scripts/dev.ts | 112 +++++++++ scripts/lib/fmt.ts | 96 ++++++++ scripts/lib/proc.ts | 113 +++++++++ scripts/package.json | 8 + scripts/test.ts | 20 ++ scripts/tsconfig.json | 15 ++ 13 files changed, 790 insertions(+), 541 deletions(-) create mode 100644 scripts/bindings.ts create mode 100644 scripts/build.ts create mode 100644 scripts/bun.lock create mode 100644 scripts/check.ts create mode 100644 scripts/db.ts create mode 100644 scripts/dev.ts create mode 100644 scripts/lib/fmt.ts create mode 100644 scripts/lib/proc.ts create mode 100644 scripts/package.json create mode 100644 scripts/test.ts create mode 100644 scripts/tsconfig.json diff --git a/.gitignore b/.gitignore index a1948a9..6a42e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env /target +/scripts/node_modules diff --git a/Justfile b/Justfile index 1abee9a..0facc48 100644 --- a/Justfile +++ b/Justfile @@ -4,275 +4,8 @@ default: just --list # Run all checks in parallel. Pass -f/--fix to auto-format and fix first. -[script("bun")] check *flags: - const args = "{{flags}}".split(/\s+/).filter(Boolean); - let fix = false; - for (const arg of args) { - if (arg === "-f" || arg === "--fix") fix = true; - else { console.error(`Unknown flag: ${arg}`); process.exit(1); } - } - - // --- Helpers --- - - const useColor = process.stdout.isTTY ?? false; - const stderrTTY = process.stderr.isTTY ?? false; - const c = (code, text) => useColor ? `\x1b[${code}m${text}\x1b[0m` : text; - const since = (t) => ((Date.now() - t) / 1000).toFixed(1); - - /** Sync spawn with inherited stdio (for --fix path). */ - const run = (cmd) => { - const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); - if (proc.exitCode !== 0) process.exit(proc.exitCode); - }; - - /** - * Spawn a command, collect stdout/stderr, return a result object. - * Catches spawn failures (e.g. missing binary) instead of throwing. - */ - const spawnCollect = async (cmd, startTime) => { - try { - const proc = Bun.spawn(cmd, { - env: { ...process.env, FORCE_COLOR: "1" }, - stdout: "pipe", stderr: "pipe", - }); - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - await proc.exited; - return { stdout, stderr, exitCode: proc.exitCode, elapsed: since(startTime) }; - } catch (err) { - return { stdout: "", stderr: String(err), exitCode: 1, elapsed: since(startTime) }; - } - }; - - /** - * Sync spawn with piped stdio. Returns { exitCode, stdout, stderr }. - * Used for Phase 2 formatters so output doesn't spill into structured results. - */ - const runPiped = (cmd) => { - const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe" }); - return { - exitCode: proc.exitCode, - stdout: proc.stdout?.toString() ?? "", - stderr: proc.stderr?.toString() ?? "", - }; - }; - - /** - * Race all promises, yielding results in completion order via callback. - * Every promise gets a .catch() wrapper so spawn failures become results, not unhandled rejections. - */ - const raceInOrder = async (promises, fallbacks, onResult) => { - const tagged = promises.map((p, i) => - p.then(r => ({ i, r })) - .catch(err => ({ i, r: { - ...fallbacks[i], exitCode: 1, stdout: "", stderr: String(err), elapsed: "?", - }})) - ); - for (let n = 0; n < promises.length; n++) { - const { i, r } = await Promise.race(tagged); - tagged[i] = new Promise(() => {}); // sentinel: never resolves - onResult(r); - } - }; - - // --- Fix path --- - - if (fix) { - console.log(c("1;36", "→ Fixing...")); - run(["cargo", "fmt", "--all"]); - run(["bun", "run", "--cwd", "web", "format"]); - run(["cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged", - "--", "--deny", "warnings"]); - console.log(c("1;36", "→ Verifying...")); - } - - // --- Domain groups: formatter → { peers, format command, sanity rechecks } --- - - const domains = { - rustfmt: { - peers: ["clippy", "cargo-check", "rust-test"], - format: () => runPiped(["cargo", "fmt", "--all"]), - recheck: [ - { name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] }, - { name: "cargo-check", cmd: ["cargo", "check", "--all-features"] }, - ], - }, - biome: { - peers: ["svelte-check", "biome-lint", "web-test"], - format: () => runPiped(["bun", "run", "--cwd", "web", "format"]), - recheck: [ - { name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] }, - { name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] }, - ], - }, - }; - - // --- Ensure TypeScript bindings are up-to-date before frontend checks --- - - { - const { statSync, existsSync, readdirSync, writeFileSync, rmSync } = await import("fs"); - const BINDINGS_DIR = "web/src/lib/bindings"; - - // Find newest Rust source mtime (src/**/*.rs + Cargo.toml + Cargo.lock) - let newestSrcMtime = 0; - for (const file of new Bun.Glob("src/**/*.rs").scanSync(".")) { - const mt = statSync(file).mtimeMs; - if (mt > newestSrcMtime) newestSrcMtime = mt; - } - for (const f of ["Cargo.toml", "Cargo.lock"]) { - if (existsSync(f)) { - const mt = statSync(f).mtimeMs; - if (mt > newestSrcMtime) newestSrcMtime = mt; - } - } - - // Find newest binding output mtime - let newestBindingMtime = 0; - if (existsSync(BINDINGS_DIR)) { - for (const file of new Bun.Glob("**/*").scanSync(BINDINGS_DIR)) { - const mt = statSync(`${BINDINGS_DIR}/${file}`).mtimeMs; - if (mt > newestBindingMtime) newestBindingMtime = mt; - } - } - - const stale = newestBindingMtime === 0 || newestSrcMtime > newestBindingMtime; - if (stale) { - const t = Date.now(); - process.stdout.write(c("1;36", "→ Regenerating TypeScript bindings (Rust sources changed)...") + "\n"); - // Build test binary first (slow part) — fail before deleting anything - const build = Bun.spawnSync(["cargo", "test", "--no-run"], { - stdio: ["inherit", "inherit", "inherit"], - }); - if (build.exitCode !== 0) process.exit(build.exitCode); - // Clean slate, then run export (fast, already compiled) - rmSync(BINDINGS_DIR, { recursive: true, force: true }); - const gen = Bun.spawnSync(["cargo", "test", "export_bindings"], { - stdio: ["inherit", "inherit", "inherit"], - }); - if (gen.exitCode !== 0) process.exit(gen.exitCode); - - // Auto-generate index.ts - const types = readdirSync(BINDINGS_DIR) - .filter(f => f.endsWith(".ts") && f !== "index.ts") - .map(f => f.replace(/\.ts$/, "")) - .sort(); - writeFileSync(`${BINDINGS_DIR}/index.ts`, types.map(t => `export type { ${t} } from "./${t}";`).join("\n") + "\n"); - - process.stdout.write(c("32", `✓ bindings`) + ` (${since(t)}s, ${types.length} types)\n`); - } else { - process.stdout.write(c("2", "· bindings up-to-date, skipped") + "\n"); - } - } - - // --- Check definitions --- - - const checks = [ - { name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"], - hint: "Run 'cargo fmt --all' to see and fix formatting issues." }, - { name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] }, - { name: "cargo-check", cmd: ["cargo", "check", "--all-features"] }, - { name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] }, - { name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] }, - { name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] }, - { name: "biome-lint", cmd: ["bun", "run", "--cwd", "web", "lint"] }, - { name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] }, - { name: "actionlint", cmd: ["actionlint"] }, - // { name: "sqlx-prepare", cmd: ["cargo", "sqlx", "prepare", "--check"] }, - ]; - - // --- Phase 1: run all checks in parallel, display results in completion order --- - - const start = Date.now(); - const remaining = new Set(checks.map(ch => ch.name)); - - const promises = checks.map(async (check) => { - if (check.fn) { - return { ...check, ...(await check.fn(start)) }; - } - return { ...check, ...(await spawnCollect(check.cmd, start)) }; - }); - - const interval = stderrTTY ? setInterval(() => { - process.stderr.write(`\r\x1b[K${since(start)}s [${Array.from(remaining).join(", ")}]`); - }, 100) : null; - - const results = {}; - await raceInOrder(promises, checks, (r) => { - results[r.name] = r; - remaining.delete(r.name); - if (stderrTTY) process.stderr.write(`\r\x1b[K`); - - if (r.exitCode !== 0) { - process.stdout.write(c("31", `✗ ${r.name}`) + ` (${r.elapsed}s)\n`); - if (r.hint) { - process.stdout.write(c("2", ` ${r.hint}`) + `\n`); - } else { - if (r.stdout) process.stdout.write(r.stdout); - if (r.stderr) process.stderr.write(r.stderr); - } - } else { - process.stdout.write(c("32", `✓ ${r.name}`) + ` (${r.elapsed}s)\n`); - } - }); - - if (interval) clearInterval(interval); - if (stderrTTY) process.stderr.write(`\r\x1b[K`); - - // --- Phase 2: auto-fix formatting if it's the only failure in its domain --- - - const autoFixedDomains = new Set(); - for (const [fmtName, domain] of Object.entries(domains)) { - const fmtResult = results[fmtName]; - if (!fmtResult || fmtResult.exitCode === 0) continue; - if (!domain.peers.every(p => results[p]?.exitCode === 0)) continue; - - process.stdout.write(`\n` + c("1;36", `→ Auto-formatting ${fmtName} (peers passed, only formatting failed)...`) + `\n`); - const fmtOut = domain.format(); - if (fmtOut.exitCode !== 0) { - process.stdout.write(c("31", ` ✗ ${fmtName} formatter failed`) + `\n`); - if (fmtOut.stdout) process.stdout.write(fmtOut.stdout); - if (fmtOut.stderr) process.stderr.write(fmtOut.stderr); - continue; - } - - // Re-verify in parallel, display in completion order - const recheckStart = Date.now(); - const recheckPromises = domain.recheck.map(async (ch) => ({ - ...ch, ...(await spawnCollect(ch.cmd, recheckStart)), - })); - - let recheckFailed = false; - await raceInOrder(recheckPromises, domain.recheck, (r) => { - if (r.exitCode !== 0) { - recheckFailed = true; - process.stdout.write(c("31", ` ✗ ${r.name}`) + ` (${r.elapsed}s)\n`); - if (r.stdout) process.stdout.write(r.stdout); - if (r.stderr) process.stderr.write(r.stderr); - } else { - process.stdout.write(c("32", ` ✓ ${r.name}`) + ` (${r.elapsed}s)\n`); - } - }); - - if (!recheckFailed) { - process.stdout.write(c("32", ` ✓ ${fmtName} auto-fix succeeded`) + `\n`); - autoFixedDomains.add(fmtName); - } else { - process.stdout.write(c("31", ` ✗ ${fmtName} auto-fix failed sanity check`) + `\n`); - } - } - - // --- Final verdict --- - - const finalFailed = Object.entries(results).some( - ([name, r]) => r.exitCode !== 0 && !autoFixedDomains.has(name) - ); - if (autoFixedDomains.size > 0 && !finalFailed) { - process.stdout.write(`\n` + c("1;32", "✓ All checks passed (formatting was auto-fixed)") + `\n`); - } - process.exit(finalFailed ? 1 : 0); + bun scripts/check.ts {{flags}} # Format all Rust and TypeScript code format: @@ -280,297 +13,30 @@ format: bun run --cwd web format # Run tests. Usage: just test [rust|web|] -[script("bun")] test *args: - const input = "{{args}}".trim(); - const run = (cmd) => { - const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); - if (proc.exitCode !== 0) process.exit(proc.exitCode); - }; - if (input === "web") { - run(["bun", "run", "--cwd", "web", "test"]); - } else if (input === "rust") { - run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]); - } else if (input === "") { - run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]); - run(["bun", "run", "--cwd", "web", "test"]); - } else { - run(["cargo", "nextest", "run", ...input.split(/\s+/)]); - } + bun scripts/test.ts {{args}} # Generate TypeScript bindings from Rust types (ts-rs) -[script("bun")] bindings: - const { readdirSync, writeFileSync, rmSync } = await import("fs"); - const dir = "web/src/lib/bindings"; - const run = (cmd) => { - const r = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); - if (r.exitCode !== 0) process.exit(r.exitCode); - }; - - // Build test binary first (slow part) — fail before deleting anything - run(["cargo", "test", "--no-run"]); - // Clean slate - rmSync(dir, { recursive: true, force: true }); - // Run the export (fast, already compiled) - run(["cargo", "test", "export_bindings"]); - - // Auto-generate index.ts from emitted .ts files - const types = readdirSync(dir) - .filter(f => f.endsWith(".ts") && f !== "index.ts") - .map(f => f.replace(/\.ts$/, "")) - .sort(); - writeFileSync(`${dir}/index.ts`, types.map(t => `export type { ${t} } from "./${t}";`).join("\n") + "\n"); - console.log(`Generated ${dir}/index.ts (${types.length} types)`); + bun scripts/bindings.ts # Run the Banner API search demo (hits live UTSA API, ~20s) search *ARGS: cargo run -q --bin search -- {{ARGS}} -# Pass args to binary after --: just dev -n -- --some-flag # Dev server. Flags: -f(rontend) -b(ackend) -W(no-watch) -n(o-build) -r(elease) -e(mbed) --tracing -[script("bun")] +# Pass args to binary after --: just dev -n -- --some-flag dev *flags: - const argv = "{{flags}}".split(/\s+/).filter(Boolean); - - let frontendOnly = false, backendOnly = false; - let noWatch = false, noBuild = false, release = false, embed = false; - let tracing = "pretty"; - const passthrough = []; - - let i = 0; - let seenDashDash = false; - while (i < argv.length) { - const arg = argv[i]; - if (seenDashDash) { passthrough.push(arg); i++; continue; } - if (arg === "--") { seenDashDash = true; i++; continue; } - if (arg.startsWith("--")) { - if (arg === "--frontend-only") frontendOnly = true; - else if (arg === "--backend-only") backendOnly = true; - else if (arg === "--no-watch") noWatch = true; - else if (arg === "--no-build") noBuild = true; - else if (arg === "--release") release = true; - else if (arg === "--embed") embed = true; - else if (arg === "--tracing") { tracing = argv[++i] || "pretty"; } - else { console.error(`Unknown flag: ${arg}`); process.exit(1); } - } else if (arg.startsWith("-") && arg.length > 1) { - for (const c of arg.slice(1)) { - if (c === "f") frontendOnly = true; - else if (c === "b") backendOnly = true; - else if (c === "W") noWatch = true; - else if (c === "n") noBuild = true; - else if (c === "r") release = true; - else if (c === "e") embed = true; - else { console.error(`Unknown flag: -${c}`); process.exit(1); } - } - } else { console.error(`Unknown argument: ${arg}`); process.exit(1); } - i++; - } - - // -e implies -b (no point running Vite if assets are embedded) - if (embed) backendOnly = true; - // -n implies -W (no build means no watch) - if (noBuild) noWatch = true; - - // Validate conflicting flags - if (frontendOnly && backendOnly) { - console.error("Cannot use -f and -b together (or -e implies -b)"); - process.exit(1); - } - - const runFrontend = !backendOnly; - const runBackend = !frontendOnly; - const profile = release ? "release" : "dev"; - const profileDir = release ? "release" : "debug"; - - const procs = []; - const cleanup = async () => { - for (const p of procs) p.kill(); - await Promise.all(procs.map(p => p.exited)); - }; - process.on("SIGINT", async () => { await cleanup(); process.exit(0); }); - process.on("SIGTERM", async () => { await cleanup(); process.exit(0); }); - - // Build frontend first when embedding assets (backend will bake them in) - if (embed && !noBuild) { - console.log(`\x1b[1;36m→ Building frontend (for embedding)...\x1b[0m`); - const fb = Bun.spawnSync(["bun", "run", "--cwd", "web", "build"], { - stdio: ["inherit", "inherit", "inherit"], - }); - if (fb.exitCode !== 0) process.exit(fb.exitCode); - } - - // Frontend: Vite dev server - if (runFrontend) { - const proc = Bun.spawn(["bun", "run", "--cwd", "web", "dev"], { - stdio: ["inherit", "inherit", "inherit"], - }); - procs.push(proc); - } - - // Backend - if (runBackend) { - const backendArgs = [`--tracing`, tracing, ...passthrough]; - const bin = `target/${profileDir}/banner`; - - if (noWatch) { - // Build first unless -n (skip build) - if (!noBuild) { - console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`); - const cargoArgs = ["cargo", "build", "--bin", "banner"]; - if (!embed) cargoArgs.push("--no-default-features"); - if (release) cargoArgs.push("--release"); - const build = Bun.spawnSync(cargoArgs, { stdio: ["inherit", "inherit", "inherit"] }); - if (build.exitCode !== 0) { cleanup(); process.exit(build.exitCode); } - } - - // Run the binary directly (no watch) - const { existsSync } = await import("fs"); - if (!existsSync(bin)) { - console.error(`Binary not found: ${bin}`); - console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`); - cleanup(); - process.exit(1); - } - console.log(`\x1b[1;36m→ Running ${bin} (no watch)\x1b[0m`); - const proc = Bun.spawn([bin, ...backendArgs], { - stdio: ["inherit", "inherit", "inherit"], - }); - procs.push(proc); - } else { - // Bacon watch mode - const baconArgs = ["bacon", "--headless", "run", "--"]; - if (!embed) baconArgs.push("--no-default-features"); - if (release) baconArgs.push("--profile", "release"); - baconArgs.push("--", ...backendArgs); - const proc = Bun.spawn(baconArgs, { - stdio: ["inherit", "inherit", "inherit"], - }); - procs.push(proc); - } - } - - // Wait for any process to exit, then kill the rest - const results = procs.map((p, i) => p.exited.then(code => ({ i, code }))); - const first = await Promise.race(results); - cleanup(); - process.exit(first.code); + bun scripts/dev.ts {{flags}} # Production build. Flags: -d(ebug) -f(rontend-only) -b(ackend-only) -[script("bun")] build *flags: - const argv = "{{flags}}".split(/\s+/).filter(Boolean); - - let debug = false, frontendOnly = false, backendOnly = false; - for (const arg of argv) { - if (arg.startsWith("--")) { - if (arg === "--debug") debug = true; - else if (arg === "--frontend-only") frontendOnly = true; - else if (arg === "--backend-only") backendOnly = true; - else { console.error(`Unknown flag: ${arg}`); process.exit(1); } - } else if (arg.startsWith("-") && arg.length > 1) { - for (const c of arg.slice(1)) { - if (c === "d") debug = true; - else if (c === "f") frontendOnly = true; - else if (c === "b") backendOnly = true; - else { console.error(`Unknown flag: -${c}`); process.exit(1); } - } - } else { console.error(`Unknown argument: ${arg}`); process.exit(1); } - } - - if (frontendOnly && backendOnly) { - console.error("Cannot use -f and -b together"); - process.exit(1); - } - - const run = (cmd) => { - const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); - if (proc.exitCode !== 0) process.exit(proc.exitCode); - }; - - const buildFrontend = !backendOnly; - const buildBackend = !frontendOnly; - const profile = debug ? "debug" : "release"; - - if (buildFrontend) { - console.log("\x1b[1;36m→ Building frontend...\x1b[0m"); - run(["bun", "run", "--cwd", "web", "build"]); - } - - if (buildBackend) { - console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`); - const cmd = ["cargo", "build", "--bin", "banner"]; - if (!debug) cmd.push("--release"); - run(cmd); - } + bun scripts/build.ts {{flags}} # Start PostgreSQL in Docker and update .env with connection string # Commands: start (default), reset, rm -[script("bun")] db cmd="start": - const fs = await import("fs/promises"); - const { spawnSync } = await import("child_process"); - - const NAME = "banner-postgres"; - const USER = "banner"; - const PASS = "banner"; - const DB = "banner"; - const PORT = "59489"; - const ENV_FILE = ".env"; - const CMD = "{{cmd}}"; - - const run = (args) => spawnSync("docker", args, { encoding: "utf8" }); - const getContainer = () => { - const res = run(["ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json"]); - return res.stdout.trim() ? JSON.parse(res.stdout) : null; - }; - - const updateEnv = async () => { - const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`; - try { - let content = await fs.readFile(ENV_FILE, "utf8"); - content = content.includes("DATABASE_URL=") - ? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`) - : content.trim() + `\nDATABASE_URL=${url}\n`; - await fs.writeFile(ENV_FILE, content); - } catch { - await fs.writeFile(ENV_FILE, `DATABASE_URL=${url}\n`); - } - }; - - const create = () => { - run(["run", "-d", "--name", NAME, "-e", `POSTGRES_USER=${USER}`, - "-e", `POSTGRES_PASSWORD=${PASS}`, "-e", `POSTGRES_DB=${DB}`, - "-p", `${PORT}:5432`, "postgres:17-alpine"]); - console.log("created"); - }; - - const container = getContainer(); - - if (CMD === "rm") { - if (!container) process.exit(0); - run(["stop", NAME]); - run(["rm", NAME]); - console.log("removed"); - } else if (CMD === "reset") { - if (!container) create(); - else { - run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `DROP DATABASE IF EXISTS ${DB}`]); - run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `CREATE DATABASE ${DB}`]); - console.log("reset"); - } - await updateEnv(); - } else { - if (!container) { - create(); - } else if (container.State !== "running") { - run(["start", NAME]); - console.log("started"); - } else { - console.log("running"); - } - await updateEnv(); - } + bun scripts/db.ts {{cmd}} alias b := bun bun *ARGS: diff --git a/scripts/bindings.ts b/scripts/bindings.ts new file mode 100644 index 0000000..d12661d --- /dev/null +++ b/scripts/bindings.ts @@ -0,0 +1,32 @@ +/** + * Generate TypeScript bindings from Rust types (ts-rs). + * + * Usage: bun scripts/bindings.ts + */ + +import { readdirSync, writeFileSync, rmSync } from "fs"; +import { run } from "./lib/proc"; + +const BINDINGS_DIR = "web/src/lib/bindings"; + +// Build test binary first (slow part) — fail before deleting anything +run(["cargo", "test", "--no-run"]); + +// Clean slate +rmSync(BINDINGS_DIR, { recursive: true, force: true }); + +// Run the export (fast, already compiled) +run(["cargo", "test", "export_bindings"]); + +// Auto-generate index.ts from emitted .ts files +const types = readdirSync(BINDINGS_DIR) + .filter((f) => f.endsWith(".ts") && f !== "index.ts") + .map((f) => f.replace(/\.ts$/, "")) + .sort(); + +writeFileSync( + `${BINDINGS_DIR}/index.ts`, + types.map((t) => `export type { ${t} } from "./${t}";`).join("\n") + "\n", +); + +console.log(`Generated ${BINDINGS_DIR}/index.ts (${types.length} types)`); diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..66c2bb3 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,45 @@ +/** + * Production build. + * + * Usage: bun scripts/build.ts [flags] + * + * Flags: + * -d, --debug Debug build instead of release + * -f, --frontend-only Frontend only + * -b, --backend-only Backend only + */ + +import { parseFlags, c } from "./lib/fmt"; +import { run } from "./lib/proc"; + +const { flags } = parseFlags( + process.argv.slice(2), + { + debug: "bool", + "frontend-only": "bool", + "backend-only": "bool", + } as const, + { d: "debug", f: "frontend-only", b: "backend-only" }, + { debug: false, "frontend-only": false, "backend-only": false }, +); + +if (flags["frontend-only"] && flags["backend-only"]) { + console.error("Cannot use -f and -b together"); + process.exit(1); +} + +const buildFrontend = !flags["backend-only"]; +const buildBackend = !flags["frontend-only"]; +const profile = flags.debug ? "debug" : "release"; + +if (buildFrontend) { + console.log(c("1;36", "→ Building frontend...")); + run(["bun", "run", "--cwd", "web", "build"]); +} + +if (buildBackend) { + console.log(c("1;36", `→ Building backend (${profile})...`)); + const cmd = ["cargo", "build", "--bin", "banner"]; + if (!flags.debug) cmd.push("--release"); + run(cmd); +} diff --git a/scripts/bun.lock b/scripts/bun.lock new file mode 100644 index 0000000..ddb30e7 --- /dev/null +++ b/scripts/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "banner-scripts", + "devDependencies": { + "@types/bun": "^1.3.8", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/scripts/check.ts b/scripts/check.ts new file mode 100644 index 0000000..c052d99 --- /dev/null +++ b/scripts/check.ts @@ -0,0 +1,241 @@ +/** + * Run all project checks in parallel. Auto-fixes formatting when safe. + * + * Usage: bun scripts/check.ts [--fix|-f] + */ + +import { c, elapsed, isStderrTTY } from "./lib/fmt"; +import { run, runPiped, spawnCollect, raceInOrder, type CollectResult } from "./lib/proc"; +import { existsSync, statSync, readdirSync, writeFileSync, rmSync } from "fs"; + +const fix = process.argv.includes("--fix") || process.argv.includes("-f"); + +// --------------------------------------------------------------------------- +// Fix path: format + clippy fix, then fall through to verification +// --------------------------------------------------------------------------- + +if (fix) { + console.log(c("1;36", "→ Fixing...")); + run(["cargo", "fmt", "--all"]); + run(["bun", "run", "--cwd", "web", "format"]); + run([ + "cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged", + "--", "--deny", "warnings", + ]); + console.log(c("1;36", "→ Verifying...")); +} + +// --------------------------------------------------------------------------- +// Ensure TypeScript bindings are up-to-date before frontend checks +// --------------------------------------------------------------------------- + +{ + const BINDINGS_DIR = "web/src/lib/bindings"; + + let newestSrcMtime = 0; + for (const file of new Bun.Glob("src/**/*.rs").scanSync(".")) { + const mt = statSync(file).mtimeMs; + if (mt > newestSrcMtime) newestSrcMtime = mt; + } + for (const f of ["Cargo.toml", "Cargo.lock"]) { + if (existsSync(f)) { + const mt = statSync(f).mtimeMs; + if (mt > newestSrcMtime) newestSrcMtime = mt; + } + } + + let newestBindingMtime = 0; + if (existsSync(BINDINGS_DIR)) { + for (const file of new Bun.Glob("**/*").scanSync(BINDINGS_DIR)) { + const mt = statSync(`${BINDINGS_DIR}/${file}`).mtimeMs; + if (mt > newestBindingMtime) newestBindingMtime = mt; + } + } + + const stale = newestBindingMtime === 0 || newestSrcMtime > newestBindingMtime; + if (stale) { + const t = Date.now(); + process.stdout.write( + c("1;36", "→ Regenerating TypeScript bindings (Rust sources changed)...") + "\n", + ); + run(["cargo", "test", "--no-run"]); + rmSync(BINDINGS_DIR, { recursive: true, force: true }); + run(["cargo", "test", "export_bindings"]); + + const types = readdirSync(BINDINGS_DIR) + .filter((f) => f.endsWith(".ts") && f !== "index.ts") + .map((f) => f.replace(/\.ts$/, "")) + .sort(); + writeFileSync( + `${BINDINGS_DIR}/index.ts`, + types.map((t) => `export type { ${t} } from "./${t}";`).join("\n") + "\n", + ); + + process.stdout.write(c("32", "✓ bindings") + ` (${elapsed(t)}s, ${types.length} types)\n`); + } else { + process.stdout.write(c("2", "· bindings up-to-date, skipped") + "\n"); + } +} + +// --------------------------------------------------------------------------- +// Check definitions +// --------------------------------------------------------------------------- + +interface Check { + name: string; + cmd: string[]; + hint?: string; +} + +const checks: Check[] = [ + { + name: "rustfmt", + cmd: ["cargo", "fmt", "--all", "--", "--check"], + hint: "Run 'cargo fmt --all' to see and fix formatting issues.", + }, + { name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] }, + { name: "cargo-check", cmd: ["cargo", "check", "--all-features"] }, + { name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] }, + { name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] }, + { name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] }, + { name: "biome-lint", cmd: ["bun", "run", "--cwd", "web", "lint"] }, + { name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] }, + { name: "actionlint", cmd: ["actionlint"] }, +]; + +// --------------------------------------------------------------------------- +// Domain groups: formatter → { peers, format command, sanity rechecks } +// --------------------------------------------------------------------------- + +const domains: Record< + string, + { + peers: string[]; + format: () => ReturnType; + recheck: Check[]; + } +> = { + rustfmt: { + peers: ["clippy", "cargo-check", "rust-test"], + format: () => runPiped(["cargo", "fmt", "--all"]), + recheck: [ + { name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] }, + { name: "cargo-check", cmd: ["cargo", "check", "--all-features"] }, + ], + }, + biome: { + peers: ["svelte-check", "biome-lint", "web-test"], + format: () => runPiped(["bun", "run", "--cwd", "web", "format"]), + recheck: [ + { name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] }, + { name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] }, + ], + }, +}; + +// --------------------------------------------------------------------------- +// Phase 1: run all checks in parallel, display in completion order +// --------------------------------------------------------------------------- + +const start = Date.now(); +const remaining = new Set(checks.map((ch) => ch.name)); + +const promises = checks.map(async (check) => ({ + ...check, + ...(await spawnCollect(check.cmd, start)), +})); + +const interval = isStderrTTY + ? setInterval(() => { + process.stderr.write(`\r\x1b[K${elapsed(start)}s [${Array.from(remaining).join(", ")}]`); + }, 100) + : null; + +const results: Record = {}; + +await raceInOrder(promises, checks, (r) => { + results[r.name] = r; + remaining.delete(r.name); + if (isStderrTTY) process.stderr.write("\r\x1b[K"); + + if (r.exitCode !== 0) { + process.stdout.write(c("31", `✗ ${r.name}`) + ` (${r.elapsed}s)\n`); + if (r.hint) { + process.stdout.write(c("2", ` ${r.hint}`) + "\n"); + } else { + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + } + } else { + process.stdout.write(c("32", `✓ ${r.name}`) + ` (${r.elapsed}s)\n`); + } +}); + +if (interval) clearInterval(interval); +if (isStderrTTY) process.stderr.write("\r\x1b[K"); + +// --------------------------------------------------------------------------- +// Phase 2: auto-fix formatting if it's the only failure in its domain +// --------------------------------------------------------------------------- + +const autoFixedDomains = new Set(); + +for (const [fmtName, domain] of Object.entries(domains)) { + const fmtResult = results[fmtName]; + if (!fmtResult || fmtResult.exitCode === 0) continue; + if (!domain.peers.every((p) => results[p]?.exitCode === 0)) continue; + + process.stdout.write( + "\n" + + c("1;36", `→ Auto-formatting ${fmtName} (peers passed, only formatting failed)...`) + + "\n", + ); + const fmtOut = domain.format(); + if (fmtOut.exitCode !== 0) { + process.stdout.write(c("31", ` ✗ ${fmtName} formatter failed`) + "\n"); + if (fmtOut.stdout) process.stdout.write(fmtOut.stdout); + if (fmtOut.stderr) process.stderr.write(fmtOut.stderr); + continue; + } + + const recheckStart = Date.now(); + const recheckPromises = domain.recheck.map(async (ch) => ({ + ...ch, + ...(await spawnCollect(ch.cmd, recheckStart)), + })); + + let recheckFailed = false; + await raceInOrder(recheckPromises, domain.recheck, (r) => { + if (r.exitCode !== 0) { + recheckFailed = true; + process.stdout.write(c("31", ` ✗ ${r.name}`) + ` (${r.elapsed}s)\n`); + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + } else { + process.stdout.write(c("32", ` ✓ ${r.name}`) + ` (${r.elapsed}s)\n`); + } + }); + + if (!recheckFailed) { + process.stdout.write(c("32", ` ✓ ${fmtName} auto-fix succeeded`) + "\n"); + autoFixedDomains.add(fmtName); + } else { + process.stdout.write(c("31", ` ✗ ${fmtName} auto-fix failed sanity check`) + "\n"); + } +} + +// --------------------------------------------------------------------------- +// Final verdict +// --------------------------------------------------------------------------- + +const finalFailed = Object.entries(results).some( + ([name, r]) => r.exitCode !== 0 && !autoFixedDomains.has(name), +); + +if (autoFixedDomains.size > 0 && !finalFailed) { + process.stdout.write( + "\n" + c("1;32", "✓ All checks passed (formatting was auto-fixed)") + "\n", + ); +} + +process.exit(finalFailed ? 1 : 0); diff --git a/scripts/db.ts b/scripts/db.ts new file mode 100644 index 0000000..506a1c1 --- /dev/null +++ b/scripts/db.ts @@ -0,0 +1,79 @@ +/** + * PostgreSQL Docker container management. + * + * Usage: bun scripts/db.ts [start|reset|rm] + */ + +import { readFile, writeFile } from "fs/promises"; +import { spawnSync } from "child_process"; + +const NAME = "banner-postgres"; +const USER = "banner"; +const PASS = "banner"; +const DB = "banner"; +const PORT = "59489"; +const ENV_FILE = ".env"; + +const cmd = process.argv[2] || "start"; + +function docker(...args: string[]) { + return spawnSync("docker", args, { encoding: "utf8" }); +} + +function getContainer() { + const res = docker("ps", "-a", "--filter", `name=^${NAME}$`, "--format", "json"); + return res.stdout.trim() ? JSON.parse(res.stdout) : null; +} + +async function updateEnv() { + const url = `postgresql://${USER}:${PASS}@localhost:${PORT}/${DB}`; + try { + let content = await readFile(ENV_FILE, "utf8"); + content = content.includes("DATABASE_URL=") + ? content.replace(/DATABASE_URL=.*$/m, `DATABASE_URL=${url}`) + : content.trim() + `\nDATABASE_URL=${url}\n`; + await writeFile(ENV_FILE, content); + } catch { + await writeFile(ENV_FILE, `DATABASE_URL=${url}\n`); + } +} + +function create() { + docker( + "run", "-d", "--name", NAME, + "-e", `POSTGRES_USER=${USER}`, + "-e", `POSTGRES_PASSWORD=${PASS}`, + "-e", `POSTGRES_DB=${DB}`, + "-p", `${PORT}:5432`, + "postgres:17-alpine", + ); + console.log("created"); +} + +const container = getContainer(); + +if (cmd === "rm") { + if (!container) process.exit(0); + docker("stop", NAME); + docker("rm", NAME); + console.log("removed"); +} else if (cmd === "reset") { + if (!container) { + create(); + } else { + docker("exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `DROP DATABASE IF EXISTS ${DB}`); + docker("exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `CREATE DATABASE ${DB}`); + console.log("reset"); + } + await updateEnv(); +} else { + if (!container) { + create(); + } else if (container.State !== "running") { + docker("start", NAME); + console.log("started"); + } else { + console.log("running"); + } + await updateEnv(); +} diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..5890e67 --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,112 @@ +/** + * Dev server orchestrator. + * + * Usage: bun scripts/dev.ts [flags] [-- passthrough-args] + * + * Flags: + * -f, --frontend-only Frontend only (Vite dev server) + * -b, --backend-only Backend only (bacon watch) + * -W, --no-watch Build once + run (no watch) + * -n, --no-build Run last compiled binary (no rebuild) + * -r, --release Use release profile + * -e, --embed Embed assets (implies -b) + * --tracing Tracing format (default: pretty) + */ + +import { existsSync } from "fs"; +import { parseFlags, c } from "./lib/fmt"; +import { run, ProcessGroup } from "./lib/proc"; + +const { flags, passthrough } = parseFlags( + process.argv.slice(2), + { + "frontend-only": "bool", + "backend-only": "bool", + "no-watch": "bool", + "no-build": "bool", + release: "bool", + embed: "bool", + tracing: "string", + } as const, + { f: "frontend-only", b: "backend-only", W: "no-watch", n: "no-build", r: "release", e: "embed" }, + { + "frontend-only": false, + "backend-only": false, + "no-watch": false, + "no-build": false, + release: false, + embed: false, + tracing: "pretty", + }, +); + +let frontendOnly = flags["frontend-only"]; +let backendOnly = flags["backend-only"]; +let noWatch = flags["no-watch"]; +const noBuild = flags["no-build"]; +const release = flags.release; +const embed = flags.embed; +const tracing = flags.tracing as string; + +// -e implies -b +if (embed) backendOnly = true; +// -n implies -W +if (noBuild) noWatch = true; + +if (frontendOnly && backendOnly) { + console.error("Cannot use -f and -b together (or -e implies -b)"); + process.exit(1); +} + +const runFrontend = !backendOnly; +const runBackend = !frontendOnly; +const profile = release ? "release" : "dev"; +const profileDir = release ? "release" : "debug"; +const group = new ProcessGroup(); + +// Build frontend first when embedding assets +if (embed && !noBuild) { + console.log(c("1;36", "→ Building frontend (for embedding)...")); + run(["bun", "run", "--cwd", "web", "build"]); +} + +// Frontend: Vite dev server +if (runFrontend) { + group.spawn(["bun", "run", "--cwd", "web", "dev"]); +} + +// Backend +if (runBackend) { + const backendArgs = ["--tracing", tracing, ...passthrough]; + const bin = `target/${profileDir}/banner`; + + if (noWatch) { + if (!noBuild) { + console.log(c("1;36", `→ Building backend (${profile})...`)); + const cargoArgs = ["cargo", "build", "--bin", "banner"]; + if (!embed) cargoArgs.push("--no-default-features"); + if (release) cargoArgs.push("--release"); + run(cargoArgs); + } + + if (!existsSync(bin)) { + console.error(`Binary not found: ${bin}`); + console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`); + await group.killAll(); + process.exit(1); + } + + console.log(c("1;36", `→ Running ${bin} (no watch)`)); + group.spawn([bin, ...backendArgs]); + } else { + // Bacon watch mode + const baconArgs = ["bacon", "--headless", "run", "--"]; + if (!embed) baconArgs.push("--no-default-features"); + if (release) baconArgs.push("--profile", "release"); + baconArgs.push("--", ...backendArgs); + group.spawn(baconArgs); + } +} + +const code = await group.waitForFirst(); +process.exit(code); diff --git a/scripts/lib/fmt.ts b/scripts/lib/fmt.ts new file mode 100644 index 0000000..0666027 --- /dev/null +++ b/scripts/lib/fmt.ts @@ -0,0 +1,96 @@ +/** + * Shared formatting, color, and CLI argument parsing utilities. + */ + +const isTTY = process.stdout.isTTY ?? false; +const isStderrTTY = process.stderr.isTTY ?? false; + +/** ANSI color wrapper — no-op when stdout is not a TTY. */ +export function c(code: string, text: string): string { + return isTTY ? `\x1b[${code}m${text}\x1b[0m` : text; +} + +/** Elapsed seconds since `start` as a formatted string. */ +export function elapsed(start: number): string { + return ((Date.now() - start) / 1000).toFixed(1); +} + +/** Whether stderr is a TTY (for progress spinners). */ +export { isStderrTTY }; + +/** + * Parse short and long CLI flags from a flat argument array. + * + * `spec` maps flag names to their type: + * - `"bool"` — presence sets the value to `true` + * - `"string"` — consumes the next argument as the value + * + * Short flags can be combined: `-fbW` expands to `-f -b -W`. + * Long flags: `--frontend-only`, `--tracing pretty`. + * `--` terminates flag parsing; remaining args go to `passthrough`. + * + * Returns `{ flags, passthrough }`. + */ +export function parseFlags>( + argv: string[], + spec: T, + shortMap: Record, + defaults: { [K in keyof T]: T[K] extends "bool" ? boolean : string }, +): { flags: typeof defaults; passthrough: string[] } { + const flags = { ...defaults }; + const passthrough: string[] = []; + let i = 0; + + while (i < argv.length) { + const arg = argv[i]; + + if (arg === "--") { + passthrough.push(...argv.slice(i + 1)); + break; + } + + if (arg.startsWith("--")) { + const name = arg.slice(2); + if (!(name in spec)) { + console.error(`Unknown flag: ${arg}`); + process.exit(1); + } + if (spec[name] === "string") { + (flags as Record)[name] = argv[++i] || ""; + } else { + (flags as Record)[name] = true; + } + } else if (arg.startsWith("-") && arg.length > 1) { + for (const ch of arg.slice(1)) { + const mapped = shortMap[ch]; + if (!mapped) { + console.error(`Unknown flag: -${ch}`); + process.exit(1); + } + if (spec[mapped as string] === "string") { + (flags as Record)[mapped as string] = argv[++i] || ""; + } else { + (flags as Record)[mapped as string] = true; + } + } + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(1); + } + + i++; + } + + return { flags, passthrough }; +} + +/** + * Simple positional-or-keyword argument parser. + * Returns the first positional arg, or empty string. + */ +export function parseArgs(raw: string): string[] { + return raw + .trim() + .split(/\s+/) + .filter(Boolean); +} diff --git a/scripts/lib/proc.ts b/scripts/lib/proc.ts new file mode 100644 index 0000000..2b6e534 --- /dev/null +++ b/scripts/lib/proc.ts @@ -0,0 +1,113 @@ +/** + * Shared process spawning utilities for project scripts. + */ + +import { elapsed } from "./fmt"; + +export interface CollectResult { + stdout: string; + stderr: string; + exitCode: number; + elapsed: string; +} + +/** Sync spawn with inherited stdio. Exits process on failure. */ +export function run(cmd: string[]): void { + const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }); + if (proc.exitCode !== 0) process.exit(proc.exitCode); +} + +/** Sync spawn with piped stdio. Returns captured output. */ +export function runPiped(cmd: string[]): { exitCode: number; stdout: string; stderr: string } { + const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe" }); + return { + exitCode: proc.exitCode, + stdout: proc.stdout?.toString() ?? "", + stderr: proc.stderr?.toString() ?? "", + }; +} + +/** + * Async spawn that collects stdout/stderr. Returns a result object. + * Catches spawn failures (e.g. missing binary) instead of throwing. + */ +export async function spawnCollect(cmd: string[], startTime: number): Promise { + try { + const proc = Bun.spawn(cmd, { + env: { ...process.env, FORCE_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + return { stdout, stderr, exitCode: proc.exitCode, elapsed: elapsed(startTime) }; + } catch (err) { + return { stdout: "", stderr: String(err), exitCode: 1, elapsed: elapsed(startTime) }; + } +} + +/** + * Race all promises, yielding results in completion order via callback. + * Spawn failures become results, not unhandled rejections. + */ +export async function raceInOrder( + promises: Promise[], + fallbacks: T[], + onResult: (r: T & CollectResult) => void, +): Promise { + const tagged = promises.map((p, i) => + p + .then((r) => ({ i, r })) + .catch((err) => ({ + i, + r: { + ...fallbacks[i], + exitCode: 1, + stdout: "", + stderr: String(err), + elapsed: "?", + } as T & CollectResult, + })), + ); + for (let n = 0; n < promises.length; n++) { + const { i, r } = await Promise.race(tagged); + tagged[i] = new Promise(() => {}); // sentinel: never resolves + onResult(r); + } +} + +/** Spawn managed processes with coordinated cleanup on exit. */ +export class ProcessGroup { + private procs: ReturnType[] = []; + + constructor() { + const cleanup = async () => { + await this.killAll(); + process.exit(0); + }; + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + } + + spawn(cmd: string[]): ReturnType { + const proc = Bun.spawn(cmd, { stdio: ["inherit", "inherit", "inherit"] }); + this.procs.push(proc); + return proc; + } + + async killAll(): Promise { + for (const p of this.procs) p.kill(); + await Promise.all(this.procs.map((p) => p.exited)); + } + + /** Wait for any process to exit, kill the rest, return exit code. */ + async waitForFirst(): Promise { + const results = this.procs.map((p, i) => p.exited.then((code) => ({ i, code }))); + const first = await Promise.race(results); + await this.killAll(); + return first.code; + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..0a342b8 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "banner-scripts", + "private": true, + "type": "module", + "devDependencies": { + "@types/bun": "^1.3.8" + } +} diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 0000000..58f98de --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,20 @@ +/** + * Run project tests. + * + * Usage: bun scripts/test.ts [rust|web|] + */ + +import { run } from "./lib/proc"; + +const input = process.argv.slice(2).join(" ").trim(); + +if (input === "web") { + run(["bun", "run", "--cwd", "web", "test"]); +} else if (input === "rust") { + run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]); +} else if (input === "") { + run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]); + run(["bun", "run", "--cwd", "web", "test"]); +} else { + run(["cargo", "nextest", "run", ...input.split(/\s+/)]); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..2b2bbf3 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun-types"], + "paths": { + "#lib/*": ["./lib/*"] + } + }, + "include": ["**/*.ts"] +}