254 lines
7.4 KiB
TypeScript
254 lines
7.4 KiB
TypeScript
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 via @mosaicstack
|
|
}
|
|
|
|
// Try @mosaic/gateway (workspace / dev)
|
|
try {
|
|
const req = createRequire(import.meta.url);
|
|
const pkgPath = req.resolve('@mosaic/gateway/package.json');
|
|
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
|
|
if (existsSync(mainEntry)) return mainEntry;
|
|
} catch {
|
|
// Not available
|
|
}
|
|
|
|
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<string, string> = { ...process.env } as Record<string, string>;
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// ─── npm install helper ─────────────────────────────────────────────────────
|
|
|
|
export function installGatewayPackage(): void {
|
|
console.log('Installing @mosaicstack/gateway...');
|
|
execSync('npm install -g @mosaicstack/gateway@latest', {
|
|
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;
|
|
}
|
|
}
|