@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.
@@ -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,7 +70,9 @@ 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
@@ -60,16 +81,12 @@ export function startTerminalResponder(): TerminalResponderHandle {
60
81
  // we'd recursively try to prompt for our own reply event. The
61
82
  // explicit replyFor === null check below is defense in depth in
62
83
  // case any responder ever emits a reply with the same shape.
63
- const watch = bus.watch('config.required.*.*', async (event) => {
64
- // Reply events carry replyFor; queries don't. Skip anything that
65
- // looks like a reply, even if it accidentally matches the pattern.
84
+ const configWatch = bus.watch('config.required.*.*', async (event) => {
66
85
  if (event.replyFor !== null) return;
67
86
  if (handled.has(event.id)) return;
68
87
  handled.add(event.id);
69
88
 
70
89
  const payload = event.payload as ConfigRequiredPayload;
71
- // Defensive: if the payload is malformed (no module/key), don't
72
- // render a "undefined.undefined:" prompt — log and skip.
73
90
  if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
74
91
  log.warn(
75
92
  `Terminal responder skipped malformed event ${event.type} (id ${event.id}): missing module/key`,
@@ -81,38 +98,99 @@ export function startTerminalResponder(): TerminalResponderHandle {
81
98
  ? `${payload.module}.${payload.key} — ${payload.description}:`
82
99
  : `${payload.module}.${payload.key}:`;
83
100
 
84
- // Hint shown next to the prompt. For non-string types, tell the
85
- // user what shape we're expecting so they don't get a coercion
86
- // failure mid-deploy.
87
- const typeHint = describeTypeHint(payload.type);
101
+ let coerced: unknown;
88
102
 
89
- const value = await promptText({
90
- message: typeHint ? `${message} (${typeHint})` : message,
91
- validate: (val) => {
92
- if (payload.required && (!val || val.trim() === '')) {
93
- return 'This field is required';
94
- }
95
- if (payload.pattern && val) {
96
- const re = new RegExp(payload.pattern);
97
- if (!re.test(val)) return `Value must match: ${payload.pattern}`;
98
- }
99
- // Type-shape validation runs HERE, at submit time, so clack
100
- // re-prompts on bad input rather than letting a malformed
101
- // value through that fails post-submit.
102
- try {
103
- coerceValue(val, payload.type);
104
- } catch (err) {
105
- return err instanceof Error ? err.message : String(err);
106
- }
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({
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,
128
+ validate: (val) => {
129
+ if (payload.required && (!val || val.trim() === '')) {
130
+ return 'This field is required';
131
+ }
132
+ if (payload.pattern && val) {
133
+ const re = new RegExp(payload.pattern);
134
+ if (!re.test(val)) return `Value must match: ${payload.pattern}`;
135
+ }
136
+ try {
137
+ coerceValue(val, payload.type);
138
+ } catch (err) {
139
+ return err instanceof Error ? err.message : String(err);
140
+ }
141
+ },
142
+ });
143
+ coerced = coerceValue(value, payload.type);
144
+ }
145
+
146
+ bus.emitRaw(
147
+ `${event.type}.reply`,
148
+ { value: coerced },
149
+ {
150
+ replyFor: event.id,
151
+ emittedBy: me,
107
152
  },
108
- });
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);
109
160
 
110
- // Already validated above; coerce can't throw here.
111
- const coerced = coerceValue(value, payload.type);
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`,
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);
182
+ } catch (err) {
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
+ }
112
190
 
113
191
  bus.emitRaw(
114
192
  `${event.type}.reply`,
115
- { value: coerced },
193
+ { acknowledged: true },
116
194
  {
117
195
  replyFor: event.id,
118
196
  emittedBy: me,
@@ -120,14 +198,163 @@ export function startTerminalResponder(): TerminalResponderHandle {
120
198
  );
121
199
  });
122
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
+ }
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
+ });
284
+ });
285
+
123
286
  return {
124
287
  close() {
125
- watch.close();
288
+ configWatch.close();
289
+ secretWatch.close();
290
+ ensureWatch.close();
126
291
  bus.close();
127
292
  },
128
293
  };
129
294
  }
130
295
 
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
+
131
358
  /**
132
359
  * Short hint shown in the prompt for non-string types so the operator
133
360
  * isn't guessing what shape we want. Returns null for plain strings —
@@ -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
  };