feat(cli): ✨ Implement status command with CLI handler logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
acc091eb6c
commit
e092cd9e72
2 changed files with 531 additions and 0 deletions
515
run/cli/commands/status/index.ts
Normal file
515
run/cli/commands/status/index.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue