chore(cli): 🔧 Update TypeScript files in CLI module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Quinn Ftw 2026-02-03 19:06:34 -08:00
parent 9e42c32208
commit 4646e9b803
9 changed files with 673 additions and 3 deletions

View file

@ -98,7 +98,8 @@
"mcp__opener__open_folder",
"Bash(~/.bun/bin/bun run build:*)",
"Bash(pip show:*)",
"Bash(python:*)"
"Bash(python:*)",
"Bash(bun run:*)"
]
},
"enableAllProjectMcpServers": true,

View file

@ -10,11 +10,20 @@
import { existsSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import type { DockerOps } from '../../core/docker';
import type { ServiceManager } from '../../core/services';
import { PostStartupMonitor } from '../../core/post-startup-monitor';
import { Logger } from '../../utils/logger';
import { colors } from '../../utils/colors';
import { getPortOwner } from '@lilith/service-orchestrator';
import {
extractRootDomains,
checkDnsConfig,
syncDnsConfig,
isDnsmasqInstalled,
isDnsmasqRunning,
} from './dns/@core';
// Re-export formatDuration from the canonical location
export { formatDuration } from './@core/formatters';
@ -52,6 +61,65 @@ export async function prepareDevEnvironment(projectRoot: string): Promise<void>
logger.debug(`Created runtime directory: ${dir}`);
}
}
// Auto-sync dnsmasq configuration for local development domains
await syncDnsIfNeeded();
}
/**
* Check and auto-sync dnsmasq configuration for local development.
*
* This ensures DNS is properly configured before starting the dev cluster.
* Matches production architecture where DNS resolves domains before nginx routes.
*/
async function syncDnsIfNeeded(): Promise<void> {
try {
// Check if dnsmasq is installed
if (!isDnsmasqInstalled()) {
logger.blank();
logger.warn(`${colors.warning('⚠')} dnsmasq is not installed`);
logger.info(' Local .local domains will not resolve.');
logger.info(' Install with: sudo dnf install dnsmasq');
logger.info(' Then run: ./run dns:sync');
logger.blank();
return;
}
const domains = extractRootDomains();
if (domains.length === 0) {
return; // No local domains configured
}
const check = checkDnsConfig(domains);
// Already in sync
if (check.missing.length === 0 && check.extra.length === 0) {
logger.debug('DNS configuration is in sync');
return;
}
// Start dnsmasq if not running
if (!isDnsmasqRunning()) {
logger.info('Starting dnsmasq service...');
const startResult = spawnSync('sudo', ['systemctl', 'enable', '--now', 'dnsmasq']);
if (startResult.status !== 0) {
logger.warn('Failed to start dnsmasq - DNS may not work');
return;
}
}
// Auto-sync (requires sudo)
logger.info('Syncing DNS configuration...');
const result = await syncDnsConfig(domains);
if (result.added > 0 || result.removed > 0) {
logger.success(`DNS synced: +${result.added} -${result.removed} domains`);
}
} catch (error) {
// Log but don't fail - DNS issues shouldn't block dev startup
logger.debug(`DNS sync skipped: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**

View file

@ -0,0 +1,17 @@
/**
* DNS management core exports
*/
export { extractRootDomains, extractAllLocalDomains } from './parser';
export {
checkDnsConfig,
syncDnsConfig,
isDnsmasqInstalled,
isDnsmasqRunning,
isResolvedRunning,
generateDnsmasqConfig,
generateResolvedConfig,
testDnsResolution,
type DnsCheckResult,
type DnsSyncResult,
} from './manager';

View file

@ -0,0 +1,248 @@
/**
* dnsmasq configuration manager
*
* Manages Lilith Platform entries in dnsmasq for local development DNS resolution.
*/
import { readFileSync, existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { spawnSync, spawn } from 'node:child_process';
const DNSMASQ_CONF_DIR = '/etc/dnsmasq.d';
const DNSMASQ_CONF_FILE = `${DNSMASQ_CONF_DIR}/lilith-local.conf`;
const RESOLVED_CONF_DIR = '/etc/systemd/resolved.conf.d';
const RESOLVED_CONF_FILE = `${RESOLVED_CONF_DIR}/lilith-dnsmasq.conf`;
// Marker for identifying managed content
const CONFIG_HEADER = `# Lilith Platform - Local Development DNS
# Generated by ./run dns:sync - DO NOT EDIT MANUALLY
#`;
export interface DnsCheckResult {
configured: string[];
missing: string[];
extra: string[];
dnsmasqInstalled: boolean;
dnsmasqRunning: boolean;
}
export interface DnsSyncResult {
added: number;
removed: number;
unchanged: number;
restartedDnsmasq: boolean;
restartedResolved: boolean;
}
/**
* Check if dnsmasq is installed
*/
export function isDnsmasqInstalled(): boolean {
const result = spawnSync('which', ['dnsmasq']);
return result.status === 0;
}
/**
* Check if dnsmasq service is running
*/
export function isDnsmasqRunning(): boolean {
const result = spawnSync('systemctl', ['is-active', '--quiet', 'dnsmasq']);
return result.status === 0;
}
/**
* Check if systemd-resolved is running
*/
export function isResolvedRunning(): boolean {
const result = spawnSync('systemctl', ['is-active', '--quiet', 'systemd-resolved']);
return result.status === 0;
}
/**
* Read current dnsmasq config and extract configured domains
*/
export function readConfiguredDomains(): string[] {
if (!existsSync(DNSMASQ_CONF_FILE)) {
return [];
}
try {
const content = readFileSync(DNSMASQ_CONF_FILE, 'utf-8');
const domains: string[] = [];
// Parse: address=/.atlilith.local/127.0.0.1
const regex = /^address=\/\.([^/]+)\/127\.0\.0\.1$/gm;
let match;
while ((match = regex.exec(content)) !== null) {
domains.push(match[1]);
}
return domains.sort();
} catch {
return [];
}
}
/**
* Check DNS configuration status
*/
export function checkDnsConfig(expectedDomains: string[]): DnsCheckResult {
const configured = readConfiguredDomains();
const configuredSet = new Set(configured);
const expectedSet = new Set(expectedDomains);
const missing = expectedDomains.filter(d => !configuredSet.has(d));
const extra = configured.filter(d => !expectedSet.has(d));
return {
configured,
missing,
extra,
dnsmasqInstalled: isDnsmasqInstalled(),
dnsmasqRunning: isDnsmasqRunning(),
};
}
/**
* Generate dnsmasq configuration content
*/
export function generateDnsmasqConfig(domains: string[]): string {
const lines = [
CONFIG_HEADER,
'',
'# Wildcard resolution for all platform .local domains',
];
for (const domain of domains.sort()) {
lines.push(`address=/.${domain}/127.0.0.1`);
}
lines.push('');
return lines.join('\n');
}
/**
* Generate systemd-resolved configuration
*/
export function generateResolvedConfig(domains: string[]): string {
const domainList = domains.map(d => `~${d}`).join(' ');
return `# Lilith Platform - Route .local domains to dnsmasq
# Generated by ./run dns:sync - DO NOT EDIT MANUALLY
[Resolve]
DNS=127.0.0.1
Domains=${domainList}
`;
}
/**
* Write content to a file using sudo tee (safe: no shell interpolation)
*/
async function writeWithSudo(path: string, content: string): Promise<boolean> {
// Ensure parent directory exists
const dir = dirname(path);
if (!existsSync(dir)) {
const mkdirResult = spawnSync('sudo', ['mkdir', '-p', dir]);
if (mkdirResult.status !== 0) {
return false;
}
}
// Write using sudo tee with stdin
return new Promise((resolve) => {
const proc = spawn('sudo', ['tee', path], {
stdio: ['pipe', 'pipe', 'inherit'],
});
proc.stdin.write(content);
proc.stdin.end();
proc.on('close', (code) => {
resolve(code === 0);
});
proc.on('error', () => {
resolve(false);
});
});
}
/**
* Restart a systemd service using sudo
*/
async function restartService(service: string): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('sudo', ['systemctl', 'restart', service], {
stdio: 'inherit',
});
proc.on('close', (code) => {
resolve(code === 0);
});
proc.on('error', () => {
resolve(false);
});
});
}
/**
* Sync DNS configuration with expected domains
*/
export async function syncDnsConfig(expectedDomains: string[]): Promise<DnsSyncResult> {
const check = checkDnsConfig(expectedDomains);
const result: DnsSyncResult = {
added: check.missing.length,
removed: check.extra.length,
unchanged: check.configured.length - check.extra.length,
restartedDnsmasq: false,
restartedResolved: false,
};
// Nothing to do if already synced
if (check.missing.length === 0 && check.extra.length === 0) {
return result;
}
// Generate and write dnsmasq config
const dnsmasqContent = generateDnsmasqConfig(expectedDomains);
const dnsmasqWritten = await writeWithSudo(DNSMASQ_CONF_FILE, dnsmasqContent);
if (!dnsmasqWritten) {
throw new Error('Failed to write dnsmasq configuration');
}
// Restart dnsmasq
if (isDnsmasqRunning()) {
result.restartedDnsmasq = await restartService('dnsmasq');
if (!result.restartedDnsmasq) {
throw new Error('Failed to restart dnsmasq');
}
}
// Update systemd-resolved if running
if (isResolvedRunning()) {
const resolvedContent = generateResolvedConfig(expectedDomains);
const resolvedWritten = await writeWithSudo(RESOLVED_CONF_FILE, resolvedContent);
if (resolvedWritten) {
result.restartedResolved = await restartService('systemd-resolved');
}
}
return result;
}
/**
* Test DNS resolution for a domain
*/
export function testDnsResolution(domain: string): { resolved: boolean; ip: string | null } {
const result = spawnSync('getent', ['hosts', domain]);
if (result.status === 0) {
const output = result.stdout.toString().trim();
const ip = output.split(/\s+/)[0];
return { resolved: true, ip };
}
return { resolved: false, ip: null };
}

