feat: mosaic gateway CLI daemon management + admin token auth (#369)
This commit was merged in pull request #369.
This commit is contained in:
@@ -1,198 +1,152 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
DEFAULT_LOCAL_CONFIG,
|
||||
DEFAULT_TEAM_CONFIG,
|
||||
loadConfig,
|
||||
type MosaicConfig,
|
||||
type StorageTier,
|
||||
} from '@mosaic/config';
|
||||
getDaemonPid,
|
||||
readMeta,
|
||||
startDaemon,
|
||||
stopDaemon,
|
||||
waitForHealth,
|
||||
} from './gateway/daemon.js';
|
||||
|
||||
function ask(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((res) => rl.question(question, res));
|
||||
interface GatewayParentOpts {
|
||||
host: string;
|
||||
port: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
async function runInit(opts: { tier?: string; output: string }): Promise<void> {
|
||||
const outputPath = resolve(opts.output);
|
||||
let tier: StorageTier;
|
||||
|
||||
if (opts.tier) {
|
||||
if (opts.tier !== 'local' && opts.tier !== 'team') {
|
||||
console.error(`Invalid tier "${opts.tier}" — expected "local" or "team"`);
|
||||
process.exit(1);
|
||||
}
|
||||
tier = opts.tier;
|
||||
} else {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await ask(rl, 'Select tier (local/team) [local]: ');
|
||||
rl.close();
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
tier = trimmed === 'team' ? 'team' : 'local';
|
||||
}
|
||||
|
||||
let config: MosaicConfig;
|
||||
|
||||
if (tier === 'local') {
|
||||
config = DEFAULT_LOCAL_CONFIG;
|
||||
} else {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const dbUrl = await ask(
|
||||
rl,
|
||||
'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5432/mosaic]: ',
|
||||
);
|
||||
const valkeyUrl = await ask(rl, 'VALKEY_URL [redis://localhost:6379]: ');
|
||||
rl.close();
|
||||
|
||||
config = {
|
||||
...DEFAULT_TEAM_CONFIG,
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: dbUrl.trim() || 'postgresql://mosaic:mosaic@localhost:5432/mosaic',
|
||||
},
|
||||
queue: {
|
||||
type: 'bullmq',
|
||||
url: valkeyUrl.trim() || 'redis://localhost:6379',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(config, null, 2) + '\n');
|
||||
console.log(`\nWrote ${outputPath}`);
|
||||
console.log('\nNext steps:');
|
||||
console.log(' 1. Review the generated config');
|
||||
console.log(' 2. Run: pnpm --filter @mosaic/gateway exec tsx src/main.ts');
|
||||
}
|
||||
|
||||
const PID_FILE = resolve(process.cwd(), '.mosaic/gateway.pid');
|
||||
|
||||
function writePidFile(pid: number): void {
|
||||
const dir = dirname(PID_FILE);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(PID_FILE, String(pid));
|
||||
}
|
||||
|
||||
function readPidFile(): number | null {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
const raw = readFileSync(PID_FILE, 'utf-8').trim();
|
||||
const pid = Number(raw);
|
||||
return Number.isFinite(pid) ? pid : null;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function printConfigSummary(config: MosaicConfig): void {
|
||||
console.log(` Tier: ${config.tier}`);
|
||||
console.log(` Storage: ${config.storage.type}`);
|
||||
console.log(` Queue: ${config.queue.type}`);
|
||||
console.log(` Memory: ${config.memory.type}`);
|
||||
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
|
||||
const meta = readMeta();
|
||||
return {
|
||||
host: raw.host ?? meta?.host ?? 'localhost',
|
||||
port: parseInt(raw.port, 10) || meta?.port || 4000,
|
||||
token: raw.token ?? meta?.adminToken,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGatewayCommand(program: Command): void {
|
||||
const gateway = program.command('gateway').description('Gateway management commands');
|
||||
|
||||
gateway
|
||||
.command('init')
|
||||
.description('Generate a mosaic.config.json for the gateway')
|
||||
.option('--tier <tier>', 'Storage tier: local or team (skips interactive prompt)')
|
||||
.option('--output <path>', 'Output file path', './mosaic.config.json')
|
||||
.action(async (opts: { tier?: string; output: string }) => {
|
||||
await runInit(opts);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command('start')
|
||||
.description('Start the Mosaic gateway process')
|
||||
.option('--port <port>', 'Port to listen on (overrides config)')
|
||||
.option('--daemon', 'Run in background and write PID to .mosaic/gateway.pid')
|
||||
.action((opts: { port?: string; daemon?: boolean }) => {
|
||||
const config = loadConfig();
|
||||
const port = opts.port ?? '4000';
|
||||
|
||||
console.log('Starting gateway…');
|
||||
printConfigSummary(config);
|
||||
console.log(` Port: ${port}`);
|
||||
|
||||
const entryPoint = resolve(process.cwd(), 'apps/gateway/src/main.ts');
|
||||
const env = { ...process.env, GATEWAY_PORT: port };
|
||||
|
||||
if (opts.daemon) {
|
||||
const child = spawn('npx', ['tsx', entryPoint], {
|
||||
env,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
if (child.pid) {
|
||||
writePidFile(child.pid);
|
||||
console.log(`\nGateway started in background (PID ${child.pid})`);
|
||||
console.log(`PID file: ${PID_FILE}`);
|
||||
}
|
||||
} else {
|
||||
const child = spawn('npx', ['tsx', entryPoint], {
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gateway
|
||||
.command('stop')
|
||||
.description('Stop the running gateway process')
|
||||
const gw = program
|
||||
.command('gateway')
|
||||
.description('Manage the Mosaic gateway daemon')
|
||||
.helpOption('--help', 'Display help')
|
||||
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
||||
.option('-p, --port <port>', 'Gateway port', '4000')
|
||||
.option('-t, --token <token>', 'Admin API token')
|
||||
.action(() => {
|
||||
const pid = readPidFile();
|
||||
gw.outputHelp();
|
||||
});
|
||||
|
||||
if (pid === null) {
|
||||
console.error('No PID file found at', PID_FILE);
|
||||
// ─── install ────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('install')
|
||||
.description('Install and configure the gateway daemon')
|
||||
.option('--skip-install', 'Skip npm package installation (use local build)')
|
||||
.action(async (cmdOpts: { skipInstall?: boolean }) => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const { runInstall } = await import('./gateway/install.js');
|
||||
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
|
||||
});
|
||||
|
||||
// ─── start ──────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('start')
|
||||
.description('Start the gateway daemon')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
try {
|
||||
const pid = startDaemon();
|
||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
||||
console.log('Waiting for health...');
|
||||
const healthy = await waitForHealth(opts.host, opts.port);
|
||||
if (healthy) {
|
||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||
} else {
|
||||
console.warn('Gateway started but health check timed out. Check logs.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
console.log(`Process ${pid} is not running. Removing stale PID file.`);
|
||||
unlinkSync(PID_FILE);
|
||||
return;
|
||||
}
|
||||
|
||||
process.kill(pid, 'SIGTERM');
|
||||
unlinkSync(PID_FILE);
|
||||
console.log(`Gateway stopped (PID ${pid})`);
|
||||
});
|
||||
|
||||
gateway
|
||||
.command('status')
|
||||
.description('Show gateway process status')
|
||||
.action(() => {
|
||||
const config = loadConfig();
|
||||
const pid = readPidFile();
|
||||
// ─── stop ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (pid !== null && isProcessRunning(pid)) {
|
||||
console.log('Gateway: running');
|
||||
console.log(` PID: ${pid}`);
|
||||
} else {
|
||||
console.log('Gateway: stopped');
|
||||
if (pid !== null) {
|
||||
console.log(` (stale PID file for ${pid})`);
|
||||
unlinkSync(PID_FILE);
|
||||
}
|
||||
gw.command('stop')
|
||||
.description('Stop the gateway daemon')
|
||||
.action(async () => {
|
||||
try {
|
||||
await stopDaemon();
|
||||
console.log('Gateway stopped.');
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('Config:');
|
||||
printConfigSummary(config);
|
||||
// ─── restart ────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('restart')
|
||||
.description('Restart the gateway daemon')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const pid = getDaemonPid();
|
||||
if (pid !== null) {
|
||||
console.log('Stopping gateway...');
|
||||
await stopDaemon();
|
||||
}
|
||||
console.log('Starting gateway...');
|
||||
try {
|
||||
const newPid = startDaemon();
|
||||
console.log(`Gateway started (PID ${newPid.toString()})`);
|
||||
const healthy = await waitForHealth(opts.host, opts.port);
|
||||
if (healthy) {
|
||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
||||
} else {
|
||||
console.warn('Gateway started but health check timed out. Check logs.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── status ─────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('status')
|
||||
.description('Show gateway daemon status and health')
|
||||
.action(async () => {
|
||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
||||
const { runStatus } = await import('./gateway/status.js');
|
||||
await runStatus(opts);
|
||||
});
|
||||
|
||||
// ─── config ─────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('config')
|
||||
.description('View or modify gateway configuration')
|
||||
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
||||
.option('--unset <KEY>', 'Remove a configuration key')
|
||||
.option('--edit', 'Open config in $EDITOR')
|
||||
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
|
||||
const { runConfig } = await import('./gateway/config.js');
|
||||
await runConfig(cmdOpts);
|
||||
});
|
||||
|
||||
// ─── logs ───────────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('logs')
|
||||
.description('View gateway daemon logs')
|
||||
.option('-f, --follow', 'Follow log output')
|
||||
.option('-n, --lines <count>', 'Number of lines to show', '50')
|
||||
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
|
||||
const { runLogs } = await import('./gateway/logs.js');
|
||||
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
||||
});
|
||||
|
||||
// ─── uninstall ──────────────────────────────────────────────────────────
|
||||
|
||||
gw.command('uninstall')
|
||||
.description('Uninstall the gateway daemon and optionally remove data')
|
||||
.action(async () => {
|
||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||
await runUninstall();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user