@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
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, statSync } from 'node:fs';
3
- import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
3
+ import { chmod, cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join, relative } from 'node:path';
5
5
  import { and, eq } from 'drizzle-orm';
6
6
  import { generateInventory } from '../ansible/inventory';
@@ -209,6 +209,31 @@ export async function readTemplateFiles(
209
209
  * @param outputPath - Base output path
210
210
  * @param files - Generated files to write
211
211
  */
212
+ /**
213
+ * Copy each `ansible/roles/<role>/files/` directory from the module source
214
+ * to the generated output, verbatim (preserving binary content + mode bits).
215
+ *
216
+ * These hold Ansible role-local static assets — they may be binaries and
217
+ * don't need template variable resolution, so the standard template
218
+ * pipeline (which is utf-8) can't handle them.
219
+ */
220
+ export async function copyAnsibleRoleFilesDirs(
221
+ modulePath: string,
222
+ outputPath: string,
223
+ ): Promise<void> {
224
+ const rolesDir = join(modulePath, 'ansible', 'roles');
225
+ if (!existsSync(rolesDir)) return;
226
+ const roles = await readdir(rolesDir, { withFileTypes: true });
227
+ for (const role of roles) {
228
+ if (!role.isDirectory()) continue;
229
+ const srcFilesDir = join(rolesDir, role.name, 'files');
230
+ if (!existsSync(srcFilesDir)) continue;
231
+ const destFilesDir = join(outputPath, 'ansible', 'roles', role.name, 'files');
232
+ await mkdir(dirname(destFilesDir), { recursive: true });
233
+ await cp(srcFilesDir, destFilesDir, { recursive: true, preserveTimestamps: true });
234
+ }
235
+ }
236
+
212
237
  export async function writeGeneratedFiles(
213
238
  outputPath: string,
214
239
  files: GeneratedFile[],
@@ -621,7 +646,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
621
646
  // biome-ignore lint/style/noNonNullAssertion: value is NOT NULL in schema, guaranteed to exist
622
647
  context.selfConfig.vmid = String(vmidConfig.value!);
623
648
  // biome-ignore lint/style/noNonNullAssertion: value is NOT NULL in schema, guaranteed to exist
624
- context.selfConfig.container_ip = ipConfig.value!;
649
+ context.selfConfig.target_ip = ipConfig.value!;
625
650
  }
626
651
 
627
652
  // Add infrastructure properties to context (target_node, lxc_template, storage)
@@ -913,6 +938,22 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
913
938
  };
914
939
  }
915
940
 
941
+ // Execution: Copy Ansible role-local `files/` directories verbatim.
942
+ // These hold static assets (binaries, certs, scripts, blobs) used by
943
+ // Ansible's `copy` module. They're outside the template pipeline because
944
+ // (a) they don't need variable resolution and (b) they may be binary —
945
+ // utf-8 round-tripping would corrupt them. The template pipeline only
946
+ // handles .tpl/.j2/.yml/.yaml/.tf files.
947
+ try {
948
+ await copyAnsibleRoleFilesDirs(modulePath, outputPath);
949
+ } catch (error) {
950
+ return {
951
+ success: false,
952
+ error: 'Failed to copy Ansible role files/ directories',
953
+ details: error,
954
+ };
955
+ }
956
+
916
957
  // Execution: Copy capability-provided Ansible tasks into generated output
917
958
  // When a module requires capabilities that provide Ansible tasks (e.g., dns_registrar),
918
959
  // copy those tasks so the consumer's playbook can include them.
@@ -19,7 +19,7 @@ describe('CompletionHarness', () => {
19
19
  CELILO_DB_PATH: ctx.dbPath,
20
20
  CELILO_DATA_DIR: ctx.dataDir,
21
21
  });
22
- harness = new CompletionHarness(ctx.cli);
22
+ harness = new CompletionHarness(cli);
23
23
  });
24
24
 
