@celilo/cli 0.1.5 → 0.1.7

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,624 @@
1
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import type { AuditCategoryEvent } from '../../services/audit';
4
+ import type { SystemAuditReport } from '../../services/audit/types';
5
+ import {
6
+ type ModalState,
7
+ emptyReport,
8
+ findingId,
9
+ findingsForCategory,
10
+ selectedFinding,
11
+ useAuditState,
12
+ } from './audit-state';
13
+ import {
14
+ copyToClipboard,
15
+ formatCommandLogForClipboard,
16
+ formatFindingForClipboard,
17
+ } from './clipboard';
18
+ import { KeyBar } from './keybar';
19
+ import { handleKey } from './keymap';
20
+ import { AnalyzingModal } from './modals/analyzing';
21
+ import { CelebrationModal } from './modals/celebration';
22
+ import { ReauditPromptModal } from './modals/reaudit-prompt';
23
+ import { RemediateModal } from './modals/remediate';
24
+ import { LIST_PANE_CHROME, MOUSE_OFF, MOUSE_ON, paneAt, parseMouseEvent } from './mouse';
25
+ import { CategoriesPane } from './panes/categories';
26
+ import { CommandLogPane } from './panes/command-log';
27
+ import { DetailPane } from './panes/detail';
28
+ import { FINDINGS_CHROME, computeScrollWindow } from './panes/findings';
29
+ import { FindingsPane } from './panes/findings';
30
+ import { SummaryPane } from './panes/summary';
31
+ import { runCelilo } from './spawn';
32
+ import { type Theme, type ThemeName, getTheme } from './theme';
33
+
34
+ /**
35
+ * Callback the audit runner uses to surface progress strings (e.g.
36
+ * "Checking caddy") into the TUI without writing to stdout.
37
+ */
38
+ export type AuditProgress = (message: string) => void;
39
+
40
+ /**
41
+ * Callback for per-category lifecycle events (start / end). Drives
42
+ * the analyzing modal's fuel-gauges. Optional — the runner can omit
43
+ * it for headless/text use.
44
+ */
45
+ export type AuditCategoryProgress = (event: AuditCategoryEvent) => void;
46
+
47
+ /**
48
+ * The audit runner: takes a progress callback (free-form messages)
49
+ * and an optional category callback (lifecycle events), and returns
50
+ * the report. Called from inside the TUI so its progress flows into
51
+ * pane 1 and the analyzing modal.
52
+ */
53
+ export type AuditRunner = (
54
+ onProgress: AuditProgress,
55
+ onCategory?: AuditCategoryProgress,
56
+ ) => Promise<SystemAuditReport>;
57
+
58
+ interface Props {
59
+ /** A finished report (no progress) or a runner the TUI invokes itself. */
60
+ source: { kind: 'report'; report: SystemAuditReport } | { kind: 'runner'; run: AuditRunner };
61
+ /** Visual theme — picks dialog colors. Defaults to dark. */
62
+ theme?: ThemeName;
63
+ }
64
+
65
+ /**
66
+ * Track the terminal's column / row count, updating on resize.
67
+ *
68
+ * Ink's `useStdout` exposes the stream but not a reactive size — we
69
+ * subscribe to its `'resize'` event and re-render the layout to fill
70
+ * the new dimensions. Falls back to 80×24 when stdout doesn't expose
71
+ * dimensions (e.g. ink-testing-library's mock).
72
+ */
73
+ function useTerminalSize(): { columns: number; rows: number } {
74
+ const { stdout } = useStdout();
75
+ const [size, setSize] = useState({
76
+ columns: stdout?.columns ?? 80,
77
+ rows: stdout?.rows ?? 24,
78
+ });
79
+ useEffect(() => {
80
+ if (!stdout) return;
81
+ const handler = () => {
82
+ setSize({ columns: stdout.columns, rows: stdout.rows });
83
+ };
84
+ stdout.on('resize', handler);
85
+ return () => {
86
+ stdout.off('resize', handler);
87
+ };
88
+ }, [stdout]);
89
+ return size;
90
+ }
91
+
92
+ export function AuditTui({ source, theme: themeName }: Props) {
93
+ const initialReport = source.kind === 'report' ? source.report : emptyReport();
94
+ // When source is a runner, an audit kicks off in useEffect on the
95
+ // very next tick — start the reducer in `auditing: true` so the
96
+ // first paint shows the spinner instead of the empty report's
97
+ // "0 blocked, 0 drift" placeholder.
98
+ const [state, dispatch] = useAuditState(
99
+ initialReport,
100
+ themeName ?? 'dark',
101
+ source.kind === 'runner',
102
+ );
103
+ const { exit } = useApp();
104
+ const { columns, rows } = useTerminalSize();
105
+ const theme: Theme = getTheme(state.theme);
106
+
107
+ // Stash the runner in a ref so re-audit (`R`) can re-invoke it
108
+ // without needing the source as a dep on every effect.
109
+ const runnerRef = useRef<AuditRunner | null>(source.kind === 'runner' ? source.run : null);
110
+
111
+ /**
112
+ * Run the audit (mount or `R`-triggered) and dispatch progress /
113
+ * final report into the reducer. Skips silently if no runner is
114
+ * available (`source.kind === 'report'`).
115
+ */
116
+ const runAudit = useCallback(() => {
117
+ const run = runnerRef.current;
118
+ if (!run) return;
119
+ // Reset the per-category fuel-gauges and pop the analyzing
120
+ // modal up front. The modal auto-dismisses (via the reducer)
121
+ // when every category-end event has fired.
122
+ dispatch({ type: 'reset-category-progress' });
123
+ dispatch({ type: 'open-analyzing' });
124
+ dispatch({ type: 'set-auditing', auditing: true });
125
+
126
+ const onCategory: AuditCategoryProgress = (event) => {
127
+ if (event.phase === 'start') {
128
+ dispatch({ type: 'category-start', category: event.category });
129
+ return;
130
+ }
131
+ const findings = event.findings ?? [];
132
+ const verdict: 'clean' | 'drift' | 'blocked' = findings.some((f) => f.severity === 'blocked')
133
+ ? 'blocked'
134
+ : findings.length > 0
135
+ ? 'drift'
136
+ : 'clean';
137
+ dispatch({ type: 'category-end', category: event.category, verdict });
138
+ };
139
+
140
+ run((message) => dispatch({ type: 'progress', message }), onCategory)
141
+ .then((report) => {
142
+ dispatch({ type: 'set-report', report });
143
+ dispatch({ type: 'set-auditing', auditing: false });
144
+ // Inbox-zero — pop the celebration so the user gets a moment
145
+ // of closure rather than just staring at empty panes.
146
+ if (report.findings.length === 0) {
147
+ dispatch({ type: 'open-celebration' });
148
+ }
149
+ })
150
+ .catch((err) => {
151
+ dispatch({
152
+ type: 'progress',
153
+ message: `audit failed: ${err instanceof Error ? err.message : String(err)}`,
154
+ });
155
+ dispatch({ type: 'set-auditing', auditing: false });
156
+ // Audit failed mid-flight — close the analyzing modal so
157
+ // the user isn't stuck staring at half-filled fuel-gauges.
158
+ dispatch({ type: 'close-modal' });
159
+ });
160
+ }, [dispatch]);
161
+
162
+ // Kick off the initial audit on mount.
163
+ // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect
164
+ useEffect(() => {
165
+ runAudit();
166
+ }, []);
167
+
168
+ // Modal-aware input handling. When a modal is open, route keys
169
+ // there; otherwise hand off to the regular keymap.
170
+ useInput((input, key) => {
171
+ // Swallow SGR mouse sequences — they reach useInput as
172
+ // `key.escape: true` plus an `input` like `[<0;5;3M`. Mouse
173
+ // handling lives in the stdin effect below.
174
+ if (key.escape && /^\[<\d+;\d+;\d+[Mm]/.test(input)) return;
175
+ if (state.helpOpen) {
176
+ if (key.escape || input === '?' || input === 'q') {
177
+ dispatch({ type: 'toggle-help' });
178
+ }
179
+ return;
180
+ }
181
+ if (state.modal?.kind === 'remediate') {
182
+ // ink-text-input handles printable keys + Enter; we just watch
183
+ // for Esc to cancel here. Enter is wired through the modal's
184
+ // onSubmit prop below.
185
+ if (key.escape) dispatch({ type: 'close-modal' });
186
+ return;
187
+ }
188
+ if (state.modal?.kind === 'reaudit-prompt') {
189
+ if (input === 'y' || input === 'Y' || key.return) {
190
+ dispatch({ type: 'close-modal' });
191
+ runAudit();
192
+ return;
193
+ }
194
+ if (input === 'n' || input === 'N' || key.escape) {
195
+ dispatch({ type: 'close-modal' });
196
+ return;
197
+ }
198
+ return;
199
+ }
200
+ if (state.modal?.kind === 'celebration') {
201
+ if (input === 'R' || input === 'r' || key.return) {
202
+ dispatch({ type: 'close-modal' });
203
+ runAudit();
204
+ return;
205
+ }
206
+ if (input === 'q' || (key.ctrl && input === 'c')) {
207
+ exit();
208
+ return;
209
+ }
210
+ if (key.escape) {
211
+ dispatch({ type: 'close-modal' });
212
+ return;
213
+ }
214
+ return;
215
+ }
216
+ if (state.modal?.kind === 'analyzing') {
217
+ // Modal is non-interactive — only quit shortcuts work. The
218
+ // modal auto-dismisses when the audit finishes.
219
+ if (input === 'q' || (key.ctrl && input === 'c')) exit();
220
+ return;
221
+ }
222
+ const result = handleKey(input, key, state.focusedPane);
223
+ for (const action of result.actions) dispatch(action);
224
+ if (result.signal === 'quit') exit();
225
+ if (result.signal === 'reaudit') runAudit();
226
+ if (result.signal === 'remediate') openRemediate();
227
+ if (result.signal === 'copy') copyForFocusedPane();
228
+ if (result.signal === 'toggle-theme') toggleTheme();
229
+ });
230
+
231
+ /**
232
+ * Flip the active theme and flash the new theme name so the user
233
+ * sees feedback even when no modal is open (the only place where
234
+ * theme colors are visible today).
235
+ */
236
+ function toggleTheme() {
237
+ const next = state.theme === 'dark' ? 'light' : 'dark';
238
+ dispatch({ type: 'toggle-theme' });
239
+ dispatch({ type: 'flash', message: `Theme: ${next}` });
240
+ setTimeout(() => dispatch({ type: 'flash', message: null }), 1500);
241
+ }
242
+
243
+ /**
244
+ * Copy the focused pane's content to the system clipboard via
245
+ * OSC 52. Detail pane → the selected finding (formatted for
246
+ * humans). Log pane → the entire command log. Other panes →
247
+ * silent no-op (nothing useful to paste).
248
+ *
249
+ * Flashes a transient confirmation in the keybar; cleared by a
250
+ * 1.5s timer (hard-coded — the message is short enough to read
251
+ * in less, and a configurable duration would be over-engineering).
252
+ */
253
+ function copyForFocusedPane() {
254
+ let payload: string | null = null;
255
+ if (state.focusedPane === 'detail') {
256
+ const finding = selectedFinding(state);
257
+ if (finding) payload = formatFindingForClipboard(finding);
258
+ } else if (state.focusedPane === 'log') {
259
+ if (state.commandLog.length > 0) payload = formatCommandLogForClipboard(state.commandLog);
260
+ }
261
+ if (!payload) return;
262
+ copyToClipboard(payload);
263
+ dispatch({ type: 'flash', message: 'Copied to clipboard' });
264
+ setTimeout(() => dispatch({ type: 'flash', message: null }), 1500);
265
+ }
266
+
267
+ /**
268
+ * Open the remediation modal for whichever finding is selected.
269
+ * Allowed only when the user is on the Findings or Detail pane,
270
+ * the selected finding has a remediation, AND that remediation is
271
+ * actionable (a runnable celilo command, not descriptive guidance).
272
+ * Non-actionable findings (capability ABI mismatches, schema
273
+ * migrations, etc.) require code or out-of-band work; surfacing the
274
+ * modal would be misleading.
275
+ */
276
+ function openRemediate() {
277
+ if (state.focusedPane !== 'findings' && state.focusedPane !== 'detail') return;
278
+ const list = findingsForCategory(state.groups, state.selectedCategory);
279
+ const finding = list.find((f) => findingId(f) === state.selectedFindingId);
280
+ if (!finding || !finding.actionable) return;
281
+ const command = finding.remediation ?? '';
282
+ if (!command) return;
283
+ dispatch({
284
+ type: 'open-remediate',
285
+ findingId: findingId(finding),
286
+ command,
287
+ });
288
+ }
289
+
290
+ /** Submit the remediation modal: close it, spawn the command, stream output. */
291
+ function submitRemediate(command: string) {
292
+ if (!command.trim()) return;
293
+ const startedAt = Date.now();
294
+ dispatch({ type: 'close-modal' });
295
+ dispatch({ type: 'log-start', startedAt, cmd: command });
296
+ runCelilo(command, {
297
+ onLine: (stream, text) => {
298
+ dispatch({ type: 'log-line', startedAt, stream, text });
299
+ },
300
+ onExit: (exitCode) => {
301
+ dispatch({ type: 'log-finish', startedAt, exitCode });
302
+ if (exitCode === 0) {
303
+ dispatch({ type: 'open-reaudit-prompt' });
304
+ }
305
+ },
306
+ });
307
+ }
308
+
309
+ // Compute exact pixel dimensions for every pane up front so flexbox
310
+ // never has a chance to redistribute space based on content. The
311
+ // pane components ignore these props in favor of their borders, but
312
+ // we pass them through wrapper Boxes that fence off the geometry.
313
+ const layout = computeLayout(columns, rows, state.groups.length);
314
+
315
+ // Mouse-click → pane focus + (for list panes) row selection.
316
+ // Reads raw stdin bytes in parallel with Ink's keyboard handling
317
+ // and parses SGR mouse-press events. Only left-button presses
318
+ // fire; releases and modifier-button events are ignored. Skipped
319
+ // while a modal is open — clicks shouldn't bleed through to focus
320
+ // or selection changes.
321
+ useEffect(() => {
322
+ if (state.modal !== null) return;
323
+ const handler = (data: Buffer) => {
324
+ const event = parseMouseEvent(data.toString());
325
+ if (!event || !event.pressed || event.button !== 0) return;
326
+ const hit = paneAt(event.col, event.row, {
327
+ bodyHeight: layout.bodyHeight,
328
+ leftColWidth: layout.leftColWidth,
329
+ summaryHeight: layout.summaryHeight,
330
+ categoriesHeight: layout.categoriesHeight,
331
+ detailHeight: layout.detailHeight,
332
+ });
333
+ if (!hit) return;
334
+
335
+ dispatch({ type: 'focus', pane: hit.pane });
336
+
337
+ // Translate row-in-pane to a list index for the two scrollable
338
+ // panes. Categories has no scroll (always shows all groups);
339
+ // findings does — apply the same scroll-window math the pane
340
+ // uses when rendering.
341
+ if (hit.pane === 'categories') {
342
+ const idx = hit.rowInPane - LIST_PANE_CHROME;
343
+ if (idx >= 0 && idx < state.groups.length) {
344
+ dispatch({ type: 'select-category', index: idx });
345
+ }
346
+ return;
347
+ }
348
+ if (hit.pane === 'findings') {
349
+ const findings = findingsForCategory(state.groups, state.selectedCategory);
350
+ if (findings.length === 0) return;
351
+ const overflowRow = findings.length > Math.max(layout.findingsHeight - 4, 0) ? 1 : 0;
352
+ const visible = Math.max(layout.findingsHeight - 4 - overflowRow, 0);
353
+ const selectedIndex = Math.max(
354
+ findings.findIndex((f) => findingId(f) === state.selectedFindingId),
355
+ 0,
356
+ );
357
+ const { start } = computeScrollWindow(selectedIndex, findings.length, visible);
358
+ const idx = start + (hit.rowInPane - FINDINGS_CHROME);
359
+ if (idx >= 0 && idx < findings.length) {
360
+ dispatch({ type: 'select-finding', index: idx });
361
+ }
362
+ }
363
+ };
364
+ process.stdin.on('data', handler);
365
+ return () => {
366
+ process.stdin.off('data', handler);
367
+ };
368
+ }, [
369
+ layout,
370
+ state.modal,
371
+ state.groups,
372
+ state.selectedCategory,
373
+ state.selectedFindingId,
374
+ dispatch,
375
+ ]);
376
+
377
+ if (state.helpOpen) {
378
+ return (
379
+ <Box
380
+ width={columns}
381
+ height={rows}
382
+ flexDirection="column"
383
+ alignItems="center"
384
+ justifyContent="center"
385
+ >
386
+ <HelpOverlay />
387
+ </Box>
388
+ );
389
+ }
390
+
391
+ return (
392
+ <Box width={columns} height={rows} flexDirection="column">
393
+ <Box width={columns} height={layout.bodyHeight} flexDirection="row">
394
+ <Box width={layout.leftColWidth} height={layout.bodyHeight} flexDirection="column">
395
+ <Box width={layout.leftColWidth} height={layout.summaryHeight} flexShrink={0}>
396
+ <SummaryPane state={state} focused={state.focusedPane === 'summary'} />
397
+ </Box>
398
+ <Box width={layout.leftColWidth} height={layout.categoriesHeight} flexShrink={0}>
399
+ <CategoriesPane state={state} focused={state.focusedPane === 'categories'} />
400
+ </Box>
401
+ <Box width={layout.leftColWidth} height={layout.findingsHeight} flexShrink={0}>
402
+ <FindingsPane
403
+ state={state}
404
+ focused={state.focusedPane === 'findings'}
405
+ height={layout.findingsHeight}
406
+ />
407
+ </Box>
408
+ </Box>
409
+ <Box width={layout.rightColWidth} height={layout.bodyHeight} flexDirection="column">
410
+ <Box width={layout.rightColWidth} height={layout.detailHeight} flexShrink={0}>
411
+ <DetailPane
412
+ state={state}
413
+ focused={state.focusedPane === 'detail'}
414
+ width={layout.rightColWidth}
415
+ />
416
+ </Box>
417
+ <Box width={layout.rightColWidth} height={layout.commandLogHeight} flexShrink={0}>
418
+ <CommandLogPane
419
+ state={state}
420
+ focused={state.focusedPane === 'log'}
421
+ height={layout.commandLogHeight}
422
+ />
423
+ </Box>
424
+ </Box>
425
+ </Box>
426
+ <Box width={columns} height={1}>
427
+ <KeyBar
428
+ focused={state.focusedPane}
429
+ modal={modalKind(state.modal)}
430
+ flashMessage={state.flashMessage}
431
+ findingActionable={selectedFinding(state)?.actionable === true}
432
+ />
433
+ </Box>
434
+ {state.modal && (
435
+ <ModalOverlay columns={columns} rows={rows}>
436
+ {state.modal.kind === 'remediate' && (
437
+ <RemediateModal
438
+ command={state.modal.command}
439
+ theme={theme}
440
+ onChange={(value) => dispatch({ type: 'set-modal-command', command: value })}
441
+ onSubmit={(value) => submitRemediate(value)}
442
+ />
443
+ )}
444
+ {state.modal.kind === 'reaudit-prompt' && <ReauditPromptModal theme={theme} />}
445
+ {state.modal.kind === 'celebration' && <CelebrationModal theme={theme} />}
446
+ {state.modal.kind === 'analyzing' && (
447
+ <AnalyzingModal progress={state.categoryProgress} theme={theme} />
448
+ )}
449
+ </ModalOverlay>
450
+ )}
451
+ </Box>
452
+ );
453
+ }
454
+
455
+ function modalKind(
456
+ m: ModalState,
457
+ ): 'remediate' | 'reaudit-prompt' | 'celebration' | 'analyzing' | null {
458
+ if (!m) return null;
459
+ return m.kind;
460
+ }
461
+
462
+ /**
463
+ * Render `children` centered on top of an absolutely-positioned
464
+ * full-screen box. Ink supports `position="absolute"` on Box, so the
465
+ * modal lifts out of the document flow and overlays whatever's below.
466
+ */
467
+ function ModalOverlay({
468
+ columns,
469
+ rows,
470
+ children,
471
+ }: {
472
+ columns: number;
473
+ rows: number;
474
+ children: React.ReactNode;
475
+ }) {
476
+ return (
477
+ <Box
478
+ position="absolute"
479
+ width={columns}
480
+ height={rows}
481
+ alignItems="center"
482
+ justifyContent="center"
483
+ >
484
+ {children}
485
+ </Box>
486
+ );
487
+ }
488
+
489
+ interface Layout {
490
+ bodyHeight: number;
491
+ leftColWidth: number;
492
+ rightColWidth: number;
493
+ summaryHeight: number;
494
+ categoriesHeight: number;
495
+ findingsHeight: number;
496
+ detailHeight: number;
497
+ commandLogHeight: number;
498
+ }
499
+
500
+ /**
501
+ * Compute fixed dimensions for every pane.
502
+ *
503
+ * The pane heights are derived from terminal dimensions and the
504
+ * number of drift categories — the only inputs that should ever
505
+ * change layout. As the user navigates between categories or
506
+ * findings, these inputs are constant, so the geometry is stable.
507
+ *
508
+ * - Summary pane: 6 rows (constant — title + verdict + counts +
509
+ * timestamp + borders).
510
+ * - Categories pane: max(N + 4, 5) where N is category count
511
+ * (border + title + spacer + N rows; minimum to render at least
512
+ * the title cleanly when there are no findings).
513
+ * - Findings pane: whatever's left after Summary + Categories.
514
+ * - Detail / CommandLog: split right column 50/50.
515
+ */
516
+ function computeLayout(columns: number, rows: number, categoryCount: number): Layout {
517
+ const bodyHeight = Math.max(rows - 1, 0);
518
+ const leftColWidth = Math.max(Math.floor(columns * 0.4), 30);
519
+ const rightColWidth = Math.max(columns - leftColWidth, 0);
520
+
521
+ const summaryHeight = 6;
522
+ const categoriesHeight = Math.max(categoryCount + 4, 5);
523
+ const findingsHeight = Math.max(bodyHeight - summaryHeight - categoriesHeight, 3);
524
+
525
+ const detailHeight = Math.floor(bodyHeight / 2);
526
+ const commandLogHeight = bodyHeight - detailHeight;
527
+
528
+ return {
529
+ bodyHeight,
530
+ leftColWidth,
531
+ rightColWidth,
532
+ summaryHeight,
533
+ categoriesHeight,
534
+ findingsHeight,
535
+ detailHeight,
536
+ commandLogHeight,
537
+ };
538
+ }
539
+
540
+ function HelpOverlay() {
541
+ const rows: [string, string][] = [
542
+ ['↑ ↓ / j k', 'Move selection'],
543
+ ['Enter', 'Focus (Summary → Categories → Findings → Detail)'],
544
+ ['Esc', 'Step back; quits from Summary/Categories'],
545
+ ['Tab / Shift-Tab', 'Cycle focus across panes'],
546
+ ['1 / 2 / 3 / 4 / 5', 'Jump to pane'],
547
+ ['r', 'Remediate (open modal for selected finding)'],
548
+ ['R', 'Re-run audit'],
549
+ ['?', 'Toggle this overlay'],
550
+ ['q / Ctrl-C', 'Quit'],
551
+ ];
552
+ return (
553
+ <Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={2} paddingY={1}>
554
+ <Text bold>Keymap</Text>
555
+ <Box marginTop={1} flexDirection="column">
556
+ {rows.map(([k, desc]) => (
557
+ <Box key={k}>
558
+ <Box width={20}>
559
+ <Text color="cyan">{k}</Text>
560
+ </Box>
561
+ <Text>{desc}</Text>
562
+ </Box>
563
+ ))}
564
+ </Box>
565
+ <Box marginTop={1}>
566
+ <Text dimColor>press ? or Esc to close</Text>
567
+ </Box>
568
+ </Box>
569
+ );
570
+ }
571
+
572
+ /**
573
+ * ANSI sequences for the alternate screen buffer (xterm-style).
574
+ *
575
+ * Entering the alt-screen gives the TUI a clean canvas separate from
576
+ * the user's normal scrollback (matching vim, htop, lazygit). On
577
+ * exit, the terminal restores the prior contents.
578
+ *
579
+ * ENTER CSI ? 1049 h switch to alt-screen
580
+ * HOME CSI H move cursor to top-left
581
+ * EXIT CSI ? 1049 l restore primary screen
582
+ * SHOW CSI ? 25 h show cursor (in case Ink hid it)
583
+ */
584
+ const ESC = '\x1b';
585
+ const ALT_SCREEN_ENTER = `${ESC}[?1049h${ESC}[H`;
586
+ const ALT_SCREEN_EXIT = `${ESC}[?1049l${ESC}[?25h`;
587
+
588
+ /**
589
+ * Render the TUI synchronously and resolve when the user exits.
590
+ *
591
+ * Throws if stdin is not a TTY — Ink needs raw-mode access to read
592
+ * keypresses, and a piped/redirected stdin can't grant it. The
593
+ * thrown message points the user at `--json` for the non-interactive
594
+ * path.
595
+ */
596
+ export async function renderAuditTui(
597
+ input: SystemAuditReport | AuditRunner,
598
+ options: { theme?: ThemeName } = {},
599
+ ): Promise<void> {
600
+ if (!process.stdin.isTTY) {
601
+ throw new Error(
602
+ '`celilo system audit --tui` requires an interactive terminal (stdin is not a TTY).\n' +
603
+ 'For non-interactive use, run `celilo system audit` (text) or `celilo system audit --json`.',
604
+ );
605
+ }
606
+ const source: Props['source'] =
607
+ typeof input === 'function'
608
+ ? { kind: 'runner', run: input }
609
+ : { kind: 'report', report: input };
610
+ const { render } = await import('ink');
611
+ process.stdout.write(ALT_SCREEN_ENTER + MOUSE_ON);
612
+ // Ensure we always restore the primary screen and disable mouse
613
+ // reporting, even on a thrown render — otherwise the user's
614
+ // terminal stays in mouse-capture mode after exit.
615
+ const restore = () => process.stdout.write(MOUSE_OFF + ALT_SCREEN_EXIT);
616
+ process.on('exit', restore);
617
+ try {
618
+ const instance = render(<AuditTui source={source} theme={options.theme} />);
619
+ await instance.waitUntilExit();
620
+ } finally {
621
+ process.off('exit', restore);
622
+ restore();
623
+ }
624
+ }
@@ -0,0 +1,29 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ /**
4
+ * Reused across the empty/READY states of pane 2 (Categories), pane
5
+ * 3 (Findings), and pane 4 (Detail) when the audit returns zero
6
+ * findings. Replaces the drab "(no findings)" placeholder with
7
+ * something that actually feels like a win.
8
+ *
9
+ * `compact` strips the body and renders only the headline — used in
10
+ * the smaller Categories/Findings panes where vertical space is at a
11
+ * premium.
12
+ */
13
+ export function InboxZeroCelebration({ compact = false }: { compact?: boolean }) {
14
+ return (
15
+ <Box flexDirection="column">
16
+ <Text color="green" bold>
17
+ 🎉 Everything is up-to-date!
18
+ </Text>
19
+ {!compact && (
20
+ <Box marginTop={1} flexDirection="column">
21
+ <Text>No drift detected across any of the audit categories.</Text>
22
+ <Box marginTop={1}>
23
+ <Text dimColor>Press R to re-run the audit, or q to quit.</Text>
24
+ </Box>
25
+ </Box>
26
+ )}
27
+ </Box>
28
+ );
29
+ }