@celilo/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -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,5 +1,11 @@
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', () => {
@@ -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
  });
@@ -9,13 +9,19 @@ import type { VariableReference } from './types';
9
9
  * Examples:
10
10
  * - $self:container_ip
11
11
  * - ${self:disk}G
12
+ * - $self:domains[0] ← array indexing on $self: refs
12
13
  * - $capability:dns_registrar.primary_domain
13
14
  * - ${system:base_url}/api
14
15
  *
15
- * Path must start with letter or underscore, not digit
16
+ * Path must start with letter or underscore, not digit. An optional
17
+ * [N] suffix indexes into an array variable (currently only
18
+ * meaningful on $self: refs whose target is `type: array` — e.g.
19
+ * namecheap's `domains` declared as an array; the manifest's
20
+ * capability data block can then read `$self:domains[0]` as a
21
+ * computed alias for the canonical default).
16
22
  */
17
23
  const VARIABLE_PATTERN =
18
- /\$\{(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*)\}|\$(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*)/g;
24
+ /\$\{(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)\}|\$(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/g;
19
25
 
20
26
  /**
21
27
  * Parse template content to extract variable references
@@ -56,8 +62,9 @@ export function parseVariables(content: string): VariableReference[] {
56
62
  */
57
63
  export function hasVariables(content: string): boolean {
58
64
  // Create new regex without state to avoid issues with global flag
59
- // Matches both ${type:path} and $type:path
60
- const pattern = /\$\{?(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*)/;
65
+ // Matches both ${type:path} and $type:path (with optional [N] index)
66
+ const pattern =
67
+ /\$\{?(self|system|system_secret|secret|capability):([a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)/;
61
68
  return pattern.test(content);
62
69
  }
63
70
 
@@ -69,8 +76,42 @@ export function hasVariables(content: string): boolean {
69
76
  */
70
77
  export function isValidVariableFormat(variable: string): boolean {
71
78
  // Path must start with letter or underscore, not digit
72
- // Accepts both ${type:path} and $type:path
79
+ // Accepts both ${type:path} and $type:path with optional [N] indexing
73
80
  const pattern =
74
- /^(?:\$\{(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*\}|\$(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*)$/;
81
+ /^(?:\$\{(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?\}|\$(self|system|system_secret|secret|capability):[a-zA-Z_][a-zA-Z0-9_.-]*(?:\[\d+\])?)$/;
75
82
  return pattern.test(variable);
76
83
  }
84
+
85
+ /**
86
+ * Split a variable path into its base name and optional array index.
87
+ *
88
+ * Examples:
89
+ * parsePath("domains") → { name: "domains" }
90
+ * parsePath("domains[0]") → { name: "domains", index: 0 }
91
+ * parsePath("a.b.c[2]") → { name: "a.b.c", index: 2 }
92
+ *
93
+ * Pure helper used wherever `$self:NAME[N]` semantics need to be
94
+ * applied to a resolved value (currently the capability-data
95
+ * resolver in `context.ts` and the lazy-resolution path in
96
+ * `resolver.ts`).
97
+ */
98
+ export function parsePath(path: string): { name: string; index?: number } {
99
+ const match = /^(.+)\[(\d+)\]$/.exec(path);
100
+ if (!match) return { name: path };
101
+ return { name: match[1], index: Number.parseInt(match[2], 10) };
102
+ }
103
+
104
+ /**
105
+ * Apply an optional `[N]` index to a value. Used by the variable
106
+ * resolver after looking up a name like `domains` in a config map —
107
+ * if the original path had `[N]`, we drill into the array.
108
+ *
109
+ * Returns `undefined` for out-of-bounds indexes or when an index is
110
+ * applied to a non-array. Callers turn that into a resolver error.
111
+ */
112
+ export function applyIndex(value: unknown, index: number | undefined): unknown {
113
+ if (index === undefined) return value;
114
+ if (!Array.isArray(value)) return undefined;
115
+ if (index < 0 || index >= value.length) return undefined;
116
+ return value[index];
117
+ }
@@ -1,4 +1,4 @@
1
- import { parseVariables } from './parser';
1
+ import { applyIndex, parsePath, parseVariables } from './parser';
2
2
  import type {
3
3
  ResolutionContext,
4
4
  ResolveResult,
@@ -173,8 +173,11 @@ export async function resolveVariable(
173
173
 
174
174
  // Check if value contains unresolved $self: variable (lazy resolution)
175
175
  if (typeof value === 'string' && value.startsWith('$self:')) {
176
- // Get provider module's config to resolve the variable
177
- const selfVarPath = value.substring(6); // Remove "$self:" prefix
176
+ // Get provider module's config to resolve the variable.
177
+ // The path can be a plain key ("primary_domain") or include
178
+ // an array index ("domains[0]") — see parsePath/applyIndex
179
+ // and the syntax note in the multi-domain DDNS design doc.
180
+ const { name, index } = parsePath(value.substring(6));
178
181
 
179
182
  // Get provider module ID
180
183
  const providerQuery = db.$client.prepare(
@@ -193,23 +196,38 @@ export async function resolveVariable(
193
196
  };
194
197
  }
195
198
 
196
- // Get provider module's config value
199
+ // Fetch both `value` and `value_json` so we can index into
200
+ // arrays when the path contained `[N]`.
197
201
  const configQuery = db.$client.prepare(
198
- 'SELECT value FROM module_configs WHERE module_id = ? AND key = ?',
202
+ 'SELECT value, value_json FROM module_configs WHERE module_id = ? AND key = ?',
199
203
  );
200
- const configResult = configQuery.get(providerResult.id, selfVarPath) as
201
- | { value: string }
204
+ const configResult = configQuery.get(providerResult.id, name) as
205
+ | { value: string; value_json: string | null }
202
206
  | undefined;
203
207
 
204
208
  if (!configResult) {
205
209
  return {
206
210
  success: false,
207
211
  variable: variable.raw,
208
- error: `Provider module '${providerResult.id}' has not configured '${selfVarPath}' (required by capability '${capabilityName}')`,
212
+ error: `Provider module '${providerResult.id}' has not configured '${name}' (required by capability '${capabilityName}')`,
209
213
  };
210
214
  }
211
215
 
212
- return { success: true, value: configResult.value };
216
+ const rawValue: unknown = configResult.value_json
217
+ ? JSON.parse(configResult.value_json)
218
+ : configResult.value;
219
+ const indexed = applyIndex(rawValue, index);
220
+ if (indexed === undefined) {
221
+ return {
222
+ success: false,
223
+ variable: variable.raw,
224
+ error:
225
+ index === undefined
226
+ ? `Provider module '${providerResult.id}' has not configured '${name}'`
227
+ : `'$self:${name}[${index}]' is out of bounds or '${name}' is not an array on '${providerResult.id}'`,
228
+ };
229
+ }
230
+ return { success: true, value: typeof indexed === 'string' ? indexed : String(indexed) };
213
231
  }
214
232
 
215
233
  // Convert value to string
package/tsconfig.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "module": "ESNext",
5
5
  "lib": ["ESNext"],
6
6
  "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
7
8
  "strict": true,
8
9
  "esModuleInterop": true,
9
10
  "skipLibCheck": true,