@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.
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
@@ -0,0 +1,25 @@
1
+ -- Caddy hostname list refactor (CADDY_HOSTNAME_LIST design).
2
+ -- web_routes used to carry `subdomain` + `custom_domain` and a flat
3
+ -- unique index on `path`. The new model has a single `hostname` FQDN
4
+ -- and a (hostname, path) unique index. Phase 0 / no production users
5
+ -- means this is a destructive migration — modules repopulate routes
6
+ -- on their next deploy after the migration runs.
7
+ DROP TABLE IF EXISTS `web_routes`;
8
+ --> statement-breakpoint
9
+ CREATE TABLE `web_routes` (
10
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
11
+ `slug` text NOT NULL,
12
+ `module_id` text NOT NULL,
13
+ `type` text NOT NULL,
14
+ `path` text NOT NULL,
15
+ `hostname` text NOT NULL,
16
+ `target_host` text,
17
+ `target_port` integer,
18
+ `websocket` integer DEFAULT false NOT NULL,
19
+ `content_hash` text,
20
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
21
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
22
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE UNIQUE INDEX `web_routes_hostname_path_idx` ON `web_routes` (`hostname`,`path`);
@@ -22,6 +22,20 @@
22
22
  "when": 1774881303520,
23
23
  "tag": "0002_web_routes",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1774924800000,
30
+ "tag": "0003_backup_storage",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1777680000000,
37
+ "tag": "0004_caddy_hostname_list",
38
+ "breakpoints": true
25
39
  }
26
40
  ]
27
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,10 +46,15 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@aws-sdk/client-s3": "^3.1024.0",
49
- "@clack/prompts": "^1.1.0",
50
49
  "@celilo/capabilities": "^0.1.2",
50
+ "@celilo/cli-display": "0.1.0",
51
+ "@clack/prompts": "^1.1.0",
51
52
  "ajv": "^8.18.0",
52
53
  "drizzle-orm": "^0.36.4",
54
+ "ink": "^7.0.1",
55
+ "ink-spinner": "^5.0.0",
56
+ "ink-text-input": "^6.0.0",
57
+ "react": "^19.2.5",
53
58
  "tar": "^7.5.10",
54
59
  "xxhash-wasm": "^1.1.0",
55
60
  "yaml": "^2.8.2",
@@ -58,7 +63,9 @@
58
63
  "devDependencies": {
59
64
  "@biomejs/biome": "^1.9.4",
60
65
  "@types/bun": "^1.1.14",
66
+ "@types/react": "^19.2.14",
61
67
  "drizzle-kit": "^0.30.0",
68
+ "ink-testing-library": "^4.0.0",
62
69
  "typescript": "^5.9.3",
63
70
  "zod-to-json-schema": "^3.25.2"
64
71
  }
@@ -75,13 +75,14 @@ describe('generateHostsIni', () => {
75
75
  });
76
76
 
77
77
  test('includes SSH private key file path when provided', () => {
78
+ const keyPath = '/tmp/celilo-ansible-keys/machine-machine-1.key';
78
79
  const hosts: InventoryHost[] = [
79
80
  {
80
81
  hostname: 'machine-host',
81
82
  ansibleHost: '192.168.1.100',
82
83
  ansibleUser: 'ubuntu',
83
84
  groups: ['machines'],
84
- ansibleSshPrivateKeyFile: '/var/lib/celilo/tmp/ansible-keys/machine-machine-1.key',
85
+ ansibleSshPrivateKeyFile: keyPath,
85
86
  },
86
87
  ];
87
88
 
@@ -89,7 +90,7 @@ describe('generateHostsIni', () => {
89
90
 
90
91
  expect(result).toContain('[machines]');
91
92
  expect(result).toContain(
92
- 'machine-host ansible_host=192.168.1.100 ansible_user=ubuntu ansible_ssh_private_key_file=/var/lib/celilo/tmp/ansible-keys/machine-machine-1.key',
93
+ `machine-host ansible_host=192.168.1.100 ansible_user=ubuntu ansible_ssh_private_key_file=${keyPath}`,
93
94
  );
94
95
  });
95
96
  });
