@celilo/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -0,0 +1,571 @@
1
+ /**
2
+ * System audit command — reports drift across the system.
3
+ *
4
+ * Usage:
5
+ * celilo system audit # human-readable table
6
+ * celilo system audit --json # machine-readable for scripts
7
+ *
8
+ * Read-only. No mutations. Wires the per-category audit checks
9
+ * (`apps/celilo/src/services/audit/*`) to the live DB / registry /
10
+ * health-runner / terraform binary, then formats the resulting
11
+ * `SystemAuditReport` for stdout.
12
+ *
13
+ * The actual audit logic lives in services/audit/. This command is a
14
+ * thin adapter — per Rule 10.5, it's a "thin adapter": parse args,
15
+ * compose deps, call `runAudit`, format output, return.
16
+ */
17
+
18
+ import { execFile } from 'node:child_process';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { promisify } from 'node:util';
23
+ import { eq, isNotNull } from 'drizzle-orm';
24
+ import { testDigitalOceanConnection } from '../../api-clients/digitalocean';
25
+ import { testProxmoxConnection } from '../../api-clients/proxmox';
26
+ import { getDb } from '../../db/client';
27
+ import {
28
+ backups,
29
+ capabilitySecrets,
30
+ moduleConfigs as moduleConfigsTbl,
31
+ modules,
32
+ secrets,
33
+ systemSecrets,
34
+ } from '../../db/schema';
35
+ import type { ModuleManifest } from '../../manifest/schema';
36
+ import { RegistryClient } from '../../registry/client';
37
+ import { decryptSecret } from '../../secrets/encryption';
38
+ import { getOrCreateMasterKey } from '../../secrets/master-key';
39
+ import { runAudit } from '../../services/audit';
40
+ import type { DriftFinding, SystemAuditReport } from '../../services/audit';
41
+ import {
42
+ type LatestCliVersionFetcher,
43
+ fetchLatestCliVersion,
44
+ } from '../../services/audit/cli-version';
45
+ import type { MachineReachableResult } from '../../services/audit/machines-reachable';
46
+ import type { ModuleVersionFetcher } from '../../services/audit/module-versions';
47
+ import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
48
+ import type { SecretCheckResult } from '../../services/audit/secrets-decryptable';
49
+ import type { ServiceCredentialsResult } from '../../services/audit/services-credentials';
50
+ import type { ServiceReachableResult } from '../../services/audit/services-reachable';
51
+ import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
52
+ import { getServiceCredentials, listContainerServices } from '../../services/container-service';
53
+ import { runAllHealthChecks } from '../../services/health-runner';
54
+ import { listMachines } from '../../services/machine-pool';
55
+ import { buildTerraformEnvForModule } from '../../services/terraform-env';
56
+ import { hasFlag } from '../parser';
57
+ import type { CommandResult } from '../types';
58
+
59
+ const execFileAsync = promisify(execFile);
60
+
61
+ /** Read the running CLI version from the bundled package.json. */
62
+ function readInstalledCliVersion(): string {
63
+ const here = dirname(fileURLToPath(import.meta.url));
64
+ const candidates = [
65
+ join(here, '..', '..', '..', 'package.json'),
66
+ join(process.cwd(), 'package.json'),
67
+ ];
68
+ for (const path of candidates) {
69
+ if (existsSync(path)) {
70
+ try {
71
+ const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { version?: string };
72
+ if (pkg.version) return pkg.version;
73
+ } catch {
74
+ // try the next candidate
75
+ }
76
+ }
77
+ }
78
+ return '0.0.0'; // unknown — caller treats as "ahead of latest"
79
+ }
80
+
81
+ function makeRegistryFetcher(client: RegistryClient): ModuleVersionFetcher {
82
+ return async (moduleId) => {
83
+ const entries = await client.getIndex(moduleId);
84
+ if (entries.length === 0) return { latest: null };
85
+ const latest = client.latestVersion(entries);
86
+ if (!latest) return { latest: null };
87
+ return { latest: latest.vers, intermediateCount: entries.length };
88
+ };
89
+ }
90
+
91
+ const realTerraformPlan: TerraformPlanRunner = async (terraformDir, envVars) => {
92
+ try {
93
+ const result = await execFileAsync(
94
+ 'terraform',
95
+ ['plan', '-detailed-exitcode', '-no-color', '-input=false'],
96
+ {
97
+ cwd: terraformDir,
98
+ timeout: 120_000,
99
+ // Inherit current env, then layer module-specific TF_VAR_*
100
+ // credentials so terraform sees the same world `module deploy`
101
+ // would. Without this, modules with provider creds (Proxmox,
102
+ // DigitalOcean) fail immediately with "no value for required
103
+ // variable" — a false positive, not real drift.
104
+ env: { ...process.env, ...envVars },
105
+ },
106
+ );
107
+ return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
108
+ } catch (err) {
109
+ const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
110
+ return {
111
+ // detailed-exitcode: 0 = no diff, 1 = error, 2 = diff
112
+ exitCode: e.code ?? 1,
113
+ stdout: e.stdout ?? '',
114
+ stderr: e.stderr ?? e.message ?? 'terraform plan failed',
115
+ };
116
+ }
117
+ };
118
+
119
+ function findMigrationsFolderSafe(): string | null {
120
+ // Mirror db/client.ts:findMigrationsFolder, but return null instead
121
+ // of throwing so the audit gracefully reports "schema check skipped"
122
+ // rather than crashing.
123
+ try {
124
+ const here = dirname(fileURLToPath(import.meta.url));
125
+ const candidates = [
126
+ join(here, '..', '..', '..', 'drizzle'),
127
+ join(process.cwd(), 'drizzle'),
128
+ join(process.cwd(), 'apps', 'celilo', 'drizzle'),
129
+ ];
130
+ for (const c of candidates) {
131
+ if (existsSync(join(c, 'meta', '_journal.json'))) return c;
132
+ }
133
+ return null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Build the dependency object for `runAudit` from the live system.
141
+ *
142
+ * `onProgress` (when supplied) suppresses stdout-bound progress
143
+ * indicators (FuelGauge) inside the audit subroutines and routes
144
+ * progress messages to the caller instead — used by the TUI so
145
+ * audit progress appears inside the alt-screen render.
146
+ */
147
+ async function buildAuditDeps(onProgress?: (msg: string) => void) {
148
+ const db = getDb();
149
+
150
+ const installed = db.select().from(modules).all();
151
+ const deployedModules = installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state));
152
+
153
+ const registryClient = new RegistryClient();
154
+
155
+ // Most recent successful backup per module.
156
+ // The `backups` table is added via inline ALTER statements in db/client.ts
157
+ // for upgraded databases; a freshly-initialized DB created from the
158
+ // drizzle journal alone may not have it yet. Treat absence as "no
159
+ // backups recorded" rather than crashing the audit.
160
+ const latestBackupByModule = new Map<string, number>();
161
+ try {
162
+ const successfulBackups = db
163
+ .select()
164
+ .from(backups)
165
+ .where(eq(backups.status, 'completed'))
166
+ .all();
167
+ for (const b of successfulBackups) {
168
+ if (!b.moduleId || !b.completedAt) continue;
169
+ const ts = b.completedAt.getTime();
170
+ const prev = latestBackupByModule.get(b.moduleId);
171
+ if (prev === undefined || ts > prev) latestBackupByModule.set(b.moduleId, ts);
172
+ }
173
+ } catch {
174
+ // backups table missing — leave map empty
175
+ }
176
+
177
+ // Build per-module config map for module-configs check.
178
+ const allConfigs = db
179
+ .select()
180
+ .from(moduleConfigsTbl)
181
+ .where(isNotNull(moduleConfigsTbl.moduleId))
182
+ .all();
183
+ const configsByModule = new Map<string, Record<string, unknown>>();
184
+ for (const c of allConfigs) {
185
+ const map = configsByModule.get(c.moduleId) ?? {};
186
+ map[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
187
+ configsByModule.set(c.moduleId, map);
188
+ }
189
+
190
+ const installedConfigs = deployedModules.map((m) => ({
191
+ id: m.id,
192
+ manifest: m.manifestData as ModuleManifest,
193
+ configs: configsByModule.get(m.id) ?? {},
194
+ }));
195
+
196
+ const installedBackupInfo = deployedModules.map((m) => ({
197
+ id: m.id,
198
+ manifest: m.manifestData as ModuleManifest,
199
+ lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
200
+ }));
201
+
202
+ // Compose per-module TF_VAR_* env vars in parallel — each call hits
203
+ // the secret store / DB so they're not free, but they're all
204
+ // independent. Empty record for machine-bound modules.
205
+ const terraformEnvByModule = new Map<string, Record<string, string>>();
206
+ await Promise.all(
207
+ deployedModules.map(async (m) => {
208
+ try {
209
+ terraformEnvByModule.set(m.id, await buildTerraformEnvForModule(m.id, db));
210
+ } catch {
211
+ // Credential lookup failed — leave empty; terraform plan will
212
+ // surface the genuine "missing var" error and the user can
213
+ // fix the underlying credential.
214
+ terraformEnvByModule.set(m.id, {});
215
+ }
216
+ }),
217
+ );
218
+
219
+ const terraformModules = deployedModules.map((m) => ({
220
+ id: m.id,
221
+ terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
222
+ ? join(m.sourcePath, 'generated', 'terraform')
223
+ : null,
224
+ envVars: terraformEnvByModule.get(m.id) ?? {},
225
+ }));
226
+
227
+ const healthResults = await runAllHealthChecks(db, { onProgress });
228
+
229
+ // Services-credentials: try decrypting each container service's
230
+ // credential envelope. Failures (missing master key, wrong
231
+ // provider shape, corrupt envelope) become BLOCKED audit findings.
232
+ const allServices = await listContainerServices();
233
+ const serviceCredResults: ServiceCredentialsResult[] = await Promise.all(
234
+ allServices.map(async (s) => {
235
+ try {
236
+ await getServiceCredentials(s.id);
237
+ return {
238
+ serviceId: s.serviceId,
239
+ name: s.name,
240
+ providerName: s.providerName,
241
+ error: null,
242
+ };
243
+ } catch (err) {
244
+ return {
245
+ serviceId: s.serviceId,
246
+ name: s.name,
247
+ providerName: s.providerName,
248
+ error: err instanceof Error ? err.message : String(err),
249
+ };
250
+ }
251
+ }),
252
+ );
253
+
254
+ // Secrets-decryptable: try decrypting every encrypted secret in
255
+ // the DB (modules / system / capabilities). One bad master key
256
+ // makes every entry fail; the audit collapses that case into a
257
+ // single "master key mismatch" finding.
258
+ const secretResults: SecretCheckResult[] = [];
259
+ try {
260
+ const masterKey = await getOrCreateMasterKey();
261
+ const tryDecrypt = (env: { encryptedValue: string; iv: string; authTag: string }):
262
+ | string
263
+ | null => {
264
+ try {
265
+ decryptSecret(env, masterKey);
266
+ return null;
267
+ } catch (err) {
268
+ return err instanceof Error ? err.message : String(err);
269
+ }
270
+ };
271
+ for (const s of db.select().from(secrets).all()) {
272
+ secretResults.push({
273
+ scope: 'module',
274
+ subject: s.moduleId,
275
+ name: s.name,
276
+ error: tryDecrypt(s),
277
+ });
278
+ }
279
+ for (const s of db.select().from(systemSecrets).all()) {
280
+ secretResults.push({
281
+ scope: 'system',
282
+ subject: 'system',
283
+ name: s.key,
284
+ error: tryDecrypt(s),
285
+ });
286
+ }
287
+ for (const s of db.select().from(capabilitySecrets).all()) {
288
+ // Capability secrets can be metadata-only (encrypted fields
289
+ // null) — skip those, they have nothing to decrypt.
290
+ if (!s.encryptedValue || !s.iv || !s.authTag) continue;
291
+ secretResults.push({
292
+ scope: 'capability',
293
+ subject: `capability:${s.capabilityId}`,
294
+ name: s.name,
295
+ error: tryDecrypt({
296
+ encryptedValue: s.encryptedValue,
297
+ iv: s.iv,
298
+ authTag: s.authTag,
299
+ }),
300
+ });
301
+ }
302
+ } catch (err) {
303
+ // Master key unavailable — surface as a single system-level finding.
304
+ secretResults.push({
305
+ scope: 'system',
306
+ subject: 'system',
307
+ name: '<master-key>',
308
+ error: err instanceof Error ? err.message : String(err),
309
+ });
310
+ }
311
+
312
+ // Services-reachable: ping each container service's API in parallel
313
+ // (Proxmox /version, DigitalOcean /v2/account). Reuses the same
314
+ // probes the `service add` wizard runs. Services with bad
315
+ // credentials get reachable=true here so we don't double-report —
316
+ // the credentials audit already flagged them.
317
+ const serviceReachableResults: ServiceReachableResult[] = await Promise.all(
318
+ allServices.map(async (s): Promise<ServiceReachableResult> => {
319
+ try {
320
+ const creds = await getServiceCredentials(s.id);
321
+ let probe: { success: boolean; message?: string };
322
+ if (s.providerName === 'proxmox' && 'api_url' in creds) {
323
+ probe = await testProxmoxConnection(creds);
324
+ } else if (s.providerName === 'digitalocean' && 'api_token' in creds) {
325
+ probe = await testDigitalOceanConnection(creds);
326
+ } else {
327
+ return {
328
+ serviceId: s.serviceId,
329
+ name: s.name,
330
+ providerName: s.providerName,
331
+ reachable: true,
332
+ };
333
+ }
334
+ return {
335
+ serviceId: s.serviceId,
336
+ name: s.name,
337
+ providerName: s.providerName,
338
+ reachable: probe.success,
339
+ message: probe.success ? undefined : (probe.message ?? 'unreachable'),
340
+ };
341
+ } catch {
342
+ return {
343
+ serviceId: s.serviceId,
344
+ name: s.name,
345
+ providerName: s.providerName,
346
+ reachable: true,
347
+ };
348
+ }
349
+ }),
350
+ );
351
+
352
+ // Machines-reachable: SSH probe each pool machine in parallel.
353
+ // BatchMode=yes prevents password prompt hangs; ConnectTimeout=5
354
+ // bounds wait on unresponsive hosts. The audit's collapse logic
355
+ // turns "all unreachable" into a single host/network finding.
356
+ const allMachines = await listMachines();
357
+ const machineReachableResults: MachineReachableResult[] = await Promise.all(
358
+ allMachines.map(async (m): Promise<MachineReachableResult> => {
359
+ try {
360
+ await execFileAsync(
361
+ 'ssh',
362
+ [
363
+ '-o',
364
+ 'BatchMode=yes',
365
+ '-o',
366
+ 'ConnectTimeout=5',
367
+ '-o',
368
+ 'StrictHostKeyChecking=no',
369
+ '-o',
370
+ 'UserKnownHostsFile=/dev/null',
371
+ `${m.sshUser}@${m.ipAddress}`,
372
+ 'true',
373
+ ],
374
+ { timeout: 8000 },
375
+ );
376
+ return { id: m.id, hostname: m.hostname, ipAddress: m.ipAddress, reachable: true };
377
+ } catch (err) {
378
+ const e = err as { stderr?: string; message?: string };
379
+ return {
380
+ id: m.id,
381
+ hostname: m.hostname,
382
+ ipAddress: m.ipAddress,
383
+ reachable: false,
384
+ message: (e.stderr || e.message || 'unknown error').slice(0, 200),
385
+ };
386
+ }
387
+ }),
388
+ );
389
+
390
+ const migrationsFolder = findMigrationsFolderSafe();
391
+
392
+ return {
393
+ cliVersion: {
394
+ installedVersion: readInstalledCliVersion(),
395
+ fetcher: fetchLatestCliVersion satisfies LatestCliVersionFetcher,
396
+ },
397
+ schema: {
398
+ journal: migrationsFolder ? makeJournalReader(migrationsFolder) : () => null,
399
+ applied: readAppliedMigrations,
400
+ db,
401
+ },
402
+ capabilityAbi: {
403
+ modules: deployedModules.map((m) => ({
404
+ id: m.id,
405
+ manifest: m.manifestData as ModuleManifest,
406
+ })),
407
+ },
408
+ terraformPlan: {
409
+ modules: terraformModules,
410
+ run: realTerraformPlan,
411
+ },
412
+ moduleVersions: {
413
+ installed: deployedModules.map((m) => ({ id: m.id, version: m.version })),
414
+ fetcher: makeRegistryFetcher(registryClient),
415
+ },
416
+ moduleConfigs: { modules: installedConfigs },
417
+ health: { results: healthResults },
418
+ backups: { modules: installedBackupInfo },
419
+ undeployedModules: {
420
+ modules: installed.map((m) => ({ id: m.id, state: m.state })),
421
+ },
422
+ unconfiguredModules: {
423
+ modules: installed.map((m) => ({
424
+ id: m.id,
425
+ state: m.state,
426
+ configCount: configsByModule.get(m.id)
427
+ ? Object.keys(configsByModule.get(m.id) ?? {}).length
428
+ : 0,
429
+ })),
430
+ },
431
+ servicesCredentials: { results: serviceCredResults },
432
+ secretsDecryptable: { results: secretResults },
433
+ servicesReachable: { results: serviceReachableResults },
434
+ machinesReachable: { results: machineReachableResults },
435
+ };
436
+ }
437
+
438
+ const VERDICT_ICON: Record<string, string> = {
439
+ READY: '●',
440
+ DRIFT: '⚠',
441
+ BLOCKED: '✗',
442
+ };
443
+
444
+ const SEVERITY_ICON: Record<string, string> = {
445
+ drift: '⚠',
446
+ blocked: '✗',
447
+ };
448
+
449
+ /** Format a `SystemAuditReport` for a human reader. */
450
+ export function formatReport(report: SystemAuditReport): string {
451
+ const lines: string[] = [];
452
+ lines.push('');
453
+ lines.push(
454
+ `${VERDICT_ICON[report.verdict] ?? '?'} ${report.verdict} (${report.findings.length} finding${report.findings.length === 1 ? '' : 's'})`,
455
+ );
456
+ lines.push(` generated at ${report.generatedAt}`);
457
+
458
+ if (report.findings.length === 0) {
459
+ lines.push('');
460
+ lines.push(' System is up to date. No drift detected.');
461
+ return lines.join('\n');
462
+ }
463
+
464
+ // Group findings by severity, then by category.
465
+ const byCategory = new Map<string, DriftFinding[]>();
466
+ for (const f of report.findings) {
467
+ const key = `${f.severity}/${f.category}`;
468
+ const arr = byCategory.get(key) ?? [];
469
+ arr.push(f);
470
+ byCategory.set(key, arr);
471
+ }
472
+
473
+ // Sort: blocked first, then by category name.
474
+ const keys = [...byCategory.keys()].sort((a, b) => {
475
+ const aBlocked = a.startsWith('blocked/');
476
+ const bBlocked = b.startsWith('blocked/');
477
+ if (aBlocked !== bBlocked) return aBlocked ? -1 : 1;
478
+ return a.localeCompare(b);
479
+ });
480
+
481
+ for (const key of keys) {
482
+ const findings = byCategory.get(key) ?? [];
483
+ const [severity, category] = key.split('/');
484
+ lines.push('');
485
+ lines.push(
486
+ ` ${SEVERITY_ICON[severity]} ${severity.toUpperCase()} • ${category} (${findings.length})`,
487
+ );
488
+ for (const f of findings) {
489
+ lines.push(` - ${f.message}`);
490
+ if (f.details) {
491
+ for (const detail of f.details.split('\n')) {
492
+ lines.push(` ${detail}`);
493
+ }
494
+ }
495
+ if (f.remediation) {
496
+ lines.push(` → ${f.remediation}`);
497
+ }
498
+ }
499
+ }
500
+
501
+ return lines.join('\n');
502
+ }
503
+
504
+ export async function handleSystemAudit(
505
+ _args: string[],
506
+ flags: Record<string, string | boolean>,
507
+ ): Promise<CommandResult> {
508
+ const json = hasFlag(flags, 'json');
509
+ const explicitTui = hasFlag(flags, 'tui');
510
+ const noTui = hasFlag(flags, 'no-tui');
511
+
512
+ // Default to the TUI when stdout is an interactive terminal AND
513
+ // the user hasn't asked for a non-interactive output mode. JSON
514
+ // and --no-tui both opt out; piped/redirected stdout falls back
515
+ // to the static text report so the audit stays scriptable.
516
+ const tui = !json && !noTui && (explicitTui || Boolean(process.stdout.isTTY));
517
+
518
+ if (tui) {
519
+ // Defer the audit to inside the TUI so progress messages render
520
+ // in the alt-screen rather than leaking to the user's scrollback.
521
+ const themeRaw = String(flags.theme ?? '').toLowerCase();
522
+ const theme = themeRaw === 'light' ? 'light' : themeRaw === 'dark' ? 'dark' : undefined;
523
+ const { renderAuditTui } = await import('../tui/audit-tui');
524
+ try {
525
+ await renderAuditTui(
526
+ async (onProgress, onCategory) => {
527
+ // Bracket the two long phases so the user sees something
528
+ // before health checks begin emitting per-module messages,
529
+ // and so the spinner caption keeps changing during the
530
+ // parallel-audit phase (which doesn't emit its own).
531
+ onProgress('Reading system state…');
532
+ const deps = await buildAuditDeps(onProgress);
533
+ onProgress('Analyzing drift across categories…');
534
+ return runAudit(deps, onCategory);
535
+ },
536
+ { theme },
537
+ );
538
+ return { success: true, message: '' };
539
+ } catch (err) {
540
+ return {
541
+ success: false,
542
+ error: err instanceof Error ? err.message : String(err),
543
+ };
544
+ }
545
+ }
546
+
547
+ let deps: Awaited<ReturnType<typeof buildAuditDeps>>;
548
+ try {
549
+ deps = await buildAuditDeps();
550
+ } catch (err) {
551
+ return {
552
+ success: false,
553
+ error: `audit failed to gather state: ${err instanceof Error ? err.message : String(err)}`,
554
+ };
555
+ }
556
+
557
+ const report = await runAudit(deps);
558
+
559
+ if (json) {
560
+ return {
561
+ success: true,
562
+ message: JSON.stringify(report, null, 2),
563
+ rawOutput: true,
564
+ };
565
+ }
566
+
567
+ return {
568
+ success: true,
569
+ message: formatReport(report),
570
+ };
571
+ }