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
JavaScript
/**
* 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);
});
}