@celilo/cli 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
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.0",
57
+ "@celilo/event-bus": "^0.1.3",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -0,0 +1,134 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { defineEvents, openBus } from '@celilo/event-bus';
6
+ import {
7
+ type ConfigReply,
8
+ type ConfigRequiredPayload,
9
+ EVENT_TYPES,
10
+ busInterview,
11
+ } from './bus-interview';
12
+
13
+ const NO_SCHEMAS = defineEvents({});
14
+
15
+ describe('busInterview', () => {
16
+ let dir: string;
17
+ let dbPath: string;
18
+ let origEnv: string | undefined;
19
+
20
+ beforeEach(() => {
21
+ dir = mkdtempSync(join(tmpdir(), 'bus-interview-test-'));
22
+ dbPath = join(dir, 'events.db');
23
+ origEnv = process.env.EVENT_BUS_DB;
24
+ process.env.EVENT_BUS_DB = dbPath;
25
+ });
26
+ afterEach(() => {
27
+ if (origEnv === undefined) process.env.EVENT_BUS_DB = undefined;
28
+ else process.env.EVENT_BUS_DB = origEnv;
29
+ try {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ });
35
+
36
+ it('emits a query and returns the responder reply', async () => {
37
+ // Stand up a responder in a separate Bus instance against the same
38
+ // DB. The deploy's busInterview opens its own short-lived Bus and
39
+ // queries; the responder's Bus emits the reply. The cross-process
40
+ // poll inside bus.query is what makes this work.
41
+ const responderBus = openBus({ dbPath, events: NO_SCHEMAS });
42
+ const watch = responderBus.watch('config.required.lunacycle.domain', (event) => {
43
+ responderBus.emitRaw(
44
+ `${event.type}.reply`,
45
+ { value: 'lunacycle.net' },
46
+ { replyFor: event.id, emittedBy: 'test-responder' },
47
+ );
48
+ });
49
+
50
+ const payload: ConfigRequiredPayload = {
51
+ module: 'lunacycle',
52
+ key: 'domain',
53
+ type: 'string',
54
+ required: true,
55
+ };
56
+ const reply = await busInterview<ConfigReply>(
57
+ EVENT_TYPES.configRequired('lunacycle', 'domain'),
58
+ payload,
59
+ );
60
+
61
+ expect(reply.value).toBe('lunacycle.net');
62
+
63
+ watch.close();
64
+ responderBus.close();
65
+ });
66
+
67
+ it('honors first-reply-wins when multiple responders compete', async () => {
68
+ const fastResponder = openBus({ dbPath, events: NO_SCHEMAS });
69
+ const slowResponder = openBus({ dbPath, events: NO_SCHEMAS });
70
+
71
+ fastResponder.watch('config.required.foo.bar', (event) => {
72
+ fastResponder.emitRaw(
73
+ `${event.type}.reply`,
74
+ { value: 'fast' },
75
+ { replyFor: event.id, emittedBy: 'fast' },
76
+ );
77
+ });
78
+ slowResponder.watch('config.required.foo.bar', (event) => {
79
+ // Reply after a delay; the fast responder should win.
80
+ setTimeout(() => {
81
+ slowResponder.emitRaw(
82
+ `${event.type}.reply`,
83
+ { value: 'slow' },
84
+ { replyFor: event.id, emittedBy: 'slow' },
85
+ );
86
+ }, 200);
87
+ });
88
+
89
+ const reply = await busInterview<ConfigReply>(EVENT_TYPES.configRequired('foo', 'bar'), {
90
+ module: 'foo',
91
+ key: 'bar',
92
+ type: 'string',
93
+ required: true,
94
+ });
95
+ expect(reply.value).toBe('fast');
96
+
97
+ fastResponder.close();
98
+ slowResponder.close();
99
+ });
100
+
101
+ it('uses the existing Bus instance when one is passed in', async () => {
102
+ const sharedBus = openBus({ dbPath, events: NO_SCHEMAS });
103
+ sharedBus.watch('config.required.shared.x', (event) => {
104
+ sharedBus.emitRaw(
105
+ `${event.type}.reply`,
106
+ { value: 'shared' },
107
+ { replyFor: event.id, emittedBy: 'in-process' },
108
+ );
109
+ });
110
+
111
+ const reply = await busInterview<ConfigReply>(
112
+ EVENT_TYPES.configRequired('shared', 'x'),
113
+ { module: 'shared', key: 'x', type: 'string', required: true },
114
+ sharedBus,
115
+ );
116
+ expect(reply.value).toBe('shared');
117
+
118
+ sharedBus.close();
119
+ });
120
+ });
121
+
122
+ describe('EVENT_TYPES', () => {
123
+ it('builds dotted event names for each interview shape', () => {
124
+ expect(EVENT_TYPES.configRequired('lunacycle', 'domain')).toBe(
125
+ 'config.required.lunacycle.domain',
126
+ );
127
+ expect(EVENT_TYPES.secretRequired('authentik', 'admin_password')).toBe(
128
+ 'secret.required.authentik.admin_password',
129
+ );
130
+ expect(EVENT_TYPES.ensureRequired('namecheap', 'add_domain')).toBe(
131
+ 'ensure.required.namecheap.add_domain',
132
+ );
133
+ });
134
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Bus-mediated deploy interview. Replaces the terminal-only prompt
3
+ * sites in `config-interview.ts` with `<bus-event-emit>` → wait for
4
+ * a responder's reply. Responders include the terminal (when stdin
5
+ * is a TTY), the `cele2e events respond` CLI, the Claude
6
+ * `celilo-config-responder` subagent, etc.
7
+ *
8
+ * No timeouts: the deploy waits indefinitely for a responder. If
9
+ * nothing answers, the operator sees the unanswered query via
10
+ * `celilo events list-pending` and fixes the responder setup.
11
+ *
12
+ * See `infra/design/INTERACTIVE_DEPLOYS_VIA_BUS.md`.
13
+ */
14
+
15
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
16
+ import { getEventBusPath } from '../config/paths';
17
+
18
+ const NO_SCHEMAS = defineEvents({});
19
+
20
+ export const EVENT_TYPES = {
21
+ configRequired: (module: string, key: string) => `config.required.${module}.${key}`,
22
+ secretRequired: (module: string, key: string) => `secret.required.${module}.${key}`,
23
+ ensureRequired: (provider: string, ensureId: string) => `ensure.required.${provider}.${ensureId}`,
24
+ } as const;
25
+
26
+ /**
27
+ * Payload for `config.required.<module>.<key>`. The deploy emits this
28
+ * when a non-secret variable is missing AND has no default to fall
29
+ * back on (defaults are applied at variable-resolution time before
30
+ * the missing-config check fires, so by the time we get here, no
31
+ * default exists).
32
+ */
33
+ export interface ConfigRequiredPayload {
34
+ module: string;
35
+ key: string;
36
+ type: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object';
37
+ required: boolean;
38
+ description?: string;
39
+ pattern?: string;
40
+ options?: Array<{ value: string; label: string; hint?: string }>;
41
+ /**
42
+ * Set on re-emits after a previous reply failed validation. The
43
+ * responder sees the prior error message and presumably does
44
+ * better the next round.
45
+ */
46
+ previousError?: string;
47
+ /** 1-indexed attempt counter. Bounds prevent infinite re-emit loops. */
48
+ attempt?: number;
49
+ }
50
+
51
+ export interface ConfigReply {
52
+ value: unknown;
53
+ }
54
+
55
+ /**
56
+ * Payload for `secret.required.<module>.<key>`. The reply NEVER
57
+ * carries the secret value — the responder calls
58
+ * `celilo module secret set <module> <key> <value>` out-of-band to
59
+ * write the value into the encrypted store, then replies with
60
+ * `{ acknowledged: true }`.
61
+ *
62
+ * Only fires for secrets WITHOUT a `generate:` block in the manifest
63
+ * (auto-generated secrets bypass the interview entirely).
64
+ */
65
+ export interface SecretRequiredPayload {
66
+ module: string;
67
+ key: string;
68
+ type: 'string' | 'integer' | 'number';
69
+ required: boolean;
70
+ description?: string;
71
+ }
72
+
73
+ export interface SecretAck {
74
+ acknowledged: true;
75
+ }
76
+
77
+ /**
78
+ * Payload for `ensure.required.<provider>.<ensureId>`. One event per
79
+ * ensure (with all `inputs[]` in the payload), since the ensure is
80
+ * conceptually a single interview — the inputs relate to each other
81
+ * via the consumer's recipe.
82
+ */
83
+ export interface EnsureRequiredPayload {
84
+ consumer: string;
85
+ provider: string;
86
+ ensureId: string;
87
+ triggerValue: string;
88
+ description?: string;
89
+ inputs: Array<{
90
+ target: string;
91
+ kind: 'append_to_array' | 'set_in_object';
92
+ prompt: string;
93
+ hint?: string;
94
+ type: string;
95
+ }>;
96
+ }
97
+
98
+ export interface EnsureReply {
99
+ values: Record<string, unknown>;
100
+ /**
101
+ * Set when one or more `inputs[]` had `target: 'secret.*'` and the
102
+ * responder set them out-of-band rather than including the value
103
+ * in the reply payload.
104
+ */
105
+ acknowledged?: true;
106
+ }
107
+
108
+ /**
109
+ * Emit a query event on the bus and wait for a responder to reply.
110
+ *
111
+ * No timeout — the deploy hangs until someone answers. The terminal-
112
+ * responder (when running on a TTY) is one of the responders; other
113
+ * responders include `celilo events respond` from another shell, the
114
+ * `celilo-config-responder` Claude subagent, and an autoresponder
115
+ * daemon (when one exists).
116
+ *
117
+ * `ownerBus` is the long-lived Bus instance the caller may already be
118
+ * holding (e.g. for a TerminalResponder). When passed, the function
119
+ * uses it; otherwise it opens + closes a short-lived bus per call.
120
+ */
121
+ export async function busInterview<TReply>(
122
+ type: string,
123
+ payload: object,
124
+ ownerBus?: Bus,
125
+ ): Promise<TReply> {
126
+ const ownsBus = !ownerBus;
127
+ const bus: Bus = ownerBus ?? openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
128
+ try {
129
+ const replies = await bus.query(type as never, payload as never, {
130
+ timeoutMs: 0, // wait forever — race semantics, no timeout
131
+ pollIntervalMs: 250,
132
+ expect: 'first',
133
+ });
134
+ if (replies.length === 0) {
135
+ throw new Error(`bus-interview: ${type} returned no reply`);
136
+ }
137
+ return replies[0].payload as unknown as TReply;
138
+ } finally {
139
+ if (ownsBus) bus.close();
140
+ }
141
+ }
@@ -14,6 +14,12 @@ import { decryptSecret, encryptSecret } from '../secrets/encryption';
14
14
  import { deriveSecret, generateSecret } from '../secrets/generators';
15
15
  import { getOrCreateMasterKey } from '../secrets/master-key';
16
16
  import type { Machine } from '../types/infrastructure';
17
+ import {
18
+ type ConfigReply,
19
+ type ConfigRequiredPayload,
20
+ EVENT_TYPES,
21
+ busInterview,
22
+ } from './bus-interview';
17
23
  import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
18
24
 
19
25
  export interface MissingVariable {
@@ -417,21 +423,31 @@ export async function interviewForMissingConfig(
417
423
  }
418
424
  }
419
425
  } else {
420
- // Prompt for regular config (visible input)
421
- const message = variable.description
422
- ? `${variable.name} - ${variable.description}:`
423
- : `${variable.name}:`;
426
+ // Bus-mediated prompt: emit a query event, await the reply
427
+ // from any responder. The terminal-responder (started by
428
+ // module-deploy when stdin.isTTY) is one racer; the Claude
429
+ // subagent and `celilo events respond` from another shell are
430
+ // others. First reply wins. No timeout — the deploy hangs
431
+ // until someone answers; misconfigured environments are
432
+ // observable via `celilo events list-pending`.
433
+ const payload: ConfigRequiredPayload = {
434
+ module: moduleId,
435
+ key: variable.name,
436
+ type: (variable.type as ConfigRequiredPayload['type']) ?? 'string',
437
+ required: true,
438
+ description: variable.description,
439
+ };
440
+ const reply = await busInterview<ConfigReply>(
441
+ EVENT_TYPES.configRequired(moduleId, variable.name),
442
+ payload,
443
+ );
424
444
 
425
- value = await promptText({
426
- message,
427
- validate: (val) => {
428
- if (!val || val.trim() === '') {
429
- return 'This field is required';
430
- }
431
- },
432
- });
445
+ // The reply.value is typed per payload.type; the terminal-
446
+ // responder coerces strings to numbers/bools/json-as-array etc.
447
+ // For storage, we still need a string + an optional valueJson
448
+ // for non-scalar types. Match what `module config set` does.
449
+ value = typeof reply.value === 'string' ? reply.value : JSON.stringify(reply.value);
433
450
 
434
- // Store config
435
451
  await db
436
452
  .insert(moduleConfigs)
437
453
  .values({
@@ -318,6 +318,17 @@ async function deployModuleImpl(
318
318
  });
319
319
  setActiveDisplay(display);
320
320
 
321
+ // 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;
331
+
321
332
  try {
322
333
  // Check for e2e test containers — live and e2e environments are mutually exclusive.
323
334
  // Skip when using a test database (integration tests use os.tmpdir() paths).
@@ -1228,5 +1239,6 @@ async function deployModuleImpl(
1228
1239
  } finally {
1229
1240
  display.flush();
1230
1241
  setActiveDisplay(null);
1242
+ terminalResponder?.close();
1231
1243
  }
1232
1244
  }
@@ -0,0 +1,139 @@
1
+ /**
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.
5
+ *
6
+ * Just one of several responder shapes — the bus query/reply path is
7
+ * a race, and the terminal is one racer. Other racers (Claude
8
+ * subagent, `celilo events respond` from another shell, autoresponder
9
+ * daemon) compete for the same events; first reply wins.
10
+ *
11
+ * The terminal-responder doesn't try to detect "another responder
12
+ * just won the race while my user was typing." For Stage 1, if the
13
+ * user types after losing, their reply is a stale-reply and the
14
+ * deploy already moved on. The user sees their prompt close and the
15
+ * deploy continues with the winning value (logged by the deploy in
16
+ * its normal output). Race-during-typing detection is a follow-up.
17
+ */
18
+
19
+ import { hostname } from 'node:os';
20
+ import { type Bus, openBus } from '@celilo/event-bus';
21
+ import { defineEvents } from '@celilo/event-bus';
22
+ import { log, promptText } from '../cli/prompts';
23
+ import { getEventBusPath } from '../config/paths';
24
+ import type { ConfigRequiredPayload } from './bus-interview';
25
+
26
+ const NO_SCHEMAS = defineEvents({});
27
+
28
+ /**
29
+ * Identifies this responder in the bus's `emittedBy` audit field.
30
+ * The deploy's audit log shows "config.required.lunacycle.domain →
31
+ * answered by terminal:hostname" so operators can correlate which
32
+ * shell answered which prompt.
33
+ */
34
+ function responderId(): string {
35
+ return `terminal:${hostname()}:${process.pid}`;
36
+ }
37
+
38
+ export interface TerminalResponderHandle {
39
+ /** Stop watching for events. Doesn't cancel any in-flight prompt. */
40
+ close(): void;
41
+ }
42
+
43
+ /**
44
+ * Register a transient subscription on `config.required.*` events.
45
+ * For each event, prompt the operator via clack and reply on the bus.
46
+ *
47
+ * Returns a handle the caller can close() when the deploy completes.
48
+ */
49
+ export function startTerminalResponder(): TerminalResponderHandle {
50
+ const bus: Bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
51
+ const me = responderId();
52
+
53
+ // Track which queries we've already started a prompt for, so a
54
+ // re-fire (e.g. validation re-emit) doesn't double-prompt.
55
+ const handled = new Set<number>();
56
+
57
+ const watch = bus.watch('config.required.**', async (event) => {
58
+ if (handled.has(event.id)) return;
59
+ handled.add(event.id);
60
+
61
+ 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}:`;
66
+
67
+ const value = await promptText({
68
+ message,
69
+ validate: (val) => {
70
+ if (payload.required && (!val || val.trim() === '')) {
71
+ return 'This field is required';
72
+ }
73
+ if (payload.pattern && val) {
74
+ const re = new RegExp(payload.pattern);
75
+ if (!re.test(val)) return `Value must match: ${payload.pattern}`;
76
+ }
77
+ },
78
+ });
79
+
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
+ },
92
+ );
93
+ } 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 });
100
+ }
101
+ });
102
+
103
+ return {
104
+ close() {
105
+ watch.close();
106
+ bus.close();
107
+ },
108
+ };
109
+ }
110
+
111
+ function coerceValue(raw: string, type: ConfigRequiredPayload['type']): unknown {
112
+ switch (type) {
113
+ case 'string':
114
+ return raw;
115
+ case 'integer':
116
+ case 'number': {
117
+ const n = Number(raw);
118
+ if (!Number.isFinite(n)) {
119
+ throw new Error(`Expected ${type}, got "${raw}"`);
120
+ }
121
+ return n;
122
+ }
123
+ case 'boolean':
124
+ if (/^(true|yes|y|1)$/i.test(raw.trim())) return true;
125
+ if (/^(false|no|n|0)$/i.test(raw.trim())) return false;
126
+ throw new Error(`Expected boolean, got "${raw}"`);
127
+ case 'array':
128
+ case 'object':
129
+ try {
130
+ return JSON.parse(raw);
131
+ } catch (err) {
132
+ throw new Error(
133
+ `Expected ${type} as JSON, got "${raw}": ${err instanceof Error ? err.message : err}`,
134
+ );
135
+ }
136
+ default:
137
+ return raw;
138
+ }
139
+ }