import { join } from 'node:path'; import { db } from 'officerdb/db'; import { eq } from 'drizzle-orm'; import { fofosAdminSettings } from '../db/schema'; import { readDatabases } from './databases'; /** * The site's two servers — development and production — run under pm2. * * ── pm2 for BOTH, including development ── * * A `bun dev` started from a terminal dies with the terminal, and nobody can tell whether it is running * without finding the window it was started in. Under pm2 both are the same kind of thing: a named * process with a status, a log and an uptime, startable from here and inspectable from anywhere. The * cost is that development no longer prints to a console somebody is watching, which is what * `pm2 logs` is for. * * ── This plugin owns EXACTLY two process names, and touches nothing else ── * * `fofos-dev` and `fofos-prod`. Every command here is built from that pair and from nothing a caller * supplies, so there is no request that can stop, restart or delete another process on the machine. * That matters more than usual: this runs on a host where Officer's own processes and other people's * production services share one pm2 daemon, and a plugin that could name any of them would be a way to * take them down through a web form. * * ── Absolute paths for pm2 and bun ── * * The platform's PATH is whatever pm2 was started with, which is not a login shell's. `website.ts` * already learned this about bun; the same applies to pm2 itself. */ const PM2 = '/usr/local/bin/pm2'; export type ServerName = 'development' | 'production'; /** The one place these names exist. Nothing else in this module may build one. */ const PROCESS = { development: 'fofos-dev', production: 'fofos-prod' } as const; export type ServerStatus = { name: ServerName; /** The pm2 process name, so somebody can go and look at it with `pm2 logs`. */ process: string; port: number | null; /** `online`, `stopped`, `errored` — pm2's own word, or null when it has never been started. */ status: string | null; uptimeMs: number | null; restarts: number | null; /** How the site would be reached, once it is up. */ url: string | null; }; const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; const run = async ( argv: string[], timeoutMs = 60_000, env?: Record, ): Promise<{ ok: boolean; output: string }> => { /* pm2 hands the environment it was INVOKED with to the process it starts, so the way to give the site its port and its database is to put them in this command's own environment. There is no flag for it — `--env` names a block in an ecosystem file, not a variable. */ const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', ...(env ? { env: { ...process.env, ...env } } : {}), }); const timer = setTimeout(() => proc.kill(), timeoutMs); try { const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); return { ok: code === 0, output: `${stdout}${stderr}`.trim() }; } finally { clearTimeout(timer); } }; export function checkPort(value: unknown, field: string): number { const port = Number(value); if (!Number.isInteger(port) || port < 1024 || port > 65535) { throw new Error(`The ${field} port must be a whole number between 1024 and 65535.`); } return port; } /** What pm2 says about our two, and nothing about anybody else's. */ export async function readServers(): Promise { const row = await readRow(); const ports: Record = { development: row?.devPort ?? null, production: row?.prodPort ?? null, }; const listed = await run([PM2, 'jlist'], 20_000); let processes: Array<{ name?: string; pm2_env?: { status?: string; pm_uptime?: number; restart_time?: number } }> = []; if (listed.ok) { try { processes = JSON.parse(listed.output); } catch { // pm2 printed something that is not JSON — a daemon starting, usually. Treated as "no answer" // rather than as "not running", because those are different and only one of them is alarming. processes = []; } } return (['development', 'production'] as const).map((name) => { const found = processes.find((entry) => entry.name === PROCESS[name]); const port = ports[name]; return { name, process: PROCESS[name], port, status: found?.pm2_env?.status ?? null, uptimeMs: found?.pm2_env?.pm_uptime && found.pm2_env.status === 'online' ? Date.now() - found.pm2_env.pm_uptime : null, restarts: found?.pm2_env?.restart_time ?? null, url: port ? `http://127.0.0.1:${port}` : null, }; }); } export async function savePorts(dev: unknown, prod: unknown): Promise { const devPort = checkPort(dev, 'development'); const prodPort = checkPort(prod, 'production'); if (devPort === prodPort) throw new Error('The two servers cannot share a port.'); const row = await readRow(); if (row) { await db .update(fofosAdminSettings) .set({ devPort, prodPort, updatedAt: new Date() }) .where(eq(fofosAdminSettings.id, row.id)); } else { await db.insert(fofosAdminSettings).values({ devPort, prodPort }); } return readServers(); } /** * Start one of them. * * `pm2 delete` first, then `start`. Not `restart`: restart on a process that has never existed fails, * and restart on one that exists reuses the env it was started with — so a port or database changed * here would be saved, reported, and not actually in effect until somebody worked out why. * * The environment is assembled here rather than left to the checkout's `.env`, because the plugin is * what knows the ports and the database names. `src/env-file.ts` reads files first and the environment * last, so anything written down in the checkout still wins — this fills what is absent, it does not * override what somebody chose. */ export async function startServer(which: ServerName): Promise<{ ok: boolean; output: string }> { const row = await readRow(); if (!row?.rootPath) throw new Error('No root location is set.'); const port = which === 'development' ? row.devPort : row.prodPort; if (!port) throw new Error(`No ${which} port is set.`); const websiteDir = join(row.rootPath, 'website'); const databases = await readDatabases(); const target = which === 'development' ? databases.development : databases.production; if (!target.name || !target.exists) throw new Error(`The ${which} database does not exist yet.`); const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL; if (!raw) throw new Error('The platform has no Postgres connection configured.'); const url = new URL(raw); url.pathname = `/${target.name}`; const env: Record = { BUN_PORT: String(port), // Both names, because the site's guard compares them and one alone leaves the other falling back // to whatever the repository shipped with. Same reasoning as the schema push. ...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}), ...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}), ...(which === 'production' ? { POSTGRES_URL: url.toString() } : { POSTGRES_URL_DEV: url.toString() }), }; // Removed first so a stale process cannot survive with the old environment. Failure is expected and // ignored: the usual case is that it was never there. await run([PM2, 'delete', PROCESS[which]], 20_000); const started = await run( [ PM2, 'start', process.execPath, '--name', PROCESS[which], '--cwd', websiteDir, '--', 'run', which === 'development' ? 'dev' : 'start', ], 120_000, env, ); return { ok: started.ok, output: `$ pm2 start ${PROCESS[which]} (${which}, port ${port})\n${started.output}`, }; } /** Stop one. `delete`, not `stop`, so a stopped entry does not linger in the list looking like a fault. */ export async function stopServer(which: ServerName): Promise<{ ok: boolean; output: string }> { const result = await run([PM2, 'delete', PROCESS[which]], 30_000); return { ok: result.ok, output: `$ pm2 delete ${PROCESS[which]}\n${result.output}` }; }