@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.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.
|
|
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> [--
|
|
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> [--
|
|
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, {
|
|
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,
|
|
109
|
-
//
|
|
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
|
-
|
|
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
|
-
--
|
|
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':
|