import { spawn, execSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, constants, } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { createRequire } from 'node:module'; // ─── Paths ────────────────────────────────────────────────────────────────── export const GATEWAY_HOME = resolve( process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'), ); export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid'); export const LOG_DIR = join(GATEWAY_HOME, 'logs'); export const LOG_FILE = join(LOG_DIR, 'gateway.log'); export const ENV_FILE = join(GATEWAY_HOME, '.env'); export const META_FILE = join(GATEWAY_HOME, 'meta.json'); // ─── Meta ─────────────────────────────────────────────────────────────────── export interface GatewayMeta { version: string; installedAt: string; entryPoint: string; adminToken?: string; host: string; port: number; } export function readMeta(): GatewayMeta | null { if (!existsSync(META_FILE)) return null; try { return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta; } catch { return null; } } export function writeMeta(meta: GatewayMeta): void { ensureDirs(); writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 }); } // ─── Directories ──────────────────────────────────────────────────────────── export function ensureDirs(): void { mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 }); mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 }); } // ─── PID management ───────────────────────────────────────────────────────── export function readPid(): number | null { if (!existsSync(PID_FILE)) return null; try { const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10); return Number.isNaN(pid) ? null : pid; } catch { return null; } } export function isRunning(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } export function getDaemonPid(): number | null { const pid = readPid(); if (pid === null) return null; return isRunning(pid) ? pid : null; } // ─── Entry point resolution ───────────────────────────────────────────────── export function resolveGatewayEntry(): string { // Check meta.json for custom entry point const meta = readMeta(); if (meta?.entryPoint && existsSync(meta.entryPoint)) { return meta.entryPoint; } // Try to resolve from globally installed @mosaicstack/gateway try { const req = createRequire(import.meta.url); const pkgPath = req.resolve('@mosaicstack/gateway/package.json'); const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js'); if (existsSync(mainEntry)) return mainEntry; } catch { // Not installed globally } throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.'); } // ─── Start / Stop / Health ────────────────────────────────────────────────── export function startDaemon(): number { const running = getDaemonPid(); if (running !== null) { throw new Error(`Gateway is already running (PID ${running.toString()})`); } ensureDirs(); const entryPoint = resolveGatewayEntry(); // Load env vars from gateway .env const env: Record = { ...process.env } as Record; if (existsSync(ENV_FILE)) { for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIdx = trimmed.indexOf('='); if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); } } const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND); const child = spawn('node', [entryPoint], { detached: true, stdio: ['ignore', logFd, logFd], env, cwd: GATEWAY_HOME, }); if (!child.pid) { throw new Error('Failed to spawn gateway process'); } writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 }); child.unref(); return child.pid; } export async function stopDaemon(timeoutMs = 10_000): Promise { const pid = getDaemonPid(); if (pid === null) { throw new Error('Gateway is not running'); } process.kill(pid, 'SIGTERM'); // Poll for exit const start = Date.now(); while (Date.now() - start < timeoutMs) { if (!isRunning(pid)) { cleanPidFile(); return; } await sleep(250); } // Force kill try { process.kill(pid, 'SIGKILL'); } catch { // Already dead } cleanPidFile(); } function cleanPidFile(): void { try { unlinkSync(PID_FILE); } catch { // Ignore } } export async function waitForHealth( host: string, port: number, timeoutMs = 30_000, ): Promise { const start = Date.now(); let delay = 500; while (Date.now() - start < timeoutMs) { try { const res = await fetch(`http://${host}:${port.toString()}/health`); if (res.ok) return true; } catch { // Not ready yet } await sleep(delay); delay = Math.min(delay * 1.5, 3000); } return false; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ─── npm install helper ───────────────────────────────────────────────────── const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/'; export function installGatewayPackage(): void { console.log('Installing @mosaicstack/gateway from Gitea registry...'); execSync(`npm install -g @mosaicstack/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, { stdio: 'inherit', timeout: 120_000, }); } export function uninstallGatewayPackage(): void { try { execSync('npm uninstall -g @mosaicstack/gateway', { stdio: 'inherit', timeout: 60_000, }); } catch { console.warn('Warning: npm uninstall may not have completed cleanly.'); } } export function getInstalledGatewayVersion(): string | null { try { const output = execSync('npm ls -g @mosaicstack/gateway --json --depth=0', { encoding: 'utf-8', timeout: 15_000, stdio: ['pipe', 'pipe', 'pipe'], }); const data = JSON.parse(output) as { dependencies?: { '@mosaicstack/gateway'?: { version?: string } }; }; return data.dependencies?.['@mosaicstack/gateway']?.version ?? null; } catch { return null; } }