@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,3 +1,4 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
1
2
  import { join } from 'node:path';
2
3
  import { log } from '../cli/prompts';
3
4
  import { executeBuildWithProgress } from './build-stream';
@@ -288,39 +289,45 @@ async function attemptAutoImport(
288
289
  * @returns Execution result
289
290
  */
290
291
  /**
291
- * Auto-recover from a stale Terraform state lock.
292
- * Parses the lock ID from the error output and runs force-unlock.
292
+ * Detect a stale Terraform state lock and surface actionable guidance.
293
+ *
294
+ * We intentionally do NOT auto-delete: if Terraform crashed mid-apply the
295
+ * state file may be inconsistent, and blindly retrying could double-apply
296
+ * partial infrastructure changes. The user should verify state before
297
+ * unlocking.
293
298
  */
294
299
  async function autoForceUnlock(
295
300
  terraformDir: string,
296
301
  errorOutput: string,
297
- terraformEnv: Record<string, string>,
298
- noInteractive?: boolean,
302
+ _terraformEnv: Record<string, string>,
303
+ _noInteractive?: boolean,
299
304
  ): Promise<boolean> {
300
305
  const lockIdMatch = errorOutput.match(/ID:\s+([0-9a-f-]+)/);
301
- if (!lockIdMatch) {
302
- log.warn('State lock detected but could not parse lock ID');
303
- return false;
306
+ const lockId = lockIdMatch?.[1];
307
+
308
+ const lockFile = join(terraformDir, '.terraform.tfstate.lock.info');
309
+ const isLocalLock = existsSync(lockFile);
310
+
311
+ // Try to surface who holds the lock and when it was created.
312
+ let lockInfo = '';
313
+ if (isLocalLock) {
314
+ try {
315
+ const raw = JSON.parse(readFileSync(lockFile, 'utf-8')) as Record<string, string>;
316
+ const who = raw.Who ?? 'unknown';
317
+ const created = raw.Created ? new Date(raw.Created).toLocaleString() : 'unknown';
318
+ const op = raw.Operation ?? 'unknown';
319
+ lockInfo = ` Held by: ${who}\n Operation: ${op}\n Created: ${created}`;
320
+ } catch {
321
+ lockInfo = ` Lock file: ${lockFile}`;
322
+ }
323
+ } else if (lockId) {
324
+ lockInfo = ` Lock ID: ${lockId}`;
304
325
  }
305
326
 
306
- const lockId = lockIdMatch[1];
307
- log.warn(`Stale state lock detected (${lockId}) -- auto-recovering...`);
308
-
309
- const result = await executeBuildWithProgress({
310
- command: 'terraform',
311
- args: ['force-unlock', '-force', lockId],
312
- cwd: terraformDir,
313
- title: 'Removing stale state lock',
314
- env: { ...terraformEnv, TF_IN_AUTOMATION: '1' },
315
- noInteractive,
316
- });
317
-
318
- if (result.success) {
319
- log.success('State lock removed');
320
- return true;
321
- }
327
+ log.error(
328
+ `Terraform state is locked — another deploy may be running, or a previous one crashed.\n${lockInfo}\n\nIf no other deploy is running, unlock with:\n celilo module terraform-unlock <module-id>`,
329
+ );
322
330
 
323
- log.error(`Failed to remove state lock: ${result.error || result.output}`);
324
331
  return false;
325
332
  }
326
333
 
@@ -4,6 +4,7 @@
4
4
  * Validates module readiness and auto-prepares (generate/build) if needed
5
5
  */
6
6
 
7
+ import { compareConsumerToProvider } from '@celilo/capabilities';
7
8
  import { and, eq } from 'drizzle-orm';
8
9
  import type { DbClient } from '../db/client';
9
10
  import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
@@ -56,14 +57,58 @@ export async function validateAndPrepareDeployment(
56
57
 
57
58
  const manifest = module.manifestData as ModuleManifest;
58
59
 
59
- // Always regenerate to ensure templates and config are up-to-date
60
+ // Check capability dependencies first no point interviewing or generating
61
+ // if required provider modules aren't deployed yet.
62
+ if (manifest.requires?.capabilities) {
63
+ const missingCapabilities = await findMissingCapabilities(manifest.requires.capabilities, db);
64
+
65
+ if (missingCapabilities.length > 0) {
66
+ const capList = missingCapabilities.join(', ');
67
+ return {
68
+ success: false,
69
+ error: `Missing required capabilities: ${capList}\nDeploy provider modules first.\n\nSuggested order:\n${getSuggestedDeploymentOrder(missingCapabilities, moduleId)}`,
70
+ };
71
+ }
72
+
73
+ // Provider exists — verify the provider's claimed capability version is
74
+ // compatible with what this module declares it requires. Catches the
75
+ // class of break where a capability had a major bump (e.g. dns_registrar
76
+ // 4.0.0 → 5.0.0) and the consumer was rebuilt against the new major but
77
+ // the provider on this system still ships the old one (or vice-versa).
78
+ const versionMismatches = await findCapabilityVersionMismatches(
79
+ manifest.requires.capabilities,
80
+ db,
81
+ );
82
+ if (versionMismatches.length > 0) {
83
+ return {
84
+ success: false,
85
+ error: formatVersionMismatchError(moduleId, versionMismatches),
86
+ };
87
+ }
88
+ }
89
+
90
+ // Check for missing required variables BEFORE generating — if any are missing
91
+ // we return them for the caller to interview, then the caller re-invokes deploy
92
+ // after the user has answered. This prevents generation from failing mid-way
93
+ // on an unresolved $self: variable.
94
+ const missingVariables = await findMissingRequiredVariables(moduleId, manifest, db);
95
+ if (missingVariables.length > 0) {
96
+ return {
97
+ success: true,
98
+ autoGenerated: false,
99
+ autoBuilt: false,
100
+ missingVariables,
101
+ };
102
+ }
103
+
104
+ // All variables present — safe to generate templates now.
60
105
  const generatedPath = getGeneratedPath(module.sourcePath, moduleId);
61
106
  const generateResult = await generateTemplates({
62
107
  moduleId,
63
108
  modulePath: module.sourcePath,
64
109
  outputPath: generatedPath,
65
110
  db,
66
- skipVariableValidation: true, // Deploy flow handles interview for missing variables
111
+ skipVariableValidation: true,
67
112
  });
68
113
 
69
114
  if (!generateResult.success) {
@@ -95,29 +140,10 @@ export async function validateAndPrepareDeployment(
95
140
  }
96
141
  }
97
142
 
98
- // Check capability dependencies (cannot auto-deploy dependencies)
99
- if (manifest.requires?.capabilities) {
100
- const missingCapabilities = await findMissingCapabilities(manifest.requires.capabilities, db);
101
-
102
- if (missingCapabilities.length > 0) {
103
- const capList = missingCapabilities.join(', ');
104
- return {
105
- success: false,
106
- error: `Missing required capabilities: ${capList}\nDeploy provider modules first.\n\nSuggested order:\n${getSuggestedDeploymentOrder(missingCapabilities, moduleId)}`,
107
- };
108
- }
109
- }
110
-
111
- // Check required variables are configured
112
- const missingVariables = await findMissingRequiredVariables(moduleId, manifest, db);
113
-
114
- // Return validation result with missing variables info
115
- // The caller (deploy command) will decide whether to prompt or fail
116
143
  return {
117
144
  success: true,
118
145
  autoGenerated,
119
146
  autoBuilt,
120
- missingVariables: missingVariables.length > 0 ? missingVariables : undefined,
121
147
  };
122
148
  }
123
149
 
@@ -145,6 +171,113 @@ async function findMissingCapabilities(
145
171
  return missing;
146
172
  }
147
173
 
174
+ interface CapabilityVersionMismatch {
175
+ capabilityName: string;
176
+ requiredVersion: string;
177
+ providedVersion: string;
178
+ providerModuleId: string;
179
+ reason: string;
180
+ }
181
+
182
+ /**
183
+ * Find capabilities whose installed providers can't satisfy what the
184
+ * consumer module's manifest declares it requires.
185
+ *
186
+ * Multi-provider semantics: a capability can have several installed
187
+ * providers (e.g., zone-scoped firewalls, or `dns_registrar` split
188
+ * across an internal-zone provider like knot-unbound-internal and an
189
+ * external-zone provider like namecheap). The deploy passes when at
190
+ * LEAST ONE provider is version-compatible with the consumer — that
191
+ * matches the runtime behaviour of `findCapabilityProvider`, which
192
+ * picks the right provider per zone. The mismatch error names the
193
+ * "best" (closest-version) incompatible provider so the operator
194
+ * sees an actionable suggestion.
195
+ *
196
+ * Uses `compareConsumerToProvider` from `@celilo/capabilities`, the
197
+ * same helper that powers the system audit's capability_abi check —
198
+ * so deploy and audit verdicts stay in lockstep.
199
+ */
200
+ async function findCapabilityVersionMismatches(
201
+ required: Array<{ name: string; version: string }>,
202
+ db: DbClient,
203
+ ): Promise<CapabilityVersionMismatch[]> {
204
+ const mismatches: CapabilityVersionMismatch[] = [];
205
+
206
+ for (const cap of required) {
207
+ const providers = await db
208
+ .select()
209
+ .from(capabilities)
210
+ .where(eq(capabilities.capabilityName, cap.name))
211
+ .all();
212
+ if (providers.length === 0) continue; // Missing-provider case is handled separately.
213
+
214
+ // If any provider is compatible, the consumer is fine — runtime's
215
+ // zone-aware lookup will pick that one for the relevant zone.
216
+ // Otherwise, capture the first provider's mismatch as the canonical
217
+ // example for the error message.
218
+ let exampleMismatch: {
219
+ provider: { version: string; moduleId: string };
220
+ reason: string;
221
+ } | null = null;
222
+ let anyCompatible = false;
223
+ for (const p of providers) {
224
+ const result = compareConsumerToProvider(cap.version, p.version);
225
+ if (result.compatible) {
226
+ anyCompatible = true;
227
+ break;
228
+ }
229
+ if (!exampleMismatch) {
230
+ exampleMismatch = { provider: p, reason: result.reason };
231
+ }
232
+ }
233
+ if (anyCompatible || !exampleMismatch) continue;
234
+
235
+ mismatches.push({
236
+ capabilityName: cap.name,
237
+ requiredVersion: cap.version,
238
+ providedVersion: exampleMismatch.provider.version,
239
+ providerModuleId: exampleMismatch.provider.moduleId,
240
+ reason: exampleMismatch.reason,
241
+ });
242
+ }
243
+
244
+ return mismatches;
245
+ }
246
+
247
+ /**
248
+ * Format a version-mismatch error message for the deploy refusal.
249
+ *
250
+ * The message names the consumer, the requirement, the actual provider
251
+ * version, and an actionable next step — so an operator can resolve the
252
+ * break without reading the source of `compareConsumerToProvider`.
253
+ */
254
+ function formatVersionMismatchError(
255
+ consumerModuleId: string,
256
+ mismatches: CapabilityVersionMismatch[],
257
+ ): string {
258
+ const lines: string[] = [`Capability version mismatch: cannot deploy '${consumerModuleId}'.`, ''];
259
+ for (const m of mismatches) {
260
+ lines.push(
261
+ ` ${m.capabilityName}: requires ${m.requiredVersion}, but ` +
262
+ `'${m.providerModuleId}' provides ${m.providedVersion}`,
263
+ );
264
+ if (m.reason === 'caller_minor_too_old') {
265
+ lines.push(
266
+ ` Fix: upgrade '${m.providerModuleId}' to a version that provides ` +
267
+ `${m.capabilityName}@${m.requiredVersion} or newer.`,
268
+ );
269
+ } else {
270
+ lines.push(
271
+ ` Fix: the major version differs. Either update '${consumerModuleId}' ` +
272
+ `to require ${m.capabilityName}@${m.providedVersion} (matching the ` +
273
+ `installed provider's major), or rebuild '${m.providerModuleId}' ` +
274
+ `against ${m.capabilityName}@${m.requiredVersion}.`,
275
+ );
276
+ }
277
+ }
278
+ return lines.join('\n');
279
+ }
280
+
148
281
  /**
149
282
  * Check if a capability is provided by any deployed module
150
283
  * Execution function - queries database
@@ -154,13 +287,24 @@ async function findMissingCapabilities(
154
287
  * @returns True if capability is provided
155
288
  */
156
289
  async function isCapabilityProvided(capabilityName: string, db: DbClient): Promise<boolean> {
157
- const result = await db
290
+ const cap = await db
158
291
  .select()
159
292
  .from(capabilities)
160
293
  .where(eq(capabilities.capabilityName, capabilityName))
161
294
  .get();
162
295
 
163
- return !!result;
296
+ if (!cap) return false;
297
+
298
+ // Also verify the provider module is actually deployed (VERIFIED or DEPLOYED),
299
+ // not just imported/configured. A capability registered by an undeployed
300
+ // module cannot serve hook requests at runtime.
301
+ const provider = await db
302
+ .select({ state: modules.state })
303
+ .from(modules)
304
+ .where(eq(modules.id, cap.moduleId))
305
+ .get();
306
+
307
+ return provider?.state === 'VERIFIED' || provider?.state === 'INSTALLED';
164
308
  }
165
309
 
166
310
  /**
@@ -0,0 +1,245 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { and, eq } from 'drizzle-orm';
6
+ import { type DbClient, getDb } from '../db/client';
7
+ import { moduleConfigs, modules, secrets } from '../db/schema';
8
+ import type { Ensure } from '../manifest/schema';
9
+ import { decryptSecret } from '../secrets/encryption';
10
+ import { getOrCreateMasterKey } from '../secrets/master-key';
11
+ import {
12
+ findEnsureOnProvider,
13
+ interviewForEnsureInputs,
14
+ renderEnsureRecipe,
15
+ } from './config-interview';
16
+
17
+ const ENSURE: Ensure = {
18
+ id: 'managed_domain',
19
+ description: 'Add a domain.',
20
+ inputs: [
21
+ { kind: 'append_to_array', target: 'config.additional_domains' },
22
+ {
23
+ kind: 'set_in_object',
24
+ target: 'secret.additional_ddns_passwords',
25
+ key: '{{value}}',
26
+ prompt: 'Namecheap DDNS password for {{value}}',
27
+ hint: 'Advanced DNS panel',
28
+ },
29
+ ],
30
+ post: 'redeploy_self',
31
+ };
32
+
33
+ describe('interviewForEnsureInputs', () => {
34
+ let tempDir: string;
35
+ let testDb: DbClient;
36
+
37
+ beforeEach(() => {
38
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-ensure-'));
39
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
40
+ testDb = getDb();
41
+ testDb
42
+ .insert(modules)
43
+ .values({
44
+ id: 'namecheap',
45
+ name: 'Namecheap',
46
+ sourcePath: tempDir,
47
+ version: '2.0.0',
48
+ manifestData: {
49
+ provides: {
50
+ capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }],
51
+ },
52
+ },
53
+ })
54
+ .run();
55
+ });
56
+
57
+ afterEach(() => {
58
+ rmSync(tempDir, { recursive: true, force: true });
59
+ process.env.CELILO_DB_PATH = undefined;
60
+ });
61
+
62
+ test('appends to array config and sets secret object key', async () => {
63
+ const promptedFor: string[] = [];
64
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
65
+ promptOverride: async (msg) => {
66
+ promptedFor.push(msg);
67
+ return 'super-secret-pw';
68
+ },
69
+ });
70
+
71
+ expect(result.success).toBe(true);
72
+ expect(result.alreadyApplied).toBe(false);
73
+ expect(promptedFor[0]).toContain('celilo.computer');
74
+
75
+ // config array
76
+ const configRow = testDb
77
+ .select()
78
+ .from(moduleConfigs)
79
+ .where(
80
+ and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'additional_domains')),
81
+ )
82
+ .get();
83
+ expect(configRow?.valueJson).toBeTruthy();
84
+ expect(JSON.parse(configRow?.valueJson ?? '[]')).toEqual(['celilo.computer']);
85
+
86
+ // secret JSON object
87
+ const secretRow = testDb
88
+ .select()
89
+ .from(secrets)
90
+ .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'additional_ddns_passwords')))
91
+ .get();
92
+ expect(secretRow).toBeTruthy();
93
+ if (!secretRow) return;
94
+ const masterKey = await getOrCreateMasterKey();
95
+ const decoded = decryptSecret(
96
+ {
97
+ encryptedValue: secretRow.encryptedValue,
98
+ iv: secretRow.iv,
99
+ authTag: secretRow.authTag,
100
+ },
101
+ masterKey,
102
+ );
103
+ expect(JSON.parse(decoded)).toEqual({ 'celilo.computer': 'super-secret-pw' });
104
+ });
105
+
106
+ test('idempotent — re-running with same value is a no-op', async () => {
107
+ await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
108
+ promptOverride: async () => 'pw1',
109
+ });
110
+
111
+ let promptCount = 0;
112
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
113
+ promptOverride: async () => {
114
+ promptCount++;
115
+ return 'should-not-be-used';
116
+ },
117
+ });
118
+
119
+ expect(result.success).toBe(true);
120
+ expect(result.alreadyApplied).toBe(true);
121
+ expect(promptCount).toBe(0);
122
+ });
123
+
124
+ test('preserves existing entries when adding a second domain', async () => {
125
+ await interviewForEnsureInputs('namecheap', ENSURE, 'first.com', testDb, {
126
+ promptOverride: async () => 'pw1',
127
+ });
128
+
129
+ await interviewForEnsureInputs('namecheap', ENSURE, 'second.com', testDb, {
130
+ promptOverride: async () => 'pw2',
131
+ });
132
+
133
+ const configRow = testDb
134
+ .select()
135
+ .from(moduleConfigs)
136
+ .where(
137
+ and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'additional_domains')),
138
+ )
139
+ .get();
140
+ expect(JSON.parse(configRow?.valueJson ?? '[]')).toEqual(['first.com', 'second.com']);
141
+
142
+ const secretRow = testDb
143
+ .select()
144
+ .from(secrets)
145
+ .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'additional_ddns_passwords')))
146
+ .get();
147
+ if (!secretRow) throw new Error('expected secret row');
148
+ const masterKey = await getOrCreateMasterKey();
149
+ const decoded = decryptSecret(
150
+ {
151
+ encryptedValue: secretRow.encryptedValue,
152
+ iv: secretRow.iv,
153
+ authTag: secretRow.authTag,
154
+ },
155
+ masterKey,
156
+ );
157
+ expect(JSON.parse(decoded)).toEqual({ 'first.com': 'pw1', 'second.com': 'pw2' });
158
+ });
159
+
160
+ // The "declined confirm short-circuits" test was removed when the
161
+ // confirm prompt itself was removed — running `module deploy <consumer>`
162
+ // is the user's consent for the cross-module config its hooks imply.
163
+ // See config-interview.ts and CADDY_HOSTNAME_LIST.md, Decision 6.
164
+
165
+ test('non-interactive mode applies directly', async () => {
166
+ // In production, non-interactive mode prints a recipe and aborts
167
+ // before reaching this function. The function itself supports being
168
+ // called non-interactively for tests / scripted use, applying inputs
169
+ // directly. promptOverride is still required for set_in_object inputs.
170
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
171
+ noInteractive: true,
172
+ promptOverride: async () => 'pw',
173
+ });
174
+ expect(result.success).toBe(true);
175
+ });
176
+ });
177
+
178
+ describe('findEnsureOnProvider', () => {
179
+ let tempDir: string;
180
+ let testDb: DbClient;
181
+
182
+ beforeEach(() => {
183
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-find-ensure-'));
184
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
185
+ testDb = getDb();
186
+ });
187
+
188
+ afterEach(() => {
189
+ rmSync(tempDir, { recursive: true, force: true });
190
+ process.env.CELILO_DB_PATH = undefined;
191
+ });
192
+
193
+ test('finds ensure by id on a registered provider', () => {
194
+ testDb
195
+ .insert(modules)
196
+ .values({
197
+ id: 'namecheap',
198
+ name: 'Namecheap',
199
+ sourcePath: tempDir,
200
+ version: '2.0.0',
201
+ manifestData: {
202
+ provides: {
203
+ capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }],
204
+ },
205
+ },
206
+ })
207
+ .run();
208
+
209
+ const found = findEnsureOnProvider('namecheap', 'managed_domain', testDb);
210
+ expect(found?.id).toBe('managed_domain');
211
+ });
212
+
213
+ test('returns null when module exists but ensure id does not match', () => {
214
+ testDb
215
+ .insert(modules)
216
+ .values({
217
+ id: 'namecheap',
218
+ name: 'Namecheap',
219
+ sourcePath: tempDir,
220
+ version: '2.0.0',
221
+ manifestData: { provides: { capabilities: [] } },
222
+ })
223
+ .run();
224
+ expect(findEnsureOnProvider('namecheap', 'managed_domain', testDb)).toBeNull();
225
+ });
226
+
227
+ test('returns null when provider module does not exist', () => {
228
+ expect(findEnsureOnProvider('ghost', 'managed_domain', testDb)).toBeNull();
229
+ });
230
+ });
231
+
232
+ describe('renderEnsureRecipe', () => {
233
+ test('mentions module id, ensure id, value, and post action', () => {
234
+ const recipe = renderEnsureRecipe('namecheap', ENSURE, 'celilo.computer');
235
+ expect(recipe).toContain('namecheap');
236
+ expect(recipe).toContain('managed_domain');
237
+ expect(recipe).toContain('celilo.computer');
238
+ expect(recipe).toContain('celilo module deploy namecheap');
239
+ });
240
+
241
+ test('renders the per-domain prompt template', () => {
242
+ const recipe = renderEnsureRecipe('namecheap', ENSURE, 'celilo.computer');
243
+ expect(recipe).toContain('Namecheap DDNS password for celilo.computer');
244
+ });
245
+ });