chore(cli): 🔧 Update CLI shutdown scripts (shutdown-display.ts, shutdown-orchestrator.ts)

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-01-30 19:30:56 -08:00
parent bf94bdeaac
commit decd2132d3
2 changed files with 380 additions and 0 deletions

View file

@ -0,0 +1,158 @@
/**
* Shutdown Display
*
* Terminal renderer for shutdown progress events.
* Renders discovery, per-service stop progress, Docker shutdown, and summary.
*/
import { colors } from '../../../../utils/colors';
import { formatDuration } from '../../@core/formatters';
import type {
ShutdownProgressEvent,
ShutdownResult,
ServiceStopStatus,
} from '../../../../core/shutdown-orchestrator';
// =============================================================================
// Display
// =============================================================================
export class ShutdownDisplay {
private serviceTimers = new Map<string, number>();
/**
* Handle a progress event from ShutdownOrchestrator
*/
handleProgress(event: ShutdownProgressEvent): void {
switch (event.phase) {
case 'discovery':
this.renderDiscovery(event);
break;
case 'host-services':
this.renderServiceEvent(event);
break;
case 'docker-containers':
this.renderDockerEvent(event);
break;
}
}
/**
* Render final summary after shutdown completes
*/
renderSummary(result: ShutdownResult): void {
const isEmpty =
result.hostServices.discovered.length === 0 &&
result.containers.discovered.length === 0;
if (isEmpty) {
console.log('');
console.log(` ${colors.muted('Nothing to stop — cluster is not running.')}`);
console.log('');
return;
}
const line = '━'.repeat(54);
const statusIcon = result.success ? colors.symbols.success : colors.symbols.warning;
const statusText = result.success ? 'Cluster Stopped' : 'Cluster Stopped (with issues)';
const duration = formatDuration(result.totalDurationMs);
console.log('');
console.log(colors.primary(line));
console.log(colors.primary.bold(` ${statusIcon} ${statusText} (${duration})`));
console.log(colors.primary(line));
// Counts
const parts: string[] = [];
if (result.hostServices.stopped.length > 0) {
parts.push(`${colors.healthy('●')} Stopped: ${result.hostServices.stopped.length}`);
}
if (result.hostServices.forceKilled.length > 0) {
parts.push(`${colors.warning('⚠')} Killed: ${result.hostServices.forceKilled.length}`);
}
if (result.hostServices.failed.length > 0) {
parts.push(`${colors.error('✗')} Failed: ${result.hostServices.failed.length}`);
}
if (result.containers.stoppedCount > 0) {
const volText = result.containers.volumesRemoved ? ' (volumes removed)' : '';
parts.push(`${colors.healthy('●')} Containers: ${result.containers.stoppedCount}${volText}`);
}
if (parts.length > 0) {
console.log(` ${parts.join(' ')}`);
}
console.log('');
}
// ---------------------------------------------------------------------------
// Phase Renderers
// ---------------------------------------------------------------------------
private renderDiscovery(event: ShutdownProgressEvent): void {
// First discovery event: show phase header
if (event.message.startsWith('Discovering')) {
return; // Skip the "discovering..." message, only show results
}
console.log('');
console.log(colors.accent(` ▸ Discovery`));
console.log(` ${colors.symbols.info} ${event.message}`);
}
private renderServiceEvent(event: ShutdownProgressEvent): void {
if (!event.serviceId) return;
if (event.status === 'stopping') {
// Track start time for duration calculation
this.serviceTimers.set(event.serviceId, Date.now());
// Print phase header on first service
if (event.current === 1) {
console.log('');
console.log(colors.accent(` ▸ Stopping Host Services (${event.total})`));
}
return;
}
// Result event
const startTime = this.serviceTimers.get(event.serviceId);
const duration = startTime ? formatDuration(Date.now() - startTime) : '';
const durationText = duration ? colors.muted(` (${duration})`) : '';
const paddedId = event.serviceId.padEnd(32);
const statusText = this.formatServiceStatus(event.status);
console.log(` ${statusText} ${paddedId}${durationText}`);
}
private renderDockerEvent(event: ShutdownProgressEvent): void {
if (event.message.includes('Stopping')) {
console.log('');
console.log(colors.accent(` ▸ Docker Containers`));
console.log(` ${colors.symbols.info} ${event.message}`);
} else {
console.log(` ${colors.healthy('●')} ${event.message}`);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private formatServiceStatus(status?: ServiceStopStatus): string {
switch (status) {
case 'stopped':
return colors.healthy('●');
case 'force-killed':
return colors.warning('⚠');
case 'failed':
return colors.error('✗');
case 'not-running':
return colors.muted('○');
default:
return colors.muted('●');
}
}
}

View file

@ -0,0 +1,222 @@
/**
* Shutdown Orchestrator
*
* Coordinates graceful shutdown of the development cluster with
* discovery, per-service progress tracking, and structured results.
*
* Used by: dev:stop, dev:reset, dev:fresh, dev:restart
*/
import { getOurServices } from '@lilith/service-orchestrator';
import type { ContainerStatus } from './docker';
import type { DockerOps } from './docker';
import type { ServiceManager } from './services';
import type { Logger } from '../utils/logger';
import type { RunConfig } from '../utils/config';
// =============================================================================
// Types
// =============================================================================
export type ShutdownPhase = 'discovery' | 'host-services' | 'docker-containers' | 'cleanup';
export type ServiceStopStatus = 'stopping' | 'stopped' | 'force-killed' | 'failed' | 'not-running';
export interface ShutdownProgressEvent {
phase: ShutdownPhase;
message: string;
serviceId?: string;
status?: ServiceStopStatus;
current?: number;
total?: number;
}
export interface ShutdownOptions {
/** Remove Docker volumes (used by dev:reset/dev:fresh) */
removeVolumes?: boolean;
/** Callback for progress updates */
onProgress?: (event: ShutdownProgressEvent) => void;
}
export interface ShutdownResult {
hostServices: {
discovered: string[];
stopped: string[];
forceKilled: string[];
failed: string[];
notRunning: string[];
durationMs: number;
};
containers: {
discovered: ContainerStatus[];
stoppedCount: number;
durationMs: number;
volumesRemoved: boolean;
};
totalDurationMs: number;
success: boolean;
}
// =============================================================================
// Shutdown Orchestrator
// =============================================================================
export class ShutdownOrchestrator {
constructor(
private logger: Logger,
private docker: DockerOps,
private services: ServiceManager,
private config: RunConfig,
) {}
/**
* Execute a full shutdown with discovery, per-service progress, and summary.
*/
async shutdown(options: ShutdownOptions = {}): Promise<ShutdownResult> {
const totalStart = Date.now();
const emit = options.onProgress;
// ── Phase 1: Discovery ──────────────────────────────────────────────
emit?.({
phase: 'discovery',
message: 'Discovering running services and containers...',
});
const [runningServiceIds, containerStatuses] = await Promise.all([
this.discoverHostServices(),
this.docker.status({ envFile: this.config.envDev }),
]);
emit?.({
phase: 'discovery',
message: `Found ${runningServiceIds.length} host services, ${containerStatuses.length} containers`,
});
// Short-circuit if nothing is running
if (runningServiceIds.length === 0 && containerStatuses.length === 0) {
return {
hostServices: {
discovered: [],
stopped: [],
forceKilled: [],
failed: [],
notRunning: [],
durationMs: 0,
},
containers: {
discovered: [],
stoppedCount: 0,
durationMs: 0,
volumesRemoved: false,
},
totalDurationMs: Date.now() - totalStart,
success: true,
};
}
// ── Phase 2: Stop Host Services ──────────────────────────────────────
const serviceStart = Date.now();
const stopped: string[] = [];
const forceKilled: string[] = [];
const failed: string[] = [];
const notRunning: string[] = [];
if (runningServiceIds.length > 0) {
for (let i = 0; i < runningServiceIds.length; i++) {
const serviceId = runningServiceIds[i];
emit?.({
phase: 'host-services',
message: `Stopping ${serviceId}`,
serviceId,
status: 'stopping',
current: i + 1,
total: runningServiceIds.length,
});
const result = await this.services.stopServices([serviceId]);
let status: ServiceStopStatus;
if (result.stopped.includes(serviceId)) {
stopped.push(serviceId);
status = 'stopped';
} else if (result.failed.includes(serviceId)) {
failed.push(serviceId);
status = 'failed';
} else if (result.notRunning.includes(serviceId)) {
notRunning.push(serviceId);
status = 'not-running';
} else {
// Default to stopped (process already gone counts as stopped)
stopped.push(serviceId);
status = 'stopped';
}
emit?.({
phase: 'host-services',
message: `${serviceId} ${status}`,
serviceId,
status,
current: i + 1,
total: runningServiceIds.length,
});
}
}
const serviceDuration = Date.now() - serviceStart;
// ── Phase 3: Stop Docker Containers ─────────────────────────────────
const dockerStart = Date.now();
if (containerStatuses.length > 0) {
emit?.({
phase: 'docker-containers',
message: `Stopping ${containerStatuses.length} containers${options.removeVolumes ? ' and removing volumes' : ''}...`,
});
await this.docker.down({
envFile: this.config.envDev,
volumes: options.removeVolumes,
});
emit?.({
phase: 'docker-containers',
message: `${containerStatuses.length} containers stopped`,
});
}
const dockerDuration = Date.now() - dockerStart;
// ── Result ──────────────────────────────────────────────────────────
return {
hostServices: {
discovered: runningServiceIds,
stopped,
forceKilled,
failed,
notRunning,
durationMs: serviceDuration,
},
containers: {
discovered: containerStatuses,
stoppedCount: containerStatuses.length,
durationMs: dockerDuration,
volumesRemoved: options.removeVolumes ?? false,
},
totalDurationMs: Date.now() - totalStart,
success: failed.length === 0,
};
}
/**
* Discover running host services via PID files.
*/
private async discoverHostServices(): Promise<string[]> {
try {
return await getOurServices();
} catch {
this.logger.debug('No running services found (PID directory may not exist)');
return [];
}
}
}