#!/usr/bin/env bun // free-router: run the freellmapi router under Bun on 127.0.0.1:$FREE_ROUTER_PORT // with its unified API key pinned to $FREE_ROUTER_API_KEY. Both come from // ~/.config/free-router/env (free-router.service loads it; the shells export it). // // Source: ~/.local/share/freellmapi (chezmoi external). Whenever its HEAD // moves, deps are reinstalled and the dashboard rebuilt before boot. // State: ~/.local/state/free-router (SQLite DB, generated ENCRYPTION_KEY). import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const home = homedir(); const src = join(process.env.XDG_DATA_HOME || join(home, '.local/share'), 'freellmapi'); const state = join(process.env.XDG_STATE_HOME || join(home, '.local/state'), 'free-router'); function die(msg) { console.error(`free-router: ${msg}`); process.exit(1); } const { FREE_ROUTER_PORT: port, FREE_ROUTER_API_KEY: apiKey } = process.env; if (!port || !apiKey) die('FREE_ROUTER_PORT and FREE_ROUTER_API_KEY must be set (see ~/.config/free-router/env)'); if (!existsSync(join(src, 'server/src/index.ts'))) die(`no freellmapi checkout at ${src} (run: chezmoi apply ${src})`); function run(cmd, cwd) { const { exitCode } = Bun.spawnSync(cmd, { cwd, stdio: ['ignore', 'inherit', 'inherit'] }); if (exitCode !== 0) die(`\`${cmd.join(' ')}\` failed in ${cwd} (exit ${exitCode})`); } mkdirSync(state, { recursive: true, mode: 0o700 }); // Deps + dashboard, once per upstream commit. --no-save keeps bun.lock out of // the checkout so chezmoi's --ff-only pulls never conflict. const head = Bun.spawnSync(['git', '-C', src, 'rev-parse', 'HEAD']).stdout.toString().trim(); const stampFile = join(state, 'built-head'); if (!existsSync(stampFile) || readFileSync(stampFile, 'utf8').trim() !== head) { run([process.execPath, 'install', '--no-save'], src); run([process.execPath, '--bun', 'run', 'build'], join(src, 'client')); writeFileSync(stampFile, `${head}\n`); } // Provider keys are AES-256-GCM encrypted with this; losing it orphans them. const keyFile = join(state, 'encryption-key'); if (!existsSync(keyFile)) writeFileSync(keyFile, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600 }); Object.assign(process.env, { NODE_ENV: 'production', HOST: '127.0.0.1', PORT: port, FREEAPI_DB_PATH: join(state, 'freeapi.db'), FREEAPI_DB_DIR_HARDENING: '1', // state dir belongs to this router alone ENCRYPTION_KEY: readFileSync(keyFile, 'utf8').trim(), }); // Bun refuses to load better-sqlite3 (oven-sh/bun#4290). Serve it from the // server's own node:sqlite adapter (its Android/Termux driver) instead; the // "module.exports" key is what require('better-sqlite3') returns. const { nodeSqliteFactory } = await import(join(src, 'server/src/db/node-sqlite.ts')); function Database(path) { return nodeSqliteFactory(path); } Bun.plugin({ name: 'better-sqlite3 -> node:sqlite', setup(build) { build.module('better-sqlite3', () => ({ loader: 'object', exports: { 'module.exports': Database } })); }, }); // Migrate, then pin the unified key before the server opens the DB itself and // starts listening, so no request ever sees the randomly generated one. const db = await import(join(src, 'server/src/db/index.ts')); db.initDb(); db.setSetting('unified_api_key', apiKey); db.getDb().close?.(); await import(join(src, 'server/src/index.ts'));