From 155310a83a59d1fd39735b2c63028e400d142b88 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:25:31 -0700 Subject: [PATCH 01/13] feat(diagnose): add metrics subcommand with EC2 metrics display --- src/commands/diagnose/metrics.ts | 133 +++++++++++++++++++++++++++++++ src/lib/api/platform.ts | 2 +- 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/commands/diagnose/metrics.ts diff --git a/src/commands/diagnose/metrics.ts b/src/commands/diagnose/metrics.ts new file mode 100644 index 0000000..f6a996c --- /dev/null +++ b/src/commands/diagnose/metrics.ts @@ -0,0 +1,133 @@ +import type { Command } from 'commander'; +import { platformFetch } from '../../lib/api/platform.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +interface MetricDataPoint { + timestamp: number; + value: number; +} + +interface MetricSeries { + metric: string; + instance_id: string; + data: MetricDataPoint[]; +} + +interface MetricsResponse { + project_id: string; + range: string; + metrics: MetricSeries[]; + _meta?: { requested_at: string; query_time_ms: number; cached: boolean }; +} + +const METRIC_LABELS: Record = { + cpu_usage: 'CPU Usage', + memory_usage: 'Memory Usage', + disk_usage: 'Disk Usage', + network_in: 'Network In', + network_out: 'Network Out', +}; + +const NETWORK_METRICS = new Set(['network_in', 'network_out']); + +function formatValue(metric: string, value: number): string { + if (NETWORK_METRICS.has(metric)) { + return formatBytes(value) + '/s'; + } + return `${value.toFixed(1)}%`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes.toFixed(1)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function computeStats(data: MetricDataPoint[]): { latest: number; avg: number; max: number } { + if (data.length === 0) return { latest: 0, avg: 0, max: 0 }; + const latest = data[data.length - 1].value; + const avg = data.reduce((sum, d) => sum + d.value, 0) / data.length; + const max = Math.max(...data.map((d) => d.value)); + return { latest, avg, max }; +} + +/** Returns true when linked via --api-key (OSS/self-hosted) — no Platform API access. */ +export function isOssMode(): boolean { + const config = getProjectConfig(); + return config?.project_id === 'oss-project'; +} + +export async function fetchMetricsSummary( + projectId: string, + apiUrl?: string, +): Promise { + const res = await platformFetch(`/projects/v1/${projectId}/metrics?range=1h`, {}, apiUrl); + return (await res.json()) as MetricsResponse; +} + +export function registerDiagnoseMetricsCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('metrics') + .description('Display EC2 instance metrics (CPU, memory, disk, network)') + .option('--range ', 'Time range: 1h, 6h, 24h, 7d', '1h') + .option('--metrics ', 'Comma-separated metrics to query') + .action(async (opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(); + const config = getProjectConfig(); + if (!config) throw new ProjectNotLinkedError(); + if (isOssMode()) { + throw new CLIError( + 'Metrics requires InsForge Platform login. Not available when linked via --api-key.', + ); + } + + const params = new URLSearchParams({ range: opts.range }); + if (opts.metrics) params.set('metrics', opts.metrics); + + const res = await platformFetch( + `/projects/v1/${config.project_id}/metrics?${params.toString()}`, + {}, + apiUrl, + ); + const data = (await res.json()) as MetricsResponse; + + if (json) { + const enriched = { + ...data, + metrics: data.metrics.map((m) => { + const stats = computeStats(m.data); + return { ...m, latest: stats.latest, avg: stats.avg, max: stats.max }; + }), + }; + outputJson(enriched); + } else { + if (!data.metrics || data.metrics.length === 0) { + console.log('No metrics data available.'); + return; + } + const headers = ['Metric', 'Latest', 'Avg', 'Max', 'Range']; + const rows = data.metrics.map((m) => { + const stats = computeStats(m.data); + return [ + METRIC_LABELS[m.metric] ?? m.metric, + formatValue(m.metric, stats.latest), + formatValue(m.metric, stats.avg), + formatValue(m.metric, stats.max), + data.range, + ]; + }); + outputTable(headers, rows); + } + await reportCliUsage('cli.diagnose.metrics', true); + } catch (err) { + await reportCliUsage('cli.diagnose.metrics', false); + handleError(err, json); + } + }); +} diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index 03cda25..fe287e6 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -9,7 +9,7 @@ import type { User, } from '../../types.js'; -async function platformFetch( +export async function platformFetch( path: string, options: RequestInit = {}, apiUrl?: string, From 10380557060a892e2687bc36079ae17839d6abab Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:27:17 -0700 Subject: [PATCH 02/13] feat(diagnose): add advisor subcommand with scan summary and issues --- src/commands/diagnose/advisor.ts | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/commands/diagnose/advisor.ts diff --git a/src/commands/diagnose/advisor.ts b/src/commands/diagnose/advisor.ts new file mode 100644 index 0000000..40ea3e6 --- /dev/null +++ b/src/commands/diagnose/advisor.ts @@ -0,0 +1,116 @@ +import type { Command } from 'commander'; +import { platformFetch } from '../../lib/api/platform.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; +import { isOssMode } from './metrics.js'; + +interface AdvisorScanSummary { + scanId: string; + status: string; + scanType: string; + scannedAt: string; + summary: { total: number; critical: number; warning: number; info: number }; + collectorErrors: { collector: string; error: string; timestamp: string }[]; +} + +interface AdvisorIssue { + id: string; + ruleId: string; + severity: string; + category: string; + title: string; + description: string; + affectedObject: string; + recommendation: string; + isResolved: boolean; +} + +interface AdvisorIssuesResponse { + issues: AdvisorIssue[]; + total: number; +} + +export async function fetchAdvisorSummary( + projectId: string, + apiUrl?: string, +): Promise { + const res = await platformFetch(`/projects/v1/${projectId}/advisor/latest`, {}, apiUrl); + return (await res.json()) as AdvisorScanSummary; +} + +export function registerDiagnoseAdvisorCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('advisor') + .description('Display latest advisor scan results and issues') + .option('--severity ', 'Filter by severity: critical, warning, info') + .option('--category ', 'Filter by category: security, performance, health') + .option('--limit ', 'Maximum number of issues to return', '50') + .action(async (opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(); + const config = getProjectConfig(); + if (!config) throw new ProjectNotLinkedError(); + if (isOssMode()) { + throw new CLIError( + 'Advisor requires InsForge Platform login. Not available when linked via --api-key.', + ); + } + + const projectId = config.project_id; + + // Fetch scan summary + const scanRes = await platformFetch( + `/projects/v1/${projectId}/advisor/latest`, + {}, + apiUrl, + ); + const scan = (await scanRes.json()) as AdvisorScanSummary; + + // Fetch issues + const issueParams = new URLSearchParams(); + if (opts.severity) issueParams.set('severity', opts.severity); + if (opts.category) issueParams.set('category', opts.category); + issueParams.set('limit', opts.limit); + + const issuesRes = await platformFetch( + `/projects/v1/${projectId}/advisor/latest/issues?${issueParams.toString()}`, + {}, + apiUrl, + ); + const issuesData = (await issuesRes.json()) as AdvisorIssuesResponse; + + if (json) { + outputJson({ scan, issues: issuesData.issues }); + } else { + // Scan summary line + const date = new Date(scan.scannedAt).toLocaleDateString(); + const s = scan.summary; + console.log( + `Scan: ${date} (${scan.status}) — ${s.critical} critical, ${s.warning} warning, ${s.info} info\n`, + ); + + if (!issuesData.issues || issuesData.issues.length === 0) { + console.log('No issues found.'); + return; + } + + const headers = ['Severity', 'Category', 'Affected Object', 'Title']; + const rows = issuesData.issues.map((issue) => [ + issue.severity, + issue.category, + issue.affectedObject, + issue.title, + ]); + outputTable(headers, rows); + } + await reportCliUsage('cli.diagnose.advisor', true); + } catch (err) { + await reportCliUsage('cli.diagnose.advisor', false); + handleError(err, json); + } + }); +} From c9039a52f93f52472b8671d5edb1675b799c9ad5 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:30:21 -0700 Subject: [PATCH 03/13] feat(diagnose): add db subcommand with predefined health checks --- src/commands/diagnose/db.ts | 208 ++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/commands/diagnose/db.ts diff --git a/src/commands/diagnose/db.ts b/src/commands/diagnose/db.ts new file mode 100644 index 0000000..5225231 --- /dev/null +++ b/src/commands/diagnose/db.ts @@ -0,0 +1,208 @@ +import type { Command } from 'commander'; +import { runRawSql } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +interface DbCheck { + label: string; + sql: string; + format: (rows: Record[]) => void; +} + +const DB_CHECKS: Record = { + connections: { + label: 'Connections', + sql: `SELECT + (SELECT count(*) FROM pg_stat_activity WHERE state IS NOT NULL) AS active, + (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max`, + format(rows) { + const r = rows[0] ?? {}; + console.log(` Active: ${r.active} / ${r.max}`); + }, + }, + 'slow-queries': { + label: 'Slow Queries (>5s)', + sql: `SELECT pid, now() - query_start AS duration, substring(query for 80) AS query + FROM pg_stat_activity + WHERE state = 'active' AND now() - query_start > interval '5 seconds' + ORDER BY query_start ASC`, + format(rows) { + if (rows.length === 0) { + console.log(' None'); + return; + } + const headers = ['PID', 'Duration', 'Query']; + const tableRows = rows.map((r) => [ + String(r.pid ?? ''), + String(r.duration ?? ''), + String(r.query ?? ''), + ]); + outputTable(headers, tableRows); + }, + }, + bloat: { + label: 'Table Bloat (top 10)', + sql: `SELECT schemaname || '.' || relname AS table, n_dead_tup AS dead_tuples + FROM pg_stat_user_tables + ORDER BY n_dead_tup DESC + LIMIT 10`, + format(rows) { + if (rows.length === 0) { + console.log(' No user tables found.'); + return; + } + const headers = ['Table', 'Dead Tuples']; + const tableRows = rows.map((r) => [ + String(r.table ?? ''), + String(r.dead_tuples ?? 0), + ]); + outputTable(headers, tableRows); + }, + }, + size: { + label: 'Table Sizes (top 10)', + sql: `SELECT schemaname || '.' || relname AS table, + pg_size_pretty(pg_total_relation_size(relid)) AS size + FROM pg_stat_user_tables + ORDER BY pg_total_relation_size(relid) DESC + LIMIT 10`, + format(rows) { + if (rows.length === 0) { + console.log(' No user tables found.'); + return; + } + const headers = ['Table', 'Size']; + const tableRows = rows.map((r) => [ + String(r.table ?? ''), + String(r.size ?? ''), + ]); + outputTable(headers, tableRows); + }, + }, + 'index-usage': { + label: 'Index Usage (worst 10)', + sql: `SELECT relname AS table, idx_scan, seq_scan, + CASE WHEN (idx_scan + seq_scan) > 0 + THEN round(100.0 * idx_scan / (idx_scan + seq_scan), 1) + ELSE 0 END AS idx_ratio + FROM pg_stat_user_tables + WHERE (idx_scan + seq_scan) > 0 + ORDER BY idx_ratio ASC + LIMIT 10`, + format(rows) { + if (rows.length === 0) { + console.log(' No scan data available.'); + return; + } + const headers = ['Table', 'Index Scans', 'Seq Scans', 'Index Ratio']; + const tableRows = rows.map((r) => [ + String(r.table ?? ''), + String(r.idx_scan ?? 0), + String(r.seq_scan ?? 0), + `${r.idx_ratio ?? 0}%`, + ]); + outputTable(headers, tableRows); + }, + }, + locks: { + label: 'Waiting Locks', + sql: `SELECT pid, mode, relation::regclass AS relation, granted + FROM pg_locks + WHERE NOT granted`, + format(rows) { + if (rows.length === 0) { + console.log(' None'); + return; + } + const headers = ['PID', 'Mode', 'Relation', 'Granted']; + const tableRows = rows.map((r) => [ + String(r.pid ?? ''), + String(r.mode ?? ''), + String(r.relation ?? ''), + String(r.granted ?? ''), + ]); + outputTable(headers, tableRows); + }, + }, + 'cache-hit': { + label: 'Cache Hit Ratio', + sql: `SELECT CASE WHEN sum(heap_blks_hit + heap_blks_read) > 0 + THEN round(100.0 * sum(heap_blks_hit) / sum(heap_blks_hit + heap_blks_read), 1) + ELSE 0 END AS ratio + FROM pg_statio_user_tables`, + format(rows) { + const ratio = rows[0]?.ratio ?? 0; + console.log(` ${ratio}%`); + }, + }, +}; + +const ALL_CHECKS = Object.keys(DB_CHECKS); + +export async function runDbChecks(): Promise[]>> { + const results: Record[]> = {}; + for (const key of ALL_CHECKS) { + try { + const { rows } = await runRawSql(DB_CHECKS[key].sql, true); + results[key] = rows; + } catch { + results[key] = []; + } + } + return results; +} + +export function registerDiagnoseDbCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('db') + .description('Run database health checks (connections, bloat, index usage, etc.)') + .option('--check ', 'Comma-separated checks: ' + ALL_CHECKS.join(', '), 'all') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const checkNames = + opts.check === 'all' + ? ALL_CHECKS + : (opts.check as string).split(',').map((s: string) => s.trim()); + + const results: Record[]> = {}; + + for (const name of checkNames) { + const check = DB_CHECKS[name]; + if (!check) { + console.error(`Unknown check: ${name}. Available: ${ALL_CHECKS.join(', ')}`); + continue; + } + try { + const { rows } = await runRawSql(check.sql, true); + results[name] = rows; + } catch (err) { + results[name] = []; + if (!json) { + console.error(` Failed to run ${name}: ${err instanceof Error ? err.message : err}`); + } + } + } + + if (json) { + outputJson(results); + } else { + for (const name of checkNames) { + const check = DB_CHECKS[name]; + if (!check) continue; + console.log(`\n── ${check.label} ${'─'.repeat(Math.max(0, 40 - check.label.length))}`); + check.format(results[name] ?? []); + } + console.log(''); + } + await reportCliUsage('cli.diagnose.db', true); + } catch (err) { + await reportCliUsage('cli.diagnose.db', false); + handleError(err, json); + } + }); +} From d83f0a1b4848066aa4bed69b8ea92cb9f008b891 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:32:08 -0700 Subject: [PATCH 04/13] feat(diagnose): add logs subcommand with error aggregation --- src/commands/diagnose/logs.ts | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/commands/diagnose/logs.ts diff --git a/src/commands/diagnose/logs.ts b/src/commands/diagnose/logs.ts new file mode 100644 index 0000000..cdd3812 --- /dev/null +++ b/src/commands/diagnose/logs.ts @@ -0,0 +1,111 @@ +import type { Command } from 'commander'; +import { ossFetch } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +const LOG_SOURCES = ['insforge.logs', 'postgREST.logs', 'postgres.logs', 'function.logs'] as const; + +const ERROR_PATTERN = /\b(error|fatal|panic)\b/i; + +interface LogEntry { + timestamp: string; + message: string; + source: string; +} + +interface SourceSummary { + source: string; + total: number; + errors: LogEntry[]; +} + +function parseLogEntry(entry: unknown, source: string): { ts: string; msg: string } { + if (typeof entry === 'string') { + return { ts: '', msg: entry }; + } + const e = entry as Record; + const ts = String(e.timestamp ?? e.time ?? ''); + const msg = String(e.message ?? e.msg ?? e.log ?? JSON.stringify(e)); + return { ts, msg }; +} + +async function fetchSourceLogs(source: string, limit: number): Promise { + const res = await ossFetch(`/api/logs/${encodeURIComponent(source)}?limit=${limit}`); + const data = await res.json(); + const logs = Array.isArray(data) ? data : ((data as Record).logs as unknown[]) ?? []; + + const errors: LogEntry[] = []; + for (const entry of logs) { + const { ts, msg } = parseLogEntry(entry, source); + if (ERROR_PATTERN.test(msg)) { + errors.push({ timestamp: ts, message: msg, source }); + } + } + + return { source, total: logs.length, errors }; +} + +export async function fetchLogsSummary(limit = 100): Promise { + const results: SourceSummary[] = []; + for (const source of LOG_SOURCES) { + try { + results.push(await fetchSourceLogs(source, limit)); + } catch { + results.push({ source, total: 0, errors: [] }); + } + } + return results; +} + +export function registerDiagnoseLogsCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('logs') + .description('Aggregate error-level logs from all backend sources') + .option('--source ', 'Specific log source to check') + .option('--limit ', 'Number of log entries per source', '100') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const limit = parseInt(opts.limit, 10) || 100; + const sources = opts.source ? [opts.source as string] : [...LOG_SOURCES]; + + const summaries: SourceSummary[] = []; + for (const source of sources) { + try { + summaries.push(await fetchSourceLogs(source, limit)); + } catch { + summaries.push({ source, total: 0, errors: [] }); + } + } + + if (json) { + outputJson({ sources: summaries }); + } else { + // Summary table + const headers = ['Source', 'Total', 'Errors']; + const rows = summaries.map((s) => [s.source, String(s.total), String(s.errors.length)]); + outputTable(headers, rows); + + // Error details + const allErrors = summaries.flatMap((s) => s.errors); + if (allErrors.length > 0) { + console.log('\n── Error Details ' + '─'.repeat(30)); + for (const err of allErrors) { + const prefix = err.timestamp ? `[${err.source}] ${err.timestamp}` : `[${err.source}]`; + console.log(`\n ${prefix}`); + console.log(` ${err.message}`); + } + console.log(''); + } + } + await reportCliUsage('cli.diagnose.logs', true); + } catch (err) { + await reportCliUsage('cli.diagnose.logs', false); + handleError(err, json); + } + }); +} From 20bf502a3801e8f2d5a532ba893f77336f265918 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:34:51 -0700 Subject: [PATCH 05/13] feat(diagnose): add comprehensive health report and command registration --- src/commands/diagnose/index.ts | 179 +++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/commands/diagnose/index.ts diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts new file mode 100644 index 0000000..97703b4 --- /dev/null +++ b/src/commands/diagnose/index.ts @@ -0,0 +1,179 @@ +import type { Command } from 'commander'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +import { fetchMetricsSummary, isOssMode, registerDiagnoseMetricsCommand } from './metrics.js'; +import { fetchAdvisorSummary, registerDiagnoseAdvisorCommand } from './advisor.js'; +import { runDbChecks, registerDiagnoseDbCommand } from './db.js'; +import { fetchLogsSummary, registerDiagnoseLogsCommand } from './logs.js'; + +function sectionHeader(title: string): string { + return `── ${title} ${'─'.repeat(Math.max(0, 44 - title.length))}`; +} + +export function registerDiagnoseCommands(diagnoseCmd: Command): void { + // Comprehensive report (no subcommand) + diagnoseCmd + .description('Backend diagnostics — run with no subcommand for a full health report') + .action(async (_opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(); + const config = getProjectConfig(); + if (!config) throw new ProjectNotLinkedError(); + + const projectId = config.project_id; + const projectName = config.project_name; + const ossMode = isOssMode(); + + // In OSS mode (linked via --api-key), skip Platform API calls (metrics/advisor) + const metricsPromise = ossMode + ? Promise.reject(new Error('Platform login required (linked via --api-key)')) + : fetchMetricsSummary(projectId, apiUrl); + const advisorPromise = ossMode + ? Promise.reject(new Error('Platform login required (linked via --api-key)')) + : fetchAdvisorSummary(projectId, apiUrl); + + const [metricsResult, advisorResult, dbResult, logsResult] = await Promise.allSettled([ + metricsPromise, + advisorPromise, + runDbChecks(), + fetchLogsSummary(100), + ]); + + if (json) { + const report: Record = { project: projectName, errors: [] }; + const errors: string[] = []; + + if (metricsResult.status === 'fulfilled') { + const data = metricsResult.value; + report.metrics = data.metrics.map((m) => { + const vals = m.data.map((d) => d.value); + return { + metric: m.metric, + latest: vals.length > 0 ? vals[vals.length - 1] : null, + avg: vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : null, + max: vals.length > 0 ? Math.max(...vals) : null, + }; + }); + } else { + report.metrics = null; + errors.push(metricsResult.reason?.message ?? 'Metrics unavailable'); + } + + if (advisorResult.status === 'fulfilled') { + report.advisor = advisorResult.value; + } else { + report.advisor = null; + errors.push(advisorResult.reason?.message ?? 'Advisor unavailable'); + } + + if (dbResult.status === 'fulfilled') { + report.db = dbResult.value; + } else { + report.db = null; + errors.push(dbResult.reason?.message ?? 'DB checks unavailable'); + } + + if (logsResult.status === 'fulfilled') { + report.logs = logsResult.value; + } else { + report.logs = null; + errors.push(logsResult.reason?.message ?? 'Logs unavailable'); + } + + report.errors = errors; + outputJson(report); + } else { + console.log(`\n InsForge Health Report — ${projectName}\n`); + + // Metrics section + console.log(sectionHeader('System Metrics (last 1h)')); + if (metricsResult.status === 'fulfilled') { + const metrics = metricsResult.value.metrics; + if (metrics.length === 0) { + console.log(' No metrics data available.'); + } else { + const vals: Record = {}; + for (const m of metrics) { + if (m.data.length > 0) vals[m.metric] = m.data[m.data.length - 1].value; + } + const cpu = vals.cpu_usage !== undefined ? `${vals.cpu_usage.toFixed(1)}%` : 'N/A'; + const mem = vals.memory_usage !== undefined ? `${vals.memory_usage.toFixed(1)}%` : 'N/A'; + const disk = vals.disk_usage !== undefined ? `${vals.disk_usage.toFixed(1)}%` : 'N/A'; + const netIn = vals.network_in !== undefined ? formatBytesCompact(vals.network_in) + '/s' : 'N/A'; + const netOut = vals.network_out !== undefined ? formatBytesCompact(vals.network_out) + '/s' : 'N/A'; + console.log(` CPU: ${cpu} Memory: ${mem}`); + console.log(` Disk: ${disk} Network: ↑${netIn} ↓${netOut}`); + } + } else { + console.log(` N/A — ${metricsResult.reason?.message ?? 'unavailable'}`); + } + + // Advisor section + console.log('\n' + sectionHeader('Advisor Scan')); + if (advisorResult.status === 'fulfilled') { + const scan = advisorResult.value; + const s = scan.summary; + const date = new Date(scan.scannedAt).toLocaleDateString(); + console.log(` ${date} (${scan.status}) — ${s.critical} critical · ${s.warning} warning · ${s.info} info`); + } else { + console.log(` N/A — ${advisorResult.reason?.message ?? 'unavailable'}`); + } + + // DB section + console.log('\n' + sectionHeader('Database')); + if (dbResult.status === 'fulfilled') { + const db = dbResult.value; + const conn = db.connections?.[0] as Record | undefined; + const cache = db['cache-hit']?.[0] as Record | undefined; + const deadTuples = (db.bloat ?? []).reduce( + (sum: number, r: Record) => sum + (Number(r.dead_tuples) || 0), + 0, + ); + const lockCount = (db.locks ?? []).length; + + console.log( + ` Connections: ${conn?.active ?? '?'}/${conn?.max ?? '?'} Cache Hit: ${cache?.ratio ?? '?'}%`, + ); + console.log( + ` Dead tuples: ${deadTuples.toLocaleString()} Locks waiting: ${lockCount}`, + ); + } else { + console.log(` N/A — ${dbResult.reason?.message ?? 'unavailable'}`); + } + + // Logs section + console.log('\n' + sectionHeader('Recent Errors (last 100 logs/source)')); + if (logsResult.status === 'fulfilled') { + const summaries = logsResult.value; + const parts = summaries.map((s) => `${s.source}: ${s.errors.length}`); + console.log(` ${parts.join(' ')}`); + } else { + console.log(` N/A — ${logsResult.reason?.message ?? 'unavailable'}`); + } + + console.log(''); + } + await reportCliUsage('cli.diagnose', true); + } catch (err) { + await reportCliUsage('cli.diagnose', false); + handleError(err, json); + } + }); + + // Register subcommands + registerDiagnoseMetricsCommand(diagnoseCmd); + registerDiagnoseAdvisorCommand(diagnoseCmd); + registerDiagnoseDbCommand(diagnoseCmd); + registerDiagnoseLogsCommand(diagnoseCmd); +} + +function formatBytesCompact(bytes: number): string { + if (bytes < 1024) return `${bytes.toFixed(0)}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} From c588b8bf580365df802d1581e2a3dadd3efa9366 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:34:56 -0700 Subject: [PATCH 06/13] feat(diagnose): register diagnose command group in CLI entry point --- src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.ts b/src/index.ts index 47f95ac..5748ce4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,7 @@ import { registerSchedulesLogsCommand } from './commands/schedules/logs.js'; import { registerLogsCommand } from './commands/logs.js'; import { registerMetadataCommand } from './commands/metadata.js'; +import { registerDiagnoseCommands } from './commands/diagnose/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')) as { version: string }; @@ -162,6 +163,10 @@ registerLogsCommand(program); // Metadata command registerMetadataCommand(program); +// Diagnose commands +const diagnoseCmd = program.command('diagnose'); +registerDiagnoseCommands(diagnoseCmd); + // Schedules commands const schedulesCmd = program.command('schedules').description('Manage scheduled tasks (cron jobs)'); registerSchedulesListCommand(schedulesCmd); From bba07447cec28621f12ef3638e2ae042e9fadaea Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:43:53 -0700 Subject: [PATCH 07/13] fix(diagnose): address code review findings - Inline isOssMode check to avoid cross-module coupling - Add ProjectNotLinkedError check to db and logs subcommands - Replace Math.max(...array) with reduce to prevent stack overflow - Remove unused source parameter from parseLogEntry --- src/commands/diagnose/advisor.ts | 4 ++-- src/commands/diagnose/db.ts | 4 +++- src/commands/diagnose/index.ts | 18 ++++++++++++------ src/commands/diagnose/logs.ts | 8 +++++--- src/commands/diagnose/metrics.ts | 18 ++++++++---------- 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/commands/diagnose/advisor.ts b/src/commands/diagnose/advisor.ts index 40ea3e6..cde7ff9 100644 --- a/src/commands/diagnose/advisor.ts +++ b/src/commands/diagnose/advisor.ts @@ -5,7 +5,7 @@ import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../.. import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputTable } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; -import { isOssMode } from './metrics.js'; + interface AdvisorScanSummary { scanId: string; @@ -54,7 +54,7 @@ export function registerDiagnoseAdvisorCommand(diagnoseCmd: Command): void { await requireAuth(); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); - if (isOssMode()) { + if (config.project_id === 'oss-project') { throw new CLIError( 'Advisor requires InsForge Platform login. Not available when linked via --api-key.', ); diff --git a/src/commands/diagnose/db.ts b/src/commands/diagnose/db.ts index 5225231..7306197 100644 --- a/src/commands/diagnose/db.ts +++ b/src/commands/diagnose/db.ts @@ -1,7 +1,8 @@ import type { Command } from 'commander'; import { runRawSql } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts } from '../../lib/errors.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputTable } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; @@ -163,6 +164,7 @@ export function registerDiagnoseDbCommand(diagnoseCmd: Command): void { const { json } = getRootOpts(cmd); try { await requireAuth(); + if (!getProjectConfig()) throw new ProjectNotLinkedError(); const checkNames = opts.check === 'all' diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 97703b4..ec42f0b 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -5,7 +5,7 @@ import { getProjectConfig } from '../../lib/config.js'; import { outputJson } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; -import { fetchMetricsSummary, isOssMode, registerDiagnoseMetricsCommand } from './metrics.js'; +import { fetchMetricsSummary, registerDiagnoseMetricsCommand } from './metrics.js'; import { fetchAdvisorSummary, registerDiagnoseAdvisorCommand } from './advisor.js'; import { runDbChecks, registerDiagnoseDbCommand } from './db.js'; import { fetchLogsSummary, registerDiagnoseLogsCommand } from './logs.js'; @@ -27,7 +27,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { const projectId = config.project_id; const projectName = config.project_name; - const ossMode = isOssMode(); + const ossMode = config.project_id === 'oss-project'; // In OSS mode (linked via --api-key), skip Platform API calls (metrics/advisor) const metricsPromise = ossMode @@ -51,12 +51,18 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { if (metricsResult.status === 'fulfilled') { const data = metricsResult.value; report.metrics = data.metrics.map((m) => { - const vals = m.data.map((d) => d.value); + if (m.data.length === 0) return { metric: m.metric, latest: null, avg: null, max: null }; + let sum = 0; + let max = -Infinity; + for (const d of m.data) { + sum += d.value; + if (d.value > max) max = d.value; + } return { metric: m.metric, - latest: vals.length > 0 ? vals[vals.length - 1] : null, - avg: vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : null, - max: vals.length > 0 ? Math.max(...vals) : null, + latest: m.data[m.data.length - 1].value, + avg: sum / m.data.length, + max, }; }); } else { diff --git a/src/commands/diagnose/logs.ts b/src/commands/diagnose/logs.ts index cdd3812..1c625b8 100644 --- a/src/commands/diagnose/logs.ts +++ b/src/commands/diagnose/logs.ts @@ -1,7 +1,8 @@ import type { Command } from 'commander'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts } from '../../lib/errors.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputTable } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; @@ -21,7 +22,7 @@ interface SourceSummary { errors: LogEntry[]; } -function parseLogEntry(entry: unknown, source: string): { ts: string; msg: string } { +function parseLogEntry(entry: unknown): { ts: string; msg: string } { if (typeof entry === 'string') { return { ts: '', msg: entry }; } @@ -38,7 +39,7 @@ async function fetchSourceLogs(source: string, limit: number): Promise sum + d.value, 0) / data.length; - const max = Math.max(...data.map((d) => d.value)); - return { latest, avg, max }; -} - -/** Returns true when linked via --api-key (OSS/self-hosted) — no Platform API access. */ -export function isOssMode(): boolean { - const config = getProjectConfig(); - return config?.project_id === 'oss-project'; + let sum = 0; + let max = -Infinity; + for (const d of data) { + sum += d.value; + if (d.value > max) max = d.value; + } + return { latest, avg: sum / data.length, max }; } export async function fetchMetricsSummary( @@ -81,7 +79,7 @@ export function registerDiagnoseMetricsCommand(diagnoseCmd: Command): void { await requireAuth(); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); - if (isOssMode()) { + if (config.project_id === 'oss-project') { throw new CLIError( 'Metrics requires InsForge Platform login. Not available when linked via --api-key.', ); From d24e81518f30c59049902ec7d9b4f8e25bc23584 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:45:38 -0700 Subject: [PATCH 08/13] bump version --- .../2026-03-27-diagnose-command-design.md | 210 ++++ ...2026-03-27-diagnose-implementation-plan.md | 952 ++++++++++++++++++ package.json | 2 +- 3 files changed, 1163 insertions(+), 1 deletion(-) create mode 100644 docs/specs/2026-03-27-diagnose-command-design.md create mode 100644 docs/specs/2026-03-27-diagnose-implementation-plan.md diff --git a/docs/specs/2026-03-27-diagnose-command-design.md b/docs/specs/2026-03-27-diagnose-command-design.md new file mode 100644 index 0000000..230c3ab --- /dev/null +++ b/docs/specs/2026-03-27-diagnose-command-design.md @@ -0,0 +1,210 @@ +# `insforge diagnose` — SRE Diagnostic Command + +## Overview + +Add a top-level `insforge diagnose` command group that aggregates backend health data from multiple sources (EC2 metrics, advisor scans, database diagnostics, logs) into a unified CLI experience. Helps developers quickly understand the state of their InsForge backend and troubleshoot issues. + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Output modes | CLI + Agent dual-mode (`--json`) | Reuses existing `--json` convention; zero extra cost | +| Unavailable data sources | Skip and mark N/A | Diagnostic tools should show what they can, not fail | +| DB SQL execution mode | Always unrestricted | Diagnostic SQLs are read-only system view queries | +| MCP tool integration | Out of scope | Lives in a separate repo; CLI only for now | +| Command name | `diagnose` | Clear SRE semantics, no conflict with existing commands | +| Architecture | Flat subcommands | Matches existing CLI patterns (db, functions, storage) | +| Advisor history/resolve | Deferred | Not in initial scope | +| OSS mode (`--api-key` link) | Skip metrics/advisor, DB+logs only | No Platform API access in OSS mode | + +## Commands + +### `insforge diagnose` + +Comprehensive health report. Fetches all 4 data sources in parallel via `Promise.allSettled`. Unavailable modules render as N/A with reason. + +**Parameters:** None (inherits global `--json`). + +**Hardcoded defaults for summary:** metrics uses `range=1h` (all metrics), advisor uses latest scan, db runs all checks, logs uses `limit=100` per source. + +**Output (table mode):** + +``` +┌─────────────────────────────────────────────────┐ +│ InsForge Health Report — {project_name} │ +├─────────────────────────────────────────────────┤ +│ System Metrics (last 1h) │ +│ CPU: 23.4% Memory: 67.8% │ +│ Disk: 42.1% Network: ↑12KB/s ↓5.7KB/s │ +├─────────────────────────────────────────────────┤ +│ Advisor Scan ({date}) │ +│ 1 critical · 3 warning · 1 info │ +├─────────────────────────────────────────────────┤ +│ Database │ +│ Connections: 12/100 Cache Hit: 98.7% │ +│ Dead tuples: 2,060 Locks waiting: 0 │ +├─────────────────────────────────────────────────┤ +│ Recent Errors (last 100 logs per source) │ +│ insforge.logs: 0 postgREST.logs: 2 │ +│ postgres.logs: 0 function.logs: 1 │ +└─────────────────────────────────────────────────┘ +``` + +**JSON mode:** `{ metrics: {...} | null, advisor: {...} | null, db: {...} | null, logs: {...} | null, errors: ["EC2 monitoring not enabled"] }` + +### `insforge diagnose metrics` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--range` | `1h\|6h\|24h\|7d` | `1h` | Time range | +| `--metrics` | string | all | Comma-separated: `cpu_usage,memory_usage,disk_usage,network_in,network_out` | + +**API:** `GET /projects/v1/:projectId/metrics?range={range}&metrics={metrics}` + +**Output (table mode):** + +``` + Metric │ Latest │ Avg │ Max │ Range +───────────────┼───────────┼───────────┼───────────┼──────── + CPU Usage │ 23.4% │ 18.7% │ 45.2% │ 6h + Memory Usage │ 67.8% │ 65.1% │ 72.3% │ 6h + Disk Usage │ 42.1% │ 41.9% │ 42.5% │ 6h + Network In │ 12.3 KB/s │ 8.1 KB/s │ 45.6 KB/s │ 6h + Network Out │ 5.7 KB/s │ 4.2 KB/s │ 21.3 KB/s │ 6h +``` + +Latest = last data point. Avg/Max computed from `MetricSeries.data[]`. Network values (bytes/sec) auto-scaled to B/KB/MB. + +**JSON mode:** API response augmented with computed `latest`, `avg`, `max` per metric. + +### `insforge diagnose advisor` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--severity` | `critical\|warning\|info` | all | Filter by severity | +| `--category` | `security\|performance\|health` | all | Filter by category | +| `--limit` | number | 50 | Max issues returned | + +**API:** +1. `GET /projects/v1/:projectId/advisor/latest` — scan summary +2. `GET /projects/v1/:projectId/advisor/latest/issues?severity={s}&category={c}&limit={n}` — issue list + +**Output (table mode):** + +``` + Scan: 2026-03-24 (completed) — 1 critical, 3 warning, 1 info + + Severity │ Category │ Affected Object │ Title +──────────┼─────────────┼────────────────────────┼────────────────────────── + critical │ security │ public.user_profiles │ Table publicly accessible + warning │ performance │ public.orders │ Missing index on foreign key + ... +``` + +**JSON mode:** `{ scan: AdvisorScanSummary, issues: AdvisorIssue[] }` + +### `insforge diagnose db` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--check` | string | `all` | Comma-separated checks: `connections,slow-queries,bloat,size,index-usage,locks,cache-hit` | + +**API:** `POST /api/database/advance/rawsql` (unrestricted mode) for each check. + +**Predefined SQL checks:** + +| Check | SQL | +|-------|-----| +| `connections` | `SELECT count(*) AS active FROM pg_stat_activity WHERE state IS NOT NULL` combined with `SHOW max_connections` | +| `slow-queries` | `SELECT pid, now()-query_start AS duration, query FROM pg_stat_activity WHERE state='active' AND now()-query_start > interval '5 seconds'` | +| `bloat` | `SELECT schemaname, relname, n_dead_tup FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10` | +| `size` | `SELECT schemaname, relname, pg_size_pretty(pg_total_relation_size(relid)) AS size FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10` | +| `index-usage` | `SELECT relname, idx_scan, seq_scan, CASE WHEN (idx_scan+seq_scan)>0 THEN round(100.0*idx_scan/(idx_scan+seq_scan),1) ELSE 0 END AS idx_ratio FROM pg_stat_user_tables WHERE (idx_scan+seq_scan)>0 ORDER BY idx_ratio ASC LIMIT 10` | +| `locks` | `SELECT pid, mode, relation::regclass, granted FROM pg_locks WHERE NOT granted` | +| `cache-hit` | `SELECT CASE WHEN sum(heap_blks_hit+heap_blks_read)>0 THEN round(100.0*sum(heap_blks_hit)/sum(heap_blks_hit+heap_blks_read),1) ELSE 0 END AS ratio FROM pg_statio_user_tables` | + +**Output (table mode):** Each check rendered as a labeled section with table or single-value display. See Design Part 2 for detailed format. + +**JSON mode:** `{ connections: {...}, slow_queries: [...], bloat: [...], size: [...], index_usage: [...], locks: [...], cache_hit: {...} }` + +### `insforge diagnose logs` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--source` | string | all 4 sources | Log source name | +| `--limit` | number | 100 | Entries per source | + +**Log sources:** `insforge.logs`, `postgREST.logs`, `postgres.logs`, `function.logs` + +**API:** `GET /api/logs/{source}?limit={n}` for each source. + +**Error filtering:** Client-side keyword match on `ERROR`, `FATAL`, `error`, `panic` (case-insensitive). + +**Output (table mode):** + +Summary table showing total/error/fatal counts per source, followed by error detail entries with timestamp and message. + +**JSON mode:** `{ sources: [{ source: string, total: number, errors: LogEntry[], fatals: LogEntry[] }] }` + +## File Structure + +``` +src/commands/diagnose/ +├── index.ts # registerDiagnoseCommands() + comprehensive report +├── metrics.ts # diagnose metrics +├── advisor.ts # diagnose advisor +├── db.ts # diagnose db (predefined SQL checks) +└── logs.ts # diagnose logs (error aggregation) +``` + +## Implementation Details + +### Command Registration + +In `src/index.ts`: +```typescript +const diagnoseCmd = program.command('diagnose'); +registerDiagnoseCommands(diagnoseCmd); +``` + +### API Communication + +- **metrics, advisor** — `platformFetch()` (Platform API, bearer token auth) +- **db, logs** — `ossFetch()` (OSS API, appkey + api_key auth) + +No new API client methods needed. Direct calls to `platformFetch`/`ossFetch` within command files, consistent with existing `db query` and `logs` commands. + +### Comprehensive Report Orchestration + +```typescript +const [metrics, advisor, db, logs] = await Promise.allSettled([ + fetchMetricsSummary(projectId), + fetchAdvisorSummary(projectId), + runDbChecks(projectId), + fetchLogsSummary(projectId), +]); +// fulfilled → render section, rejected → render N/A with reason +``` + +### DB Checks Registry + +```typescript +const DB_CHECKS: Record string }> = { + connections: { label: 'Connections', sql: '...', format: ... }, + 'slow-queries': { ... }, + // ... +}; +``` + +`--check all` iterates all entries; otherwise only specified checks. Each SQL executed independently via `ossFetch` rawsql endpoint. + +### Error Handling + +Follows existing CLI patterns: +- `requireAuth()` + project config check as preconditions +- `handleError(err, json)` for standardized error output +- `reportCliUsage('cli.diagnose.*', success)` for analytics + +### Logs Error Filtering + +Reuses existing `logs` command's log parsing logic. Fetches raw logs per source, then filters client-side by error-level keywords (`ERROR`, `FATAL`, `error`, `panic`). diff --git a/docs/specs/2026-03-27-diagnose-implementation-plan.md b/docs/specs/2026-03-27-diagnose-implementation-plan.md new file mode 100644 index 0000000..0dac545 --- /dev/null +++ b/docs/specs/2026-03-27-diagnose-implementation-plan.md @@ -0,0 +1,952 @@ +# `insforge diagnose` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `insforge diagnose` command group that aggregates backend health data (EC2 metrics, advisor scans, DB diagnostics, logs) into a unified CLI experience. + +**Architecture:** Flat subcommand structure under `diagnose`, each file calling `platformFetch` or `ossFetch` directly. Comprehensive report (`diagnose` with no subcommand) orchestrates all sources via `Promise.allSettled`. Follows existing CLI command patterns exactly. + +**Tech Stack:** TypeScript, Commander.js, cli-table3, node fetch (via existing `platformFetch`/`ossFetch` wrappers) + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `src/commands/diagnose/index.ts` | Register all diagnose subcommands + comprehensive report action | +| `src/commands/diagnose/metrics.ts` | `diagnose metrics` — fetch and display EC2 metrics | +| `src/commands/diagnose/advisor.ts` | `diagnose advisor` — fetch advisor scan summary + issues | +| `src/commands/diagnose/db.ts` | `diagnose db` — run predefined diagnostic SQL checks | +| `src/commands/diagnose/logs.ts` | `diagnose logs` — aggregate error-level log entries | +| `src/index.ts` | Register the `diagnose` command group (modify) | + +--- + +### Task 1: Scaffold `diagnose metrics` subcommand + +**Files:** +- Create: `src/commands/diagnose/metrics.ts` + +- [ ] **Step 1: Create `src/commands/diagnose/metrics.ts`** + +```typescript +import type { Command } from 'commander'; +import { platformFetch } from '../../lib/api/platform.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +interface MetricDataPoint { + timestamp: number; + value: number; +} + +interface MetricSeries { + metric: string; + instance_id: string; + data: MetricDataPoint[]; +} + +interface MetricsResponse { + project_id: string; + range: string; + metrics: MetricSeries[]; + _meta?: { requested_at: string; query_time_ms: number; cached: boolean }; +} + +const METRIC_LABELS: Record = { + cpu_usage: 'CPU Usage', + memory_usage: 'Memory Usage', + disk_usage: 'Disk Usage', + network_in: 'Network In', + network_out: 'Network Out', +}; + +const NETWORK_METRICS = new Set(['network_in', 'network_out']); + +function formatValue(metric: string, value: number): string { + if (NETWORK_METRICS.has(metric)) { + return formatBytes(value) + '/s'; + } + return `${value.toFixed(1)}%`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes.toFixed(1)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function computeStats(data: MetricDataPoint[]): { latest: number; avg: number; max: number } { + if (data.length === 0) return { latest: 0, avg: 0, max: 0 }; + const latest = data[data.length - 1].value; + const avg = data.reduce((sum, d) => sum + d.value, 0) / data.length; + const max = Math.max(...data.map((d) => d.value)); + return { latest, avg, max }; +} + +/** Returns true when linked via --api-key (OSS/self-hosted) — no Platform API access. */ +export function isOssMode(): boolean { + const config = getProjectConfig(); + return config?.project_id === 'oss-project'; +} + +export async function fetchMetricsSummary( + projectId: string, + apiUrl?: string, +): Promise { + const res = await platformFetch(`/projects/v1/${projectId}/metrics?range=1h`, {}, apiUrl); + return (await res.json()) as MetricsResponse; +} + +export function registerDiagnoseMetricsCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('metrics') + .description('Display EC2 instance metrics (CPU, memory, disk, network)') + .option('--range ', 'Time range: 1h, 6h, 24h, 7d', '1h') + .option('--metrics ', 'Comma-separated metrics to query') + .action(async (opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(); + const config = getProjectConfig(); + if (!config) throw new ProjectNotLinkedError(); + if (isOssMode()) { + throw new CLIError( + 'Metrics requires InsForge Platform login. Not available when linked via --api-key.', + ); + } + + const params = new URLSearchParams({ range: opts.range }); + if (opts.metrics) params.set('metrics', opts.metrics); + + const res = await platformFetch( + `/projects/v1/${config.project_id}/metrics?${params.toString()}`, + {}, + apiUrl, + ); + const data = (await res.json()) as MetricsResponse; + + if (json) { + const enriched = { + ...data, + metrics: data.metrics.map((m) => { + const stats = computeStats(m.data); + return { ...m, latest: stats.latest, avg: stats.avg, max: stats.max }; + }), + }; + outputJson(enriched); + } else { + if (!data.metrics || data.metrics.length === 0) { + console.log('No metrics data available.'); + return; + } + const headers = ['Metric', 'Latest', 'Avg', 'Max', 'Range']; + const rows = data.metrics.map((m) => { + const stats = computeStats(m.data); + return [ + METRIC_LABELS[m.metric] ?? m.metric, + formatValue(m.metric, stats.latest), + formatValue(m.metric, stats.avg), + formatValue(m.metric, stats.max), + data.range, + ]; + }); + outputTable(headers, rows); + } + await reportCliUsage('cli.diagnose.metrics', true); + } catch (err) { + await reportCliUsage('cli.diagnose.metrics', false); + handleError(err, json); + } + }); +} +``` + +- [ ] **Step 2: Verify `platformFetch` is exported** + +The `platformFetch` function in `src/lib/api/platform.ts` is currently not exported (it's a module-private function used by the public API functions). We need to export it. + +Open `src/lib/api/platform.ts` and change: + +```typescript +// Before: +async function platformFetch( + +// After: +export async function platformFetch( +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/diagnose/metrics.ts src/lib/api/platform.ts +git commit -m "feat(diagnose): add metrics subcommand with EC2 metrics display" +``` + +--- + +### Task 2: Scaffold `diagnose advisor` subcommand + +**Files:** +- Create: `src/commands/diagnose/advisor.ts` + +- [ ] **Step 1: Create `src/commands/diagnose/advisor.ts`** + +```typescript +import type { Command } from 'commander'; +import { platformFetch } from '../../lib/api/platform.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; +import { isOssMode } from './metrics.js'; + +interface AdvisorScanSummary { + scanId: string; + status: string; + scanType: string; + scannedAt: string; + summary: { total: number; critical: number; warning: number; info: number }; + collectorErrors: { collector: string; error: string; timestamp: string }[]; +} + +interface AdvisorIssue { + id: string; + ruleId: string; + severity: string; + category: string; + title: string; + description: string; + affectedObject: string; + recommendation: string; + isResolved: boolean; +} + +interface AdvisorIssuesResponse { + issues: AdvisorIssue[]; + total: number; +} + +export async function fetchAdvisorSummary( + projectId: string, + apiUrl?: string, +): Promise { + const res = await platformFetch(`/projects/v1/${projectId}/advisor/latest`, {}, apiUrl); + return (await res.json()) as AdvisorScanSummary; +} + +export function registerDiagnoseAdvisorCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('advisor') + .description('Display latest advisor scan results and issues') + .option('--severity ', 'Filter by severity: critical, warning, info') + .option('--category ', 'Filter by category: security, performance, health') + .option('--limit ', 'Maximum number of issues to return', '50') + .action(async (opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(); + const config = getProjectConfig(); + if (!config) throw new ProjectNotLinkedError(); + if (isOssMode()) { + throw new CLIError( + 'Advisor requires InsForge Platform login. Not available when linked via --api-key.', + ); + } + + const projectId = config.project_id; + + // Fetch scan summary + const scanRes = await platformFetch( + `/projects/v1/${projectId}/advisor/latest`, + {}, + apiUrl, + ); + const scan = (await scanRes.json()) as AdvisorScanSummary; + + // Fetch issues + const issueParams = new URLSearchParams(); + if (opts.severity) issueParams.set('severity', opts.severity); + if (opts.category) issueParams.set('category', opts.category); + issueParams.set('limit', opts.limit); + + const issuesRes = await platformFetch( + `/projects/v1/${projectId}/advisor/latest/issues?${issueParams.toString()}`, + {}, + apiUrl, + ); + const issuesData = (await issuesRes.json()) as AdvisorIssuesResponse; + + if (json) { + outputJson({ scan, issues: issuesData.issues }); + } else { + // Scan summary line + const date = new Date(scan.scannedAt).toLocaleDateString(); + const s = scan.summary; + console.log( + `Scan: ${date} (${scan.status}) — ${s.critical} critical, ${s.warning} warning, ${s.info} info\n`, + ); + + if (!issuesData.issues || issuesData.issues.length === 0) { + console.log('No issues found.'); + return; + } + + const headers = ['Severity', 'Category', 'Affected Object', 'Title']; + const rows = issuesData.issues.map((issue) => [ + issue.severity, + issue.category, + issue.affectedObject, + issue.title, + ]); + outputTable(headers, rows); + } + await reportCliUsage('cli.diagnose.advisor', true); + } catch (err) { + await reportCliUsage('cli.diagnose.advisor', false); + handleError(err, json); + } + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/commands/diagnose/advisor.ts +git commit -m "feat(diagnose): add advisor subcommand with scan summary and issues" +``` + +--- + +### Task 3: Scaffold `diagnose db` subcommand + +**Files:** +- Create: `src/commands/diagnose/db.ts` + +- [ ] **Step 1: Create `src/commands/diagnose/db.ts`** + +```typescript +import type { Command } from 'commander'; +import { runRawSql } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +interface DbCheck { + label: string; + sql: string; + format: (rows: Record[]) => void; +} + +const DB_CHECKS: Record = { + connections: { + label: 'Connections', + sql: `SELECT + (SELECT count(*) FROM pg_stat_activity WHERE state IS NOT NULL) AS active, + (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max`, + format(rows) { + const r = rows[0] ?? {}; + console.log(` Active: ${r.active} / ${r.max}`); + }, + }, + 'slow-queries': { + label: 'Slow Queries (>5s)', + sql: `SELECT pid, now() - query_start AS duration, substring(query for 80) AS query + FROM pg_stat_activity + WHERE state = 'active' AND now() - query_start > interval '5 seconds' + ORDER BY query_start ASC`, + format(rows) { + if (rows.length === 0) { + console.log(' None'); + return; + } + const headers = ['PID', 'Duration', 'Query']; + const tableRows = rows.map((r) => [ + String(r.pid ?? ''), + String(r.duration ?? ''), + String(r.query ?? ''), + ]); + outputTable(headers, tableRows); + }, + }, + bloat: { + label: 'Table Bloat (top 10)', + sql: `SELECT schemaname || '.' || relname AS table, n_dead_tup AS dead_tuples + FROM pg_stat_user_tables + ORDER BY n_dead_tup DESC + LIMIT 10`, + format(rows) { + if (rows.length === 0) { + console.log(' No user tables found.'); + return; + } + const headers = ['Table', 'Dead Tuples']; + const tableRows = rows.map((r) => [ + String(r.table ?? ''), + String(r.dead_tuples ?? 0), + ]); + outputTable(headers, tableRows); + }, + }, + size: { + label: 'Table Sizes (top 10)', + sql: `SELECT schemaname || '.' || relname AS table, + pg_size_pretty(pg_total_relation_size(relid)) AS size + FROM pg_stat_user_tables + ORDER BY pg_total_relation_size(relid) DESC + LIMIT 10`, + format(rows) { + if (rows.length === 0) { + console.log(' No user tables found.'); + return; + } + const headers = ['Table', 'Size']; + const tableRows = rows.map((r) => [ + String(r.table ?? ''), + String(r.size ?? ''), + ]); + outputTable(headers, tableRows); + }, + }, + 'index-usage': { + label: 'Index Usage (worst 10)', + sql: `SELECT relname AS table, idx_scan, seq_scan, + CASE WHEN (idx_scan + seq_scan) > 0 + THEN round(100.0 * idx_scan / (idx_scan + seq_scan), 1) + ELSE 0 END AS idx_ratio + FROM pg_stat_user_tables + WHERE (idx_scan + seq_scan) > 0 + ORDER BY idx_ratio ASC + LIMIT 10`, + format(rows) { + if (rows.length === 0) { + console.log(' No scan data available.'); + return; + } + const headers = ['Table', 'Index Scans', 'Seq Scans', 'Index Ratio']; + const tableRows = rows.map((r) => [ + String(r.table ?? ''), + String(r.idx_scan ?? 0), + String(r.seq_scan ?? 0), + `${r.idx_ratio ?? 0}%`, + ]); + outputTable(headers, tableRows); + }, + }, + locks: { + label: 'Waiting Locks', + sql: `SELECT pid, mode, relation::regclass AS relation, granted + FROM pg_locks + WHERE NOT granted`, + format(rows) { + if (rows.length === 0) { + console.log(' None'); + return; + } + const headers = ['PID', 'Mode', 'Relation', 'Granted']; + const tableRows = rows.map((r) => [ + String(r.pid ?? ''), + String(r.mode ?? ''), + String(r.relation ?? ''), + String(r.granted ?? ''), + ]); + outputTable(headers, tableRows); + }, + }, + 'cache-hit': { + label: 'Cache Hit Ratio', + sql: `SELECT CASE WHEN sum(heap_blks_hit + heap_blks_read) > 0 + THEN round(100.0 * sum(heap_blks_hit) / sum(heap_blks_hit + heap_blks_read), 1) + ELSE 0 END AS ratio + FROM pg_statio_user_tables`, + format(rows) { + const ratio = rows[0]?.ratio ?? 0; + console.log(` ${ratio}%`); + }, + }, +}; + +const ALL_CHECKS = Object.keys(DB_CHECKS); + +export async function runDbChecks(): Promise[]>> { + const results: Record[]> = {}; + for (const key of ALL_CHECKS) { + try { + const { rows } = await runRawSql(DB_CHECKS[key].sql, true); + results[key] = rows; + } catch { + results[key] = []; + } + } + return results; +} + +export function registerDiagnoseDbCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('db') + .description('Run database health checks (connections, bloat, index usage, etc.)') + .option('--check ', 'Comma-separated checks: ' + ALL_CHECKS.join(', '), 'all') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const checkNames = + opts.check === 'all' + ? ALL_CHECKS + : (opts.check as string).split(',').map((s: string) => s.trim()); + + const results: Record[]> = {}; + + for (const name of checkNames) { + const check = DB_CHECKS[name]; + if (!check) { + console.error(`Unknown check: ${name}. Available: ${ALL_CHECKS.join(', ')}`); + continue; + } + try { + const { rows } = await runRawSql(check.sql, true); + results[name] = rows; + } catch (err) { + results[name] = []; + if (!json) { + console.error(` Failed to run ${name}: ${err instanceof Error ? err.message : err}`); + } + } + } + + if (json) { + outputJson(results); + } else { + for (const name of checkNames) { + const check = DB_CHECKS[name]; + if (!check) continue; + console.log(`\n── ${check.label} ${'─'.repeat(Math.max(0, 40 - check.label.length))}`); + check.format(results[name] ?? []); + } + console.log(''); + } + await reportCliUsage('cli.diagnose.db', true); + } catch (err) { + await reportCliUsage('cli.diagnose.db', false); + handleError(err, json); + } + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/commands/diagnose/db.ts +git commit -m "feat(diagnose): add db subcommand with predefined health checks" +``` + +--- + +### Task 4: Scaffold `diagnose logs` subcommand + +**Files:** +- Create: `src/commands/diagnose/logs.ts` + +- [ ] **Step 1: Create `src/commands/diagnose/logs.ts`** + +```typescript +import type { Command } from 'commander'; +import { ossFetch } from '../../lib/api/oss.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts } from '../../lib/errors.js'; +import { outputJson, outputTable } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +const LOG_SOURCES = ['insforge.logs', 'postgREST.logs', 'postgres.logs', 'function.logs'] as const; + +const ERROR_PATTERN = /\b(error|fatal|panic)\b/i; + +interface LogEntry { + timestamp: string; + message: string; + source: string; +} + +interface SourceSummary { + source: string; + total: number; + errors: LogEntry[]; +} + +function parseLogEntry(entry: unknown, source: string): { ts: string; msg: string } { + if (typeof entry === 'string') { + return { ts: '', msg: entry }; + } + const e = entry as Record; + const ts = String(e.timestamp ?? e.time ?? ''); + const msg = String(e.message ?? e.msg ?? e.log ?? JSON.stringify(e)); + return { ts, msg }; +} + +async function fetchSourceLogs(source: string, limit: number): Promise { + const res = await ossFetch(`/api/logs/${encodeURIComponent(source)}?limit=${limit}`); + const data = await res.json(); + const logs = Array.isArray(data) ? data : ((data as Record).logs as unknown[]) ?? []; + + const errors: LogEntry[] = []; + for (const entry of logs) { + const { ts, msg } = parseLogEntry(entry, source); + if (ERROR_PATTERN.test(msg)) { + errors.push({ timestamp: ts, message: msg, source }); + } + } + + return { source, total: logs.length, errors }; +} + +export async function fetchLogsSummary(limit = 100): Promise { + const results: SourceSummary[] = []; + for (const source of LOG_SOURCES) { + try { + results.push(await fetchSourceLogs(source, limit)); + } catch { + results.push({ source, total: 0, errors: [] }); + } + } + return results; +} + +export function registerDiagnoseLogsCommand(diagnoseCmd: Command): void { + diagnoseCmd + .command('logs') + .description('Aggregate error-level logs from all backend sources') + .option('--source ', 'Specific log source to check') + .option('--limit ', 'Number of log entries per source', '100') + .action(async (opts, cmd) => { + const { json } = getRootOpts(cmd); + try { + await requireAuth(); + + const limit = parseInt(opts.limit, 10) || 100; + const sources = opts.source ? [opts.source as string] : [...LOG_SOURCES]; + + const summaries: SourceSummary[] = []; + for (const source of sources) { + try { + summaries.push(await fetchSourceLogs(source, limit)); + } catch { + summaries.push({ source, total: 0, errors: [] }); + } + } + + if (json) { + outputJson({ sources: summaries }); + } else { + // Summary table + const headers = ['Source', 'Total', 'Errors']; + const rows = summaries.map((s) => [s.source, String(s.total), String(s.errors.length)]); + outputTable(headers, rows); + + // Error details + const allErrors = summaries.flatMap((s) => s.errors); + if (allErrors.length > 0) { + console.log('\n── Error Details ' + '─'.repeat(30)); + for (const err of allErrors) { + const prefix = err.timestamp ? `[${err.source}] ${err.timestamp}` : `[${err.source}]`; + console.log(`\n ${prefix}`); + console.log(` ${err.message}`); + } + console.log(''); + } + } + await reportCliUsage('cli.diagnose.logs', true); + } catch (err) { + await reportCliUsage('cli.diagnose.logs', false); + handleError(err, json); + } + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/commands/diagnose/logs.ts +git commit -m "feat(diagnose): add logs subcommand with error aggregation" +``` + +--- + +### Task 5: Scaffold `diagnose/index.ts` with comprehensive report and command registration + +**Files:** +- Create: `src/commands/diagnose/index.ts` + +- [ ] **Step 1: Create `src/commands/diagnose/index.ts`** + +```typescript +import type { Command } from 'commander'; +import { requireAuth } from '../../lib/credentials.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { outputJson } from '../../lib/output.js'; +import { reportCliUsage } from '../../lib/skills.js'; + +import { fetchMetricsSummary, isOssMode, registerDiagnoseMetricsCommand } from './metrics.js'; +import { fetchAdvisorSummary, registerDiagnoseAdvisorCommand } from './advisor.js'; +import { runDbChecks, registerDiagnoseDbCommand } from './db.js'; +import { fetchLogsSummary, registerDiagnoseLogsCommand } from './logs.js'; + +function sectionHeader(title: string): string { + return `── ${title} ${'─'.repeat(Math.max(0, 44 - title.length))}`; +} + +export function registerDiagnoseCommands(diagnoseCmd: Command): void { + // Comprehensive report (no subcommand) + diagnoseCmd + .description('Backend diagnostics — run with no subcommand for a full health report') + .action(async (_opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + try { + await requireAuth(); + const config = getProjectConfig(); + if (!config) throw new ProjectNotLinkedError(); + + const projectId = config.project_id; + const projectName = config.project_name; + const ossMode = isOssMode(); + + // In OSS mode (linked via --api-key), skip Platform API calls (metrics/advisor) + const metricsPromise = ossMode + ? Promise.reject(new Error('Platform login required (linked via --api-key)')) + : fetchMetricsSummary(projectId, apiUrl); + const advisorPromise = ossMode + ? Promise.reject(new Error('Platform login required (linked via --api-key)')) + : fetchAdvisorSummary(projectId, apiUrl); + + const [metricsResult, advisorResult, dbResult, logsResult] = await Promise.allSettled([ + metricsPromise, + advisorPromise, + runDbChecks(), + fetchLogsSummary(100), + ]); + + if (json) { + const report: Record = { project: projectName, errors: [] }; + const errors: string[] = []; + + if (metricsResult.status === 'fulfilled') { + const data = metricsResult.value; + report.metrics = data.metrics.map((m) => { + const vals = m.data.map((d) => d.value); + return { + metric: m.metric, + latest: vals.length > 0 ? vals[vals.length - 1] : null, + avg: vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : null, + max: vals.length > 0 ? Math.max(...vals) : null, + }; + }); + } else { + report.metrics = null; + errors.push(metricsResult.reason?.message ?? 'Metrics unavailable'); + } + + if (advisorResult.status === 'fulfilled') { + report.advisor = advisorResult.value; + } else { + report.advisor = null; + errors.push(advisorResult.reason?.message ?? 'Advisor unavailable'); + } + + if (dbResult.status === 'fulfilled') { + report.db = dbResult.value; + } else { + report.db = null; + errors.push(dbResult.reason?.message ?? 'DB checks unavailable'); + } + + if (logsResult.status === 'fulfilled') { + report.logs = logsResult.value; + } else { + report.logs = null; + errors.push(logsResult.reason?.message ?? 'Logs unavailable'); + } + + report.errors = errors; + outputJson(report); + } else { + console.log(`\n InsForge Health Report — ${projectName}\n`); + + // Metrics section + console.log(sectionHeader('System Metrics (last 1h)')); + if (metricsResult.status === 'fulfilled') { + const metrics = metricsResult.value.metrics; + if (metrics.length === 0) { + console.log(' No metrics data available.'); + } else { + const vals: Record = {}; + for (const m of metrics) { + if (m.data.length > 0) vals[m.metric] = m.data[m.data.length - 1].value; + } + const cpu = vals.cpu_usage !== undefined ? `${vals.cpu_usage.toFixed(1)}%` : 'N/A'; + const mem = vals.memory_usage !== undefined ? `${vals.memory_usage.toFixed(1)}%` : 'N/A'; + const disk = vals.disk_usage !== undefined ? `${vals.disk_usage.toFixed(1)}%` : 'N/A'; + const netIn = vals.network_in !== undefined ? formatBytesCompact(vals.network_in) + '/s' : 'N/A'; + const netOut = vals.network_out !== undefined ? formatBytesCompact(vals.network_out) + '/s' : 'N/A'; + console.log(` CPU: ${cpu} Memory: ${mem}`); + console.log(` Disk: ${disk} Network: ↑${netIn} ↓${netOut}`); + } + } else { + console.log(` N/A — ${metricsResult.reason?.message ?? 'unavailable'}`); + } + + // Advisor section + console.log('\n' + sectionHeader('Advisor Scan')); + if (advisorResult.status === 'fulfilled') { + const scan = advisorResult.value; + const s = scan.summary; + const date = new Date(scan.scannedAt).toLocaleDateString(); + console.log(` ${date} (${scan.status}) — ${s.critical} critical · ${s.warning} warning · ${s.info} info`); + } else { + console.log(` N/A — ${advisorResult.reason?.message ?? 'unavailable'}`); + } + + // DB section + console.log('\n' + sectionHeader('Database')); + if (dbResult.status === 'fulfilled') { + const db = dbResult.value; + const conn = db.connections?.[0] as Record | undefined; + const cache = db['cache-hit']?.[0] as Record | undefined; + const deadTuples = (db.bloat ?? []).reduce( + (sum: number, r: Record) => sum + (Number(r.dead_tuples) || 0), + 0, + ); + const lockCount = (db.locks ?? []).length; + + console.log( + ` Connections: ${conn?.active ?? '?'}/${conn?.max ?? '?'} Cache Hit: ${cache?.ratio ?? '?'}%`, + ); + console.log( + ` Dead tuples: ${deadTuples.toLocaleString()} Locks waiting: ${lockCount}`, + ); + } else { + console.log(` N/A — ${dbResult.reason?.message ?? 'unavailable'}`); + } + + // Logs section + console.log('\n' + sectionHeader('Recent Errors (last 100 logs/source)')); + if (logsResult.status === 'fulfilled') { + const summaries = logsResult.value; + const parts = summaries.map((s) => `${s.source}: ${s.errors.length}`); + console.log(` ${parts.join(' ')}`); + } else { + console.log(` N/A — ${logsResult.reason?.message ?? 'unavailable'}`); + } + + console.log(''); + } + await reportCliUsage('cli.diagnose', true); + } catch (err) { + await reportCliUsage('cli.diagnose', false); + handleError(err, json); + } + }); + + // Register subcommands + registerDiagnoseMetricsCommand(diagnoseCmd); + registerDiagnoseAdvisorCommand(diagnoseCmd); + registerDiagnoseDbCommand(diagnoseCmd); + registerDiagnoseLogsCommand(diagnoseCmd); +} + +function formatBytesCompact(bytes: number): string { + if (bytes < 1024) return `${bytes.toFixed(0)}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/commands/diagnose/index.ts +git commit -m "feat(diagnose): add comprehensive health report and command registration" +``` + +--- + +### Task 6: Register `diagnose` in the main CLI entry point + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Add import to `src/index.ts`** + +Add after the existing imports (e.g. after the `registerMetadataCommand` import): + +```typescript +import { registerDiagnoseCommands } from './commands/diagnose/index.js'; +``` + +- [ ] **Step 2: Register the diagnose command group** + +Add after the `registerMetadataCommand(program);` line (around line 163): + +```typescript +// Diagnose commands +const diagnoseCmd = program.command('diagnose'); +registerDiagnoseCommands(diagnoseCmd); +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/index.ts +git commit -m "feat(diagnose): register diagnose command group in CLI entry point" +``` + +--- + +### Task 7: Build and manual smoke test + +- [ ] **Step 1: Build the project** + +Run: `npm run build` +Expected: No TypeScript errors, clean build. + +- [ ] **Step 2: Verify command registration** + +Run: `node dist/index.js diagnose --help` +Expected: Shows `diagnose` description with subcommands `metrics`, `advisor`, `db`, `logs`. + +- [ ] **Step 3: Test `--json` output structure** + +Run: `node dist/index.js --json diagnose` (with a linked project) +Expected: JSON output with `metrics`, `advisor`, `db`, `logs`, and `errors` fields. Some may be `null` if services are unavailable. + +- [ ] **Step 4: Test individual subcommands** + +Run each: +```bash +node dist/index.js diagnose metrics --range 1h +node dist/index.js diagnose advisor +node dist/index.js diagnose db --check connections,cache-hit +node dist/index.js diagnose logs --limit 50 +``` +Expected: Table output for each. If a service is unavailable, should show an error message (not crash). + +- [ ] **Step 5: Test `--json` mode for subcommands** + +Run: `node dist/index.js --json diagnose db` +Expected: JSON object with keys for each check. + +- [ ] **Step 6: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix(diagnose): address smoke test findings" +``` diff --git a/package.json b/package.json index c853f92..7f87a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.31", + "version": "0.1.32", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { From 0a2c56f3d03849054ca047974920b63f14eb7f7d Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:53:25 -0700 Subject: [PATCH 09/13] fix(diagnose): aggregate metrics by name to merge multiple network interfaces Network metrics (network_in/network_out) are returned per-interface by the API, causing duplicate rows. Now sums across interfaces into a single row per metric. --- src/commands/diagnose/metrics.ts | 41 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/commands/diagnose/metrics.ts b/src/commands/diagnose/metrics.ts index ff42e10..610c9c1 100644 --- a/src/commands/diagnose/metrics.ts +++ b/src/commands/diagnose/metrics.ts @@ -59,6 +59,39 @@ function computeStats(data: MetricDataPoint[]): { latest: number; avg: number; m return { latest, avg: sum / data.length, max }; } +/** + * Aggregate multiple series of the same metric (e.g. multiple network interfaces) + * into a single series per metric name. Network metrics are summed; others take the first series. + */ +function aggregateByMetric(series: MetricSeries[]): MetricSeries[] { + const grouped = new Map(); + for (const s of series) { + const existing = grouped.get(s.metric); + if (existing) existing.push(s); + else grouped.set(s.metric, [s]); + } + + const result: MetricSeries[] = []; + for (const [metric, group] of grouped) { + if (group.length === 1 || !NETWORK_METRICS.has(metric)) { + result.push(group[0]); + continue; + } + // Sum network metrics across interfaces by matching timestamps + const tsMap = new Map(); + for (const s of group) { + for (const d of s.data) { + tsMap.set(d.timestamp, (tsMap.get(d.timestamp) ?? 0) + d.value); + } + } + const merged: MetricDataPoint[] = [...tsMap.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([timestamp, value]) => ({ timestamp, value })); + result.push({ metric, instance_id: 'aggregate', data: merged }); + } + return result; +} + export async function fetchMetricsSummary( projectId: string, apiUrl?: string, @@ -95,22 +128,24 @@ export function registerDiagnoseMetricsCommand(diagnoseCmd: Command): void { ); const data = (await res.json()) as MetricsResponse; + const aggregated = aggregateByMetric(data.metrics); + if (json) { const enriched = { ...data, - metrics: data.metrics.map((m) => { + metrics: aggregated.map((m) => { const stats = computeStats(m.data); return { ...m, latest: stats.latest, avg: stats.avg, max: stats.max }; }), }; outputJson(enriched); } else { - if (!data.metrics || data.metrics.length === 0) { + if (!aggregated.length) { console.log('No metrics data available.'); return; } const headers = ['Metric', 'Latest', 'Avg', 'Max', 'Range']; - const rows = data.metrics.map((m) => { + const rows = aggregated.map((m) => { const stats = computeStats(m.data); return [ METRIC_LABELS[m.metric] ?? m.metric, From b6da2cfd93b14b118aff9df29c2c23482ef47787 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 11:57:28 -0700 Subject: [PATCH 10/13] fix(diagnose): add schema prefix to index-usage table names --- src/commands/diagnose/db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/diagnose/db.ts b/src/commands/diagnose/db.ts index 7306197..b75698b 100644 --- a/src/commands/diagnose/db.ts +++ b/src/commands/diagnose/db.ts @@ -84,7 +84,7 @@ const DB_CHECKS: Record = { }, 'index-usage': { label: 'Index Usage (worst 10)', - sql: `SELECT relname AS table, idx_scan, seq_scan, + sql: `SELECT schemaname || '.' || relname AS table, idx_scan, seq_scan, CASE WHEN (idx_scan + seq_scan) > 0 THEN round(100.0 * idx_scan / (idx_scan + seq_scan), 1) ELSE 0 END AS idx_ratio From 8f1e4ad61221844f5026a211af94b94601af753b Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 12:01:44 -0700 Subject: [PATCH 11/13] fix(diagnose): pass apiUrl to requireAuth for custom API server support --- src/commands/diagnose/advisor.ts | 2 +- src/commands/diagnose/index.ts | 2 +- src/commands/diagnose/metrics.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/diagnose/advisor.ts b/src/commands/diagnose/advisor.ts index cde7ff9..0098338 100644 --- a/src/commands/diagnose/advisor.ts +++ b/src/commands/diagnose/advisor.ts @@ -51,7 +51,7 @@ export function registerDiagnoseAdvisorCommand(diagnoseCmd: Command): void { .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { - await requireAuth(); + await requireAuth(apiUrl); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); if (config.project_id === 'oss-project') { diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index ec42f0b..7ec13f9 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -21,7 +21,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { .action(async (_opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { - await requireAuth(); + await requireAuth(apiUrl); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); diff --git a/src/commands/diagnose/metrics.ts b/src/commands/diagnose/metrics.ts index 610c9c1..dc5e004 100644 --- a/src/commands/diagnose/metrics.ts +++ b/src/commands/diagnose/metrics.ts @@ -109,7 +109,7 @@ export function registerDiagnoseMetricsCommand(diagnoseCmd: Command): void { .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { - await requireAuth(); + await requireAuth(apiUrl); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); if (config.project_id === 'oss-project') { From d085bb13724f1bef2628f77bfe7a25a6c2767fb5 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 12:02:31 -0700 Subject: [PATCH 12/13] =?UTF-8?q?fix(diagnose):=20swap=20network=20directi?= =?UTF-8?q?on=20arrows=20(=E2=86=93in=20=E2=86=91out)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/diagnose/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 7ec13f9..531b3c7 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -113,7 +113,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { const netIn = vals.network_in !== undefined ? formatBytesCompact(vals.network_in) + '/s' : 'N/A'; const netOut = vals.network_out !== undefined ? formatBytesCompact(vals.network_out) + '/s' : 'N/A'; console.log(` CPU: ${cpu} Memory: ${mem}`); - console.log(` Disk: ${disk} Network: ↑${netIn} ↓${netOut}`); + console.log(` Disk: ${disk} Network: ↓${netIn} ↑${netOut}`); } } else { console.log(` N/A — ${metricsResult.reason?.message ?? 'unavailable'}`); From efb7abb70f49ccb2c8064d705a219c90a2e8433a Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 27 Mar 2026 12:09:31 -0700 Subject: [PATCH 13/13] docs(diagnose): sync implementation plan with shipped code --- ...2026-03-27-diagnose-implementation-plan.md | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/specs/2026-03-27-diagnose-implementation-plan.md b/docs/specs/2026-03-27-diagnose-implementation-plan.md index 0dac545..482b852 100644 --- a/docs/specs/2026-03-27-diagnose-implementation-plan.md +++ b/docs/specs/2026-03-27-diagnose-implementation-plan.md @@ -88,12 +88,6 @@ function computeStats(data: MetricDataPoint[]): { latest: number; avg: number; m return { latest, avg, max }; } -/** Returns true when linked via --api-key (OSS/self-hosted) — no Platform API access. */ -export function isOssMode(): boolean { - const config = getProjectConfig(); - return config?.project_id === 'oss-project'; -} - export async function fetchMetricsSummary( projectId: string, apiUrl?: string, @@ -111,10 +105,10 @@ export function registerDiagnoseMetricsCommand(diagnoseCmd: Command): void { .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { - await requireAuth(); + await requireAuth(apiUrl); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); - if (isOssMode()) { + if (config.project_id === 'oss-project') { throw new CLIError( 'Metrics requires InsForge Platform login. Not available when linked via --api-key.', ); @@ -204,7 +198,7 @@ import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../.. import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputTable } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; -import { isOssMode } from './metrics.js'; + interface AdvisorScanSummary { scanId: string; @@ -250,10 +244,10 @@ export function registerDiagnoseAdvisorCommand(diagnoseCmd: Command): void { .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { - await requireAuth(); + await requireAuth(apiUrl); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); - if (isOssMode()) { + if (config.project_id === 'oss-project') { throw new CLIError( 'Advisor requires InsForge Platform login. Not available when linked via --api-key.', ); @@ -696,7 +690,7 @@ import { getProjectConfig } from '../../lib/config.js'; import { outputJson } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; -import { fetchMetricsSummary, isOssMode, registerDiagnoseMetricsCommand } from './metrics.js'; +import { fetchMetricsSummary, registerDiagnoseMetricsCommand } from './metrics.js'; import { fetchAdvisorSummary, registerDiagnoseAdvisorCommand } from './advisor.js'; import { runDbChecks, registerDiagnoseDbCommand } from './db.js'; import { fetchLogsSummary, registerDiagnoseLogsCommand } from './logs.js'; @@ -712,13 +706,13 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { .action(async (_opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { - await requireAuth(); + await requireAuth(apiUrl); const config = getProjectConfig(); if (!config) throw new ProjectNotLinkedError(); const projectId = config.project_id; const projectName = config.project_name; - const ossMode = isOssMode(); + const ossMode = config.project_id === 'oss-project'; // In OSS mode (linked via --api-key), skip Platform API calls (metrics/advisor) const metricsPromise = ossMode @@ -742,12 +736,18 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { if (metricsResult.status === 'fulfilled') { const data = metricsResult.value; report.metrics = data.metrics.map((m) => { - const vals = m.data.map((d) => d.value); + if (m.data.length === 0) return { metric: m.metric, latest: null, avg: null, max: null }; + let sum = 0; + let max = -Infinity; + for (const d of m.data) { + sum += d.value; + if (d.value > max) max = d.value; + } return { metric: m.metric, - latest: vals.length > 0 ? vals[vals.length - 1] : null, - avg: vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : null, - max: vals.length > 0 ? Math.max(...vals) : null, + latest: m.data[m.data.length - 1].value, + avg: sum / m.data.length, + max, }; }); } else {