Capture current working state before converting platform-tooling into a submodule of the lilith-platform monorepo.
378 lines
11 KiB
TypeScript
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();
|