@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.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Terminal-responder: when a deploy runs on a TTY, this subscribes
3
- * to `config.required.*` events and prompts the operator via clack
4
- * for each one, replying on the bus with the user's input.
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 { log, promptText } from '../cli/prompts';
31
+ import * as p from '@clack/prompts';
32
+ import { log, promptPassword, promptText } from '../cli/prompts';
23
33
  import { getEventBusPath } from '../config/paths';
24
- import type { ConfigRequiredPayload } from './bus-interview';
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 a transient subscription on `config.required.*` events.
45
- * For each event, prompt the operator via clack and reply on the bus.
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
- const watch = bus.watch('config.required.**', async (event) => {
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
- try {
63
- const message = payload.description
64
- ? `${payload.module}.${payload.key} ${payload.description}:`
65
- : `${payload.module}.${payload.key}:`;
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
- const value = await promptText({
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
- // Coerce to the declared type. clack returns string; the
81
- // responder honors the type contract so the deploy doesn't
82
- // have to second-guess the reply shape.
83
- const coerced = coerceValue(value, payload.type);
84
-
85
- bus.emitRaw(
86
- `${event.type}.reply`,
87
- { value: coerced },
88
- {
89
- replyFor: event.id,
90
- emittedBy: me,
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
- // Reply with an error-shaped payload so the deploy can surface
95
- // it cleanly. The deploy can decide whether to re-emit (for
96
- // recoverable errors) or fail.
97
- const message = err instanceof Error ? err.message : String(err);
98
- log.warn(`Terminal responder error for ${event.type}: ${message}`);
99
- bus.emitRaw(`${event.type}.reply`, { error: message }, { replyFor: event.id, emittedBy: me });
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
- watch.close();
288
+ configWatch.close();
289
+ secretWatch.close();
290
+ ensureWatch.close();
106
291
  bus.close();
107
292
  },
108
293
  };
109
294
  }
110
295
 
111
- function coerceValue(raw: string, type: ConfigRequiredPayload['type']): unknown {
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
+ }
@@ -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
  };