@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,525 @@
1
+ import { useReducer } from 'react';
2
+ import type {
3
+ DriftCategory,
4
+ DriftFinding,
5
+ DriftSeverity,
6
+ SystemAuditReport,
7
+ } from '../../services/audit/types';
8
+ import { severityRank } from './icons';
9
+ import type { ThemeName } from './theme';
10
+
11
+ export type PaneId = 'summary' | 'categories' | 'findings' | 'detail' | 'log';
12
+
13
+ export const PANE_NUMBER: Record<PaneId, '1' | '2' | '3' | '4' | '5'> = {
14
+ summary: '1',
15
+ categories: '2',
16
+ findings: '3',
17
+ detail: '4',
18
+ log: '5',
19
+ };
20
+
21
+ export const PANE_BY_NUMBER: Record<'1' | '2' | '3' | '4' | '5', PaneId> = {
22
+ '1': 'summary',
23
+ '2': 'categories',
24
+ '3': 'findings',
25
+ '4': 'detail',
26
+ '5': 'log',
27
+ };
28
+
29
+ export interface CategoryGroup {
30
+ category: DriftCategory;
31
+ severity: DriftSeverity;
32
+ findings: DriftFinding[];
33
+ }
34
+
35
+ export interface CommandLogEntry {
36
+ /** Stable identifier (and start timestamp ms) for keying / ordering. */
37
+ startedAt: number;
38
+ cmd: string;
39
+ lines: { stream: 'stdout' | 'stderr'; text: string }[];
40
+ /** null while the command is still running. */
41
+ exitCode: number | null;
42
+ }
43
+
44
+ /**
45
+ * Active modal, if any. Modals are mutually exclusive — only one
46
+ * shows at a time, and the regular pane keymap is suspended while
47
+ * one is open.
48
+ */
49
+ export type ModalState =
50
+ | null
51
+ | { kind: 'remediate'; findingId: string; command: string }
52
+ | { kind: 'reaudit-prompt' }
53
+ | { kind: 'celebration' }
54
+ | { kind: 'analyzing' };
55
+
56
+ /**
57
+ * Per-category progress state — drives the analyzing modal's
58
+ * fuel-gauges. `done` carries the verdict so the row can show the
59
+ * right icon (✓ clean / ▲ drift / × blocked).
60
+ */
61
+ export type CategoryStatus =
62
+ | 'pending'
63
+ | 'running'
64
+ | { kind: 'done'; verdict: 'clean' | 'drift' | 'blocked' };
65
+
66
+ /**
67
+ * The full set of categories the audit emits — used to seed the
68
+ * progress map and order the rows in the analyzing modal. Mirrors
69
+ * the order in `runAudit`'s wrap calls.
70
+ */
71
+ export const ALL_CATEGORIES: readonly DriftCategory[] = [
72
+ 'cli_version',
73
+ 'schema',
74
+ 'capability_abi',
75
+ 'terraform_plan',
76
+ 'module_versions',
77
+ 'module_configs',
78
+ 'health',
79
+ 'backups',
80
+ 'undeployed_modules',
81
+ 'unconfigured_modules',
82
+ 'services_credentials',
83
+ 'secrets_decryptable',
84
+ 'services_reachable',
85
+ 'machines_reachable',
86
+ ];
87
+
88
+ export const CATEGORY_LABELS: Record<DriftCategory, string> = {
89
+ cli_version: 'CLI version',
90
+ schema: 'Schema migrations',
91
+ capability_abi: 'Capability ABI',
92
+ terraform_plan: 'Terraform plans',
93
+ module_versions: 'Module versions',
94
+ module_configs: 'Module configs',
95
+ health: 'Module health',
96
+ backups: 'Backups',
97
+ undeployed_modules: 'Undeployed modules',
98
+ unconfigured_modules: 'Unconfigured modules',
99
+ services_credentials: 'Service credentials',
100
+ secrets_decryptable: 'Secrets',
101
+ services_reachable: 'Service reachability',
102
+ machines_reachable: 'Machine reachability',
103
+ };
104
+
105
+ /** Total lines we keep across the command log before evicting oldest. */
106
+ export const COMMAND_LOG_MAX_LINES = 1000;
107
+
108
+ export interface AuditTuiState {
109
+ report: SystemAuditReport;
110
+ groups: CategoryGroup[];
111
+ selectedCategory: DriftCategory | null;
112
+ /**
113
+ * Composite identifier (`${subject}/${code}`) for the selected
114
+ * finding. Two findings within a category often share a `code`
115
+ * (e.g. `module_config_unset` for every misconfigured module), so
116
+ * `code` alone isn't unique — we need the subject too.
117
+ */
118
+ selectedFindingId: string | null;
119
+ focusedPane: PaneId;
120
+ helpOpen: boolean;
121
+ auditing: boolean;
122
+ /** Latest message from an audit-progress callback; cleared when audit completes. */
123
+ progressMessage: string | null;
124
+ commandLog: CommandLogEntry[];
125
+ modal: ModalState;
126
+ /** Transient confirmation shown in the keybar (e.g. "Copied"); cleared by timer. */
127
+ flashMessage: string | null;
128
+ /** Active theme. Toggled in-session via `t`; defaults from CLI flag. */
129
+ theme: ThemeName;
130
+ /**
131
+ * Per-category audit progress. Populated by `category-start` /
132
+ * `category-end` actions emitted by the audit runner. Drives the
133
+ * analyzing modal's fuel-gauges and auto-dismiss.
134
+ */
135
+ categoryProgress: Record<DriftCategory, CategoryStatus>;
136
+ }
137
+
138
+ /** Stable per-finding identifier used for selection and React keys. */
139
+ export function findingId(f: DriftFinding): string {
140
+ return `${f.subject}/${f.code}`;
141
+ }
142
+
143
+ export type AuditTuiAction =
144
+ | { type: 'focus'; pane: PaneId }
145
+ | { type: 'cycle'; direction: 1 | -1 }
146
+ | { type: 'move'; direction: 1 | -1 }
147
+ | { type: 'enter' }
148
+ | { type: 'escape' }
149
+ | { type: 'toggle-help' }
150
+ | { type: 'set-report'; report: SystemAuditReport }
151
+ | { type: 'set-auditing'; auditing: boolean }
152
+ | { type: 'progress'; message: string }
153
+ // Modal lifecycle
154
+ | { type: 'open-remediate'; findingId: string; command: string }
155
+ | { type: 'set-modal-command'; command: string }
156
+ | { type: 'open-reaudit-prompt' }
157
+ | { type: 'open-celebration' }
158
+ | { type: 'open-analyzing' }
159
+ | { type: 'close-modal' }
160
+ // Command log lifecycle
161
+ | { type: 'log-start'; startedAt: number; cmd: string }
162
+ | { type: 'log-line'; startedAt: number; stream: 'stdout' | 'stderr'; text: string }
163
+ | { type: 'log-finish'; startedAt: number; exitCode: number }
164
+ | { type: 'flash'; message: string | null }
165
+ | { type: 'toggle-theme' }
166
+ | { type: 'select-category'; index: number }
167
+ | { type: 'select-finding'; index: number }
168
+ // Per-category audit-progress lifecycle.
169
+ | { type: 'category-start'; category: DriftCategory }
170
+ | { type: 'category-end'; category: DriftCategory; verdict: 'clean' | 'drift' | 'blocked' }
171
+ | { type: 'reset-category-progress' };
172
+
173
+ const PANE_ORDER: PaneId[] = ['summary', 'categories', 'findings', 'detail', 'log'];
174
+
175
+ /**
176
+ * Bucket findings by category, sorted with `blocked` first then `drift`.
177
+ *
178
+ * The category list shown in pane 2 is exactly the categories that
179
+ * have at least one finding — empty categories are omitted from the
180
+ * UI entirely (they're "OK" and would be noise).
181
+ */
182
+ export function groupFindings(findings: DriftFinding[]): CategoryGroup[] {
183
+ const byCat = new Map<DriftCategory, DriftFinding[]>();
184
+ for (const f of findings) {
185
+ const existing = byCat.get(f.category) ?? [];
186
+ existing.push(f);
187
+ byCat.set(f.category, existing);
188
+ }
189
+ const groups: CategoryGroup[] = [];
190
+ for (const [category, list] of byCat) {
191
+ const severity: DriftSeverity = list.some((f) => f.severity === 'blocked')
192
+ ? 'blocked'
193
+ : 'drift';
194
+ groups.push({ category, severity, findings: list });
195
+ }
196
+ groups.sort((a, b) => {
197
+ const sd = severityRank(a.severity) - severityRank(b.severity);
198
+ if (sd !== 0) return sd;
199
+ return a.category.localeCompare(b.category);
200
+ });
201
+ return groups;
202
+ }
203
+
204
+ export function findingsForCategory(
205
+ groups: CategoryGroup[],
206
+ category: DriftCategory | null,
207
+ ): DriftFinding[] {
208
+ if (!category) return [];
209
+ return groups.find((g) => g.category === category)?.findings ?? [];
210
+ }
211
+
212
+ export function selectedFinding(state: AuditTuiState): DriftFinding | null {
213
+ if (!state.selectedCategory || !state.selectedFindingId) return null;
214
+ const list = findingsForCategory(state.groups, state.selectedCategory);
215
+ return list.find((f) => findingId(f) === state.selectedFindingId) ?? null;
216
+ }
217
+
218
+ function initialCategoryProgress(): Record<DriftCategory, CategoryStatus> {
219
+ const out: Partial<Record<DriftCategory, CategoryStatus>> = {};
220
+ for (const c of ALL_CATEGORIES) out[c] = 'pending';
221
+ return out as Record<DriftCategory, CategoryStatus>;
222
+ }
223
+
224
+ export function initState(
225
+ report: SystemAuditReport,
226
+ theme: ThemeName = 'dark',
227
+ /**
228
+ * Whether an audit run is about to start. When true, the summary
229
+ * pane shows the spinner + "starting…" from frame zero, instead of
230
+ * briefly flashing the empty-report's "0 blocked, 0 drift" between
231
+ * mount and the first audit dispatch. Also opens the analyzing
232
+ * modal so the user sees the per-category fuel-gauges immediately.
233
+ */
234
+ auditing = false,
235
+ ): AuditTuiState {
236
+ const groups = groupFindings(report.findings);
237
+ const firstCategory = groups[0]?.category ?? null;
238
+ const firstFindingObj = firstCategory ? findingsForCategory(groups, firstCategory)[0] : undefined;
239
+ return {
240
+ report,
241
+ groups,
242
+ selectedCategory: firstCategory,
243
+ selectedFindingId: firstFindingObj ? findingId(firstFindingObj) : null,
244
+ focusedPane: 'categories',
245
+ helpOpen: false,
246
+ auditing,
247
+ progressMessage: null,
248
+ commandLog: [],
249
+ modal: auditing ? { kind: 'analyzing' } : null,
250
+ flashMessage: null,
251
+ theme,
252
+ categoryProgress: initialCategoryProgress(),
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Build an empty starting report — used when the TUI runs the audit
258
+ * itself (deferred audit) so the panes have valid placeholders while
259
+ * the real audit is still running.
260
+ */
261
+ export function emptyReport(): SystemAuditReport {
262
+ return {
263
+ version: 1,
264
+ verdict: 'READY',
265
+ generatedAt: new Date().toISOString(),
266
+ findings: [],
267
+ };
268
+ }
269
+
270
+ function nextPane(current: PaneId, direction: 1 | -1): PaneId {
271
+ const i = PANE_ORDER.indexOf(current);
272
+ const n = PANE_ORDER.length;
273
+ const next = (i + direction + n) % n;
274
+ return PANE_ORDER[next];
275
+ }
276
+
277
+ /**
278
+ * Drill-in chain (Enter):
279
+ * summary → categories
280
+ * categories → findings (and selects first finding of selected category)
281
+ * findings → detail
282
+ * detail → no-op (scrolling handled separately)
283
+ * log → no-op
284
+ */
285
+ function drillIn(state: AuditTuiState): AuditTuiState {
286
+ switch (state.focusedPane) {
287
+ case 'summary':
288
+ return { ...state, focusedPane: 'categories' };
289
+ case 'categories': {
290
+ const first = findingsForCategory(state.groups, state.selectedCategory)[0];
291
+ if (!first) return state;
292
+ return {
293
+ ...state,
294
+ focusedPane: 'findings',
295
+ selectedFindingId: findingId(first),
296
+ };
297
+ }
298
+ case 'findings':
299
+ if (state.selectedFindingId === null) return state;
300
+ return { ...state, focusedPane: 'detail' };
301
+ default:
302
+ return state;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Step-back chain (Esc):
308
+ * detail → findings
309
+ * findings → categories
310
+ * categories → quit (handled by caller — reducer sets a sentinel via
311
+ * helpOpen=false focusedPane='summary' is ambiguous; the
312
+ * top-level component watches for Esc on pane 1/2 itself)
313
+ * summary → quit (top-level handles)
314
+ * log → detail (escape "down then over" if user got there from 5)
315
+ */
316
+ function stepBack(state: AuditTuiState): AuditTuiState {
317
+ switch (state.focusedPane) {
318
+ case 'detail':
319
+ return { ...state, focusedPane: 'findings' };
320
+ case 'findings':
321
+ return { ...state, focusedPane: 'categories' };
322
+ case 'log':
323
+ return { ...state, focusedPane: 'detail' };
324
+ default:
325
+ return state;
326
+ }
327
+ }
328
+
329
+ function moveCategorySelection(state: AuditTuiState, direction: 1 | -1): AuditTuiState {
330
+ if (state.groups.length === 0) return state;
331
+ const idx = state.groups.findIndex((g) => g.category === state.selectedCategory);
332
+ const next = (idx + direction + state.groups.length) % state.groups.length;
333
+ const newCategory = state.groups[next].category;
334
+ const first = findingsForCategory(state.groups, newCategory)[0];
335
+ return {
336
+ ...state,
337
+ selectedCategory: newCategory,
338
+ selectedFindingId: first ? findingId(first) : null,
339
+ };
340
+ }
341
+
342
+ function moveFindingSelection(state: AuditTuiState, direction: 1 | -1): AuditTuiState {
343
+ const list = findingsForCategory(state.groups, state.selectedCategory);
344
+ if (list.length === 0) return state;
345
+ const idx = list.findIndex((f) => findingId(f) === state.selectedFindingId);
346
+ const next = (idx + direction + list.length) % list.length;
347
+ return { ...state, selectedFindingId: findingId(list[next]) };
348
+ }
349
+
350
+ export function reducer(state: AuditTuiState, action: AuditTuiAction): AuditTuiState {
351
+ switch (action.type) {
352
+ case 'focus':
353
+ // Prevent focusing 'findings' if there's no category, etc.
354
+ if (action.pane === 'findings' && state.selectedCategory === null) return state;
355
+ if (action.pane === 'detail' && state.selectedFindingId === null) return state;
356
+ return { ...state, focusedPane: action.pane };
357
+ case 'cycle':
358
+ return { ...state, focusedPane: nextPane(state.focusedPane, action.direction) };
359
+ case 'move':
360
+ if (state.focusedPane === 'categories') {
361
+ return moveCategorySelection(state, action.direction);
362
+ }
363
+ if (state.focusedPane === 'findings') {
364
+ return moveFindingSelection(state, action.direction);
365
+ }
366
+ // detail and log handle their own scrolling at the component level.
367
+ return state;
368
+ case 'enter':
369
+ return drillIn(state);
370
+ case 'escape':
371
+ return stepBack(state);
372
+ case 'toggle-help':
373
+ return { ...state, helpOpen: !state.helpOpen };
374
+ case 'set-report': {
375
+ const groups = groupFindings(action.report.findings);
376
+ // Preserve selection if still present after re-audit.
377
+ const stillHasCategory = groups.some((g) => g.category === state.selectedCategory);
378
+ const newCategory = stillHasCategory ? state.selectedCategory : (groups[0]?.category ?? null);
379
+ const list = findingsForCategory(groups, newCategory);
380
+ const stillHasFinding = list.some((f) => findingId(f) === state.selectedFindingId);
381
+ const newFindingId = stillHasFinding
382
+ ? state.selectedFindingId
383
+ : list[0]
384
+ ? findingId(list[0])
385
+ : null;
386
+ return {
387
+ ...state,
388
+ report: action.report,
389
+ groups,
390
+ selectedCategory: newCategory,
391
+ selectedFindingId: newFindingId,
392
+ progressMessage: null,
393
+ // Defensive: close the analyzing modal if any category-end
394
+ // events were dropped. By the time set-report fires, all
395
+ // categories have completed by definition.
396
+ modal: state.modal?.kind === 'analyzing' ? null : state.modal,
397
+ };
398
+ }
399
+ case 'set-auditing':
400
+ return {
401
+ ...state,
402
+ auditing: action.auditing,
403
+ progressMessage: action.auditing ? state.progressMessage : null,
404
+ };
405
+ case 'progress':
406
+ return { ...state, progressMessage: action.message };
407
+ case 'open-remediate':
408
+ return {
409
+ ...state,
410
+ modal: { kind: 'remediate', findingId: action.findingId, command: action.command },
411
+ };
412
+ case 'set-modal-command':
413
+ if (state.modal?.kind !== 'remediate') return state;
414
+ return { ...state, modal: { ...state.modal, command: action.command } };
415
+ case 'open-reaudit-prompt':
416
+ return { ...state, modal: { kind: 'reaudit-prompt' } };
417
+ case 'open-celebration':
418
+ return { ...state, modal: { kind: 'celebration' } };
419
+ case 'open-analyzing':
420
+ return { ...state, modal: { kind: 'analyzing' } };
421
+ case 'close-modal':
422
+ return { ...state, modal: null };
423
+ case 'log-start': {
424
+ const entry: CommandLogEntry = {
425
+ startedAt: action.startedAt,
426
+ cmd: action.cmd,
427
+ lines: [],
428
+ exitCode: null,
429
+ };
430
+ return { ...state, commandLog: capLog([...state.commandLog, entry]) };
431
+ }
432
+ case 'log-line':
433
+ return {
434
+ ...state,
435
+ commandLog: capLog(
436
+ state.commandLog.map((e) =>
437
+ e.startedAt === action.startedAt
438
+ ? { ...e, lines: [...e.lines, { stream: action.stream, text: action.text }] }
439
+ : e,
440
+ ),
441
+ ),
442
+ };
443
+ case 'log-finish':
444
+ return {
445
+ ...state,
446
+ commandLog: state.commandLog.map((e) =>
447
+ e.startedAt === action.startedAt ? { ...e, exitCode: action.exitCode } : e,
448
+ ),
449
+ };
450
+ case 'flash':
451
+ return { ...state, flashMessage: action.message };
452
+ case 'toggle-theme':
453
+ return { ...state, theme: state.theme === 'dark' ? 'light' : 'dark' };
454
+ case 'select-category': {
455
+ const target = state.groups[action.index];
456
+ if (!target) return state;
457
+ const first = findingsForCategory(state.groups, target.category)[0];
458
+ return {
459
+ ...state,
460
+ selectedCategory: target.category,
461
+ selectedFindingId: first ? findingId(first) : null,
462
+ };
463
+ }
464
+ case 'select-finding': {
465
+ const list = findingsForCategory(state.groups, state.selectedCategory);
466
+ const target = list[action.index];
467
+ if (!target) return state;
468
+ return { ...state, selectedFindingId: findingId(target) };
469
+ }
470
+ case 'category-start': {
471
+ return {
472
+ ...state,
473
+ categoryProgress: { ...state.categoryProgress, [action.category]: 'running' },
474
+ };
475
+ }
476
+ case 'category-end': {
477
+ const next = {
478
+ ...state.categoryProgress,
479
+ [action.category]: { kind: 'done' as const, verdict: action.verdict },
480
+ };
481
+ // Auto-dismiss the analyzing modal once every category has
482
+ // finished — the user shouldn't have to press anything to
483
+ // transition from "we're working" to "here are the results."
484
+ const allDone = ALL_CATEGORIES.every((c) => {
485
+ const s = next[c];
486
+ return typeof s === 'object' && s.kind === 'done';
487
+ });
488
+ const modalCleared = allDone && state.modal?.kind === 'analyzing' ? null : state.modal;
489
+ return {
490
+ ...state,
491
+ categoryProgress: next,
492
+ modal: modalCleared,
493
+ };
494
+ }
495
+ case 'reset-category-progress':
496
+ return { ...state, categoryProgress: initialCategoryProgress() };
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Drop oldest entries until the total line count across all entries
502
+ * is under the cap. Entries are kept whole — we don't truncate the
503
+ * middle of an entry. With chatty commands a single very long entry
504
+ * can exceed the cap on its own; that's intentional, we'd rather
505
+ * show all of one command's output than half of two.
506
+ */
507
+ function capLog(log: CommandLogEntry[]): CommandLogEntry[] {
508
+ let total = log.reduce((acc, e) => acc + e.lines.length, 0);
509
+ if (total <= COMMAND_LOG_MAX_LINES) return log;
510
+ const out = [...log];
511
+ while (out.length > 1 && total > COMMAND_LOG_MAX_LINES) {
512
+ const dropped = out.shift();
513
+ if (!dropped) break;
514
+ total -= dropped.lines.length;
515
+ }
516
+ return out;
517
+ }
518
+
519
+ export function useAuditState(
520
+ initialReport: SystemAuditReport,
521
+ theme: ThemeName = 'dark',
522
+ initiallyAuditing = false,
523
+ ) {
524
+ return useReducer(reducer, initialReport, (r) => initState(r, theme, initiallyAuditing));
525
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { render } from 'ink-testing-library';
3
+ import type { SystemAuditReport } from '../../services/audit/types';
4
+ import { AuditTui } from './audit-tui';
5
+
6
+ const FIXTURE: SystemAuditReport = {
7
+ version: 1,
8
+ verdict: 'BLOCKED',
9
+ generatedAt: '2026-04-25T12:00:00.000Z',
10
+ findings: [
11
+ {
12
+ category: 'capability_abi',
13
+ severity: 'blocked',
14
+ code: 'pw_major_drift',
15
+ subject: 'public_web',
16
+ message: 'public_web@2.0.0 vs runtime 1.0.0',
17
+ },
18
+ {
19
+ category: 'module_configs',
20
+ severity: 'drift',
21
+ code: 'cfg_unset_acme_ca',
22
+ subject: 'caddy',
23
+ message: 'config "acme_ca" is unset',
24
+ remediation: 'celilo module config set caddy acme_ca <value>',
25
+ actionable: true,
26
+ },
27
+ {
28
+ category: 'module_configs',
29
+ severity: 'drift',
30
+ code: 'cfg_unset_nat_rules',
31
+ subject: 'iptables',
32
+ message: 'config "nat_rules" is unset',
33
+ },
34
+ ],
35
+ };
36
+
37
+ function strip(s: string): string {
38
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping CSI escape sequences
39
+ return s.replace(/\[[0-9;]*m/g, '');
40
+ }
41
+
42
+ describe('AuditTui — initial frame', () => {
43
+ test('renders all five pane headers and the keybar', () => {
44
+ const { lastFrame } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
45
+ const out = strip(lastFrame() ?? '');
46
+ expect(out).toContain('1 Summary');
47
+ expect(out).toContain('2 Categories');
48
+ expect(out).toContain('3 Findings');
49
+ expect(out).toContain('4 Detail');
50
+ expect(out).toContain('5 Command log');
51
+ // Bottom bar shows the categories-pane bindings on initial focus
52
+ expect(out).toContain('Focus: <enter>');
53
+ expect(out).toContain('? help · q quit');
54
+ });
55
+
56
+ test('shows the verdict and finding counts in the summary pane', () => {
57
+ const { lastFrame } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
58
+ const out = strip(lastFrame() ?? '');
59
+ expect(out).toContain('BLOCKED');
60
+ expect(out).toContain('1 blocked, 2 drift');
61
+ });
62
+
63
+ test('shows the categories sorted blocked-first with counts', () => {
64
+ const { lastFrame } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
65
+ const out = strip(lastFrame() ?? '');
66
+ // Match "× BLOCKED · capability_abi (1)" and "▲ DRIFT · module_configs (2)"
67
+ // pattern in the categories pane. Use the bullet+count suffix to
68
+ // disambiguate from the findings header text.
69
+ const abiIdx = out.indexOf('capability_abi (1)');
70
+ const cfgIdx = out.indexOf('module_configs (2)');
71
+ expect(abiIdx).toBeGreaterThanOrEqual(0);
72
+ expect(cfgIdx).toBeGreaterThanOrEqual(0);
73
+ expect(abiIdx).toBeLessThan(cfgIdx);
74
+ });
75
+ });
76
+
77
+ describe('AuditTui — navigation', () => {
78
+ test('Enter from categories drills into findings', async () => {
79
+ const { lastFrame, stdin } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
80
+ stdin.write('\r'); // Enter
81
+ await new Promise((r) => setTimeout(r, 10));
82
+ const out = strip(lastFrame() ?? '');
83
+ // Findings-pane bottom bar shows "Back: <esc>" regardless of
84
+ // whether the selected finding is actionable.
85
+ expect(out).toContain('Back: <esc>');
86
+ });
87
+
88
+ test('? opens help overlay', async () => {
89
+ const { lastFrame, stdin } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
90
+ stdin.write('?');
91
+ await new Promise((r) => setTimeout(r, 10));
92
+ const out = strip(lastFrame() ?? '');
93
+ expect(out).toContain('Keymap');
94
+ expect(out).toContain('Toggle this overlay');
95
+ });
96
+
97
+ test('moving down in categories pane changes selection (and findings update)', async () => {
98
+ const { lastFrame, stdin } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
99
+ stdin.write('j'); // down
100
+ await new Promise((r) => setTimeout(r, 10));
101
+ const out = strip(lastFrame() ?? '');
102
+ // After moving down, findings header should show module_configs
103
+ expect(out).toContain('Findings · module_configs');
104
+ });
105
+ });
106
+
107
+ describe('AuditTui — remediation modal', () => {
108
+ test('r on a finding with a remediation opens the modal pre-filled', async () => {
109
+ const { lastFrame, stdin } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
110
+ // Move down to module_configs (which has a finding with remediation),
111
+ // drill into Findings, then press 'r'.
112
+ stdin.write('j');
113
+ await new Promise((r) => setTimeout(r, 10));
114
+ stdin.write('\r');
115
+ await new Promise((r) => setTimeout(r, 10));
116
+ stdin.write('r');
117
+ await new Promise((r) => setTimeout(r, 10));
118
+ const out = strip(lastFrame() ?? '');
119
+ expect(out).toContain('Remediate');
120
+ expect(out).toContain('celilo module config set caddy acme_ca');
121
+ });
122
+
123
+ test('keybar shows modal-specific bindings while remediate modal is open', async () => {
124
+ const { lastFrame, stdin } = render(<AuditTui source={{ kind: 'report', report: FIXTURE }} />);
125
+ stdin.write('j');
126
+ await new Promise((r) => setTimeout(r, 10));
127
+ stdin.write('\r');
128
+ await new Promise((r) => setTimeout(r, 10));
129
+ stdin.write('r');
130
+ await new Promise((r) => setTimeout(r, 10));
131
+ const out = strip(lastFrame() ?? '');
132
+ expect(out).toContain('Run: <enter>');
133
+ expect(out).toContain('Cancel: <esc>');
134
+ });
135
+ });