import { createRouter } from '../../create-router'; import { rebuildFrontend } from '../../plugins/generate'; import { isKnownToPm2, restartProcess } from '../../app-store/pm2'; import { fixEnvFile } from '../../env-prefix'; import { PLATFORM_DIR } from '../../data-path'; import { readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { readServerSettings, writeServerSettings } from 'officerdb'; import { chatProvidersRouter } from './chat-providers'; import { smtpRouter } from './smtp'; import { ttsRouter } from './tts'; import { ocrRouter } from './ocr'; export const serverSettingsRouter = createRouter(); serverSettingsRouter.route('/chat-providers', chatProvidersRouter); serverSettingsRouter.route('/smtp', smtpRouter); serverSettingsRouter.route('/tts', ttsRouter); serverSettingsRouter.route('/ocr', ocrRouter); export { readServerSettings as readSettings }; /** * Rebuild the SPA from source, without restarting anything. * * This is the same `rebuildFrontend` a plugin install runs, exposed as a button. It works — and it is * worth being precise about WHY, because its neighbour below does not: * * `Bun.build` reads the source from disk on every call and writes `build/`, and the server serves that * directory by reading each file per request. So a rebuild is visible on the next page load with no * process restart, no dropped websocket, and no chat session lost. * * Frontend changes only. Nothing here can pick up an edit to server code — see `/restart`. */ serverSettingsRouter.post('/rebuild-frontend', async (ctx) => { const result = await rebuildFrontend(); if (!result.ok) return ctx.json({ ok: false, error: result.error }, 500); return ctx.json({ ok: true, ms: result.ms, outputs: result.outputs }); }); /** * Pull the platform's own repository. * * The button that was missing between the two below: `/rebuild-frontend` compiles what is on disk and * `/restart` reloads it, but neither CHANGES what is on disk. Doing that meant a terminal, on every * server, every time. * * ── What it refuses, and why ── * * A dirty tree, and a non-fast-forward. Same two refusals as `pullPlugin`, for the same reason: this * checkout is somebody's working copy on at least one machine, and a button that discards uncommitted * work is worse than a button that does not exist. `--ff-only` so a divergence is reported rather than * merged by a machine. * * ── What it does NOT do ── * * It does not rebuild, restart, install dependencies or push the schema. Those are separate buttons and * separate decisions — `bun install` after a lockfile change is a supply-chain moment the frozen-lockfile * rule exists to make deliberate, and `db:push` alters a live database. So this REPORTS when the pull * touched `package.json`, `bun.lock` or a schema file, and leaves the choice with whoever pressed it. */ serverSettingsRouter.post('/pull', async (ctx) => { const git = async (args: string[]) => { const proc = Bun.spawn(['git', ...args], { cwd: PLATFORM_DIR, stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { code: await proc.exited, out: out.trim(), err: err.trim() }; }; const dirty = await git(['status', '--porcelain']); if (dirty.out) { const files = dirty.out.split('\n').length; return ctx.json( { ok: false, error: `${files} uncommitted change(s) — commit or discard them first, refusing to overwrite local work`, }, 400, ); } const before = (await git(['rev-parse', 'HEAD'])).out; // `origin HEAD`, not a branch name: a checkout made before the default branch was renamed is on one // the remote no longer has, and `git pull origin ` fails on exactly the installs that most // need updating. Learned in `plugins/update.ts`; the same trap applies here. const fetched = await git(['fetch', 'origin', 'HEAD']); if (fetched.code !== 0) return ctx.json({ ok: false, error: fetched.err || 'git fetch failed' }, 500); const merged = await git(['merge', '--ff-only', 'FETCH_HEAD']); if (merged.code !== 0) { return ctx.json( { ok: false, error: merged.err || merged.out || 'not a fast-forward — this checkout has diverged' }, 409, ); } const after = (await git(['rev-parse', 'HEAD'])).out; if (before === after) return ctx.json({ ok: true, updated: false, commits: [], needs: [] }); const log = await git(['log', '--oneline', '--no-decorate', `${before}..${after}`]); const changed = (await git(['diff', '--name-only', `${before}..${after}`])).out.split('\n'); // What the pull implies but must not do on its own. const needs: string[] = []; if (changed.some((f) => f === 'package.json' || f === 'bun.lock')) needs.push('bun install'); if (changed.some((f) => f.includes('/schema.ts') || f.endsWith('drizzle-schema.ts'))) needs.push('bun db:push'); if (changed.some((f) => f.startsWith('src/servers/') || f.startsWith('src/databases/'))) needs.push('restart'); else if (changed.some((f) => f.startsWith('src/'))) needs.push('rebuild-frontend'); return ctx.json({ ok: true, updated: true, from: before.slice(0, 7), to: after.slice(0, 7), commits: log.out ? log.out.split('\n') : [], files: changed.length, needs, }); }); /** * Restart the platform process. The only way to load changed SERVER code. * * ── Why there is no gentler option ── * * Bun caches modules for the life of the process. Measured rather than assumed: editing a file and * re-importing it returns the ORIGINAL export, and a cache-busting query picks up only that one * specifier while everything it imports stays cached. Rebuilding the Hono app — which install already * does — therefore re-wires the same module instances into a new router tree and cannot see an edit. * * Busting the whole graph would not be a fix either: singletons would be duplicated, giving two database * clients and two in-memory session maps in one process. * * So this is honest about what it costs. Websockets drop, in-memory chat sessions are lost, and the * browser must reconnect. It saves a terminal, not a reconnection. * * The response is sent BEFORE the restart is scheduled, or the caller would never learn it worked: the * process serving this request is the one about to be replaced. */ serverSettingsRouter.post('/restart', async (ctx) => { // Refuse when PM2 does not know this process, rather than reporting a restart that cannot happen. In // development the platform runs under `bun --watch`, where `pm2 restart officer` would exit non-zero // into a promise nobody awaits — and the button would say it worked. if (!(await isKnownToPm2('officer', PLATFORM_DIR))) { return ctx.json({ ok: false, error: 'not running under PM2 — restart it the way you started it' }, 400); } // Convert `.env` to the `OFFICERDEV_` prefix on the way past. // // Here rather than at boot, because a restart is the one moment the file can be rewritten and the // change take effect in the same breath — the process about to start is the one that reads it. Doing // it at boot would rewrite a file the running process had already loaded. // // Idempotent and non-fatal: an already-converted file is not even written, and a failure must not stop // a restart the owner asked for. Every consumer still reads the unprefixed name as a fallback, so the // worst case is that nothing changed. let envFixed: string[] = []; try { envFixed = fixEnvFile(join(PLATFORM_DIR, '.env')).changed; if (envFixed.length) console.log(`[env] prefixed on restart: ${envFixed.join(', ')}`); } catch (err) { console.error('[env] could not prefix .env —', err instanceof Error ? err.message : err); } setTimeout(() => { void restartProcess('officer', PLATFORM_DIR); }, 250); return ctx.json({ ok: true, restartingIn: 250, envFixed }); }); serverSettingsRouter.get('/settings', async (ctx) => { return ctx.json(await readServerSettings()); }); serverSettingsRouter.put('/', async (ctx) => { const body = await ctx.req.json(); const settings = await readServerSettings(); const updated = { ...settings, ...body }; await writeServerSettings(updated); return ctx.json(updated); }); serverSettingsRouter.get('/plugins', async (ctx) => { const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins'); const settings = await readServerSettings(); const pluginSettings: Record = (settings.plugins as Record) ?? {}; const plugins: { id: string; name: string; description: string; enabled: boolean }[] = []; if (!existsSync(pluginsDir)) return ctx.json(plugins); const dirs = readdirSync(pluginsDir, { withFileTypes: true }).filter((d) => d.isDirectory()); for (const dir of dirs) { const hasServer = existsSync(join(pluginsDir, dir.name, 'server', 'index.ts')); const hasClient = existsSync(join(pluginsDir, dir.name, 'client', 'index.ts')); if (!hasServer && !hasClient) continue; const mainIndex = join(pluginsDir, dir.name, 'index.ts'); if (!existsSync(mainIndex)) continue; const mod = await import(mainIndex); const meta = mod.plugin ?? { id: dir.name, name: dir.name, description: '' }; plugins.push({ id: meta.id, name: meta.name, description: meta.description, enabled: pluginSettings[meta.id] !== false, }); } return ctx.json(plugins); });