@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.
|
|
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.
|
|
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
|
-
//
|
|
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({
|
|
@@ -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
|
+
}
|