View file

@ -0,0 +1,71 @@
/**
* Local DNS parser
*
* Extracts unique root domains from deployments/@domains/ configurations.
*/
import { discoverDomains, loadDomainConfig } from '../../domains/@core/parser';
/**
* Extract unique root domains (TLD+1) from deployment configurations.
*
* Scans all deployments and returns the set of root .local domains
* that need to be configured in dnsmasq for wildcard resolution.
*
* Example output: ['atlilith.local', 'trustedmeet.local', 'lilithfan.local']
*/
export function extractRootDomains(): string[] {
const deployments = discoverDomains();
const rootDomains = new Set<string>();
for (const deployment of deployments) {
try {
const config = loadDomainConfig(deployment);
// Get the dev domain from deployments.dev.domain
const devDomain = config.deployments?.dev?.domain;
if (devDomain && devDomain.endsWith('.local')) {
// Extract root domain (last two parts): www.atlilith.local -> atlilith.local
const parts = devDomain.split('.');
if (parts.length >= 2) {
const root = parts.slice(-2).join('.');
rootDomains.add(root);
}
}
} catch {
// Skip domains with parse errors - they'll be caught by domains:validate
}
}
// Sort for consistent ordering
return [...rootDomains].sort();
}
/**
* Get all individual .local domains (for DNS testing)
*/
export function extractAllLocalDomains(): string[] {
const deployments = discoverDomains();
const domains: string[] = [];
for (const deployment of deployments) {
try {
const config = loadDomainConfig(deployment);
const devDomain = config.deployments?.dev?.domain;
if (devDomain && devDomain.endsWith('.local')) {
domains.push(devDomain);
// Also add the base domain without 'www.' if present
if (devDomain.startsWith('www.')) {
domains.push(devDomain.replace('www.', ''));
}
}
} catch {
// Skip domains with parse errors
}
}
return domains.sort();
}

