@celilo/cli 0.3.4 → 0.3.9

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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Layer 1 — bus-mediated ensure-interview flow.
3
+ *
4
+ * Drives `interviewForEnsureInputs` against a real sqlite bus + real
5
+ * encrypted store + a programmatic test responder. Mirrors what
6
+ * `bus-secret-flow.test.ts` does for the secret interview.
7
+ *
8
+ * Covers what stage 3 introduced:
9
+ * - `append_to_array` inputs apply deterministic without a bus event.
10
+ * - `set_in_object` inputs go through one bulk `ensure.required` event.
11
+ * - Config-target values arrive in `reply.values`, applied by the
12
+ * deploy via the read-merge-write helper.
13
+ * - Secret-target values are written out-of-band by the responder
14
+ * (never on the bus); the reply carries `acknowledged: true` only.
15
+ * - Idempotent re-runs skip already-populated inputs and don't fire
16
+ * bus events at all.
17
+ */
18
+
19
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
24
+ import { and, eq } from 'drizzle-orm';
25
+ import { type DbClient, getDb } from '../db/client';
26
+ import { moduleConfigs, modules, secrets } from '../db/schema';
27
+ import type { Ensure } from '../manifest/schema';
28
+ import { decryptSecret } from '../secrets/encryption';
29
+ import { getOrCreateMasterKey } from '../secrets/master-key';
30
+ import type { EnsureRequiredPayload } from './bus-interview';
31
+ import { interviewForEnsureInputs, writeModuleSecretKey } from './config-interview';
32
+
33
+ const NO_SCHEMAS = defineEvents({});
34
+
35
+ const ENSURE: Ensure = {
36
+ id: 'managed_domain',
37
+ description: 'Add a domain.',
38
+ inputs: [
39
+ { kind: 'append_to_array', target: 'config.domains' },
40
+ {
41
+ kind: 'set_in_object',
42
+ target: 'config.zone_ids',
43
+ key: '{{value}}',
44
+ prompt: 'Zone id for {{value}}',
45
+ },
46
+ {
47
+ kind: 'set_in_object',
48
+ target: 'secret.ddns_passwords',
49
+ key: '{{value}}',
50
+ prompt: 'DDNS password for {{value}}',
51
+ hint: 'Advanced DNS panel',
52
+ },
53
+ ],
54
+ post: 'redeploy_self',
55
+ };
56
+
57
+ interface EnsureResponderOpts {
58
+ /** Value to "type" for config-target inputs, keyed by target. */
59
+ configValues: Record<string, string>;
60
+ /** Value to "type" for secret-target inputs, keyed by target. */
61
+ secretValues: Record<string, string>;
62
+ }
63
+
64
+ /**
65
+ * In-process test responder for ensure.required. Mirrors what the
66
+ * terminal-responder does: write secret values out-of-band via the
67
+ * read-merge-write path, return config values in `reply.values`,
68
+ * tag the reply with `acknowledged: true` if any secrets were touched.
69
+ */
70
+ function startEnsureResponder(
71
+ bus: Bus,
72
+ pattern: string,
73
+ opts: EnsureResponderOpts,
74
+ db: DbClient,
75
+ ): { seen: Array<{ payload: EnsureRequiredPayload; type: string }>; close: () => void } {
76
+ const seen: Array<{ payload: EnsureRequiredPayload; type: string }> = [];
77
+
78
+ const handle = bus.watch(pattern, async (event) => {
79
+ if (event.replyFor !== null) return;
80
+ const payload = event.payload as EnsureRequiredPayload;
81
+ seen.push({ payload, type: event.type });
82
+
83
+ const values: Record<string, unknown> = {};
84
+ let acknowledged = false;
85
+ let masterKey: Buffer | null = null;
86
+
87
+ for (const input of payload.inputs) {
88
+ if (input.target.startsWith('config.')) {
89
+ values[input.target] = opts.configValues[input.target];
90
+ continue;
91
+ }
92
+
93
+ // Secret target — read-merge-write the secret object, never
94
+ // include the value in `values`.
95
+ const name = input.target.slice('secret.'.length);
96
+ if (!masterKey) masterKey = await getOrCreateMasterKey();
97
+
98
+ const { readModuleSecretKey } = await import('./config-interview');
99
+ let obj: Record<string, unknown> = {};
100
+ const currentRaw = await readModuleSecretKey(payload.provider, name, db, masterKey);
101
+ if (currentRaw) {
102
+ try {
103
+ const parsed = JSON.parse(currentRaw);
104
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
105
+ obj = parsed as Record<string, unknown>;
106
+ }
107
+ } catch {
108
+ /* overwrite */
109
+ }
110
+ }
111
+ obj[input.objectKey] = opts.secretValues[input.target];
112
+ await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), db, masterKey);
113
+ acknowledged = true;
114
+ }
115
+
116
+ bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, {
117
+ replyFor: event.id,
118
+ emittedBy: 'test-responder',
119
+ });
120
+ });
121
+
122
+ return { seen, close: () => handle.close() };
123
+ }
124
+
125
+ describe('bus-mediated interviewForEnsureInputs', () => {
126
+ let tempDir: string;
127
+ let testDb: DbClient;
128
+ let bus: Bus;
129
+ let busDbPath: string;
130
+
131
+ beforeEach(() => {
132
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-bus-ensure-'));
133
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
134
+ busDbPath = join(tempDir, 'events.db');
135
+ process.env.EVENT_BUS_DB = busDbPath;
136
+
137
+ testDb = getDb();
138
+ testDb
139
+ .insert(modules)
140
+ .values({
141
+ id: 'namecheap',
142
+ name: 'Namecheap',
143
+ sourcePath: tempDir,
144
+ version: '2.0.0',
145
+ manifestData: {
146
+ provides: {
147
+ capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }],
148
+ },
149
+ },
150
+ })
151
+ .run();
152
+
153
+ bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
154
+ });
155
+
156
+ afterEach(() => {
157
+ bus.close();
158
+ rmSync(tempDir, { recursive: true, force: true });
159
+ process.env.CELILO_DB_PATH = undefined;
160
+ process.env.EVENT_BUS_DB = undefined;
161
+ });
162
+
163
+ test('mixed inputs: array applied deterministic, config + secret routed through bus', async () => {
164
+ const { seen, close } = startEnsureResponder(
165
+ bus,
166
+ 'ensure.required.namecheap.managed_domain',
167
+ {
168
+ configValues: { 'config.zone_ids': 'zone-abc-123' },
169
+ secretValues: { 'secret.ddns_passwords': 'super-secret-pw' },
170
+ },
171
+ testDb,
172
+ );
173
+
174
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb);
175
+
176
+ expect(result.success).toBe(true);
177
+ expect(result.alreadyApplied).toBe(false);
178
+
179
+ // Exactly one bus event for the whole ensure (set_in_object inputs
180
+ // bundled together; append_to_array stayed off the bus).
181
+ expect(seen.length).toBe(1);
182
+ expect(seen[0].type).toBe('ensure.required.namecheap.managed_domain');
183
+ expect(seen[0].payload.provider).toBe('namecheap');
184
+ expect(seen[0].payload.ensureId).toBe('managed_domain');
185
+ expect(seen[0].payload.triggerValue).toBe('celilo.computer');
186
+ // Only set_in_object inputs reach the responder.
187
+ expect(seen[0].payload.inputs).toHaveLength(2);
188
+ expect(seen[0].payload.inputs.map((i) => i.target).sort()).toEqual([
189
+ 'config.zone_ids',
190
+ 'secret.ddns_passwords',
191
+ ]);
192
+ // The bus payload must NEVER carry the secret value.
193
+ expect(JSON.stringify(seen[0].payload)).not.toContain('super-secret-pw');
194
+
195
+ // append_to_array applied without a bus event.
196
+ const domains = testDb
197
+ .select()
198
+ .from(moduleConfigs)
199
+ .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'domains')))
200
+ .get();
201
+ expect(JSON.parse(domains?.valueJson ?? '[]')).toEqual(['celilo.computer']);
202
+
203
+ // set_in_object config applied via reply.values.
204
+ const zoneIds = testDb
205
+ .select()
206
+ .from(moduleConfigs)
207
+ .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'zone_ids')))
208
+ .get();
209
+ expect(JSON.parse(zoneIds?.valueJson ?? '{}')).toEqual({
210
+ 'celilo.computer': 'zone-abc-123',
211
+ });
212
+
213
+ // set_in_object secret applied out-of-band by the responder.
214
+ const secretRow = testDb
215
+ .select()
216
+ .from(secrets)
217
+ .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'ddns_passwords')))
218
+ .get();
219
+ expect(secretRow).toBeTruthy();
220
+ if (!secretRow) return;
221
+ const masterKey = await getOrCreateMasterKey();
222
+ const decoded = decryptSecret(
223
+ {
224
+ encryptedValue: secretRow.encryptedValue,
225
+ iv: secretRow.iv,
226
+ authTag: secretRow.authTag,
227
+ },
228
+ masterKey,
229
+ );
230
+ expect(JSON.parse(decoded)).toEqual({ 'celilo.computer': 'super-secret-pw' });
231
+
232
+ close();
233
+ });
234
+
235
+ test('idempotent: re-run with same trigger value fires no bus event', async () => {
236
+ // First run populates everything.
237
+ {
238
+ const { close } = startEnsureResponder(
239
+ bus,
240
+ 'ensure.required.namecheap.managed_domain',
241
+ {
242
+ configValues: { 'config.zone_ids': 'z1' },
243
+ secretValues: { 'secret.ddns_passwords': 'pw1' },
244
+ },
245
+ testDb,
246
+ );
247
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb);
248
+ expect(result.success).toBe(true);
249
+ close();
250
+ }
251
+
252
+ // Second run: nothing should fire on the bus.
253
+ const fired: string[] = [];
254
+ const handle = bus.watch('ensure.required.namecheap.managed_domain', (event) => {
255
+ if (event.replyFor !== null) return;
256
+ fired.push(event.type);
257
+ });
258
+
259
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb);
260
+
261
+ expect(result.success).toBe(true);
262
+ expect(result.alreadyApplied).toBe(true);
263
+ expect(fired).toEqual([]);
264
+
265
+ handle.close();
266
+ });
267
+
268
+ test('multiple trigger values: bus-mediated ensure preserves prior entries', async () => {
269
+ {
270
+ const { close } = startEnsureResponder(
271
+ bus,
272
+ 'ensure.required.namecheap.managed_domain',
273
+ {
274
+ configValues: { 'config.zone_ids': 'z1' },
275
+ secretValues: { 'secret.ddns_passwords': 'pw1' },
276
+ },
277
+ testDb,
278
+ );
279
+ await interviewForEnsureInputs('namecheap', ENSURE, 'first.com', testDb);
280
+ close();
281
+ }
282
+
283
+ {
284
+ const { close } = startEnsureResponder(
285
+ bus,
286
+ 'ensure.required.namecheap.managed_domain',
287
+ {
288
+ configValues: { 'config.zone_ids': 'z2' },
289
+ secretValues: { 'secret.ddns_passwords': 'pw2' },
290
+ },
291
+ testDb,
292
+ );
293
+ await interviewForEnsureInputs('namecheap', ENSURE, 'second.com', testDb);
294
+ close();
295
+ }
296
+
297
+ const domains = testDb
298
+ .select()
299
+ .from(moduleConfigs)
300
+ .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'domains')))
301
+ .get();
302
+ expect(JSON.parse(domains?.valueJson ?? '[]')).toEqual(['first.com', 'second.com']);
303
+
304
+ const zoneIds = testDb
305
+ .select()
306
+ .from(moduleConfigs)
307
+ .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'zone_ids')))
308
+ .get();
309
+ expect(JSON.parse(zoneIds?.valueJson ?? '{}')).toEqual({
310
+ 'first.com': 'z1',
311
+ 'second.com': 'z2',
312
+ });
313
+
314
+ const secretRow = testDb
315
+ .select()
316
+ .from(secrets)
317
+ .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'ddns_passwords')))
318
+ .get();
319
+ if (!secretRow) throw new Error('expected secret row');
320
+ const masterKey = await getOrCreateMasterKey();
321
+ const decoded = decryptSecret(
322
+ {
323
+ encryptedValue: secretRow.encryptedValue,
324
+ iv: secretRow.iv,
325
+ authTag: secretRow.authTag,
326
+ },
327
+ masterKey,
328
+ );
329
+ expect(JSON.parse(decoded)).toEqual({ 'first.com': 'pw1', 'second.com': 'pw2' });
330
+ });
331
+
332
+ test('append_to_array-only ensure fires no bus event', async () => {
333
+ const arrayOnly: Ensure = {
334
+ id: 'simple_append',
335
+ inputs: [{ kind: 'append_to_array', target: 'config.allowed_hosts' }],
336
+ };
337
+
338
+ // Wildcard catches any ensure event (this test's `arrayOnly`
339
+ // ensure id is `simple_append`, not `managed_domain`).
340
+ const fired: string[] = [];
341
+ const handle = bus.watch('ensure.required.namecheap.*', (event) => {
342
+ if (event.replyFor !== null) return;
343
+ fired.push(event.type);
344
+ });
345
+
346
+ const result = await interviewForEnsureInputs('namecheap', arrayOnly, 'newhost.local', testDb);
347
+
348
+ expect(result.success).toBe(true);
349
+ expect(fired).toEqual([]);
350
+
351
+ const row = testDb
352
+ .select()
353
+ .from(moduleConfigs)
354
+ .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'allowed_hosts')))
355
+ .get();
356
+ expect(JSON.parse(row?.valueJson ?? '[]')).toEqual(['newhost.local']);
357
+
358
+ handle.close();
359
+ });
360
+
361
+ test('promptOverride still works (legacy test escape hatch)', async () => {
362
+ // The promptOverride path bypasses the bus entirely so existing
363
+ // unit tests don't have to set up a responder. Verify it still
364
+ // works.
365
+ const fired: string[] = [];
366
+ const handle = bus.watch('ensure.required.namecheap.managed_domain', (event) => {
367
+ if (event.replyFor !== null) return;
368
+ fired.push(event.type);
369
+ });
370
+
371
+ const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
372
+ promptOverride: async () => 'overridden',
373
+ });
374
+
375
+ expect(result.success).toBe(true);
376
+ expect(fired).toEqual([]); // override path skipped the bus
377
+
378
+ handle.close();
379
+ });
380
+ });
@@ -75,14 +75,24 @@ describe('busInterview', () => {
75
75
  { replyFor: event.id, emittedBy: 'fast' },
76
76
  );
77
77
  });
