@celilo/cli 0.1.4 → 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 (161) 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 +9 -8
  5. package/src/ansible/inventory.ts +9 -7
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +45 -12
  8. package/src/capabilities/registration.test.ts +6 -6
  9. package/src/capabilities/well-known.test.ts +2 -2
  10. package/src/capabilities/well-known.ts +5 -5
  11. package/src/cli/cli.test.ts +2 -2
  12. package/src/cli/command-registry.ts +146 -3
  13. package/src/cli/command-tree-parser.test.ts +1 -1
  14. package/src/cli/command-tree-parser.ts +9 -8
  15. package/src/cli/commands/hook-run.ts +15 -66
  16. package/src/cli/commands/module-audit.ts +14 -44
  17. package/src/cli/commands/module-deploy.ts +4 -1
  18. package/src/cli/commands/module-import-registry.test.ts +115 -0
  19. package/src/cli/commands/module-import.ts +106 -22
  20. package/src/cli/commands/module-publish.test.ts +235 -0
  21. package/src/cli/commands/module-publish.ts +234 -0
  22. package/src/cli/commands/module-remove.ts +82 -2
  23. package/src/cli/commands/module-search.ts +57 -0
  24. package/src/cli/commands/module-secret-get.ts +59 -0
  25. package/src/cli/commands/module-show.ts +1 -1
  26. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  27. package/src/cli/commands/module-verify.test.ts +59 -0
  28. package/src/cli/commands/module-verify.ts +53 -0
  29. package/src/cli/commands/status.ts +30 -20
  30. package/src/cli/commands/system-audit.test.ts +138 -0
  31. package/src/cli/commands/system-audit.ts +571 -0
  32. package/src/cli/commands/system-update.ts +391 -0
  33. package/src/cli/completion.ts +15 -1
  34. package/src/cli/fuel-gauge.ts +68 -3
  35. package/src/cli/generate-zsh-completion.ts +13 -3
  36. package/src/cli/index.ts +112 -5
  37. package/src/cli/parser.ts +11 -0
  38. package/src/cli/prompts.ts +36 -5
  39. package/src/cli/tui/audit-state.test.ts +246 -0
  40. package/src/cli/tui/audit-state.ts +525 -0
  41. package/src/cli/tui/audit-tui.test.tsx +135 -0
  42. package/src/cli/tui/audit-tui.tsx +624 -0
  43. package/src/cli/tui/celebration.tsx +29 -0
  44. package/src/cli/tui/clipboard.test.ts +94 -0
  45. package/src/cli/tui/clipboard.ts +101 -0
  46. package/src/cli/tui/icons.ts +22 -0
  47. package/src/cli/tui/keybar.tsx +65 -0
  48. package/src/cli/tui/keymap.test.ts +105 -0
  49. package/src/cli/tui/keymap.ts +70 -0
  50. package/src/cli/tui/modals/analyzing.tsx +75 -0
  51. package/src/cli/tui/modals/celebration.tsx +44 -0
  52. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  53. package/src/cli/tui/modals/remediate.tsx +44 -0
  54. package/src/cli/tui/modals.test.ts +137 -0
  55. package/src/cli/tui/mouse.test.ts +78 -0
  56. package/src/cli/tui/mouse.ts +114 -0
  57. package/src/cli/tui/panes/categories.tsx +62 -0
  58. package/src/cli/tui/panes/command-log.tsx +87 -0
  59. package/src/cli/tui/panes/detail.tsx +175 -0
  60. package/src/cli/tui/panes/findings.tsx +97 -0
  61. package/src/cli/tui/panes/summary.tsx +64 -0
  62. package/src/cli/tui/spawn.ts +130 -0
  63. package/src/cli/tui/theme.ts +42 -0
  64. package/src/cli/tui/wrap.test.ts +43 -0
  65. package/src/cli/tui/wrap.ts +45 -0
  66. package/src/cli/types.ts +5 -0
  67. package/src/db/client.ts +55 -2
  68. package/src/db/schema.test.ts +3 -3
  69. package/src/db/schema.ts +26 -17
  70. package/src/hooks/capability-loader.ts +135 -72
  71. package/src/hooks/define-hook.test.ts +11 -3
  72. package/src/hooks/executor.ts +22 -1
  73. package/src/hooks/load-hook-config.test.ts +165 -0
  74. package/src/hooks/load-hook-config.ts +60 -0
  75. package/src/hooks/logger.ts +42 -12
  76. package/src/hooks/run-named-hook.ts +128 -0
  77. package/src/hooks/types.ts +19 -0
  78. package/src/manifest/ensure-schema.test.ts +115 -0
  79. package/src/manifest/schema.ts +76 -0
  80. package/src/manifest/template-validator.test.ts +1 -1
  81. package/src/manifest/template-validator.ts +1 -1
  82. package/src/manifest/validate.test.ts +1 -1
  83. package/src/module/import.ts +20 -12
  84. package/src/module/packaging/build.ts +121 -25
  85. package/src/module/packaging/release-metadata.test.ts +103 -0
  86. package/src/module/packaging/release-metadata.ts +145 -0
  87. package/src/registry/client.test.ts +228 -0
  88. package/src/registry/client.ts +157 -0
  89. package/src/services/audit/backups.test.ts +233 -0
  90. package/src/services/audit/backups.ts +128 -0
  91. package/src/services/audit/capability-abi.test.ts +153 -0
  92. package/src/services/audit/capability-abi.ts +204 -0
  93. package/src/services/audit/cli-version.test.ts +60 -0
  94. package/src/services/audit/cli-version.ts +87 -0
  95. package/src/services/audit/health.test.ts +84 -0
  96. package/src/services/audit/health.ts +43 -0
  97. package/src/services/audit/index.test.ts +99 -0
  98. package/src/services/audit/index.ts +118 -0
  99. package/src/services/audit/machines-reachable.test.ts +87 -0
  100. package/src/services/audit/machines-reachable.ts +87 -0
  101. package/src/services/audit/module-configs.test.ts +131 -0
  102. package/src/services/audit/module-configs.ts +80 -0
  103. package/src/services/audit/module-versions.test.ts +99 -0
  104. package/src/services/audit/module-versions.ts +154 -0
  105. package/src/services/audit/schema.test.ts +68 -0
  106. package/src/services/audit/schema.ts +115 -0
  107. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  108. package/src/services/audit/secrets-decryptable.ts +97 -0
  109. package/src/services/audit/services-credentials.test.ts +54 -0
  110. package/src/services/audit/services-credentials.ts +64 -0
  111. package/src/services/audit/services-reachable.test.ts +60 -0
  112. package/src/services/audit/services-reachable.ts +64 -0
  113. package/src/services/audit/terraform-plan.test.ts +127 -0
  114. package/src/services/audit/terraform-plan.ts +153 -0
  115. package/src/services/audit/types.test.ts +36 -0
  116. package/src/services/audit/types.ts +90 -0
  117. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  118. package/src/services/audit/unconfigured-modules.ts +71 -0
  119. package/src/services/audit/undeployed-modules.test.ts +66 -0
  120. package/src/services/audit/undeployed-modules.ts +72 -0
  121. package/src/services/build-stream.ts +122 -122
  122. package/src/services/config-interview.ts +407 -2
  123. package/src/services/deploy-ansible.ts +73 -7
  124. package/src/services/deploy-planner.ts +5 -5
  125. package/src/services/deploy-preflight.ts +45 -4
  126. package/src/services/deploy-terraform.ts +31 -24
  127. package/src/services/deploy-validation.ts +167 -23
  128. package/src/services/dns-auto-register.ts +4 -4
  129. package/src/services/ensure-interview.test.ts +245 -0
  130. package/src/services/health-runner.ts +110 -38
  131. package/src/services/infrastructure-variable-resolver.test.ts +1 -1
  132. package/src/services/infrastructure-variable-resolver.ts +3 -3
  133. package/src/services/module-build.ts +11 -13
  134. package/src/services/module-deploy.ts +372 -61
  135. package/src/services/proxmox-state-recovery.ts +6 -6
  136. package/src/services/ssh-key-manager.test.ts +1 -1
  137. package/src/services/ssh-key-manager.ts +3 -2
  138. package/src/services/terraform-env.ts +62 -0
  139. package/src/services/update/dep-graph.test.ts +214 -0
  140. package/src/services/update/dep-graph.ts +215 -0
  141. package/src/services/update/orchestrator.test.ts +463 -0
  142. package/src/services/update/orchestrator.ts +359 -0
  143. package/src/services/update/progress.ts +49 -0
  144. package/src/services/update/self-update.test.ts +68 -0
  145. package/src/services/update/self-update.ts +57 -0
  146. package/src/services/update/types.ts +94 -0
  147. package/src/templates/generator.test.ts +3 -3
  148. package/src/templates/generator.ts +43 -2
  149. package/src/test-utils/completion-harness.test.ts +1 -1
  150. package/src/test-utils/completion-harness.ts +4 -4
  151. package/src/variables/capability-self-ref.test.ts +203 -0
  152. package/src/variables/context.test.ts +31 -31
  153. package/src/variables/context.ts +65 -17
  154. package/src/variables/declarative-derivation.test.ts +306 -0
  155. package/src/variables/declarative-derivation.ts +4 -2
  156. package/src/variables/parser.test.ts +64 -9
  157. package/src/variables/parser.ts +47 -6
  158. package/src/variables/resolver.test.ts +14 -14
  159. package/src/variables/resolver.ts +27 -9
  160. package/src/variables/types.ts +1 -1
  161. package/tsconfig.json +1 -0
