890 lines
25 KiB
JavaScript
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);
|
|
});
|