feat(cli): Implement status command with CLI handler logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-03-18 21:54:52 -07:00
parent acc091eb6c
commit e092cd9e72
2 changed files with 531 additions and 0 deletions

View file

@ -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<string, NetworkGroup>;
hosts?: Record<string, HostQuickRef>;
}
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<string, HostResult[]>;
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<StatusEnv> {
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<CheckResult> {
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<CheckResult> {
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<StatusData> {
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<string, HostResult[]>();
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, HostResult[]>): 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<CommandResult> {
const config = loadConfig();
const tierFilter = getTierFilter(ctx.args);
const watchInterval = getWatchInterval(ctx.args);
const run = async (): Promise<void> => {
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<void>((resolve) => setTimeout(resolve, watchInterval));
}
}
await run();
return { code: 0 };
}
export async function status(ctx: CommandContext): Promise<CommandResult> {
return runStatus(ctx, true, true);
}
export async function statusHosts(ctx: CommandContext): Promise<CommandResult> {
return runStatus(ctx, true, false);
}
export async function statusDomains(ctx: CommandContext): Promise<CommandResult> {
return runStatus(ctx, false, true);
}

View file

@ -112,6 +112,11 @@ const lazyCommands: Record<string, [string, string]> = {
'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