diff --git a/run/cli/commands/status/index.ts b/run/cli/commands/status/index.ts new file mode 100644 index 0000000..445a4d9 --- /dev/null +++ b/run/cli/commands/status/index.ts @@ -0,0 +1,515 @@ +/** + * Status command — unified platform overview + * + * Shows host reachability (SSH TCP) and domain health (HTTP/DNS/SMTP) + * grouped by priority tier from deployments/priority.yaml. + * + * Usage: + * ./run status Full overview (hosts + domains) + * ./run status:hosts Hosts only + * ./run status:domains Domains by priority tier + * ./run status --watch [n] Live refresh every n seconds (default 5) + * ./run status --tier N Filter domains to priority tier N + * ./run status --dev Force local domain checks (*.local) + * ./run status --prod Force production domain checks + * + * Environment auto-detection (priority order): + * 1. --dev / --prod CLI flags + * 2. deployments/.env AUTODETECT_ENVIRONMENT=local|prod + * 3. DNS probe: resolve status.atlilith.local → local, else prod + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import * as dnsPromises from 'node:dns/promises'; +import * as net from 'node:net'; +import { parse as parseYaml } from 'yaml'; +import { loadConfig } from '../../../utils/config'; +import { colors } from '../../../utils/colors'; +import type { CommandContext, CommandResult } from '../@core/types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type CheckType = 'dns' | 'http' | 'smtp' | 'tcp'; +type StatusEnv = 'local' | 'prod'; + +interface PriorityEntry { + purpose: string; + domains: string[]; + localDomains?: string[]; + check: CheckType; + deploymentId?: string; + deploymentIds?: string[]; + note?: string; +} + +interface PriorityTier { + priority: number; + label: string; + entries: PriorityEntry[]; +} + +interface PriorityConfig { + tiers: PriorityTier[]; +} + +interface HostSsh { + host: string; + ip: string; + port?: number; + user: string; +} + +interface HostYaml { + id: string; + hostname: string; + displayName: string; + networkGroup: string; + ssh: HostSsh; + status?: string; +} + +interface HostQuickRef { + status?: string; +} + +interface NetworkGroup { + description: string; + hosts: string[]; +} + +interface HostsIndex { + networkGroups: Record; + hosts?: Record; +} + +interface HostEntry { + yaml: HostYaml; + networkGroup: string; + groupDescription: string; +} + +interface CheckResult { + ok: boolean; + detail: string; + latencyMs?: number; +} + +interface DomainResult { + domain: string; + purpose: string; + check: CheckType; + result: CheckResult; +} + +interface TierResult { + priority: number; + label: string; + domains: DomainResult[]; +} + +interface HostResult { + entry: HostEntry; + result: CheckResult; +} + +interface StatusData { + env: StatusEnv; + timestamp: Date; + /** networkGroup → results, preserving declaration order */ + hostsByGroup: Map; + tiers: TierResult[]; +} + +// ─── YAML Loading ───────────────────────────────────────────────────────────── + +function loadPriorityConfig(projectRoot: string): PriorityConfig { + const path = join(projectRoot, 'deployments/priority.yaml'); + if (!existsSync(path)) return { tiers: [] }; + return parseYaml(readFileSync(path, 'utf-8')) as PriorityConfig; +} + +function loadHostEntries(projectRoot: string): HostEntry[] { + const indexPath = join(projectRoot, 'deployments/hosts/index.yaml'); + if (!existsSync(indexPath)) return []; + + const index = parseYaml(readFileSync(indexPath, 'utf-8')) as HostsIndex; + const hostsDir = join(projectRoot, 'deployments/hosts'); + const entries: HostEntry[] = []; + + for (const [groupKey, group] of Object.entries(index.networkGroups)) { + for (const hostFile of group.hosts) { + const hostPath = join(hostsDir, hostFile); + if (!existsSync(hostPath)) continue; + + try { + const yaml = parseYaml(readFileSync(hostPath, 'utf-8')) as HostYaml; + + // Check decommissioned from both the individual file and index quick-ref + const quickRef = index.hosts?.[yaml.id]; + if (yaml.status === 'decommissioned' || quickRef?.status === 'decommissioned') continue; + + entries.push({ yaml, networkGroup: groupKey, groupDescription: group.description }); + } catch { + // Skip unparseable host files silently + } + } + } + + return entries; +} + +// ─── Environment Detection ──────────────────────────────────────────────────── + +function readConfiguredEnv(projectRoot: string): StatusEnv | null { + const envPath = join(projectRoot, 'deployments/.env'); + if (!existsSync(envPath)) return null; + + const content = readFileSync(envPath, 'utf-8'); + const match = content.match(/^AUTODETECT_ENVIRONMENT=(.+)$/m); + if (!match || match[1] === undefined) return null; + + const val = match[1].trim(); + if (val === 'local') return 'local'; + if (val === 'prod') return 'prod'; + return null; +} + +async function detectEnv(args: string[], projectRoot: string): Promise { + if (args.includes('--dev')) return 'local'; + if (args.includes('--prod')) return 'prod'; + + const configured = readConfiguredEnv(projectRoot); + if (configured !== null) return configured; + + // Autodetect: probe status.atlilith.local via DNS + try { + await dnsPromises.resolve('status.atlilith.local'); + return 'local'; + } catch { + return 'prod'; + } +} + +// ─── Network Checks ─────────────────────────────────────────────────────────── + +function tcpProbe( + host: string, + port: number, + timeoutMs: number, +): Promise<{ connected: boolean; latencyMs: number }> { + return new Promise((resolve) => { + const start = Date.now(); + const socket = net.createConnection({ host, port }); + + const timer = setTimeout(() => { + socket.destroy(); + resolve({ connected: false, latencyMs: timeoutMs }); + }, timeoutMs); + + socket.on('connect', () => { + clearTimeout(timer); + socket.destroy(); + resolve({ connected: true, latencyMs: Date.now() - start }); + }); + + socket.on('error', () => { + clearTimeout(timer); + resolve({ connected: false, latencyMs: Date.now() - start }); + }); + }); +} + +async function checkHost(entry: HostEntry): Promise { + const { ip, port } = entry.yaml.ssh; + const { connected, latencyMs } = await tcpProbe(ip, port ?? 22, 3000); + return connected + ? { ok: true, detail: 'reachable', latencyMs } + : { ok: false, detail: 'unreachable' }; +} + +async function checkDomain(domain: string, check: CheckType, env: StatusEnv): Promise { + const start = Date.now(); + + switch (check) { + case 'dns': { + try { + await dnsPromises.resolve(domain); + return { ok: true, detail: 'resolves', latencyMs: Date.now() - start }; + } catch { + return { ok: false, detail: 'no record' }; + } + } + + case 'http': { + const scheme = env === 'prod' ? 'https' : 'http'; + try { + const res = await fetch(`${scheme}://${domain}`, { + signal: AbortSignal.timeout(5000), + redirect: 'follow', + }); + return { + ok: res.status < 500, + detail: String(res.status), + latencyMs: Date.now() - start, + }; + } catch (e) { + if (e instanceof Error && e.name === 'TimeoutError') { + return { ok: false, detail: 'timeout' }; + } + return { ok: false, detail: 'unreachable' }; + } + } + + case 'smtp': { + const { connected, latencyMs } = await tcpProbe(domain, 25, 5000); + return connected + ? { ok: true, detail: 'port 25 open', latencyMs } + : { ok: false, detail: 'port 25 closed' }; + } + + case 'tcp': { + const { connected, latencyMs } = await tcpProbe(domain, 80, 5000); + return connected + ? { ok: true, detail: 'reachable', latencyMs } + : { ok: false, detail: 'unreachable' }; + } + + default: { + const exhaustive: never = check; + return { ok: false, detail: `unknown check type: ${exhaustive}` }; + } + } +} + +// ─── Data Collection ────────────────────────────────────────────────────────── + +async function collectStatus( + args: string[], + projectRoot: string, + tierFilter: number | null, +): Promise { + const [env] = await Promise.all([detectEnv(args, projectRoot)]); + const priorityConfig = loadPriorityConfig(projectRoot); + const hostEntries = loadHostEntries(projectRoot); + + // Run all host checks concurrently + const hostChecks = await Promise.all( + hostEntries.map(async (entry) => ({ entry, result: await checkHost(entry) })), + ); + + // Group host results by network group, preserving declaration order + const hostsByGroup = new Map(); + for (const check of hostChecks) { + const group = check.entry.networkGroup; + if (!hostsByGroup.has(group)) hostsByGroup.set(group, []); + hostsByGroup.get(group)!.push(check); + } + + // Filter tiers and run all domain checks concurrently + const filteredTiers = + tierFilter === null + ? priorityConfig.tiers + : priorityConfig.tiers.filter((t) => t.priority === tierFilter); + + const tiers = await Promise.all( + filteredTiers.map(async (tier) => { + const domains = await Promise.all( + tier.entries.flatMap((entry) => { + const activeDomains = + env === 'local' && entry.localDomains?.length ? entry.localDomains : entry.domains; + return activeDomains.map(async (domain) => ({ + domain, + purpose: entry.purpose, + check: entry.check, + result: await checkDomain(domain, entry.check, env), + })); + }), + ); + return { priority: tier.priority, label: tier.label, domains }; + }), + ); + + return { env, timestamp: new Date(), hostsByGroup, tiers }; +} + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +const BOX_WIDTH = 72; + +function renderHeader(): string { + const inner = BOX_WIDTH - 2; + const title = 'Lilith Platform Status'; + const pad = inner - title.length; + const left = Math.floor(pad / 2); + const right = pad - left; + return [ + `╔${'═'.repeat(inner)}╗`, + `║${' '.repeat(left)}${title}${' '.repeat(right)}║`, + `╚${'═'.repeat(inner)}╝`, + ].join('\n'); +} + +function statusIcon(ok: boolean): string { + return ok ? colors.symbols.success : colors.symbols.error; +} + +function latencyTag(ms?: number): string { + if (ms === undefined) return ''; + return colors.muted(`${ms}ms`); +} + +function roleFromDisplayName(hostname: string, displayName: string): string { + const prefix = hostname.charAt(0).toUpperCase() + hostname.slice(1); + if (displayName.startsWith(`${prefix} (`)) { + return displayName.slice(prefix.length + 2, -1); + } + return displayName; +} + +function renderHosts(hostsByGroup: Map): string { + const lines: string[] = ['', ` ${colors.accent('HOSTS')}`, '']; + + for (const [group, results] of hostsByGroup) { + lines.push(` ${colors.muted(group)}`); + + const maxHostname = Math.max(...results.map((r) => r.entry.yaml.hostname.length)); + const maxIp = Math.max(...results.map((r) => (r.entry.yaml.ssh.ip ?? '').length)); + const maxRole = Math.max( + ...results.map((r) => + roleFromDisplayName(r.entry.yaml.hostname, r.entry.yaml.displayName).length, + ), + ); + + for (const { entry, result } of results) { + const { hostname, ssh, displayName } = entry.yaml; + const name = hostname.padEnd(maxHostname); + const ip = colors.muted((ssh.ip ?? '').padEnd(maxIp)); + const role = colors.secondary(roleFromDisplayName(hostname, displayName).padEnd(maxRole)); + const detail = result.ok ? colors.success(result.detail) : colors.error(result.detail); + const lat = latencyTag(result.latencyMs); + lines.push(` ${statusIcon(result.ok)} ${name} ${ip} ${role} ${detail} ${lat}`); + } + + lines.push(''); + } + + return lines.join('\n'); +} + +function renderDomains(tiers: TierResult[], env: StatusEnv): string { + const lines: string[] = ['', ` ${colors.accent('DOMAINS')}`, '']; + + if (tiers.length === 0) { + lines.push(` ${colors.muted('(no tiers configured)')}`); + return lines.join('\n'); + } + + for (const tier of tiers) { + lines.push(` ${colors.primary(`P${tier.priority} · ${tier.label}`)}`); + + const maxPurpose = Math.max(...tier.domains.map((d) => d.purpose.length)); + const maxDomain = Math.max(...tier.domains.map((d) => d.domain.length)); + + for (const { domain, purpose, result } of tier.domains) { + const p = colors.muted(purpose.padEnd(maxPurpose)); + const d = colors.secondary(domain.padEnd(maxDomain)); + const detail = result.ok ? colors.success(result.detail) : colors.error(result.detail); + const lat = latencyTag(result.latencyMs); + lines.push(` ${statusIcon(result.ok)} ${p} ${d} ${detail} ${lat}`); + } + + lines.push(''); + } + + const envNote = + env === 'local' + ? '(local: checking *.local domains)' + : '(prod: checking production domains)'; + lines.push(` ${colors.muted(envNote)}`); + + return lines.join('\n'); +} + +function renderStatus(data: StatusData, showHosts: boolean, showDomains: boolean): string { + const parts: string[] = ['', renderHeader(), '']; + if (showHosts) parts.push(renderHosts(data.hostsByGroup)); + if (showDomains) parts.push(renderDomains(data.tiers, data.env)); + parts.push(` ${colors.muted(data.timestamp.toLocaleTimeString())}`, ''); + return parts.join('\n'); +} + +// ─── Flag Parsing ───────────────────────────────────────────────────────────── + +function getWatchInterval(args: string[]): number | null { + const idx = args.findIndex((a) => a === '--watch' || a.startsWith('--watch=')); + if (idx === -1) return null; + + const arg = args[idx]; + if (arg === undefined) return 5000; + if (arg.startsWith('--watch=')) return parseInt(arg.slice(8), 10) * 1000; + + const next = args[idx + 1]; + if (next && /^\d+$/.test(next)) return parseInt(next, 10) * 1000; + + return 5000; +} + +function getTierFilter(args: string[]): number | null { + const eqIdx = args.findIndex((a) => a.startsWith('--tier=')); + if (eqIdx !== -1) { + const eqArg = args[eqIdx]; + if (eqArg !== undefined) return parseInt(eqArg.slice(7), 10); + } + + const idx = args.indexOf('--tier'); + const nextArg = args[idx + 1]; + if (idx !== -1 && nextArg !== undefined) return parseInt(nextArg, 10); + + return null; +} + +// ─── Command Handlers ───────────────────────────────────────────────────────── + +async function runStatus( + ctx: CommandContext, + showHosts: boolean, + showDomains: boolean, +): Promise { + const config = loadConfig(); + const tierFilter = getTierFilter(ctx.args); + const watchInterval = getWatchInterval(ctx.args); + + const run = async (): Promise => { + const data = await collectStatus(ctx.args, config.projectRoot, tierFilter); + process.stdout.write(renderStatus(data, showHosts, showDomains)); + }; + + if (watchInterval !== null) { + process.on('SIGINT', () => { + console.log(''); + process.exit(0); + }); + + for (;;) { + process.stdout.write('\x1Bc'); + await run(); + await new Promise((resolve) => setTimeout(resolve, watchInterval)); + } + } + + await run(); + return { code: 0 }; +} + +export async function status(ctx: CommandContext): Promise { + return runStatus(ctx, true, true); +} + +export async function statusHosts(ctx: CommandContext): Promise { + return runStatus(ctx, true, false); +} + +export async function statusDomains(ctx: CommandContext): Promise { + return runStatus(ctx, false, true); +} diff --git a/run/cli/index.ts b/run/cli/index.ts index 87f71d5..bb10a60 100644 --- a/run/cli/index.ts +++ b/run/cli/index.ts @@ -112,6 +112,11 @@ const lazyCommands: Record = { 'dns:check': ['./commands/dns/index', 'dnsCheck'], 'dns:test': ['./commands/dns/index', 'dnsTest'], + // Infrastructure status (hosts + domain tiers) + 'status': ['./commands/status/index', 'status'], + 'status:hosts': ['./commands/status/index', 'statusHosts'], + 'status:domains': ['./commands/status/index', 'statusDomains'], + // Crystal — Knowledge verification AI 'crystal': ['./commands/crystal', 'crystal'], @@ -323,6 +328,17 @@ ${colors.accent('SEO Frame Evaluation:')} seo:frames-all Evaluate all branded frames from TERMS.md Flags: --json +${colors.accent('Infrastructure Status:')} + status Unified overview: hosts (SSH) + domains by priority tier + status:hosts Hosts only — SSH reachability per network group + status:domains Domains only — health checks grouped by priority tier + Flags: --watch [n] Live refresh every n seconds (default 5) + --tier N Filter to priority tier N (0, 1, 2) + --dev Force *.local domain checks + --prod Force production domain checks + Environment: reads deployments/.env AUTODETECT_ENVIRONMENT + auto-probes status.atlilith.local if unset + ${colors.accent('Crystal — Knowledge AI:')} crystal Start interactive Crystal chat (default: chat) crystal chat Interactive knowledge assistant REPL