@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,694 @@
1
+ /**
2
+ * Interactive Configuration Interview Service
3
+ *
4
+ * Prompts users for missing required configuration during deployment
5
+ */
6
+
7
+ import * as p from '@clack/prompts';
8
+ import { and, eq } from 'drizzle-orm';
9
+ import { log, promptPassword, promptText } from '../cli/prompts';
10
+ import type { DbClient } from '../db/client';
11
+ import { moduleConfigs, secrets } from '../db/schema';
12
+ import { encryptSecret } from '../secrets/encryption';
13
+ import { deriveSecret, generateSecret } from '../secrets/generators';
14
+ import { getOrCreateMasterKey } from '../secrets/master-key';
15
+ import type { Machine } from '../types/infrastructure';
16
+ import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
17
+
18
+ export interface MissingVariable {
19
+ name: string;
20
+ source: 'user' | 'secret' | 'capability' | 'system';
21
+ description?: string;
22
+ /** Variable type from manifest (string, array, etc.) */
23
+ type?: string;
24
+ /** Derivation source (e.g., "$machine:ipAddress") */
25
+ derive_from?: string;
26
+ /** For multi-select: available options */
27
+ options?: Array<{ value: string; label: string; hint?: string }>;
28
+ /** Follow-up prompt for each selected option */
29
+ per_selection?: {
30
+ key_pattern: string;
31
+ prompt: string;
32
+ type?: string;
33
+ derive_from?: string;
34
+ };
35
+ /** Secret auto-generation config from manifest */
36
+ generate?: {
37
+ method: string;
38
+ length: number;
39
+ encoding: string;
40
+ };
41
+ }
42
+
43
+ export interface InterviewResult {
44
+ success: boolean;
45
+ configured: string[];
46
+ error?: string;
47
+ }
48
+
49
+ /**
50
+ * Resolve a $machine: derivation from an earmarked machine
51
+ *
52
+ * @returns Resolved value, or null if not resolvable
53
+ */
54
+ function resolveMachineDerivation(deriveFrom: string, machine: Machine): string | null {
55
+ const key = deriveFrom.replace('$machine:', '');
56
+
57
+ switch (key) {
58
+ case 'ipAddress':
59
+ return machine.ipAddress;
60
+ case 'hostname':
61
+ return machine.hostname;
62
+ case 'zone':
63
+ return machine.zone;
64
+ case 'zones':
65
+ // Return comma-separated list of zones from interfaces
66
+ if (machine.interfaces.length === 0) return null;
67
+ return [
68
+ ...new Set(machine.interfaces.map((i) => i.zone).filter((z) => z !== 'unknown')),
69
+ ].join(',');
70
+ default:
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolve a $machine:zone_ip derivation for a per_selection follow-up
77
+ * Looks up the interface IP for a given zone
78
+ */
79
+ function resolveMachineZoneIp(machine: Machine, zone: string): string | null {
80
+ const iface = machine.interfaces.find((i) => i.zone === zone);
81
+ return iface?.ipAddress ?? null;
82
+ }
83
+
84
+ /**
85
+ * Interview user for missing required configuration
86
+ *
87
+ * @param moduleId - Module identifier
88
+ * @param missingVariables - Variables that need to be configured
89
+ * @param db - Database connection
90
+ * @param earmarkedMachine - Optional earmarked machine for $machine: derivation
91
+ * @returns Interview result
92
+ */
93
+ export async function interviewForMissingConfig(
94
+ moduleId: string,
95
+ missingVariables: MissingVariable[],
96
+ db: DbClient,
97
+ earmarkedMachine?: Machine | null,
98
+ ): Promise<InterviewResult> {
99
+ const configured: string[] = [];
100
+
101
+ log.info(`Module '${moduleId}' requires configuration. Please provide the following:`);
102
+
103
+ for (const variable of missingVariables) {
104
+ try {
105
+ // Try to derive from earmarked machine before prompting
106
+ if (variable.derive_from?.startsWith('$machine:') && earmarkedMachine) {
107
+ const derived = resolveMachineDerivation(variable.derive_from, earmarkedMachine);
108
+ if (derived !== null) {
109
+ log.info(`✓ ${variable.name} = ${derived} (from machine ${earmarkedMachine.hostname})`);
110
+
111
+ // For multi-select variables with options, also handle per_selection follow-ups
112
+ if (variable.options && variable.per_selection) {
113
+ const selectedValues = derived.split(',');
114
+
115
+ // Store the main variable
116
+ await db
117
+ .insert(moduleConfigs)
118
+ .values({
119
+ moduleId,
120
+ key: variable.name,
121
+ value: derived,
122
+ valueJson: null,
123
+ createdAt: new Date(),
124
+ updatedAt: new Date(),
125
+ })
126
+ .onConflictDoUpdate({
127
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
128
+ set: { value: derived, updatedAt: new Date() },
129
+ });
130
+ configured.push(variable.name);
131
+
132
+ // Handle per_selection follow-ups
133
+ for (const selectedVal of selectedValues) {
134
+ const followUpKey = variable.per_selection.key_pattern.replace(
135
+ '{value}',
136
+ selectedVal,
137
+ );
138
+
139
+ // Try to derive the follow-up value from machine
140
+ let followUpValue: string | null = null;
141
+ if (variable.per_selection.derive_from === '$machine:zone_ip' && earmarkedMachine) {
142
+ followUpValue = resolveMachineZoneIp(earmarkedMachine, selectedVal);
143
+ }
144
+
145
+ if (followUpValue !== null) {
146
+ log.info(
147
+ `✓ ${followUpKey} = ${followUpValue} (from machine ${earmarkedMachine.hostname})`,
148
+ );
149
+ await db
150
+ .insert(moduleConfigs)
151
+ .values({
152
+ moduleId,
153
+ key: followUpKey,
154
+ value: followUpValue,
155
+ valueJson: null,
156
+ createdAt: new Date(),
157
+ updatedAt: new Date(),
158
+ })
159
+ .onConflictDoUpdate({
160
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
161
+ set: { value: followUpValue, updatedAt: new Date() },
162
+ });
163
+ configured.push(followUpKey);
164
+ } else {
165
+ // Can't derive - prompt for this follow-up
166
+ const option = variable.options?.find((o) => o.value === selectedVal);
167
+ const followUpPrompt = variable.per_selection.prompt
168
+ .replace('{value}', selectedVal)
169
+ .replace('{label}', option?.label || selectedVal)
170
+ .replace('{hint}', option?.hint || '');
171
+
172
+ const userValue = await promptText({
173
+ message: followUpPrompt,
174
+ validate: (val) => {
175
+ if (!val || val.trim() === '') return 'This field is required';
176
+ },
177
+ });
178
+
179
+ await db
180
+ .insert(moduleConfigs)
181
+ .values({
182
+ moduleId,
183
+ key: followUpKey,
184
+ value: userValue,
185
+ valueJson: null,
186
+ createdAt: new Date(),
187
+ updatedAt: new Date(),
188
+ })
189
+ .onConflictDoUpdate({
190
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
191
+ set: { value: userValue, updatedAt: new Date() },
192
+ });
193
+ configured.push(followUpKey);
194
+ }
195
+ }
196
+
197
+ continue; // Already handled this variable fully
198
+ }
199
+
200
+ // Simple (non-multi-select) derived variable
201
+ await db
202
+ .insert(moduleConfigs)
203
+ .values({
204
+ moduleId,
205
+ key: variable.name,
206
+ value: derived,
207
+ valueJson: null,
208
+ createdAt: new Date(),
209
+ updatedAt: new Date(),
210
+ })
211
+ .onConflictDoUpdate({
212
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
213
+ set: { value: derived, updatedAt: new Date() },
214
+ });
215
+ configured.push(variable.name);
216
+ continue;
217
+ }
218
+ }
219
+
220
+ let value: string;
221
+
222
+ if (variable.source === 'secret') {
223
+ // Prompt for secret (masked input)
224
+ const message = variable.description
225
+ ? `${variable.name} - ${variable.description}:`
226
+ : `${variable.name}:`;
227
+
228
+ value = await promptPassword({
229
+ message,
230
+ validate: (val) => {
231
+ if (!val || val.trim() === '') {
232
+ return 'This field is required';
233
+ }
234
+ },
235
+ });
236
+
237
+ // Encrypt and store secret
238
+ const masterKey = await getOrCreateMasterKey();
239
+ const encrypted = encryptSecret(value, masterKey);
240
+ await db
241
+ .insert(secrets)
242
+ .values({
243
+ moduleId,
244
+ name: variable.name,
245
+ encryptedValue: encrypted.encryptedValue,
246
+ iv: encrypted.iv,
247
+ authTag: encrypted.authTag,
248
+ })
249
+ .run();
250
+
251
+ configured.push(`${variable.name} (secret)`);
252
+ } else if (variable.options && variable.options.length > 0) {
253
+ // Multi-select prompt for variables with options
254
+ const message = variable.description
255
+ ? `${variable.name} - ${variable.description}:`
256
+ : `${variable.name}:`;
257
+
258
+ const selected = await p.multiselect({
259
+ message,
260
+ options: variable.options.map((opt) => ({
261
+ value: opt.value,
262
+ label: opt.label,
263
+ hint: opt.hint,
264
+ })),
265
+ required: true,
266
+ });
267
+
268
+ if (p.isCancel(selected)) {
269
+ return {
270
+ success: false,
271
+ configured,
272
+ error: `Configuration cancelled for ${variable.name}`,
273
+ };
274
+ }
275
+
276
+ const selectedValues = selected as string[];
277
+ value = selectedValues.join(',');
278
+
279
+ // Store config
280
+ await db
281
+ .insert(moduleConfigs)
282
+ .values({
283
+ moduleId,
284
+ key: variable.name,
285
+ value,
286
+ valueJson: null,
287
+ createdAt: new Date(),
288
+ updatedAt: new Date(),
289
+ })
290
+ .onConflictDoUpdate({
291
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
292
+ set: {
293
+ value,
294
+ updatedAt: new Date(),
295
+ },
296
+ });
297
+
298
+ configured.push(variable.name);
299
+
300
+ // Handle per_selection follow-up prompts
301
+ if (variable.per_selection) {
302
+ for (const selectedVal of selectedValues) {
303
+ const option = variable.options?.find((o) => o.value === selectedVal);
304
+ const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal);
305
+ const followUpPrompt = variable.per_selection.prompt
306
+ .replace('{value}', selectedVal)
307
+ .replace('{label}', option?.label || selectedVal)
308
+ .replace('{hint}', option?.hint || '');
309
+
310
+ // Check if already configured
311
+ const existingFollowUp = db
312
+ .select()
313
+ .from(moduleConfigs)
314
+ .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, followUpKey)))
315
+ .get();
316
+
317
+ if (!existingFollowUp || !existingFollowUp.value) {
318
+ const followUpValue = await promptText({
319
+ message: followUpPrompt,
320
+ validate: (val) => {
321
+ if (!val || val.trim() === '') return 'This field is required';
322
+ },
323
+ });
324
+
325
+ await db
326
+ .insert(moduleConfigs)
327
+ .values({
328
+ moduleId,
329
+ key: followUpKey,
330
+ value: followUpValue,
331
+ valueJson: null,
332
+ createdAt: new Date(),
333
+ updatedAt: new Date(),
334
+ })
335
+ .onConflictDoUpdate({
336
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
337
+ set: { value: followUpValue, updatedAt: new Date() },
338
+ });
339
+
340
+ configured.push(followUpKey);
341
+ }
342
+ }
343
+ }
344
+ } else {
345
+ // Prompt for regular config (visible input)
346
+ const message = variable.description
347
+ ? `${variable.name} - ${variable.description}:`
348
+ : `${variable.name}:`;
349
+
350
+ value = await promptText({
351
+ message,
352
+ validate: (val) => {
353
+ if (!val || val.trim() === '') {
354
+ return 'This field is required';
355
+ }
356
+ },
357
+ });
358
+
359
+ // Store config
360
+ await db
361
+ .insert(moduleConfigs)
362
+ .values({
363
+ moduleId,
364
+ key: variable.name,
365
+ value,
366
+ valueJson: null,
367
+ createdAt: new Date(),
368
+ updatedAt: new Date(),
369
+ })
370
+ .onConflictDoUpdate({
371
+ target: [moduleConfigs.moduleId, moduleConfigs.key],
372
+ set: {
373
+ value,
374
+ updatedAt: new Date(),
375
+ },
376
+ });
377
+
378
+ configured.push(variable.name);
379
+ }
380
+ } catch (error) {
381
+ return {
382
+ success: false,
383
+ configured,
384
+ error: `Failed to configure ${variable.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
385
+ };
386
+ }
387
+ }
388
+
389
+ return {
390
+ success: true,
391
+ configured,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Validate module secrets against manifest declarations
397
+ *
398
+ * Policy function (Rule 10.1) - reads database and manifest, no side effects
399
+ *
400
+ * @param moduleId - Module identifier
401
+ * @param db - Database connection
402
+ * @returns Array of missing secrets with metadata
403
+ */
404
+ export async function validateModuleSecrets(
405
+ moduleId: string,
406
+ db: DbClient,
407
+ ): Promise<MissingVariable[]> {
408
+ const missingSecrets: MissingVariable[] = [];
409
+
410
+ // Load secrets schema
411
+ const schema = await loadSecretsSchema(moduleId, db);
412
+ if (!schema) {
413
+ // No schema file = no secrets declared
414
+ return [];
415
+ }
416
+
417
+ // Check each declared secret
418
+ for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
419
+ // Check if secret exists in database
420
+ const existing = db
421
+ .select()
422
+ .from(secrets)
423
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secretName)))
424
+ .get();
425
+
426
+ if (!existing) {
427
+ missingSecrets.push({
428
+ name: secretName,
429
+ source: 'secret',
430
+ description: propertySchema.description || propertySchema.title,
431
+ });
432
+ }
433
+ }
434
+
435
+ return missingSecrets;
436
+ }
437
+
438
+ /**
439
+ * Interview user for missing module secrets with schema-aware logic
440
+ *
441
+ * Execution function (Rule 10.1) - performs I/O (prompts, database writes)
442
+ *
443
+ * Handles three secret source types:
444
+ * - "generated": Auto-generate, never prompt
445
+ * - "user_provided": Always prompt, required
446
+ * - "generated_optional": Prompt with "Press Enter to auto-generate"
447
+ *
448
+ * Also handles derived secrets (derive_from field)
449
+ *
450
+ * @param moduleId - Module identifier
451
+ * @param missingSecrets - Secrets that need to be configured
452
+ * @param db - Database connection
453
+ * @returns Interview result
454
+ */
455
+ export async function interviewForMissingSecrets(
456
+ moduleId: string,
457
+ missingSecrets: MissingVariable[],
458
+ db: DbClient,
459
+ ): Promise<InterviewResult> {
460
+ const configured: string[] = [];
461
+
462
+ log.info(`Module '${moduleId}' requires secrets. Configuring:`);
463
+
464
+ const masterKey = await getOrCreateMasterKey();
465
+
466
+ // Sort secrets to ensure derived secrets come after their source secrets
467
+ // Build dependency map
468
+ const metadataMap = new Map<string, Awaited<ReturnType<typeof getSecretMetadata>>>();
469
+ for (const variable of missingSecrets) {
470
+ const metadata = await getSecretMetadata(moduleId, variable.name, db);
471
+ metadataMap.set(variable.name, metadata);
472
+ }
473
+
474
+ // Topological sort: ensure derived secrets come immediately after their source
475
+ const sorted: typeof missingSecrets = [];
476
+ const processed = new Set<string>();
477
+
478
+ function addWithDependents(secret: (typeof missingSecrets)[0]) {
479
+ if (processed.has(secret.name)) return;
480
+
481
+ processed.add(secret.name);
482
+ sorted.push(secret);
483
+
484
+ // Immediately add any secrets that derive from this one
485
+ for (const s of missingSecrets) {
486
+ const meta = metadataMap.get(s.name);
487
+ if (meta?.deriveFrom === secret.name && !processed.has(s.name)) {
488
+ addWithDependents(s);
489
+ }
490
+ }
491
+ }
492
+
493
+ // Process all non-derived secrets first (in original order), each followed by its dependents
494
+ for (const secret of missingSecrets) {
495
+ const meta = metadataMap.get(secret.name);
496
+ if (!meta?.deriveFrom) {
497
+ addWithDependents(secret);
498
+ }
499
+ }
500
+
501
+ // Add any remaining secrets (shouldn't happen if derivation graph is valid)
502
+ for (const secret of missingSecrets) {
503
+ if (!processed.has(secret.name)) {
504
+ addWithDependents(secret);
505
+ }
506
+ }
507
+
508
+ for (const variable of sorted) {
509
+ try {
510
+ // Get metadata from schema (already loaded during sorting)
511
+ const metadata = metadataMap.get(variable.name);
512
+
513
+ // Manifest generate field takes priority over schema metadata
514
+ const hasManifestGenerate = !!variable.generate;
515
+ // If no metadata, default to user_provided (safe default)
516
+ const source = hasManifestGenerate ? 'generated' : metadata?.source || 'user_provided';
517
+
518
+ let value: string;
519
+
520
+ // Handle derived secrets first
521
+ if (metadata?.deriveFrom) {
522
+ // Look up source secret
523
+ const sourceSecret = db
524
+ .select()
525
+ .from(secrets)
526
+ .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, metadata.deriveFrom)))
527
+ .get();
528
+
529
+ if (!sourceSecret) {
530
+ return {
531
+ success: false,
532
+ configured,
533
+ error: `Cannot derive ${variable.name}: source secret ${metadata.deriveFrom} not found`,
534
+ };
535
+ }
536
+
537
+ // Decrypt source secret
538
+ const decryptedSource = await import('../secrets/encryption').then((m) =>
539
+ m.decryptSecret(
540
+ {
541
+ encryptedValue: sourceSecret.encryptedValue,
542
+ iv: sourceSecret.iv,
543
+ authTag: sourceSecret.authTag,
544
+ },
545
+ masterKey,
546
+ ),
547
+ );
548
+
549
+ // Derive value
550
+ if (!metadata.deriveMethod) {
551
+ return {
552
+ success: false,
553
+ configured,
554
+ error: `Cannot derive ${variable.name}: derive_method not specified in schema`,
555
+ };
556
+ }
557
+
558
+ value = deriveSecret({
559
+ sourceSecret: decryptedSource,
560
+ deriveMethod: metadata.deriveMethod,
561
+ });
562
+
563
+ log.info(`🔗 Derived ${variable.name} from ${metadata.deriveFrom}`);
564
+ } else if (source === 'generated') {
565
+ // Auto-generate without prompting
566
+ // Manifest generate field takes priority over schema metadata
567
+ const format = variable.generate?.encoding || metadata?.format || 'base64';
568
+ const length = variable.generate?.length || metadata?.length || 32;
569
+
570
+ value = generateSecret({ format, length });
571
+
572
+ log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
573
+ } else if (source === 'user_provided') {
574
+ // Always prompt, required
575
+ // Check if we're in interactive mode
576
+ if (!process.stdin.isTTY) {
577
+ // Non-interactive: skip this secret and continue processing others
578
+ // The caller will handle the remaining user-provided secrets
579
+ continue;
580
+ }
581
+
582
+ const message = variable.description
583
+ ? `${variable.name} - ${variable.description}:`
584
+ : `${variable.name}:`;
585
+
586
+ value = await promptPassword({
587
+ message,
588
+ validate: (val) => {
589
+ if (!val || val.trim() === '') {
590
+ return 'This field is required';
591
+ }
592
+ },
593
+ });
594
+
595
+ log.info(`✓ Saved ${variable.name}`);
596
+ } else if (source === 'user_password') {
597
+ // Password the user must remember — prompt twice to confirm
598
+ if (!process.stdin.isTTY) {
599
+ continue;
600
+ }
601
+
602
+ const message = variable.description
603
+ ? `${variable.name} - ${variable.description}:`
604
+ : `${variable.name}:`;
605
+
606
+ value = await promptPassword({
607
+ message,
608
+ validate: (val) => {
609
+ if (!val || val.trim() === '') {
610
+ return 'This field is required';
611
+ }
612
+ },
613
+ });
614
+
615
+ const confirmation = await promptPassword({
616
+ message: `Confirm ${variable.name}:`,
617
+ validate: (val) => {
618
+ if (!val || val.trim() === '') {
619
+ return 'This field is required';
620
+ }
621
+ },
622
+ });
623
+
624
+ if (value !== confirmation) {
625
+ log.error('Passwords do not match. Please try again.');
626
+ // Re-prompt by decrementing — but we're in a for-of loop, so just return error
627
+ return {
628
+ success: false,
629
+ configured,
630
+ error: `Passwords do not match for ${variable.name}. Re-run deploy to try again.`,
631
+ };
632
+ }
633
+
634
+ log.info(`✓ Saved ${variable.name}`);
635
+ } else if (source === 'generated_optional') {
636
+ // Prompt with auto-generate option
637
+ const message = variable.description
638
+ ? `${variable.name} - ${variable.description} (Press Enter to auto-generate):`
639
+ : `${variable.name} (Press Enter to auto-generate):`;
640
+
641
+ const userValue = await promptPassword({
642
+ message,
643
+ validate: () => undefined, // Allow empty
644
+ });
645
+
646
+ if (!userValue || userValue.trim() === '') {
647
+ // Auto-generate
648
+ const format = metadata?.format || 'base64';
649
+ const length = metadata?.length || 32;
650
+
651
+ value = generateSecret({ format, length });
652
+
653
+ log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
654
+ } else {
655
+ // Use user-provided value
656
+ value = userValue;
657
+ log.info(`✓ Saved ${variable.name}`);
658
+ }
659
+ } else {
660
+ return {
661
+ success: false,
662
+ configured,
663
+ error: `Unknown secret source type: ${source}`,
664
+ };
665
+ }
666
+
667
+ // Encrypt and store secret
668
+ const encrypted = encryptSecret(value, masterKey);
669
+ await db
670
+ .insert(secrets)
671
+ .values({
672
+ moduleId,
673
+ name: variable.name,
674
+ encryptedValue: encrypted.encryptedValue,
675
+ iv: encrypted.iv,
676
+ authTag: encrypted.authTag,
677
+ })
678
+ .run();
679
+
680
+ configured.push(`${variable.name} (secret)`);
681
+ } catch (error) {
682
+ return {
683
+ success: false,
684
+ configured,
685
+ error: `Failed to configure ${variable.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
686
+ };
687
+ }
688
+ }
689
+
690
+ return {
691
+ success: true,
692
+ configured,
693
+ };
694
+ }