273 lines
7.5 KiB
TypeScript
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 };
|
|
}
|