feat: mosaic gateway CLI daemon management + admin token auth (#369)
This commit was merged in pull request #369.
This commit is contained in:
253
packages/cli/src/commands/gateway/daemon.ts
Normal file
253
packages/cli/src/commands/gateway/daemon.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user