@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.
@@ -162,13 +162,11 @@ describe('interviewForEnsureInputs', () => {
162
162
  // is the user's consent for the cross-module config its hooks imply.
163
163
  // See config-interview.ts and CADDY_HOSTNAME_LIST.md, Decision 6.
164
164
 
165
- test('non-interactive mode applies directly', async () => {
166
- // In production, non-interactive mode prints a recipe and aborts
167
- // before reaching this function. The function itself supports being
168
- // called non-interactively for tests / scripted use, applying inputs
169
- // directly. promptOverride is still required for set_in_object inputs.
165
+ test('promptOverride applies inputs directly without bus or terminal', async () => {
166
+ // promptOverride is the test escape hatch bypasses the bus path
167
+ // so unit tests don't need to set up a responder. See bus-ensure-
168
+ // flow.test.ts for the full bus-mediated flow.
170
169
  const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
171
- noInteractive: true,
172
170
  promptOverride: async () => 'pw',
173
171
  });
174
172
  expect(result.success).toBe(true);
@@ -26,7 +26,6 @@ import {
26
26
  interviewForEnsureInputs,
27
27
  interviewForMissingConfig,
28
28
  interviewForMissingSecrets,
29
- renderEnsureRecipe,
30
29
  } from './config-interview';
31
30
  import { getContainerService } from './container-service';
32
31
  import { executeAnsible } from './deploy-ansible';
@@ -89,7 +88,6 @@ async function updateMachineAssignment(
89
88
  }
90
89
 
91
90
  export interface DeployOptions {
92
- noInteractive?: boolean;
93
91
  debug?: boolean;
94
92
  /**
95
93
  * Keep all sub-events visible during the deploy. With this off (the
@@ -99,6 +97,17 @@ export interface DeployOptions {
99
97
  * useful for debugging slow or hanging steps.
100
98
  */
101
99
  verbose?: boolean;
100
+ /**
101
+ * Exit cleanly after the initial config + secrets interview phase
102
+ * — before any infrastructure code generation, terraform, ansible,
103
+ * or hook execution. Manual validation tool: lets an operator
104
+ * exercise the bus-mediated interview (config.required / secret.required
105
+ * events fire, responders answer, values land in the encrypted store)
106
+ * without actually deploying anything to a machine. The cross-module
107
+ * `ensure` interview (which fires from hooks) is NOT exercised in
108
+ * this mode — that requires a real hook run.
109
+ */
110
+ stopAfterInterview?: boolean;
102
111
  }
103
112
 
104
113
  /**
@@ -189,12 +198,6 @@ async function invokeHookWithEnsureRetry(
189
198
  };
190
199
  }
191
200
 
192
- if (deployOptions.noInteractive) {
193
- attempt.gauge.stop(false);
194
- log.error(renderEnsureRecipe(m.providerModuleId, ensure, m.value));
195
- return result;
196
- }
197
-
198
201
  // Step the gauge out of the way so prompts render on a clean line.
199
202
  attempt.gauge.stopSilent();
200
203
 
@@ -302,14 +305,15 @@ async function deployModuleImpl(
302
305
  ): Promise<DeployResult> {
303
306
  const phases: DeployResult['phases'] = {};
304
307
 
305
- // --no-interactive emits structured [progress:*] markers (for cele2e
306
- // to parse). --verbose forces render mode but with isTTY=false, which
307
- // disables cursor magic — every sub-event stays visible after each
308
- // step completes (no collapse-on-success). When neither is set, mode
309
- // is 'auto' and the display picks based on the real stdout's isTTY.
310
- // --no-interactive wins if both are set (protocol output is what
311
- // cele2e expects regardless of verbosity).
312
- const displayMode = options.noInteractive ? 'protocol' : options.verbose ? 'render' : 'auto';
308
+ // When stdout isn't a TTY (cele2e subprocess, CI), emit structured
309
+ // [progress:*] markers that cele2e parses. --verbose forces render
310
+ // mode but with isTTY=false, which disables cursor magic — every
311
+ // sub-event stays visible after each step completes (no collapse-
312
+ // on-success). On a TTY without --verbose, mode is 'auto' and the
313
+ // ProgressDisplay picks animated gauges. Non-TTY wins if both are
314
+ // set (protocol output is what cele2e expects regardless).
315
+ const nonTTY = !process.stdout.isTTY;
316
+ const displayMode = nonTTY ? 'protocol' : options.verbose ? 'render' : 'auto';
313
317
  const display = new ProgressDisplay({
314
318
  mode: displayMode,
315
319
  out: options.verbose
@@ -319,15 +323,14 @@ async function deployModuleImpl(
319
323
  setActiveDisplay(display);
320
324
 
321
325
  // Terminal-responder: when running on a TTY, this subscribes to
322
- // `config.required.*` events and prompts via clack so the operator
323
- // can answer the deploy's interview without `--no-interactive`'s
324
- // brittle "everything must be pre-staged" flow. Other responders
325
- // (Claude subagent, `celilo events respond`) compete; first reply
326
- // wins. See infra/design/INTERACTIVE_DEPLOYS_VIA_BUS.md.
327
- const terminalResponder =
328
- !options.noInteractive && process.stdin.isTTY
329
- ? (await import('./terminal-responder')).startTerminalResponder()
330
- : null;
326
+ // `config.required.*` / `secret.required.*` / `ensure.required.*`
327
+ // events and prompts via clack. Other responder shapes (Claude
328
+ // subagent, `celilo events respond` from another shell, autoresponder
329
+ // daemon) compete on the bus; first reply wins. See
330
+ // infra/design/INTERACTIVE_DEPLOYS_VIA_BUS.md.
331
+ const terminalResponder = process.stdin.isTTY
332
+ ? (await import('./terminal-responder')).startTerminalResponder()
333
+ : null;
331
334
 
332
335
  try {
333
336
  // Check for e2e test containers — live and e2e environments are mutually exclusive.
@@ -440,18 +443,12 @@ async function deployModuleImpl(
440
443
  }
441
444
  }
442
445
 
443
- if (remainingMissing.length > 0 && options.noInteractive) {
444
- // Non-interactive mode: fail with error for remaining missing variables
445
- const varList = remainingMissing.map((v) => ` - ${v.name} (${v.source})`).join('\n');
446
- return {
447
- success: false,
448
- error: `Missing required configuration:\n${varList}\n\nConfigure with: celilo module config set ${moduleId} <variable> <value>\nOr run without --no-interactive to be prompted.`,
449
- phases,
450
- };
451
- }
452
-
453
- // Interactive mode: prompt for remaining missing config
454
- // Separate secrets from regular config
446
+ // Bus-mediated interview: any remaining missing config produces
447
+ // `config.required.*` / `secret.required.*` events. A responder
448
+ // (terminal, Claude subagent, `events respond`) answers; the
449
+ // deploy waits indefinitely if none does. Operators see stuck
450
+ // queries via `celilo events list-pending`.
451
+ // Separate secrets from regular config.
455
452
  const secrets = remainingMissing.filter((v) => v.source === 'secret');
456
453
  const regularConfig = remainingMissing.filter((v) => v.source !== 'secret');
457
454
 
@@ -510,6 +507,21 @@ async function deployModuleImpl(
510
507
  }
511
508
  }
512
509
 
510
+ // --stop-after-interview: bail before any infrastructure work.
511
+ // The bus-mediated interview has fired and any responders have
512
+ // answered; the operator can inspect the encrypted store and
513
+ // module config to confirm what landed without spinning up
514
+ // terraform / ansible / actual hooks. Cross-module `ensure`
515
+ // events fire later from hook execution, so they're NOT
516
+ // exercised here — that requires a real run.
517
+ if (options.stopAfterInterview) {
518
+ log.info('--stop-after-interview: exiting before infrastructure phase.');
519
+ return {
520
+ success: true,
521
+ phases: { ...phases, validation: true },
522
+ };
523
+ }
524
+
513
525
  // If validation returned early (missing variables were present), generation
514
526
  // was deferred until after the interview. Run it now that all vars are set.
515
527
  if (!validation.autoGenerated) {
@@ -538,7 +550,7 @@ async function deployModuleImpl(
538
550
  if (manifest.hooks?.validate_config) {
539
551
  const hookDef = manifest.hooks.validate_config;
540
552
  const gauge = new FuelGauge('Validating configuration', {
541
- skipAnimation: options.noInteractive,
553
+ skipAnimation: !process.stdout.isTTY,
542
554
  });
543
555
  gauge.start();
544
556
  const hookLogger = createGaugeLogger(gauge, moduleId, 'validate_config');
@@ -720,7 +732,7 @@ async function deployModuleImpl(
720
732
  installSecretMap,
721
733
  async () => {
722
734
  const gauge = new FuelGauge(`${moduleId}: on_install`, {
723
- skipAnimation: options.noInteractive,
735
+ skipAnimation: !process.stdout.isTTY,
724
736
  });
725
737
  gauge.start();
726
738
  const logger = createGaugeLogger(gauge, moduleId, 'on_install');
@@ -756,7 +768,7 @@ async function deployModuleImpl(
756
768
  const { runModuleHealthCheck } = await import('./health-runner');
757
769
  const healthResult = await runModuleHealthCheck(moduleId, db, {
758
770
  debug: options.debug,
759
- noInteractive: options.noInteractive,
771
+ noInteractive: !process.stdout.isTTY,
760
772
  });
761
773
  if (healthResult.status === 'error') {
762
774
  return { success: false, phases, error: `Health check failed: ${healthResult.error}` };
@@ -800,7 +812,7 @@ async function deployModuleImpl(
800
812
  }
801
813
 
802
814
  const terraformResult = await executeTerraform(generatedPath, phases, terraformEnvVars, {
803
- noInteractive: options.noInteractive,
815
+ noInteractive: !process.stdout.isTTY,
804
816
  });
805
817
 
806
818
  if (!terraformResult.success) {
@@ -917,7 +929,7 @@ async function deployModuleImpl(
917
929
  );
918
930
 
919
931
  const gauge = new FuelGauge(`${capRecord.moduleId}: container_created`, {
920
- skipAnimation: options.noInteractive,
932
+ skipAnimation: !process.stdout.isTTY,
921
933
  });
922
934
  gauge.start();
923
935
  const hookLogger = createGaugeLogger(gauge, capRecord.moduleId, 'container_created');
@@ -1050,7 +1062,7 @@ async function deployModuleImpl(
1050
1062
 
1051
1063
  try {
1052
1064
  const ansibleResult = await executeAnsible(generatedPath, {
1053
- noInteractive: options.noInteractive,
1065
+ noInteractive: !process.stdout.isTTY,
1054
1066
  });
1055
1067
  phases.ansible = ansibleResult.success;
1056
1068
  if (!ansibleResult.success) {
@@ -1139,7 +1151,7 @@ async function deployModuleImpl(
1139
1151
  installSecretMap,
1140
1152
  async () => {
1141
1153
  const gauge = new FuelGauge(`${moduleId}: on_install`, {
1142
- skipAnimation: options.noInteractive,
1154
+ skipAnimation: !process.stdout.isTTY,
1143
1155
  });
1144
1156
  gauge.start();
1145
1157
  const logger = createGaugeLogger(gauge, moduleId, 'on_install');
@@ -1179,7 +1191,7 @@ async function deployModuleImpl(
1179
1191
  const { runModuleHealthCheck } = await import('./health-runner');
1180
1192
  const healthResult = await runModuleHealthCheck(moduleId, db, {
1181
1193
  debug: options.debug,
1182
- noInteractive: options.noInteractive,
1194
+ noInteractive: !process.stdout.isTTY,
1183
1195
  });
1184
1196
 
1185
1197
  if (healthResult.status === 'error') {
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Programmatic bus responder — watches `config.required.*`,
3
+ * `secret.required.*`, and `ensure.required.*` events and replies
4
+ * from a pre-baked values map. Drives the `celilo events respond
5
+ * --values` CLI mode and the `bus-responder` test fixture.
6
+ *
7
+ * The responder runs in-process: it owns its own Bus + DbClient
8
+ * against the celilo data dir, so it can write secrets to the
9
+ * encrypted store out-of-band (per the `secret.required` flow) and
10
+ * the deploy re-reads after acks.
11
+ *
12
+ * Two consumers, two missing-value policies:
13
+ * - tests use `onMissing: 'throw'` so a forgotten value fails
14
+ * loudly instead of hanging.
15
+ * - the CLI uses `onMissing: 'skip'` so the responder lets other
16
+ * responders win the race instead of erroring out.
17
+ */
18
+
19
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
20
+ import type { DbClient } from '../db/client';
21
+ import { generateSecret } from '../secrets/generators';
22
+ import { getOrCreateMasterKey } from '../secrets/master-key';
23
+ import type {
24
+ ConfigRequiredPayload,
25
+ EnsureRequiredPayload,
26
+ SecretRequiredPayload,
27
+ } from './bus-interview';
28
+ import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
29
+
30
+ const NO_SCHEMAS = defineEvents({});
31
+
32
+ export interface ResponderValues {
33
+ /**
34
+ * Config values keyed by `<module>.<key>`. When the deploy emits
35
+ * `config.required.<m>.<k>`, the responder replies with `{ value }`.
36
+ */
37
+ config?: Record<string, unknown>;
38
+ /**
39
+ * Secret values keyed by `<module>.<key>`. When the deploy emits
40
+ * `secret.required.<m>.<k>`, the responder writes the value to
41
+ * the encrypted store and replies with `{ acknowledged: true }`.
42
+ * If the entry is missing AND the payload's `style` is
43
+ * `generated_optional`, the responder auto-generates per the
44
+ * payload's `generate` hint instead of raising / skipping.
45
+ */
46
+ secrets?: Record<string, string>;
47
+ /**
48
+ * Ensure values keyed by `<provider>.<ensureId>`. For each
49
+ * `set_in_object` input in the payload, the responder either puts
50
+ * the value in `reply.values[target]` (config target) or writes
51
+ * the value out-of-band (secret target). `append_to_array` inputs
52
+ * never reach the responder — the deploy applies them deterministic.
53
+ */
54
+ ensures?: Record<
55
+ string,
56
+ {
57
+ configValues?: Record<string, unknown>;
58
+ secretValues?: Record<string, string>;
59
+ }
60
+ >;
61
+ }
62
+
63
+ export interface ProgrammaticResponderOptions {
64
+ /** Path to the bus sqlite db (the deploy and responder share it). */
65
+ busDbPath: string;
66
+ /** Open db client used for out-of-band secret writes. */
67
+ db: DbClient;
68
+ /** Values to reply with. */
69
+ values: ResponderValues;
70
+ /**
71
+ * What to do when an event arrives that has no matching value:
72
+ * 'throw' — fail the responder loudly (tests use this).
73
+ * 'skip' — log and don't reply (let other responders win).
74
+ */
75
+ onMissing: 'throw' | 'skip';
76
+ /**
77
+ * Identifier for the `emittedBy` audit field on replies. Operators
78
+ * see this in `celilo events tail` to correlate which responder
79
+ * answered which query. Defaults to `programmatic`.
80
+ */
81
+ emittedBy?: string;
82
+ }
83
+
84
+ export interface AnsweredEvent {
85
+ /** Full event type (e.g. `config.required.lunacycle.domain`). */
86
+ type: string;
87
+ /** `<module>.<key>` or `<provider>.<ensureId>` lookup key. */
88
+ key: string;
89
+ }
90
+
91
+ export interface MissedEvent extends AnsweredEvent {
92
+ reason: string;
93
+ }
94
+
95
+ export interface ProgrammaticResponderHandle {
96
+ /** Activity timestamp (ms) — last time we saw an event. */
97
+ lastActivityAt(): number;
98
+ /** Total events answered + skipped since start. */
99
+ eventCount(): number;
100
+ /** Snapshot of replied events. */
101
+ answered(): AnsweredEvent[];
102
+ /** Snapshot of skipped events (reason populated). */
103
+ missed(): MissedEvent[];
104
+ /**
105
+ * Full payload snapshots, indexed by event family. Tests use these
106
+ * to assert on what the deploy emitted; the CLI ignores them.
107
+ */
108
+ seenConfigPayloads(): ConfigRequiredPayload[];
109
+ seenSecretPayloads(): SecretRequiredPayload[];
110
+ seenEnsurePayloads(): EnsureRequiredPayload[];
111
+ /** Stop watching. Caller still owns the db client. */
112
+ close(): void;
113
+ }
114
+
115
+ /**
116
+ * Open the bus, register watches on the three interview event
117
+ * families, reply per the values map. Returns a handle the caller
118
+ * uses to drive idle-timeout exit logic; the caller owns the db
119
+ * client lifecycle.
120
+ */
121
+ export function startProgrammaticResponder(
122
+ opts: ProgrammaticResponderOptions,
123
+ ): ProgrammaticResponderHandle {
124
+ const answered: AnsweredEvent[] = [];
125
+ const missed: MissedEvent[] = [];
126
+ const seenConfig: ConfigRequiredPayload[] = [];
127
+ const seenSecret: SecretRequiredPayload[] = [];
128
+ const seenEnsure: EnsureRequiredPayload[] = [];
129
+ let lastActivityAt = Date.now();
130
+
131
+ const me = opts.emittedBy ?? 'programmatic';
132
+ const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
133
+
134
+ const handleMissing = (type: string, key: string, reason: string): boolean => {
135
+ if (opts.onMissing === 'throw') {
136
+ throw new Error(`programmatic-responder: ${reason} (event: ${type}, key: "${key}")`);
137
+ }
138
+ missed.push({ type, key, reason });
139
+ return false;
140
+ };
141
+
142
+ const configWatch = bus.watch('config.required.*.*', async (event) => {
143
+ if (event.replyFor !== null) return;
144
+ lastActivityAt = Date.now();
145
+
146
+ const payload = event.payload as ConfigRequiredPayload;
147
+ if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
148
+ missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
149
+ return;
150
+ }
151
+ seenConfig.push(payload);
152
+
153
+ const lookupKey = `${payload.module}.${payload.key}`;
154
+ const value = opts.values.config?.[lookupKey];
155
+ if (value === undefined) {
156
+ handleMissing(event.type, lookupKey, `no config value for "${lookupKey}"`);
157
+ return;
158
+ }
159
+
160
+ bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
161
+ answered.push({ type: event.type, key: lookupKey });
162
+ });
163
+
164
+ const secretWatch = bus.watch('secret.required.*.*', async (event) => {
165
+ if (event.replyFor !== null) return;
166
+ lastActivityAt = Date.now();
167
+
168
+ const payload = event.payload as SecretRequiredPayload;
169
+ if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
170
+ missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
171
+ return;
172
+ }
173
+ seenSecret.push(payload);
174
+
175
+ const lookupKey = `${payload.module}.${payload.key}`;
176
+ let value = opts.values.secrets?.[lookupKey];
177
+ if (value === undefined) {
178
+ // generated_optional fallback: use the payload's hint instead
179
+ // of asking the operator. Same UX as the terminal-responder's
180
+ // empty-input branch.
181
+ if (payload.style === 'generated_optional' && payload.generate) {
182
+ value = generateSecret(payload.generate);
183
+ } else {
184
+ handleMissing(event.type, lookupKey, `no secret value for "${lookupKey}"`);
185
+ return;
186
+ }
187
+ }
188
+
189
+ const masterKey = await getOrCreateMasterKey();
190
+ await writeModuleSecretKey(payload.module, payload.key, value, opts.db, masterKey);
191
+
192
+ bus.emitRaw(
193
+ `${event.type}.reply`,
194
+ { acknowledged: true },
195
+ { replyFor: event.id, emittedBy: me },
196
+ );
197
+ answered.push({ type: event.type, key: lookupKey });
198
+ });
199
+
200
+ const ensureWatch = bus.watch('ensure.required.*.*', async (event) => {
201
+ if (event.replyFor !== null) return;
202
+ lastActivityAt = Date.now();
203
+
204
+ const payload = event.payload as EnsureRequiredPayload;
205
+ if (!payload || typeof payload.provider !== 'string' || !Array.isArray(payload.inputs)) {
206
+ missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
207
+ return;
208
+ }
209
+ seenEnsure.push(payload);
210
+
211
+ const lookupKey = `${payload.provider}.${payload.ensureId}`;
212
+ const fixture = opts.values.ensures?.[lookupKey];
213
+ if (!fixture) {
214
+ handleMissing(event.type, lookupKey, `no ensure values for "${lookupKey}"`);
215
+ return;
216
+ }
217
+
218
+ const values: Record<string, unknown> = {};
219
+ let acknowledged = false;
220
+ let masterKey: Buffer | null = null;
221
+ let perInputMissing = false;
222
+
223
+ for (const input of payload.inputs) {
224
+ if (input.target.startsWith('config.')) {
225
+ const v = fixture.configValues?.[input.target];
226
+ if (v === undefined) {
227
+ handleMissing(
228
+ event.type,
229
+ lookupKey,
230
+ `ensure "${lookupKey}" missing config value for "${input.target}"`,
231
+ );
232
+ perInputMissing = true;
233
+ break;
234
+ }
235
+ values[input.target] = v;
236
+ continue;
237
+ }
238
+
239
+ // Secret target — read-merge-write the JSON-encoded secret.
240
+ const name = input.target.slice('secret.'.length);
241
+ const v = fixture.secretValues?.[input.target];
242
+ if (v === undefined) {
243
+ handleMissing(
244
+ event.type,
245
+ lookupKey,
246
+ `ensure "${lookupKey}" missing secret value for "${input.target}"`,
247
+ );
248
+ perInputMissing = true;
249
+ break;
250
+ }
251
+ if (!masterKey) masterKey = await getOrCreateMasterKey();
252
+
253
+ let obj: Record<string, unknown> = {};
254
+ const currentRaw = await readModuleSecretKey(payload.provider, name, opts.db, masterKey);
255
+ if (currentRaw) {
256
+ try {
257
+ const parsed = JSON.parse(currentRaw);
258
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
259
+ obj = parsed as Record<string, unknown>;
260
+ }
261
+ } catch {
262
+ /* overwrite */
263
+ }
264
+ }
265
+ obj[input.objectKey] = v;
266
+ await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), opts.db, masterKey);
267
+ acknowledged = true;
268
+ }
269
+
270
+ if (perInputMissing) return;
271
+
272
+ bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, {
273
+ replyFor: event.id,
274
+ emittedBy: me,
275
+ });
276
+ answered.push({ type: event.type, key: lookupKey });
277
+ });
278
+
279
+ return {
280
+ lastActivityAt: () => lastActivityAt,
281
+ eventCount: () => answered.length + missed.length,
282
+ answered: () => [...answered],
283
+ missed: () => [...missed],
284
+ seenConfigPayloads: () => [...seenConfig],
285
+ seenSecretPayloads: () => [...seenSecret],
286
+ seenEnsurePayloads: () => [...seenEnsure],
287
+ close: () => {
288
+ configWatch.close();
289
+ secretWatch.close();
290
+ ensureWatch.close();
291
+ bus.close();
292
+ },
293
+ };
294
+ }