@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,200 @@
1
+ import type { ModuleManifest, VariableDeclare } from '../manifest/schema';
2
+ import type { ResolutionContext } from './types';
3
+
4
+ /**
5
+ * Get nested property from object using dot notation
6
+ *
7
+ * Policy function - pure navigation
8
+ *
9
+ * @param obj - Object to navigate
10
+ * @param path - Dot-separated path (e.g., "server.ip.primary")
11
+ * @returns Value at path or undefined if not found
12
+ */
13
+ function getNestedProperty(obj: Record<string, unknown>, path: string): unknown {
14
+ const parts = path.split('.');
15
+ let current: unknown = obj;
16
+
17
+ for (const part of parts) {
18
+ if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
19
+ current = (current as Record<string, unknown>)[part];
20
+ } else {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ return current;
26
+ }
27
+
28
+ /**
29
+ * Resolve declarative variable derivation from manifest
30
+ *
31
+ * Policy function - pure template substitution
32
+ *
33
+ * Supports the following template patterns:
34
+ * - $system:key - System config value (e.g., $system:primary_domain)
35
+ * - {variable} - Module's own variable (e.g., {hostname})
36
+ * - $capability:name.path - Capability data (e.g., $capability:dns_registrar.primary_domain)
37
+ *
38
+ * @param variable - Variable declaration with derive_from field
39
+ * @param context - Resolution context (selfConfig, systemConfig, etc.)
40
+ * @returns Derived value or undefined if dependencies missing
41
+ * @throws Error if required dependencies are missing
42
+ */
43
+ /**
44
+ * Pattern that matches any unresolved variable reference.
45
+ * Used to detect whether another resolution pass is needed.
46
+ */
47
+ const UNRESOLVED_PATTERN = /\$\{?(?:system|self|capability|secret|system_secret):/;
48
+
49
+ /**
50
+ * Run one pass of variable substitution on a string.
51
+ */
52
+ function substituteVariables(
53
+ input: string,
54
+ variableName: string,
55
+ context: ResolutionContext,
56
+ ): string {
57
+ let result = input;
58
+
59
+ // Replace $system:key patterns (both $system:key and ${system:key} forms)
60
+ result = result.replace(/\$\{?system:([a-zA-Z0-9_.]+)\}?/g, (_match, key) => {
61
+ const value = context.systemConfig[key];
62
+ if (value === undefined) {
63
+ throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`);
64
+ }
65
+ return value;
66
+ });
67
+
68
+ // Replace {variable_name} patterns
69
+ result = result.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, varName) => {
70
+ const value = context.selfConfig[varName];
71
+ if (value === undefined) {
72
+ throw new Error(`Missing variable: ${varName} (required by variable '${variableName}')`);
73
+ }
74
+ return value;
75
+ });
76
+
77
+ // Replace $capability:name.path patterns
78
+ result = result.replace(
79
+ /\$capability:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_.]+)/g,
80
+ (_match, capName, path) => {
81
+ const capData = context.capabilities[capName];
82
+ if (!capData) {
83
+ throw new Error(`Missing capability: ${capName} (required by variable '${variableName}')`);
84
+ }
85
+
86
+ const value = getNestedProperty(capData, path);
87
+ if (value === undefined) {
88
+ throw new Error(
89
+ `Missing capability field: ${capName}.${path} (required by variable '${variableName}')`,
90
+ );
91
+ }
92
+ return String(value);
93
+ },
94
+ );
95
+
96
+ return result;
97
+ }
98
+
99
+ export function resolveDeclarativeDerivation(
100
+ variable: VariableDeclare,
101
+ context: ResolutionContext,
102
+ ): string | undefined {
103
+ if (!variable.derive_from) {
104
+ return undefined;
105
+ }
106
+
107
+ const template = variable.derive_from;
108
+
109
+ // $machine: derivations are handled by the config interview, not template resolution
110
+ if (template.startsWith('$machine:')) {
111
+ return undefined;
112
+ }
113
+
114
+ // Resolve variables iteratively — capability values may contain $system: or
115
+ // other references that need a second pass to fully resolve.
116
+ let result = template;
117
+ const maxPasses = 5;
118
+ for (let i = 0; i < maxPasses; i++) {
119
+ const resolved = substituteVariables(result, variable.name, context);
120
+ if (resolved === result) break; // Stable — no more substitutions possible
121
+ result = resolved;
122
+ if (!UNRESOLVED_PATTERN.test(result)) break; // Fully resolved
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /**
129
+ * Apply all declarative derivations from manifest
130
+ *
131
+ * Planning function - processes variable declarations in order
132
+ *
133
+ * Rules:
134
+ * 1. User-provided values always take precedence (not overwritten)
135
+ * 2. Variables are resolved in declaration order
136
+ * 3. Only works for type: string variables
137
+ * 4. If derivation fails for optional variable, silently skip
138
+ * 5. If derivation fails for required variable, throw error
139
+ *
140
+ * @param manifest - Module manifest with variable declarations
141
+ * @param context - Resolution context (will be mutated with derived values)
142
+ */
143
+ export function applyDeclarativeDerivations(
144
+ manifest: ModuleManifest,
145
+ context: ResolutionContext,
146
+ ): void {
147
+ const variables = manifest.variables?.owns ?? [];
148
+
149
+ for (const variable of variables) {
150
+ // Re-derive capability-sourced and infrastructure-sourced variables every time,
151
+ // since the upstream data may have changed. User-provided values take precedence
152
+ // only for user-sourced variables.
153
+ const shouldRederive = variable.source === 'capability' || variable.source === 'infrastructure';
154
+ if (!shouldRederive && context.selfConfig[variable.name] !== undefined) {
155
+ continue;
156
+ }
157
+
158
+ // Skip if no derivation defined
159
+ if (!variable.derive_from) {
160
+ continue;
161
+ }
162
+
163
+ // Declarative derivation only resolves string template expressions.
164
+ // Non-string types (arrays, objects) with derive_from are handled by
165
+ // the config interview system instead.
166
+ if (variable.type !== 'string') {
167
+ continue;
168
+ }
169
+
170
+ try {
171
+ const derived = resolveDeclarativeDerivation(variable, context);
172
+ if (derived !== undefined) {
173
+ // Don't overwrite with a value that still contains unresolved
174
+ // template references ($self:, $system:, $capability:). This
175
+ // happens when capability data contains template strings that
176
+ // can't be fully resolved in the consuming module's context —
177
+ // e.g. $capability:dns_registrar.primary_domain resolves to
178
+ // namecheap's "$self:primary_domain", but $self: in that
179
+ // context is namecheap, not the consumer.
180
+ const hasUnresolved =
181
+ derived.includes('$self:') ||
182
+ derived.includes('$system:') ||
183
+ derived.includes('$capability:') ||
184
+ derived.includes('$secret:');
185
+ if (hasUnresolved && context.selfConfig[variable.name] !== undefined) {
186
+ // Keep the existing (user-set or previously-resolved) value
187
+ continue;
188
+ }
189
+ context.selfConfig[variable.name] = derived;
190
+ }
191
+ } catch (error) {
192
+ // If derivation fails due to missing dependencies, handle based on required flag
193
+ if (variable.required) {
194
+ // Required variable - propagate error
195
+ throw error;
196
+ }
197
+ // Optional variable - silently skip (will use default or remain unset)
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,231 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { hasVariables, isValidVariableFormat, parseVariables } from './parser';
3
+
4
+ describe('parseVariables', () => {
5
+ test('should parse self variables', () => {
6
+ const content = 'ip: $self:container_ip';
7
+ const variables = parseVariables(content);
8
+
9
+ expect(variables).toHaveLength(1);
10
+ expect(variables[0]).toEqual({
11
+ type: 'self',
12
+ path: 'container_ip',
13
+ raw: '$self:container_ip',
14
+ });
15
+ });
16
+
17
+ test('should parse system variables', () => {
18
+ const content = 'server: $system:management.ip';
19
+ const variables = parseVariables(content);
20
+
21
+ expect(variables).toHaveLength(1);
22
+ expect(variables[0]).toEqual({
23
+ type: 'system',
24
+ path: 'management.ip',
25
+ raw: '$system:management.ip',
26
+ });
27
+ });
28
+
29
+ test('should parse secret variables', () => {
30
+ const content = 'key: $secret:api_key';
31
+ const variables = parseVariables(content);
32
+
33
+ expect(variables).toHaveLength(1);
34
+ expect(variables[0]).toEqual({
35
+ type: 'secret',
36
+ path: 'api_key',
37
+ raw: '$secret:api_key',
38
+ });
39
+ });
40
+
41
+ test('should parse capability variables', () => {
42
+ const content = 'dns: $capability:dns_external.nameserver';
43
+ const variables = parseVariables(content);
44
+
45
+ expect(variables).toHaveLength(1);
46
+ expect(variables[0]).toEqual({
47
+ type: 'capability',
48
+ path: 'dns_external.nameserver',
49
+ raw: '$capability:dns_external.nameserver',
50
+ });
51
+ });
52
+
53
+ test('should parse multiple variables in same content', () => {
54
+ const content = `
55
+ ip: $self:container_ip
56
+ dns: $capability:dns_external.nameserver
57
+ key: $secret:api_key
58
+ `;
59
+ const variables = parseVariables(content);
60
+
61
+ expect(variables).toHaveLength(3);
62
+ expect(variables[0]?.type).toBe('self');
63
+ expect(variables[1]?.type).toBe('capability');
64
+ expect(variables[2]?.type).toBe('secret');
65
+ });
66
+
67
+ test('should handle nested paths in capability variables', () => {
68
+ const content = '$capability:dns_external.config.primary.nameserver';
69
+ const variables = parseVariables(content);
70
+
71
+ expect(variables).toHaveLength(1);
72
+ expect(variables[0]?.path).toBe('dns_external.config.primary.nameserver');
73
+ });
74
+
75
+ test('should handle underscores and numbers in paths', () => {
76
+ const content = '$self:vlan_id_10';
77
+ const variables = parseVariables(content);
78
+
79
+ expect(variables).toHaveLength(1);
80
+ expect(variables[0]?.path).toBe('vlan_id_10');
81
+ });
82
+
83
+ test('should not parse paths starting with numbers', () => {
84
+ const content = '$self:123invalid';
85
+ const variables = parseVariables(content);
86
+
87
+ expect(variables).toHaveLength(0);
88
+ });
89
+
90
+ test('should return empty array for content without variables', () => {
91
+ const content = 'no variables here';
92
+ const variables = parseVariables(content);
93
+
94
+ expect(variables).toHaveLength(0);
95
+ });
96
+
97
+ test('should handle variables in complex template', () => {
98
+ const content = `
99
+ resource "proxmox_lxc" "homebridge" {
100
+ hostname = "$self:hostname"
101
+ cores = $self:cores
102
+ memory = $self:memory
103
+ network {
104
+ ip = "$self:container_ip/24"
105
+ gw = "$system:management.ip"
106
+ }
107
+ }
108
+ `;
109
+ const variables = parseVariables(content);
110
+
111
+ expect(variables.length).toBeGreaterThan(3);
112
+ });
113
+
114
+ test('should parse braced variables for concatenation', () => {
115
+ const content = 'size = "${self:disk}G"';
116
+ const variables = parseVariables(content);
117
+
118
+ expect(variables).toHaveLength(1);
119
+ expect(variables[0]).toEqual({
120
+ type: 'self',
121
+ path: 'disk',
122
+ raw: '${self:disk}',
123
+ });
124
+ });
125
+
126
+ test('should parse multiple braced variables', () => {
127
+ const content = 'url = "${system:protocol}://${system:host}:${system:port}"';
128
+ const variables = parseVariables(content);
129
+
130
+ expect(variables).toHaveLength(3);
131
+ expect(variables[0]?.raw).toBe('${system:protocol}');
132
+ expect(variables[1]?.raw).toBe('${system:host}');
133
+ expect(variables[2]?.raw).toBe('${system:port}');
134
+ });
135
+
136
+ test('should handle mix of braced and simple variables', () => {
137
+ const content = 'size = "${self:disk}G" and vmid = $self:vmid';
138
+ const variables = parseVariables(content);
139
+
140
+ expect(variables).toHaveLength(2);
141
+ expect(variables[0]?.raw).toBe('${self:disk}');
142
+ expect(variables[1]?.raw).toBe('$self:vmid');
143
+ });
144
+
145
+ test('should handle braced variables with nested paths', () => {
146
+ const content = 'api = "${capability:auth.endpoint.url}/v1"';
147
+ const variables = parseVariables(content);
148
+
149
+ expect(variables).toHaveLength(1);
150
+ expect(variables[0]).toEqual({
151
+ type: 'capability',
152
+ path: 'auth.endpoint.url',
153
+ raw: '${capability:auth.endpoint.url}',
154
+ });
155
+ });
156
+
157
+ test('should NOT match Terraform variables (no colon after type)', () => {
158
+ const content = `
159
+ hostname = "\${var.hostname}"
160
+ ip = "\${local.ip_address}"
161
+ id = "\${proxmox_lxc.caddy.id}"
162
+ cidr = "\${var.network.cidr}"
163
+ `;
164
+ const variables = parseVariables(content);
165
+
166
+ // Should find zero variables because Terraform uses dots, not colons
167
+ expect(variables).toHaveLength(0);
168
+ });
169
+
170
+ test('should match our syntax but not Terraform syntax in same file', () => {
171
+ const content = `
172
+ # Our syntax (with colon)
173
+ size = "\${self:disk}G"
174
+
175
+ # Terraform syntax (with dots)
176
+ hostname = "\${var.hostname}"
177
+
178
+ # Our syntax again
179
+ vmid = $self:vmid
180
+ `;
181
+ const variables = parseVariables(content);
182
+
183
+ // Should only find our 2 variables
184
+ expect(variables).toHaveLength(2);
185
+ expect(variables[0]?.raw).toBe('${self:disk}');
186
+ expect(variables[1]?.raw).toBe('$self:vmid');
187
+ });
188
+ });
189
+
190
+ describe('hasVariables', () => {
191
+ test('should return true for content with variables', () => {
192
+ expect(hasVariables('ip: $self:container_ip')).toBe(true);
193
+ expect(hasVariables('$secret:key')).toBe(true);
194
+ });
195
+
196
+ test('should return true for content with braced variables', () => {
197
+ expect(hasVariables('size: "${self:disk}G"')).toBe(true);
198
+ expect(hasVariables('${system:url}')).toBe(true);
199
+ });
200
+
201
+ test('should return false for content without variables', () => {
202
+ expect(hasVariables('no variables')).toBe(false);
203
+ expect(hasVariables('dollar sign $ but not a variable')).toBe(false);
204
+ });
205
+ });
206
+
207
+ describe('isValidVariableFormat', () => {
208
+ test('should validate correct variable formats', () => {
209
+ expect(isValidVariableFormat('$self:container_ip')).toBe(true);
210
+ expect(isValidVariableFormat('$system:management.ip')).toBe(true);
211
+ expect(isValidVariableFormat('$secret:api_key')).toBe(true);
212
+ expect(isValidVariableFormat('$capability:dns_external.nameserver')).toBe(true);
213
+ });
214
+
215
+ test('should validate correct braced variable formats', () => {
216
+ expect(isValidVariableFormat('${self:disk}')).toBe(true);
217
+ expect(isValidVariableFormat('${system:url}')).toBe(true);
218
+ expect(isValidVariableFormat('${capability:auth.endpoint}')).toBe(true);
219
+ });
220
+
221
+ test('should reject invalid variable formats', () => {
222
+ expect(isValidVariableFormat('self:container_ip')).toBe(false); // missing $
223
+ expect(isValidVariableFormat('$unknown:value')).toBe(false); // invalid type
224
+ expect(isValidVariableFormat('$self:')).toBe(false); // missing path
225
+ expect(isValidVariableFormat('$self')).toBe(false); // missing colon and path
226
+ expect(isValidVariableFormat('$self:123')).toBe(false); // path starts with number
227
+ expect(isValidVariableFormat('$self:path with spaces')).toBe(false); // spaces not allowed
228
+ expect(isValidVariableFormat('${self:test')).toBe(false); // missing closing brace
229
+ expect(isValidVariableFormat('$self:test}')).toBe(false); // mismatched brace
230
+ });
231
+ });
@@ -0,0 +1,76 @@
1
+ import type { VariableReference } from './types';
2
+
3
+ /**
4
+ * Regular expression to match variable references
5
+ * Supports two syntaxes:
6
+ * 1. ${type:path.to.value} - Explicit braces for concatenation (e.g., "${self:disk}G")
7
+ * 2. $type:path.to.value - Simple syntax for standalone variables (e.g., "vmid = $self:vmid")
8
+ *
9
+ * Examples:
10
+ * - $self:container_ip
11
+ * - ${self:disk}G
12
+ * - $capability:dns_registrar.primary_domain
13
+ * - ${system:base_url}/api
14
+ *
15
+ * Path must start with letter or underscore, not digit
16
+ */
17
+ 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;
19
+
20
+ /**
21
+ * Parse template content to extract variable references
22
+ *
23
+ * Policy function (Rule 10.1) - parses only, no side effects
24
+ *
25
+ * @param content - Template content
26
+ * @returns Array of variable references found in template
27
+ */
28
+ export function parseVariables(content: string): VariableReference[] {
29
+ const variables: VariableReference[] = [];
30
+ const matches = content.matchAll(VARIABLE_PATTERN);
31
+
32
+ for (const match of matches) {
33
+ const [raw, bracedType, bracedPath, simpleType, simplePath] = match;
34
+
35
+ // Check which syntax matched (braced vs simple)
36
+ const type = bracedType || simpleType;
37
+ const path = bracedPath || simplePath;
38
+
39
+ if (type && path) {
40
+ variables.push({
41
+ type: type as VariableReference['type'],
42
+ path,
43
+ raw,
44
+ });
45
+ }
46
+ }
47
+
48
+ return variables;
49
+ }
50
+
51
+ /**
52
+ * Check if a string contains any variable references
53
+ *
54
+ * @param content - String to check
55
+ * @returns True if content contains variables
56
+ */
57
+ export function hasVariables(content: string): boolean {
58
+ // 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_.-]*)/;
61
+ return pattern.test(content);
62
+ }
63
+
64
+ /**
65
+ * Validate variable reference format
66
+ *
67
+ * @param variable - Variable string to validate
68
+ * @returns True if format is valid
69
+ */
70
+ export function isValidVariableFormat(variable: string): boolean {
71
+ // Path must start with letter or underscore, not digit
72
+ // Accepts both ${type:path} and $type:path
73
+ 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_.-]*)$/;
75
+ return pattern.test(variable);
76
+ }