@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 +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/config/paths.ts +4 -3
- package/src/module/import.ts +32 -7
- package/src/services/bus-interview.test.ts +134 -0
- package/src/services/bus-interview.ts +141 -0
- package/src/services/celilo-events.test.ts +3 -3
- package/src/services/config-interview.ts +29 -13
- package/src/services/events-daemon.test.ts +3 -3
- package/src/services/events-daemon.ts +2 -2
- package/src/services/module-deploy.ts +12 -0
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/terminal-responder.ts +139 -0
package/package.json
CHANGED
|
@@ -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.
|
|
25
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
26
26
|
});
|
|
27
27
|
afterEach(() => {
|
|
28
|
-
process.env.
|
|
28
|
+
process.env.EVENT_BUS_DB = undefined;
|
|
29
29
|
try {
|
|
30
30
|
rmSync(dir, { recursive: true, force: true });
|
|
31
31
|
} catch {
|
package/src/config/paths.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
109
|
-
return process.env.
|
|
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
|
}
|
package/src/module/import.ts
CHANGED
|
@@ -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
|
|
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
|
|
590
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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.
|
|
20
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
21
21
|
});
|
|
22
22
|
afterEach(() => {
|
|
23
|
-
process.env.
|
|
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.
|
|
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
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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=
|
|
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('
|
|
144
|
+
expect(written).toContain('EVENT_BUS_DB=/db2');
|
|
145
145
|
expect(written).toContain('--poll-ms 500');
|
|
146
|
-
expect(written).not.toContain('
|
|
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=
|
|
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>
|
|
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.
|
|
81
|
+
process.env.EVENT_BUS_DB = dbPath;
|
|
82
82
|
});
|
|
83
83
|
afterEach(() => {
|
|
84
|
-
process.env.
|
|
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
|
+
}
|