@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.
- package/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +3 -2
- package/src/ansible/inventory.ts +5 -1
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +34 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +133 -73
- package/src/hooks/define-hook.test.ts +9 -1
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +85 -16
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +370 -59
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +1 -1
- package/src/templates/generator.ts +42 -1
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.ts +49 -1
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +56 -1
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.ts +27 -9
- 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
|
+
}
|