79 lines
3.4 KiB
TypeScript
Executable File
79 lines
3.4 KiB
TypeScript
Executable File
#!/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'));
|