@celilo/cli 0.1.5 → 0.1.7
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.
- package/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +3 -2
- package/src/ansible/inventory.ts +5 -1
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +34 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +133 -73
- package/src/hooks/define-hook.test.ts +9 -1
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +85 -16
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +370 -59
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +1 -1
- package/src/templates/generator.ts +42 -1
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.ts +49 -1
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +56 -1
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.ts +27 -9
- 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
|
|
186
|
-
//
|
|
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 {
|
|
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
|
});
|
package/src/variables/parser.ts
CHANGED
|
@@ -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 =
|
|
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_.-]
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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 '${
|
|
212
|
+
error: `Provider module '${providerResult.id}' has not configured '${name}' (required by capability '${capabilityName}')`,
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
|
|
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
|