chore(dev-utils): 🔧 Update dev service configuration in service-dev.ts
This commit is contained in:
parent
68d305b7c9
commit
4800a2544f
1 changed files with 931 additions and 0 deletions
931
scripts/services/service-dev.ts
Normal file
931
scripts/services/service-dev.ts
Normal file
|
|
@ -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 <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, 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 <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 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 <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(featureId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<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 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<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 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<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 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<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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue