platform-tooling/scripts/services/service-dev.ts
Quinn Ftw 85621b287e chore: snapshot before monorepo consolidation
Capture current working state before converting platform-tooling
into a submodule of the lilith-platform monorepo.
2026-01-29 07:04:39 -08:00

890 lines
25 KiB
JavaScript

#!/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 <feature> # Start feature + deps (Docker + host)
* pnpm dev:start <feature> --dry-run # Preview what would start
* pnpm dev:start --list # Show all features
* pnpm dev:start <feature> --stop # Stop services for feature
* pnpm dev:start <feature> --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 <feature> [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 <feature-id> to start a feature${colors.reset}`);
console.log();
}
// =============================================================================
// Docker Operations
// =============================================================================
async function checkDocker(): Promise<boolean> {
try {
await execAsync('docker info', { timeout: 5000 });
return true;
} catch {
return false;
}
}
async function startDockerCompose(deploymentId: string): Promise<void> {
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<void> {
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<boolean> {
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<boolean> {
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<StartupResult | null> {
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<typeof s> => 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<void> {
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<void> {
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<void> {
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);
});