platform-tooling/scripts/orchestration/terminal-ui.ts
Quinn Ftw 85621b287e chore: snapshot before monorepo consolidation
Capture current working state before converting platform-tooling
into a submodule of the lilith-platform monorepo.
2026-01-29 07:04:39 -08:00

378 lines
11 KiB
TypeScript

/**
* Terminal UI for service orchestration
*
* Provides a clean, modern terminal interface for startup progress:
* - In-place progress updates (no spam)
* - Phase-based grouping
* - Service status tracking with timing
* - Clean summary
*/
import chalk from 'chalk';
import { WriteStream } from 'tty';
// =============================================================================
// Types
// =============================================================================
export interface ServiceState {
id: string;
status: 'pending' | 'starting' | 'healthy' | 'failed' | 'skipped';
startTime?: number;
duration?: number;
error?: string;
}
export interface PhaseState {
index: number;
name: string;
services: string[];
completed: number;
failed: number;
startTime?: number;
}
// =============================================================================
// Terminal UI Class
// =============================================================================
export class TerminalUI {
private services: Map<string, ServiceState> = new Map();
private phases: PhaseState[] = [];
private currentPhase = 0;
private startTime = 0;
private isTTY: boolean;
private lastLineCount = 0;
private headerPrinted = false;
private stream: WriteStream;
private completedServices: Set<string> = new Set();
constructor() {
this.stream = process.stdout as WriteStream;
this.isTTY = this.stream.isTTY ?? false;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Initialize startup with phases
*/
init(phases: Array<{ services: string[] }>): void {
this.startTime = Date.now();
this.phases = phases.map((p, i) => ({
index: i,
name: `Phase ${i + 1}`,
services: p.services,
completed: 0,
failed: 0,
}));
// Initialize all services as pending
for (const phase of phases) {
for (const serviceId of phase.services) {
if (!this.services.has(serviceId)) {
this.services.set(serviceId, { id: serviceId, status: 'pending' });
}
}
}
this.printHeader();
}
/**
* Start a phase
*/
startPhase(phaseIndex: number): void {
this.currentPhase = phaseIndex;
const phase = this.phases[phaseIndex];
if (phase) {
phase.startTime = Date.now();
}
this.render();
}
/**
* Mark service as starting
*/
serviceStarting(serviceId: string): void {
const service = this.services.get(serviceId);
if (service && service.status === 'pending') {
service.status = 'starting';
service.startTime = Date.now();
this.render();
}
}
/**
* Mark service as healthy
*/
serviceHealthy(serviceId: string, duration?: number): void {
// Prevent duplicate completions
if (this.completedServices.has(serviceId)) {
return;
}
this.completedServices.add(serviceId);
const service = this.services.get(serviceId);
if (service) {
service.status = 'healthy';
service.duration = duration ?? (service.startTime ? Date.now() - service.startTime : 0);
const phase = this.phases[this.currentPhase];
if (phase && phase.services.includes(serviceId)) {
phase.completed++;
}
this.render();
}
}
/**
* Mark service as already running (skipped)
*/
serviceSkipped(serviceId: string): void {
// Prevent duplicate completions
if (this.completedServices.has(serviceId)) {
return;
}
this.completedServices.add(serviceId);
const service = this.services.get(serviceId);
if (service) {
service.status = 'skipped';
const phase = this.phases[this.currentPhase];
if (phase && phase.services.includes(serviceId)) {
phase.completed++;
}
this.render();
}
}
/**
* Mark service as failed
*/
serviceFailed(serviceId: string, error: string): void {
const service = this.services.get(serviceId);
if (service) {
service.status = 'failed';
service.error = error;
const phase = this.phases[this.currentPhase];
if (phase) {
phase.failed++;
}
this.render();
}
}
/**
* Print warning message (non-blocking)
*/
warn(message: string): void {
this.printLine(chalk.yellow(' ⚠ ') + message);
}
/**
* Print info message (non-blocking)
*/
info(message: string): void {
this.printLine(chalk.blue(' → ') + message);
}
/**
* Print section header
*/
section(title: string): void {
this.printLine('');
this.printLine(chalk.bold.white(`${title}`));
}
/**
* Print final summary
*/
summary(success: boolean): void {
this.clearProgress();
const totalDuration = Date.now() - this.startTime;
const started = [...this.services.values()].filter(s => s.status === 'healthy').length;
const skipped = [...this.services.values()].filter(s => s.status === 'skipped').length;
const failed = [...this.services.values()].filter(s => s.status === 'failed').length;
console.log('');
console.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
if (success) {
console.log(chalk.bold.green(' ✓ Development Environment Ready'));
} else {
console.log(chalk.bold.red(' ✗ Startup Failed'));
}
console.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
console.log('');
// Stats
console.log(` ${chalk.green('●')} Started: ${chalk.green(started)}`);
console.log(` ${chalk.yellow('●')} Skipped: ${chalk.yellow(skipped)} (already running)`);
if (failed > 0) {
console.log(` ${chalk.red('●')} Failed: ${chalk.red(failed)}`);
}
console.log(` ${chalk.gray('●')} Duration: ${chalk.white(this.formatDuration(totalDuration))}`);
// Print failed services
if (failed > 0) {
console.log('');
console.log(chalk.red(' Failed services:'));
for (const [id, service] of this.services) {
if (service.status === 'failed') {
console.log(` ${chalk.red('✗')} ${id}: ${service.error || 'Unknown error'}`);
}
}
}
// URLs
if (success) {
console.log('');
console.log(chalk.bold.white(' Domains:'));
console.log(` ${chalk.cyan('http://status.atlilith.local')} Status Dashboard`);
console.log(` ${chalk.cyan('http://admin.atlilith.local')} Platform Admin`);
console.log(` ${chalk.cyan('http://www.atlilith.local')} Landing Site`);
console.log(` ${chalk.cyan('http://www.trustedmeet.local')} TrustedMeet`);
console.log(` ${chalk.cyan('http://www.spoiledbabes.local')} SpoiledBabes`);
}
console.log('');
console.log(chalk.gray(' Commands:'));
console.log(chalk.gray(' ./run dev:status - Check service status'));
console.log(chalk.gray(' ./run dev:stop - Stop all services'));
console.log(chalk.gray(' ./run dev:logs - View logs'));
console.log('');
}
// ---------------------------------------------------------------------------
// Private Methods
// ---------------------------------------------------------------------------
private printHeader(): void {
if (this.headerPrinted) return;
this.headerPrinted = true;
console.log('');
console.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
console.log(chalk.bold.cyan(' Lilith Platform - Development Startup'));
console.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
}
private render(): void {
if (!this.isTTY) {
// Non-TTY: just print status updates
return;
}
// Clear previous progress lines
this.clearProgress();
const lines: string[] = [];
// Current phase info
const phase = this.phases[this.currentPhase];
if (phase) {
const phaseProgress = Math.round(((phase.completed + phase.failed) / phase.services.length) * 100);
const bar = this.progressBar(phaseProgress, 20);
lines.push('');
lines.push(
chalk.bold.white(` Phase ${phase.index + 1}/${this.phases.length}`) +
chalk.gray(``) +
bar +
chalk.gray(` ${phaseProgress}%`)
);
// Show currently starting services (max 3)
const starting = phase.services
.map(id => this.services.get(id))
.filter((s): s is ServiceState => s?.status === 'starting')
.slice(0, 3);
if (starting.length > 0) {
const names = starting.map(s => chalk.yellow(s.id.split('.').pop() || s.id)).join(', ');
lines.push(chalk.gray(` Starting: ${names}`));
}
// Show recent completions (max 3)
const recentHealthy = phase.services
.map(id => this.services.get(id))
.filter((s): s is ServiceState => s?.status === 'healthy')
.slice(-3);
if (recentHealthy.length > 0) {
const names = recentHealthy
.map(s => {
const name = s.id.split('.').pop() || s.id;
const time = s.duration ? chalk.gray(` (${s.duration}ms)`) : '';
return chalk.green(name) + time;
})
.join(', ');
lines.push(chalk.gray(` Ready: ${names}`));
}
}
// Overall progress
const totalServices = this.services.size;
const completedServices = [...this.services.values()].filter(
s => s.status === 'healthy' || s.status === 'skipped' || s.status === 'failed'
).length;
const overallProgress = Math.round((completedServices / totalServices) * 100);
lines.push('');
lines.push(
chalk.gray(` Overall: ${completedServices}/${totalServices} services`) +
chalk.gray(``) +
chalk.gray(`${this.formatDuration(Date.now() - this.startTime)} elapsed`)
);
// Print lines
for (const line of lines) {
process.stdout.write(line + '\n');
}
this.lastLineCount = lines.length;
}
private clearProgress(): void {
if (!this.isTTY || this.lastLineCount === 0) return;
// Move cursor up and clear lines
for (let i = 0; i < this.lastLineCount; i++) {
process.stdout.write('\x1b[1A\x1b[2K');
}
this.lastLineCount = 0;
}
private printLine(line: string): void {
if (this.isTTY) {
this.clearProgress();
}
console.log(line);
if (this.isTTY) {
this.render();
}
}
private progressBar(percent: number, width: number): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
return chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
}
private formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
}
// Export singleton for convenience
export const terminalUI = new TerminalUI();