@celilo/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
package/src/cli/index.ts CHANGED
@@ -32,14 +32,18 @@ import { handleModuleConfigGet, handleModuleConfigSet } from './commands/module-
32
32
  import { handleModuleDeploy } from './commands/module-deploy';
33
33
  import { handleModuleGenerate } from './commands/module-generate';
34
34
  import { handleModuleHealth } from './commands/module-health';
35
- import { handleModuleImport } from './commands/module-import';
35
+ import { handleModuleImport, handlePublicRegistryImport } from './commands/module-import';
36
36
  import { handleModuleList } from './commands/module-list';
37
37
  import { handleModuleLogs } from './commands/module-logs';
38
+ import { handleModulePublish } from './commands/module-publish';
38
39
  import { handleModuleRemove } from './commands/module-remove';
40
+ import { handleModuleSearch } from './commands/module-search';
39
41
  import { handleModuleShowConfig, handleModuleShowZone } from './commands/module-show';
40
42
  import { handleModuleStatus } from './commands/module-status';
43
+ import { handleModuleTerraformUnlock } from './commands/module-terraform-unlock';
41
44
  import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/module-types';
42
45
  import { handleModuleUpgrade } from './commands/module-upgrade';
46
+ import { moduleVerify } from './commands/module-verify';
43
47
  import { handlePackage } from './commands/package';
44
48
  import { handleSecretList } from './commands/secret-list';
45
49
  import { handleSecretSet } from './commands/secret-set';
@@ -52,10 +56,12 @@ import { handleServiceReconfigure } from './commands/service-reconfigure';
52
56
  import { handleServiceRemove } from './commands/service-remove';
53
57
  import { handleServiceVerify } from './commands/service-verify';
54
58
  import { handleStatus } from './commands/status';
59
+ import { handleSystemAudit } from './commands/system-audit';
55
60
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
56
61
  import { handleSystemInit } from './commands/system-init';
57
62
  import { handleSystemSecretGet } from './commands/system-secret-get';
58
63
  import { handleSystemSecretSet } from './commands/system-secret-set';
64
+ import { handleSystemUpdate } from './commands/system-update';
59
65
  import { handleSystemVaultPassword } from './commands/system-vault-password';
60
66
  import { getCompletions } from './completion';
61
67
  import { parseArguments, validateFlags } from './parser';
@@ -102,6 +108,17 @@ function checkFlags(
102
108
  return null;
103
109
  }
104
110
 
111
+ /**
112
+ * Display CLI version. Reads the npm package version from this
113
+ * package's manifest at runtime — single source of truth, so
114
+ * `bun publish` bumping the version automatically updates what
115
+ * `celilo --version` reports.
116
+ */
117
+ function displayVersion(): CommandResult {
118
+ const pkg = require('../../package.json') as { version: string };
119
+ return { success: true, message: `celilo ${pkg.version}` };
120
+ }
121
+
105
122
  /**
106
123
  * Display general help message
107
124
  */
@@ -114,6 +131,7 @@ Usage:
114
131
 
115
132
  Commands:
116
133
  status Show system and module status
134
+ audit Top-level alias for 'system audit'
117
135
  capability View registered module capabilities
118
136
  package Create distributable .netapp packages from module source
119
137
  module Manage modules (import, list, configure, build, generate)
@@ -182,7 +200,7 @@ Examples:
182
200
 
183
201
  Related Commands:
184
202
  celilo module import <package.netapp> Import a packaged module
