@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.
Files changed (145) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -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[],
@@ -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
  });
@@ -15,6 +15,7 @@ import type { ModuleManifest } from '../manifest/schema';
15
15
  import { decryptSecret } from '../secrets/encryption';
16
16
  import { getOrCreateMasterKey } from '../secrets/master-key';
17
17
  import { applyDeclarativeDerivations } from './declarative-derivation';
18
+ import { applyIndex, parsePath } from './parser';
18
19
  import type { ResolutionContext } from './types';
19
20
 
20
21
  /**
@@ -202,6 +203,34 @@ function autoDeriveInventoryVariables(
202
203
  return derived;
203
204
  }
204
205
 
206
+ /**
207
+ * Recursively resolve "$self:key" strings in a capability data object
208
+ * using the provider module's actual config values. Supports the
209
+ * optional `[N]` array-index suffix (e.g. `$self:domains[0]`) so a
210
+ * provider can declare computed aliases — `primary_domain:
211
+ * $self:domains[0]` — without the framework storing the alias as a
212
+ * separate value. Non-string values and strings that don't start
213
+ * with "$self:" are returned unchanged.
214
+ */
215
+ function resolveSelfRefsInObject(
216
+ obj: Record<string, unknown>,
217
+ providerConfig: Record<string, unknown>,
218
+ ): Record<string, unknown> {
219
+ const result: Record<string, unknown> = {};
220
+ for (const [key, value] of Object.entries(obj)) {
221
+ if (typeof value === 'string' && value.startsWith('$self:')) {
222
+ const { name, index } = parsePath(value.slice(6));
223
+ const resolved = applyIndex(providerConfig[name], index);
224
+ result[key] = resolved !== undefined ? resolved : value;
225
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
226
+ result[key] = resolveSelfRefsInObject(value as Record<string, unknown>, providerConfig);
227
+ } else {
228
+ result[key] = value;
229
+ }
230
+ }
231
+ return result;
232
+ }
233
+
205
234
  /**
206
235
  * Build resolution context for a module
207
236
  *
@@ -418,7 +447,26 @@ export async function buildResolutionContext(
418
447
  } else {
419
448
  data = row.data;
420
449
  }
421
- capabilitiesMap[row.capabilityName] = data as Record<string, unknown>;
450
+
451
+ // Lazily resolve $self: references in capability data using the provider
452
+ // module's actual config values. Capability data is stored with unresolved
453
+ // $self: references (e.g. namecheap stores primary_domain: "$self:primary_domain")
454
+ // because the real value isn't known at import time. We resolve them here so
455
+ // consuming modules see the actual values (e.g. "iamtheinternet.org") rather
456
+ // than the raw template strings.
457
+ const providerConfigs = db
458
+ .select()
459
+ .from(moduleConfigs)
460
+ .where(eq(moduleConfigs.moduleId, row.moduleId))
461
+ .all();
462
+ const providerConfigMap: Record<string, unknown> = {};
463
+ for (const c of providerConfigs) {
464
+ providerConfigMap[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
465
+ }
466
+ capabilitiesMap[row.capabilityName] = resolveSelfRefsInObject(
467
+ data as Record<string, unknown>,
468
+ providerConfigMap,
469
+ );
422
470
  }
423
471
 
424
472
  // Fetch system configuration (for $system: variables)