#!/usr/bin/env node /** * Feature-focused development startup * * Starts services needed for a specific feature with all dependencies: * - Docker containers (PostgreSQL, Redis, etc.) * - Host services (APIs, frontends) * - SSO as a dependency for most features * * Usage: * pnpm dev:start # Start feature + deps (Docker + host) * pnpm dev:start --dry-run # Preview what would start * pnpm dev:start --list # Show all features * pnpm dev:start --stop # Stop services for feature * pnpm dev:start --no-docker # Skip Docker deps (host only) * * Examples: * pnpm dev:start marketplace # Start marketplace + SSO + Docker * pnpm dev:start seo --dry-run # Preview SEO startup plan * pnpm dev:start --list # List all available features */ import { exec, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { buildDeploymentRegistry, type ServiceRegistry } from '@lilith/service-registry'; import { buildStartupPlan, executeStartupPlan, stopOurServices, type StartupPlan, type StartupResult, } from '@lilith/service-orchestrator'; import { PATHS, REGISTRY_PATHS } from '../../configs/paths'; const execAsync = promisify(exec); const projectRoot = PATHS.root; // ============================================================================= // Types // ============================================================================= interface CliOptions { feature?: string; dryRun: boolean; list: boolean; stop: boolean; noDocker: boolean; help: boolean; } interface ServiceGroup { id: string; name: string; description: string; services: string[]; } // ============================================================================= // Constants // ============================================================================= const DOCKER_ONLY_TYPES = new Set([ 'postgresql', 'redis', 'minio', 'elasticsearch', 'meilisearch', 'rabbitmq', 'kafka', ]); const GPU_SERVICE_PATTERNS = [ 'cot-reasoning', 'rag-retrieval', 'classifier', 'imajin', 'ml-service', ]; // ANSI color codes const colors = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', gray: '\x1b[90m', }; // ============================================================================= // CLI Parsing // ============================================================================= function parseArgs(): CliOptions { const args = process.argv.slice(2); const options: CliOptions = { dryRun: false, list: false, stop: false, noDocker: false, help: false, }; for (const arg of args) { if (arg === '--dry-run') { options.dryRun = true; } else if (arg === '--list') { options.list = true; } else if (arg === '--stop') { options.stop = true; } else if (arg === '--no-docker') { options.noDocker = true; } else if (arg === '--help' || arg === '-h') { options.help = true; } else if (!arg.startsWith('-')) { options.feature = arg; } } return options; } function printHelp(): void { console.log(` ${colors.bold}Usage:${colors.reset} pnpm dev:start [options] ${colors.bold}Arguments:${colors.reset} feature Feature name (e.g., marketplace, seo, landing) ${colors.bold}Options:${colors.reset} --dry-run Show what would be started without starting --list List all available features --stop Stop services for the feature --no-docker Skip Docker dependencies (host services only) --help, -h Show this help message ${colors.bold}Examples:${colors.reset} pnpm dev:start marketplace # Start marketplace + deps (Docker + host) pnpm dev:start seo --dry-run # Preview what would start pnpm dev:start --list # Show all features pnpm dev:start marketplace --stop # Stop marketplace services `); } // ============================================================================= // Logging Helpers // ============================================================================= function info(message: string): void { console.log(`${colors.blue}[INFO]${colors.reset} ${message}`); } function success(message: string): void { console.log(`${colors.green}[OK]${colors.reset} ${message}`); } function warn(message: string): void { console.log(`${colors.yellow}[WARN]${colors.reset} ${message}`); } function error(message: string): void { console.log(`${colors.red}[ERROR]${colors.reset} ${message}`); } function section(title: string): void { console.log(); console.log(`${colors.cyan}━━━ ${title} ━━━${colors.reset}`); } // ============================================================================= // Service Groups (mirrors tooling/run/core/services.ts) // ============================================================================= function getServiceGroups(): ServiceGroup[] { // Deployment-centric service groups return [ // Shared services (deployments/shared-services/) { id: 'sso', name: 'SSO / Auth', description: 'Authentication and single sign-on', services: ['sso.postgresql', 'sso.redis', 'sso.api'], }, { id: 'merchant', name: 'Merchant', description: 'Merchant accounts and payouts', services: ['merchant.postgresql', 'merchant.redis', 'merchant.api'], }, { id: 'profile', name: 'Profile', description: 'User profiles and avatars', services: ['profile.postgresql', 'profile.api'], }, { id: 'seo', name: 'SEO Platform', description: 'SEO tools, ML pipelines, content optimization', services: [ 'seo.postgresql', 'seo.redis', 'seo.api', 'seo.ml-service', 'seo.cot-reasoning', 'seo.rag-retrieval', 'seo.classifier', 'seo.imajin', ], }, { id: 'messaging', name: 'Messaging', description: 'Real-time messaging and notifications', services: [ 'messaging.postgresql', 'messaging.redis', 'messaging.api', ], }, { id: 'media', name: 'Media', description: 'Media storage and processing', services: ['media.postgresql', 'media.minio', 'media.api'], }, // Deployments (deployments/@domains/) { id: 'trustedmeet', name: 'TrustedMeet Marketplace', description: 'TrustedMeet marketplace platform', services: [ 'trustedmeet.www.postgresql', 'trustedmeet.www.redis', 'trustedmeet.www.api', 'trustedmeet.www.frontend', ], }, { id: 'atlilith-www', name: 'Atlilith Landing', description: 'Atlilith landing page', services: [ 'atlilith.www.postgresql', 'atlilith.www.minio', 'atlilith.www.api', 'atlilith.www.frontend', ], }, { id: 'atlilith-status', name: 'Atlilith Status', description: 'Service status dashboard', services: ['atlilith.status.api', 'atlilith.status.frontend'], }, { id: 'atlilith-admin', name: 'Atlilith Admin', description: 'Admin dashboard and management', services: ['atlilith.admin.api', 'atlilith.admin.frontend'], }, { id: 'spoiledbabes', name: 'SpoiledBabes Marketplace', description: 'SpoiledBabes marketplace platform', services: [ 'spoiledbabes.www.postgresql', 'spoiledbabes.www.redis', 'spoiledbabes.www.api', 'spoiledbabes.www.frontend', ], }, ]; } function getServiceGroup(groupId: string): ServiceGroup | undefined { return getServiceGroups().find((g) => g.id === groupId); } /** * Get services for a group, including SSO as dependency */ function getServicesWithDeps(groupId: string): string[] { const group = getServiceGroup(groupId); if (!group) return []; const services = [...group.services]; // Add SSO as dependency for all features except SSO itself if (groupId !== 'sso') { const ssoGroup = getServiceGroup('sso'); if (ssoGroup) { for (const svc of ssoGroup.services) { if (!services.includes(svc)) { services.unshift(svc); // Prepend so SSO starts first } } } } return services; } // ============================================================================= // Feature Discovery (from registry) // ============================================================================= interface FeatureInfo { id: string; name: string; description: string; services: string[]; hasDocker: boolean; } function getRegistry(): ServiceRegistry { return buildDeploymentRegistry(REGISTRY_PATHS); } function discoverFeatures(): FeatureInfo[] { const registry = getRegistry(); const features: FeatureInfo[] = []; // Discover deployments from deployments/@domains/ for (const [deploymentId, deployment] of registry.features) { const deployDir = join(PATHS.domains, deploymentId); const hasDocker = existsSync(join(deployDir, 'docker-compose.yml')); features.push({ id: deploymentId, name: deployment.name || deploymentId, description: deployment.description || '', services: deployment.services.map((s) => s.id), hasDocker, }); } // Sort alphabetically features.sort((a, b) => a.id.localeCompare(b.id)); return features; } function listFeatures(): void { section('Available Features'); const features = discoverFeatures(); const groups = getServiceGroups(); // Show predefined groups console.log(); console.log(`${colors.bold}Predefined Groups:${colors.reset}`); console.log(); for (const group of groups) { const dockerServices = group.services.filter((s) => { const parts = s.split('.'); const type = parts[1] || ''; return DOCKER_ONLY_TYPES.has(type); }).length; const hostServices = group.services.length - dockerServices; console.log( ` ${colors.cyan}${group.id.padEnd(24)}${colors.reset} ` + `${colors.dim}Docker: ${dockerServices}, Host: ${hostServices}${colors.reset}` ); console.log(` ${colors.dim}${group.description}${colors.reset}`); } // Show discovered deployments console.log(); console.log(`${colors.bold}Discovered Deployments (from deployments/@domains):${colors.reset}`); console.log(); for (const feature of features) { const dockerIndicator = feature.hasDocker ? `${colors.green}+docker${colors.reset}` : ''; console.log( ` ${colors.cyan}${feature.id.padEnd(24)}${colors.reset} ` + `${colors.dim}${feature.services.length} services${colors.reset} ${dockerIndicator}` ); if (feature.description) { console.log(` ${colors.dim}${feature.description}${colors.reset}`); } } console.log(); console.log(`${colors.dim}Use: pnpm dev:start to start a feature${colors.reset}`); console.log(); } // ============================================================================= // Docker Operations // ============================================================================= async function checkDocker(): Promise { try { await execAsync('docker info', { timeout: 5000 }); return true; } catch { return false; } } async function startDockerCompose(deploymentId: string): Promise { let composeFile: string; // Handle infrastructure (shared services) vs deployment-specific compose files if (deploymentId === 'infrastructure') { composeFile = PATHS.composeFile; } else { composeFile = join(PATHS.domains, deploymentId, 'docker-compose.yml'); } if (!existsSync(composeFile)) { warn(`No docker-compose.yml for ${deploymentId}`); return; } info(`Starting Docker containers for ${deploymentId}...`); try { const { stdout, stderr } = await execAsync( `docker compose -f "${composeFile}" up -d`, { cwd: projectRoot, timeout: 120000, } ); if (stdout) console.log(stdout.trim()); if (stderr && !stderr.includes('Started') && !stderr.includes('Running')) { console.log(colors.dim + stderr.trim() + colors.reset); } success(`Docker containers started for ${deploymentId}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); error(`Failed to start Docker for ${deploymentId}: ${message}`); throw err; } } async function stopDockerCompose(deploymentId: string): Promise { let composeFile: string; if (deploymentId === 'infrastructure') { composeFile = PATHS.composeFile; } else { composeFile = join(PATHS.domains, deploymentId, 'docker-compose.yml'); } if (!existsSync(composeFile)) { return; } info(`Stopping Docker containers for ${deploymentId}...`); try { await execAsync(`docker compose -f "${composeFile}" down`, { cwd: projectRoot, timeout: 60000, }); success(`Docker containers stopped for ${deploymentId}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); warn(`Failed to stop Docker for ${deploymentId}: ${message}`); } } async function waitForDockerHealth(deploymentId: string, timeoutMs = 60000): Promise { let composeFile: string; if (deploymentId === 'infrastructure') { composeFile = PATHS.composeFile; } else { composeFile = join(PATHS.domains, deploymentId, 'docker-compose.yml'); } if (!existsSync(composeFile)) { return true; // No Docker = nothing to wait for } const start = Date.now(); const checkInterval = 2000; info(`Waiting for Docker containers to be healthy...`); while (Date.now() - start < timeoutMs) { try { const { stdout } = await execAsync( `docker compose -f "${composeFile}" ps --format json`, { cwd: projectRoot, timeout: 10000 } ); const containers: Array<{ Health?: string; State?: string }> = []; for (const line of stdout.trim().split('\n')) { if (!line) continue; try { containers.push(JSON.parse(line)); } catch { // Skip non-JSON lines } } if (containers.length === 0) { await sleep(checkInterval); continue; } const allHealthy = containers.every((c) => { const health = (c.Health || '').toLowerCase(); const state = (c.State || '').toLowerCase(); // Healthy, or no health check and running return health === 'healthy' || (!health.includes('starting') && state === 'running'); }); if (allHealthy) { success('Docker containers healthy'); return true; } } catch { // Ignore errors during health check } await sleep(checkInterval); } warn('Docker health check timed out'); return false; } // ============================================================================= // Host Service Operations // ============================================================================= function isGpuService(serviceId: string): boolean { return GPU_SERVICE_PATTERNS.some((pattern) => serviceId.includes(pattern)); } async function checkModelBossAvailable(): Promise { try { await execAsync('curl -sf http://localhost:8210/health', { timeout: 5000 }); return true; } catch { return false; } } async function startHostServices( featureId: string, serviceIds: string[], dryRun: boolean ): Promise { const registry = getRegistry(); // Check GPU availability for ML services const gpuAvailable = await checkModelBossAvailable(); if (!gpuAvailable) { const gpuServices = serviceIds.filter(isGpuService); if (gpuServices.length > 0) { warn(`@model-boss not available - GPU services will be skipped: ${gpuServices.join(', ')}`); } } // Filter services const servicesToStart = serviceIds.filter((serviceId) => { const service = registry.services.get(serviceId); if (!service) { return false; } // Skip Docker-only services (handled by docker compose) if (DOCKER_ONLY_TYPES.has(service.type)) { return false; } // Skip GPU services if not available if (!gpuAvailable && isGpuService(serviceId)) { return false; } // Skip services marked with devSkip if (service.devSkip) { return false; } return true; }); if (servicesToStart.length === 0) { info('No host services to start'); return null; } // Create virtual feature for the services we want to start const serviceObjects = servicesToStart .map((id) => registry.services.get(id)) .filter((s): s is NonNullable => s !== undefined); registry.features.set('dev-target', { id: 'dev-target', name: `Dev: ${featureId}`, description: `Development startup for ${featureId}`, services: serviceObjects, ports: {}, }); // Build startup plan const plan = buildStartupPlan(registry, 'dev-target', { includeSelf: true, includeInfrastructure: false, // Docker handles infra includeDevDependencies: true, }); // Filter plan to exclude Docker-only and devSkip services const filteredPhases = plan.phases .map((phase) => ({ ...phase, services: phase.services.filter( (s) => !DOCKER_ONLY_TYPES.has(s.type) && !s.devSkip ), })) .filter((phase) => phase.services.length > 0); const filteredPlan: StartupPlan = { ...plan, phases: filteredPhases, totalServices: filteredPhases.reduce((sum, p) => sum + p.services.length, 0), }; info(`Startup plan: ${filteredPlan.totalServices} host services in ${filteredPlan.phases.length} phases`); // Show plan details for (let i = 0; i < filteredPlan.phases.length; i++) { const phase = filteredPlan.phases[i]!; console.log( ` ${colors.dim}Phase ${i + 1}:${colors.reset} ` + phase.services.map((s) => s.id).join(', ') ); } if (dryRun) { info('Dry run - not starting services'); return null; } // Execute startup plan section('Starting Host Services'); const result = await executeStartupPlan(filteredPlan, { projectRoot, healthTimeoutMs: 300000, // 5 minutes continueOnFailure: false, onServiceStarted: (r) => { if (r.started) { success(`${r.serviceId} started (${r.durationMs}ms)`); } else if (r.alreadyRunning) { info(`${r.serviceId} already running`); } else if (r.error) { error(`${r.serviceId} failed: ${r.error}`); } }, onProgress: (p) => { if (p.currentService) { info(`Starting ${p.currentService}...`); } }, }); return result; } async function stopServices(featureId: string): Promise { section(`Stopping Services for ${featureId}`); // Stop deployment-specific Docker containers await stopDockerCompose(featureId); // Stop shared infrastructure (SSO, databases, etc.) await stopDockerCompose('infrastructure'); // Stop host services tracked by orchestrator info('Stopping host services...'); const result = await stopOurServices(); if (result.stopped.length > 0) { success(`Stopped ${result.stopped.length} host services`); for (const serviceId of result.stopped) { console.log(` ${colors.dim}- ${serviceId}${colors.reset}`); } } if (result.failed.length > 0) { warn(`Failed to stop ${result.failed.length} services`); for (const serviceId of result.failed) { console.log(` ${colors.red}- ${serviceId}${colors.reset}`); } } success('Services stopped'); } // ============================================================================= // Main Entry Point // ============================================================================= async function main(): Promise { const options = parseArgs(); if (options.help) { printHelp(); return; } if (options.list) { listFeatures(); return; } if (!options.feature) { error('Feature name required. Use --list to see available features.'); console.log(); printHelp(); process.exit(1); } const featureId = options.feature; // Handle stop command if (options.stop) { await stopServices(featureId); return; } // Resolve services for this feature const group = getServiceGroup(featureId); let serviceIds: string[]; if (group) { // Use predefined group serviceIds = getServicesWithDeps(featureId); info(`Using predefined group: ${group.name}`); } else { // Check if feature exists in registry const features = discoverFeatures(); const feature = features.find((f) => f.id === featureId); if (!feature) { error(`Unknown feature: ${featureId}`); console.log(`${colors.dim}Use --list to see available features${colors.reset}`); process.exit(1); } // Build service list from feature + SSO dependency serviceIds = [...feature.services]; if (featureId !== 'sso') { const ssoGroup = getServiceGroup('sso'); if (ssoGroup) { for (const svc of ssoGroup.services) { if (!serviceIds.includes(svc)) { serviceIds.unshift(svc); } } } } info(`Using discovered feature: ${feature.name}`); } section(`Starting ${featureId}`); info(`Services: ${serviceIds.length}`); // Categorize services const dockerServices = serviceIds.filter((s) => { const type = s.split('.')[1] || ''; return DOCKER_ONLY_TYPES.has(type); }); const hostServices = serviceIds.filter((s) => { const type = s.split('.')[1] || ''; return !DOCKER_ONLY_TYPES.has(type); }); console.log(` ${colors.dim}Docker: ${dockerServices.length} (${dockerServices.join(', ') || 'none'})${colors.reset}`); console.log(` ${colors.dim}Host: ${hostServices.length} (${hostServices.join(', ') || 'none'})${colors.reset}`); if (options.dryRun) { section('Dry Run - Would Start'); // Check Docker if (!options.noDocker && dockerServices.length > 0) { console.log(); console.log(`${colors.bold}Docker Containers:${colors.reset}`); console.log(` Feature: ${featureId}`); if (featureId !== 'sso') { console.log(` Feature: sso (dependency)`); } } // Show host services plan if (hostServices.length > 0) { await startHostServices(featureId, serviceIds, true); } console.log(); info('Dry run complete - no services started'); return; } // Start Docker containers if (!options.noDocker) { const dockerOk = await checkDocker(); if (!dockerOk) { error('Docker is not running. Start Docker first or use --no-docker flag.'); process.exit(1); } section('Starting Docker Containers'); // Start shared infrastructure (includes SSO, etc.) const infraComposeFile = PATHS.composeFile; if (existsSync(infraComposeFile)) { await startDockerCompose('infrastructure'); await waitForDockerHealth('infrastructure'); } // Start deployment-specific Docker if exists const deploymentComposeFile = join(PATHS.domains, featureId, 'docker-compose.yml'); if (existsSync(deploymentComposeFile)) { await startDockerCompose(featureId); await waitForDockerHealth(featureId); } } // Start host services if (hostServices.length > 0) { const result = await startHostServices(featureId, serviceIds, false); if (result && !result.success) { error('Some services failed to start'); process.exit(1); } } // Summary section('Summary'); success(`Feature ${featureId} is ready`); console.log(); console.log(`${colors.dim}Commands:${colors.reset}`); console.log(` ${colors.dim}pnpm dev:start ${featureId} --stop Stop these services${colors.reset}`); console.log(` ${colors.dim}pnpm services:status Check service health${colors.reset}`); console.log(); } // ============================================================================= // Utilities // ============================================================================= function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ============================================================================= // Entry Point // ============================================================================= // Suppress noisy warnings const originalWarn = console.warn; console.warn = (...args: unknown[]) => { const msg = args[0]; if (typeof msg === 'string') { if (msg.includes('[Nest]') && msg.includes('WARN')) return; if (msg.includes('DomainEventsEmitter')) return; if (msg.includes('ExperimentalWarning')) return; } originalWarn.apply(console, args); }; main().catch((err) => { error(err instanceof Error ? err.message : String(err)); process.exit(1); });