diff --git a/run/cli/commands/stream/index.ts b/run/cli/commands/stream/index.ts new file mode 100644 index 0000000..fa6324b --- /dev/null +++ b/run/cli/commands/stream/index.ts @@ -0,0 +1,490 @@ +/** + * Streaming feature commands + * + * Standalone runner for the streaming companion stack (streaming API + SSO). + * Uses dedicated Docker compose files with LILITH_ENV-based volume separation. + * + * Commands: + * - stream Default to stream:prod + * - stream:dev Dev mode with watch + * - stream:prod Prod mode with compiled builds + * - stream:stop Stop all streaming services + * - stream:logs Follow Docker logs + */ + +import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { spawn, execFileSync, type ChildProcess } from 'node:child_process'; +import { colors } from '../../../utils/colors'; +import { Logger } from '../../../utils/logger'; +import { FeatureServiceRegistry } from '../../../core/feature-service-registry'; +import type { CommandContext, CommandResult } from '../@core'; + +const PLATFORM_ROOT = resolve(import.meta.dirname, '../../../../..'); +const STACK_NAME = 'streaming'; + +// ============================================================================= +// Docker Compose Paths +// ============================================================================= + +const COMPOSE_FILES = { + streaming: resolve(PLATFORM_ROOT, 'codebase/features/streaming/docker-compose.yml'), + sso: resolve(PLATFORM_ROOT, 'codebase/features/sso/docker-compose.yml'), +}; + +// Container names for health checks +const HEALTH_CONTAINERS = [ + 'lilith-streaming-postgres', + 'lilith-streaming-redis', + 'lilith-sso-postgres', + 'lilith-sso-redis', +]; + +// ============================================================================= +// Types +// ============================================================================= + +interface ServiceDef { + name: string; + stack: string; + port: number; + dir: string; + cmd: string; + args: string[]; + env?: Record; +} + +// ============================================================================= +// Service Definitions +// ============================================================================= + +function getServiceDefs(mode: 'dev' | 'prod'): ServiceDef[] { + return [ + { + name: 'sso/backend-api', + stack: STACK_NAME, + port: 4001, + dir: 'codebase/features/sso/backend-api', + cmd: 'bun', + args: mode === 'dev' ? ['run', 'start:dev'] : ['run', 'start:prod'], + }, + { + name: 'streaming/backend-api', + stack: STACK_NAME, + port: 3130, + dir: 'codebase/features/streaming/backend-api', + cmd: 'bun', + args: mode === 'dev' ? ['run', 'dev'] : ['run', 'start:prod'], + }, + ]; +} + +// ============================================================================= +// Output Helper +// ============================================================================= + +function print(text: string): void { + process.stdout.write(text + '\n'); +} + +// ============================================================================= +// Docker Infrastructure +// ============================================================================= + +function checkDocker(): boolean { + try { + execFileSync('docker', ['info'], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function startCompose(composePath: string, env: string, logger: Logger): void { + logger.info(`Starting containers from ${composePath.split('/').slice(-3).join('/')}...`); + execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], { + stdio: 'inherit', + env: { ...process.env, LILITH_ENV: env }, + }); +} + +function stopCompose(composePath: string): void { + try { + execFileSync('docker', ['compose', '-f', composePath, 'down'], { + stdio: 'inherit', + }); + } catch { + // Container might already be stopped + } +} + +async function waitForContainerHealth( + containerName: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const status = execFileSync( + 'docker', + ['inspect', '--format', '{{.State.Health.Status}}', containerName], + { encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (status === 'healthy') return true; + } catch { + // Container might not exist yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + return false; +} + +async function ensureStreamingInfra(env: string, logger: Logger): Promise { + if (!checkDocker()) { + logger.error('Docker is not running — start Docker first'); + process.exit(1); + } + + startCompose(COMPOSE_FILES.sso, env, logger); + startCompose(COMPOSE_FILES.streaming, env, logger); + + logger.info('Waiting for containers to be healthy (30s timeout)...'); + + const results = await Promise.all( + HEALTH_CONTAINERS.map(async (name) => { + const healthy = await waitForContainerHealth(name, 30_000); + if (healthy) { + logger.success(`${name} healthy`); + } else { + logger.error(`${name} did not become healthy within 30s`); + } + return healthy; + }), + ); + + if (results.some((ok) => !ok)) { + logger.error('Not all containers are healthy — aborting'); + process.exit(1); + } + + logger.success('All Docker containers healthy'); +} + +// ============================================================================= +// Migrations +// ============================================================================= + +function runStreamingMigrations(logger: Logger): void { + const migrationDir = resolve(PLATFORM_ROOT, 'codebase/features/streaming/backend-api'); + logger.info('Running streaming migrations...'); + try { + execFileSync('bun', ['run', 'migration:run'], { + cwd: migrationDir, + stdio: 'inherit', + }); + logger.success('Migrations complete'); + } catch (error) { + logger.error('Migration failed', error instanceof Error ? error : new Error(String(error))); + process.exit(1); + } +} + +// ============================================================================= +// Build (prod only) +// ============================================================================= + +function buildServices(logger: Logger): void { + const dirs = [ + { name: 'sso', path: resolve(PLATFORM_ROOT, 'codebase/features/sso/backend-api') }, + { name: 'streaming', path: resolve(PLATFORM_ROOT, 'codebase/features/streaming/backend-api') }, + ]; + + for (const { name, path } of dirs) { + logger.info(`Building ${name}...`); + execFileSync('bun', ['run', 'build'], { cwd: path, stdio: 'inherit' }); + logger.success(`${name} built`); + } +} + +// ============================================================================= +// Service Spawner +// ============================================================================= + +function spawnService(svc: ServiceDef, registry: FeatureServiceRegistry): ChildProcess { + const cwd = resolve(PLATFORM_ROOT, svc.dir); + + if (!existsSync(cwd)) { + print(colors.error(` ${colors.symbols.error} Directory not found: ${cwd}`)); + process.exit(1); + } + + if (registry.isRunning(svc.name, svc.port)) { + print(colors.muted(` → ${svc.name} already running on port ${svc.port}, skipping`)); + return spawn('true', [], { stdio: 'ignore' }); + } + + const child = spawn(svc.cmd, svc.args, { + cwd, + stdio: 'inherit', + env: { ...process.env, ...svc.env }, + }); + + if (child.pid !== undefined) { + registry.register({ + name: svc.name, + stack: svc.stack, + port: svc.port, + pid: child.pid, + startedAt: Date.now(), + }); + } + + child.on('exit', () => { + registry.unregister(svc.name); + }); + + return child; +} + +// ============================================================================= +// Banner +// ============================================================================= + +function printStreamingBanner(mode: 'dev' | 'prod'): void { + const W = 58; + const line = '═'.repeat(W); + const pad = (s: string, w = W) => s + ' '.repeat(Math.max(0, w - s.length)); + + const title = mode === 'dev' + ? 'Streaming Companion — Dev Mode' + : 'Streaming Companion — Prod Mode'; + + print(''); + print(colors.primary(` ╔${line}╗`)); + print(colors.primary(` ║${' '.repeat(W)}║`)); + print( + colors.primary(' ║') + + colors.primary.bold(` ${pad(title, W - 3)}`) + + colors.primary('║'), + ); + print(colors.primary(` ║${' '.repeat(W)}║`)); + + // Backend services + const backends = [ + { name: 'sso/backend-api', port: 4001 }, + { name: 'streaming/backend-api', port: 3130 }, + ]; + + for (const { name, port } of backends) { + const label = ` ► ${name}`; + const portStr = `port ${port}`; + const p = Math.max(0, W - label.length - portStr.length - 1); + print( + colors.primary(' ║') + + `${label}${' '.repeat(p)}${colors.muted(portStr)}` + + colors.primary(' ║'), + ); + } + + print(colors.primary(` ║${' '.repeat(W)}║`)); + + // Docker services + const docker = [ + { name: 'streaming-postgres', port: 25468 }, + { name: 'streaming-redis', port: 26398 }, + { name: 'sso-postgres', port: 25440 }, + { name: 'sso-redis', port: 26386 }, + ]; + + print( + colors.primary(' ║') + + colors.muted(pad(' Docker:', W)) + + colors.primary('║'), + ); + + for (const { name, port } of docker) { + const label = ` ► ${name}`; + const portStr = `port ${port}`; + const p = Math.max(0, W - label.length - portStr.length - 1); + print( + colors.primary(' ║') + + `${colors.muted(label)}${' '.repeat(p)}${colors.muted(portStr)}` + + colors.primary(' ║'), + ); + } + + print(colors.primary(` ║${' '.repeat(W)}║`)); + + // URLs + const urls = [ + { label: 'Streaming API', url: 'http://localhost:3130' }, + { label: 'SSO API', url: 'http://localhost:4001' }, + ]; + + if (mode === 'dev') { + urls.push({ label: 'Swagger', url: 'http://localhost:3130/api/docs' }); + } + + for (const { label, url } of urls) { + const text = ` ${label}: ${url}`; + print( + colors.primary(' ║') + + colors.primary.bold(pad(text, W)) + + colors.primary('║'), + ); + } + + print(colors.primary(` ║${' '.repeat(W)}║`)); + print(colors.primary(` ╚${line}╝`)); + print(''); +} + +// ============================================================================= +// Main Runner +// ============================================================================= + +async function runStreaming(mode: 'dev' | 'prod'): Promise { + const logger = new Logger({ context: 'Streaming' }); + const registry = new FeatureServiceRegistry(); + const env = mode === 'dev' ? 'dev' : 'prod'; + + logger.info(`Starting streaming stack in ${mode} mode (LILITH_ENV=${env})`); + + // Step 1: Docker infrastructure + await ensureStreamingInfra(env, logger); + + // Step 2: Migrations + runStreamingMigrations(logger); + + // Step 3: Build (prod only) + if (mode === 'prod') { + buildServices(logger); + } + + // Step 4: Spawn services + const services = getServiceDefs(mode); + const processes: ChildProcess[] = []; + let exiting = false; + + function cleanup(): void { + if (exiting) return; + exiting = true; + print(''); + logger.info('Stopping all services...'); + for (const child of processes) { + child.kill('SIGTERM'); + } + registry.unregisterStack(STACK_NAME); + } + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start SSO first, then streaming with 2s stagger + for (let i = 0; i < services.length; i++) { + if (i > 0) await new Promise((r) => setTimeout(r, 2000)); + + const svc = services[i]; + const child = spawnService(svc, registry); + processes.push(child); + + child.on('exit', (code) => { + if (!exiting) { + print( + colors.warning(` ${colors.symbols.warning} ${svc.name} exited (code ${code})`), + ); + } + }); + } + + printStreamingBanner(mode); + + // Keep alive until the last service exits or Ctrl+C + return new Promise((resolvePromise) => { + const lastService = processes[processes.length - 1]; + lastService.on('exit', (code) => { + cleanup(); + resolvePromise({ code: code ?? 0 }); + }); + lastService.on('error', (err) => { + cleanup(); + resolvePromise({ code: 1, error: err.message }); + }); + }); +} + +// ============================================================================= +// Stop +// ============================================================================= + +async function stopStreaming(): Promise { + const logger = new Logger({ context: 'Streaming' }); + const registry = new FeatureServiceRegistry(); + + // Kill registered backend processes + const running = registry.getStack(STACK_NAME); + if (running.length > 0) { + logger.info(`Stopping ${running.length} backend process(es)...`); + for (const svc of running) { + try { + process.kill(svc.pid, 'SIGTERM'); + logger.success(`Stopped ${svc.name} (PID ${svc.pid})`); + } catch { + logger.warn(`${svc.name} (PID ${svc.pid}) already stopped`); + } + } + registry.unregisterStack(STACK_NAME); + } else { + logger.info('No tracked backend processes'); + } + + // Stop Docker containers + logger.info('Stopping Docker containers...'); + stopCompose(COMPOSE_FILES.streaming); + stopCompose(COMPOSE_FILES.sso); + logger.success('All streaming services stopped'); + + return { code: 0 }; +} + +// ============================================================================= +// Logs +// ============================================================================= + +async function followStreamLogs(): Promise { + const logger = new Logger({ context: 'Streaming' }); + + if (!checkDocker()) { + logger.error('Docker is not running'); + return { code: 1 }; + } + + // Follow logs from both compose files + const logProcesses: ChildProcess[] = []; + + for (const composePath of Object.values(COMPOSE_FILES)) { + const child = spawn('docker', ['compose', '-f', composePath, 'logs', '-f', '--tail=50'], { + stdio: 'inherit', + }); + logProcesses.push(child); + } + + return new Promise((resolvePromise) => { + process.on('SIGINT', () => { + for (const child of logProcesses) { + child.kill('SIGTERM'); + } + resolvePromise({ code: 0 }); + }); + }); +} + +// ============================================================================= +// Exported Command Handlers +// ============================================================================= + +export const stream = (_ctx: CommandContext) => runStreaming('prod'); +export const streamDev = (_ctx: CommandContext) => runStreaming('dev'); +export const streamProd = (_ctx: CommandContext) => runStreaming('prod'); +export const streamStop = (_ctx: CommandContext) => stopStreaming(); +export const streamLogs = (_ctx: CommandContext) => followStreamLogs(); diff --git a/run/cli/index.ts b/run/cli/index.ts index 9081716..1cb425e 100644 --- a/run/cli/index.ts +++ b/run/cli/index.ts @@ -126,6 +126,13 @@ const lazyCommands: Record = { 'mock:profile': ['./commands/mock/index', 'mockProfile'], 'mock:profile-assistant': ['./commands/mock/index', 'mockProfileAssistant'], 'mock:list': ['./commands/mock/index', 'mockList'], + + // Streaming feature (standalone stack) + 'stream': ['./commands/stream/index', 'stream'], + 'stream:dev': ['./commands/stream/index', 'streamDev'], + 'stream:prod': ['./commands/stream/index', 'streamProd'], + 'stream:stop': ['./commands/stream/index', 'streamStop'], + 'stream:logs': ['./commands/stream/index', 'streamLogs'], }; /** @@ -228,6 +235,16 @@ ${colors.accent('Mock Development (No Docker):')} mock:profile-assistant Start profile + AI assistant with MSW mocks (port 5130) mock:list List available mock targets +${colors.accent('Streaming Feature (Standalone Stack):')} + stream Start streaming stack in prod mode (default) + stream:dev Dev mode: watch mode, dev DB volumes + stream:prod Prod mode: compiled builds, prod DB volumes + stream:stop Stop all streaming containers + backend processes + stream:logs Follow Docker container logs (streaming + SSO) + Services: SSO (4001), Streaming API (3130) + Docker: streaming-postgres (25468), streaming-redis (26398), + sso-postgres (25440), sso-redis (26386) + ${colors.accent('Production Commands:')} prod [group] Start production cluster (real domains, SSL) Default group: platform (same group resolution as dev)