@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.9",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
55
  "@celilo/capabilities": "^0.1.10",
56
56
  "@celilo/cli-display": "^0.1.6",
57
- "@celilo/event-bus": "^0.1.3",
57
+ "@celilo/event-bus": "^0.1.4",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -182,6 +182,34 @@ export const COMMANDS: CommandDef[] = [
182
182
  name: 'resume',
183
183
  description: 'Acknowledge halt-on-recovery (alias for repair)',
184
184
  },
185
+ {
186
+ name: 'respond',
187
+ description: 'Answer config/secret/ensure prompts for a deploy running in another shell',
188
+ flags: [
189
+ {
190
+ name: 'values',
191
+ description:
192
+ 'JSON file with config/secrets/ensures map; switches to non-interactive mode',
193
+ takesValue: true,
194
+ valueHint: '_files',
195
+ },
196
+ {
197
+ name: 'idle-timeout',
198
+ description: 'Exit when bus is quiet for this duration (e.g. 30s, 2m)',
199
+ takesValue: true,
200
+ },
201
+ {
202
+ name: 'max-duration',
203
+ description: 'Hard ceiling on responder runtime (e.g. 10m)',
204
+ takesValue: true,
205
+ },
206
+ {
207
+ name: 'emittedBy',
208
+ description: 'Identifier for the emittedBy audit field on replies',
209
+ takesValue: true,
210
+ },
211
+ ],
212
+ },
185
213
  {
186
214
  name: 'install-daemon',
187
215
  description: 'Write a systemd/launchd user unit for the dispatcher',
@@ -386,11 +414,6 @@ export const COMMANDS: CommandDef[] = [
386
414
  description: 'Deploy module to infrastructure',
387
415
  args: [{ name: 'id', description: 'Module ID', completion: 'module_ids' }],
388
416
  flags: [
389
- {
390
- name: 'no-interactive',
391
- description: 'Disable progress animation and stream output directly',
392
- takesValue: false,
393
- },
394
417
  { name: 'debug', description: 'Enable debug mode', takesValue: false },
395
418
  {
396
419
  name: 'preflight',
@@ -403,6 +426,12 @@ export const COMMANDS: CommandDef[] = [
403
426
  'Keep all sub-events visible (no collapse-on-success); useful for debugging slow steps',
404
427
  takesValue: false,
405
428
  },
429
+ {
430
+ name: 'stop-after-interview',
431
+ description:
432
+ 'Exit cleanly after the initial config + secrets interview (no terraform/ansible/hooks). Manual validation of the bus-mediated interview path.',
433
+ takesValue: false,
434
+ },
406
435
  ],
407
436
  },
408
437
  {
@@ -258,6 +258,187 @@ export async function handleEventsRepair(): Promise<CommandResult> {
258
258
  }
259
259
  }
260
260
 
261
+ /**
262
+ * `celilo events respond` — start a responder against the bus and
263
+ * block. Two modes:
264
+ *
265
+ * - **Interactive (default).** Same code path as the in-deploy
266
+ * terminal-responder, but spawned from a separate shell so an
267
+ * operator can answer config / secret / ensure prompts for a
268
+ * deploy running elsewhere. Blocks on SIGINT/SIGTERM. Use case:
269
+ * deploy in shell A; this shell B types the answers.
270
+ *
271
+ * - **Non-interactive (`--values <file>`).** Reads a JSON values
272
+ * map from the given file and replies programmatically. Used by
273
+ * the `celilo-config-responder` AI subagent and any other
274
+ * scripted operator that wants to drive a deploy without typing.
275
+ * Exits when the bus has been quiet for `--idle-timeout` (default
276
+ * 30s) or after `--max-duration` (default 10m), whichever comes
277
+ * first. Outputs a final JSON summary on stdout.
278
+ *
279
+ * The first reply still wins, so multiple responders racing on the
280
+ * same query is fine — whoever answers first wins; the rest log a
281
+ * "stale-reply" event that's audit-only.
282
+ *
283
+ * Values-file shape:
284
+ * ```json
285
+ * {
286
+ * "config": { "module.key": "value", ... },
287
+ * "secrets": { "module.key": "value", ... },
288
+ * "ensures": {
289
+ * "provider.ensureId": {
290
+ * "configValues": { "config.target": "value" },
291
+ * "secretValues": { "secret.target": "value" }
292
+ * }
293
+ * }
294
+ * }
295
+ * ```
296
+ */
297
+ export async function handleEventsRespond(
298
+ _args: string[],
299
+ flags: Record<string, string | boolean>,
300
+ ): Promise<CommandResult> {
301
+ const valuesPath = typeof flags.values === 'string' ? flags.values : undefined;
302
+ if (valuesPath) {
303
+ return runProgrammaticResponder(valuesPath, flags);
304
+ }
305
+
306
+ const { startTerminalResponder } = await import('../../services/terminal-responder');
307
+ const handle = startTerminalResponder();
308
+ console.error(
309
+ `[celilo events respond] terminal responder running (pid ${process.pid}). Ctrl-C to stop.`,
310
+ );
311
+
312
+ let stopping = false;
313
+ const stop = (signal: string) => {
314
+ if (stopping) return;
315
+ stopping = true;
316
+ console.error(`[celilo events respond] received ${signal}, stopping...`);
317
+ handle.close();
318
+ process.exit(0);
319
+ };
320
+ process.on('SIGINT', () => stop('SIGINT'));
321
+ process.on('SIGTERM', () => stop('SIGTERM'));
322
+ await new Promise(() => {}); // hold open
323
+ return { success: true, message: '' }; // unreachable
324
+ }
325
+
326
+ /**
327
+ * Read values JSON, start a programmatic responder, and exit when
328
+ * the bus is quiet for `--idle-timeout` or after `--max-duration`.
329
+ * Returns a CommandResult with the summary so the JSON-result CLI
330
+ * envelope wraps it.
331
+ */
332
+ async function runProgrammaticResponder(
333
+ valuesPath: string,
334
+ flags: Record<string, string | boolean>,
335
+ ): Promise<CommandResult> {
336
+ const { readFileSync } = await import('node:fs');
337
+ const { startProgrammaticResponder } = await import('../../services/programmatic-responder');
338
+ const { getDb } = await import('../../db/client');
339
+ const { getEventBusPath } = await import('../../config/paths');
340
+
341
+ let values: import('../../services/programmatic-responder').ResponderValues;
342
+ try {
343
+ values = JSON.parse(readFileSync(valuesPath, 'utf-8'));
344
+ } catch (err) {
345
+ return {
346
+ success: false,
347
+ error: `Failed to read --values file ${valuesPath}: ${err instanceof Error ? err.message : String(err)}`,
348
+ };
349
+ }
350
+
351
+ const idleTimeoutMs = parseDurationMs(flags['idle-timeout'], 30_000);
352
+ const maxDurationMs = parseDurationMs(flags['max-duration'], 600_000);
353
+
354
+ const db = getDb();
355
+ const startedAt = Date.now();
356
+ const handle = startProgrammaticResponder({
357
+ busDbPath: getEventBusPath(),
358
+ db,
359
+ values,
360
+ onMissing: 'skip',
361
+ emittedBy: typeof flags.emittedBy === 'string' ? flags.emittedBy : 'cli:respond',
362
+ });
363
+
364
+ console.error(
365
+ `[celilo events respond] programmatic responder running (pid ${process.pid}, idle ${idleTimeoutMs}ms, max ${maxDurationMs}ms).`,
366
+ );
367
+
368
+ let stopping = false;
369
+ let stopReason: 'idle' | 'max-duration' | 'signal' = 'idle';
370
+ const buildSummary = (): { success: true; message: string; data: unknown } => {
371
+ if (!stopping) {
372
+ stopping = true;
373
+ handle.close();
374
+ }
375
+ const summary = {
376
+ exitReason: stopReason,
377
+ durationMs: Date.now() - startedAt,
378
+ answered: handle.answered(),
379
+ missed: handle.missed(),
380
+ };
381
+ return { success: true, message: JSON.stringify(summary, null, 2), data: summary };
382
+ };
383
+
384
+ process.on('SIGINT', () => {
385
+ stopReason = 'signal';
386
+ const r = buildSummary();
387
+ console.error(r.message);
388
+ process.exit(0);
389
+ });
390
+ process.on('SIGTERM', () => {
391
+ stopReason = 'signal';
392
+ const r = buildSummary();
393
+ console.error(r.message);
394
+ process.exit(0);
395
+ });
396
+
397
+ // Poll for idle/max-duration. Each tick: if (now - lastActivity) > idle and
398
+ // we've seen at least one event, exit. If now - start > max-duration,
399
+ // exit regardless.
400
+ const pollMs = 500;
401
+ while (true) {
402
+ await new Promise((r) => setTimeout(r, pollMs));
403
+ const now = Date.now();
404
+ if (now - startedAt > maxDurationMs) {
405
+ stopReason = 'max-duration';
406
+ break;
407
+ }
408
+ if (handle.eventCount() > 0 && now - handle.lastActivityAt() > idleTimeoutMs) {
409
+ stopReason = 'idle';
410
+ break;
411
+ }
412
+ }
413
+
414
+ return buildSummary();
415
+ }
416
+
417
+ /**
418
+ * Parse a duration flag value. Accepts plain milliseconds (e.g.
419
+ * `30000`), or suffixed values (`30s`, `5m`, `1h`). Falls back to
420
+ * the default if the flag isn't a string.
421
+ */
422
+ function parseDurationMs(flag: string | boolean | undefined, defaultMs: number): number {
423
+ if (typeof flag !== 'string') return defaultMs;
424
+ const m = flag.match(/^(\d+)(ms|s|m|h)?$/);
425
+ if (!m) return defaultMs;
426
+ const n = Number(m[1]);
427
+ const unit = m[2] ?? 'ms';
428
+ switch (unit) {
429
+ case 'ms':
430
+ return n;
431
+ case 's':
432
+ return n * 1000;
433
+ case 'm':
434
+ return n * 60_000;
435
+ case 'h':
436
+ return n * 3_600_000;
437
+ default:
438
+ return defaultMs;
439
+ }
440
+ }
441
+
261
442
  /**
262
443
  * `celilo events install-daemon` — write a systemd user unit (Linux)
263
444
  * or launchd plist (macOS) that runs the dispatcher under supervision.
@@ -14,7 +14,13 @@ import type { CommandResult } from '../types';
14
14
  * Handle module deploy command
15
15
  *
16
16
  * Usage:
17
- * celilo module deploy <module-id> [--no-interactive] [--debug] [--preflight] [--verbose]
17
+ * celilo module deploy <module-id> [--debug] [--preflight] [--verbose]
18
+ *
19
+ * Note: there is no `--no-interactive` flag. The deploy interview
20
+ * runs through the bus event system; automation answers via a
21
+ * responder (Claude subagent, `celilo events respond`, autoresponder
22
+ * daemon, etc.). When stdin is a TTY the deploy registers a built-in
23
+ * terminal-responder. See INTERACTIVE_DEPLOYS_VIA_BUS.md.
18
24
  *
19
25
  * @param args - Command arguments
20
26
  * @param flags - Command flags
@@ -29,7 +35,7 @@ export async function handleModuleDeploy(
29
35
  if (error) {
30
36
  return {
31
37
  success: false,
32
- error: `${error}\n\nUsage:\n celilo module deploy <module-id> [--no-interactive] [--preflight]`,
38
+ error: `${error}\n\nUsage:\n celilo module deploy <module-id> [--preflight]`,
33
39
  };
34
40
  }
35
41
 
@@ -50,11 +56,11 @@ export async function handleModuleDeploy(
50
56
  return preflight.success ? { success: true, message } : { success: false, error: message };
51
57
  }
52
58
 
53
- const noInteractive = hasFlag(flags, 'no-interactive');
54
59
  const debug = hasFlag(flags, 'debug');
55
60
  const verbose = hasFlag(flags, 'verbose');
61
+ const stopAfterInterview = hasFlag(flags, 'stop-after-interview');
56
62
 
57
- const result = await deployModule(moduleId, db, { noInteractive, debug, verbose });
63
+ const result = await deployModule(moduleId, db, { debug, verbose, stopAfterInterview });
58
64
 
59
65
  if (!result.success) {
60
66
  return {
@@ -105,8 +105,8 @@ export async function handleModuleRemove(
105
105
  const manifestForHook = module.manifestData as { hooks?: { on_uninstall?: unknown } } | undefined;
106
106
  if (manifestForHook?.hooks?.on_uninstall) {
107
107
  // Match build-stream's TTY detection: in non-TTY contexts (tests,
108
- // pipes, --no-interactive flow) skipAnimation prevents the gauge's
109
- // setInterval/raw-stdin handlers from blocking process exit.
108
+ // pipes, CI) skipAnimation prevents the gauge's setInterval and
109
+ // raw-stdin handlers from blocking process exit.
110
110
  const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
111
111
  const gauge = new FuelGauge(`${moduleId}: on_uninstall`, {
112
112
  skipAnimation: !isInteractive,
@@ -166,7 +166,11 @@ function buildOps(): OrchestratorOps {
166
166
  upgrade: async (_id) => ({ ok: true }),
167
167
  deploy: async (id) => {
168
168
  const db = getDb();
169
- const result = await deployModule(id, db, { noInteractive: true });
169
+ // The deploy interview runs through the bus; if config is
170
+ // missing the deploy hangs waiting for a responder. system-update
171
+ // runs against pre-staged modules — config gaps surface via
172
+ // `celilo events list-pending` and are an operator concern.
173
+ const result = await deployModule(id, db, {});
170
174
  return result.success ? { ok: true } : { ok: false, error: result.error };
171
175
  },
172
176
  health: async (id) => {
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  handleEventsListPending,
20
20
  handleEventsListSubscribers,
21
21
  handleEventsRepair,
22
+ handleEventsRespond,
22
23
  handleEventsRun,
23
24
  handleEventsShowDaemon,
24
25
  handleEventsStatus,
@@ -246,6 +247,7 @@ Subcommands:
246
247
  fail <event_id> --error MSG Mark a running delivery failed
247
248
  repair Crash-recovery sweep without starting the dispatcher
248
249
  resume Alias for repair (acknowledges halt-on-recovery)
250
+ respond Run the terminal responder; answer deploy prompts from another shell
249
251
  install-daemon Write a systemd/launchd user unit for the dispatcher
250
252
  uninstall-daemon Remove the installed supervisor unit
251
253
  show-daemon Print the currently installed unit file
@@ -338,7 +340,9 @@ Subcommands:
338
340
  deploy <module-id> Deploy module to infrastructure (auto-generates/builds if needed)
339
341
  Options:
340
342
  --debug Run hooks with visible browser (Playwright)
341
- --no-interactive Fail instead of prompting for missing config
343
+ --preflight Run pre-flight validation only (no deployment)
344
+ --verbose Keep all sub-events visible (no collapse-on-success)
345
+ --stop-after-interview Exit after config+secrets interview (no infra/hooks)
342
346
 
343
347
  health [module-id] Run health checks (transitions to VERIFIED on success)
344
348
  Options:
@@ -1007,6 +1011,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1007
1011
  case 'repair':
1008
1012
  case 'resume':
1009
1013
  return handleEventsRepair();
1014
+ case 'respond':
1015
+ return handleEventsRespond(parsed.args, parsed.flags);
1010
1016
  case 'install-daemon':
1011
1017
  return handleEventsInstallDaemon(parsed.args, parsed.flags);
1012
1018
  case 'uninstall-daemon':