@@ -66,7 +66,11 @@ export function generateHostsIni(hosts: InventoryHost[]): string {
66
66
  // Add [all:vars] section with common variables
67
67
  lines.push('[all:vars]');
68
68
  lines.push('ansible_python_interpreter=/usr/bin/python3');
69
- lines.push('ansible_ssh_common_args=-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null');
69
+ // Disable ControlMaster to avoid Python format string issues with ControlPath
70
+ // ControlPath uses %h, %p, %r placeholders which trigger Python's % formatter
71
+ lines.push(
72
+ 'ansible_ssh_common_args=-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlMaster=no',
73
+ );
70
74
  lines.push('');
71
75
 
72
76
  return lines.join('\n');
@@ -101,11 +101,11 @@ describe('validatePublishStaticSiteRequest', () => {
101
101
  expect(result.errors).toEqual([]);
102
102
  });
103
103
 
104
- test('accepts a request with optional clientConfig and subdomain', () => {
104
+ test('accepts a request with optional clientConfig and hostname', () => {
105
105
  const result = validatePublishStaticSiteRequest(
106
106
  basePublishRequest({
107
107
  clientConfig: { FOO: 'bar' },
108
- subdomain: 'app',
108
+ hostname: 'app.iamtheinternet.org',
109
109
  }),
110
110
  );
111
111
  expect(result.valid).toBe(true);
@@ -53,9 +53,9 @@ function makeRouteOps(): { ops: RouteOps; routes: WebRoute[] } {
53
53
  moduleId: route.moduleId,
54
54
  type: route.type,
55
55
  path: route.path,
56
+ hostname: route.hostname,
56
57
  targetHost: route.targetHost ?? null,
57
58
  targetPort: route.targetPort ?? null,
58
- subdomain: route.subdomain ?? null,
59
59
  websocket: route.websocket ?? false,
60
60
  contentHash: route.contentHash ?? null,
61
61
  createdAt: existing >= 0 ? routes[existing].createdAt : now,
@@ -116,6 +116,9 @@ describe('publishStaticSite — clientConfig injection', () => {
116
116
  },
117
117
  secrets: {},
118
118
  routeOps: ops,
119
+ hostnames: ['www.example.com'],
120
+ caddyModuleId: 'caddy',
121
+ dnsManagedDomains: ['www.example.com'],
119
122
  });
120
123
 
121
124
  const result = await cap.publishStaticSite({
@@ -157,6 +160,9 @@ describe('publishStaticSite — clientConfig injection', () => {
157
160
  },
158
161
  secrets: {},
159
162
  routeOps: ops,
163
+ hostnames: ['www.example.com'],
164
+ caddyModuleId: 'caddy',
165
+ dnsManagedDomains: ['www.example.com'],
160
166
  });
161
167
 
162
168
  await cap.publishStaticSite({
@@ -181,6 +187,9 @@ describe('publishStaticSite — clientConfig injection', () => {
181
187
  },
182
188
  secrets: {},
183
189
  routeOps: ops,
190
+ hostnames: ['www.example.com'],
191
+ caddyModuleId: 'caddy',
192
+ dnsManagedDomains: ['www.example.com'],
184
193
  });
185
194
 
186
195
  await cap.publishStaticSite({
@@ -208,6 +217,9 @@ describe('publishStaticSite — clientConfig injection', () => {
208
217
  },
209
218
  secrets: {},
210
219
  routeOps: ops,
220
+ hostnames: ['www.example.com'],
221
+ caddyModuleId: 'caddy',
222
+ dnsManagedDomains: ['www.example.com'],
211
223
  });
212
224
 
213
225
  await expect(
@@ -232,6 +244,9 @@ describe('publishStaticSite — clientConfig injection', () => {
232
244
  },
233
245
  secrets: {},
234
246
  routeOps: ops,
247
+ hostnames: ['www.example.com'],
248
+ caddyModuleId: 'caddy',
249
+ dnsManagedDomains: ['www.example.com'],
235
250
  });
236
251
 
237
252
  await expect(
@@ -268,6 +283,9 @@ describe('registerReverseProxy', () => {
268
283
  },
269
284
  secrets: {},
270
285
  routeOps: ops,
286
+ hostnames: ['www.example.com'],
287
+ caddyModuleId: 'caddy',
288
+ dnsManagedDomains: ['www.example.com'],
271
289
  });
272
290
 
273
291
  const result = await cap.registerReverseProxy({
@@ -301,6 +319,9 @@ describe('registerReverseProxy', () => {
301
319
  },
302
320
  secrets: {},
303
321
  routeOps: ops,
322
+ hostnames: ['www.example.com'],
323
+ caddyModuleId: 'caddy',
324
+ dnsManagedDomains: ['www.example.com'],
304
325
  });
305
326
 
306
327
  await expect(
@@ -344,6 +365,9 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
344
365
  },
345
366
  secrets: {},
346
367
  routeOps: ops,
368
+ hostnames: ['www.example.com'],
369
+ caddyModuleId: 'caddy',
370
+ dnsManagedDomains: ['www.example.com'],
347
371
  });
348
372
 
349
373
  await cap.register_route({
@@ -375,6 +399,9 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
375
399
  },
376
400
  secrets: {},
377
401
  routeOps: ops,
402
+ hostnames: ['www.example.com'],
403
+ caddyModuleId: 'caddy',
404
+ dnsManagedDomains: ['www.example.com'],
378
405
  });
379
406
 
380
407
  // Bad path triggers the validator inside register_route, which
@@ -407,6 +434,9 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
407
434
  },
408
435
  secrets: {},
409
436
  routeOps: ops,
437
+ hostnames: ['www.example.com'],
438
+ caddyModuleId: 'caddy',
439
+ dnsManagedDomains: ['www.example.com'],
410
440
  });
411
441
 
412
442
  await cap.publishStaticSite({ path: '/lunacycle', sourceDir });
@@ -439,6 +469,9 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
439
469
  },
440
470
  secrets: {},
441
471
  routeOps: ops,
472
+ hostnames: ['www.example.com'],
473
+ caddyModuleId: 'caddy',
474
+ dnsManagedDomains: ['www.example.com'],
442
475
  });
