@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
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
package/src/cli/prompts.ts
CHANGED
|
@@ -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) =>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
});
|