@celilo/cli 0.3.3 → 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.
- package/package.json +2 -2
- package/src/cli/command-registry.ts +34 -5
- package/src/cli/commands/events.ts +181 -0
- package/src/cli/commands/module-deploy.ts +10 -4
- package/src/cli/commands/module-remove.ts +2 -2
- package/src/cli/commands/system-update.ts +5 -1
- package/src/cli/index.ts +7 -1
- package/src/services/bus-ensure-flow.test.ts +380 -0
- package/src/services/bus-interview.test.ts +73 -5
- package/src/services/bus-interview.ts +24 -4
- package/src/services/bus-secret-flow.test.ts +327 -0
- package/src/services/config-interview.ts +285 -278
- package/src/services/ensure-interview.test.ts +4 -6
- package/src/services/module-deploy.ts +57 -45
- package/src/services/programmatic-responder.ts +294 -0
- package/src/services/terminal-responder.ts +310 -33
- package/src/test-utils/bus-responder.ts +126 -0
- package/src/test-utils/index.ts +7 -0
- package/src/test-utils/integration.ts +12 -0
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
59
|
-
*
|
|
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: '
|
|
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
|
|