From 4646e9b80379278e79a18640f42441efd3b1ffc2 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Tue, 3 Feb 2026 19:06:34 -0800 Subject: [PATCH] =?UTF-8?q?chore(cli):=20=F0=9F=94=A7=20Update=20TypeScrip?= =?UTF-8?q?t=20files=20in=20CLI=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- claude/dot-claude/settings.local.json | 3 +- run/cli/commands/dev-helpers.ts | 68 +++++++ run/cli/commands/dns/@core/index.ts | 17 ++ run/cli/commands/dns/@core/manager.ts | 248 ++++++++++++++++++++++++++ run/cli/commands/dns/@core/parser.ts | 71 ++++++++ run/cli/commands/dns/index.ts | 7 + run/cli/commands/dns/sync.ts | 235 ++++++++++++++++++++++++ run/cli/index.ts | 13 ++ run/core/post-startup-monitor.ts | 14 +- 9 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 run/cli/commands/dns/@core/index.ts create mode 100644 run/cli/commands/dns/@core/manager.ts create mode 100644 run/cli/commands/dns/@core/parser.ts create mode 100644 run/cli/commands/dns/index.ts create mode 100644 run/cli/commands/dns/sync.ts diff --git a/claude/dot-claude/settings.local.json b/claude/dot-claude/settings.local.json index 5d6c2d3..7b63717 100644 --- a/claude/dot-claude/settings.local.json +++ b/claude/dot-claude/settings.local.json @@ -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, diff --git a/run/cli/commands/dev-helpers.ts b/run/cli/commands/dev-helpers.ts index a874206..b8a91ad 100644 --- a/run/cli/commands/dev-helpers.ts +++ b/run/cli/commands/dev-helpers.ts @@ -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 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 { + 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)}`); + } } /** diff --git a/run/cli/commands/dns/@core/index.ts b/run/cli/commands/dns/@core/index.ts new file mode 100644 index 0000000..c943f68 --- /dev/null +++ b/run/cli/commands/dns/@core/index.ts @@ -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'; diff --git a/run/cli/commands/dns/@core/manager.ts b/run/cli/commands/dns/@core/manager.ts new file mode 100644 index 0000000..0f7d48f --- /dev/null +++ b/run/cli/commands/dns/@core/manager.ts @@ -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 { + // 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 { + 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 { + 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 }; +} diff --git a/run/cli/commands/dns/@core/parser.ts b/run/cli/commands/dns/@core/parser.ts new file mode 100644 index 0000000..2673a08 --- /dev/null +++ b/run/cli/commands/dns/@core/parser.ts @@ -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(); + + 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(); +} diff --git a/run/cli/commands/dns/index.ts b/run/cli/commands/dns/index.ts new file mode 100644 index 0000000..78d81f9 --- /dev/null +++ b/run/cli/commands/dns/index.ts @@ -0,0 +1,7 @@ +/** + * DNS commands + * + * IAC for dnsmasq configuration - syncs local development domains. + */ + +export { dnsSync, dnsCheck, dnsTest } from './sync'; diff --git a/run/cli/commands/dns/sync.ts b/run/cli/commands/dns/sync.ts new file mode 100644 index 0000000..7a3675f --- /dev/null +++ b/run/cli/commands/dns/sync.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/run/cli/index.ts b/run/cli/index.ts index 5bdb724..e3f2c41 100644 --- a/run/cli/index.ts +++ b/run/cli/index.ts @@ -90,6 +90,11 @@ const lazyCommands: Record = { // 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=, --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) diff --git a/run/core/post-startup-monitor.ts b/run/core/post-startup-monitor.ts index 0657d32..39abf4f 100644 --- a/run/core/post-startup-monitor.ts +++ b/run/core/post-startup-monitor.ts @@ -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;