platform-tooling/run/cli/commands/dev/stop.ts
2026-03-18 22:28:37 -07:00

273 lines
7.5 KiB
TypeScript

/**
* Dev stop commands - devStop, devReset, devFresh, devCleanup
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { unlink, readdir } from 'node:fs/promises';
import { DockerOps } from '../../../core/docker';
import { ServiceManager } from '../../../core/services';
import { ShutdownOrchestrator } from '../../../core/shutdown-orchestrator';
import { ShutdownDisplay } from './@core/shutdown-display';
import { Logger } from '../../../utils/logger';
import { loadConfig } from '../../../utils/config';
import { colors } from '../../../utils/colors';
import { PATHS } from '../../../../configs/paths';
import type { CommandContext, CommandResult } from '../@core';
import { dev } from './start';
const execAsync = promisify(exec);
const logger = new Logger({ context: 'Dev' });
const docker = new DockerOps(logger);
const services = new ServiceManager(logger);
const config = loadConfig();
/**
* Stop development cluster
*/
export async function devStop(_ctx: CommandContext): Promise<CommandResult> {
logger.header('Stopping Development Cluster');
try {
const display = new ShutdownDisplay();
const orchestrator = new ShutdownOrchestrator(logger, docker, services, config);
const result = await orchestrator.shutdown({
onProgress: (event) => display.handleProgress(event),
});
display.renderSummary(result);
return { code: result.success ? 0 : 1 };
} catch (err) {
logger.error(`Stop failed: ${err instanceof Error ? err.message : err}`);
return { code: 1, error: String(err) };
}
}
/**
* Stop development cluster without PTY (no TUI — safe to call from Claude Code / CI)
*/
export async function devStopNoptty(_ctx: CommandContext): Promise<CommandResult> {
logger.header('Stopping Development Cluster (no-PTY)');
try {
const orchestrator = new ShutdownOrchestrator(logger, docker, services, config);
const result = await orchestrator.shutdown({
onProgress: (event) => {
const suffix = event.serviceId ? ` (${event.serviceId})` : '';
logger.info(`[${event.phase}] ${event.message}${suffix}`);
},
});
if (result.success) {
logger.success('Cluster stopped');
} else {
logger.warn('Cluster stopped with issues');
for (const svc of result.hostServices.failed) {
logger.error(` host service failed: ${svc}`);
}
}
return { code: result.success ? 0 : 1 };
} catch (err) {
logger.error(`Stop failed: ${err instanceof Error ? err.message : err}`);
return { code: 1, error: String(err) };
}
}
/**
* Reset development cluster (stop + remove volumes)
*/
export async function devReset(_ctx: CommandContext): Promise<CommandResult> {
logger.header('Resetting Development Cluster');
try {
const display = new ShutdownDisplay();
const orchestrator = new ShutdownOrchestrator(logger, docker, services, config);
const result = await orchestrator.shutdown({
removeVolumes: true,
onProgress: (event) => display.handleProgress(event),
});
display.renderSummary(result);
if (result.success) {
logger.info('All data removed — next start will be fresh');
logger.blank();
}
return { code: result.success ? 0 : 1 };
} catch (err) {
logger.error(`Reset failed: ${err instanceof Error ? err.message : err}`);
return { code: 1, error: String(err) };
}
}
/**
* Fresh development start (reset + start)
*/
export async function devFresh(ctx: CommandContext): Promise<CommandResult> {
logger.header('Fresh Development Start');
try {
const resetResult = await devReset(ctx);
if (resetResult.code !== 0) {
return resetResult;
}
logger.info('Starting fresh cluster...');
logger.blank();
return await dev(ctx);
} catch (err) {
logger.error(`Fresh start failed: ${err instanceof Error ? err.message : err}`);
return { code: 1, error: String(err) };
}
}
/**
* Patterns to match dev processes for cleanup
*/
const DEV_PROCESS_PATTERNS = [
'nest.js start --watch',
'nest start --watch',
'vite.*--host',
'astro dev',
'bun run start:dev',
];
/**
* Kill orphan dev processes by pattern
*
* This command aggressively kills all processes matching dev patterns,
* regardless of PID file state. Use when the cluster gets into a bad state
* with orphan processes blocking ports.
*/
export async function devCleanup(_ctx: CommandContext): Promise<CommandResult> {
logger.header('Cleaning Up Orphan Dev Processes');
logger.blank();
let totalKilled = 0;
const errors: string[] = [];
// Kill processes by pattern
for (const pattern of DEV_PROCESS_PATTERNS) {
try {
const { stdout } = await execAsync(
`pgrep -f "${pattern}" 2>/dev/null || true`,
);
const pids = stdout
.trim()
.split('\n')
.filter(Boolean)
.map((p) => parseInt(p, 10))
.filter((p) => !isNaN(p) && p !== process.pid);
if (pids.length === 0) continue;
// Filter to only processes in our project directory
const toKill: number[] = [];
for (const pid of pids) {
try {
const { stdout: cwd } = await execAsync(
`readlink /proc/${pid}/cwd 2>/dev/null || true`,
);
if (cwd.trim().includes(PATHS.root)) {
toKill.push(pid);
}
} catch {
// Can't read cwd, skip
}
}
if (toKill.length === 0) continue;
logger.info(`Found ${toKill.length} process(es) matching "${colors.accent(pattern)}"`);
// Send SIGTERM
for (const pid of toKill) {
try {
process.kill(pid, 'SIGTERM');
totalKilled++;
} catch {
// Process may have exited
}
}
} catch (err) {
errors.push(`Pattern "${pattern}" failed: ${err}`);
}
}
// Wait for graceful shutdown
if (totalKilled > 0) {
logger.info('Waiting for processes to exit...');
await new Promise((resolve) => setTimeout(resolve, 2000));
}
// Clean up stale PID files
logger.blank();
logger.info('Cleaning up stale PID files...');
let pidsCleaned = 0;
try {
const pidFiles = await readdir(PATHS.pids);
for (const file of pidFiles) {
if (!file.endsWith('.pid')) continue;
const pidPath = `${PATHS.pids}/${file}`;
try {
const { stdout } = await execAsync(`cat "${pidPath}" 2>/dev/null`);
const pid = parseInt(stdout.trim(), 10);
if (!isNaN(pid)) {
// Check if process is running
try {
process.kill(pid, 0);
// Still running, leave it
} catch {
// Process dead, remove PID file
await unlink(pidPath);
pidsCleaned++;
}
}
} catch {
// File unreadable, try to remove it
try {
await unlink(pidPath);
pidsCleaned++;
} catch {
// Ignore
}
}
}
} catch {
// PID directory doesn't exist or is empty
}
// Summary
logger.blank();
if (totalKilled > 0 || pidsCleaned > 0) {
logger.success(`Cleanup complete:`);
if (totalKilled > 0) {
logger.info(` ${colors.healthy('●')} Killed ${totalKilled} orphan process(es)`);
}
if (pidsCleaned > 0) {
logger.info(` ${colors.healthy('●')} Removed ${pidsCleaned} stale PID file(s)`);
}
} else {
logger.info('No orphan processes or stale PID files found');
}
if (errors.length > 0) {
logger.blank();
logger.warn('Some patterns had errors:');
for (const err of errors) {
logger.warn(` ${err}`);
}
}
logger.blank();
return { code: 0 };
}