185
- celilo module audit <module-id> Verify package integrity
203
+ celilo module verify <module-id> Verify package integrity
186
204
  `;
187
205
 
188
206
  return {
@@ -245,7 +263,8 @@ Subcommands:
245
263
 
246
264
  remove <id> Remove a module and all its data
247
265
 
248
- audit <id> Verify module integrity (checksums)
266
+ verify <id> Verify module integrity (signature + checksums)
267
+ (legacy alias: 'audit')
249
268
 
250
269
  config set <id> <key> <value> Set module configuration value
251
270
  config get <id> [key] Get module configuration value(s)
@@ -278,13 +297,33 @@ Subcommands:
278
297
 
279
298
  update <path> [path...] Update module code while preserving state (configs, secrets, infra)
280
299
 
300
+ Registry:
301
+ install <name> Download and import a module from the registry
302
+ Options:
303
+ --registry <url> Use a custom registry (default: https://celilo.computer/registry)
304
+
305
+ search [query] Search the registry for modules
306
+ Options:
307
+ --registry <url> Use a custom registry
308
+ --limit <n> Max results (default: 25)
309
+
310
+ publish <module-dir> Build and publish a module to the registry
311
+ Options:
312
+ --token <token> Publish token (or set CELILO_PUBLISH_TOKEN env var)
313
+ --registry <url> Use a custom registry
314
+ --revision <n> Force package revision number (default: auto)
315
+
281
316
  Examples:
317
+ celilo module install caddy
318
+ celilo module install namecheap --registry https://my-registry.example.com/registry
319
+ celilo module search dns
320
+ celilo module publish ./modules/caddy --token mytoken
282
321
  celilo module import ./modules/homebridge
283
322
  celilo module import homebridge.netapp
284
323
  celilo module import /abs/path/to/module --target /custom/location
285
324
  celilo module list
286
325
  celilo module remove homebridge
287
- celilo module audit homebridge
326
+ celilo module verify homebridge
288
327
  celilo module config set homebridge hostname myhost
289
328
  celilo module config set homebridge container_ip "192.168.0.110/24"
290
329
  celilo module config get homebridge
@@ -678,6 +717,15 @@ Subcommands:
678
717
 
679
718
  vault-password Get Ansible Vault password for decrypting secrets.yml
680
719
 
720
+ audit [--json] Report system-wide drift (no mutations)
721
+
722
+ update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
723
+ Bring the system to the audit-determined READY state
724
+
725
+ Runbook:
726
+ https://celilo.computer/docs/system-update
727
+ (offline summary in apps/celilo/docs/INDEX.md)
728
+
681
729
  Description:
682
730
  System initialization (init) guides you through first-time setup with interactive
683
731
  prompts or applies sensible defaults for testing.
@@ -765,6 +813,17 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
765
813
  }
766
814
  }
767
815
 
816
+ // Handle --version / -v / `version` (top-level only — subcommands
817
+ // like `celilo module --version` aren't supported here; they'd
818
+ // reach the per-command help anyway).
819
+ if (
820
+ parsed.flags.version ||
821
+ parsed.flags.v ||
822
+ (parsed.command === 'version' && !parsed.subcommand)
823
+ ) {
824
+ return displayVersion();
825
+ }
826
+
768
827
  // Handle general help (only if no specific command, or command is 'help')
769
828
  if (parsed.command === 'help') {
770
829
  return displayHelp();
@@ -783,6 +842,11 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
783
842
  return handleStatus();
784
843
  }
785
844
 
845
+ // Top-level alias: `celilo audit` → `celilo system audit`
846
+ if (parsed.command === 'audit') {
847
+ return handleSystemAudit(parsed.args, parsed.flags);
848
+ }
849
+
786
850
  // Route commands
787
851
  if (parsed.command === 'package') {
788
852
  // Handle package --help
@@ -887,6 +951,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
887
951
  return handleModuleUpgrade(parsed.args, parsed.flags);
888
952
  case 'audit':
889
953
  return moduleAudit(parsed.args);
954
+ case 'verify':
955
+ return moduleVerify(parsed.args);
890
956
  case 'config': {
891
957
  // Config requires additional subcommand (set/get)
892
958
  const configSubcommand = parsed.args[0];
@@ -915,7 +981,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
915
981
  return {
916
982
  success: false,
917
983
  error:
918
- 'Secret action required (set or list)\n\nUsage:\n celilo module secret set <module-id> <key> <value>\n celilo module secret list <module-id>',
984
+ 'Secret action required\n\nUsage:\n celilo module secret set <module-id> <key> <value>\n celilo module secret get <module-id> <key>\n celilo module secret list <module-id>',
919
985
  };
920
986
  }
921
987
  const moduleSecretArgs = parsed.args.slice(1);
@@ -925,6 +991,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
925
991
  if (moduleSecretSubcommand === 'list') {
926
992
  return handleSecretList(moduleSecretArgs);
927
993
  }
994
+ if (moduleSecretSubcommand === 'get') {
995
+ const { handleModuleSecretGet } = await import('./commands/module-secret-get');
996
+ return handleModuleSecretGet(moduleSecretArgs);
997
+ }
928
998
  return {
929
999
  success: false,
930
1000
  error: `Unknown secret action: ${moduleSecretSubcommand}`,
@@ -963,10 +1033,27 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
963
1033
  return handleModuleDeploy(parsed.args, { ...parsed.flags, preflight: true });
964
1034
  case 'run-hook':
965
1035
  return handleHookRun(parsed.args, parsed.flags);
1036
+ case 'terraform-unlock':
1037
+ return handleModuleTerraformUnlock(parsed.args);
966
1038
  case 'show-config':
967
1039
  return handleModuleShowConfig(parsed.args);
968
1040
  case 'show-zone':
969
1041
  return handleModuleShowZone(parsed.args);
1042
+ case 'search':
1043
+ return handleModuleSearch(parsed.args, parsed.flags);
1044
+ case 'install': {
1045
+ // `celilo module install <name>` — shorthand for `module import public-registry <name>`
1046
+ const name = parsed.args[0] ?? parsed.subcommand;
1047
+ if (!name) {
1048
+ return {
1049
+ success: false,
1050
+ error: 'Module name required\n\nUsage: celilo module install <name> [--registry <url>]',
1051
+ };
1052
+ }
1053
+ return handlePublicRegistryImport(name, parsed.flags);
1054
+ }
1055
+ case 'publish':
1056
+ return handleModulePublish(parsed.args, parsed.flags);
970
1057
  default:
971
1058
  return {
972
1059
  success: false,
@@ -1296,6 +1383,14 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1296
1383
  return handleSystemVaultPassword();
1297
1384
  }
1298
1385
 
1386
+ if (parsed.subcommand === 'audit') {
1387
+ return handleSystemAudit(parsed.args, parsed.flags);
1388
+ }
1389
+
1390
+ if (parsed.subcommand === 'update') {
1391
+ return handleSystemUpdate(parsed.args, parsed.flags);
1392
+ }
1393
+
1299
1394
  return {
1300
1395
  success: false,
1301
1396
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
@@ -1554,6 +1649,18 @@ export async function main(): Promise<void> {
1554
1649
  process.exit(0);
1555
1650
  }
1556
1651
 
1652
+ // Script-friendly commands bypass clack and write directly to stdout
1653
+ if (result.rawOutput) {
1654
+ process.stdout.write(`${result.message}\n`);
1655
+ process.exit(0);
1656
+ }
1657
+
1658
+ // Empty message → command already emitted its own output (e.g. via
1659
+ // ProgressDisplay) and doesn't want a clack outro on top.
1660
+ if (!result.message) {
1661
+ process.exit(0);
1662
+ }
1663
+
1557
1664
  // Handle multi-line messages: split on section boundaries (\n\n) so
1558
1665
  // lines within a section are logged together (avoiding clack's per-call spacing).
1559
1666
  const sections = result.message.split('\n\n');
package/src/cli/parser.ts CHANGED
@@ -45,6 +45,17 @@ export function parseArguments(argv: string[]): ParsedCommand {
45
45
  };
46
46
  }
47
47
 
48
+ // Handle --version or -v as first argument. Without this, the
49
+ // top-level loop in index.ts would never see the flag — `--version`
50
+ // would land as the command name (`Unknown command: --version`).
51
+ if (firstArg === '--version' || firstArg === '-v') {
52
+ return {
53
+ command: 'version',
54
+ args: [],
55
+ flags: { version: true },
56
+ };
57
+ }
58
+
48
59
  // Handle --get-completions as special flag (for shell completion)
49
60
  if (firstArg === '--get-completions') {
50
61
  return {
@@ -3,8 +3,24 @@
3
3
  * Beautiful interactive prompts using @clack/prompts
4
4
  */
5
5
 
6
+ import type { ProgressDisplay } from '@celilo/cli-display';
6
7
  import * as p from '@clack/prompts';
7
8
 
9
+ let activeDisplay: ProgressDisplay | null = null;
10
+
11
+ /**
12
+ * When set, log.* calls route through the ProgressDisplay instead of @clack/prompts.
13
+ * Callers (e.g. module deploy) set this for the duration of a long-running operation
14
+ * so all the sub-services they call emit through one coherent display.
15
+ */
16
+ export function setActiveDisplay(display: ProgressDisplay | null): void {
17
+ activeDisplay = display;
18
+ }
19
+
20
+ export function getActiveDisplay(): ProgressDisplay | null {
21
+ return activeDisplay;
22
+ }
23
+
8
24
  /**
9
25
  * Interactive prompt wrapper with celilo branding
10
26
  */
@@ -113,9 +129,24 @@ export function showNote(message: string, title?: string): void {
113
129
  * Show log messages
114
130
  */
115
131
  export const log = {
116
- success: (message: string) => p.log.success(message),
117
- error: (message: string) => p.log.error(message),
118
- warn: (message: string) => p.log.warn(message),
119
- info: (message: string) => p.log.info(message),
120
- message: (message: string) => p.log.message(message),
132
+ success: (message: string) => {
133
+ if (activeDisplay) return activeDisplay.subEvent(`\x1b[32m✓\x1b[0m ${message}`);
134
+ p.log.success(message);
135
+ },
136
+ error: (message: string) => {
137
+ if (activeDisplay) return activeDisplay.subEvent(`\x1b[31m✗\x1b[0m ${message}`);
138
+ p.log.error(message);
139
+ },
140
+ warn: (message: string) => {
141
+ if (activeDisplay) return activeDisplay.subEvent(`\x1b[33m⚠\x1b[0m ${message}`);
142
+ p.log.warn(message);
143
+ },
144
+ info: (message: string) => {
145
+ if (activeDisplay) return activeDisplay.instantEvent(message);
146
+ p.log.info(message);
147
+ },
148
+ message: (message: string) => {
149
+ if (activeDisplay) return activeDisplay.subEvent(message);
150
+ p.log.message(message);
151
+ },
121
152
  };
@@ -0,0 +1,246 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { DriftFinding, SystemAuditReport } from '../../services/audit/types';
3
+ import {
4
+ type AuditTuiState,
5
+ groupFindings,
6
+ initState,
7
+ reducer,
8
+ selectedFinding,
9
+ } from './audit-state';
10
+
11
+ function finding(over: Partial<DriftFinding>): DriftFinding {
12
+ return {
13
+ category: 'module_configs',
14
+ severity: 'drift',
15
+ code: 'cfg_unset',
16
+ subject: 'caddy',
17
+ message: 'config "acme_ca" is unset',
18
+ ...over,
19
+ };
20
+ }
21
+
22
+ function report(findings: DriftFinding[]): SystemAuditReport {
23
+ return {
24
+ version: 1,
25
+ verdict: findings.some((f) => f.severity === 'blocked')
26
+ ? 'BLOCKED'
27
+ : findings.length > 0
28
+ ? 'DRIFT'
29
+ : 'READY',
30
+ generatedAt: '2026-04-25T12:00:00.000Z',
31
+ findings,
32
+ };
33
+ }
34
+
35
+ describe('groupFindings', () => {
36
+ test('buckets by category and sorts blocked first', () => {
37
+ const groups = groupFindings([
38
+ finding({ category: 'module_configs', code: 'a', subject: 'caddy' }),
39
+ finding({
40
+ category: 'capability_abi',
41
+ severity: 'blocked',
42
+ code: 'b',
43
+ subject: 'public_web',
44
+ }),
45
+ finding({ category: 'module_configs', code: 'c', subject: 'iptables' }),
46
+ ]);
47
+ expect(groups.map((g) => g.category)).toEqual(['capability_abi', 'module_configs']);
48
+ expect(groups[0].severity).toBe('blocked');
49
+ expect(groups[1].findings).toHaveLength(2);
50
+ });
51
+
52
+ test('empty findings → empty groups', () => {
53
+ expect(groupFindings([])).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe('initState', () => {
58
+ test('selects first category and first finding by default', () => {
59
+ const s = initState(
60
+ report([
61
+ finding({ category: 'backups', code: 'b1', subject: 'caddy' }),
62
+ finding({ category: 'backups', code: 'b2', subject: 'iptables' }),
63
+ ]),
64
+ );
65
+ expect(s.selectedCategory).toBe('backups');
66
+ expect(s.selectedFindingId).toBe('caddy/b1');
67
+ expect(s.focusedPane).toBe('categories');
68
+ });
69
+
70
+ test('READY report with no findings has null selection', () => {
71
+ const s = initState(report([]));
72
+ expect(s.selectedCategory).toBeNull();
73
+ expect(s.selectedFindingId).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe('reducer — drill-in (Enter)', () => {
78
+ function setup(): AuditTuiState {
79
+ return initState(
80
+ report([
81
+ finding({ category: 'backups', code: 'b1', subject: 'caddy' }),
82
+ finding({ category: 'module_configs', code: 'm1', subject: 'caddy' }),
83
+ ]),
84
+ );
85
+ }
86
+
87
+ test('summary → categories', () => {
88
+ const s = setup();
89
+ const next = reducer({ ...s, focusedPane: 'summary' }, { type: 'enter' });
90
+ expect(next.focusedPane).toBe('categories');
91
+ });
92
+
93
+ test('categories → findings (and selects first finding of category)', () => {
94
+ const s = setup();
95
+ const next = reducer(s, { type: 'enter' });
96
+ expect(next.focusedPane).toBe('findings');
97
+ expect(next.selectedFindingId).toBe('caddy/b1');
98
+ });
99
+
100
+ test('findings → detail', () => {
101
+ const s = setup();
102
+ const next = reducer({ ...s, focusedPane: 'findings' }, { type: 'enter' });
103
+ expect(next.focusedPane).toBe('detail');
104
+ });
105
+
106
+ test('detail → no-op (does not advance)', () => {
107
+ const s = setup();
108
+ const next = reducer({ ...s, focusedPane: 'detail' }, { type: 'enter' });
109
+ expect(next.focusedPane).toBe('detail');
110
+ });
111
+ });
112
+
113
+ describe('reducer — step-back (Esc)', () => {
114
+ function setup(): AuditTuiState {
115
+ return initState(report([finding({ code: 'a' })]));
116
+ }
117
+
118
+ test('detail → findings', () => {
119
+ const next = reducer({ ...setup(), focusedPane: 'detail' }, { type: 'escape' });
120
+ expect(next.focusedPane).toBe('findings');
121
+ });
122
+
123
+ test('findings → categories', () => {
124
+ const next = reducer({ ...setup(), focusedPane: 'findings' }, { type: 'escape' });
125
+ expect(next.focusedPane).toBe('categories');
126
+ });
127
+
128
+ test('log → detail (down-then-over recovery)', () => {
129
+ const next = reducer({ ...setup(), focusedPane: 'log' }, { type: 'escape' });
130
+ expect(next.focusedPane).toBe('detail');
131
+ });
132
+
133
+ test('categories → no-op (top-level handles quit)', () => {
134
+ const s = { ...setup(), focusedPane: 'categories' as const };
135
+ const next = reducer(s, { type: 'escape' });
136
+ expect(next).toBe(s);
137
+ });
138
+ });
139
+
140
+ describe('reducer — move within categories', () => {
141
+ test('moving down updates selected category and resets finding', () => {
142
+ const s = initState(
143
+ report([
144
+ finding({ category: 'backups', code: 'b1', subject: 'caddy' }),
145
+ finding({ category: 'capability_abi', severity: 'blocked', code: 'c1', subject: 'pw' }),
146
+ ]),
147
+ );
148
+ // initial: capability_abi (sorted blocked-first), c1 selected
149
+ expect(s.selectedCategory).toBe('capability_abi');
150
+ expect(s.selectedFindingId).toBe('pw/c1');
151
+
152
+ const next = reducer({ ...s, focusedPane: 'categories' }, { type: 'move', direction: 1 });
153
+ expect(next.selectedCategory).toBe('backups');
154
+ expect(next.selectedFindingId).toBe('caddy/b1');
155
+ });
156
+
157
+ test('moving wraps around', () => {
158
+ const s = initState(
159
+ report([
160
+ finding({ category: 'backups', code: 'b1' }),
161
+ finding({ category: 'module_configs', code: 'm1' }),
162
+ ]),
163
+ );
164
+ const down = reducer({ ...s, focusedPane: 'categories' }, { type: 'move', direction: 1 });
165
+ const wrap = reducer(down, { type: 'move', direction: 1 });
166
+ expect(wrap.selectedCategory).toBe(s.selectedCategory);
167
+ });
168
+ });
169
+
170
+ describe('reducer — move within findings', () => {
171
+ test('cycles within current category only', () => {
172
+ const s = initState(
173
+ report([
174
+ finding({ category: 'module_configs', code: 'a', subject: 'caddy' }),
175
+ finding({ category: 'module_configs', code: 'b', subject: 'iptables' }),
176
+ finding({ category: 'module_configs', code: 'c', subject: 'greenwave' }),
177
+ ]),
178
+ );
179
+ let st: AuditTuiState = { ...s, focusedPane: 'findings' };
180
+ expect(st.selectedFindingId).toBe('caddy/a');
181
+ st = reducer(st, { type: 'move', direction: 1 });
182
+ expect(st.selectedFindingId).toBe('iptables/b');
183
+ st = reducer(st, { type: 'move', direction: 1 });
184
+ expect(st.selectedFindingId).toBe('greenwave/c');
185
+ st = reducer(st, { type: 'move', direction: 1 });
186
+ expect(st.selectedFindingId).toBe('caddy/a');
187
+ });
188
+ });
189
+
190
+ describe('reducer — focus by number', () => {
191
+ test('cannot focus findings when no category', () => {
192
+ const s = initState(report([]));
193
+ const next = reducer(s, { type: 'focus', pane: 'findings' });
194
+ expect(next.focusedPane).toBe(s.focusedPane); // unchanged
195
+ });
196
+
197
+ test('focus pane succeeds when prerequisites met', () => {
198
+ const s = initState(report([finding({ code: 'a' })]));
199
+ const next = reducer(s, { type: 'focus', pane: 'detail' });
200
+ expect(next.focusedPane).toBe('detail');
201
+ });
202
+ });
203
+
204
+ describe('reducer — set-report preserves selection', () => {
205
+ test('selection retained when finding still present', () => {
206
+ const before = initState(
207
+ report([
208
+ finding({ category: 'backups', code: 'b1', subject: 'caddy' }),
209
+ finding({ category: 'backups', code: 'b2', subject: 'iptables' }),
210
+ ]),
211
+ );
212
+ // Select b2
213
+ const moved = reducer({ ...before, focusedPane: 'findings' }, { type: 'move', direction: 1 });
214
+ expect(moved.selectedFindingId).toBe('iptables/b2');
215
+
216
+ // Re-audit returns same findings
217
+ const after = reducer(moved, {
218
+ type: 'set-report',
219
+ report: report([
220
+ finding({ category: 'backups', code: 'b1', subject: 'caddy' }),
221
+ finding({ category: 'backups', code: 'b2', subject: 'iptables' }),
222
+ ]),
223
+ });
224
+ expect(after.selectedFindingId).toBe('iptables/b2');
225
+ });
226
+
227
+ test('selection falls back to first if missing after re-audit', () => {
228
+ const before = initState(report([finding({ code: 'gone' })]));
229
+ const after = reducer(before, {
230
+ type: 'set-report',
231
+ report: report([finding({ code: 'new', subject: 'other' })]),
232
+ });
233
+ expect(after.selectedFindingId).toBe('other/new');
234
+ });
235
+ });
236
+
237
+ describe('selectedFinding', () => {
238
+ test('returns the live finding object', () => {
239
+ const s = initState(report([finding({ code: 'x', message: 'hello' })]));
240
+ expect(selectedFinding(s)?.message).toBe('hello');
241
+ });
242
+
243
+ test('returns null with no selection', () => {
244
+ expect(selectedFinding(initState(report([])))).toBeNull();
245
+ });
246
+ });