25
25
  afterEach(async () => {
@@ -6,13 +6,13 @@
6
6
  */
7
7
 
8
8
  import { expect } from 'bun:test';
9
- import { runCli } from './cli';
9
+ import type { CLIContext } from './cli-context';
10
10
 
11
11
  /**
12
12
  * Test harness for CLI completion
13
13
  */
14
14
  export class CompletionHarness {
15
- constructor(private cli: string) {}
15
+ constructor(private cli: CLIContext) {}
16
16
 
17
17
  /**
18
18
  * Get completions for a given set of words
@@ -26,10 +26,10 @@ export class CompletionHarness {
26
26
  // If partial=false, we're completing the next word after all current words
27
27
  const currentIndex = partial && words.length > 0 ? words.length - 1 : words.length;
28
28
  try {
29
- const result = runCli(this.cli, `--get-completions ${words.join(' ')} ${currentIndex}`);
29
+ const result = await this.cli.run(`--get-completions ${words.join(' ')} ${currentIndex}`);
30
30
 
31
31
  // Parse completion output (one per line)
32
- return result.split('\n').filter(Boolean);
32
+ return result.stdout.split('\n').filter(Boolean);
33
33
  } catch (_error) {
34
34
  // Completion errors return empty array
35
35
  return [];
@@ -9,6 +9,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { createDbClient } from '@/db/client';
12
+ import { buildResolutionContext } from './context';
12
13
  import { resolveVariable } from './resolver';
13
14
  import type { ResolutionContext } from './types';
14
15
 
@@ -217,4 +218,206 @@ describe('Capability data $self: variable resolution', () => {
217
218
  expect(result.error).toContain("has not configured 'ip.primary'");
218
219
  }
219
220
  });
221
+
222
+ test('buildResolutionContext resolves capability-derived variable even when DB has raw unresolved $self: value', async () => {
223
+ // Regression: hook config was assembled from raw DB reads without applying
224
+ // capability derivation. When lunacycle stores primary_domain as "$self:primary_domain"
225
+ // (because the DB guard skipped persisting the resolved value), the hook
226
+ // received the literal template string instead of "iamtheinternet.org".
227
+ // After the fix, buildResolutionContext always returns the resolved value in selfConfig.
228
+ const testDir = mkdtempSync(join(tmpdir(), 'test-cap-self-raw-'));
229
+ testDirs.push(testDir);
230
+ const db = createDbClient({ path: join(testDir, 'test.db') });
231
+
232
+ db.$client
233
+ .prepare(
234
+ 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)',
235
+ )
236
+ .run(
237
+ 'namecheap',
238
+ 'Namecheap',
239
+ '1.0.0',
240
+ '/tmp/modules/namecheap',
241
+ 'VERIFIED',
242
+ JSON.stringify({ id: 'namecheap', name: 'Namecheap', version: '1.0.0' }),
243
+ );
244
+
245
+ db.$client
246
+ .prepare('INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?)')
247
+ .run('namecheap', 'primary_domain', 'iamtheinternet.org');
248
+
249
+ db.$client
250
+ .prepare(
251
+ 'INSERT INTO capabilities (module_id, capability_name, version, data) VALUES (?, ?, ?, ?)',
252
+ )
253
+ .run(
254
+ 'namecheap',
255
+ 'dns_registrar',
256
+ '2.0.0',
257
+ JSON.stringify({ primary_domain: '$self:primary_domain' }),
258
+ );
259
+
260
+ const consumerManifest = {
261
+ id: 'lunacycle',
262
+ name: 'LunaCycle',
263
+ version: '1.0.0',
264
+ celilo_contract: '1.0',
265
+ variables: {
266
+ owns: [
267
+ {
268
+ name: 'primary_domain',
269
+ type: 'string',
270
+ required: false,
271
+ source: 'capability',
272
+ derive_from: '$capability:dns_registrar.primary_domain',
273
+ },
274
+ ],
275
+ },
276
+ };
277
+
278
+ db.$client
279
+ .prepare(
280
+ 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)',
281
+ )
282
+ .run(
283
+ 'lunacycle',
284
+ 'LunaCycle',
285
+ '1.0.0',
286
+ '/tmp/modules/lunacycle',
287
+ 'INSTALLED',
288
+ JSON.stringify(consumerManifest),
289
+ );
290
+
291
+ // Simulate raw DB state: primary_domain stored as unresolved template string
292
+ db.$client
293
+ .prepare('INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?)')
294
+ .run('lunacycle', 'primary_domain', '$self:primary_domain');
295
+
296
+ const context = await buildResolutionContext('lunacycle', db);
297
+
298
+ // selfConfig should have the resolved value, not the raw DB "$self:primary_domain"
299
+ expect(context.selfConfig.primary_domain).toBe('iamtheinternet.org');
300
+
301
+ // Simulate the hook installConfigMap merge logic from module-deploy.ts:
302
+ // Raw DB read produces the unresolved string; the fix overwrites it from context.selfConfig
303
+ const installConfigMap: Record<string, unknown> = { primary_domain: '$self:primary_domain' };
304
+ for (const [key, value] of Object.entries(context.selfConfig)) {
305
+ if (
306
+ !(key in installConfigMap) ||
307
+ (typeof installConfigMap[key] === 'string' &&
308
+ (installConfigMap[key] as string).startsWith('$'))
309
+ ) {
310
+ installConfigMap[key] = value;
311
+ }
312
+ }
313
+ expect(installConfigMap.primary_domain).toBe('iamtheinternet.org');
314
+ });
315
+
316
+ test('hook config merge does not overwrite user-set values with capability-derived ones', async () => {
317
+ // If a user explicitly sets a value that happens to match a capability-derived variable,
318
+ // their value (which does NOT start with $) should be preserved.
319
+ const installConfigMap: Record<string, unknown> = {
320
+ primary_domain: 'my-custom-domain.com', // user-set, no $ prefix
321
+ other_key: '$self:unresolved', // raw template — should be overwritten
322
+ };
323
+
324
+ const resolvedSelfConfig: Record<string, string> = {
325
+ primary_domain: 'iamtheinternet.org', // capability-derived value
326
+ other_key: 'resolved-value',
327
+ new_key: 'auto-derived',
328
+ };
329
+
330
+ for (const [key, value] of Object.entries(resolvedSelfConfig)) {
331
+ if (
332
+ !(key in installConfigMap) ||
333
+ (typeof installConfigMap[key] === 'string' &&
334
+ (installConfigMap[key] as string).startsWith('$'))
335
+ ) {
336
+ installConfigMap[key] = value;
337
+ }
338
+ }
339
+
340
+ // User-set value preserved (no $ prefix → not overwritten)
341
+ expect(installConfigMap.primary_domain).toBe('my-custom-domain.com');
342
+ // Unresolved template overwritten
343
+ expect(installConfigMap.other_key).toBe('resolved-value');
344
+ // New keys from context added
345
+ expect(installConfigMap.new_key).toBe('auto-derived');
346
+ });
347
+
348
+ test('buildResolutionContext resolves $self: refs in capability data for consumer module', async () => {
349
+ // Regression test: lunacycle's primary_domain (source: capability, derive_from:
350
+ // "$capability:dns_registrar.primary_domain") should resolve to the provider's
351
+ // actual configured value, not the raw "$self:primary_domain" template string.
352
+ const testDir = mkdtempSync(join(tmpdir(), 'test-cap-self-ctx-'));
353
+ testDirs.push(testDir);
354
+ const db = createDbClient({ path: join(testDir, 'test.db') });
355
+
356
+ // Provider module (namecheap) with primary_domain configured
357
+ db.$client
358
+ .prepare(
359
+ 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)',
360
+ )
361
+ .run(
362
+ 'namecheap',
363
+ 'Namecheap',
364
+ '1.0.0',
365
+ '/tmp/modules/namecheap',
366
+ 'VERIFIED',
367
+ JSON.stringify({ id: 'namecheap', name: 'Namecheap', version: '1.0.0' }),
368
+ );
369
+
370
+ db.$client
371
+ .prepare('INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?)')
372
+ .run('namecheap', 'primary_domain', 'iamtheinternet.org');
373
+
374
+ // Capability data stored with unresolved $self: reference (as registered at import time)
375
+ db.$client
376
+ .prepare(
377
+ 'INSERT INTO capabilities (module_id, capability_name, version, data) VALUES (?, ?, ?, ?)',
378
+ )
379
+ .run(
380
+ 'namecheap',
381
+ 'dns_registrar',
382
+ '2.0.0',
383
+ JSON.stringify({ provider: 'namecheap', primary_domain: '$self:primary_domain' }),
384
+ );
385
+
386
+ // Consumer module (lunacycle) with capability-derived primary_domain
387
+ const consumerManifest = {
388
+ id: 'lunacycle',
389
+ name: 'LunaCycle',
390
+ version: '1.0.0',
391
+ celilo_contract: '1.0',
392
+ variables: {
393
+ owns: [
394
+ {
395
+ name: 'primary_domain',
396
+ type: 'string',
397
+ required: false,
398
+ source: 'capability',
399
+ derive_from: '$capability:dns_registrar.primary_domain',
400
+ },
401
+ ],
402
+ },
403
+ };
404
+
405
+ db.$client
406
+ .prepare(
407
+ 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)',
408
+ )
409
+ .run(
410
+ 'lunacycle',
411
+ 'LunaCycle',
412
+ '1.0.0',
413
+ '/tmp/modules/lunacycle',
414
+ 'INSTALLED',
415
+ JSON.stringify(consumerManifest),
416
+ );
417
+
418
+ const context = await buildResolutionContext('lunacycle', db);
419
+
420
+ // primary_domain should be resolved to the provider's actual value
421
+ expect(context.selfConfig.primary_domain).toBe('iamtheinternet.org');
422
+ });
220
423
  });
@@ -166,7 +166,7 @@ describe('Variable Context', () => {
166
166
 
167
167
  db.insert(moduleConfigs)
168
168
  .values([
169
- { moduleId: 'homebridge', key: 'container_ip', value: '192.168.0.50' },
169
+ { moduleId: 'homebridge', key: 'target_ip', value: '192.168.0.50' },
170
170
  { moduleId: 'homebridge', key: 'hostname', value: 'homebridge' },
171
171
  ])
172
172
  .run();
@@ -174,7 +174,7 @@ describe('Variable Context', () => {
174
174
  const context = await buildResolutionContext('homebridge', db);
175
175
 
176
176
  expect(context.moduleId).toBe('homebridge');
177
- expect(context.selfConfig.container_ip).toBe('192.168.0.50');
177
+ expect(context.selfConfig.target_ip).toBe('192.168.0.50');
178
178
  expect(context.selfConfig.hostname).toBe('homebridge');
179
179
  // Auto-derived variables
180
180
  expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.0.50');
@@ -262,7 +262,7 @@ describe('Variable Context', () => {
262
262
  );
263
263
 
264
264
  db.insert(moduleConfigs)
265
- .values({ moduleId: 'caddy', key: 'container_ip', value: '10.0.20.10' })
265
+ .values({ moduleId: 'caddy', key: 'target_ip', value: '10.0.20.10' })
266
266
  .run();
267
267
 
268
268
  db.insert(secrets)
@@ -290,7 +290,7 @@ describe('Variable Context', () => {
290
290
 
291
291
  const context = await buildResolutionContext('caddy', db);
292
292
 
293
- expect(context.selfConfig.container_ip).toBe('10.0.20.10');
293
+ expect(context.selfConfig.target_ip).toBe('10.0.20.10');
294
294
  expect(context.secrets.ssl_cert).toBe('cert_data');
295
295
  expect(context.capabilities.dns_external).toBeDefined();
296
296
  expect(context.systemConfig['dns.primary']).toBe('192.168.0.1');
@@ -303,7 +303,7 @@ describe('Variable Context', () => {
303
303
  // Should have auto-derived inventory variables
304
304
  expect(context.selfConfig['inventory.ansible_user']).toBe('root');
305
305
  expect(context.selfConfig['inventory.groups']).toBe('empty-module');
306
- // Should not have ansible_host (no container_ip)
306
+ // Should not have ansible_host (no target_ip)
307
307
  expect(context.selfConfig['inventory.ansible_host']).toBeUndefined();
308
308
  expect(context.secrets).toEqual({});
309
309
  expect(context.capabilities).toEqual({});
@@ -367,18 +367,18 @@ describe('Variable Context', () => {
367
367
  expect(context.selfConfig['requires.machine.memory']).toBeUndefined();
368
368
  });
369
369
 
370
- test('should auto-derive inventory variables from container_ip', async () => {
370
+ test('should auto-derive inventory variables from target_ip', async () => {
371
371
  db.$client.run(
372
372
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`,
373
373
  );
374
374
 
375
375
  db.insert(moduleConfigs)
376
- .values({ moduleId: 'test-module', key: 'container_ip', value: '10.0.10.10/24' })
376
+ .values({ moduleId: 'test-module', key: 'target_ip', value: '10.0.10.10/24' })
377
377
  .run();
378
378
 
379
379
  const context = await buildResolutionContext('test-module', db);
380
380
 
381
- // Should strip CIDR from container_ip
381
+ // Should strip CIDR from target_ip
382
382
  expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.10.10');
383
383
  expect(context.selfConfig['inventory.ansible_user']).toBe('root');
384
384
  expect(context.selfConfig['inventory.groups']).toBe('test-module');
@@ -391,7 +391,7 @@ describe('Variable Context', () => {
391
391
 
392
392
  db.insert(moduleConfigs)
393
393
  .values([
394
- { moduleId: 'custom', key: 'container_ip', value: '10.0.20.10' },
394
+ { moduleId: 'custom', key: 'target_ip', value: '10.0.20.10' },
395
395
  { moduleId: 'custom', key: 'inventory.ansible_user', value: 'admin' },
396
396
  ])
397
397
  .run();
@@ -403,13 +403,13 @@ describe('Variable Context', () => {
403
403
  expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.20.10');
404
404
  });
405
405
 
406
- test('should handle container_ip without CIDR', async () => {
406
+ test('should handle target_ip without CIDR', async () => {
407
407
  db.$client.run(
408
408
  `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('no-cidr', 'No CIDR', '1.0.0', '/path', '{}')`,
409
409
  );
410
410
 
411
411
  db.insert(moduleConfigs)
412
- .values({ moduleId: 'no-cidr', key: 'container_ip', value: '192.168.1.100' })
412
+ .values({ moduleId: 'no-cidr', key: 'target_ip', value: '192.168.1.100' })
413
413
  .run();
414
414
 
415
415
  const context = await buildResolutionContext('no-cidr', db);
@@ -418,8 +418,8 @@ describe('Variable Context', () => {
418
418
  expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.1.100');
419
419
  });
420
420
 
421
- test('should auto-allocate IPAM resources when module declares vmid and container_ip', async () => {
422
- // Create module with manifest that declares vmid and container_ip
421
+ test('should auto-allocate IPAM resources when module declares vmid and target_ip', async () => {
422
+ // Create module with manifest that declares vmid and target_ip
423
423
  db.insert(modules)
424
424
  .values({
425
425
  id: 'auto-module',
@@ -433,7 +433,7 @@ describe('Variable Context', () => {
433
433
  variables: {
434
434
  owns: [
435
435
  { name: 'vmid', type: 'integer', required: true, source: 'user' },
436
- { name: 'container_ip', type: 'string', required: true, source: 'user' },
436
+ { name: 'target_ip', type: 'string', required: true, source: 'user' },
437
437
  ],
438
438
  },
439
439
  requires: {
@@ -447,9 +447,9 @@ describe('Variable Context', () => {
447
447
 
448
448
  const context = await buildResolutionContext('auto-module', db);
449
449
 
450
- // Should have auto-allocated vmid and container_ip
450
+ // Should have auto-allocated vmid and target_ip
451
451
  expect(context.selfConfig.vmid).toBe('2100');
452
- expect(context.selfConfig.container_ip).toBe('10.0.10.10/24');
452
+ expect(context.selfConfig.target_ip).toBe('10.0.10.10/24');
453
453
 
454
454
  // Verify allocation persisted to database
455
455
  const allocations = await db.select().from(ipAllocations).all();
@@ -465,7 +465,7 @@ describe('Variable Context', () => {
465
465
  .where(eq(moduleConfigs.moduleId, 'auto-module'))
466
466
  .all();
467
467
  const vmidConfig = configs.find((c) => c.key === 'vmid');
468
- const ipConfig = configs.find((c) => c.key === 'container_ip');
468
+ const ipConfig = configs.find((c) => c.key === 'target_ip');
469
469
  expect(vmidConfig?.value).toBe('2100');
470
470
  expect(ipConfig?.value).toBe('10.0.10.10/24');
471
471
  });
@@ -485,7 +485,7 @@ describe('Variable Context', () => {
485
485
  variables: {
486
486
  owns: [
487
487
  { name: 'vmid', type: 'integer', required: true, source: 'user' },
488
- { name: 'container_ip', type: 'string', required: true, source: 'user' },
488
+ { name: 'target_ip', type: 'string', required: true, source: 'user' },
489
489
  ],
490
490
  },
491
491
  },
@@ -506,14 +506,14 @@ describe('Variable Context', () => {
506
506
 
507
507
  // Should reuse existing allocation
508
508
  expect(context.selfConfig.vmid).toBe('2150');
509
- expect(context.selfConfig.container_ip).toBe('10.0.10.50/24');
509
+ expect(context.selfConfig.target_ip).toBe('10.0.10.50/24');
510
510
 
511
511
  // Should not create duplicate allocation
512
512
  const allocations = await db.select().from(ipAllocations).all();
513
513
  expect(allocations).toHaveLength(1);
514
514
  });
515
515
 
516
- test('should skip IPAM allocation if vmid and container_ip already configured', async () => {
516
+ test('should skip IPAM allocation if vmid and target_ip already configured', async () => {
517
517
  // Create module with existing config
518
518
  db.insert(modules)
519
519
  .values({
@@ -528,7 +528,7 @@ describe('Variable Context', () => {
528
528
  variables: {
529
529
  owns: [
530
530
  { name: 'vmid', type: 'integer', required: true, source: 'user' },
531
- { name: 'container_ip', type: 'string', required: true, source: 'user' },
531
+ { name: 'target_ip', type: 'string', required: true, source: 'user' },
532
532
  ],
533
533
  },
534
534
  },
@@ -538,7 +538,7 @@ describe('Variable Context', () => {
538
538
  db.insert(moduleConfigs)
539
539
  .values([
540
540
  { moduleId: 'manual-config', key: 'vmid', value: '9999' },
541
- { moduleId: 'manual-config', key: 'container_ip', value: '192.168.99.99/24' },
541
+ { moduleId: 'manual-config', key: 'target_ip', value: '192.168.99.99/24' },
542
542
  ])
543
543
  .run();
544
544
 
@@ -546,15 +546,15 @@ describe('Variable Context', () => {
546
546
 
547
547
  // Should use existing config
548
548
  expect(context.selfConfig.vmid).toBe('9999');
549
- expect(context.selfConfig.container_ip).toBe('192.168.99.99/24');
549
+ expect(context.selfConfig.target_ip).toBe('192.168.99.99/24');
550
550
 
551
551
  // Should not create allocation
552
552
  const allocations = await db.select().from(ipAllocations).all();
553
553
  expect(allocations).toHaveLength(0);
554
554
  });
555
555
 
556
- test('should skip IPAM allocation for VPS modules without container_ip', async () => {
557
- // Create VPS module (no container_ip variable)
556
+ test('should skip IPAM allocation for VPS modules without target_ip', async () => {
557
+ // Create VPS module (no target_ip variable)
558
558
  db.insert(modules)
559
559
  .values({
560
560
  id: 'vps-module',
@@ -576,7 +576,7 @@ describe('Variable Context', () => {
576
576
 
577
577
  // Should not allocate IPAM resources
578
578
  expect(context.selfConfig.vmid).toBeUndefined();
579
- expect(context.selfConfig.container_ip).toBeUndefined();
579
+ expect(context.selfConfig.target_ip).toBeUndefined();
580
580
 
581
581
  const allocations = await db.select().from(ipAllocations).all();
582
582
  expect(allocations).toHaveLength(0);
@@ -597,7 +597,7 @@ describe('Variable Context', () => {
597
597
  variables: {
598
598
  owns: [
599
599
  { name: 'vmid', type: 'integer', required: true, source: 'user' },
600
- { name: 'container_ip', type: 'string', required: true, source: 'user' },
600
+ { name: 'target_ip', type: 'string', required: true, source: 'user' },
601
601
  ],
602
602
  },
603
603
  },
@@ -618,7 +618,7 @@ describe('Variable Context', () => {
618
618
  variables: {
619
619
  owns: [
620
620
  { name: 'vmid', type: 'integer', required: true, source: 'user' },
621
- { name: 'container_ip', type: 'string', required: true, source: 'user' },
621
+ { name: 'target_ip', type: 'string', required: true, source: 'user' },
622
622
  ],
623
623
  },
624
624
  },
@@ -634,8 +634,8 @@ describe('Variable Context', () => {
634
634
  expect(context2.selfConfig.vmid).toBe('2101');
635
635
 
636
636
  // Should have sequential IPs
637
- expect(context1.selfConfig.container_ip).toBe('10.0.10.10/24');
638
- expect(context2.selfConfig.container_ip).toBe('10.0.10.11/24');
637
+ expect(context1.selfConfig.target_ip).toBe('10.0.10.10/24');
638
+ expect(context2.selfConfig.target_ip).toBe('10.0.10.11/24');
639
639
 
640
640
  const allocations = await db.select().from(ipAllocations).all();
641
641
  expect(allocations).toHaveLength(2);
@@ -1254,7 +1254,7 @@ describe('Variable Context', () => {
1254
1254
 
1255
1255
  test('should auto-derive inventory variables in buildContextFromData', () => {
1256
1256
  const context = buildContextFromData('my-module', {
1257
- selfConfig: { container_ip: '10.0.30.15/24', hostname: 'my-host' },
1257
+ selfConfig: { target_ip: '10.0.30.15/24', hostname: 'my-host' },
1258
1258
  });
1259
1259
 
1260
1260
  expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.30.15');