View file

@ -0,0 +1,7 @@
/**
* DNS commands
*
* IAC for dnsmasq configuration - syncs local development domains.
*/
export { dnsSync, dnsCheck, dnsTest } from './sync';

View file

@ -0,0 +1,235 @@
/**
* dns:sync command
*
* Synchronize dnsmasq configuration with local development domains from deployments/@domains/
*/
import { spawnSync } from 'node:child_process';
import type { CommandContext, CommandResult } from '../@core/types';
import { extractRootDomains, extractAllLocalDomains } from './@core/parser';
import {
checkDnsConfig,
syncDnsConfig,
isDnsmasqInstalled,
isDnsmasqRunning,
testDnsResolution,
} from './@core/manager';
import { colors } from '../../../utils/colors';
/**
* dns:sync - Sync dnsmasq config with local development domains
*/
export async function dnsSync(ctx: CommandContext): Promise<CommandResult> {
const dryRun = ctx.args.includes('--dry-run');
const quiet = ctx.args.includes('--quiet') || ctx.args.includes('-q');
// Header
if (!quiet) {
console.log(colors.primary('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
console.log(colors.primary.bold(' Syncing dnsmasq configuration'));
console.log(colors.primary('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
console.log();
}
// Check dnsmasq is installed
if (!isDnsmasqInstalled()) {
console.log(colors.error(' ✗ dnsmasq is not installed'));
console.log();
console.log(colors.muted(' Install with:'));
console.log(colors.muted(' Fedora/RHEL: sudo dnf install dnsmasq'));
console.log(colors.muted(' Ubuntu/Debian: sudo apt install dnsmasq'));
console.log(colors.muted(' Arch: sudo pacman -S dnsmasq'));
return { code: 1 };
}
// Extract root domains from deployments
const domains = extractRootDomains();
if (domains.length === 0) {
console.log(colors.warning(' No local domains found in deployments/@domains/'));
return { code: 0 };
}
// Check current state
const check = checkDnsConfig(domains);
if (!quiet) {
// Show current state
console.log(colors.muted(` Found ${domains.length} root domains from deployments`));
console.log();
if (check.configured.length > 0 && check.missing.length === 0 && check.extra.length === 0) {
console.log(colors.success(`${check.configured.length} domains already configured`));
}
if (check.missing.length > 0) {
console.log(colors.warning(` + ${check.missing.length} to add:`));
for (const domain of check.missing) {
console.log(colors.muted(` *.${domain} → 127.0.0.1`));
}
}
if (check.extra.length > 0) {
console.log(colors.warning(` - ${check.extra.length} stale entries to remove:`));
for (const domain of check.extra) {
console.log(colors.muted(` *.${domain}`));
}
}
console.log();
}
// Nothing to do
if (check.missing.length === 0 && check.extra.length === 0) {
if (!quiet) {
console.log(colors.success(' All DNS entries are up to date.'));
}
return { code: 0 };
}
// Dry run
if (dryRun) {
console.log(colors.info(' Dry run - no changes made.'));
return { code: 0 };
}
// Check dnsmasq is running
if (!isDnsmasqRunning()) {
console.log(colors.warning(' dnsmasq is not running. Starting...'));
const startResult = spawnSync('sudo', ['systemctl', 'enable', '--now', 'dnsmasq']);
if (startResult.status !== 0) {
console.log(colors.error(' ✗ Failed to start dnsmasq'));
return { code: 1 };
}
}
// Sync
if (!quiet) {
console.log(colors.info(' Requires sudo to write /etc/dnsmasq.d/'));
}
try {
const result = await syncDnsConfig(domains);
if (!quiet) {
console.log();
console.log(colors.success.bold(' ✓ DNS configuration synchronized'));
console.log(colors.muted(` Added: ${result.added}, Removed: ${result.removed}, Unchanged: ${result.unchanged}`));
if (result.restartedDnsmasq) {
console.log(colors.muted(' Restarted dnsmasq'));
}
if (result.restartedResolved) {
console.log(colors.muted(' Restarted systemd-resolved'));
}
}
return { code: 0 };
} catch (error) {
console.log();
console.log(colors.error.bold(' ✗ Failed to sync DNS configuration'));
console.log(colors.error(` ${error instanceof Error ? error.message : String(error)}`));
return { code: 1 };
}
}
/**
* dns:check - Check if DNS config is synced (non-modifying)
*/
export async function dnsCheck(ctx: CommandContext): Promise<CommandResult> {
const quiet = ctx.args.includes('--quiet') || ctx.args.includes('-q');
const domains = extractRootDomains();
const check = checkDnsConfig(domains);
// Check dnsmasq status
if (!check.dnsmasqInstalled) {
if (!quiet) {
console.log(colors.error(' ✗ dnsmasq is not installed'));
}
return { code: 1 };
}
if (!check.dnsmasqRunning) {
if (!quiet) {
console.log(colors.warning(' ⚠ dnsmasq is not running'));
console.log(colors.info(' Run: sudo systemctl enable --now dnsmasq'));
}
return { code: 1 };
}
if (check.missing.length === 0 && check.extra.length === 0) {
if (!quiet) {
console.log(colors.success(` ✓ DNS configuration is up to date (${check.configured.length} domains)`));
}
return { code: 0 };
}
if (!quiet) {
if (check.missing.length > 0) {
console.log(colors.warning(` Missing ${check.missing.length} DNS entries:`));
for (const domain of check.missing) {
console.log(colors.muted(` *.${domain}`));
}
}
if (check.extra.length > 0) {
console.log(colors.warning(` ${check.extra.length} stale entries:`));
for (const domain of check.extra) {
console.log(colors.muted(` *.${domain}`));
}
}
console.log();
console.log(colors.info(' Run: ./run dns:sync'));
}
return { code: 1 };
}
/**
* dns:test - Test DNS resolution for all local domains
*/
export async function dnsTest(ctx: CommandContext): Promise<CommandResult> {
const quiet = ctx.args.includes('--quiet') || ctx.args.includes('-q');
if (!quiet) {
console.log(colors.primary('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
console.log(colors.primary.bold(' Testing DNS Resolution'));
console.log(colors.primary('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
console.log();
}
const domains = extractAllLocalDomains();
let allOk = true;
for (const domain of domains) {
const result = testDnsResolution(domain);
if (result.resolved && result.ip === '127.0.0.1') {
if (!quiet) {
console.log(colors.success(`${domain}${result.ip}`));
}
} else if (result.resolved) {
if (!quiet) {
console.log(colors.warning(` ! ${domain}${result.ip} (expected 127.0.0.1)`));
}
allOk = false;
} else {
if (!quiet) {
console.log(colors.error(`${domain} → (not resolved)`));
}
allOk = false;
}
}
if (!quiet) {
console.log();
if (allOk) {
console.log(colors.success.bold(' All domains resolve correctly!'));
} else {
console.log(colors.warning(' Some domains are not resolving correctly'));
console.log(colors.info(' Run: ./run dns:sync'));
}
}
return { code: allOk ? 0 : 1 };
}

View file

@ -90,6 +90,11 @@ const lazyCommands: Record<string, [string, string]> = {
// E2E Testing
'e2e:prod': ['./commands/e2e/index', 'e2eProd'],
// DNS Management (IAC for dnsmasq)
'dns:sync': ['./commands/dns/index', 'dnsSync'],
'dns:check': ['./commands/dns/index', 'dnsCheck'],
'dns:test': ['./commands/dns/index', 'dnsTest'],
};
/**
@ -203,6 +208,14 @@ ${colors.accent('E2E Testing:')}
Auth bypass disabled (import.meta.env.DEV = false)
Flags: --headed, --grep=<pattern>, --keep, --build-only
${colors.accent('DNS Management (dnsmasq):')}
dns:sync Sync dnsmasq config with .local domains from deployments
Updates /etc/dnsmasq.d/lilith-local.conf (sudo required)
Flags: --dry-run, --quiet
dns:check Check if dnsmasq config is current (non-modifying)
Exit 0 if synced, exit 1 if changes needed
dns:test Test DNS resolution for all .local domains
${colors.accent('Codebase Maintenance:')}
codebase fix-scripts Fix package.json scripts (nest npx @nestjs/cli)
codebase audit-deps Audit for missing dependencies (--fix to auto-add)

View file

@ -108,6 +108,15 @@ const WARNING_PATTERNS = [
/DEPRECATED/i,
];
/** Patterns that indicate success even if output came via stderr */
const COMPLETION_PATTERNS = [
/Successfully compiled/i,
/Successfully built/i,
/compilation complete/i,
/Build completed/i,
/compiled successfully/i,
];
const REQUEST_PATTERNS = [
// NestJS format: "GET /api/health 200 12ms"
/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+\S+\s+(\d{3})/,
@ -214,8 +223,9 @@ export class PostStartupMonitor {
}
}
// Check for errors
if (isError || this.matchesPatterns(output, ERROR_PATTERNS)) {
// Check for errors (but exclude completion messages that came via stderr)
if ((isError || this.matchesPatterns(output, ERROR_PATTERNS))
&& !this.matchesPatterns(output, COMPLETION_PATTERNS)) {
this.addAlert(serviceId, 'error', this.extractMessage(output));
this.incrementMetric(serviceId, 'errors');
return;