78
+ let slowFired = false;
78
79
  slowResponder.watch('config.required.foo.bar', (event) => {
79
80
  // Reply after a delay; the fast responder should win.
80
81
  setTimeout(() => {
81
- slowResponder.emitRaw(
82
- `${event.type}.reply`,
83
- { value: 'slow' },
84
- { replyFor: event.id, emittedBy: 'slow' },
85
- );
82
+ // Bus may have been closed by the time this fires (test won
83
+ // already). Skip the emit if so — checking via the
84
+ // testFinished flag set after assertions.
85
+ if (slowFired) return;
86
+ slowFired = true;
87
+ try {
88
+ slowResponder.emitRaw(
89
+ `${event.type}.reply`,
90
+ { value: 'slow' },
91
+ { replyFor: event.id, emittedBy: 'slow' },
92
+ );
93
+ } catch {
94
+ // Bus closed; benign in this test.
95
+ }
86
96
  }, 200);
87
97
  });
88
98
 
@@ -93,6 +103,7 @@ describe('busInterview', () => {
93
103
  required: true,
94
104
  });
95
105
  expect(reply.value).toBe('fast');
106
+ slowFired = true; // suppress the slow timer if it hasn't fired yet
96
107
 
97
108
  fastResponder.close();
98
109
  slowResponder.close();
