@celilo/cli 0.3.30 → 0.4.0-alpha.1

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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +6 -5
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Behavior tests for the celilo-mgmt module's on_backup / on_restore
3
+ * hooks (Phase 3 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md).
4
+ *
5
+ * We import the hook scripts directly and call their handlers with a
6
+ * synthesized HookContext, then assert on the side-effects (files
7
+ * written to backup_dir / restore staging dirs). This catches
8
+ * regressions in the hook bodies without spinning up the full
9
+ * deploy/backup pipeline.
10
+ *
11
+ * What this DOESN'T test: invokeHook validation, end-to-end backup
12
+ * envelope round-trip. Those live in backup-envelope-roundtrip.test.ts
13
+ * and (eventually) a fresh-box e2e.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
17
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ import type { HookContext } from '@celilo/capabilities';
22
+
23
+ // Hooks under test — imported via the scripts dir adjacent to the
24
+ // modules/celilo-mgmt/manifest.yml. The dynamic import keeps the test
25
+ // runnable from the apps/celilo working dir.
26
+ const HOOK_DIR = join(import.meta.dirname, '../../../../modules/celilo-mgmt/scripts');
27
+
28
+ interface BackupHookOutput {
29
+ artifact_count: number;
30
+ size_bytes: number;
31
+ schema_version: string;
32
+ }
33
+ interface RestoreHookOutput {
34
+ restored_items: number;
35
+ }
36
+
37
+ function buildLogger(): {
38
+ logger: HookContext['logger'];
39
+ messages: string[];
40
+ } {
41
+ const messages: string[] = [];
42
+ const logger: HookContext['logger'] = {
43
+ info: (m: string) => messages.push(`info: ${m}`),
44
+ warn: (m: string) => messages.push(`warn: ${m}`),
45
+ error: (m: string) => messages.push(`error: ${m}`),
46
+ success: (m: string) => messages.push(`success: ${m}`),
47
+ };
48
+ return { logger, messages };
49
+ }
50
+
51
+ function buildContext(extras: Record<string, unknown>): HookContext {
52
+ const { logger } = buildLogger();
53
+ return {
54
+ logger,
55
+ config: {
56
+ db_path: '', // populated by caller via env
57
+ hostname: 'test-mgmt',
58
+ target_ip: '127.0.0.1',
59
+ event_bus_socket: '',
60
+ listen_port: 8123,
61
+ dns_primary: '1.1.1.1',
62
+ dns_fallback: '',
63
+ network_dmz_subnet: '',
64
+ network_app_subnet: '',
65
+ network_secure_subnet: '',
66
+ network_internal_subnet: '',
67
+ ssh_public_key: '',
68
+ install_docker: false,
69
+ install_terraform: false,
70
+ } as Record<string, unknown>,
71
+ secrets: {},
72
+ capabilities: {},
73
+ debug: false,
74
+ screenshotDir: '',
75
+ ...extras,
76
+ } as HookContext;
77
+ }
78
+
79
+ describe('celilo-mgmt on_backup', () => {
80
+ let dir: string;
81
+ let backupDir: string;
82
+ let crossModuleRoot: string;
83
+ let dbPath: string;
84
+ let keyPath: string;
85
+
86
+ beforeEach(() => {
87
+ dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-backup-test-'));
88
+ backupDir = join(dir, 'backup-dir');
89
+ mkdirSync(backupDir, { recursive: true });
90
+ crossModuleRoot = join(dir, 'cross-module-root');
91
+ mkdirSync(join(crossModuleRoot, 'modules', 'caddy', 'terraform'), { recursive: true });
92
+ writeFileSync(
93
+ join(crossModuleRoot, 'modules', 'caddy', 'terraform', 'terraform.tfstate'),
94
+ '{"state":"caddy"}',
95
+ );
96
+ writeFileSync(
97
+ join(crossModuleRoot, 'index.json'),
98
+ JSON.stringify({
99
+ schemaVersion: '1.0',
100
+ generatedAt: '2026-05-20T00:00:00Z',
101
+ modules: [{ id: 'caddy', version: '0.0.1', terraformStateDir: 'modules/caddy/terraform' }],
102
+ }),
103
+ );
104
+
105
+ dbPath = join(dir, 'celilo.db');
106
+ writeFileSync(dbPath, 'fake-sqlite-bytes-for-test');
107
+ process.env.CELILO_DB_PATH = dbPath;
108
+
109
+ keyPath = join(dir, 'master.key');
110
+ writeFileSync(keyPath, 'fake-master-key-32-bytes-padding!');
111
+ process.env.CELILO_MASTER_KEY_PATH = keyPath;
112
+ });
113
+
114
+ afterEach(() => {
115
+ process.env.CELILO_DB_PATH = undefined;
116
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
117
+ try {
118
+ rmSync(dir, { recursive: true, force: true });
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ });
123
+
124
+ it('writes celilo.db, master.key, machine-pool.json, and cross_module_state into backup_dir', async () => {
125
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
126
+ const ctx = buildContext({
127
+ backup_dir: backupDir,
128
+ cross_module_root: crossModuleRoot,
129
+ });
130
+ const result = (await hook(ctx)) as BackupHookOutput;
131
+
132
+ expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
133
+ expect(existsSync(join(backupDir, 'master.key'))).toBe(true);
134
+ expect(existsSync(join(backupDir, 'machine-pool.json'))).toBe(true);
135
+ expect(
136
+ existsSync(
137
+ join(backupDir, 'cross_module_state', 'modules', 'caddy', 'terraform', 'terraform.tfstate'),
138
+ ),
139
+ ).toBe(true);
140
+ expect(existsSync(join(backupDir, 'cross_module_state', 'index.json'))).toBe(true);
141
+
142
+ expect(result.schema_version).toBe('1.0');
143
+ expect(result.artifact_count).toBeGreaterThan(0);
144
+ expect(result.size_bytes).toBeGreaterThan(0);
145
+ });
146
+
147
+ it('machine-pool.json is valid JSON (array)', async () => {
148
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
149
+ await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
150
+
151
+ const machinePool = JSON.parse(readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'));
152
+ expect(Array.isArray(machinePool)).toBe(true);
153
+ });
154
+
155
+ it('proceeds (with a warning) when cross_module_root is missing', async () => {
156
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
157
+ const result = (await hook(
158
+ buildContext({ backup_dir: backupDir }), // no cross_module_root
159
+ )) as BackupHookOutput;
160
+
161
+ expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
162
+ expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
163
+ expect(result.schema_version).toBe('1.0');
164
+ });
165
+
166
+ it('proceeds (with a warning) when master.key is missing on disk', async () => {
167
+ rmSync(keyPath);
168
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
169
+ const result = (await hook(
170
+ buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }),
171
+ )) as BackupHookOutput;
172
+
173
+ expect(existsSync(join(backupDir, 'master.key'))).toBe(false);
174
+ expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
175
+ expect(result.artifact_count).toBeGreaterThan(0);
176
+ });
177
+ });
178
+
179
+ describe('celilo-mgmt on_restore', () => {
180
+ let dir: string;
181
+ let restoreDir: string;
182
+ let crossModuleWriteRoot: string;
183
+
184
+ beforeEach(() => {
185
+ dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-restore-test-'));
186
+ restoreDir = join(dir, 'restore-dir');
187
+ mkdirSync(restoreDir, { recursive: true });
188
+ crossModuleWriteRoot = join(dir, 'cross-module-write-root');
189
+ mkdirSync(crossModuleWriteRoot, { recursive: true });
190
+ });
191
+
192
+ afterEach(() => {
193
+ try {
194
+ rmSync(dir, { recursive: true, force: true });
195
+ } catch {
196
+ /* ignore */
197
+ }
198
+ });
199
+
200
+ function plantArtifact(): void {
201
+ writeFileSync(join(restoreDir, 'celilo.db'), 'restored-db-bytes');
202
+ writeFileSync(join(restoreDir, 'master.key'), 'restored-master-key');
203
+ writeFileSync(join(restoreDir, 'machine-pool.json'), '[]');
204
+ mkdirSync(join(restoreDir, 'cross_module_state', 'modules', 'caddy', 'terraform'), {
205
+ recursive: true,
206
+ });
207
+ writeFileSync(
208
+ join(restoreDir, 'cross_module_state', 'modules', 'caddy', 'terraform', 'terraform.tfstate'),
209
+ '{"state":"caddy-restored"}',
210
+ );
211
+ mkdirSync(join(restoreDir, 'cross_module_state', 'modules', 'homebridge', 'terraform'), {
212
+ recursive: true,
213
+ });
214
+ writeFileSync(
215
+ join(
216
+ restoreDir,
217
+ 'cross_module_state',
218
+ 'modules',
219
+ 'homebridge',
220
+ 'terraform',
221
+ 'terraform.tfstate',
222
+ ),
223
+ '{"state":"homebridge-restored"}',
224
+ );
225
+ }
226
+
227
+ it('moves cross-module terraform state into cross_module_write_root for the framework apply', async () => {
228
+ plantArtifact();
229
+ const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`);
230
+ const result = (await hook(
231
+ buildContext({
232
+ restore_dir: restoreDir,
233
+ schema_version: '1.0',
234
+ cross_module_write_root: crossModuleWriteRoot,
235
+ }),
236
+ )) as RestoreHookOutput;
237
+
238
+ expect(
239
+ existsSync(join(crossModuleWriteRoot, 'modules', 'caddy', 'terraform', 'terraform.tfstate')),
240
+ ).toBe(true);
241
+ expect(
242
+ existsSync(
243
+ join(crossModuleWriteRoot, 'modules', 'homebridge', 'terraform', 'terraform.tfstate'),
244
+ ),
245
+ ).toBe(true);
246
+
247
+ // 2 cross-module modules + 2 system files (db + master.key)
248
+ expect(result.restored_items).toBe(4);
249
+ });
250
+
251
+ it('stages celilo.db + master.key under restore_dir/system/ for Phase 4 wrapper', async () => {
252
+ plantArtifact();
253
+ const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`);
254
+ await hook(
255
+ buildContext({
256
+ restore_dir: restoreDir,
257
+ schema_version: '1.0',
258
+ cross_module_write_root: crossModuleWriteRoot,
259
+ }),
260
+ );
261
+
262
+ expect(existsSync(join(restoreDir, 'system', 'celilo.db'))).toBe(true);
263
+ expect(existsSync(join(restoreDir, 'system', 'master.key'))).toBe(true);
264
+ expect(readFileSync(join(restoreDir, 'system', 'celilo.db'), 'utf-8')).toBe(
265
+ 'restored-db-bytes',
266
+ );
267
+ });
268
+
269
+ it('skips cross-module restore (with a warning) when cross_module_write_root is missing', async () => {
270
+ plantArtifact();
271
+ const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`);
272
+ const result = (await hook(
273
+ buildContext({ restore_dir: restoreDir, schema_version: '1.0' }), // no cross_module_write_root
274
+ )) as RestoreHookOutput;
275
+
276
+ // Only system files staged; cross-module skipped.
277
+ expect(result.restored_items).toBe(2);
278
+ expect(existsSync(join(restoreDir, 'system', 'celilo.db'))).toBe(true);
279
+ });
280
+
281
+ it('tolerates an artifact with no cross_module_state/ subdir', async () => {
282
+ // No plantArtifact call's cross-module branch — write only system files.
283
+ writeFileSync(join(restoreDir, 'celilo.db'), 'db');
284
+ writeFileSync(join(restoreDir, 'master.key'), 'key');
285
+ const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`);
286
+ const result = (await hook(
287
+ buildContext({
288
+ restore_dir: restoreDir,
289
+ schema_version: '1.0',
290
+ cross_module_write_root: crossModuleWriteRoot,
291
+ }),
292
+ )) as RestoreHookOutput;
293
+
294
+ expect(result.restored_items).toBe(2);
295
+ });
296
+ });
@@ -17,6 +17,7 @@ import {
17
17
  type SecretRequiredPayload,
18
18
  busInterview,
19
19
  } from './bus-interview';
