UNPKG

spaps

Version:

Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware

870 lines (786 loc) 26.7 kB
#!/usr/bin/env node /** * SPAPS Local Development Server - Docker Compose Orchestrator * Uses repo-native assets inside sweet-potato when available and falls back to a * bundled portable runtime when installed from npm elsewhere. */ const { spawn, execSync, execFileSync } = require('child_process'); const chalk = require('chalk'); const axios = require('axios'); const os = require('os'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const PACKAGE_ROOT = path.resolve(__dirname, '..'); const BUNDLED_RUNTIME_ROOT = path.join(PACKAGE_ROOT, 'assets', 'local-runtime'); const BUNDLED_RUNTIME_MANIFEST = path.join(BUNDLED_RUNTIME_ROOT, 'manifest.json'); const DEFAULT_RUNTIME_SOURCE = 'auto'; const DEFAULT_AUTH_BASE_URL = 'http://localhost:5173'; const DEFAULT_DATA_SOURCE = 'empty'; const EXTERNAL_NETWORKS = ['reverse-proxy']; const DEFAULT_CORS_ALLOW_ORIGINS = [ 'http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3001', 'http://127.0.0.1:3001', 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:30000', 'http://127.0.0.1:30000', ].join(','); const DEFAULT_CORS_ALLOW_HEADERS = [ 'Accept', 'Accept-Language', 'Authorization', 'Content-Language', 'Content-Type', 'X-API-Key', 'X-Request-ID', 'X-SPAPS-Signature', 'X-SPAPS-Use-Refresh-Cookie', 'X-Test-User', ].join(','); function readBundledRuntimeManifest() { if (!fs.existsSync(BUNDLED_RUNTIME_MANIFEST)) { throw new Error(`Bundled runtime manifest not found at ${BUNDLED_RUNTIME_MANIFEST}`); } return JSON.parse(fs.readFileSync(BUNDLED_RUNTIME_MANIFEST, 'utf8')); } function normalizeRuntimeSource(rawValue) { const value = String(rawValue || DEFAULT_RUNTIME_SOURCE).trim().toLowerCase(); if (!value) { return DEFAULT_RUNTIME_SOURCE; } if (!['auto', 'repo', 'bundle'].includes(value)) { throw new Error(`Unsupported runtime source "${rawValue}". Use auto, repo, or bundle.`); } return value; } function normalizeDataSource(rawValue) { const value = String(rawValue || DEFAULT_DATA_SOURCE).trim().toLowerCase(); if (!value) { return DEFAULT_DATA_SOURCE; } if (!['empty', 'prod-cache', 'prod-fresh'].includes(value)) { throw new Error( `Unsupported data source "${rawValue}". Use empty, prod-cache, or prod-fresh.` ); } return value; } function tryFindRepoRoot(startDir = __dirname) { let current = startDir; const maxDepth = 10; let depth = 0; while (depth < maxDepth) { const candidate = path.join(current, 'docker-compose.spaps-dev.yml'); if (fs.existsSync(candidate)) { return current; } const parent = path.dirname(current); if (parent === current) { break; } current = parent; depth += 1; } return null; } function defaultBundledRuntimeDir(port) { const xdgCacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); return path.join(xdgCacheHome, 'spaps', `local-${port}`); } function defaultCacheRoot() { const xdgCacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); return path.join(xdgCacheHome, 'spaps'); } function shellEscape(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } function ensureBundledRuntime(runtimeDir) { if (!fs.existsSync(BUNDLED_RUNTIME_ROOT)) { throw new Error( `Bundled runtime assets not found at ${BUNDLED_RUNTIME_ROOT}. Reinstall the spaps package.` ); } fs.mkdirSync(runtimeDir, { recursive: true }); fs.cpSync(BUNDLED_RUNTIME_ROOT, runtimeDir, { recursive: true, force: true }); for (const scriptName of ['container-entrypoint.sh', 'run-migrations.sh']) { const scriptPath = path.join(runtimeDir, 'scripts', scriptName); if (fs.existsSync(scriptPath)) { fs.chmodSync(scriptPath, 0o755); } } } function readOrCreateRuntimeSecret(runtimeDir, envName, fileName) { const envValue = process.env[envName]; if (envValue) { return envValue; } const secretPath = path.join(runtimeDir, fileName); if (fs.existsSync(secretPath)) { const existing = fs.readFileSync(secretPath, 'utf8').trim(); if (existing) { return existing; } } fs.mkdirSync(runtimeDir, { recursive: true }); const generated = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(secretPath, `${generated}\n`, { mode: 0o600 }); fs.chmodSync(secretPath, 0o600); return generated; } function buildComposeEnv({ port, authBaseUrl, runtimeDir = null, runtimeMode = 'repo' }) { const manifest = readBundledRuntimeManifest(); const portableRuntime = manifest.portable_runtime || {}; const composeEnv = { ...process.env, }; if (runtimeMode === 'bundle' && runtimeDir) { composeEnv.SPAPS_LOCAL_POSTGRES_PASSWORD = readOrCreateRuntimeSecret( runtimeDir, 'SPAPS_LOCAL_POSTGRES_PASSWORD', '.spaps-local-postgres-password' ); } return { ...composeEnv, SPAPS_LOCAL_PORT: String(port), SPAPS_LOCAL_MODE: String(process.env.SPAPS_LOCAL_MODE ?? portableRuntime.default_local_mode ?? true), SPAPS_AUTH_BASE_URL: process.env.SPAPS_AUTH_BASE_URL || authBaseUrl || portableRuntime.default_auth_base_url || DEFAULT_AUTH_BASE_URL, JWT_SECRET: process.env.JWT_SECRET || 'spaps_local_dev_jwt_secret', REFRESH_TOKEN_SECRET: process.env.REFRESH_TOKEN_SECRET || 'spaps_local_dev_refresh_secret', SELF_SERVICE_PASSWORD: process.env.SELF_SERVICE_PASSWORD || portableRuntime.default_self_service_password || 'spaps_local_self_service_password', CORS_ALLOW_ORIGINS: process.env.CORS_ALLOW_ORIGINS || portableRuntime.default_cors_allow_origins || DEFAULT_CORS_ALLOW_ORIGINS, CORS_ALLOW_HEADERS: process.env.CORS_ALLOW_HEADERS || portableRuntime.default_cors_allow_headers || DEFAULT_CORS_ALLOW_HEADERS, LEGACY_API_KEY_AUTH_ENABLED: process.env.LEGACY_API_KEY_AUTH_ENABLED || 'true', SPAPS_SERVER_QUICKSTART_VERSION: process.env.SPAPS_SERVER_QUICKSTART_VERSION || manifest.spaps_server_quickstart_version, }; } function resolveLocalRuntime({ port = 3301, runtimeDir = null, runtimeSource = DEFAULT_RUNTIME_SOURCE, authBaseUrl = DEFAULT_AUTH_BASE_URL, } = {}) { const source = normalizeRuntimeSource(runtimeSource || process.env.SPAPS_LOCAL_RUNTIME_SOURCE); const repoRoot = tryFindRepoRoot(); if (source === 'repo' || (source === 'auto' && repoRoot)) { if (!repoRoot) { throw new Error('Repo runtime requested but sweet-potato repo root was not found'); } return { mode: 'repo', runtimeDir: repoRoot, cwd: repoRoot, composeFile: path.join(repoRoot, 'docker-compose.spaps-dev.yml'), composeEnv: buildComposeEnv({ port, authBaseUrl, runtimeDir: repoRoot, runtimeMode: 'repo' }), projectName: path.basename(repoRoot), }; } const resolvedRuntimeDir = path.resolve( runtimeDir || process.env.SPAPS_LOCAL_RUNTIME_DIR || defaultBundledRuntimeDir(port) ); ensureBundledRuntime(resolvedRuntimeDir); return { mode: 'bundle', runtimeDir: resolvedRuntimeDir, cwd: resolvedRuntimeDir, composeFile: path.join(resolvedRuntimeDir, 'docker-compose.yml'), composeEnv: buildComposeEnv({ port, authBaseUrl, runtimeDir: resolvedRuntimeDir, runtimeMode: 'bundle', }), projectName: `spaps-local-${port}`, }; } class LocalServer { constructor(options = {}) { this.port = options.port || 3301; this.json = options.json || false; this.detach = options.detach || false; this.fresh = options.fresh || false; this.fromBackup = options.fromBackup ? path.resolve(options.fromBackup) : null; this.dataSource = this.fromBackup ? 'backup' : normalizeDataSource(options.dataSource || process.env.SPAPS_LOCAL_DATA_SOURCE); this.runtimeSource = normalizeRuntimeSource( options.runtimeSource || process.env.SPAPS_LOCAL_RUNTIME_SOURCE || DEFAULT_RUNTIME_SOURCE ); this.authBaseUrl = options.authBaseUrl || DEFAULT_AUTH_BASE_URL; this.runtime = resolveLocalRuntime({ port: this.port, runtimeDir: options.runtimeDir || null, runtimeSource: this.runtimeSource, authBaseUrl: this.authBaseUrl, }); this.runtimeMode = this.runtime.mode; this.runtimeDir = this.runtime.runtimeDir; this.cwd = this.runtime.cwd; this.composeFile = this.runtime.composeFile; this.composeEnv = this.runtime.composeEnv; this.projectName = this.runtime.projectName; this.apiUrl = `http://localhost:${this.port}`; this.healthUrl = `${this.apiUrl}/health`; this.logProcess = null; this.cacheRoot = path.resolve(process.env.SPAPS_CACHE_ROOT || defaultCacheRoot()); this.restoreStateFile = path.join( this.cacheRoot, 'restore-state', `${this.projectName}.json` ); this.cachedProdDumpPath = path.join(this.cacheRoot, 'db', 'prod.sql.gz'); } checkDockerCompose() { try { execSync('docker compose version', { stdio: 'ignore' }); return 'docker compose'; } catch { try { execSync('docker-compose version', { stdio: 'ignore' }); return 'docker-compose'; } catch { throw new Error('docker compose is required but not installed'); } } } runCompose(args, options = {}) { const composeCmd = this.checkDockerCompose(); const fullArgs = ['-p', this.projectName, '-f', this.composeFile, ...args]; const command = `${composeCmd} ${fullArgs.map(shellEscape).join(' ')}`; if (options.silent) { return execSync(command, { cwd: this.cwd, stdio: 'ignore', env: this.composeEnv, }); } return execSync(command, { cwd: this.cwd, encoding: 'utf-8', env: this.composeEnv, }); } runBash(command, options = {}) { const stdio = options.silent ? 'ignore' : (options.stdio || 'inherit'); return execFileSync('bash', ['-lc', command], { cwd: this.cwd, env: this.composeEnv, stdio, encoding: options.encoding || 'utf8', }); } ensureExternalNetworks() { if (this.runtimeMode !== 'repo') { return; } for (const network of EXTERNAL_NETWORKS) { try { execSync(`docker network inspect ${network}`, { stdio: 'ignore' }); } catch { if (!this.json) { console.log(chalk.dim(`🔌 Creating Docker network ${network}...`)); } execSync(`docker network create ${network}`, { stdio: this.json ? 'ignore' : 'inherit', }); } } } async waitForHealthCheck(maxAttempts = 60, intervalMs = 1000) { if (!this.json) { console.log(chalk.dim(`⏳ Waiting for SPAPS API at ${this.healthUrl}...`)); } for (let i = 0; i < maxAttempts; i += 1) { try { const response = await axios.get(this.healthUrl, { timeout: 2000 }); if (response.status === 200) { return true; } } catch { // Keep polling. } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Health check failed after ${maxAttempts} attempts`); } async waitForDatabaseReady(maxAttempts = 60, intervalMs = 1000) { if (!this.json) { console.log(chalk.dim('⏳ Waiting for SPAPS database...')); } for (let i = 0; i < maxAttempts; i += 1) { try { this.runCompose(['exec', '-T', 'spaps-dev-db', 'pg_isready', '-U', 'postgres'], { silent: true, }); return true; } catch { // Keep polling. } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Database did not become ready after ${maxAttempts} attempts`); } readRestoreState() { if (!fs.existsSync(this.restoreStateFile)) { return null; } try { return JSON.parse(fs.readFileSync(this.restoreStateFile, 'utf8')); } catch { return null; } } restoreStateMatches(signature) { const payload = this.readRestoreState(); if (!payload || !payload.signature) { return false; } return JSON.stringify(payload.signature) === JSON.stringify(signature); } writeRestoreState(signature) { fs.mkdirSync(path.dirname(this.restoreStateFile), { recursive: true }); fs.writeFileSync( this.restoreStateFile, JSON.stringify( { signature, updated_at: new Date().toISOString(), }, null, 2 ) ); } clearRestoreState() { if (fs.existsSync(this.restoreStateFile)) { fs.unlinkSync(this.restoreStateFile); } } resolveFetchProdScript() { const scriptPath = path.join(BUNDLED_RUNTIME_ROOT, 'scripts', 'fetch-prod-db.sh'); if (!fs.existsSync(scriptPath)) { throw new Error(`Bundled prod dump fetch script not found at ${scriptPath}`); } return scriptPath; } fetchProdDump(forceFresh = false) { const scriptPath = this.resolveFetchProdScript(); const env = { ...process.env, SPAPS_CACHE_ROOT: this.cacheRoot, }; if (forceFresh) { env.SPAPS_PROD_DB_FRESH = '1'; } if (!this.json) { const modeLabel = forceFresh ? 'fresh production dump' : 'cached production dump'; console.log(chalk.blue(`📦 Resolving ${modeLabel}...`)); } execFileSync('bash', [scriptPath], { cwd: this.cwd, env, stdio: this.json ? 'ignore' : 'inherit', }); return this.cachedProdDumpPath; } resolveRestorePlan() { if (this.fromBackup) { if (!fs.existsSync(this.fromBackup)) { throw new Error(`Backup file not found: ${this.fromBackup}`); } const stat = fs.statSync(this.fromBackup); return { source: 'backup', path: this.fromBackup, signature: { source: 'backup', path: this.fromBackup, size: stat.size, mtime_ms: stat.mtimeMs, }, }; } if (this.dataSource === 'empty') { return null; } const dumpPath = this.fetchProdDump(this.dataSource === 'prod-fresh'); if (!fs.existsSync(dumpPath)) { throw new Error(`Resolved dump file not found: ${dumpPath}`); } const stat = fs.statSync(dumpPath); return { source: this.dataSource, path: dumpPath, signature: { source: 'prod-dump', path: dumpPath, size: stat.size, mtime_ms: stat.mtimeMs, }, }; } databaseHasRestoredBaseData() { try { const count = this.runCompose( [ 'exec', '-T', 'spaps-dev-db', 'psql', '-U', 'postgres', '-d', 'spaps', '-At', '-c', 'SELECT COUNT(*) FROM applications;', ], { silent: false } ); return Number.parseInt(String(count).trim(), 10) > 0; } catch { return false; } } restoreDumpIntoDatabase(dumpPath) { const composeCmd = this.checkDockerCompose(); const fullArgs = [ '-p', this.projectName, '-f', this.composeFile, 'exec', '-T', 'spaps-dev-db', 'psql', '-U', 'postgres', '-d', 'spaps', ]; const composeInvocation = `${composeCmd} ${fullArgs.map(shellEscape).join(' ')}`; const restoreCommand = `set -o pipefail; gunzip -c ${shellEscape(dumpPath)} | ${composeInvocation} >/dev/null`; if (!this.json) { console.log(chalk.blue(`📥 Restoring data from ${dumpPath}...`)); } let restoreHadWarnings = false; try { this.runBash(restoreCommand, { silent: this.json }); } catch (error) { restoreHadWarnings = true; if (!this.json) { console.log( chalk.yellow( '⚠️ Database restore reported warnings. Continuing because SPAPS dumps can contain benign owner/duplicate-object noise.' ) ); } } if (!this.databaseHasRestoredBaseData()) { throw new Error('Database restore did not materialize SPAPS base data.'); } return { restoreHadWarnings }; } composeStartArgs(...services) { if (this.runtimeMode === 'bundle') { return ['up', '-d', '--build', ...services]; } return ['up', '-d', ...services]; } printConnectionInfo({ restorePlan, restoreApplied }) { // Local mode authenticates by header-selected persona, not by // email/password. Picking a persona is what actually works against the // running server (see middleware/local_mode.py + spaps_deps.py), so we // surface the X-Test-User / ?_user contract rather than fake credentials. const localModeEnabled = String(this.composeEnv.SPAPS_LOCAL_MODE ?? 'true').trim().toLowerCase() === 'true'; const connectionInfo = { SPAPS_API_URL: this.apiUrl, SPAPS_API_KEY: 'spaps_local_development_key', SPAPS_APPLICATION_ID: '00000000-0000-0000-0000-000000000100', SELF_SERVICE_PASSWORD: this.composeEnv.SELF_SERVICE_PASSWORD, data_source: this.dataSource, restore_applied: Boolean(restoreApplied), restore_source: restorePlan ? restorePlan.source : null, test_personas: { local_mode_enabled: localModeEnabled, select_via: 'X-Test-User: <persona> header (or ?_user=<persona> query param)', default_persona: 'user', note: 'Local-mode personas are header-selected; there are no password logins.', personas: [ { persona: 'user', header: 'X-Test-User: user', email: 'user@localhost', tier: 'free' }, { persona: 'admin', header: 'X-Test-User: admin', email: 'buildooor@gmail.com', tier: 'enterprise', }, { persona: 'premium', header: 'X-Test-User: premium', email: 'premium@localhost', tier: 'premium', }, ], }, }; if (this.json) { console.log( JSON.stringify({ success: true, command: 'local', server: { url: this.apiUrl, docs: `${this.apiUrl}/docs`, health: `${this.apiUrl}/health`, mode: 'docker-compose', runtime_source: this.runtimeMode, runtime_dir: this.runtimeDir, data_source: this.dataSource, restore_applied: Boolean(restoreApplied), port: this.port, connection: connectionInfo, }, }) ); return; } console.log(); console.log(chalk.green('✨ SPAPS server is running!')); console.log(); console.log(chalk.cyan('📡 Connection Info:')); console.log(` ${chalk.bold('API URL:')} ${this.apiUrl}`); console.log(` ${chalk.bold('Documentation:')} ${this.apiUrl}/docs`); console.log(` ${chalk.bold('Health Check:')} ${this.apiUrl}/health`); console.log(` ${chalk.bold('Runtime:')} ${this.runtimeMode} (${this.runtimeDir})`); console.log(` ${chalk.bold('Data Source:')} ${this.dataSource}`); if (restorePlan) { console.log(` ${chalk.bold('Restore:')} ${restoreApplied ? 'applied' : 'reused existing data'} (${restorePlan.source})`); } console.log(); console.log(chalk.cyan('🔑 Credentials:')); console.log(` ${chalk.bold('API Key:')} ${connectionInfo.SPAPS_API_KEY}`); console.log(` ${chalk.bold('Application ID:')} ${connectionInfo.SPAPS_APPLICATION_ID}`); console.log(` ${chalk.bold('Self-service:')} ${connectionInfo.SELF_SERVICE_PASSWORD}`); console.log(); const { test_personas: testPersonas } = connectionInfo; console.log(chalk.cyan('👥 Test Personas (local mode):')); if (testPersonas.local_mode_enabled) { console.log(chalk.dim(' Select one with the X-Test-User header (or ?_user= query param).')); for (const persona of testPersonas.personas) { const label = `${persona.persona}:`.padEnd(9); console.log( ` ${chalk.bold(label)} X-Test-User: ${chalk.bold(persona.persona)} (${persona.email}, tier=${persona.tier})` ); } console.log( chalk.dim(` Default persona when no header is sent: ${testPersonas.default_persona}.`) ); console.log(chalk.dim(` Example: curl -H "X-Test-User: admin" ${this.apiUrl}/api/auth/user`)); } else { console.log( chalk.dim(' Local-mode persona bypass is disabled (SPAPS_LOCAL_MODE != true); use real auth flows.') ); } console.log(); if (this.detach) { console.log(chalk.dim(' Running in background. Use `npx spaps local stop` to stop.')); console.log(chalk.dim(` View logs: docker compose -p ${this.projectName} -f ${this.composeFile} logs -f`)); console.log(); return; } console.log(chalk.dim(' Press Ctrl+C to stop')); console.log(); console.log(chalk.gray('─'.repeat(60))); console.log(); this.tailLogs(); } async start() { try { this.checkDockerCompose(); if (!fs.existsSync(this.composeFile)) { throw new Error(`Compose file not found at ${this.composeFile}`); } this.ensureExternalNetworks(); if (!this.json) { console.log(); console.log(chalk.yellow('🍠 SPAPS Local Development Server')); } const restorePlan = this.resolveRestorePlan(); let restoreApplied = false; if (restorePlan) { if (!this.json) { console.log(chalk.blue('🐳 Starting SPAPS data services...')); } this.runCompose(['up', '-d', 'spaps-dev-db', 'spaps-dev-redis'], { silent: this.json }); await this.waitForDatabaseReady(); const canReuseRestoredData = !this.fresh && this.restoreStateMatches(restorePlan.signature) && this.databaseHasRestoredBaseData(); if (!canReuseRestoredData) { if (!this.json) { const reason = this.fresh ? 'fresh mode requested' : 'base data missing or stale'; console.log(chalk.yellow(`🔄 Reinitializing SPAPS data volumes (${reason})...`)); } try { this.runCompose(['down', '-v'], { silent: true }); } catch { // Ignore teardown errors when the stack is partially absent. } this.clearRestoreState(); this.ensureExternalNetworks(); this.runCompose(['up', '-d', 'spaps-dev-db', 'spaps-dev-redis'], { silent: this.json }); await this.waitForDatabaseReady(); this.restoreDumpIntoDatabase(restorePlan.path); this.writeRestoreState(restorePlan.signature); restoreApplied = true; } if (!this.json) { console.log(chalk.blue('🚀 Starting SPAPS API...')); } this.runCompose(this.composeStartArgs('spaps-dev-api'), { silent: this.json }); } else { if (this.fresh) { if (!this.json) { console.log(chalk.yellow('🔄 Fresh mode: tearing down existing stack...')); } try { this.runCompose(['down', '-v'], { silent: true }); } catch { // Ignore teardown errors when the stack does not exist yet. } this.clearRestoreState(); } if (!this.json) { console.log(chalk.blue('🐳 Starting Docker Compose stack...')); } this.runCompose(this.composeStartArgs(), { silent: this.json }); } await this.waitForHealthCheck(); this.printConnectionInfo({ restorePlan, restoreApplied }); return { success: true }; } catch (error) { if (this.json) { console.log(JSON.stringify({ success: false, error: error.message })); } else { console.error(chalk.red('❌ Failed to start SPAPS server:'), error.message); } throw error; } } stop() { try { if (!this.json) { console.log(chalk.yellow('🛑 Stopping SPAPS Docker Compose stack...')); } this.runCompose(['down'], { silent: this.json }); if (this.json) { console.log( JSON.stringify({ success: true, command: 'local stop', message: 'SPAPS server stopped', }) ); } else { console.log(chalk.green('✅ SPAPS server stopped')); } return { success: true }; } catch (error) { if (this.json) { console.log(JSON.stringify({ success: false, error: error.message })); } else { console.error(chalk.red('❌ Failed to stop SPAPS server:'), error.message); } throw error; } } tailLogs() { const composeCmd = this.checkDockerCompose(); const cmdParts = composeCmd.split(' '); const command = cmdParts[0]; const subArgs = cmdParts.slice(1); const args = [ ...subArgs, '-p', this.projectName, '-f', this.composeFile, 'logs', '-f', '--tail=50', 'spaps-dev-api', ]; this.logProcess = spawn(command, args, { cwd: this.cwd, stdio: 'inherit', env: this.composeEnv, }); this.logProcess.on('error', (error) => { if (!this.json) { console.error(chalk.red('❌ Failed to tail logs:'), error.message); } }); } async shutdown() { if (this.logProcess) { this.logProcess.kill(); } if (this.detach) { return; } if (!this.json) { console.log(); console.log(chalk.yellow('👋 Shutting down SPAPS server...')); } try { this.runCompose(['down'], { silent: this.json }); if (!this.json) { console.log(chalk.green('✅ Server stopped')); } } catch (error) { if (!this.json) { console.error(chalk.red('❌ Error during shutdown:'), error.message); } } } } module.exports = LocalServer; module.exports.readBundledRuntimeManifest = readBundledRuntimeManifest; module.exports.resolveLocalRuntime = resolveLocalRuntime; module.exports.normalizeRuntimeSource = normalizeRuntimeSource; module.exports.normalizeDataSource = normalizeDataSource; module.exports.defaultBundledRuntimeDir = defaultBundledRuntimeDir; module.exports.defaultCacheRoot = defaultCacheRoot; if (require.main === module) { const server = new LocalServer(); const shutdown = async () => { await server.shutdown(); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); server.start().catch((error) => { console.error(chalk.red('❌ Fatal error:'), error.message); process.exit(1); }); }