@@ -132,3 +143,60 @@ describe('EVENT_TYPES', () => {
132
143
  );
133
144
  });
134
145
  });
146
+
147
+ describe('multi-select payload roundtrip', () => {
148
+ let dir: string;
149
+ let dbPath: string;
150
+ let origEnv: string | undefined;
151
+
152
+ beforeEach(() => {
153
+ dir = mkdtempSync(join(tmpdir(), 'bus-interview-multi-'));
154
+ dbPath = join(dir, 'events.db');
155
+ origEnv = process.env.EVENT_BUS_DB;
156
+ process.env.EVENT_BUS_DB = dbPath;
157
+ });
158
+ afterEach(() => {
159
+ if (origEnv === undefined) process.env.EVENT_BUS_DB = undefined;
160
+ else process.env.EVENT_BUS_DB = origEnv;
161
+ try {
162
+ rmSync(dir, { recursive: true, force: true });
163
+ } catch {
164
+ /* ignore */
165
+ }
166
+ });
167
+
168
+ it('options-bearing payload can be answered with a string[] reply', async () => {
169
+ // Stand up a responder that ignores text input and replies with a
170
+ // selection. Mimics what the terminal-responder does when it sees
171
+ // options[] and uses p.multiselect instead of promptText.
172
+ const responder = openBus({ dbPath, events: NO_SCHEMAS });
173
+ responder.watch('config.required.greenwave.zones', (event) => {
174
+ const payload = event.payload as ConfigRequiredPayload;
175
+ expect(payload.options).toBeDefined();
176
+ expect(payload.options?.map((o) => o.value).sort()).toEqual(['app', 'dmz', 'internal']);
177
+ responder.emitRaw(
178
+ `${event.type}.reply`,
179
+ { value: ['dmz', 'internal'] },
180
+ { replyFor: event.id, emittedBy: 'test' },
181
+ );
182
+ });
183
+
184
+ const reply = await busInterview<ConfigReply>(
185
+ EVENT_TYPES.configRequired('greenwave', 'zones'),
186
+ {
187
+ module: 'greenwave',
188
+ key: 'zones',
189
+ type: 'array',
190
+ required: true,
191
+ options: [
192
+ { value: 'dmz', label: 'DMZ' },
193
+ { value: 'app', label: 'App tier' },
194
+ { value: 'internal', label: 'Internal' },
195
+ ],
196
+ } satisfies ConfigRequiredPayload,
197
+ );
198
+
199
+ expect(reply.value).toEqual(['dmz', 'internal']);
200
+ responder.close();
201
+ });
202
+ });
@@ -55,12 +55,17 @@ export interface ConfigReply {
55
55
  /**
56
56
  * Payload for `secret.required.<module>.<key>`. The reply NEVER
57
57
  * carries the secret value — the responder calls
58
- * `celilo module secret set <module> <key> <value>` out-of-band to
59
- * write the value into the encrypted store, then replies with
60
- * `{ acknowledged: true }`.
58
+ * `celilo module secret set <module> <key> <value>` out-of-band (or
59
+ * uses the in-process equivalent) to write the value into the
60
+ * encrypted store, then replies with `{ acknowledged: true }`.
61
61
  *
62
62
  * Only fires for secrets WITHOUT a `generate:` block in the manifest
63
63
  * (auto-generated secrets bypass the interview entirely).
64
+ *
65
+ * `style` extends the design's payload so the responder knows whether
66
+ * to single-prompt, double-confirm, or treat empty as auto-generate.
67
+ * `generate` carries the format/length the responder needs for the
68
+ * `generated_optional` fallback when the user submits an empty value.
64
69
  */
65
70
  export interface SecretRequiredPayload {
66
71
  module: string;
@@ -68,6 +73,8 @@ export interface SecretRequiredPayload {
68
73
  type: 'string' | 'integer' | 'number';
69
74
  required: boolean;
70
75
  description?: string;
76
+ style?: 'user_provided' | 'user_password' | 'generated_optional';
77
+ generate?: { format: string; length: number };
71
78
  }
72
79
 
73
80
  export interface SecretAck {
@@ -86,12 +93,25 @@ export interface EnsureRequiredPayload {
86
93
  ensureId: string;
87
94
  triggerValue: string;
88
95
  description?: string;
96
+ /**
97
+ * Only inputs that genuinely need user input are sent. The deploy
98
+ * applies `append_to_array` inputs deterministically before/after
99
+ * the bus interview (the value being appended is the trigger value
100
+ * — known up-front — so no responder is needed).
101
+ */
89
102
  inputs: Array<{
90
103
  target: string;
91
- kind: 'append_to_array' | 'set_in_object';
104
+ kind: 'set_in_object';
92
105
  prompt: string;
93
106
  hint?: string;
94
107
  type: string;
108
+ /**
109
+ * Rendered key inside the object/secret the user-provided value
110
+ * will be stored under. The deploy renders this from `input.key`
111
+ * plus the trigger value before emitting, so the responder doesn't
112
+ * need template-rendering knowledge.
113
+ */
114
+ objectKey: string;
95
115
  }>;
96
116
  }
97
117