diff --git a/scripts/services/service-dev.ts b/scripts/services/service-dev.ts new file mode 100644 index 00000000..2b5c6edf --- /dev/null +++ b/scripts/services/service-dev.ts @@ -0,0 +1,931 @@ +#!/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, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { initServiceRegistry } from '@lilith/service-registry'; +import { + buildStartupPlan, + executeStartupPlan, + stopOurServices, + type StartupPlan, + type StartupResult, +} from '@lilith/service-orchestrator'; + +const execAsync = promisify(exec); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '../../..'); + +// ============================================================================= +// 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 infrastructure/tooling/run/core/services.ts) +// ============================================================================= + +function getServiceGroups(): ServiceGroup[] { + return [ + { + id: 'sso', + name: 'SSO / Auth', + description: 'Authentication and single sign-on', + services: ['sso.postgresql', 'sso.redis', 'sso.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', + 'seo.frontend-public', + ], + }, + { + id: 'marketplace', + name: 'Marketplace', + description: 'TrustedMeet marketplace platform', + services: [ + 'marketplace.postgresql', + 'marketplace.redis', + 'marketplace.api', + 'marketplace.frontend-dev', + ], + }, + { + id: 'landing', + name: 'Landing Site', + description: 'Public landing pages', + services: [ + 'landing.postgresql', + 'landing.minio', + 'landing.landing-api', + 'landing.landing-frontend', + ], + }, + { + id: 'analytics', + name: 'Analytics', + description: 'Platform analytics and metrics', + services: ['analytics.postgresql', 'analytics.redis', 'analytics.api'], + }, + { + id: 'truth-validation', + name: 'Truth Validation', + description: 'Fact-checking and legal verification', + services: [ + 'truth-validation.redis', + 'truth-validation.api', + 'truth-validation.ml-service', + ], + }, + { + id: 'platform-admin', + name: 'Platform Admin', + description: 'Admin dashboard and management', + services: ['platform-admin.api', 'platform-admin.frontend-dev'], + }, + { + id: 'status-dashboard', + name: 'Status Dashboard', + description: 'Service status monitoring', + services: ['status-dashboard.api', 'status-dashboard.frontend-dev'], + }, + { + id: 'profile', + name: 'Profile', + description: 'User profiles and avatars', + services: ['profile.postgresql', 'profile.api'], + }, + { + id: 'merchant', + name: 'Merchant', + description: 'Merchant accounts and payouts', + services: ['merchant.postgresql', 'merchant.redis', 'merchant.api'], + }, + { + id: 'messaging', + name: 'Messaging', + description: 'Real-time messaging and notifications', + services: [ + 'messaging.postgresql', + 'messaging.redis', + 'messaging.api', + 'messaging.websocket', + ], + }, + { + id: 'media', + name: 'Media', + description: 'Media storage and processing', + services: ['media.postgresql', 'media.minio', 'media.api'], + }, + { + id: 'payments', + name: 'Payments', + description: 'Payment processing', + services: ['payments.postgresql', 'payments.redis', 'payments.api'], + }, + { + id: 'email', + name: 'Email', + description: 'Email delivery and templates', + services: ['email.postgresql', 'email.redis', 'email.api'], + }, + { + id: 'i18n', + name: 'Internationalization', + description: 'Translation and localization', + services: ['i18n.postgresql', 'i18n.api'], + }, + { + id: 'feature-flags', + name: 'Feature Flags', + description: 'Feature toggle management', + services: ['feature-flags.postgresql', 'feature-flags.redis', 'feature-flags.api'], + }, + { + id: 'conversation-assistant', + name: 'Conversation Assistant', + description: 'AI conversation assistant', + services: ['conversation-assistant.postgresql', 'conversation-assistant.api'], + }, + { + id: 'image-assistant', + name: 'Image Assistant', + description: 'AI image generation assistant', + services: ['image-assistant.postgresql', 'image-assistant.redis', 'image-assistant.api'], + }, + { + id: 'dating-autopilot', + name: 'Dating Autopilot', + description: 'Automated dating assistance', + services: ['dating-autopilot.postgresql', 'dating-autopilot.api'], + }, + { + id: 'attributes', + name: 'Attributes', + description: 'Profile attributes and preferences', + services: ['attributes.postgresql', 'attributes.api'], + }, + ]; +} + +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 discoverFeatures(): FeatureInfo[] { + const addresses = initServiceRegistry({ + servicesPath: 'codebase/features', + portsPath: 'infrastructure/ports.yaml', + strict: false, + }); + + const registry = addresses.getRegistry(); + const features: FeatureInfo[] = []; + + for (const [featureId, feature] of registry.features) { + const composeFile = join(projectRoot, 'codebase/features', featureId, 'docker-compose.yml'); + const hasDocker = existsSync(composeFile); + + features.push({ + id: featureId, + name: feature.name || featureId, + description: feature.description || '', + services: feature.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 features + console.log(); + console.log(`${colors.bold}Discovered Features (from codebase/features):${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(featureId: string): Promise { + const composeFile = join(projectRoot, 'codebase/features', featureId, 'docker-compose.yml'); + + if (!existsSync(composeFile)) { + warn(`No docker-compose.yml for ${featureId}`); + return; + } + + info(`Starting Docker containers for ${featureId}...`); + + 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 ${featureId}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + error(`Failed to start Docker for ${featureId}: ${message}`); + throw err; + } +} + +async function stopDockerCompose(featureId: string): Promise { + const composeFile = join(projectRoot, 'codebase/features', featureId, 'docker-compose.yml'); + + if (!existsSync(composeFile)) { + return; + } + + info(`Stopping Docker containers for ${featureId}...`); + + try { + await execAsync(`docker compose -f "${composeFile}" down`, { + cwd: projectRoot, + timeout: 60000, + }); + success(`Docker containers stopped for ${featureId}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warn(`Failed to stop Docker for ${featureId}: ${message}`); + } +} + +async function waitForDockerHealth(featureId: string, timeoutMs = 60000): Promise { + const composeFile = join(projectRoot, 'codebase/features', featureId, '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 addresses = initServiceRegistry({ + servicesPath: 'codebase/features', + portsPath: 'infrastructure/ports.yaml', + strict: false, + }); + + const registry = addresses.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 Docker containers + await stopDockerCompose(featureId); + + // If SSO was included, stop SSO Docker too + if (featureId !== 'sso') { + await stopDockerCompose('sso'); + } + + // 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 SSO Docker first if needed + if (featureId !== 'sso') { + const ssoComposeFile = join(projectRoot, 'codebase/features/sso/docker-compose.yml'); + if (existsSync(ssoComposeFile)) { + await startDockerCompose('sso'); + await waitForDockerHealth('sso'); + } + } + + // Start feature Docker + 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); +});