@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal-responder: when a deploy runs on a TTY, this subscribes
|
|
3
|
-
* to `config.required
|
|
4
|
-
*
|
|
3
|
+
* to `config.required.*`, `secret.required.*`, and `ensure.required.*`
|
|
4
|
+
* events and prompts the operator via clack for each one, replying
|
|
5
|
+
* on the bus.
|
|
5
6
|
*
|
|
6
7
|
* Just one of several responder shapes — the bus query/reply path is
|
|
7
8
|
* a race, and the terminal is one racer. Other racers (Claude
|
|
@@ -14,14 +15,31 @@
|
|
|
14
15
|
* deploy already moved on. The user sees their prompt close and the
|
|
15
16
|
* deploy continues with the winning value (logged by the deploy in
|
|
16
17
|
* its normal output). Race-during-typing detection is a follow-up.
|
|
18
|
+
*
|
|
19
|
+
* Secret + ensure values never cross the bus. The responder writes
|
|
20
|
+
* them directly to the encrypted store via the same helpers the
|
|
21
|
+
* deploy uses, then replies with `{ acknowledged: true }`. Other
|
|
22
|
+
* responder shapes (Claude subagent, separate-shell `events respond`)
|
|
23
|
+
* achieve the same out-of-band injection by shelling out to
|
|
24
|
+
* `celilo module secret set`; the in-process terminal-responder takes
|
|
25
|
+
* the shorter path of calling the helpers directly.
|
|
17
26
|
*/
|
|
18
27
|
|
|
19
28
|
import { hostname } from 'node:os';
|
|
20
29
|
import { type Bus, openBus } from '@celilo/event-bus';
|
|
21
30
|
import { defineEvents } from '@celilo/event-bus';
|
|
22
|
-
import
|
|
31
|
+
import * as p from '@clack/prompts';
|
|
32
|
+
import { log, promptPassword, promptText } from '../cli/prompts';
|
|
23
33
|
import { getEventBusPath } from '../config/paths';
|
|
24
|
-
import
|
|
34
|
+
import { getDb } from '../db/client';
|
|
35
|
+
import { generateSecret } from '../secrets/generators';
|
|
36
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
37
|
+
import type {
|
|
38
|
+
ConfigRequiredPayload,
|
|
39
|
+
EnsureRequiredPayload,
|
|
40
|
+
SecretRequiredPayload,
|
|
41
|
+
} from './bus-interview';
|
|
42
|
+
import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
|
|
25
43
|
|
|
26
44
|
const NO_SCHEMAS = defineEvents({});
|
|
27
45
|
|
|
@@ -41,8 +59,9 @@ export interface TerminalResponderHandle {
|
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/**
|
|
44
|
-
* Register
|
|
45
|
-
* For each event, prompt the operator via clack
|
|
62
|
+
* Register transient subscriptions on the three interview event
|
|
63
|
+
* families. For each event, prompt the operator via clack, optionally
|
|
64
|
+
* inject the secret value out-of-band, and reply on the bus.
|
|
46
65
|
*
|
|
47
66
|
* Returns a handle the caller can close() when the deploy completes.
|
|
48
67
|
*/
|
|
@@ -51,21 +70,61 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
51
70
|
const me = responderId();
|
|
52
71
|
|
|
53
72
|
// Track which queries we've already started a prompt for, so a
|
|
54
|
-
// re-fire (e.g. validation re-emit) doesn't double-prompt.
|
|
73
|
+
// re-fire (e.g. validation re-emit) doesn't double-prompt. Shared
|
|
74
|
+
// across the three watches by event id (ids are globally unique
|
|
75
|
+
// within the events table).
|
|
55
76
|
const handled = new Set<number>();
|
|
56
77
|
|
|
57
|
-
|
|
78
|
+
// Pattern matches exactly `config.required.<module>.<key>` (4 dot
|
|
79
|
+
// segments). The reply we emit is `config.required.<m>.<k>.reply`
|
|
80
|
+
// (5 segments) — wouldn't match — but `**` would have matched, and
|
|
81
|
+
// we'd recursively try to prompt for our own reply event. The
|
|
82
|
+
// explicit replyFor === null check below is defense in depth in
|
|
83
|
+
// case any responder ever emits a reply with the same shape.
|
|
84
|
+
const configWatch = bus.watch('config.required.*.*', async (event) => {
|
|
85
|
+
if (event.replyFor !== null) return;
|
|
58
86
|
if (handled.has(event.id)) return;
|
|
59
87
|
handled.add(event.id);
|
|
60
88
|
|
|
61
89
|
const payload = event.payload as ConfigRequiredPayload;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
90
|
+
if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
|
|
91
|
+
log.warn(
|
|
92
|
+
`Terminal responder skipped malformed event ${event.type} (id ${event.id}): missing module/key`,
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
66
96
|
|
|
67
|
-
|
|
97
|
+
const message = payload.description
|
|
98
|
+
? `${payload.module}.${payload.key} — ${payload.description}:`
|
|
99
|
+
: `${payload.module}.${payload.key}:`;
|
|
100
|
+
|
|
101
|
+
let coerced: unknown;
|
|
102
|
+
|
|
103
|
+
if (payload.options && payload.options.length > 0) {
|
|
104
|
+
// Multi-select prompt for vars with options[] declared in the
|
|
105
|
+
// manifest. clack's multiselect returns an array of selected
|
|
106
|
+
// values directly — no JSON-typing to coerce.
|
|
107
|
+
const selected = await p.multiselect({
|
|
68
108
|
message,
|
|
109
|
+
options: payload.options.map((opt) => ({
|
|
110
|
+
value: opt.value,
|
|
111
|
+
label: opt.label,
|
|
112
|
+
hint: opt.hint,
|
|
113
|
+
})),
|
|
114
|
+
required: payload.required,
|
|
115
|
+
});
|
|
116
|
+
if (p.isCancel(selected)) {
|
|
117
|
+
log.warn(
|
|
118
|
+
`Terminal responder: cancelled prompt for ${payload.module}.${payload.key}; no reply emitted`,
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
coerced = selected as unknown[];
|
|
123
|
+
} else {
|
|
124
|
+
// Free-text prompt with type-shape validation at submit-time.
|
|
125
|
+
const typeHint = describeTypeHint(payload.type);
|
|
126
|
+
const value = await promptText({
|
|
127
|
+
message: typeHint ? `${message} (${typeHint})` : message,
|
|
69
128
|
validate: (val) => {
|
|
70
129
|
if (payload.required && (!val || val.trim() === '')) {
|
|
71
130
|
return 'This field is required';
|
|
@@ -74,41 +133,259 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
74
133
|
const re = new RegExp(payload.pattern);
|
|
75
134
|
if (!re.test(val)) return `Value must match: ${payload.pattern}`;
|
|
76
135
|
}
|
|
136
|
+
try {
|
|
137
|
+
coerceValue(val, payload.type);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return err instanceof Error ? err.message : String(err);
|
|
140
|
+
}
|
|
77
141
|
},
|
|
78
142
|
});
|
|
143
|
+
coerced = coerceValue(value, payload.type);
|
|
144
|
+
}
|
|
79
145
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
146
|
+
bus.emitRaw(
|
|
147
|
+
`${event.type}.reply`,
|
|
148
|
+
{ value: coerced },
|
|
149
|
+
{
|
|
150
|
+
replyFor: event.id,
|
|
151
|
+
emittedBy: me,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const secretWatch = bus.watch('secret.required.*.*', async (event) => {
|
|
157
|
+
if (event.replyFor !== null) return;
|
|
158
|
+
if (handled.has(event.id)) return;
|
|
159
|
+
handled.add(event.id);
|
|
160
|
+
|
|
161
|
+
const payload = event.payload as SecretRequiredPayload;
|
|
162
|
+
if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
|
|
163
|
+
log.warn(
|
|
164
|
+
`Terminal responder skipped malformed event ${event.type} (id ${event.id}): missing module/key`,
|
|
92
165
|
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const value = await promptForSecret(payload);
|
|
170
|
+
if (value === undefined) {
|
|
171
|
+
// User cancelled; don't reply.
|
|
172
|
+
log.warn(
|
|
173
|
+
`Terminal responder: cancelled prompt for ${payload.module}.${payload.key}; no reply emitted`,
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const db = getDb();
|
|
180
|
+
const masterKey = await getOrCreateMasterKey();
|
|
181
|
+
await writeModuleSecretKey(payload.module, payload.key, value, db, masterKey);
|
|
93
182
|
} catch (err) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
183
|
+
log.error(
|
|
184
|
+
`Terminal responder failed to write secret ${payload.module}.${payload.key}: ${
|
|
185
|
+
err instanceof Error ? err.message : String(err)
|
|
186
|
+
}`,
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
bus.emitRaw(
|
|
192
|
+
`${event.type}.reply`,
|
|
193
|
+
{ acknowledged: true },
|
|
194
|
+
{
|
|
195
|
+
replyFor: event.id,
|
|
196
|
+
emittedBy: me,
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const ensureWatch = bus.watch('ensure.required.*.*', async (event) => {
|
|
202
|
+
if (event.replyFor !== null) return;
|
|
203
|
+
if (handled.has(event.id)) return;
|
|
204
|
+
handled.add(event.id);
|
|
205
|
+
|
|
206
|
+
const payload = event.payload as EnsureRequiredPayload;
|
|
207
|
+
if (!payload || typeof payload.provider !== 'string' || !Array.isArray(payload.inputs)) {
|
|
208
|
+
log.warn(`Terminal responder skipped malformed ensure event ${event.type} (id ${event.id})`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const values: Record<string, unknown> = {};
|
|
213
|
+
let acknowledged = false;
|
|
214
|
+
let cancelled = false;
|
|
215
|
+
|
|
216
|
+
let masterKey: Buffer | null = null;
|
|
217
|
+
|
|
218
|
+
for (const input of payload.inputs) {
|
|
219
|
+
const message = input.hint ? `${input.prompt}\n ${input.hint}` : input.prompt;
|
|
220
|
+
const userValue = await promptText({ message });
|
|
221
|
+
if (userValue === undefined || userValue.trim() === '') {
|
|
222
|
+
// Treat empty/cancel as cancellation; don't reply with a
|
|
223
|
+
// half-filled values object.
|
|
224
|
+
cancelled = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (input.target.startsWith('config.')) {
|
|
229
|
+
// The deploy applies the read-merge-write on the config side
|
|
230
|
+
// using the value we hand back here. Keyed by full target;
|
|
231
|
+
// the deploy already knows objectKey from the input payload
|
|
232
|
+
// it sent us, so we don't echo it.
|
|
233
|
+
values[input.target] = userValue;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Secret target: read-merge-write the secret in-process so the
|
|
238
|
+
// value never crosses the bus. The deploy will re-read after
|
|
239
|
+
// the ack, so it sees the merged object.
|
|
240
|
+
const name = input.target.slice('secret.'.length);
|
|
241
|
+
const db = getDb();
|
|
242
|
+
if (!masterKey) masterKey = await getOrCreateMasterKey();
|
|
243
|
+
|
|
244
|
+
let obj: Record<string, unknown> = {};
|
|
245
|
+
const currentRaw = await readModuleSecretKey(payload.provider, name, db, masterKey);
|
|
246
|
+
if (currentRaw) {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(currentRaw);
|
|
249
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
250
|
+
obj = parsed as Record<string, unknown>;
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Existing secret isn't a JSON object — overwrite (the
|
|
254
|
+
// schema declares this secret as an object, so any other
|
|
255
|
+
// shape is stale).
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
obj[input.objectKey] = userValue;
|
|
259
|
+
try {
|
|
260
|
+
await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), db, masterKey);
|
|
261
|
+
acknowledged = true;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
log.error(
|
|
264
|
+
`Terminal responder failed to write secret ${payload.provider}.${name}: ${
|
|
265
|
+
err instanceof Error ? err.message : String(err)
|
|
266
|
+
}`,
|
|
267
|
+
);
|
|
268
|
+
cancelled = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
100
271
|
}
|
|
272
|
+
|
|
273
|
+
if (cancelled) {
|
|
274
|
+
log.warn(
|
|
275
|
+
`Terminal responder: cancelled ensure ${payload.provider}.${payload.ensureId}; no reply emitted`,
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, {
|
|
281
|
+
replyFor: event.id,
|
|
282
|
+
emittedBy: me,
|
|
283
|
+
});
|
|
101
284
|
});
|
|
102
285
|
|
|
103
286
|
return {
|
|
104
287
|
close() {
|
|
105
|
-
|
|
288
|
+
configWatch.close();
|
|
289
|
+
secretWatch.close();
|
|
290
|
+
ensureWatch.close();
|
|
106
291
|
bus.close();
|
|
107
292
|
},
|
|
108
293
|
};
|
|
109
294
|
}
|
|
110
295
|
|
|
111
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Prompt the operator for a secret value, handling all three
|
|
298
|
+
* `style` variants. Returns the value to store, or `undefined` if
|
|
299
|
+
* the user cancelled.
|
|
300
|
+
*
|
|
301
|
+
* For `user_password`: re-prompts on mismatch (loop until match or
|
|
302
|
+
* cancel) — a much friendlier UX than the old "fail and re-run
|
|
303
|
+
* deploy" behavior.
|
|
304
|
+
*
|
|
305
|
+
* For `generated_optional`: empty input falls back to
|
|
306
|
+
* `generateSecret(payload.generate)` so the operator can hit Enter
|
|
307
|
+
* to skip and let the deploy auto-generate.
|
|
308
|
+
*/
|
|
309
|
+
async function promptForSecret(payload: SecretRequiredPayload): Promise<string | undefined> {
|
|
310
|
+
const message = payload.description
|
|
311
|
+
? `${payload.module}.${payload.key} — ${payload.description}:`
|
|
312
|
+
: `${payload.module}.${payload.key}:`;
|
|
313
|
+
const style = payload.style ?? 'user_provided';
|
|
314
|
+
|
|
315
|
+
if (style === 'user_password') {
|
|
316
|
+
while (true) {
|
|
317
|
+
const value = await promptPassword({
|
|
318
|
+
message,
|
|
319
|
+
validate: (val) => (!val || val.trim() === '' ? 'This field is required' : undefined),
|
|
320
|
+
});
|
|
321
|
+
if (value === undefined) return undefined;
|
|
322
|
+
const confirm = await promptPassword({
|
|
323
|
+
message: `Confirm ${payload.module}.${payload.key}:`,
|
|
324
|
+
validate: (val) => (!val || val.trim() === '' ? 'This field is required' : undefined),
|
|
325
|
+
});
|
|
326
|
+
if (confirm === undefined) return undefined;
|
|
327
|
+
if (value === confirm) return value;
|
|
328
|
+
log.error('Passwords do not match. Try again.');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (style === 'generated_optional') {
|
|
333
|
+
const optMessage = `${message} (Enter = auto-generate)`;
|
|
334
|
+
const value = await promptPassword({
|
|
335
|
+
message: optMessage,
|
|
336
|
+
validate: () => undefined,
|
|
337
|
+
});
|
|
338
|
+
if (value === undefined) return undefined;
|
|
339
|
+
if (value.trim() === '') {
|
|
340
|
+
const format = payload.generate?.format ?? 'base64';
|
|
341
|
+
const length = payload.generate?.length ?? 32;
|
|
342
|
+
const generated = generateSecret({ format, length });
|
|
343
|
+
log.message(`Auto-generated ${format} secret: ${payload.module}.${payload.key}`);
|
|
344
|
+
return generated;
|
|
345
|
+
}
|
|
346
|
+
return value;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Default: user_provided — single prompt, required.
|
|
350
|
+
const value = await promptPassword({
|
|
351
|
+
message,
|
|
352
|
+
validate: (val) =>
|
|
353
|
+
payload.required && (!val || val.trim() === '') ? 'This field is required' : undefined,
|
|
354
|
+
});
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Short hint shown in the prompt for non-string types so the operator
|
|
360
|
+
* isn't guessing what shape we want. Returns null for plain strings —
|
|
361
|
+
* no hint needed.
|
|
362
|
+
*/
|
|
363
|
+
function describeTypeHint(type: ConfigRequiredPayload['type']): string | null {
|
|
364
|
+
switch (type) {
|
|
365
|
+
case 'string':
|
|
366
|
+
return null;
|
|
367
|
+
case 'integer':
|
|
368
|
+
return 'integer';
|
|
369
|
+
case 'number':
|
|
370
|
+
return 'number';
|
|
371
|
+
case 'boolean':
|
|
372
|
+
return 'true/false/yes/no/y/n/1/0';
|
|
373
|
+
case 'array':
|
|
374
|
+
return 'JSON array, e.g. ["a","b"]';
|
|
375
|
+
case 'object':
|
|
376
|
+
return 'JSON object, e.g. {"k":"v"}';
|
|
377
|
+
default:
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function coerceValue(raw: string | undefined, type: ConfigRequiredPayload['type']): unknown {
|
|
383
|
+
// clack returns undefined when the user cancels (Ctrl+C). Bubble
|
|
384
|
+
// that up as an error so validate sees it and the responder can
|
|
385
|
+
// skip the reply rather than emit a malformed one.
|
|
386
|
+
if (raw === undefined) {
|
|
387
|
+
throw new Error('Cancelled');
|
|
388
|
+
}
|
|
112
389
|
switch (type) {
|
|
113
390
|
case 'string':
|
|
114
391
|
return raw;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bus responder test fixture.
|
|
3
|
+
*
|
|
4
|
+
* Stage 4 dropped the `--no-interactive` deploy flag, so missing
|
|
5
|
+
* config now fires bus events instead of aborting the deploy.
|
|
6
|
+
* Integration tests that previously asserted on the abort behavior
|
|
7
|
+
* need to attach a responder to the same bus DB the celilo CLI
|
|
8
|
+
* subprocess uses, then assert on what the responder saw + the
|
|
9
|
+
* deploy's final state.
|
|
10
|
+
*
|
|
11
|
+
* The fixture is in-process, the deploy runs in a subprocess —
|
|
12
|
+
* they share the bus via the file path passed in.
|
|
13
|
+
*
|
|
14
|
+
* Implementation: thin wrapper around `services/programmatic-
|
|
15
|
+
* responder.ts`, which is the production-grade responder that also
|
|
16
|
+
* powers `celilo events respond --values <file>`. The fixture sets
|
|
17
|
+
* `onMissing: 'throw'` so a forgotten value fails the test loudly
|
|
18
|
+
* instead of letting the deploy hang waiting for a reply.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
*
|
|
22
|
+
* const responder = startBusResponderFixture({
|
|
23
|
+
* busDbPath,
|
|
24
|
+
* config: { 'mymod.host': 'example.com' },
|
|
25
|
+
* secrets: { 'mymod.api_key': 'sk-fake' },
|
|
26
|
+
* ensures: {
|
|
27
|
+
* 'namecheap.managed_domain': {
|
|
28
|
+
* configValues: { 'config.zone_ids': 'zone-1' },
|
|
29
|
+
* secretValues: { 'secret.ddns_passwords': 'pw' },
|
|
30
|
+
* },
|
|
31
|
+
* },
|
|
32
|
+
* });
|
|
33
|
+
* try {
|
|
34
|
+
* ...
|
|
35
|
+
* expect(responder.seenConfigKeys()).toContain('mymod.host');
|
|
36
|
+
* } finally {
|
|
37
|
+
* responder.close();
|
|
38
|
+
* }
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
42
|
+
import type {
|
|
43
|
+
ConfigRequiredPayload,
|
|
44
|
+
EnsureRequiredPayload,
|
|
45
|
+
SecretRequiredPayload,
|
|
46
|
+
} from '../services/bus-interview';
|
|
47
|
+
import {
|
|
48
|
+
type ProgrammaticResponderHandle,
|
|
49
|
+
type ResponderValues,
|
|
50
|
+
startProgrammaticResponder,
|
|
51
|
+
} from '../services/programmatic-responder';
|
|
52
|
+
|
|
53
|
+
export interface BusResponderFixtureOptions extends ResponderValues {
|
|
54
|
+
/** Path to the bus sqlite db (shared with the celilo subprocess). */
|
|
55
|
+
busDbPath: string;
|
|
56
|
+
/**
|
|
57
|
+
* Path to the celilo sqlite db. The responder reaches into this DB
|
|
58
|
+
* directly to write secret values out-of-band — same pattern the
|
|
59
|
+
* terminal-responder uses when running in-process inside the deploy.
|
|
60
|
+
*/
|
|
61
|
+
celiloDbPath: string;
|
|
62
|
+
/**
|
|
63
|
+
* Path to the celilo data dir (where master.key lives). The
|
|
64
|
+
* responder needs the master key to encrypt secret values.
|
|
65
|
+
*/
|
|
66
|
+
dataDir: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BusResponderFixture {
|
|
70
|
+
/** Module.key strings the responder fielded on `config.required`. */
|
|
71
|
+
seenConfigKeys(): string[];
|
|
72
|
+
/** Module.key strings the responder fielded on `secret.required`. */
|
|
73
|
+
seenSecretKeys(): string[];
|
|
74
|
+
/** `<provider>.<ensureId>` strings the responder fielded. */
|
|
75
|
+
seenEnsureKeys(): string[];
|
|
76
|
+
/** Snapshot of every config.required payload the responder saw. */
|
|
77
|
+
seenConfigPayloads(): ConfigRequiredPayload[];
|
|
78
|
+
/** Snapshot of every secret.required payload the responder saw. */
|
|
79
|
+
seenSecretPayloads(): SecretRequiredPayload[];
|
|
80
|
+
/** Snapshot of every ensure.required payload the responder saw. */
|
|
81
|
+
seenEnsurePayloads(): EnsureRequiredPayload[];
|
|
82
|
+
/** Stop watching and close the bus + db connections. */
|
|
83
|
+
close(): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start a programmatic responder against the given bus DB. Opens
|
|
88
|
+
* its own bus + db client in the test process so the celilo
|
|
89
|
+
* subprocess and the responder share state via file storage.
|
|
90
|
+
*/
|
|
91
|
+
export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusResponderFixture {
|
|
92
|
+
// Set CELILO_DATA_DIR for getOrCreateMasterKey so the responder
|
|
93
|
+
// reads the subprocess's master.key. The fixture's process inherits
|
|
94
|
+
// these env vars; restore on close so other tests aren't affected.
|
|
95
|
+
const prevDataDir = process.env.CELILO_DATA_DIR;
|
|
96
|
+
process.env.CELILO_DATA_DIR = opts.dataDir;
|
|
97
|
+
|
|
98
|
+
const prevDbPath = process.env.CELILO_DB_PATH;
|
|
99
|
+
process.env.CELILO_DB_PATH = opts.celiloDbPath;
|
|
100
|
+
const db: DbClient = createDbClient();
|
|
101
|
+
|
|
102
|
+
const handle: ProgrammaticResponderHandle = startProgrammaticResponder({
|
|
103
|
+
busDbPath: opts.busDbPath,
|
|
104
|
+
db,
|
|
105
|
+
values: { config: opts.config, secrets: opts.secrets, ensures: opts.ensures },
|
|
106
|
+
onMissing: 'throw',
|
|
107
|
+
emittedBy: 'test-bus-responder',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
seenConfigKeys: () => handle.seenConfigPayloads().map((p) => `${p.module}.${p.key}`),
|
|
112
|
+
seenSecretKeys: () => handle.seenSecretPayloads().map((p) => `${p.module}.${p.key}`),
|
|
113
|
+
seenEnsureKeys: () => handle.seenEnsurePayloads().map((p) => `${p.provider}.${p.ensureId}`),
|
|
114
|
+
seenConfigPayloads: () => handle.seenConfigPayloads(),
|
|
115
|
+
seenSecretPayloads: () => handle.seenSecretPayloads(),
|
|
116
|
+
seenEnsurePayloads: () => handle.seenEnsurePayloads(),
|
|
117
|
+
close: () => {
|
|
118
|
+
handle.close();
|
|
119
|
+
db.$client.close();
|
|
120
|
+
if (prevDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
|
|
121
|
+
else process.env.CELILO_DATA_DIR = prevDataDir;
|
|
122
|
+
if (prevDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
|
|
123
|
+
else process.env.CELILO_DB_PATH = prevDbPath;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/test-utils/index.ts
CHANGED
|
@@ -21,6 +21,13 @@ export { runCli, runCliExpectingFailure } from './cli';
|
|
|
21
21
|
// Integration test setup
|
|
22
22
|
export { setupIntegrationTest, type IntegrationTestContext } from './integration';
|
|
23
23
|
|
|
24
|
+
// Bus responder fixture for stage-4+ tests
|
|
25
|
+
export {
|
|
26
|
+
startBusResponderFixture,
|
|
27
|
+
type BusResponderFixture,
|
|
28
|
+
type BusResponderFixtureOptions,
|
|
29
|
+
} from './bus-responder';
|
|
30
|
+
|
|
24
31
|
// Database utilities
|
|
25
32
|
export {
|
|
26
33
|
setupTestDatabase,
|
|
@@ -12,6 +12,12 @@ export interface IntegrationTestContext {
|
|
|
12
12
|
dbPath: string;
|
|
13
13
|
/** Path to test data directory */
|
|
14
14
|
dataDir: string;
|
|
15
|
+
/**
|
|
16
|
+
* Path to the bus event sqlite db. Tests that need to attach a
|
|
17
|
+
* bus responder fixture can pass this to `startBusResponderFixture`
|
|
18
|
+
* so the responder shares the bus with the celilo CLI subprocess.
|
|
19
|
+
*/
|
|
20
|
+
busDbPath: string;
|
|
15
21
|
/** CLI command prefix with environment variables */
|
|
16
22
|
cli: string;
|
|
17
23
|
/** Cleanup function (closes DB and removes temp directories) */
|
|
@@ -56,6 +62,11 @@ export async function setupIntegrationTest(): Promise<IntegrationTestContext> {
|
|
|
56
62
|
// Setup isolated data directory (use mkdtemp to avoid timestamp collisions in parallel tests)
|
|
57
63
|
const dataDir = await mkdtemp(join(tmpdir(), 'celilo-test-'));
|
|
58
64
|
|
|
65
|
+
// The celilo CLI's getEventBusPath() falls back to <dataDir>/events.db
|
|
66
|
+
// when EVENT_BUS_DB isn't set. Tests that attach a responder need
|
|
67
|
+
// the same path so subprocess + responder share the bus DB.
|
|
68
|
+
const busDbPath = join(dataDir, 'events.db');
|
|
69
|
+
|
|
59
70
|
// CLI with isolated environment
|
|
60
71
|
const cli = `CELILO_DB_PATH="${dbPath}" CELILO_DATA_DIR="${dataDir}" bun run src/cli/index.ts`;
|
|
61
72
|
|
|
@@ -75,6 +86,7 @@ export async function setupIntegrationTest(): Promise<IntegrationTestContext> {
|
|
|
75
86
|
return {
|
|
76
87
|
dbPath,
|
|
77
88
|
dataDir,
|
|
89
|
+
busDbPath,
|
|
78
90
|
cli,
|
|
79
91
|
cleanup,
|
|
80
92
|
};
|