20
+ import { parseStoredConfigValue, upsertModuleConfig } from './module-config';
20
21
  import { getSecretMetadata } from './secret-schema-loader';
21
22
 
22
23
  /**
@@ -245,20 +246,7 @@ export async function autoDeriveMachineConfig(
245
246
 
246
247
  log.success(`${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
247
248
 
248
- await db
249
- .insert(moduleConfigs)
250
- .values({
251
- moduleId,
252
- key: variable.name,
253
- value: derived,
254
- valueJson: null,
255
- createdAt: new Date(),
256
- updatedAt: new Date(),
257
- })
258
- .onConflictDoUpdate({
259
- target: [moduleConfigs.moduleId, moduleConfigs.key],
260
- set: { value: derived, updatedAt: new Date() },
261
- });
249
+ upsertModuleConfig(db, moduleId, variable.name, derived);
262
250
  configured.push(variable.name);
263
251
 
264
252
  // Handle per_selection follow-ups (e.g., zone_ip_* from zone list)
@@ -273,20 +261,7 @@ export async function autoDeriveMachineConfig(
273
261
 
274
262
  if (followUpValue !== null) {
275
263
  log.success(`${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
276
- await db
277
- .insert(moduleConfigs)
278
- .values({
279
- moduleId,
280
- key: followUpKey,
281
- value: followUpValue,
282
- valueJson: null,
283
- createdAt: new Date(),
284
- updatedAt: new Date(),
285
- })
286
- .onConflictDoUpdate({
287
- target: [moduleConfigs.moduleId, moduleConfigs.key],
288
- set: { value: followUpValue, updatedAt: new Date() },
289
- });
264
+ upsertModuleConfig(db, moduleId, followUpKey, followUpValue);
290
265
  configured.push(followUpKey);
291
266
  }
292
267
  }
@@ -328,20 +303,7 @@ export async function interviewForMissingConfig(
328
303
  const selectedValues = derived.split(',');
329
304
 
330
305
  // Store the main variable
331
- await db
332
- .insert(moduleConfigs)
333
- .values({
334
- moduleId,
335
- key: variable.name,
336
- value: derived,
337
- valueJson: null,
338
- createdAt: new Date(),
339
- updatedAt: new Date(),
340
- })
341
- .onConflictDoUpdate({
342
- target: [moduleConfigs.moduleId, moduleConfigs.key],
343
- set: { value: derived, updatedAt: new Date() },
344
- });
306
+ upsertModuleConfig(db, moduleId, variable.name, derived);
345
307
  configured.push(variable.name);
346
308
 
347
309
  // Handle per_selection follow-ups
@@ -361,20 +323,7 @@ export async function interviewForMissingConfig(
361
323
  log.info(
362
324
  `✓ ${followUpKey} = ${followUpValue} (from machine ${earmarkedMachine.hostname})`,
363
325
  );
364
- await db
365
- .insert(moduleConfigs)
366
- .values({
367
- moduleId,
368
- key: followUpKey,
369
- value: followUpValue,
370
- valueJson: null,
371
- createdAt: new Date(),
372
- updatedAt: new Date(),
373
- })
374
- .onConflictDoUpdate({
375
- target: [moduleConfigs.moduleId, moduleConfigs.key],
376
- set: { value: followUpValue, updatedAt: new Date() },
377
- });
326
+ upsertModuleConfig(db, moduleId, followUpKey, followUpValue);
378
327
  configured.push(followUpKey);
379
328
  } else {
380
329
  // Can't derive — bus-mediated prompt (responder
@@ -404,20 +353,7 @@ export async function interviewForMissingConfig(
404
353
  }
405
354
 
406
355
  // Simple (non-multi-select) derived variable
407
- await db
408
- .insert(moduleConfigs)
409
- .values({
410
- moduleId,
411
- key: variable.name,
412
- value: derived,
413
- valueJson: null,
414
- createdAt: new Date(),
415
- updatedAt: new Date(),
416
- })
417
- .onConflictDoUpdate({
418
- target: [moduleConfigs.moduleId, moduleConfigs.key],
419
- set: { value: derived, updatedAt: new Date() },
420
- });
356
+ upsertModuleConfig(db, moduleId, variable.name, derived);
421
357
  configured.push(variable.name);
422
358
  continue;
423
359
  }
@@ -850,14 +786,7 @@ export async function readModuleConfigKey(
850
786
  .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
851
787
  .get();
852
788
  if (!row) return undefined;
853
- if (row.valueJson) {
854
- try {
855
- return JSON.parse(row.valueJson);
856
- } catch {
857
- return row.value;
858
- }
859
- }
860
- return row.value;
789
+ return parseStoredConfigValue(row);
861
790
  }
862
791
 
863
792
  export async function writeModuleConfigKey(
@@ -866,23 +795,12 @@ export async function writeModuleConfigKey(
866
795
  value: unknown,
867
796
  db: DbClient,
868
797
  ): Promise<void> {
869
- const valueJson = JSON.stringify(value);
870
- const stringValue = typeof value === 'string' ? value : valueJson;
871
- await db
872
- .insert(moduleConfigs)
873
- .values({
874
- moduleId,
875
- key,
876
- value: stringValue,
877
- valueJson,
878
- createdAt: new Date(),
879
- updatedAt: new Date(),
880
- })
881
- .onConflictDoUpdate({
882
- target: [moduleConfigs.moduleId, moduleConfigs.key],
883
- set: { value: stringValue, valueJson, updatedAt: new Date() },
884
- })
885
- .run();
798
+ upsertModuleConfig(
799
+ db,
800
+ moduleId,
801
+ key,
802
+ value as string | number | boolean | unknown[] | Record<string, unknown>,
803
+ );
886
804
  }
887
805
 
888
806
  export async function readModuleSecretKey(
@@ -17,6 +17,7 @@ import { decryptSecret, encryptSecret } from '../secrets/encryption';
17
17
  import { getOrCreateMasterKey } from '../secrets/master-key';
18
18
  import { buildResolutionContext } from '../variables/context';
19
19
  import { resolveTemplate } from '../variables/resolver';
20
+ import { upsertModuleConfig } from './module-config';
20
21
 
21
22
  /**
22
23
  * Configuration data type
@@ -101,37 +102,7 @@ export class CrossModuleDataManager {
101
102
  key: string,
102
103
  value: string | number | boolean | unknown[] | Record<string, unknown>,
103
104
  ): Promise<void> {
104
- const isComplex = typeof value === 'object' && value !== null;
105
-
106
- const existing = this.db
107
- .select()
108
- .from(moduleConfigs)
109
- .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
110
- .get();
111
-
112
- if (existing) {
113
- // Update existing
114
- this.db
115
- .update(moduleConfigs)
116
- .set({
117
- value: isComplex ? '' : String(value),
118
- valueJson: isComplex ? JSON.stringify(value) : null,
119
- updatedAt: new Date(Date.now()),
120
- })
121
- .where(eq(moduleConfigs.id, existing.id))
122
- .run();
123
- } else {
124
- // Insert new
125
- this.db
126
- .insert(moduleConfigs)
127
- .values({
128
- moduleId,
129
- key,
130
- value: isComplex ? '' : String(value),
131
- valueJson: isComplex ? JSON.stringify(value) : null,
132
- })
133
- .run();
134
- }
105
+ upsertModuleConfig(this.db, moduleId, key, value);
135
106
  }
136
107
 
137
108
  /**