@celilo/cli 0.3.0 → 0.3.2

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.0",
3
+ "version": "0.3.2",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,10 +22,10 @@ describe('celilo events command handlers', () => {
22
22
  beforeEach(() => {
23
23
  dir = mkdtempSync(join(tmpdir(), 'events-cmd-test-'));
24
24
  dbPath = join(dir, 'events.db');
25
- process.env.CELILO_EVENT_BUS_PATH = dbPath;
25
+ process.env.EVENT_BUS_DB = dbPath;
26
26
  });
27
27
  afterEach(() => {
28
- process.env.CELILO_EVENT_BUS_PATH = undefined;
28
+ process.env.EVENT_BUS_DB = undefined;
29
29
  try {
30
30
  rmSync(dir, { recursive: true, force: true });
31
31
  } catch {
@@ -96,7 +96,8 @@ export function getDbPath(): string {
96
96
  * Get the SQLite event-bus database file path.
97
97
  *
98
98
  * Priority:
99
- * 1. CELILO_EVENT_BUS_PATH environment variable (explicit override)
99
+ * 1. EVENT_BUS_DB environment variable (the bus library's native name —
100
+ * used by the event-bus CLI, defineHandler, and any standalone tool)
100
101
  * 2. <data-dir>/events.db (platform-specific)
101
102
  *
102
103
  * Kept separate from the main celilo.db so the bus library remains
@@ -105,8 +106,8 @@ export function getDbPath(): string {
105
106
  * @returns Absolute path to event-bus database
106
107
  */
107
108
  export function getEventBusPath(): string {
108
- if (process.env.CELILO_EVENT_BUS_PATH) {
109
- return process.env.CELILO_EVENT_BUS_PATH;
109
+ if (process.env.EVENT_BUS_DB) {
110
+ return process.env.EVENT_BUS_DB;
110
111
  }
111
112
  return join(getDataDir(), 'events.db');
112
113
  }
@@ -23,6 +23,25 @@ import {
23
23
  import { parseJsonWithValidation } from '../validation/schemas';
24
24
  import { cleanupTempDir, extractPackage, verifyPackageIntegrity } from './packaging/extract';
25
25
 
26
+ /**
27
+ * Phase-timing helper for `celilo module import`. Set CELILO_IMPORT_DEBUG=1
28
+ * to get a phase-by-phase breakdown to stderr — useful when an import is
29
+ * inexplicably slow (e.g. a Docker bind-mount fsync amplification, a stale
30
+ * lockfile triggering a fresh bun install, etc.).
31
+ *
32
+ * Zero overhead when disabled: the wrapper just calls the inner fn.
33
+ */
34
+ async function timedPhase<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
35
+ if (!process.env.CELILO_IMPORT_DEBUG) return await fn();
36
+ const start = Date.now();
37
+ try {
38
+ return await fn();
39
+ } finally {
40
+ const ms = Date.now() - start;
41
+ process.stderr.write(`[import-timing] ${label.padEnd(36)} ${ms}ms\n`);
42
+ }
43
+ }
44
+
26
45
  /**
27
46
  * Module import options
28
47
  */
@@ -547,7 +566,7 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
547
566
 
548
567
  // Execution: Copy files
549
568
  try {
550
- await copyModuleFiles(actualSourcePath, targetPath);
569
+ await timedPhase('copyModuleFiles', () => copyModuleFiles(actualSourcePath, targetPath));
551
570
  } catch (error) {
552
571
  if (tempDir) await cleanupTempDir(tempDir);
553
572
  return {
@@ -563,7 +582,9 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
563
582
  // If no package.json exists but hook scripts do, auto-generate one with
564
583
  // just the framework dep — smooths migration for existing modules.
565
584
  try {
566
- await installScriptDependencies(targetPath, manifest);
585
+ await timedPhase('installScriptDependencies', () =>
586
+ installScriptDependencies(targetPath, manifest),
587
+ );
567
588
  } catch (error) {
568
589
  // Non-fatal: the module is importable without deps, but hooks
569
590
  // will fail at runtime. Warn and continue.
@@ -574,7 +595,7 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
574
595
 
575
596
  // Execution: Insert to database
576
597
  try {
577
- await insertModuleToDb(manifest, targetPath, db);
598
+ await timedPhase('insertModuleToDb', () => insertModuleToDb(manifest, targetPath, db));
578
599
  } catch (error) {
579
600
  if (tempDir) await cleanupTempDir(tempDir);
580
601
  return {
@@ -586,8 +607,10 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
586
607
 
587
608
  // Execution: Register capabilities if module provides them
588
609
  if (manifest.provides?.capabilities && manifest.provides.capabilities.length > 0) {
589
- const { registerModuleCapabilities } = await import('../capabilities/registration');
590
- const capResult = await registerModuleCapabilities(manifest.id, manifest, db.$client, flags);
610
+ const capResult = await timedPhase('registerModuleCapabilities', async () => {
611
+ const { registerModuleCapabilities } = await import('../capabilities/registration');
612
+ return registerModuleCapabilities(manifest.id, manifest, db.$client, flags);
613
+ });
591
614
 
592
615
  if (!capResult.success) {
593
616
  if (tempDir) await cleanupTempDir(tempDir);
@@ -604,8 +627,10 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
604
627
  // can re-run the import after fixing the bus, and bus.subscribe is
605
628
  // idempotent.
606
629
  try {
607
- const { registerModuleSubscriptions } = await import('../services/module-subscriptions');
608
- registerModuleSubscriptions(manifest, targetPath);
630
+ await timedPhase('registerModuleSubscriptions', async () => {
631
+ const { registerModuleSubscriptions } = await import('../services/module-subscriptions');
632
+ registerModuleSubscriptions(manifest, targetPath);
633
+ });
609
634
  } catch (error) {
610
635
  const msg = error instanceof Error ? error.message : String(error);
611
636
  log.warn(`Failed to register event-bus subscriptions: ${msg}`);
@@ -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
+ }
@@ -17,10 +17,10 @@ describe('celilo lifecycle events', () => {
17
17
  beforeEach(() => {
18
18
  dir = mkdtempSync(join(tmpdir(), 'celilo-events-test-'));
19
19
  dbPath = join(dir, 'events.db');
20
- process.env.CELILO_EVENT_BUS_PATH = dbPath;
20
+ process.env.EVENT_BUS_DB = dbPath;
21
21
  });
22
22
  afterEach(() => {
23
- process.env.CELILO_EVENT_BUS_PATH = undefined;
23
+ process.env.EVENT_BUS_DB = undefined;
24
24
  try {
25
25
  rmSync(dir, { recursive: true, force: true });
26
26
  } catch {
@@ -92,7 +92,7 @@ describe('celilo lifecycle events', () => {
92
92
  });
93
93
 
94
94
  it('does not throw when the bus path is unwritable', () => {
95
- process.env.CELILO_EVENT_BUS_PATH = '/proc/no/such/place/events.db';
95
+ process.env.EVENT_BUS_DB = '/proc/no/such/place/events.db';
96
96
  expect(() => emitDeployStarted({ module: 'x', startedAt: 0 })).not.toThrow();
97
97
  });
98
98
  });
@@ -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({
@@ -23,7 +23,7 @@ describe('renderSystemdUnit', () => {
23
23
  expect(out).toContain(
24
24
  'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
25
25
  );
26
- expect(out).toContain('Environment=CELILO_EVENT_BUS_PATH=/var/lib/celilo/events.db');
26
+ expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
27
27
  expect(out).toContain('Restart=on-failure');
28
28
  expect(out).toContain('WantedBy=default.target');
29
29
  });
@@ -141,9 +141,9 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
141
141
  pollMs: 500,
142
142
  });
143
143
  const written = readFileSync(second.unitPath, 'utf-8');
144
- expect(written).toContain('CELILO_EVENT_BUS_PATH=/db2');
144
+ expect(written).toContain('EVENT_BUS_DB=/db2');
145
145
  expect(written).toContain('--poll-ms 500');
146
- expect(written).not.toContain('CELILO_EVENT_BUS_PATH=/db1');
146
+ expect(written).not.toContain('EVENT_BUS_DB=/db1');
147
147
  });
148
148
 
149
149
  it('uninstall on a missing unit reports not-removed', () => {
@@ -119,7 +119,7 @@ Type=simple
119
119
  ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --concurrency ${input.concurrency}
120
120
  Restart=on-failure
121
121
  RestartSec=10s
122
- Environment=CELILO_EVENT_BUS_PATH=${input.busDbPath}
122
+ Environment=EVENT_BUS_DB=${input.busDbPath}
123
123
  # stdout/stderr are captured by journalctl --user -u celilo-events.service.
124
124
  StandardOutput=journal
125
125
  StandardError=journal
@@ -151,7 +151,7 @@ export function renderLaunchdPlist(input: UnitInputs): string {
151
151
  </array>
152
152
  <key>EnvironmentVariables</key>
153
153
  <dict>
154
- <key>CELILO_EVENT_BUS_PATH</key>
154
+ <key>EVENT_BUS_DB</key>
155
155
  <string>${input.busDbPath}</string>
156
156
  </dict>
157
157
  <key>RunAtLoad</key>
@@ -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
  }
@@ -78,10 +78,10 @@ describe('register / unregister roundtrip', () => {
78
78
  beforeEach(() => {
79
79
  dir = mkdtempSync(join(tmpdir(), 'modsubs-test-'));
80
80
  dbPath = join(dir, 'events.db');
81
- process.env.CELILO_EVENT_BUS_PATH = dbPath;
81
+ process.env.EVENT_BUS_DB = dbPath;
82
82
  });
83
83
  afterEach(() => {
84
- process.env.CELILO_EVENT_BUS_PATH = undefined;
84
+ process.env.EVENT_BUS_DB = undefined;
85
85
  try {
86
86
  rmSync(dir, { recursive: true, force: true });
87
87
  } catch {
@@ -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
+ }