@celilo/cli 0.1.4 → 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 (161) 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 +9 -8
  5. package/src/ansible/inventory.ts +9 -7
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +45 -12
  8. package/src/capabilities/registration.test.ts +6 -6
  9. package/src/capabilities/well-known.test.ts +2 -2
  10. package/src/capabilities/well-known.ts +5 -5
  11. package/src/cli/cli.test.ts +2 -2
  12. package/src/cli/command-registry.ts +146 -3
  13. package/src/cli/command-tree-parser.test.ts +1 -1
  14. package/src/cli/command-tree-parser.ts +9 -8
  15. package/src/cli/commands/hook-run.ts +15 -66
  16. package/src/cli/commands/module-audit.ts +14 -44
  17. package/src/cli/commands/module-deploy.ts +4 -1
  18. package/src/cli/commands/module-import-registry.test.ts +115 -0
  19. package/src/cli/commands/module-import.ts +106 -22
  20. package/src/cli/commands/module-publish.test.ts +235 -0
  21. package/src/cli/commands/module-publish.ts +234 -0
  22. package/src/cli/commands/module-remove.ts +82 -2
  23. package/src/cli/commands/module-search.ts +57 -0
  24. package/src/cli/commands/module-secret-get.ts +59 -0
  25. package/src/cli/commands/module-show.ts +1 -1
  26. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  27. package/src/cli/commands/module-verify.test.ts +59 -0
  28. package/src/cli/commands/module-verify.ts +53 -0
  29. package/src/cli/commands/status.ts +30 -20
  30. package/src/cli/commands/system-audit.test.ts +138 -0
  31. package/src/cli/commands/system-audit.ts +571 -0
  32. package/src/cli/commands/system-update.ts +391 -0
  33. package/src/cli/completion.ts +15 -1
  34. package/src/cli/fuel-gauge.ts +68 -3
  35. package/src/cli/generate-zsh-completion.ts +13 -3
  36. package/src/cli/index.ts +112 -5
  37. package/src/cli/parser.ts +11 -0
  38. package/src/cli/prompts.ts +36 -5
  39. package/src/cli/tui/audit-state.test.ts +246 -0
  40. package/src/cli/tui/audit-state.ts +525 -0
  41. package/src/cli/tui/audit-tui.test.tsx +135 -0
  42. package/src/cli/tui/audit-tui.tsx +624 -0
  43. package/src/cli/tui/celebration.tsx +29 -0
  44. package/src/cli/tui/clipboard.test.ts +94 -0
  45. package/src/cli/tui/clipboard.ts +101 -0
  46. package/src/cli/tui/icons.ts +22 -0
  47. package/src/cli/tui/keybar.tsx +65 -0
  48. package/src/cli/tui/keymap.test.ts +105 -0
  49. package/src/cli/tui/keymap.ts +70 -0
  50. package/src/cli/tui/modals/analyzing.tsx +75 -0
  51. package/src/cli/tui/modals/celebration.tsx +44 -0
  52. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  53. package/src/cli/tui/modals/remediate.tsx +44 -0
  54. package/src/cli/tui/modals.test.ts +137 -0
  55. package/src/cli/tui/mouse.test.ts +78 -0
  56. package/src/cli/tui/mouse.ts +114 -0
  57. package/src/cli/tui/panes/categories.tsx +62 -0
  58. package/src/cli/tui/panes/command-log.tsx +87 -0
  59. package/src/cli/tui/panes/detail.tsx +175 -0
  60. package/src/cli/tui/panes/findings.tsx +97 -0
  61. package/src/cli/tui/panes/summary.tsx +64 -0
  62. package/src/cli/tui/spawn.ts +130 -0
  63. package/src/cli/tui/theme.ts +42 -0
  64. package/src/cli/tui/wrap.test.ts +43 -0
  65. package/src/cli/tui/wrap.ts +45 -0
  66. package/src/cli/types.ts +5 -0
  67. package/src/db/client.ts +55 -2
  68. package/src/db/schema.test.ts +3 -3
  69. package/src/db/schema.ts +26 -17
  70. package/src/hooks/capability-loader.ts +135 -72
  71. package/src/hooks/define-hook.test.ts +11 -3
  72. package/src/hooks/executor.ts +22 -1
  73. package/src/hooks/load-hook-config.test.ts +165 -0
  74. package/src/hooks/load-hook-config.ts +60 -0
  75. package/src/hooks/logger.ts +42 -12
  76. package/src/hooks/run-named-hook.ts +128 -0
  77. package/src/hooks/types.ts +19 -0
  78. package/src/manifest/ensure-schema.test.ts +115 -0
  79. package/src/manifest/schema.ts +76 -0
  80. package/src/manifest/template-validator.test.ts +1 -1
  81. package/src/manifest/template-validator.ts +1 -1
  82. package/src/manifest/validate.test.ts +1 -1
  83. package/src/module/import.ts +20 -12
  84. package/src/module/packaging/build.ts +121 -25
  85. package/src/module/packaging/release-metadata.test.ts +103 -0
  86. package/src/module/packaging/release-metadata.ts +145 -0
  87. package/src/registry/client.test.ts +228 -0
  88. package/src/registry/client.ts +157 -0
  89. package/src/services/audit/backups.test.ts +233 -0
  90. package/src/services/audit/backups.ts +128 -0
  91. package/src/services/audit/capability-abi.test.ts +153 -0
  92. package/src/services/audit/capability-abi.ts +204 -0
  93. package/src/services/audit/cli-version.test.ts +60 -0
  94. package/src/services/audit/cli-version.ts +87 -0
  95. package/src/services/audit/health.test.ts +84 -0
  96. package/src/services/audit/health.ts +43 -0
  97. package/src/services/audit/index.test.ts +99 -0
  98. package/src/services/audit/index.ts +118 -0
  99. package/src/services/audit/machines-reachable.test.ts +87 -0
  100. package/src/services/audit/machines-reachable.ts +87 -0
  101. package/src/services/audit/module-configs.test.ts +131 -0
  102. package/src/services/audit/module-configs.ts +80 -0
  103. package/src/services/audit/module-versions.test.ts +99 -0
  104. package/src/services/audit/module-versions.ts +154 -0
  105. package/src/services/audit/schema.test.ts +68 -0
  106. package/src/services/audit/schema.ts +115 -0
  107. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  108. package/src/services/audit/secrets-decryptable.ts +97 -0
  109. package/src/services/audit/services-credentials.test.ts +54 -0
  110. package/src/services/audit/services-credentials.ts +64 -0
  111. package/src/services/audit/services-reachable.test.ts +60 -0
  112. package/src/services/audit/services-reachable.ts +64 -0
  113. package/src/services/audit/terraform-plan.test.ts +127 -0
  114. package/src/services/audit/terraform-plan.ts +153 -0
  115. package/src/services/audit/types.test.ts +36 -0
  116. package/src/services/audit/types.ts +90 -0
  117. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  118. package/src/services/audit/unconfigured-modules.ts +71 -0
  119. package/src/services/audit/undeployed-modules.test.ts +66 -0
  120. package/src/services/audit/undeployed-modules.ts +72 -0
  121. package/src/services/build-stream.ts +122 -122
  122. package/src/services/config-interview.ts +407 -2
  123. package/src/services/deploy-ansible.ts +73 -7
  124. package/src/services/deploy-planner.ts +5 -5
  125. package/src/services/deploy-preflight.ts +45 -4
  126. package/src/services/deploy-terraform.ts +31 -24
  127. package/src/services/deploy-validation.ts +167 -23
  128. package/src/services/dns-auto-register.ts +4 -4
  129. package/src/services/ensure-interview.test.ts +245 -0
  130. package/src/services/health-runner.ts +110 -38
  131. package/src/services/infrastructure-variable-resolver.test.ts +1 -1
  132. package/src/services/infrastructure-variable-resolver.ts +3 -3
  133. package/src/services/module-build.ts +11 -13
  134. package/src/services/module-deploy.ts +372 -61
  135. package/src/services/proxmox-state-recovery.ts +6 -6
  136. package/src/services/ssh-key-manager.test.ts +1 -1
  137. package/src/services/ssh-key-manager.ts +3 -2
  138. package/src/services/terraform-env.ts +62 -0
  139. package/src/services/update/dep-graph.test.ts +214 -0
  140. package/src/services/update/dep-graph.ts +215 -0
  141. package/src/services/update/orchestrator.test.ts +463 -0
  142. package/src/services/update/orchestrator.ts +359 -0
  143. package/src/services/update/progress.ts +49 -0
  144. package/src/services/update/self-update.test.ts +68 -0
  145. package/src/services/update/self-update.ts +57 -0
  146. package/src/services/update/types.ts +94 -0
  147. package/src/templates/generator.test.ts +3 -3
  148. package/src/templates/generator.ts +43 -2
  149. package/src/test-utils/completion-harness.test.ts +1 -1
  150. package/src/test-utils/completion-harness.ts +4 -4
  151. package/src/variables/capability-self-ref.test.ts +203 -0
  152. package/src/variables/context.test.ts +31 -31
  153. package/src/variables/context.ts +65 -17
  154. package/src/variables/declarative-derivation.test.ts +306 -0
  155. package/src/variables/declarative-derivation.ts +4 -2
  156. package/src/variables/parser.test.ts +64 -9
  157. package/src/variables/parser.ts +47 -6
  158. package/src/variables/resolver.test.ts +14 -14
  159. package/src/variables/resolver.ts +27 -9
  160. package/src/variables/types.ts +1 -1
  161. package/tsconfig.json +1 -0
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { DriftFinding, SystemAuditReport } from '../../services/audit/types';
3
+ import { COMMAND_LOG_MAX_LINES, initState, reducer } from './audit-state';
4
+
5
+ function finding(over: Partial<DriftFinding>): DriftFinding {
6
+ return {
7
+ category: 'module_configs',
8
+ severity: 'drift',
9
+ code: 'cfg_unset',
10
+ subject: 'caddy',
11
+ message: 'config "acme_ca" is unset',
12
+ remediation: 'celilo module config set caddy acme_ca <value>',
13
+ ...over,
14
+ };
15
+ }
16
+
17
+ function report(findings: DriftFinding[]): SystemAuditReport {
18
+ return {
19
+ version: 1,
20
+ verdict: findings.length > 0 ? 'DRIFT' : 'READY',
21
+ generatedAt: '2026-04-25T12:00:00.000Z',
22
+ findings,
23
+ };
24
+ }
25
+
26
+ describe('modal lifecycle', () => {
27
+ test('open-remediate stores findingId + command', () => {
28
+ const s = initState(report([finding({ code: 'a' })]));
29
+ const next = reducer(s, {
30
+ type: 'open-remediate',
31
+ findingId: 'caddy/a',
32
+ command: 'celilo module config set caddy acme_ca <value>',
33
+ });
34
+ expect(next.modal).toEqual({
35
+ kind: 'remediate',
36
+ findingId: 'caddy/a',
37
+ command: 'celilo module config set caddy acme_ca <value>',
38
+ });
39
+ });
40
+
41
+ test('set-modal-command updates the command in the modal', () => {
42
+ const s = initState(report([finding({ code: 'a' })]));
43
+ let st = reducer(s, {
44
+ type: 'open-remediate',
45
+ findingId: 'caddy/a',
46
+ command: 'celilo x',
47
+ });
48
+ st = reducer(st, { type: 'set-modal-command', command: 'celilo y' });
49
+ expect(st.modal).toMatchObject({ kind: 'remediate', command: 'celilo y' });
50
+ });
51
+
52
+ test('set-modal-command is a no-op when no remediate modal is open', () => {
53
+ const s = initState(report([finding({ code: 'a' })]));
54
+ const next = reducer(s, { type: 'set-modal-command', command: 'noop' });
55
+ expect(next.modal).toBeNull();
56
+ });
57
+
58
+ test('open-reaudit-prompt switches modal', () => {
59
+ const s = initState(report([finding({ code: 'a' })]));
60
+ const opened = reducer(s, {
61
+ type: 'open-remediate',
62
+ findingId: 'caddy/a',
63
+ command: 'celilo x',
64
+ });
65
+ const next = reducer(opened, { type: 'open-reaudit-prompt' });
66
+ expect(next.modal).toEqual({ kind: 'reaudit-prompt' });
67
+ });
68
+
69
+ test('close-modal clears the modal', () => {
70
+ const s = initState(report([finding({ code: 'a' })]));
71
+ const opened = reducer(s, {
72
+ type: 'open-remediate',
73
+ findingId: 'caddy/a',
74
+ command: 'celilo x',
75
+ });
76
+ const next = reducer(opened, { type: 'close-modal' });
77
+ expect(next.modal).toBeNull();
78
+ });
79
+ });
80
+
81
+ describe('command log lifecycle', () => {
82
+ test('log-start appends a running entry', () => {
83
+ const s = initState(report([]));
84
+ const next = reducer(s, { type: 'log-start', startedAt: 1000, cmd: 'celilo x' });
85
+ expect(next.commandLog).toHaveLength(1);
86
+ expect(next.commandLog[0]).toEqual({
87
+ startedAt: 1000,
88
+ cmd: 'celilo x',
89
+ lines: [],
90
+ exitCode: null,
91
+ });
92
+ });
93
+
94
+ test('log-line appends a line to the matching entry', () => {
95
+ let st = initState(report([]));
96
+ st = reducer(st, { type: 'log-start', startedAt: 1000, cmd: 'celilo x' });
97
+ st = reducer(st, { type: 'log-line', startedAt: 1000, stream: 'stdout', text: 'hello' });
98
+ st = reducer(st, { type: 'log-line', startedAt: 1000, stream: 'stderr', text: 'oh no' });
99
+ expect(st.commandLog[0].lines).toEqual([
100
+ { stream: 'stdout', text: 'hello' },
101
+ { stream: 'stderr', text: 'oh no' },
102
+ ]);
103
+ });
104
+
105
+ test('log-finish sets exit code on matching entry', () => {
106
+ let st = initState(report([]));
107
+ st = reducer(st, { type: 'log-start', startedAt: 1000, cmd: 'celilo x' });
108
+ st = reducer(st, { type: 'log-finish', startedAt: 1000, exitCode: 0 });
109
+ expect(st.commandLog[0].exitCode).toBe(0);
110
+ });
111
+
112
+ test('log entries with mismatched startedAt are not affected', () => {
113
+ let st = initState(report([]));
114
+ st = reducer(st, { type: 'log-start', startedAt: 1000, cmd: 'a' });
115
+ st = reducer(st, { type: 'log-start', startedAt: 2000, cmd: 'b' });
116
+ st = reducer(st, { type: 'log-line', startedAt: 1000, stream: 'stdout', text: 'hi' });
117
+ expect(st.commandLog[0].lines).toHaveLength(1);
118
+ expect(st.commandLog[1].lines).toHaveLength(0);
119
+ });
120
+
121
+ test('log evicts oldest entries when total lines exceed cap', () => {
122
+ let st = initState(report([]));
123
+ // First entry — fills almost the whole cap
124
+ st = reducer(st, { type: 'log-start', startedAt: 1, cmd: 'big' });
125
+ for (let i = 0; i < COMMAND_LOG_MAX_LINES - 5; i++) {
126
+ st = reducer(st, { type: 'log-line', startedAt: 1, stream: 'stdout', text: `line ${i}` });
127
+ }
128
+ // Second entry — pushes us over
129
+ st = reducer(st, { type: 'log-start', startedAt: 2, cmd: 'small' });
130
+ for (let i = 0; i < 50; i++) {
131
+ st = reducer(st, { type: 'log-line', startedAt: 2, stream: 'stdout', text: `t ${i}` });
132
+ }
133
+ // Oldest (entry 1) should have been evicted
134
+ expect(st.commandLog).toHaveLength(1);
135
+ expect(st.commandLog[0].cmd).toBe('small');
136
+ });
137
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { paneAt, parseMouseEvent } from './mouse';
3
+
4
+ describe('parseMouseEvent', () => {
5
+ test('parses raw SGR press event', () => {
6
+ expect(parseMouseEvent('\x1b[<0;5;3M')).toEqual({
7
+ button: 0,
8
+ col: 4,
9
+ row: 2,
10
+ pressed: true,
11
+ });
12
+ });
13
+
14
+ test('parses Ink-relayed form (no leading ESC+CSI)', () => {
15
+ expect(parseMouseEvent('[<2;15;7M')).toEqual({
16
+ button: 2,
17
+ col: 14,
18
+ row: 6,
19
+ pressed: true,
20
+ });
21
+ });
22
+
23
+ test('press vs release', () => {
24
+ expect(parseMouseEvent('[<0;1;1M')?.pressed).toBe(true);
25
+ expect(parseMouseEvent('[<0;1;1m')?.pressed).toBe(false);
26
+ });
27
+
28
+ test('returns null for non-mouse input', () => {
29
+ expect(parseMouseEvent('q')).toBeNull();
30
+ expect(parseMouseEvent('\x1b[A')).toBeNull(); // arrow key, not mouse
31
+ expect(parseMouseEvent('')).toBeNull();
32
+ });
33
+ });
34
+
35
+ const REGIONS = {
36
+ bodyHeight: 30,
37
+ leftColWidth: 40,
38
+ summaryHeight: 6,
39
+ categoriesHeight: 8,
40
+ detailHeight: 15,
41
+ };
42
+
43
+ describe('paneAt', () => {
44
+ test('top-left of left column → summary', () => {
45
+ expect(paneAt(0, 0, REGIONS)).toEqual({ pane: 'summary', rowInPane: 0 });
46
+ });
47
+
48
+ test('mid-left below summary → categories with row offset', () => {
49
+ expect(paneAt(10, 8, REGIONS)).toEqual({ pane: 'categories', rowInPane: 2 });
50
+ });
51
+
52
+ test('bottom of left column → findings with row offset', () => {
53
+ expect(paneAt(20, 25, REGIONS)).toEqual({
54
+ pane: 'findings',
55
+ rowInPane: 25 - REGIONS.summaryHeight - REGIONS.categoriesHeight,
56
+ });
57
+ });
58
+
59
+ test('right column top → detail', () => {
60
+ expect(paneAt(50, 5, REGIONS)).toEqual({ pane: 'detail', rowInPane: 5 });
61
+ });
62
+
63
+ test('right column bottom → log with offset', () => {
64
+ expect(paneAt(60, 20, REGIONS)).toEqual({
65
+ pane: 'log',
66
+ rowInPane: 20 - REGIONS.detailHeight,
67
+ });
68
+ });
69
+
70
+ test('keybar row (out of body) → null', () => {
71
+ expect(paneAt(10, 30, REGIONS)).toBeNull();
72
+ expect(paneAt(10, 50, REGIONS)).toBeNull();
73
+ });
74
+
75
+ test('negative col returns null', () => {
76
+ expect(paneAt(-1, 5, REGIONS)).toBeNull();
77
+ });
78
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Mouse-click support via SGR mouse reporting.
3
+ *
4
+ * SGR mode (`CSI ? 1006 h`) is the modern, well-supported encoding —
5
+ * iTerm2, kitty, Alacritty, WezTerm, modern Windows Terminal all
6
+ * speak it. Format on press: `ESC [ < button ; col ; row M`; on
7
+ * release the trailing M becomes m.
8
+ *
9
+ * Buttons in SGR mode:
10
+ * 0 = left, 1 = middle, 2 = right
11
+ * 64 = wheel up, 65 = wheel down
12
+ * modifiers (shift/alt/ctrl) get OR'd into the button code (4/8/16)
13
+ *
14
+ * `CSI ? 1000 h` enables button-event reporting (presses + releases,
15
+ * no motion). We don't need motion or any-event mode for click-to-
16
+ * focus.
17
+ */
18
+
19
+ const ESC = '\x1b';
20
+
21
+ export const MOUSE_ON = `${ESC}[?1000h${ESC}[?1006h`;
22
+ export const MOUSE_OFF = `${ESC}[?1000l${ESC}[?1006l`;
23
+
24
+ export interface MouseEvent {
25
+ button: number;
26
+ /** 0-based terminal column. SGR reports 1-based; we normalize. */
27
+ col: number;
28
+ /** 0-based terminal row. */
29
+ row: number;
30
+ pressed: boolean;
31
+ }
32
+
33
+ /**
34
+ * Match an SGR mouse sequence in a chunk of input. Ink's `useInput`
35
+ * sees mouse events as `key.escape: true` + an `input` like
36
+ * `[<0;5;3M`, so we accept both that form (Ink-relayed) and the raw
37
+ * form including the leading ESC + CSI. Built from a string at
38
+ * runtime so the `\x1b` control byte doesn't trip biome's "control
39
+ * character in regex" lint.
40
+ */
41
+ const SGR_PATTERN = new RegExp(`^(?:${ESC}\\[|\\[)<(\\d+);(\\d+);(\\d+)([Mm])`);
42
+
43
+ export function parseMouseEvent(input: string): MouseEvent | null {
44
+ const match = input.match(SGR_PATTERN);
45
+ if (!match) return null;
46
+ return {
47
+ button: Number.parseInt(match[1], 10),
48
+ // SGR coordinates are 1-based; our layout math is 0-based.
49
+ col: Number.parseInt(match[2], 10) - 1,
50
+ row: Number.parseInt(match[3], 10) - 1,
51
+ pressed: match[4] === 'M',
52
+ };
53
+ }
54
+
55
+ export interface ClickRegions {
56
+ /** Width and height of the body (everything above the keybar). */
57
+ bodyHeight: number;
58
+ leftColWidth: number;
59
+ /** rightColWidth is implied by total columns; not needed for hit-testing. */
60
+ summaryHeight: number;
61
+ categoriesHeight: number;
62
+ // findingsHeight is the remainder of the left column.
63
+ detailHeight: number;
64
+ // commandLogHeight is the remainder of the right column.
65
+ }
66
+
67
+ export type PaneId = 'summary' | 'categories' | 'findings' | 'detail' | 'log';
68
+
69
+ export interface PaneHit {
70
+ pane: PaneId;
71
+ /**
72
+ * 0-based row offset relative to the pane's top edge (border
73
+ * inclusive). Useful for translating a click into a list index in
74
+ * scrollable panes.
75
+ */
76
+ rowInPane: number;
77
+ }
78
+
79
+ /**
80
+ * Hit-test a click against the pane layout. Returns the pane the
81
+ * click landed in (with the row offset within that pane), or null
82
+ * if the click is outside the body (e.g. keybar row).
83
+ */
84
+ export function paneAt(col: number, row: number, regions: ClickRegions): PaneHit | null {
85
+ if (row < 0 || row >= regions.bodyHeight) return null;
86
+ if (col < 0) return null;
87
+
88
+ if (col < regions.leftColWidth) {
89
+ if (row < regions.summaryHeight) {
90
+ return { pane: 'summary', rowInPane: row };
91
+ }
92
+ if (row < regions.summaryHeight + regions.categoriesHeight) {
93
+ return { pane: 'categories', rowInPane: row - regions.summaryHeight };
94
+ }
95
+ return {
96
+ pane: 'findings',
97
+ rowInPane: row - regions.summaryHeight - regions.categoriesHeight,
98
+ };
99
+ }
100
+
101
+ // Right column.
102
+ if (row < regions.detailHeight) {
103
+ return { pane: 'detail', rowInPane: row };
104
+ }
105
+ return { pane: 'log', rowInPane: row - regions.detailHeight };
106
+ }
107
+
108
+ /**
109
+ * Each pane's "chrome" — non-content rows above the first list item.
110
+ * Categories: top border (1) + title (1) + marginTop spacer (1) = 3.
111
+ * Findings: same. (Bottom border doesn't matter; we only care
112
+ * about the top offset.)
113
+ */
114
+ export const LIST_PANE_CHROME = 3;
@@ -0,0 +1,62 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { AuditTuiState } from '../audit-state';
3
+ import { InboxZeroCelebration } from '../celebration';
4
+ import { SEVERITY_VISUALS } from '../icons';
5
+
6
+ interface Props {
7
+ state: AuditTuiState;
8
+ focused: boolean;
9
+ }
10
+
11
+ export function CategoriesPane({ state, focused }: Props) {
12
+ const { groups, selectedCategory } = state;
13
+
14
+ return (
15
+ <Box
16
+ borderStyle="round"
17
+ borderColor={focused ? 'cyan' : 'gray'}
18
+ flexDirection="column"
19
+ paddingX={1}
20
+ width="100%"
21
+ height="100%"
22
+ overflow="hidden"
23
+ >
24
+ <Text dimColor>2 Categories</Text>
25
+ <Box flexDirection="column" marginTop={1}>
26
+ {groups.length === 0 ? (
27
+ // Don't celebrate while the audit is still running — the
28
+ // empty report is a placeholder, not a verdict.
29
+ state.auditing ? (
30
+ <Text dimColor>(checking…)</Text>
31
+ ) : (
32
+ <InboxZeroCelebration compact />
33
+ )
34
+ ) : (
35
+ groups.map((g) => {
36
+ const v = SEVERITY_VISUALS[g.severity];
37
+ const isSelected = focused && g.category === selectedCategory;
38
+ // When selected, render the whole row as a single inverse
39
+ // Text — nested <Text color> children override the parent's
40
+ // inverse styling in Ink's renderer, so we flatten.
41
+ if (isSelected) {
42
+ return (
43
+ <Text key={g.category} inverse wrap="truncate-end">
44
+ {`▸ ${v.icon} ${v.label} · ${g.category} (${g.findings.length})`}
45
+ </Text>
46
+ );
47
+ }
48
+ return (
49
+ <Text key={g.category} wrap="truncate-end">
50
+ {' '}
51
+ <Text color={v.color}>
52
+ {v.icon} {v.label}
53
+ </Text>
54
+ {` · ${g.category} (${g.findings.length})`}
55
+ </Text>
56
+ );
57
+ })
58
+ )}
59
+ </Box>
60
+ </Box>
61
+ );
62
+ }
@@ -0,0 +1,87 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { AuditTuiState, CommandLogEntry } from '../audit-state';
3
+
4
+ interface Props {
5
+ state: AuditTuiState;
6
+ focused: boolean;
7
+ /** Total height the pane will occupy. Used to slice to the latest visible lines. */
8
+ height: number;
9
+ }
10
+
11
+ interface Line {
12
+ key: string;
13
+ text: string;
14
+ color?: string;
15
+ dim?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Flatten the command log into renderable lines: a separator
20
+ * header + stdout/stderr lines + an exit-code footer per entry.
21
+ * Returned in chronological order (oldest first).
22
+ */
23
+ function flattenLog(log: CommandLogEntry[]): Line[] {
24
+ const lines: Line[] = [];
25
+ for (const entry of log) {
26
+ const time = new Date(entry.startedAt).toLocaleTimeString();
27
+ lines.push({
28
+ key: `${entry.startedAt}-hdr`,
29
+ text: `─── ${time} · ${entry.cmd} ───`,
30
+ dim: true,
31
+ });
32
+ for (let i = 0; i < entry.lines.length; i++) {
33
+ const ln = entry.lines[i];
34
+ lines.push({
35
+ key: `${entry.startedAt}-${i}`,
36
+ text: ln.text,
37
+ color: ln.stream === 'stderr' ? 'red' : undefined,
38
+ });
39
+ }
40
+ lines.push({
41
+ key: `${entry.startedAt}-exit`,
42
+ text: entry.exitCode === null ? 'running…' : `exit ${entry.exitCode}`,
43
+ color: entry.exitCode === 0 ? 'green' : entry.exitCode === null ? undefined : 'red',
44
+ dim: true,
45
+ });
46
+ }
47
+ return lines;
48
+ }
49
+
50
+ export function CommandLogPane({ state, focused, height }: Props) {
51
+ const all = flattenLog(state.commandLog);
52
+ // chrome = 2 border rows + 1 title + 1 spacer = 4. Show the last
53
+ // `height - chrome` lines so the most recent output is always
54
+ // visible (terminal-tail behavior).
55
+ const chrome = 4;
56
+ const visible = Math.max(height - chrome, 0);
57
+ const slice = all.length <= visible ? all : all.slice(all.length - visible);
58
+ const hidden = all.length - slice.length;
59
+
60
+ return (
61
+ <Box
62
+ borderStyle="round"
63
+ borderColor={focused ? 'cyan' : 'gray'}
64
+ flexDirection="column"
65
+ paddingX={1}
66
+ width="100%"
67
+ height="100%"
68
+ overflow="hidden"
69
+ >
70
+ <Text dimColor wrap="truncate-end">
71
+ 5 Command log
72
+ {hidden > 0 ? ` (${hidden} earlier line${hidden === 1 ? '' : 's'} hidden)` : ''}
73
+ </Text>
74
+ <Box flexDirection="column" marginTop={1}>
75
+ {state.commandLog.length === 0 ? (
76
+ <Text dimColor>(empty — press r on a finding to remediate)</Text>
77
+ ) : (
78
+ slice.map((ln) => (
79
+ <Text key={ln.key} color={ln.color} dimColor={ln.dim} wrap="truncate-end">
80
+ {ln.text}
81
+ </Text>
82
+ ))
83
+ )}
84
+ </Box>
85
+ </Box>
86
+ );
87
+ }
@@ -0,0 +1,175 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { AuditTuiState } from '../audit-state';
3
+ import { selectedFinding } from '../audit-state';
4
+ import { InboxZeroCelebration } from '../celebration';
5
+ import { SEVERITY_VISUALS } from '../icons';
6
+ import { wrapText } from '../wrap';
7
+
8
+ interface Props {
9
+ state: AuditTuiState;
10
+ focused: boolean;
11
+ /**
12
+ * The pane's full width in columns. Used to pre-wrap multi-line
13
+ * content (details, non-actionable guidance) so each rendered line
14
+ * fits within the pane — Ink's column layout assumes one Text =
15
+ * one row, so a wrapping Text would overlap the next sibling.
16
+ */
17
+ width: number;
18
+ }
19
+
20
+ /**
21
+ * ASCII-art "celilo" wordmark in the figlet "Standard" font. 4 rows
22
+ * tall, ~30 cols wide. Rendered cyan to pop against the pane chrome.
23
+ *
24
+ * Hard-coded as a string array rather than a runtime figlet
25
+ * dependency — the wordmark is stable and the dep would be overkill.
26
+ */
27
+ const CELILO_ASCII = [
28
+ ' ___ ___ _ ___ _ ___ ',
29
+ ' / __| __| | |_ _| | / _ \\ ',
30
+ ' | (__| _|| |__ | || |_| (_) |',
31
+ ' \\___|___|____|___|____\\___/ ',
32
+ ];
33
+
34
+ /**
35
+ * Banner shown in this pane when the user is focused on Summary
36
+ * (pane 1). Lazygit-style orientation: ASCII wordmark, what celilo
37
+ * is, where to learn more, where to find help. The full keymap
38
+ * lives behind `?` and is suppressed here to keep the banner clean.
39
+ */
40
+ function CeliloBanner({ contentWidth }: { contentWidth: number }) {
41
+ const description = wrapText(
42
+ 'Home-lab orchestration: Terraform + Ansible + a module registry, wrapped in a CLI that knows your topology.',
43
+ contentWidth,
44
+ );
45
+ const screenDesc = wrapText(
46
+ 'Reports drift across CLI version, schema, capability ABI, terraform plan, module versions, configs, health, backups, undeployed modules, and unconfigured modules.',
47
+ contentWidth,
48
+ );
49
+ // Show ASCII art only when the pane is wide enough to fit it
50
+ // without ugly wrap. Falls back to a plain "celilo" title on
51
+ // narrow terminals.
52
+ const fitsAscii = contentWidth >= CELILO_ASCII[0].length;
53
+ return (
54
+ <Box flexDirection="column">
55
+ {fitsAscii ? (
56
+ <Box flexDirection="column">
57
+ {CELILO_ASCII.map((line, i) => (
58
+ // biome-ignore lint/suspicious/noArrayIndexKey: static ASCII art
59
+ <Text key={`a-${i}`} color="cyan" bold>
60
+ {line}
61
+ </Text>
62
+ ))}
63
+ </Box>
64
+ ) : (
65
+ <Text bold color="cyan">
66
+ celilo
67
+ </Text>
68
+ )}
69
+ <Box marginTop={1} flexDirection="column">
70
+ {description.map((line, i) => (
71
+ // biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
72
+ <Text key={`d-${i}`}>{line}</Text>
73
+ ))}
74
+ </Box>
75
+ <Box marginTop={1}>
76
+ <Text dimColor>https://celilo.computer</Text>
77
+ </Box>
78
+ <Box marginTop={1} flexDirection="column">
79
+ <Text bold>This screen — `system audit`</Text>
80
+ {screenDesc.map((line, i) => (
81
+ // biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
82
+ <Text key={`s-${i}`}>{line}</Text>
83
+ ))}
84
+ </Box>
85
+ <Box marginTop={1}>
86
+ <Text dimColor>Press `?` for the full keymap.</Text>
87
+ </Box>
88
+ <Box marginTop={1}>
89
+ <Text dimColor>Copyright 2026 Peter Banka</Text>
90
+ </Box>
91
+ </Box>
92
+ );
93
+ }
94
+
95
+ export function DetailPane({ state, focused, width }: Props) {
96
+ const finding = selectedFinding(state);
97
+ const showBanner = state.focusedPane === 'summary';
98
+ const inboxZero = !state.auditing && state.report.findings.length === 0;
99
+ // Content width = pane width - 2 border cols - 2 padding cols (paddingX={1}).
100
+ const contentWidth = Math.max(width - 4, 10);
101
+
102
+ return (
103
+ <Box
104
+ borderStyle="round"
105
+ borderColor={focused ? 'cyan' : 'gray'}
106
+ flexDirection="column"
107
+ paddingX={1}
108
+ width="100%"
109
+ height="100%"
110
+ overflow="hidden"
111
+ >
112
+ <Text dimColor>4 Detail</Text>
113
+ <Box flexDirection="column" marginTop={1}>
114
+ {showBanner ? (
115
+ <CeliloBanner contentWidth={contentWidth} />
116
+ ) : inboxZero ? (
117
+ <InboxZeroCelebration />
118
+ ) : finding === null ? (
119
+ <Text dimColor>(no finding selected)</Text>
120
+ ) : (
121
+ <>
122
+ {wrapText(finding.message, contentWidth).map((line, i) => (
123
+ <Text
124
+ // biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
125
+ key={`m-${i}`}
126
+ bold
127
+ color={SEVERITY_VISUALS[finding.severity].color}
128
+ >
129
+ {line}
130
+ </Text>
131
+ ))}
132
+ {finding.details && (
133
+ <Box marginTop={1} flexDirection="column">
134
+ {wrapText(finding.details, contentWidth).map((line, i) => (
135
+ // biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
136
+ <Text key={`line-${i}`}>{line}</Text>
137
+ ))}
138
+ </Box>
139
+ )}
140
+ {finding.remediation && finding.actionable && (
141
+ <Box marginTop={1} flexDirection="column">
142
+ {wrapText(`→ ${finding.remediation}`, contentWidth).map((line, i) => (
143
+ // biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
144
+ <Text key={`rem-${i}`} color="cyan">
145
+ {line}
146
+ </Text>
147
+ ))}
148
+ </Box>
149
+ )}
150
+ {finding.remediation && !finding.actionable && (
151
+ <Box marginTop={1} flexDirection="column">
152
+ {/*
153
+ * Non-runnable remediation — render as plain guidance
154
+ * (no `→` arrow, no command-style cyan) so the user
155
+ * doesn't expect to hit `r` and get a one-shot fix.
156
+ */}
157
+ {wrapText(finding.remediation, contentWidth).map((line, i) => (
158
+ // biome-ignore lint/suspicious/noArrayIndexKey: pre-wrapped static lines
159
+ <Text key={`rem-${i}`} dimColor>
160
+ {line}
161
+ </Text>
162
+ ))}
163
+ </Box>
164
+ )}
165
+ <Box marginTop={1}>
166
+ <Text dimColor>
167
+ {finding.subject} · {finding.code}
168
+ </Text>
169
+ </Box>
170
+ </>
171
+ )}
172
+ </Box>
173
+ </Box>
174
+ );
175
+ }