@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,1180 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ validateCapabilityNames,
4
+ validateCapabilityRequirements,
5
+ validateDeriveFromSources,
6
+ validateHookContract,
7
+ validateManifest,
8
+ validateProvidesNoCrossCapabilityRefs,
9
+ validateVariableSources,
10
+ } from './validate';
11
+
12
+ const CONTRACT_LINE = 'celilo_contract: "1.0"';
13
+
14
+ describe('validateManifest', () => {
15
+ test('should validate minimal valid manifest', () => {
16
+ const yaml = `
17
+ ${CONTRACT_LINE}
18
+ id: homebridge
19
+ name: Homebridge
20
+ version: 1.0.0
21
+ `;
22
+
23
+ const result = validateManifest(yaml);
24
+
25
+ expect(result.success).toBe(true);
26
+ if (result.success) {
27
+ expect(result.data.id).toBe('homebridge');
28
+ expect(result.data.name).toBe('Homebridge');
29
+ expect(result.data.version).toBe('1.0.0');
30
+ expect(result.data.requires.capabilities).toEqual([]);
31
+ expect(result.data.provides.capabilities).toEqual([]);
32
+ }
33
+ });
34
+
35
+ test('should validate complete homebridge manifest', () => {
36
+ const yaml = `
37
+ ${CONTRACT_LINE}
38
+ id: homebridge
39
+ name: Homebridge
40
+ version: 1.0.0
41
+ description: HomeKit bridge for smart home devices
42
+
43
+ requires:
44
+ capabilities: []
45
+ machine:
46
+ cpu: 1
47
+ memory: 1024
48
+ disk: 20
49
+ zone: app
50
+
51
+ provides:
52
+ capabilities: []
53
+
54
+ variables:
55
+ owns:
56
+ - name: container_ip
57
+ type: string
58
+ required: true
59
+ description: IP address for Homebridge container
60
+ source: user
61
+ - name: homebridge_pin
62
+ type: string
63
+ required: true
64
+ description: HomeKit PIN code
65
+ source: user
66
+ `;
67
+
68
+ const result = validateManifest(yaml);
69
+
70
+ expect(result.success).toBe(true);
71
+ if (result.success) {
72
+ expect(result.data.id).toBe('homebridge');
73
+ expect(result.data.variables.owns).toHaveLength(2);
74
+ expect(result.data.requires.machine?.cpu).toBe(1);
75
+ expect(result.data.requires.machine?.zone).toBe('app');
76
+ }
77
+ });
78
+
79
+ test('should validate dns-external manifest with capability provider', () => {
80
+ const yaml = `
81
+ ${CONTRACT_LINE}
82
+ id: dns-external
83
+ name: DNS External (Knot)
84
+ version: 1.0.0
85
+
86
+ requires:
87
+ capabilities: []
88
+
89
+ provides:
90
+ capabilities:
91
+ - name: dns_external
92
+ version: 1.0.0
93
+ data:
94
+ nameserver: "$self:container_ip"
95
+ zone: "$self:zone"
96
+ api_endpoint: "http://$self:container_ip:8053"
97
+
98
+ variables:
99
+ owns:
100
+ - name: zone
101
+ type: string
102
+ required: true
103
+ source: user
104
+ - name: container_ip
105
+ type: string
106
+ required: true
107
+ source: user
108
+ `;
109
+
110
+ const result = validateManifest(yaml);
111
+
112
+ expect(result.success).toBe(true);
113
+ if (result.success) {
114
+ expect(result.data.provides.capabilities).toHaveLength(1);
115
+ expect(result.data.provides.capabilities[0]?.name).toBe('dns_external');
116
+ expect(result.data.provides.capabilities[0]?.data).toHaveProperty('nameserver');
117
+ }
118
+ });
119
+
120
+ test('should validate caddy manifest with capability consumer', () => {
121
+ const yaml = `
122
+ ${CONTRACT_LINE}
123
+ id: caddy
124
+ name: Caddy Web Server
125
+ version: 1.0.0
126
+
127
+ requires:
128
+ capabilities:
129
+ - name: dns_external
130
+ version: "^1.0.0"
131
+
132
+ provides:
133
+ capabilities:
134
+ - name: web
135
+ version: 1.0.0
136
+ data:
137
+ reverse_proxy_host: "$self:container_ip"
138
+
139
+ variables:
140
+ owns:
141
+ - name: container_ip
142
+ type: string
143
+ required: true
144
+ source: user
145
+ imports:
146
+ - name: dns_nameserver
147
+ source: capability
148
+ from: dns_external.nameserver
149
+ - name: dns_zone
150
+ source: capability
151
+ from: dns_external.zone
152
+ `;
153
+
154
+ const result = validateManifest(yaml);
155
+
156
+ expect(result.success).toBe(true);
157
+ if (result.success) {
158
+ expect(result.data.requires.capabilities).toHaveLength(1);
159
+ expect(result.data.requires.capabilities[0]?.name).toBe('dns_external');
160
+ expect(result.data.variables.imports).toHaveLength(2);
161
+ expect(result.data.variables.imports[0]?.from).toBe('dns_external.nameserver');
162
+ }
163
+ });
164
+
165
+ test('should reject empty manifest', () => {
166
+ const result = validateManifest('');
167
+
168
+ expect(result.success).toBe(false);
169
+ if (!result.success) {
170
+ expect(result.errors[0]?.message).toContain('cannot be empty');
171
+ }
172
+ });
173
+
174
+ test('should reject manifest missing celilo_contract', () => {
175
+ const yaml = `
176
+ id: test-module
177
+ name: Test
178
+ version: 1.0.0
179
+ `;
180
+ const result = validateManifest(yaml);
181
+ expect(result.success).toBe(false);
182
+ if (!result.success) {
183
+ expect(result.errors.some((e) => e.path === 'celilo_contract')).toBe(true);
184
+ }
185
+ });
186
+
187
+ test('should reject manifest with unsupported celilo_contract version', () => {
188
+ const yaml = `
189
+ celilo_contract: "9.9"
190
+ id: test-module
191
+ name: Test
192
+ version: 1.0.0
193
+ `;
194
+ const result = validateManifest(yaml);
195
+ expect(result.success).toBe(false);
196
+ });
197
+
198
+ test('should reject manifest with unknown top-level key', () => {
199
+ const yaml = `
200
+ ${CONTRACT_LINE}
201
+ id: test
202
+ name: Test
203
+ version: 1.0.0
204
+ poop: 12
205
+ `;
206
+ const result = validateManifest(yaml);
207
+ expect(result.success).toBe(false);
208
+ if (!result.success) {
209
+ expect(result.errors.some((e) => e.message.toLowerCase().includes('unrecognized'))).toBe(
210
+ true,
211
+ );
212
+ }
213
+ });
214
+
215
+ test('should reject manifest with unknown hook name', () => {
216
+ const yaml = `
217
+ ${CONTRACT_LINE}
218
+ id: test
219
+ name: Test
220
+ version: 1.0.0
221
+
222
+ hooks:
223
+ on_poop:
224
+ script: ./scripts/poop.ts
225
+ `;
226
+ const result = validateManifest(yaml);
227
+ expect(result.success).toBe(false);
228
+ if (!result.success) {
229
+ expect(result.errors.some((e) => e.message.toLowerCase().includes('unrecognized'))).toBe(
230
+ true,
231
+ );
232
+ }
233
+ });
234
+
235
+ test('should reject manifest with missing id', () => {
236
+ const yaml = `
237
+ ${CONTRACT_LINE}
238
+ name: Test Module
239
+ version: 1.0.0
240
+ `;
241
+
242
+ const result = validateManifest(yaml);
243
+
244
+ expect(result.success).toBe(false);
245
+ if (!result.success) {
246
+ expect(result.errors.some((e) => e.path === 'id')).toBe(true);
247
+ }
248
+ });
249
+
250
+ test('should reject manifest with invalid id format', () => {
251
+ const yaml = `
252
+ ${CONTRACT_LINE}
253
+ id: Invalid_ID_With_Caps
254
+ name: Test Module
255
+ version: 1.0.0
256
+ `;
257
+
258
+ const result = validateManifest(yaml);
259
+
260
+ expect(result.success).toBe(false);
261
+ if (!result.success) {
262
+ expect(result.errors.some((e) => e.path === 'id')).toBe(true);
263
+ expect(result.errors.some((e) => e.message.includes('lowercase'))).toBe(true);
264
+ }
265
+ });
266
+
267
+ test('should reject manifest with invalid version format', () => {
268
+ const yaml = `
269
+ ${CONTRACT_LINE}
270
+ id: test-module
271
+ name: Test Module
272
+ version: 1.0
273
+ `;
274
+
275
+ const result = validateManifest(yaml);
276
+
277
+ expect(result.success).toBe(false);
278
+ if (!result.success) {
279
+ const versionError = result.errors.find((e) => e.path === 'version');
280
+ expect(versionError).toBeDefined();
281
+ expect(versionError?.message).toBeTruthy();
282
+ }
283
+ });
284
+
285
+ test('should reject malformed YAML', () => {
286
+ const yaml = `
287
+ ${CONTRACT_LINE}
288
+ id: test
289
+ name: Test
290
+ version: 1.0.0
291
+ bad_yaml: [unclosed array
292
+ `;
293
+
294
+ const result = validateManifest(yaml);
295
+
296
+ expect(result.success).toBe(false);
297
+ });
298
+
299
+ test('should validate manifest with lifecycle hooks', () => {
300
+ const yaml = `
301
+ ${CONTRACT_LINE}
302
+ id: test-module
303
+ name: Test Module
304
+ version: 1.0.0
305
+
306
+ hooks:
307
+ on_install:
308
+ script: ./scripts/install.sh
309
+ timeout: 300
310
+ on_uninstall:
311
+ script: ./scripts/uninstall.sh
312
+ health_check:
313
+ script: ./scripts/health.sh
314
+ timeout: 60
315
+ `;
316
+
317
+ const result = validateManifest(yaml);
318
+
319
+ expect(result.success).toBe(true);
320
+ if (result.success) {
321
+ expect(result.data.hooks?.on_install?.script).toBe('./scripts/install.sh');
322
+ expect(result.data.hooks?.on_install?.timeout).toBe(300);
323
+ expect(result.data.hooks?.health_check?.script).toBe('./scripts/health.sh');
324
+ }
325
+ });
326
+
327
+ test('should validate manifest with build script', () => {
328
+ const yaml = `
329
+ ${CONTRACT_LINE}
330
+ id: caddy-custom
331
+ name: Custom Caddy Build
332
+ version: 1.0.0
333
+
334
+ build:
335
+ script: ./build.sh
336
+ artifacts:
337
+ - caddy
338
+ - caddy.service
339
+ `;
340
+
341
+ const result = validateManifest(yaml);
342
+
343
+ expect(result.success).toBe(true);
344
+ if (result.success) {
345
+ expect(result.data.build?.script).toBe('./build.sh');
346
+ expect(result.data.build?.artifacts).toHaveLength(2);
347
+ }
348
+ });
349
+
350
+ test('should reject build artifacts with absolute paths', () => {
351
+ const yaml = `
352
+ ${CONTRACT_LINE}
353
+ id: test-module
354
+ name: Test
355
+ version: 1.0.0
356
+
357
+ build:
358
+ script: build.sh
359
+ artifacts:
360
+ - /usr/local/bin/binary
361
+ `;
362
+
363
+ const result = validateManifest(yaml);
364
+ expect(result.success).toBe(false);
365
+ });
366
+
367
+ test('should reject build artifacts with tilde paths', () => {
368
+ const yaml = `
369
+ ${CONTRACT_LINE}
370
+ id: test-module
371
+ name: Test
372
+ version: 1.0.0
373
+
374
+ build:
375
+ script: build.sh
376
+ artifacts:
377
+ - ~/build/binary
378
+ `;
379
+
380
+ const result = validateManifest(yaml);
381
+ expect(result.success).toBe(false);
382
+ });
383
+
384
+ test('should reject build artifacts with path traversal', () => {
385
+ const yaml = `
386
+ ${CONTRACT_LINE}
387
+ id: test-module
388
+ name: Test
389
+ version: 1.0.0
390
+
391
+ build:
392
+ script: build.sh
393
+ artifacts:
394
+ - ../../../etc/passwd
395
+ `;
396
+
397
+ const result = validateManifest(yaml);
398
+ expect(result.success).toBe(false);
399
+ });
400
+
401
+ test('should reject build artifacts with leading ./', () => {
402
+ const yaml = `
403
+ ${CONTRACT_LINE}
404
+ id: test-module
405
+ name: Test
406
+ version: 1.0.0
407
+
408
+ build:
409
+ script: build.sh
410
+ artifacts:
411
+ - ./build/binary
412
+ `;
413
+
414
+ const result = validateManifest(yaml);
415
+ expect(result.success).toBe(false);
416
+ });
417
+
418
+ test('should accept valid relative build artifact paths', () => {
419
+ const yaml = `
420
+ ${CONTRACT_LINE}
421
+ id: test-module
422
+ name: Test
423
+ version: 1.0.0
424
+
425
+ build:
426
+ script: build.sh
427
+ artifacts:
428
+ - build/binary
429
+ - output/compiled.bin
430
+ - dist/package.tar.gz
431
+ `;
432
+
433
+ const result = validateManifest(yaml);
434
+
435
+ expect(result.success).toBe(true);
436
+ if (result.success) {
437
+ expect(result.data.build?.artifacts).toEqual([
438
+ 'build/binary',
439
+ 'output/compiled.bin',
440
+ 'dist/package.tar.gz',
441
+ ]);
442
+ }
443
+ });
444
+
445
+ test('should validate manifest with VM resource recommendations under requires.machine', () => {
446
+ const yaml = `
447
+ ${CONTRACT_LINE}
448
+ id: grafana
449
+ name: Grafana
450
+ version: 1.0.0
451
+
452
+ requires:
453
+ capabilities: []
454
+ machine:
455
+ cpu: 2
456
+ memory: 2048
457
+ disk: 20
458
+ storage: local-lvm
459
+ zone: app
460
+ `;
461
+
462
+ const result = validateManifest(yaml);
463
+
464
+ expect(result.success).toBe(true);
465
+ if (result.success) {
466
+ expect(result.data.requires.machine?.cpu).toBe(2);
467
+ expect(result.data.requires.machine?.memory).toBe(2048);
468
+ expect(result.data.requires.machine?.disk).toBe(20);
469
+ expect(result.data.requires.machine?.storage).toBe('local-lvm');
470
+ expect(result.data.requires.machine?.zone).toBe('app');
471
+ }
472
+ });
473
+
474
+ test('should validate manifest with minimal VM resources', () => {
475
+ const yaml = `
476
+ ${CONTRACT_LINE}
477
+ id: simple-service
478
+ name: Simple Service
479
+ version: 1.0.0
480
+
481
+ requires:
482
+ capabilities: []
483
+ machine:
484
+ zone: dmz
485
+ `;
486
+
487
+ const result = validateManifest(yaml);
488
+
489
+ expect(result.success).toBe(true);
490
+ if (result.success) {
491
+ expect(result.data.requires.machine?.zone).toBe('dmz');
492
+ expect(result.data.requires.machine?.cpu).toBeUndefined();
493
+ expect(result.data.requires.machine?.memory).toBeUndefined();
494
+ }
495
+ });
496
+
497
+ test('should reject negative CPU value', () => {
498
+ const yaml = `
499
+ ${CONTRACT_LINE}
500
+ id: bad-module
501
+ name: Bad Module
502
+ version: 1.0.0
503
+
504
+ requires:
505
+ capabilities: []
506
+ machine:
507
+ cpu: -2
508
+ zone: app
509
+ `;
510
+
511
+ const result = validateManifest(yaml);
512
+ expect(result.success).toBe(false);
513
+ });
514
+
515
+ test('should reject invalid zone value', () => {
516
+ const yaml = `
517
+ ${CONTRACT_LINE}
518
+ id: bad-module
519
+ name: Bad Module
520
+ version: 1.0.0
521
+
522
+ requires:
523
+ capabilities: []
524
+ machine:
525
+ zone: invalid
526
+ `;
527
+
528
+ const result = validateManifest(yaml);
529
+ expect(result.success).toBe(false);
530
+ });
531
+
532
+ test('should reject non-integer CPU value', () => {
533
+ const yaml = `
534
+ ${CONTRACT_LINE}
535
+ id: bad-module
536
+ name: Bad Module
537
+ version: 1.0.0
538
+
539
+ requires:
540
+ capabilities: []
541
+ machine:
542
+ cpu: 2.5
543
+ zone: app
544
+ `;
545
+
546
+ const result = validateManifest(yaml);
547
+ expect(result.success).toBe(false);
548
+ });
549
+ });
550
+
551
+ describe('optional.capabilities (HOOK_API_V2 D3)', () => {
552
+ test('parses through validateManifest as a top-level field', () => {
553
+ const yaml = `
554
+ ${CONTRACT_LINE}
555
+ id: caddy
556
+ name: Caddy
557
+ version: 1.0.0
558
+
559
+ requires:
560
+ capabilities:
561
+ - name: public_web
562
+ version: 1.0.0
563
+
564
+ optional:
565
+ capabilities:
566
+ - name: dns_registrar
567
+ version: 2.0.0
568
+ `;
569
+
570
+ const result = validateManifest(yaml);
571
+
572
+ expect(result.success).toBe(true);
573
+ if (result.success) {
574
+ expect(result.data.optional?.capabilities).toHaveLength(1);
575
+ expect(result.data.optional?.capabilities[0]?.name).toBe('dns_registrar');
576
+ expect(result.data.optional?.capabilities[0]?.version).toBe('2.0.0');
577
+ }
578
+ });
579
+
580
+ test('is undefined when not declared (truly optional at schema level)', () => {
581
+ const yaml = `
582
+ ${CONTRACT_LINE}
583
+ id: standalone
584
+ name: Standalone
585
+ version: 1.0.0
586
+ `;
587
+
588
+ const result = validateManifest(yaml);
589
+
590
+ expect(result.success).toBe(true);
591
+ if (result.success) {
592
+ expect(result.data.optional).toBeUndefined();
593
+ }
594
+ });
595
+ });
596
+
597
+ describe('validateCapabilityNames', () => {
598
+ test('accepts known capability names in requires', () => {
599
+ const manifest = {
600
+ celilo_contract: '1.0' as const,
601
+ id: 'lunacycle',
602
+ name: 'LunaCycle',
603
+ version: '1.0.0',
604
+ requires: {
605
+ capabilities: [
606
+ { name: 'public_web', version: '^1.0.0' },
607
+ { name: 'idp', version: '^1.0.0' },
608
+ ],
609
+ },
610
+ provides: { capabilities: [] },
611
+ variables: { owns: [], imports: [] },
612
+ };
613
+
614
+ expect(validateCapabilityNames(manifest)).toBeNull();
615
+ });
616
+
617
+ test('accepts known capability names in optional', () => {
618
+ const manifest = {
619
+ celilo_contract: '1.0' as const,
620
+ id: 'caddy',
621
+ name: 'Caddy',
622
+ version: '1.0.0',
623
+ requires: { capabilities: [] },
624
+ optional: {
625
+ capabilities: [{ name: 'dns_registrar', version: '^2.0.0' }],
626
+ },
627
+ provides: { capabilities: [] },
628
+ variables: { owns: [], imports: [] },
629
+ };
630
+
631
+ expect(validateCapabilityNames(manifest)).toBeNull();
632
+ });
633
+
634
+ test('rejects unknown capability name in requires', () => {
635
+ const manifest = {
636
+ celilo_contract: '1.0' as const,
637
+ id: 'broken',
638
+ name: 'Broken',
639
+ version: '1.0.0',
640
+ requires: {
641
+ capabilities: [{ name: 'dns_register', version: '^1.0.0' }], // typo
642
+ },
643
+ provides: { capabilities: [] },
644
+ variables: { owns: [], imports: [] },
645
+ };
646
+
647
+ const result = validateCapabilityNames(manifest);
648
+ expect(result).not.toBeNull();
649
+ if (result) {
650
+ expect(result.errors).toHaveLength(1);
651
+ expect(result.errors[0]?.path).toBe('requires.capabilities.dns_register');
652
+ expect(result.errors[0]?.message).toContain("Unknown capability 'dns_register'");
653
+ expect(result.errors[0]?.message).toContain('dns_registrar');
654
+ }
655
+ });
656
+
657
+ test('rejects unknown capability name in optional', () => {
658
+ const manifest = {
659
+ celilo_contract: '1.0' as const,
660
+ id: 'broken',
661
+ name: 'Broken',
662
+ version: '1.0.0',
663
+ requires: { capabilities: [] },
664
+ optional: {
665
+ capabilities: [{ name: 'monitoring', version: '^1.0.0' }],
666
+ },
667
+ provides: { capabilities: [] },
668
+ variables: { owns: [], imports: [] },
669
+ };
670
+
671
+ const result = validateCapabilityNames(manifest);
672
+ expect(result).not.toBeNull();
673
+ if (result) {
674
+ expect(result.errors).toHaveLength(1);
675
+ expect(result.errors[0]?.path).toBe('optional.capabilities.monitoring');
676
+ }
677
+ });
678
+
679
+ test('reports both required and optional unknowns in one pass', () => {
680
+ const manifest = {
681
+ celilo_contract: '1.0' as const,
682
+ id: 'broken',
683
+ name: 'Broken',
684
+ version: '1.0.0',
685
+ requires: {
686
+ capabilities: [{ name: 'bad_required', version: '^1.0.0' }],
687
+ },
688
+ optional: {
689
+ capabilities: [{ name: 'bad_optional', version: '^1.0.0' }],
690
+ },
691
+ provides: { capabilities: [] },
692
+ variables: { owns: [], imports: [] },
693
+ };
694
+
695
+ const result = validateCapabilityNames(manifest);
696
+ expect(result).not.toBeNull();
697
+ if (result) {
698
+ expect(result.errors).toHaveLength(2);
699
+ }
700
+ });
701
+
702
+ test('passes when no capabilities declared', () => {
703
+ const manifest = {
704
+ celilo_contract: '1.0' as const,
705
+ id: 'standalone',
706
+ name: 'Standalone',
707
+ version: '1.0.0',
708
+ requires: { capabilities: [] },
709
+ provides: { capabilities: [] },
710
+ variables: { owns: [], imports: [] },
711
+ };
712
+
713
+ expect(validateCapabilityNames(manifest)).toBeNull();
714
+ });
715
+ });
716
+
717
+ describe('validateCapabilityRequirements', () => {
718
+ test('should pass when all required capabilities are available', () => {
719
+ const manifest = {
720
+ celilo_contract: '1.0' as const,
721
+ id: 'caddy',
722
+ name: 'Caddy',
723
+ version: '1.0.0',
724
+ requires: {
725
+ capabilities: [{ name: 'dns_external', version: '^1.0.0' }],
726
+ },
727
+ provides: { capabilities: [] },
728
+ variables: { owns: [], imports: [] },
729
+ };
730
+
731
+ const result = validateCapabilityRequirements(manifest, ['dns_external', 'idp']);
732
+
733
+ expect(result).toBeNull();
734
+ });
735
+
736
+ test('should fail when required capability is missing', () => {
737
+ const manifest = {
738
+ celilo_contract: '1.0' as const,
739
+ id: 'caddy',
740
+ name: 'Caddy',
741
+ version: '1.0.0',
742
+ requires: {
743
+ capabilities: [
744
+ { name: 'dns_external', version: '^1.0.0' },
745
+ { name: 'idp', version: '^1.0.0' },
746
+ ],
747
+ },
748
+ provides: { capabilities: [] },
749
+ variables: { owns: [], imports: [] },
750
+ };
751
+
752
+ const result = validateCapabilityRequirements(manifest, ['dns_external']);
753
+
754
+ expect(result).not.toBeNull();
755
+ if (result) {
756
+ expect(result.errors).toHaveLength(1);
757
+ expect(result.errors[0]?.message).toContain('idp');
758
+ }
759
+ });
760
+
761
+ test('should pass when no capabilities required', () => {
762
+ const manifest = {
763
+ celilo_contract: '1.0' as const,
764
+ id: 'homebridge',
765
+ name: 'Homebridge',
766
+ version: '1.0.0',
767
+ requires: { capabilities: [] },
768
+ provides: { capabilities: [] },
769
+ variables: { owns: [], imports: [] },
770
+ };
771
+
772
+ const result = validateCapabilityRequirements(manifest, []);
773
+
774
+ expect(result).toBeNull();
775
+ });
776
+ });
777
+
778
+ describe('validateVariableSources', () => {
779
+ test('should pass when imported capability variables reference required capabilities', () => {
780
+ const manifest = {
781
+ celilo_contract: '1.0' as const,
782
+ id: 'caddy',
783
+ name: 'Caddy',
784
+ version: '1.0.0',
785
+ requires: {
786
+ capabilities: [{ name: 'dns_external', version: '^1.0.0' }],
787
+ },
788
+ provides: { capabilities: [] },
789
+ variables: {
790
+ owns: [],
791
+ imports: [
792
+ {
793
+ name: 'dns_nameserver',
794
+ source: 'capability' as const,
795
+ from: 'dns_external.nameserver',
796
+ },
797
+ { name: 'dns_zone', source: 'capability' as const, from: 'dns_external.zone' },
798
+ ],
799
+ },
800
+ };
801
+
802
+ const result = validateVariableSources(manifest);
803
+
804
+ expect(result).toBeNull();
805
+ });
806
+
807
+ test('should fail when imported capability variable references unrequired capability', () => {
808
+ const manifest = {
809
+ celilo_contract: '1.0' as const,
810
+ id: 'caddy',
811
+ name: 'Caddy',
812
+ version: '1.0.0',
813
+ requires: { capabilities: [] },
814
+ provides: { capabilities: [] },
815
+ variables: {
816
+ owns: [],
817
+ imports: [
818
+ {
819
+ name: 'dns_nameserver',
820
+ source: 'capability' as const,
821
+ from: 'dns_external.nameserver',
822
+ },
823
+ ],
824
+ },
825
+ };
826
+
827
+ const result = validateVariableSources(manifest);
828
+
829
+ expect(result).not.toBeNull();
830
+ if (result) {
831
+ expect(result.errors).toHaveLength(1);
832
+ expect(result.errors[0]?.message).toContain('does not declare it');
833
+ }
834
+ });
835
+
836
+ test('should pass when imported capability variable references an optional capability', () => {
837
+ const manifest = {
838
+ celilo_contract: '1.0' as const,
839
+ id: 'caddy',
840
+ name: 'Caddy',
841
+ version: '1.0.0',
842
+ requires: { capabilities: [] },
843
+ optional: {
844
+ capabilities: [{ name: 'dns_registrar', version: '^2.0.0' }],
845
+ },
846
+ provides: { capabilities: [] },
847
+ variables: {
848
+ owns: [],
849
+ imports: [
850
+ {
851
+ name: 'dns_nameserver',
852
+ source: 'capability' as const,
853
+ from: 'dns_registrar.nameserver',
854
+ },
855
+ ],
856
+ },
857
+ };
858
+
859
+ const result = validateVariableSources(manifest);
860
+
861
+ expect(result).toBeNull();
862
+ });
863
+
864
+ test('should fail when imported capability variable has invalid reference format', () => {
865
+ const manifest = {
866
+ celilo_contract: '1.0' as const,
867
+ id: 'test',
868
+ name: 'Test',
869
+ version: '1.0.0',
870
+ requires: { capabilities: [] },
871
+ provides: { capabilities: [] },
872
+ variables: {
873
+ owns: [],
874
+ imports: [{ name: 'bad_var', source: 'capability' as const, from: '' }],
875
+ },
876
+ };
877
+
878
+ const result = validateVariableSources(manifest);
879
+
880
+ expect(result).not.toBeNull();
881
+ if (result) {
882
+ expect(result.errors[0]?.message).toContain('invalid capability reference');
883
+ }
884
+ });
885
+
886
+ test('should pass when no variables import capabilities', () => {
887
+ const manifest = {
888
+ celilo_contract: '1.0' as const,
889
+ id: 'homebridge',
890
+ name: 'Homebridge',
891
+ version: '1.0.0',
892
+ requires: { capabilities: [] },
893
+ provides: { capabilities: [] },
894
+ variables: {
895
+ owns: [
896
+ {
897
+ name: 'container_ip',
898
+ type: 'string' as const,
899
+ required: true,
900
+ source: 'user' as const,
901
+ },
902
+ ],
903
+ imports: [],
904
+ },
905
+ };
906
+
907
+ const result = validateVariableSources(manifest);
908
+
909
+ expect(result).toBeNull();
910
+ });
911
+ });
912
+
913
+ describe('validateDeriveFromSources', () => {
914
+ test('should pass when capability source uses $capability: prefix', () => {
915
+ const manifest = {
916
+ celilo_contract: '1.0' as const,
917
+ id: 'lunacycle',
918
+ name: 'LunaCycle',
919
+ version: '1.0.0',
920
+ requires: { capabilities: [] },
921
+ provides: { capabilities: [] },
922
+ variables: {
923
+ owns: [
924
+ {
925
+ name: 'primary_domain',
926
+ type: 'string' as const,
927
+ required: false,
928
+ source: 'capability' as const,
929
+ derive_from: '$capability:dns_registrar.primary_domain',
930
+ },
931
+ ],
932
+ imports: [],
933
+ },
934
+ };
935
+
936
+ const result = validateDeriveFromSources(manifest);
937
+ expect(result).toBeNull();
938
+ });
939
+
940
+ test('should reject capability source with $system: prefix in derive_from (regression)', () => {
941
+ // This is the original lunacycle bug that motivated D7.
942
+ const manifest = {
943
+ celilo_contract: '1.0' as const,
944
+ id: 'lunacycle',
945
+ name: 'LunaCycle',
946
+ version: '1.0.0',
947
+ requires: { capabilities: [] },
948
+ provides: { capabilities: [] },
949
+ variables: {
950
+ owns: [
951
+ {
952
+ name: 'primary_domain',
953
+ type: 'string' as const,
954
+ required: false,
955
+ source: 'capability' as const,
956
+ derive_from: '$system:primary_domain',
957
+ },
958
+ ],
959
+ imports: [],
960
+ },
961
+ };
962
+
963
+ const result = validateDeriveFromSources(manifest);
964
+ expect(result).not.toBeNull();
965
+ if (result) {
966
+ expect(result.errors[0]?.path).toContain('primary_domain');
967
+ expect(result.errors[0]?.message).toContain('$system:');
968
+ }
969
+ });
970
+
971
+ test('should pass for variables without derive_from', () => {
972
+ const manifest = {
973
+ celilo_contract: '1.0' as const,
974
+ id: 'test',
975
+ name: 'Test',
976
+ version: '1.0.0',
977
+ requires: { capabilities: [] },
978
+ provides: { capabilities: [] },
979
+ variables: {
980
+ owns: [
981
+ {
982
+ name: 'app_port',
983
+ type: 'integer' as const,
984
+ required: false,
985
+ source: 'user' as const,
986
+ default: 3000,
987
+ },
988
+ ],
989
+ imports: [],
990
+ },
991
+ };
992
+
993
+ const result = validateDeriveFromSources(manifest);
994
+ expect(result).toBeNull();
995
+ });
996
+ });
997
+
998
+ describe('validateHookContract', () => {
999
+ test('should pass when all declared hooks are in v1 contract', () => {
1000
+ const manifest = {
1001
+ celilo_contract: '1.0' as const,
1002
+ id: 'test',
1003
+ name: 'Test',
1004
+ version: '1.0.0',
1005
+ requires: { capabilities: [] },
1006
+ provides: { capabilities: [] },
1007
+ variables: { owns: [], imports: [] },
1008
+ hooks: {
1009
+ on_install: { script: './install.ts' },
1010
+ on_backup: { script: './backup.ts' },
1011
+ },
1012
+ };
1013
+
1014
+ const result = validateHookContract(manifest);
1015
+ expect(result).toBeNull();
1016
+ });
1017
+
1018
+ test('should pass for manifests with no hooks block', () => {
1019
+ const manifest = {
1020
+ celilo_contract: '1.0' as const,
1021
+ id: 'test',
1022
+ name: 'Test',
1023
+ version: '1.0.0',
1024
+ requires: { capabilities: [] },
1025
+ provides: { capabilities: [] },
1026
+ variables: { owns: [], imports: [] },
1027
+ };
1028
+
1029
+ const result = validateHookContract(manifest);
1030
+ expect(result).toBeNull();
1031
+ });
1032
+ });
1033
+
1034
+ describe('validateProvidesNoCrossCapabilityRefs', () => {
1035
+ test('should pass when capability data has no cross-capability references', () => {
1036
+ const manifest = {
1037
+ celilo_contract: '1.0' as const,
1038
+ id: 'authentik',
1039
+ name: 'Authentik',
1040
+ version: '1.0.0',
1041
+ requires: { capabilities: [] },
1042
+ provides: {
1043
+ capabilities: [
1044
+ {
1045
+ name: 'idp',
1046
+ version: '1.0.0',
1047
+ data: {
1048
+ auth_url: '$self:auth_url',
1049
+ api_url: 'http://$self:container_ip:9000',
1050
+ },
1051
+ },
1052
+ ],
1053
+ },
1054
+ variables: { owns: [], imports: [] },
1055
+ };
1056
+
1057
+ const result = validateProvidesNoCrossCapabilityRefs(manifest);
1058
+ expect(result).toBeNull();
1059
+ });
1060
+
1061
+ test('should reject capability data containing $capability: reference (D9 firm rule)', () => {
1062
+ const manifest = {
1063
+ celilo_contract: '1.0' as const,
1064
+ id: 'authentik',
1065
+ name: 'Authentik',
1066
+ version: '1.0.0',
1067
+ requires: { capabilities: [] },
1068
+ provides: {
1069
+ capabilities: [
1070
+ {
1071
+ name: 'idp',
1072
+ version: '1.0.0',
1073
+ data: {
1074
+ issuer_url: 'https://auth.$capability:dns_registrar.primary_domain',
1075
+ },
1076
+ },
1077
+ ],
1078
+ },
1079
+ variables: { owns: [], imports: [] },
1080
+ };
1081
+
1082
+ const result = validateProvidesNoCrossCapabilityRefs(manifest);
1083
+ expect(result).not.toBeNull();
1084
+ if (result) {
1085
+ expect(result.errors[0]?.path).toContain('issuer_url');
1086
+ expect(result.errors[0]?.message).toContain('$capability:');
1087
+ }
1088
+ });
1089
+ });
1090
+
1091
+ describe('secret generate field', () => {
1092
+ test('should parse secrets with generate config', () => {
1093
+ const yaml = `
1094
+ ${CONTRACT_LINE}
1095
+ id: test-secrets
1096
+ name: Test Secrets
1097
+ version: 1.0.0
1098
+
1099
+ secrets:
1100
+ declares:
1101
+ - name: auto_secret
1102
+ type: string
1103
+ required: true
1104
+ description: "Auto-generated secret"
1105
+ generate:
1106
+ method: random
1107
+ length: 50
1108
+ encoding: base64
1109
+ - name: user_secret
1110
+ type: string
1111
+ required: true
1112
+ description: "User-provided secret"
1113
+ `;
1114
+
1115
+ const result = validateManifest(yaml);
1116
+ expect(result.success).toBe(true);
1117
+ if (result.success) {
1118
+ const secrets = result.data.secrets?.declares;
1119
+ if (!secrets) throw new Error('secrets.declares should exist');
1120
+ expect(secrets).toHaveLength(2);
1121
+
1122
+ const autoSecret = secrets.find((s) => s.name === 'auto_secret');
1123
+ expect(autoSecret?.generate).toBeDefined();
1124
+ expect(autoSecret?.generate?.method).toBe('random');
1125
+ expect(autoSecret?.generate?.length).toBe(50);
1126
+ expect(autoSecret?.generate?.encoding).toBe('base64');
1127
+
1128
+ const userSecret = secrets.find((s) => s.name === 'user_secret');
1129
+ expect(userSecret?.generate).toBeUndefined();
1130
+ }
1131
+ });
1132
+
1133
+ test('should use defaults for generate fields', () => {
1134
+ const yaml = `
1135
+ ${CONTRACT_LINE}
1136
+ id: test-defaults
1137
+ name: Test Defaults
1138
+ version: 1.0.0
1139
+
1140
+ secrets:
1141
+ declares:
1142
+ - name: minimal_generate
1143
+ type: string
1144
+ required: true
1145
+ generate: {}
1146
+ `;
1147
+
1148
+ const result = validateManifest(yaml);
1149
+ expect(result.success).toBe(true);
1150
+ if (result.success) {
1151
+ const declares = result.data.secrets?.declares;
1152
+ if (!declares) throw new Error('secrets.declares should exist');
1153
+ const secret = declares[0];
1154
+ expect(secret.generate).toBeDefined();
1155
+ expect(secret.generate?.method).toBe('random');
1156
+ expect(secret.generate?.length).toBe(32);
1157
+ expect(secret.generate?.encoding).toBe('base64');
1158
+ }
1159
+ });
1160
+
1161
+ test('should reject invalid generate encoding', () => {
1162
+ const yaml = `
1163
+ ${CONTRACT_LINE}
1164
+ id: test-invalid
1165
+ name: Test Invalid
1166
+ version: 1.0.0
1167
+
1168
+ secrets:
1169
+ declares:
1170
+ - name: bad_secret
1171
+ type: string
1172
+ required: true
1173
+ generate:
1174
+ encoding: sha256
1175
+ `;
1176
+
1177
+ const result = validateManifest(yaml);
1178
+ expect(result.success).toBe(false);
1179
+ });
1180
+ });