@@ -15,6 +15,7 @@ import type { ModuleManifest } from '../manifest/schema';
15
15
  import { decryptSecret } from '../secrets/encryption';
16
16
  import { getOrCreateMasterKey } from '../secrets/master-key';
17
17
  import { applyDeclarativeDerivations } from './declarative-derivation';
18
+ import { applyIndex, parsePath } from './parser';
18
19
  import type { ResolutionContext } from './types';
19
20
 
20
21
  /**
@@ -92,7 +93,7 @@ async function autoAssignFromWellKnown(
92
93
  * Policy function - checks manifest and config
93
94
  *
94
95
  * A module needs IPAM if:
95
- * 1. It declares vmid and container_ip variables (container-based), AND
96
+ * 1. It declares vmid and target_ip variables (container-based), AND
96
97
  * 2. These values are not already set in module config
97
98
  *
98
99
  * @param manifest - Module manifest
@@ -106,16 +107,16 @@ function needsIpamAllocation(
106
107
  const variables = manifest.variables?.owns ?? [];
107
108
 
108
109
  const hasVmid = variables.some((v) => v.name === 'vmid');
109
- const hasContainerIp = variables.some((v) => v.name === 'container_ip');
110
+ const hasTargetIp = variables.some((v) => v.name === 'target_ip');
110
111
 
111
- // Module must declare both vmid and container_ip to be container-based
112
- if (!hasVmid || !hasContainerIp) {
112
+ // Module must declare both vmid and target_ip to be container-based
113
+ if (!hasVmid || !hasTargetIp) {
113
114
  return false;
114
115
  }
115
116
 
116
117
  // Check if already allocated (both must be present)
117
118
  const vmidSet = selfConfig.vmid !== undefined && selfConfig.vmid !== '';
118
- const ipSet = selfConfig.container_ip !== undefined && selfConfig.container_ip !== '';
119
+ const ipSet = selfConfig.target_ip !== undefined && selfConfig.target_ip !== '';
119
120
 
120
121
  // Need allocation if either is missing
121
122
  return !vmidSet || !ipSet;
@@ -162,7 +163,7 @@ function determineModuleZone(
162
163
  *
163
164
  * These variables are automatically available in Ansible templates:
164
165
  * - inventory.hostname: Derived from hostname variable
165
- * - inventory.ansible_host: Derived from container_ip (strips CIDR) or vps_ip
166
+ * - inventory.ansible_host: Derived from target_ip (strips CIDR) or vps_ip
166
167
  * - inventory.ansible_user: Defaults to "root"
167
168
  * - inventory.groups: Derived from module ID
168
169
  *
@@ -181,9 +182,9 @@ function autoDeriveInventoryVariables(
181
182
  derived['inventory.hostname'] = selfConfig.hostname;
182
183
  }
183
184
 
184
- // Auto-derive ansible_host from container_ip (strips CIDR) or vps_ip
185
- if (selfConfig.container_ip) {
186
- derived['inventory.ansible_host'] = stripCidr(selfConfig.container_ip);
185
+ // Auto-derive ansible_host from target_ip (strips CIDR) or vps_ip
186
+ if (selfConfig.target_ip) {
187
+ derived['inventory.ansible_host'] = stripCidr(selfConfig.target_ip);
187
188
  } else if (selfConfig.vps_ip) {
188
189
  // VPS-based modules use vps_ip directly (no CIDR to strip)
189
190
  derived['inventory.ansible_host'] = selfConfig.vps_ip;
@@ -202,6 +203,34 @@ function autoDeriveInventoryVariables(
202
203
  return derived;
203
204
  }
204
205
 
206
+ /**
207
+ * Recursively resolve "$self:key" strings in a capability data object
208
+ * using the provider module's actual config values. Supports the
209
+ * optional `[N]` array-index suffix (e.g. `$self:domains[0]`) so a
210
+ * provider can declare computed aliases — `primary_domain:
211
+ * $self:domains[0]` — without the framework storing the alias as a
212
+ * separate value. Non-string values and strings that don't start
213
+ * with "$self:" are returned unchanged.
214
+ */
215
+ function resolveSelfRefsInObject(
216
+ obj: Record<string, unknown>,
217
+ providerConfig: Record<string, unknown>,
218
+ ): Record<string, unknown> {
219
+ const result: Record<string, unknown> = {};
220
+ for (const [key, value] of Object.entries(obj)) {
221
+ if (typeof value === 'string' && value.startsWith('$self:')) {
222
+ const { name, index } = parsePath(value.slice(6));
223
+ const resolved = applyIndex(providerConfig[name], index);
224
+ result[key] = resolved !== undefined ? resolved : value;
225
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
226
+ result[key] = resolveSelfRefsInObject(value as Record<string, unknown>, providerConfig);
227
+ } else {
228
+ result[key] = value;
229
+ }
230
+ }
231
+ return result;
232
+ }
233
+
205
234
  /**
206
235
  * Build resolution context for a module
207
236
  *
@@ -329,7 +358,7 @@ export async function buildResolutionContext(
329
358
  }
330
359
 
331
360
  // IPAM auto-allocation
332
- // If module declares vmid/container_ip but they're not configured, allocate automatically
361
+ // If module declares vmid/target_ip but they're not configured, allocate automatically
333
362
  if (module?.manifestData) {
334
363
  const manifest = module.manifestData as ModuleManifest;
335
364
 
@@ -342,17 +371,17 @@ export async function buildResolutionContext(
342
371
  const existing = await getAllocation(moduleId, tx);
343
372
 
344
373
  let vmid: number;
345
- let containerIp: string;
374
+ let allocatedIp: string;
346
375
 
347
376
  if (existing) {
348
377
  // Use existing allocation
349
378
  vmid = existing.vmid;
350
- containerIp = existing.containerIp;
379
+ allocatedIp = existing.containerIp;
351
380
  } else {
352
381
  // Allocate new resources (persists to ipAllocations)
353
382
  const allocation = await allocateResources(moduleId, zone, tx);
354
383
  vmid = allocation.vmid;
355
- containerIp = allocation.containerIp;
384
+ allocatedIp = allocation.containerIp;
356
385
  }
357
386
 
358
387
  // Ensure values are in module config (upsert to handle existing keys)
@@ -366,15 +395,15 @@ export async function buildResolutionContext(
366
395
 
367
396
  await tx
368
397
  .insert(moduleConfigs)
369
- .values({ moduleId, key: 'container_ip', value: containerIp })
398
+ .values({ moduleId, key: 'target_ip', value: allocatedIp })
370
399
  .onConflictDoUpdate({
371
400
  target: [moduleConfigs.moduleId, moduleConfigs.key],
372
- set: { value: containerIp },
401
+ set: { value: allocatedIp },
373
402
  });
374
403
 
375
404
  // Update selfConfig with allocated values
376
405
  selfConfig.vmid = String(vmid);
377
- selfConfig.container_ip = containerIp;
406
+ selfConfig.target_ip = allocatedIp;
378
407
  });
379
408
  }
380
409
  }
@@ -418,7 +447,26 @@ export async function buildResolutionContext(
418
447
  } else {
419
448
  data = row.data;
420
449
  }
421
- capabilitiesMap[row.capabilityName] = data as Record<string, unknown>;
450
+
451
+ // Lazily resolve $self: references in capability data using the provider
452
+ // module's actual config values. Capability data is stored with unresolved
453
+ // $self: references (e.g. namecheap stores primary_domain: "$self:primary_domain")
454
+ // because the real value isn't known at import time. We resolve them here so
455
+ // consuming modules see the actual values (e.g. "iamtheinternet.org") rather
456
+ // than the raw template strings.
457
+ const providerConfigs = db
458
+ .select()
459
+ .from(moduleConfigs)
460
+ .where(eq(moduleConfigs.moduleId, row.moduleId))
461
+ .all();
462
+ const providerConfigMap: Record<string, unknown> = {};
463
+ for (const c of providerConfigs) {
464
+ providerConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
465
+ }
466
+ capabilitiesMap[row.capabilityName] = resolveSelfRefsInObject(
467
+ data as Record<string, unknown>,
468
+ providerConfigMap,
469
+ );
422
470
  }
423
471
 
424
472
  // Fetch system configuration (for $system: variables)
@@ -676,6 +676,312 @@ describe('applyDeclarativeDerivations', () => {
676
676
  });
677
677
  });
678
678
 
679
+ describe('capability data with unresolved $self: refs', () => {
680
+ // This is the real-world scenario: namecheap's dns_registrar capability is stored
681
+ // in the DB with raw {primary_domain: "$self:primary_domain"} by buildCapabilityData.
682
+ // When lunacycle's primary_domain derives from $capability:dns_registrar.primary_domain,
683
+ // it receives the string "$self:primary_domain" — which can't be resolved in lunacycle's
684
+ // context. The key must remain unset rather than being poisoned with the raw template.
685
+ test('does not set selfConfig when capability value still contains $self: ref', () => {
686
+ const manifest: ModuleManifest = {
687
+ celilo_contract: '1.0',
688
+ id: 'lunacycle',
689
+ name: 'Lunacycle',
690
+ version: '1.0.0',
691
+ requires: { capabilities: [] },
692
+ provides: { capabilities: [] },
693
+ variables: {
694
+ owns: [
695
+ {
696
+ name: 'primary_domain',
697
+ type: 'string',
698
+ required: false,
699
+ source: 'capability',
700
+ derive_from: '$capability:dns_registrar.primary_domain',
701
+ },
702
+ ],
703
+ imports: [],
704
+ },
705
+ };
706
+
707
+ const context: ResolutionContext = {
708
+ moduleId: 'lunacycle',
709
+ selfConfig: {}, // primary_domain not set yet
710
+ systemConfig: {},
711
+ systemSecrets: {},
712
+ secrets: {},
713
+ capabilities: {
714
+ // Raw capability data as stored by buildCapabilityData — $self: unresolved
715
+ dns_registrar: { primary_domain: '$self:primary_domain' },
716
+ },
717
+ };
718
+
719
+ applyDeclarativeDerivations(manifest, context);
720
+
721
+ // Must NOT be set to '$self:primary_domain' — that poisons downstream code
722
+ expect(context.selfConfig.primary_domain).toBeUndefined();
723
+ });
724
+
725
+ test('uses existing selfConfig value when capability returns unresolved $self: ref', () => {
726
+ const manifest: ModuleManifest = {
727
+ celilo_contract: '1.0',
728
+ id: 'lunacycle',
729
+ name: 'Lunacycle',
730
+ version: '1.0.0',
731
+ requires: { capabilities: [] },
732
+ provides: { capabilities: [] },
733
+ variables: {
734
+ owns: [
735
+ {
736
+ name: 'primary_domain',
737
+ type: 'string',
738
+ required: false,
739
+ source: 'capability',
740
+ derive_from: '$capability:dns_registrar.primary_domain',
741
+ },
742
+ ],
743
+ imports: [],
744
+ },
745
+ };
746
+
747
+ const context: ResolutionContext = {
748
+ moduleId: 'lunacycle',
749
+ selfConfig: { primary_domain: 'iamtheinternet.org' }, // Previously resolved value
750
+ systemConfig: {},
751
+ systemSecrets: {},
752
+ secrets: {},
753
+ capabilities: {
754
+ dns_registrar: { primary_domain: '$self:primary_domain' }, // Raw, unresolved
755
+ },
756
+ };
757
+
758
+ applyDeclarativeDerivations(manifest, context);
759
+
760
+ // Should keep the existing resolved value
761
+ expect(context.selfConfig.primary_domain).toBe('iamtheinternet.org');
762
+ });
763
+ });
764
+
765
+ describe('capability data with unresolved $secret: refs', () => {
766
+ // $secret: is not handled by substituteVariables (only $system:, {var}, $capability:
767
+ // are resolved). So if a capability stores "$secret:api_key", it passes through
768
+ // all 5 resolution passes unchanged and must be skipped.
769
+ test('does not set selfConfig when capability value contains $secret: ref', () => {
770
+ const manifest: ModuleManifest = {
771
+ celilo_contract: '1.0',
772
+ id: 'consumer',
773
+ name: 'Consumer',
774
+ version: '1.0.0',
775
+ requires: { capabilities: [] },
776
+ provides: { capabilities: [] },
777
+ variables: {
778
+ owns: [
779
+ {
780
+ name: 'api_key',
781
+ type: 'string',
782
+ required: false,
783
+ source: 'capability',
784
+ derive_from: '$capability:provider.api_key',
785
+ },
786
+ ],
787
+ imports: [],
788
+ },
789
+ };
790
+
791
+ const context: ResolutionContext = {
792
+ moduleId: 'consumer',
793
+ selfConfig: {},
794
+ systemConfig: {},
795
+ systemSecrets: {},
796
+ secrets: {},
797
+ capabilities: {
798
+ provider: { api_key: '$secret:provider_api_key' },
799
+ },
800
+ };
801
+
802
+ applyDeclarativeDerivations(manifest, context);
803
+
804
+ expect(context.selfConfig.api_key).toBeUndefined();
805
+ });
806
+ });
807
+
808
+ describe('two-level resolution via capability', () => {
809
+ // Capability data contains $system: refs — the multi-pass loop in
810
+ // resolveDeclarativeDerivation resolves them. This tests applyDeclarativeDerivations
811
+ // end-to-end, not just resolveDeclarativeDerivation in isolation.
812
+ test('resolves $system: ref inside capability value', () => {
813
+ const manifest: ModuleManifest = {
814
+ celilo_contract: '1.0',
815
+ id: 'caddy',
816
+ name: 'Caddy',
817
+ version: '1.0.0',
818
+ requires: { capabilities: [] },
819
+ provides: { capabilities: [] },
820
+ variables: {
821
+ owns: [
822
+ {
823
+ name: 'auth_url',
824
+ type: 'string',
825
+ required: false,
826
+ source: 'capability',
827
+ derive_from: '$capability:idp.auth_url',
828
+ },
829
+ ],
830
+ imports: [],
831
+ },
832
+ };
833
+
834
+ const context: ResolutionContext = {
835
+ moduleId: 'caddy',
836
+ selfConfig: {},
837
+ systemConfig: { primary_domain: 'example.com' },
838
+ systemSecrets: {},
839
+ secrets: {},
840
+ capabilities: {
841
+ // Capability data uses $system: — this CAN be resolved in the consumer's context
842
+ idp: { auth_url: 'https://auth.$system:primary_domain' },
843
+ },
844
+ };
845
+
846
+ applyDeclarativeDerivations(manifest, context);
847
+
848
+ expect(context.selfConfig.auth_url).toBe('https://auth.example.com');
849
+ });
850
+ });
851
+
852
+ describe('circular variable references', () => {
853
+ // Both optional variables reference each other — each throws "Missing variable"
854
+ // on first pass (the other isn't set yet). Both should remain unset.
855
+ test('silently skips both optional variables in a circular reference', () => {
856
+ const manifest: ModuleManifest = {
857
+ celilo_contract: '1.0',
858
+ id: 'test-module',
859
+ name: 'Test Module',
860
+ version: '1.0.0',
861
+ requires: { capabilities: [] },
862
+ provides: { capabilities: [] },
863
+ variables: {
864
+ owns: [
865
+ {
866
+ name: 'foo',
867
+ type: 'string',
868
+ required: false,
869
+ source: 'user',
870
+ derive_from: '{bar}',
871
+ },
872
+ {
873
+ name: 'bar',
874
+ type: 'string',
875
+ required: false,
876
+ source: 'user',
877
+ derive_from: '{foo}',
878
+ },
879
+ ],
880
+ imports: [],
881
+ },
882
+ };
883
+
884
+ const context: ResolutionContext = {
885
+ moduleId: 'test-module',
886
+ selfConfig: {},
887
+ systemConfig: {},
888
+ systemSecrets: {},
889
+ secrets: {},
890
+ capabilities: {},
891
+ };
892
+
893
+ applyDeclarativeDerivations(manifest, context);
894
+
895
+ expect(context.selfConfig.foo).toBeUndefined();
896
+ expect(context.selfConfig.bar).toBeUndefined();
897
+ });
898
+
899
+ test('throws when either circular variable is required', () => {
900
+ const manifest: ModuleManifest = {
901
+ celilo_contract: '1.0',
902
+ id: 'test-module',
903
+ name: 'Test Module',
904
+ version: '1.0.0',
905
+ requires: { capabilities: [] },
906
+ provides: { capabilities: [] },
907
+ variables: {
908
+ owns: [
909
+ {
910
+ name: 'foo',
911
+ type: 'string',
912
+ required: true, // Required — must throw
913
+ source: 'user',
914
+ derive_from: '{bar}',
915
+ },
916
+ {
917
+ name: 'bar',
918
+ type: 'string',
919
+ required: false,
920
+ source: 'user',
921
+ derive_from: '{foo}',
922
+ },
923
+ ],
924
+ imports: [],
925
+ },
926
+ };
927
+
928
+ const context: ResolutionContext = {
929
+ moduleId: 'test-module',
930
+ selfConfig: {},
931
+ systemConfig: {},
932
+ systemSecrets: {},
933
+ secrets: {},
934
+ capabilities: {},
935
+ };
936
+
937
+ expect(() => applyDeclarativeDerivations(manifest, context)).toThrow(
938
+ "Missing variable: bar (required by variable 'foo')",
939
+ );
940
+ });
941
+ });
942
+
943
+ describe('$self: directly in derive_from', () => {
944
+ // $self: in derive_from is a manifest authoring error — substituteVariables
945
+ // does not handle $self:, so it passes through all resolution passes unchanged.
946
+ // The hasUnresolved check must prevent it from poisoning selfConfig.
947
+ test('does not set selfConfig when derive_from contains $self: directly', () => {
948
+ const manifest: ModuleManifest = {
949
+ celilo_contract: '1.0',
950
+ id: 'test-module',
951
+ name: 'Test Module',
952
+ version: '1.0.0',
953
+ requires: { capabilities: [] },
954
+ provides: { capabilities: [] },
955
+ variables: {
956
+ owns: [
957
+ {
958
+ name: 'hostname_copy',
959
+ type: 'string',
960
+ required: false,
961
+ source: 'user',
962
+ derive_from: '$self:hostname', // Wrong syntax — $self: is not supported in derive_from
963
+ },
964
+ ],
965
+ imports: [],
966
+ },
967
+ };
968
+
969
+ const context: ResolutionContext = {
970
+ moduleId: 'test-module',
971
+ selfConfig: { hostname: 'my-host' },
972
+ systemConfig: {},
973
+ systemSecrets: {},
974
+ secrets: {},
975
+ capabilities: {},
976
+ };
977
+
978
+ applyDeclarativeDerivations(manifest, context);
979
+
980
+ // $self: passes through unresolved; must not poison selfConfig
981
+ expect(context.selfConfig.hostname_copy).toBeUndefined();
982
+ });
983
+ });
984
+
679
985
  describe('skips', () => {
680
986
  test('skips variables without derive_from', () => {
681
987
  const manifest: ModuleManifest = {
@@ -182,8 +182,10 @@ export function applyDeclarativeDerivations(
182
182
  derived.includes('$system:') ||
183
183
  derived.includes('$capability:') ||
184
184
  derived.includes('$secret:');
185
- if (hasUnresolved && context.selfConfig[variable.name] !== undefined) {
186
- // Keep the existing (user-set or previously-resolved) value
185
+ if (hasUnresolved) {
186
+ // Don't store a value that still contains unresolved template refs.
187
+ // This happens when a capability's data contains $self: refs that
188
+ // resolve in the provider's context, not the consumer's.
187
189
  continue;
188
190
  }
189
191
  context.selfConfig[variable.name] = derived;
@@ -1,16 +1,22 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { hasVariables, isValidVariableFormat, parseVariables } from './parser';
2
+ import {
3
+ applyIndex,
4
+ hasVariables,
5
+ isValidVariableFormat,
6
+ parsePath,
7
+ parseVariables,
8
+ } from './parser';
3
9
 
4
10
  describe('parseVariables', () => {
5
11
  test('should parse self variables', () => {
6
- const content = 'ip: $self:container_ip';
12
+ const content = 'ip: $self:target_ip';
7
13
  const variables = parseVariables(content);
8
14
 
9
15
  expect(variables).toHaveLength(1);
10
16
  expect(variables[0]).toEqual({
11
17
  type: 'self',
12
- path: 'container_ip',
13
- raw: '$self:container_ip',
18
+ path: 'target_ip',
19
+ raw: '$self:target_ip',
14
20
  });
15
21
  });
16
22
 
@@ -52,7 +58,7 @@ describe('parseVariables', () => {
52
58
 
53
59
  test('should parse multiple variables in same content', () => {
54
60
  const content = `
55
- ip: $self:container_ip
61
+ ip: $self:target_ip
56
62
  dns: $capability:dns_external.nameserver
57
63
  key: $secret:api_key
58
64
  `;
@@ -101,7 +107,7 @@ resource "proxmox_lxc" "homebridge" {
101
107
  cores = $self:cores
102
108
  memory = $self:memory
103
109
  network {
104
- ip = "$self:container_ip/24"
110
+ ip = "$self:target_ip/24"
105
111
  gw = "$system:management.ip"
106
112
  }
107
113
  }
@@ -189,7 +195,7 @@ resource "proxmox_lxc" "homebridge" {
189
195
 
190
196
  describe('hasVariables', () => {
191
197
  test('should return true for content with variables', () => {
192
- expect(hasVariables('ip: $self:container_ip')).toBe(true);
198
+ expect(hasVariables('ip: $self:target_ip')).toBe(true);
193
199
  expect(hasVariables('$secret:key')).toBe(true);
194
200
  });
195
201
 
@@ -206,7 +212,7 @@ describe('hasVariables', () => {
206
212
 
207
213
  describe('isValidVariableFormat', () => {
208
214
  test('should validate correct variable formats', () => {
209
- expect(isValidVariableFormat('$self:container_ip')).toBe(true);
215
+ expect(isValidVariableFormat('$self:target_ip')).toBe(true);
210
216
  expect(isValidVariableFormat('$system:management.ip')).toBe(true);
211
217
  expect(isValidVariableFormat('$secret:api_key')).toBe(true);
212
218
  expect(isValidVariableFormat('$capability:dns_external.nameserver')).toBe(true);
@@ -219,7 +225,7 @@ describe('isValidVariableFormat', () => {
219
225
  });
220
226
 
221
227
  test('should reject invalid variable formats', () => {
222
- expect(isValidVariableFormat('self:container_ip')).toBe(false); // missing $
228
+ expect(isValidVariableFormat('self:target_ip')).toBe(false); // missing $
223
229
  expect(isValidVariableFormat('$unknown:value')).toBe(false); // invalid type
224
230
  expect(isValidVariableFormat('$self:')).toBe(false); // missing path
225
231
  expect(isValidVariableFormat('$self')).toBe(false); // missing colon and path
@@ -228,4 +234,53 @@ describe('isValidVariableFormat', () => {
228
234
  expect(isValidVariableFormat('${self:test')).toBe(false); // missing closing brace
229
235
  expect(isValidVariableFormat('$self:test}')).toBe(false); // mismatched brace
230
236
  });
237
+
238
+ test('should accept [N] array-index suffix on $self:', () => {
239
+ expect(isValidVariableFormat('$self:domains[0]')).toBe(true);
240
+ expect(isValidVariableFormat('$self:domains[42]')).toBe(true);
241
+ expect(isValidVariableFormat('${self:domains[0]}')).toBe(true);
242
+ });
243
+
244
+ test('should reject malformed [N] indexes', () => {
245
+ expect(isValidVariableFormat('$self:domains[]')).toBe(false);
246
+ expect(isValidVariableFormat('$self:domains[a]')).toBe(false);
247
+ expect(isValidVariableFormat('$self:domains[-1]')).toBe(false);
248
+ expect(isValidVariableFormat('$self:domains[0')).toBe(false);
249
+ });
250
+ });
251
+
252
+ describe('parsePath', () => {
253
+ test('returns name without index when no brackets', () => {
254
+ expect(parsePath('domains')).toEqual({ name: 'domains' });
255
+ expect(parsePath('a.b.c')).toEqual({ name: 'a.b.c' });
256
+ });
257
+
258
+ test('separates trailing [N] into index field', () => {
259
+ expect(parsePath('domains[0]')).toEqual({ name: 'domains', index: 0 });
260
+ expect(parsePath('domains[7]')).toEqual({ name: 'domains', index: 7 });
261
+ expect(parsePath('a.b.c[2]')).toEqual({ name: 'a.b.c', index: 2 });
262
+ });
263
+ });
264
+
265
+ describe('applyIndex', () => {
266
+ test('returns the value unchanged when index is undefined', () => {
267
+ expect(applyIndex('hello', undefined)).toBe('hello');
268
+ expect(applyIndex(['a', 'b'], undefined)).toEqual(['a', 'b']);
269
+ });
270
+
271
+ test('drills into array at the given index', () => {
272
+ expect(applyIndex(['a', 'b', 'c'], 0)).toBe('a');
273
+ expect(applyIndex(['a', 'b', 'c'], 2)).toBe('c');
274
+ });
275
+
276
+ test('returns undefined for out-of-bounds indexes', () => {
277
+ expect(applyIndex(['a'], 5)).toBeUndefined();
278
+ expect(applyIndex([], 0)).toBeUndefined();
279
+ });
280
+
281
+ test('returns undefined when applying an index to a non-array', () => {
282
+ expect(applyIndex('not an array', 0)).toBeUndefined();
283
+ expect(applyIndex(42, 0)).toBeUndefined();
284
+ expect(applyIndex(null, 0)).toBeUndefined();
285
+ });
231
286
  });