@celilo/cli 0.1.0

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 (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. package/src/variables/types.ts +59 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Hook Executor
3
+ *
4
+ * Executes hook scripts in a controlled environment with:
5
+ * - Timeout management (total + idle)
6
+ * - Input validation
7
+ * - Output validation
8
+ * - Structured logging
9
+ *
10
+ * Execution function (Rule 10.1) - performs side effects (script execution)
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ import { isCompiledHook } from '@celilo/capabilities';
16
+ import {
17
+ type ContractHookSignature,
18
+ resolveContract,
19
+ supportedContractVersions,
20
+ } from '../manifest/contracts';
21
+ import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
22
+
23
+ /** Default total timeout: 60 seconds */
24
+ const DEFAULT_TIMEOUT_MS = 60_000;
25
+
26
+ /** Default idle timeout: 30 seconds since last log message */
27
+ const IDLE_TIMEOUT_MS = 30_000;
28
+
29
+ /**
30
+ * Validate hook inputs against a contract signature.
31
+ *
32
+ * Policy function (Rule 10.1) - validates inputs.
33
+ *
34
+ * The set of required inputs comes from the contract version registered for
35
+ * the manifest's `celilo_contract`, NOT from the manifest itself. See
36
+ * `apps/celilo/src/manifest/contracts/v1.ts`.
37
+ *
38
+ * @param signature - Contract hook signature for this hook name
39
+ * @param inputs - Provided input values
40
+ * @returns Error message if validation fails, null if valid
41
+ */
42
+ export function validateHookInputs(
43
+ signature: ContractHookSignature,
44
+ inputs: Record<string, unknown>,
45
+ ): string | null {
46
+ const required = Object.entries(signature.inputs)
47
+ .filter(([, def]) => def.required)
48
+ .map(([name]) => name);
49
+
50
+ const missing = required.filter((name) => !(name in inputs));
51
+ if (missing.length > 0) {
52
+ return `Missing required hook inputs: ${missing.join(', ')}`;
53
+ }
54
+
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Validate hook outputs against a contract signature.
60
+ *
61
+ * Policy function (Rule 10.1) - validates outputs.
62
+ *
63
+ * @param signature - Contract hook signature for this hook name
64
+ * @param outputs - Returned output values
65
+ * @returns Error message if validation fails, null if valid
66
+ */
67
+ export function validateHookOutputs(
68
+ signature: ContractHookSignature,
69
+ outputs: Record<string, unknown>,
70
+ ): string | null {
71
+ const required = Object.entries(signature.outputs)
72
+ .filter(([, def]) => def.required)
73
+ .map(([name]) => name);
74
+
75
+ const missing = required.filter((name) => !(name in outputs));
76
+ if (missing.length > 0) {
77
+ return `Hook did not return required outputs: ${missing.join(', ')}`;
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Resolve the absolute path to a hook script
85
+ *
86
+ * Policy function (Rule 10.1) - validates path
87
+ *
88
+ * @param modulePath - Absolute path to module directory
89
+ * @param scriptPath - Relative script path from manifest
90
+ * @returns Resolved absolute path
91
+ */
92
+ export function resolveHookScript(modulePath: string, scriptPath: string): string {
93
+ const resolved = resolve(modulePath, scriptPath);
94
+
95
+ // Security: ensure resolved path is within module directory
96
+ const normalizedModule = resolve(modulePath);
97
+ if (!resolved.startsWith(normalizedModule)) {
98
+ throw new Error(`Hook script path escapes module directory: ${scriptPath}`);
99
+ }
100
+
101
+ return resolved;
102
+ }
103
+
104
+ /**
105
+ * Execute a hook script
106
+ *
107
+ * Execution function (Rule 10.1) - performs side effects
108
+ *
109
+ * @param scriptPath - Absolute path to the hook script
110
+ * @param context - Hook context with config, secrets, logger, and inputs
111
+ * @param timeoutMs - Total timeout in milliseconds
112
+ * @returns Hook result with outputs
113
+ */
114
+ export async function executeHookScript(
115
+ scriptPath: string,
116
+ context: HookContext,
117
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
118
+ ): Promise<Record<string, unknown>> {
119
+ if (!existsSync(scriptPath)) {
120
+ throw new Error(`Hook script not found: ${scriptPath}`);
121
+ }
122
+
123
+ // Create an idle timeout tracker
124
+ let lastActivity = Date.now();
125
+ const originalLogger = context.logger;
126
+
127
+ // Wrap logger to track activity for idle timeout
128
+ const trackedLogger: HookLogger = {
129
+ info(message: string) {
130
+ lastActivity = Date.now();
131
+ originalLogger.info(message);
132
+ },
133
+ warn(message: string) {
134
+ lastActivity = Date.now();
135
+ originalLogger.warn(message);
136
+ },
137
+ error(message: string) {
138
+ lastActivity = Date.now();
139
+ originalLogger.error(message);
140
+ },
141
+ success(message: string) {
142
+ lastActivity = Date.now();
143
+ originalLogger.success(message);
144
+ },
145
+ };
146
+
147
+ const trackedContext: HookContext = {
148
+ ...context,
149
+ logger: trackedLogger,
150
+ };
151
+
152
+ // Execute with timeout — disable idle timeout in debug mode
153
+ const promises: Promise<Record<string, unknown>>[] = [
154
+ runScript(scriptPath, trackedContext),
155
+ createTimeoutPromise(timeoutMs, 'total'),
156
+ ];
157
+ if (!context.debug) {
158
+ promises.push(createIdleTimeoutPromise(() => Date.now() - lastActivity, IDLE_TIMEOUT_MS));
159
+ }
160
+ const result = await Promise.race(promises);
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Run a hook script by dynamically importing it.
167
+ *
168
+ * HOOK_API_V2 Phase 8 (D8): the executor only accepts hook scripts that
169
+ * use `defineHook` from `@celilo/capabilities`. The compiled output
170
+ * carries a brand symbol (`CELILO_HOOK_BRAND`) that this function
171
+ * checks via `isCompiledHook`. Raw `async function(context)` exports are
172
+ * rejected with a clear error pointing at the migration guide — no more
173
+ * silent failures from forgotten brand registration.
174
+ *
175
+ * @param scriptPath - Absolute path to script
176
+ * @param context - Hook context
177
+ * @returns Script outputs
178
+ */
179
+ async function runScript(
180
+ scriptPath: string,
181
+ context: HookContext,
182
+ ): Promise<Record<string, unknown>> {
183
+ const module = await import(scriptPath);
184
+
185
+ if (typeof module.default !== 'function') {
186
+ throw new Error(`Hook script must export a default function: ${scriptPath}`);
187
+ }
188
+
189
+ if (!isCompiledHook(module.default)) {
190
+ throw new Error(
191
+ `Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See design/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
192
+ );
193
+ }
194
+
195
+ const result = await module.default(context);
196
+
197
+ if (result === null || result === undefined) {
198
+ return {};
199
+ }
200
+
201
+ if (typeof result !== 'object' || Array.isArray(result)) {
202
+ throw new Error('Hook script must return an object (or nothing)');
203
+ }
204
+
205
+ return result as Record<string, unknown>;
206
+ }
207
+
208
+ /**
209
+ * Create a promise that rejects after a total timeout
210
+ */
211
+ function createTimeoutPromise(timeoutMs: number, type: string): Promise<never> {
212
+ return new Promise((_, reject) => {
213
+ setTimeout(() => {
214
+ reject(new Error(`Hook ${type} timeout exceeded (${Math.round(timeoutMs / 1000)}s)`));
215
+ }, timeoutMs);
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Create a promise that rejects when idle timeout is exceeded
221
+ * Polls every 5 seconds to check idle duration
222
+ */
223
+ function createIdleTimeoutPromise(
224
+ getIdleDuration: () => number,
225
+ idleTimeoutMs: number,
226
+ ): Promise<never> {
227
+ return new Promise((_, reject) => {
228
+ const interval = setInterval(() => {
229
+ if (getIdleDuration() >= idleTimeoutMs) {
230
+ clearInterval(interval);
231
+ reject(
232
+ new Error(
233
+ `Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
234
+ ),
235
+ );
236
+ }
237
+ }, 5000);
238
+
239
+ // Don't block process exit
240
+ if (interval.unref) {
241
+ interval.unref();
242
+ }
243
+ });
244
+ }
245
+
246
+ export interface InvokeHookOptions {
247
+ debug?: boolean;
248
+ /** Pre-loaded capability function interfaces to inject into context */
249
+ capabilities?: Record<string, unknown>;
250
+ /**
251
+ * Names of capabilities the hook's module declares in
252
+ * `requires.capabilities`. The executor checks each one is present in
253
+ * `capabilities` before invoking the hook handler and fails fast with a
254
+ * clear error if any are missing. Added in HOOK_API_V2 Phase 3 (D3).
255
+ *
256
+ * Optional for backwards compatibility — callers that don't pass this
257
+ * skip the pre-flight check, matching the pre-Phase-3 behavior.
258
+ */
259
+ requiredCapabilities?: string[];
260
+ }
261
+
262
+ /**
263
+ * Verify every required capability is present in the loaded capability map.
264
+ *
265
+ * Policy function (Rule 10.1) — pure check, no side effects.
266
+ *
267
+ * @returns Error message listing missing capabilities, or null if all present
268
+ */
269
+ export function checkRequiredCapabilities(
270
+ hookName: string,
271
+ requiredCapabilities: string[],
272
+ loadedCapabilities: Record<string, unknown>,
273
+ ): string | null {
274
+ const missing = requiredCapabilities.filter((name) => !(name in loadedCapabilities));
275
+ if (missing.length === 0) return null;
276
+
277
+ const lines = [
278
+ `Hook '${hookName}' requires capability ${missing.length === 1 ? '' : 'capabilities '}${missing.map((n) => `'${n}'`).join(', ')} but no provider${missing.length === 1 ? ' is' : 's are'} loaded.`,
279
+ `Install a module that provides ${missing.length === 1 ? missing[0] : 'each missing capability'} and retry.`,
280
+ ];
281
+ return lines.join(' ');
282
+ }
283
+
284
+ /**
285
+ * Find the most recently created screenshot in a directory
286
+ *
287
+ * @param dir - Directory to scan
288
+ * @param since - Only consider files created after this timestamp (ms)
289
+ * @returns Path to screenshot, or undefined
290
+ */
291
+ function findRecentScreenshot(dir: string, since: number): string | undefined {
292
+ if (!existsSync(dir)) return undefined;
293
+
294
+ try {
295
+ const files = readdirSync(dir).filter((f) => f.endsWith('.png'));
296
+ for (const file of files) {
297
+ const fullPath = join(dir, file);
298
+ const stat = statSync(fullPath);
299
+ if (stat.mtimeMs >= since) {
300
+ return fullPath;
301
+ }
302
+ }
303
+ } catch {
304
+ // Best effort — don't mask the original error
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ /**
310
+ * Execute a named hook for a module
311
+ *
312
+ * Orchestration function - coordinates validation, resolution, and execution.
313
+ *
314
+ * Resolves the hook's inputs/outputs signature from the contract registry
315
+ * keyed by `contractVersion`, validates the provided inputs against it,
316
+ * runs the script, and validates the returned outputs.
317
+ *
318
+ * @param modulePath - Absolute path to module directory
319
+ * @param hookName - Name of the hook to execute
320
+ * @param contractVersion - Celilo contract version from `manifest.celilo_contract`
321
+ * @param definition - Hook definition from manifest (script + timeout only)
322
+ * @param inputs - Input values for the hook
323
+ * @param config - Module configuration values
324
+ * @param secrets - Module secret values (decrypted)
325
+ * @param logger - Logger instance
326
+ * @param options - Debug and other options
327
+ * @returns Hook result
328
+ */
329
+ export async function invokeHook(
330
+ modulePath: string,
331
+ hookName: string,
332
+ contractVersion: string,
333
+ definition: HookDefinition,
334
+ inputs: Record<string, unknown>,
335
+ config: Record<string, unknown>,
336
+ secrets: Record<string, string>,
337
+ logger: HookLogger,
338
+ options: InvokeHookOptions = {},
339
+ ): Promise<HookResult> {
340
+ const startTime = Date.now();
341
+ const debug = options.debug ?? false;
342
+
343
+ // Resolve the hook's contract signature
344
+ const contract = resolveContract(contractVersion);
345
+ if (!contract) {
346
+ return {
347
+ success: false,
348
+ outputs: {},
349
+ error: `Unsupported celilo_contract version '${contractVersion}'. Supported: ${supportedContractVersions().join(', ')}`,
350
+ duration: Date.now() - startTime,
351
+ };
352
+ }
353
+
354
+ const signature = contract.hooks[hookName];
355
+ if (!signature) {
356
+ return {
357
+ success: false,
358
+ outputs: {},
359
+ error: `Hook '${hookName}' is not part of celilo_contract ${contractVersion}`,
360
+ duration: Date.now() - startTime,
361
+ };
362
+ }
363
+
364
+ // Validate inputs against the contract signature
365
+ const inputError = validateHookInputs(signature, inputs);
366
+ if (inputError) {
367
+ return {
368
+ success: false,
369
+ outputs: {},
370
+ error: inputError,
371
+ duration: Date.now() - startTime,
372
+ };
373
+ }
374
+
375
+ // Resolve script path
376
+ let scriptPath: string;
377
+ try {
378
+ scriptPath = resolveHookScript(modulePath, definition.script);
379
+ } catch (error) {
380
+ return {
381
+ success: false,
382
+ outputs: {},
383
+ error: error instanceof Error ? error.message : String(error),
384
+ duration: Date.now() - startTime,
385
+ };
386
+ }
387
+
388
+ // Prepare screenshot directory
389
+ const screenshotDir = join(modulePath, 'screenshots');
390
+ mkdirSync(screenshotDir, { recursive: true });
391
+
392
+ // Build context
393
+ const loadedCapabilities = options.capabilities ?? {};
394
+ const context: HookContext = {
395
+ ...inputs,
396
+ config,
397
+ secrets,
398
+ logger,
399
+ debug,
400
+ screenshotDir,
401
+ capabilities: loadedCapabilities,
402
+ };
403
+
404
+ // Pre-flight: every required capability must be loaded (HOOK_API_V2 D3).
405
+ // Fail before invoking the handler so the script never sees a missing
406
+ // capability. Skipped if the caller didn't pass requiredCapabilities,
407
+ // for backwards compatibility with pre-Phase-3 invocation sites.
408
+ if (options.requiredCapabilities && options.requiredCapabilities.length > 0) {
409
+ const capError = checkRequiredCapabilities(
410
+ hookName,
411
+ options.requiredCapabilities,
412
+ loadedCapabilities,
413
+ );
414
+ if (capError) {
415
+ return {
416
+ success: false,
417
+ outputs: {},
418
+ error: capError,
419
+ duration: Date.now() - startTime,
420
+ };
421
+ }
422
+ }
423
+
424
+ // In debug mode, use much longer timeouts for interactive debugging
425
+ const timeoutMs = debug
426
+ ? 600_000 // 10 minutes
427
+ : (definition.timeout ?? DEFAULT_TIMEOUT_MS);
428
+
429
+ // Execute
430
+ try {
431
+ logger.info(`Executing hook: ${hookName}`);
432
+ const outputs = await executeHookScript(scriptPath, context, timeoutMs);
433
+
434
+ // Validate outputs against the contract signature
435
+ const outputError = validateHookOutputs(signature, outputs);
436
+ if (outputError) {
437
+ return {
438
+ success: false,
439
+ outputs,
440
+ error: outputError,
441
+ duration: Date.now() - startTime,
442
+ };
443
+ }
444
+
445
+ logger.success(`Hook ${hookName} completed successfully`);
446
+ return {
447
+ success: true,
448
+ outputs,
449
+ duration: Date.now() - startTime,
450
+ };
451
+ } catch (error) {
452
+ const message = error instanceof Error ? error.message : String(error);
453
+ logger.error(`Hook ${hookName} failed: ${message}`);
454
+
455
+ // Check for screenshots captured by the hook
456
+ const screenshotPath = findRecentScreenshot(screenshotDir, startTime);
457
+ if (screenshotPath) {
458
+ logger.info(`Screenshot saved: ${screenshotPath}`);
459
+ }
460
+
461
+ return {
462
+ success: false,
463
+ outputs: {},
464
+ error: message,
465
+ screenshotPath,
466
+ duration: Date.now() - startTime,
467
+ };
468
+ }
469
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createCapturingLogger, createConsoleLogger } from './logger';
3
+
4
+ describe('Hook Logger', () => {
5
+ describe('createCapturingLogger', () => {
6
+ test('captures info messages', () => {
7
+ const { logger, messages } = createCapturingLogger();
8
+ logger.info('test message');
9
+ expect(messages).toEqual([{ level: 'info', message: 'test message' }]);
10
+ });
11
+
12
+ test('captures warn messages', () => {
13
+ const { logger, messages } = createCapturingLogger();
14
+ logger.warn('warning message');
15
+ expect(messages).toEqual([{ level: 'warn', message: 'warning message' }]);
16
+ });
17
+
18
+ test('captures error messages', () => {
19
+ const { logger, messages } = createCapturingLogger();
20
+ logger.error('error message');
21
+ expect(messages).toEqual([{ level: 'error', message: 'error message' }]);
22
+ });
23
+
24
+ test('captures success messages', () => {
25
+ const { logger, messages } = createCapturingLogger();
26
+ logger.success('success message');
27
+ expect(messages).toEqual([{ level: 'success', message: 'success message' }]);
28
+ });
29
+
30
+ test('captures messages in order', () => {
31
+ const { logger, messages } = createCapturingLogger();
32
+ logger.info('first');
33
+ logger.warn('second');
34
+ logger.error('third');
35
+ logger.success('fourth');
36
+
37
+ expect(messages).toHaveLength(4);
38
+ expect(messages[0]).toEqual({ level: 'info', message: 'first' });
39
+ expect(messages[1]).toEqual({ level: 'warn', message: 'second' });
40
+ expect(messages[2]).toEqual({ level: 'error', message: 'third' });
41
+ expect(messages[3]).toEqual({ level: 'success', message: 'fourth' });
42
+ });
43
+ });
44
+
45
+ describe('createConsoleLogger', () => {
46
+ test('creates a logger with all methods', () => {
47
+ const logger = createConsoleLogger('test-module', 'container_created');
48
+ expect(typeof logger.info).toBe('function');
49
+ expect(typeof logger.warn).toBe('function');
50
+ expect(typeof logger.error).toBe('function');
51
+ expect(typeof logger.success).toBe('function');
52
+ });
53
+ });
54
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Hook Logger
3
+ *
4
+ * Provides a structured logger for hook scripts to report progress
5
+ * back to the CLI. Integrates with FuelGauge for visual feedback.
6
+ */
7
+
8
+ import type { FuelGauge } from '../cli/fuel-gauge';
9
+ import type { HookLogger } from './types';
10
+
11
+ /**
12
+ * Create a hook logger that outputs to a FuelGauge progress indicator
13
+ *
14
+ * @param gauge - FuelGauge instance for visual output
15
+ * @param moduleId - Module ID for log prefix
16
+ * @param hookName - Hook name for log prefix
17
+ * @returns HookLogger instance
18
+ */
19
+ export function createGaugeLogger(
20
+ gauge: FuelGauge,
21
+ moduleId: string,
22
+ hookName: string,
23
+ ): HookLogger {
24
+ const prefix = `[${moduleId}:${hookName}]`;
25
+
26
+ return {
27
+ info(message: string) {
28
+ gauge.addOutput(`${prefix} ${message}`);
29
+ },
30
+ warn(message: string) {
31
+ gauge.addOutput(`${prefix} ⚠ ${message}`);
32
+ },
33
+ error(message: string) {
34
+ gauge.addOutput(`${prefix} ✗ ${message}`);
35
+ },
36
+ success(message: string) {
37
+ gauge.addOutput(`${prefix} ✓ ${message}`);
38
+ },
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Create a hook logger that outputs to console (for testing/debugging)
44
+ *
45
+ * @param moduleId - Module ID for log prefix
46
+ * @param hookName - Hook name for log prefix
47
+ * @returns HookLogger instance
48
+ */
49
+ export function createConsoleLogger(moduleId: string, hookName: string): HookLogger {
50
+ const prefix = `[${moduleId}:${hookName}]`;
51
+
52
+ return {
53
+ info(message: string) {
54
+ console.log(`${prefix} ${message}`);
55
+ },
56
+ warn(message: string) {
57
+ console.warn(`${prefix} ⚠ ${message}`);
58
+ },
59
+ error(message: string) {
60
+ console.error(`${prefix} ✗ ${message}`);
61
+ },
62
+ success(message: string) {
63
+ console.log(`${prefix} ✓ ${message}`);
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Create a hook logger that captures messages for testing
70
+ *
71
+ * @returns Object with logger and captured messages array
72
+ */
73
+ export function createCapturingLogger(): {
74
+ logger: HookLogger;
75
+ messages: Array<{ level: string; message: string }>;
76
+ } {
77
+ const messages: Array<{ level: string; message: string }> = [];
78
+
79
+ const logger: HookLogger = {
80
+ info(message: string) {
81
+ messages.push({ level: 'info', message });
82
+ },
83
+ warn(message: string) {
84
+ messages.push({ level: 'warn', message });
85
+ },
86
+ error(message: string) {
87
+ messages.push({ level: 'error', message });
88
+ },
89
+ success(message: string) {
90
+ messages.push({ level: 'success', message });
91
+ },
92
+ };
93
+
94
+ return { logger, messages };
95
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Test fixture: hook script that throws an error. Migrated to
3
+ * defineHook in HOOK_API_V2 Phase 8.
4
+ */
5
+
6
+ import { defineHook } from '@celilo/capabilities';
7
+
8
+ export default defineHook({
9
+ requires: [],
10
+ handler: async () => {
11
+ throw new Error('Hook execution failed: simulated error');
12
+ },
13
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Test fixture: hook script without default export
3
+ */
4
+ export function notDefault() {
5
+ return { result: 'wrong export' };
6
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Test fixture: successful hook script. Migrated to defineHook in
3
+ * HOOK_API_V2 Phase 8 — the executor now requires the brand.
4
+ *
5
+ * Uses `hook: 'container_created'` because that hook's contract output
6
+ * is `SecretCapturingHookOutput` (Record<string, unknown> | void), so
7
+ * returning an `api_key` field is allowed.
8
+ */
9
+
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'container_created',
14
+ requires: [],
15
+ handler: async (ctx) => {
16
+ ctx.logger.info('Starting test hook');
17
+ ctx.logger.success('Test hook completed');
18
+ return { api_key: `test-key-for-${(ctx.vps_ip as string | undefined) ?? 'unknown'}` };
19
+ },
20
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Test fixture: a default-exported function that does NOT use defineHook.
3
+ *
4
+ * The executor's brand check (HOOK_API_V2 Phase 8 / D8) must reject
5
+ * this with a clear error pointing at the migration guide. Used by
6
+ * executor.test.ts to verify the brand-enforcement code path.
7
+ */
8
+
9
+ export default async function unbrandedHook() {
10
+ return { ok: true };
11
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Test fixture: hook script that returns nothing. Migrated to
3
+ * defineHook in HOOK_API_V2 Phase 8.
4
+ */
5
+
6
+ import { defineHook } from '@celilo/capabilities';
7
+
8
+ export default defineHook({
9
+ requires: [],
10
+ handler: async (ctx) => {
11
+ ctx.logger.info('Void hook ran');
12
+ },
13
+ });