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