443
476
 
444
477
  await cap.register_route({
@@ -574,12 +574,12 @@ secrets:
574
574
  });
575
575
 
576
576
  describe('module import', () => {
577
- test('should error on missing path', async () => {
577
+ test('should error on missing subcommand', async () => {
578
578
  const result = await runCli(['node', 'celilo', 'module', 'import']);
579
579
 
580
580
  expect(result.success).toBe(false);
581
581
  if (result.success) return;
582
- expect(result.error).toContain('Missing required arguments');
582
+ expect(result.error).toContain('Subcommand required');
583
583
  });
584
584
 
585
585
  test('should error on non-existent path', async () => {
@@ -66,6 +66,32 @@ export const COMMANDS: CommandDef[] = [
66
66
  name: 'status',
67
67
  description: 'Show system and module status',
68
68
  },
69
+ {
70
+ name: 'audit',
71
+ description: 'Top-level alias for `system audit`',
72
+ flags: [
73
+ {
74
+ name: 'json',
75
+ description: 'Output a stable JSON schema instead of the human-readable table',
76
+ takesValue: false,
77
+ },
78
+ {
79
+ name: 'tui',
80
+ description: 'Force the interactive TUI (default when stdout is a terminal)',
81
+ takesValue: false,
82
+ },
83
+ {
84
+ name: 'no-tui',
85
+ description: 'Force the static text report even when stdout is a terminal',
86
+ takesValue: false,
87
+ },
88
+ {
89
+ name: 'theme',
90
+ description: 'TUI color theme (dark|light); defaults to dark',
91
+ takesValue: true,
92
+ },
93
+ ],
94
+ },
69
95
  {
70
96
  name: 'capability',
71
97
  description: 'View registered module capabilities',
@@ -87,8 +113,26 @@ export const COMMANDS: CommandDef[] = [
87
113
  subcommands: [
88
114
  {
89
115
  name: 'import',
90
- description: 'Import module from directory or package',
91
- args: [{ name: 'path', description: 'Module path', completion: 'directories' }],
116
+ description: 'Import a module (file <path> | public-registry <name>)',
117
+ subcommands: [
118
+ {
119
+ name: 'file',
120
+ description: 'Import from local filesystem',
121
+ args: [{ name: 'path', description: 'Module path', completion: 'directories' }],
122
+ },
123
+ {
124
+ name: 'public-registry',
125
+ description: 'Import from celilo.computer registry',
126
+ args: [{ name: 'name', description: 'Module name' }],
127
+ flags: [
128
+ {
129
+ name: 'registry',
130
+ description: 'Registry URL (overrides default celilo.computer)',
131
+ takesValue: true,
132
+ },
133
+ ],
134
+ },
135
+ ],
92
136
  flags: [
93
137
  {
94
138
  name: 'target',
@@ -141,8 +185,15 @@ export const COMMANDS: CommandDef[] = [
141
185
  ],
142
186
  },
143
187
  {
188
+ name: 'verify',
189
+ description: 'Verify module integrity (signature + checksums)',
190
+ args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
191
+ },
192
+ {
193
+ // Deprecation alias for `module verify`. Removed after one
194
+ // release cycle. See CELILO_UPDATE D11.
144
195
  name: 'audit',
145
- description: 'Verify module integrity',
196
+ description: 'DEPRECATED — use `module verify` instead',
146
197
  args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
147
198
  },
148
199
  {
@@ -277,6 +328,41 @@ export const COMMANDS: CommandDef[] = [
277
328
  },
278
329
  ],
279
330
  },
331
+ {
332
+ name: 'install',
333
+ description: 'Download and import a module from the registry',
334
+ args: [{ name: 'name', description: 'Module name' }],
335
+ flags: [{ name: 'registry', description: 'Registry URL', takesValue: true }],
336
+ },
337
+ {
338
+ name: 'search',
339
+ description: 'Search the module registry',
340
+ args: [{ name: 'query', description: 'Search query (optional)' }],
341
+ flags: [
342
+ { name: 'registry', description: 'Registry URL', takesValue: true },
343
+ { name: 'limit', description: 'Max results', takesValue: true },
344
+ ],
345
+ },
346
+ {
347
+ name: 'publish',
348
+ description: 'Build and publish a module to the registry',
349
+ args: [{ name: 'module-dir', description: 'Module directory', completion: 'directories' }],
350
+ flags: [
351
+ { name: 'token', description: 'Publish token', takesValue: true },
352
+ { name: 'registry', description: 'Registry URL', takesValue: true },
353
+ { name: 'revision', description: 'Package revision number', takesValue: true },
354
+ {
355
+ name: 'message',
356
+ description: 'One-line release note stamped into release.json',
357
+ takesValue: true,
358
+ },
359
+ {
360
+ name: 'allow-dirty',
361
+ description: 'Bypass the clean-working-tree check (emergency publish)',
362
+ takesValue: false,
363
+ },
364
+ ],
365
+ },
280
366
  ],
281
367
  },
282
368
  {
@@ -500,6 +586,63 @@ export const COMMANDS: CommandDef[] = [
500
586
  ],
501
587
  },
502
588
  { name: 'vault-password', description: 'Display Ansible vault password' },
589
+ {
590
+ name: 'audit',
591
+ description: 'Report system-wide drift (no mutations)',
592
+ flags: [
593
+ {
594
+ name: 'json',
595
+ description: 'Output a stable JSON schema instead of the human-readable table',
596
+ takesValue: false,
597
+ },
598
+ {
599
+ name: 'tui',
600
+ description: 'Force the interactive TUI (default when stdout is a terminal)',
601
+ takesValue: false,
602
+ },
603
+ {
604
+ name: 'no-tui',
605
+ description: 'Force the static text report even when stdout is a terminal',
606
+ takesValue: false,
607
+ },
608
+ {
609
+ name: 'theme',
610
+ description: 'TUI color theme (dark|light); defaults to dark',
611
+ takesValue: true,
612
+ },
613
+ ],
614
+ },
615
+ {
616
+ name: 'update',
617
+ description: 'Bring the system to the audit-determined READY state',
618
+ flags: [
619
+ {
620
+ name: 'module',
621
+ description: 'Restrict the run to a single module',
622
+ takesValue: true,
623
+ },
624
+ {
625
+ name: 'dry-run',
626
+ description: 'Print the plan without executing',
627
+ takesValue: false,
628
+ },
629
+ {
630
+ name: 'no-backup',
631
+ description: 'Skip pre-update backups (requires explicit acknowledgement)',
632
+ takesValue: false,
633
+ },
634
+ {
635
+ name: 'allow-destructive',
636
+ description: 'Allow destructive terraform plans through',
637
+ takesValue: false,
638
+ },
639
+ {
640
+ name: 'json',
641
+ description: 'Output a stable JSON schema instead of the human-readable summary',
642
+ takesValue: false,
643
+ },
644
+ ],
645
+ },
503
646
  ],
504
647
  },
505
648
  {
@@ -19,7 +19,7 @@ describe('CommandTreeParser', () => {
19
19
  CELILO_DB_PATH: ctx.dbPath,
20
20
  CELILO_DATA_DIR: ctx.dataDir,
21
21
  });
22
- parser = new CommandTreeParser(ctx.cli);
22
+ parser = new CommandTreeParser(cli);
23
23
  });
24
24
 
25
25
  afterEach(async () => {
@@ -5,7 +5,7 @@
5
5
  * Used for automated completion testing to ensure 100% coverage.
6
6
  */
7
7
 
8
- import { runCli } from '../test-utils/cli';
8
+ import type { CLIContext } from '../test-utils/cli-context';
9
9
 
10
10
  /**
11
11
  * A node in the command tree
@@ -21,7 +21,7 @@ export interface CommandNode {
21
21
  * Parser that discovers command structure from help text
22
22
  */
23
23
  export class CommandTreeParser {
24
- constructor(private cli: string) {}
24
+ constructor(private cli: CLIContext) {}
25
25
 
26
26
  /**
27
27
  * Discover all commands by parsing help output
@@ -30,15 +30,15 @@ export class CommandTreeParser {
30
30
  */
31
31
  async discover(): Promise<Map<string, CommandNode>> {
32
32
  // Get top-level commands
33
- const helpOutput = runCli(this.cli, '--help');
34
- const topLevelCommands = this.parseHelpText(helpOutput);
33
+ const helpResult = await this.cli.run('--help');
34
+ const topLevelCommands = this.parseHelpText(helpResult.stdout);
35
35
  const tree = new Map<string, CommandNode>();
36
36
 
37
37
  // For each top-level command, discover its subcommands
38
38
  for (const cmdName of topLevelCommands) {
39
39
  try {
40
- const subHelpOutput = runCli(this.cli, `${cmdName} --help`);
41
- const subcommandNames = this.parseHelpText(subHelpOutput);
40
+ const subHelpResult = await this.cli.run(`${cmdName} --help`);
41
+ const subcommandNames = this.parseHelpText(subHelpResult.stdout);
42
42
 
43
43
  // Check if subcommands are actually the same as top-level commands
44
44
  // This happens when a command like 'help' just shows the main help again
@@ -111,10 +111,11 @@ export class CommandTreeParser {
111
111
 
112
112
  // Check if we're leaving the Commands section
113
113
  // Look for major section headers that appear with little/no indentation
114
- // Examples: " Usage:", " Options:", " Description:", " For command-specific help:"
114
+ // Examples: "Usage:", "Options:", "Description:", " For command-specific help:"
115
+ // NOTE: Must NOT match nested " Options:" (4-space indent) inside subcommand listings
115
116
  if (
116
117
  inCommandSection &&
117
- /^[│\s]{0,4}(Usage|Options|Description|Examples|For|Enable):/i.test(line)
118
+ /^[│\s]{0,3}(Usage|Options|Description|Examples|For|Enable|Related):/i.test(line)
118
119
  ) {
119
120
  inCommandSection = false;
120
121
  continue;
@@ -10,14 +10,11 @@
10
10
  import { eq } from 'drizzle-orm';
11
11
  import { FuelGauge } from '../../cli/fuel-gauge';
12
12
  import { getDb } from '../../db/client';
13
- import { moduleConfigs, modules, secrets } from '../../db/schema';
14
- import { loadCapabilityFunctions } from '../../hooks/capability-loader';
15
- import { invokeHook } from '../../hooks/executor';
16
- import { createConsoleLogger } from '../../hooks/logger';
17
- import { createGaugeLogger } from '../../hooks/logger';
13
+ import { modules } from '../../db/schema';
14
+ import { createConsoleLogger, createGaugeLogger } from '../../hooks/logger';
15
+ import { runNamedHook } from '../../hooks/run-named-hook';
16
+ import type { HookName } from '../../hooks/types';
18
17
  import type { ModuleManifest } from '../../manifest/schema';
19
- import { decryptSecret } from '../../secrets/encryption';
20
- import { getOrCreateMasterKey } from '../../secrets/master-key';
21
18
  import { getArg, hasFlag, validateRequiredArgs } from '../parser';
22
19
  import type { CommandResult } from '../types';
23
20
 
@@ -52,19 +49,15 @@ export async function handleHookRun(
52
49
  const debug = hasFlag(flags, 'debug');
53
50
  const db = getDb();
54
51
 
55
- // Look up module
52
+ // Surface "module not found" / "hook not in manifest" with the same
53
+ // error messages we used to produce inline. The runNamedHook helper
54
+ // returns a structured result we can format here.
56
55
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
57
56
  if (!module) {
58
- return {
59
- success: false,
60
- error: `Module not found: ${moduleId}`,
61
- };
57
+ return { success: false, error: `Module not found: ${moduleId}` };
62
58
  }
63
-
64
- // Find hook definition in manifest
65
59
  const manifest = module.manifestData as ModuleManifest;
66
- const hookDef = manifest.hooks?.[hookName as keyof typeof manifest.hooks];
67
- if (!hookDef) {
60
+ if (!manifest.hooks?.[hookName as keyof typeof manifest.hooks]) {
68
61
  const available = Object.keys(manifest.hooks || {});
69
62
  return {
70
63
  success: false,
@@ -82,48 +75,12 @@ export async function handleHookRun(
82
75
  }
83
76
  }
84
77
 
85
- // Build config map from DB
86
- const configRecords = db
87
- .select()
88
- .from(moduleConfigs)
89
- .where(eq(moduleConfigs.moduleId, moduleId))
90
- .all();
91
- const configMap: Record<string, unknown> = {};
92
- for (const c of configRecords) {
93
- configMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
94
- }
95
-
96
- // Build secrets map from DB
97
- const secretRecords = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all();
98
- const masterKey = await getOrCreateMasterKey();
99
- const secretMap: Record<string, string> = {};
100
- for (const s of secretRecords) {
101
- secretMap[s.name] = decryptSecret(
102
- { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag },
103
- masterKey,
104
- );
105
- }
106
-
107
- const requiredCapabilities = manifest.requires.capabilities.map((c) => c.name);
108
-
109
- // Run the hook — use console logger in debug mode, FuelGauge otherwise.
110
- // The logger is constructed BEFORE loadCapabilityFunctions so the
111
- // auto-logging wrapper (HOOK_API_V2 D6) can capture it for every
112
- // capability call.
113
78
  if (debug) {
114
79
  const logger = createConsoleLogger(moduleId, hookName);
115
- const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
116
- const result = await invokeHook(
117
- module.sourcePath,
118
- hookName,
119
- manifest.celilo_contract,
120
- hookDef,
80
+ const result = await runNamedHook(moduleId, hookName as HookName, db, logger, {
81
+ debug: true,
121
82
  inputs,
122
- configMap,
123
- secretMap,
124
- logger,
125
- { debug, capabilities: capabilityFunctions, requiredCapabilities },
126
- );
83
+ });
127
84
 
128
85
  if (!result.success) {
129
86
  let errorMsg = result.error || 'Hook execution failed';
@@ -144,19 +101,11 @@ export async function handleHookRun(
144
101
  const gauge = new FuelGauge(`Running hook: ${hookName}`);
145
102
  gauge.start();
146
103
  const logger = createGaugeLogger(gauge, moduleId, hookName);
147
- const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
148
104
 
149
- const result = await invokeHook(
150
- module.sourcePath,
151
- hookName,
152
- manifest.celilo_contract,
153
- hookDef,
105
+ const result = await runNamedHook(moduleId, hookName as HookName, db, logger, {
106
+ debug: false,
154
107
  inputs,
155
- configMap,
156
- secretMap,
157
- logger,
158
- { debug: false, capabilities: capabilityFunctions, requiredCapabilities },
159
- );
108
+ });
160
109
 
161
110
  if (!result.success) {
162
111
  gauge.stop(false);