@celilo/cli 0.3.4 → 0.3.10
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 +3 -3
- package/src/cli/command-registry.ts +34 -5
- package/src/cli/commands/events.ts +181 -0
- package/src/cli/commands/module-deploy.ts +10 -4
- package/src/cli/commands/module-remove.ts +2 -2
- package/src/cli/commands/system-update.ts +5 -1
- package/src/cli/index.ts +7 -1
- package/src/services/bus-ensure-flow.test.ts +380 -0
- package/src/services/bus-interview.test.ts +73 -5
- package/src/services/bus-interview.ts +24 -4
- package/src/services/bus-secret-flow.test.ts +327 -0
- package/src/services/config-interview.ts +278 -255
- package/src/services/ensure-interview.test.ts +4 -6
- package/src/services/module-deploy.ts +62 -45
- package/src/services/programmatic-responder.ts +294 -0
- package/src/services/terminal-responder.ts +266 -39
- package/src/test-utils/bus-responder.ts +126 -0
- package/src/test-utils/index.ts +7 -0
- package/src/test-utils/integration.ts +12 -0
|
@@ -162,13 +162,11 @@ describe('interviewForEnsureInputs', () => {
|
|
|
162
162
|
// is the user's consent for the cross-module config its hooks imply.
|
|
163
163
|
// See config-interview.ts and CADDY_HOSTNAME_LIST.md, Decision 6.
|
|
164
164
|
|
|
165
|
-
test('
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// directly. promptOverride is still required for set_in_object inputs.
|
|
165
|
+
test('promptOverride applies inputs directly without bus or terminal', async () => {
|
|
166
|
+
// promptOverride is the test escape hatch — bypasses the bus path
|
|
167
|
+
// so unit tests don't need to set up a responder. See bus-ensure-
|
|
168
|
+
// flow.test.ts for the full bus-mediated flow.
|
|
170
169
|
const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, {
|
|
171
|
-
noInteractive: true,
|
|
172
170
|
promptOverride: async () => 'pw',
|
|
173
171
|
});
|
|
174
172
|
expect(result.success).toBe(true);
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
interviewForEnsureInputs,
|
|
27
27
|
interviewForMissingConfig,
|
|
28
28
|
interviewForMissingSecrets,
|
|
29
|
-
renderEnsureRecipe,
|
|
30
29
|
} from './config-interview';
|
|
31
30
|
import { getContainerService } from './container-service';
|
|
32
31
|
import { executeAnsible } from './deploy-ansible';
|
|
@@ -89,7 +88,6 @@ async function updateMachineAssignment(
|
|
|
89
88
|
}
|
|
90
89
|
|
|
91
90
|
export interface DeployOptions {
|
|
92
|
-
noInteractive?: boolean;
|
|
93
91
|
debug?: boolean;
|
|
94
92
|
/**
|
|
95
93
|
* Keep all sub-events visible during the deploy. With this off (the
|
|
@@ -99,6 +97,17 @@ export interface DeployOptions {
|
|
|
99
97
|
* useful for debugging slow or hanging steps.
|
|
100
98
|
*/
|
|
101
99
|
verbose?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Exit cleanly after the initial config + secrets interview phase
|
|
102
|
+
* — before any infrastructure code generation, terraform, ansible,
|
|
103
|
+
* or hook execution. Manual validation tool: lets an operator
|
|
104
|
+
* exercise the bus-mediated interview (config.required / secret.required
|
|
105
|
+
* events fire, responders answer, values land in the encrypted store)
|
|
106
|
+
* without actually deploying anything to a machine. The cross-module
|
|
107
|
+
* `ensure` interview (which fires from hooks) is NOT exercised in
|
|
108
|
+
* this mode — that requires a real hook run.
|
|
109
|
+
*/
|
|
110
|
+
stopAfterInterview?: boolean;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
/**
|
|
@@ -189,12 +198,6 @@ async function invokeHookWithEnsureRetry(
|
|
|
189
198
|
};
|
|
190
199
|
}
|
|
191
200
|
|
|
192
|
-
if (deployOptions.noInteractive) {
|
|
193
|
-
attempt.gauge.stop(false);
|
|
194
|
-
log.error(renderEnsureRecipe(m.providerModuleId, ensure, m.value));
|
|
195
|
-
return result;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
201
|
// Step the gauge out of the way so prompts render on a clean line.
|
|
199
202
|
attempt.gauge.stopSilent();
|
|
200
203
|
|
|
@@ -302,14 +305,15 @@ async function deployModuleImpl(
|
|
|
302
305
|
): Promise<DeployResult> {
|
|
303
306
|
const phases: DeployResult['phases'] = {};
|
|
304
307
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
// disables cursor magic — every
|
|
308
|
-
// step completes (no collapse-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// cele2e expects regardless
|
|
312
|
-
const
|
|
308
|
+
// When stdout isn't a TTY (cele2e subprocess, CI), emit structured
|
|
309
|
+
// [progress:*] markers that cele2e parses. --verbose forces render
|
|
310
|
+
// mode but with isTTY=false, which disables cursor magic — every
|
|
311
|
+
// sub-event stays visible after each step completes (no collapse-
|
|
312
|
+
// on-success). On a TTY without --verbose, mode is 'auto' and the
|
|
313
|
+
// ProgressDisplay picks animated gauges. Non-TTY wins if both are
|
|
314
|
+
// set (protocol output is what cele2e expects regardless).
|
|
315
|
+
const nonTTY = !process.stdout.isTTY;
|
|
316
|
+
const displayMode = nonTTY ? 'protocol' : options.verbose ? 'render' : 'auto';
|
|
313
317
|
const display = new ProgressDisplay({
|
|
314
318
|
mode: displayMode,
|
|
315
319
|
out: options.verbose
|
|
@@ -319,15 +323,14 @@ async function deployModuleImpl(
|
|
|
319
323
|
setActiveDisplay(display);
|
|
320
324
|
|
|
321
325
|
// Terminal-responder: when running on a TTY, this subscribes to
|
|
322
|
-
// `config.required.*`
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
const terminalResponder =
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
: null;
|
|
326
|
+
// `config.required.*` / `secret.required.*` / `ensure.required.*`
|
|
327
|
+
// events and prompts via clack. Other responder shapes (Claude
|
|
328
|
+
// subagent, `celilo events respond` from another shell, autoresponder
|
|
329
|
+
// daemon) compete on the bus; first reply wins. See
|
|
330
|
+
// infra/design/INTERACTIVE_DEPLOYS_VIA_BUS.md.
|
|
331
|
+
const terminalResponder = process.stdin.isTTY
|
|
332
|
+
? (await import('./terminal-responder')).startTerminalResponder()
|
|
333
|
+
: null;
|
|
331
334
|
|
|
332
335
|
try {
|
|
333
336
|
// Check for e2e test containers — live and e2e environments are mutually exclusive.
|
|
@@ -440,18 +443,12 @@ async function deployModuleImpl(
|
|
|
440
443
|
}
|
|
441
444
|
}
|
|
442
445
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
phases,
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Interactive mode: prompt for remaining missing config
|
|
454
|
-
// Separate secrets from regular config
|
|
446
|
+
// Bus-mediated interview: any remaining missing config produces
|
|
447
|
+
// `config.required.*` / `secret.required.*` events. A responder
|
|
448
|
+
// (terminal, Claude subagent, `events respond`) answers; the
|
|
449
|
+
// deploy waits indefinitely if none does. Operators see stuck
|
|
450
|
+
// queries via `celilo events list-pending`.
|
|
451
|
+
// Separate secrets from regular config.
|
|
455
452
|
const secrets = remainingMissing.filter((v) => v.source === 'secret');
|
|
456
453
|
const regularConfig = remainingMissing.filter((v) => v.source !== 'secret');
|
|
457
454
|
|
|
@@ -510,6 +507,21 @@ async function deployModuleImpl(
|
|
|
510
507
|
}
|
|
511
508
|
}
|
|
512
509
|
|
|
510
|
+
// --stop-after-interview: bail before any infrastructure work.
|
|
511
|
+
// The bus-mediated interview has fired and any responders have
|
|
512
|
+
// answered; the operator can inspect the encrypted store and
|
|
513
|
+
// module config to confirm what landed without spinning up
|
|
514
|
+
// terraform / ansible / actual hooks. Cross-module `ensure`
|
|
515
|
+
// events fire later from hook execution, so they're NOT
|
|
516
|
+
// exercised here — that requires a real run.
|
|
517
|
+
if (options.stopAfterInterview) {
|
|
518
|
+
log.info('--stop-after-interview: exiting before infrastructure phase.');
|
|
519
|
+
return {
|
|
520
|
+
success: true,
|
|
521
|
+
phases: { ...phases, validation: true },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
513
525
|
// If validation returned early (missing variables were present), generation
|
|
514
526
|
// was deferred until after the interview. Run it now that all vars are set.
|
|
515
527
|
if (!validation.autoGenerated) {
|
|
@@ -538,7 +550,7 @@ async function deployModuleImpl(
|
|
|
538
550
|
if (manifest.hooks?.validate_config) {
|
|
539
551
|
const hookDef = manifest.hooks.validate_config;
|
|
540
552
|
const gauge = new FuelGauge('Validating configuration', {
|
|
541
|
-
skipAnimation:
|
|
553
|
+
skipAnimation: !process.stdout.isTTY,
|
|
542
554
|
});
|
|
543
555
|
gauge.start();
|
|
544
556
|
const hookLogger = createGaugeLogger(gauge, moduleId, 'validate_config');
|
|
@@ -720,7 +732,7 @@ async function deployModuleImpl(
|
|
|
720
732
|
installSecretMap,
|
|
721
733
|
async () => {
|
|
722
734
|
const gauge = new FuelGauge(`${moduleId}: on_install`, {
|
|
723
|
-
skipAnimation:
|
|
735
|
+
skipAnimation: !process.stdout.isTTY,
|
|
724
736
|
});
|
|
725
737
|
gauge.start();
|
|
726
738
|
const logger = createGaugeLogger(gauge, moduleId, 'on_install');
|
|
@@ -756,7 +768,7 @@ async function deployModuleImpl(
|
|
|
756
768
|
const { runModuleHealthCheck } = await import('./health-runner');
|
|
757
769
|
const healthResult = await runModuleHealthCheck(moduleId, db, {
|
|
758
770
|
debug: options.debug,
|
|
759
|
-
noInteractive:
|
|
771
|
+
noInteractive: !process.stdout.isTTY,
|
|
760
772
|
});
|
|
761
773
|
if (healthResult.status === 'error') {
|
|
762
774
|
return { success: false, phases, error: `Health check failed: ${healthResult.error}` };
|
|
@@ -770,6 +782,11 @@ async function deployModuleImpl(
|
|
|
770
782
|
}
|
|
771
783
|
}
|
|
772
784
|
|
|
785
|
+
// Mirror the infrastructure-path success message at the end of
|
|
786
|
+
// a successful deploy. Without this, config-only deploys end
|
|
787
|
+
// abruptly with whatever the last hook line was — operator sees
|
|
788
|
+
// no clear "this finished cleanly" signal.
|
|
789
|
+
log.success(`Module '${moduleId}' deployed successfully`);
|
|
773
790
|
return {
|
|
774
791
|
success: true,
|
|
775
792
|
phases: {
|
|
@@ -800,7 +817,7 @@ async function deployModuleImpl(
|
|
|
800
817
|
}
|
|
801
818
|
|
|
802
819
|
const terraformResult = await executeTerraform(generatedPath, phases, terraformEnvVars, {
|
|
803
|
-
noInteractive:
|
|
820
|
+
noInteractive: !process.stdout.isTTY,
|
|
804
821
|
});
|
|
805
822
|
|
|
806
823
|
if (!terraformResult.success) {
|
|
@@ -917,7 +934,7 @@ async function deployModuleImpl(
|
|
|
917
934
|
);
|
|
918
935
|
|
|
919
936
|
const gauge = new FuelGauge(`${capRecord.moduleId}: container_created`, {
|
|
920
|
-
skipAnimation:
|
|
937
|
+
skipAnimation: !process.stdout.isTTY,
|
|
921
938
|
});
|
|
922
939
|
gauge.start();
|
|
923
940
|
const hookLogger = createGaugeLogger(gauge, capRecord.moduleId, 'container_created');
|
|
@@ -1050,7 +1067,7 @@ async function deployModuleImpl(
|
|
|
1050
1067
|
|
|
1051
1068
|
try {
|
|
1052
1069
|
const ansibleResult = await executeAnsible(generatedPath, {
|
|
1053
|
-
noInteractive:
|
|
1070
|
+
noInteractive: !process.stdout.isTTY,
|
|
1054
1071
|
});
|
|
1055
1072
|
phases.ansible = ansibleResult.success;
|
|
1056
1073
|
if (!ansibleResult.success) {
|
|
@@ -1139,7 +1156,7 @@ async function deployModuleImpl(
|
|
|
1139
1156
|
installSecretMap,
|
|
1140
1157
|
async () => {
|
|
1141
1158
|
const gauge = new FuelGauge(`${moduleId}: on_install`, {
|
|
1142
|
-
skipAnimation:
|
|
1159
|
+
skipAnimation: !process.stdout.isTTY,
|
|
1143
1160
|
});
|
|
1144
1161
|
gauge.start();
|
|
1145
1162
|
const logger = createGaugeLogger(gauge, moduleId, 'on_install');
|
|
@@ -1179,7 +1196,7 @@ async function deployModuleImpl(
|
|
|
1179
1196
|
const { runModuleHealthCheck } = await import('./health-runner');
|
|
1180
1197
|
const healthResult = await runModuleHealthCheck(moduleId, db, {
|
|
1181
1198
|
debug: options.debug,
|
|
1182
|
-
noInteractive:
|
|
1199
|
+
noInteractive: !process.stdout.isTTY,
|
|
1183
1200
|
});
|
|
1184
1201
|
|
|
1185
1202
|
if (healthResult.status === 'error') {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic bus responder — watches `config.required.*`,
|
|
3
|
+
* `secret.required.*`, and `ensure.required.*` events and replies
|
|
4
|
+
* from a pre-baked values map. Drives the `celilo events respond
|
|
5
|
+
* --values` CLI mode and the `bus-responder` test fixture.
|
|
6
|
+
*
|
|
7
|
+
* The responder runs in-process: it owns its own Bus + DbClient
|
|
8
|
+
* against the celilo data dir, so it can write secrets to the
|
|
9
|
+
* encrypted store out-of-band (per the `secret.required` flow) and
|
|
10
|
+
* the deploy re-reads after acks.
|
|
11
|
+
*
|
|
12
|
+
* Two consumers, two missing-value policies:
|
|
13
|
+
* - tests use `onMissing: 'throw'` so a forgotten value fails
|
|
14
|
+
* loudly instead of hanging.
|
|
15
|
+
* - the CLI uses `onMissing: 'skip'` so the responder lets other
|
|
16
|
+
* responders win the race instead of erroring out.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
20
|
+
import type { DbClient } from '../db/client';
|
|
21
|
+
import { generateSecret } from '../secrets/generators';
|
|
22
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
23
|
+
import type {
|
|
24
|
+
ConfigRequiredPayload,
|
|
25
|
+
EnsureRequiredPayload,
|
|
26
|
+
SecretRequiredPayload,
|
|
27
|
+
} from './bus-interview';
|
|
28
|
+
import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
|
|
29
|
+
|
|
30
|
+
const NO_SCHEMAS = defineEvents({});
|
|
31
|
+
|
|
32
|
+
export interface ResponderValues {
|
|
33
|
+
/**
|
|
34
|
+
* Config values keyed by `<module>.<key>`. When the deploy emits
|
|
35
|
+
* `config.required.<m>.<k>`, the responder replies with `{ value }`.
|
|
36
|
+
*/
|
|
37
|
+
config?: Record<string, unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Secret values keyed by `<module>.<key>`. When the deploy emits
|
|
40
|
+
* `secret.required.<m>.<k>`, the responder writes the value to
|
|
41
|
+
* the encrypted store and replies with `{ acknowledged: true }`.
|
|
42
|
+
* If the entry is missing AND the payload's `style` is
|
|
43
|
+
* `generated_optional`, the responder auto-generates per the
|
|
44
|
+
* payload's `generate` hint instead of raising / skipping.
|
|
45
|
+
*/
|
|
46
|
+
secrets?: Record<string, string>;
|
|
47
|
+
/**
|
|
48
|
+
* Ensure values keyed by `<provider>.<ensureId>`. For each
|
|
49
|
+
* `set_in_object` input in the payload, the responder either puts
|
|
50
|
+
* the value in `reply.values[target]` (config target) or writes
|
|
51
|
+
* the value out-of-band (secret target). `append_to_array` inputs
|
|
52
|
+
* never reach the responder — the deploy applies them deterministic.
|
|
53
|
+
*/
|
|
54
|
+
ensures?: Record<
|
|
55
|
+
string,
|
|
56
|
+
{
|
|
57
|
+
configValues?: Record<string, unknown>;
|
|
58
|
+
secretValues?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ProgrammaticResponderOptions {
|
|
64
|
+
/** Path to the bus sqlite db (the deploy and responder share it). */
|
|
65
|
+
busDbPath: string;
|
|
66
|
+
/** Open db client used for out-of-band secret writes. */
|
|
67
|
+
db: DbClient;
|
|
68
|
+
/** Values to reply with. */
|
|
69
|
+
values: ResponderValues;
|
|
70
|
+
/**
|
|
71
|
+
* What to do when an event arrives that has no matching value:
|
|
72
|
+
* 'throw' — fail the responder loudly (tests use this).
|
|
73
|
+
* 'skip' — log and don't reply (let other responders win).
|
|
74
|
+
*/
|
|
75
|
+
onMissing: 'throw' | 'skip';
|
|
76
|
+
/**
|
|
77
|
+
* Identifier for the `emittedBy` audit field on replies. Operators
|
|
78
|
+
* see this in `celilo events tail` to correlate which responder
|
|
79
|
+
* answered which query. Defaults to `programmatic`.
|
|
80
|
+
*/
|
|
81
|
+
emittedBy?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface AnsweredEvent {
|
|
85
|
+
/** Full event type (e.g. `config.required.lunacycle.domain`). */
|
|
86
|
+
type: string;
|
|
87
|
+
/** `<module>.<key>` or `<provider>.<ensureId>` lookup key. */
|
|
88
|
+
key: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MissedEvent extends AnsweredEvent {
|
|
92
|
+
reason: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ProgrammaticResponderHandle {
|
|
96
|
+
/** Activity timestamp (ms) — last time we saw an event. */
|
|
97
|
+
lastActivityAt(): number;
|
|
98
|
+
/** Total events answered + skipped since start. */
|
|
99
|
+
eventCount(): number;
|
|
100
|
+
/** Snapshot of replied events. */
|
|
101
|
+
answered(): AnsweredEvent[];
|
|
102
|
+
/** Snapshot of skipped events (reason populated). */
|
|
103
|
+
missed(): MissedEvent[];
|
|
104
|
+
/**
|
|
105
|
+
* Full payload snapshots, indexed by event family. Tests use these
|
|
106
|
+
* to assert on what the deploy emitted; the CLI ignores them.
|
|
107
|
+
*/
|
|
108
|
+
seenConfigPayloads(): ConfigRequiredPayload[];
|
|
109
|
+
seenSecretPayloads(): SecretRequiredPayload[];
|
|
110
|
+
seenEnsurePayloads(): EnsureRequiredPayload[];
|
|
111
|
+
/** Stop watching. Caller still owns the db client. */
|
|
112
|
+
close(): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Open the bus, register watches on the three interview event
|
|
117
|
+
* families, reply per the values map. Returns a handle the caller
|
|
118
|
+
* uses to drive idle-timeout exit logic; the caller owns the db
|
|
119
|
+
* client lifecycle.
|
|
120
|
+
*/
|
|
121
|
+
export function startProgrammaticResponder(
|
|
122
|
+
opts: ProgrammaticResponderOptions,
|
|
123
|
+
): ProgrammaticResponderHandle {
|
|
124
|
+
const answered: AnsweredEvent[] = [];
|
|
125
|
+
const missed: MissedEvent[] = [];
|
|
126
|
+
const seenConfig: ConfigRequiredPayload[] = [];
|
|
127
|
+
const seenSecret: SecretRequiredPayload[] = [];
|
|
128
|
+
const seenEnsure: EnsureRequiredPayload[] = [];
|
|
129
|
+
let lastActivityAt = Date.now();
|
|
130
|
+
|
|
131
|
+
const me = opts.emittedBy ?? 'programmatic';
|
|
132
|
+
const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
|
|
133
|
+
|
|
134
|
+
const handleMissing = (type: string, key: string, reason: string): boolean => {
|
|
135
|
+
if (opts.onMissing === 'throw') {
|
|
136
|
+
throw new Error(`programmatic-responder: ${reason} (event: ${type}, key: "${key}")`);
|
|
137
|
+
}
|
|
138
|
+
missed.push({ type, key, reason });
|
|
139
|
+
return false;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const configWatch = bus.watch('config.required.*.*', async (event) => {
|
|
143
|
+
if (event.replyFor !== null) return;
|
|
144
|
+
lastActivityAt = Date.now();
|
|
145
|
+
|
|
146
|
+
const payload = event.payload as ConfigRequiredPayload;
|
|
147
|
+
if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
|
|
148
|
+
missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
seenConfig.push(payload);
|
|
152
|
+
|
|
153
|
+
const lookupKey = `${payload.module}.${payload.key}`;
|
|
154
|
+
const value = opts.values.config?.[lookupKey];
|
|
155
|
+
if (value === undefined) {
|
|
156
|
+
handleMissing(event.type, lookupKey, `no config value for "${lookupKey}"`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
|
|
161
|
+
answered.push({ type: event.type, key: lookupKey });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const secretWatch = bus.watch('secret.required.*.*', async (event) => {
|
|
165
|
+
if (event.replyFor !== null) return;
|
|
166
|
+
lastActivityAt = Date.now();
|
|
167
|
+
|
|
168
|
+
const payload = event.payload as SecretRequiredPayload;
|
|
169
|
+
if (!payload || typeof payload.module !== 'string' || typeof payload.key !== 'string') {
|
|
170
|
+
missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
seenSecret.push(payload);
|
|
174
|
+
|
|
175
|
+
const lookupKey = `${payload.module}.${payload.key}`;
|
|
176
|
+
let value = opts.values.secrets?.[lookupKey];
|
|
177
|
+
if (value === undefined) {
|
|
178
|
+
// generated_optional fallback: use the payload's hint instead
|
|
179
|
+
// of asking the operator. Same UX as the terminal-responder's
|
|
180
|
+
// empty-input branch.
|
|
181
|
+
if (payload.style === 'generated_optional' && payload.generate) {
|
|
182
|
+
value = generateSecret(payload.generate);
|
|
183
|
+
} else {
|
|
184
|
+
handleMissing(event.type, lookupKey, `no secret value for "${lookupKey}"`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const masterKey = await getOrCreateMasterKey();
|
|
190
|
+
await writeModuleSecretKey(payload.module, payload.key, value, opts.db, masterKey);
|
|
191
|
+
|
|
192
|
+
bus.emitRaw(
|
|
193
|
+
`${event.type}.reply`,
|
|
194
|
+
{ acknowledged: true },
|
|
195
|
+
{ replyFor: event.id, emittedBy: me },
|
|
196
|
+
);
|
|
197
|
+
answered.push({ type: event.type, key: lookupKey });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const ensureWatch = bus.watch('ensure.required.*.*', async (event) => {
|
|
201
|
+
if (event.replyFor !== null) return;
|
|
202
|
+
lastActivityAt = Date.now();
|
|
203
|
+
|
|
204
|
+
const payload = event.payload as EnsureRequiredPayload;
|
|
205
|
+
if (!payload || typeof payload.provider !== 'string' || !Array.isArray(payload.inputs)) {
|
|
206
|
+
missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
seenEnsure.push(payload);
|
|
210
|
+
|
|
211
|
+
const lookupKey = `${payload.provider}.${payload.ensureId}`;
|
|
212
|
+
const fixture = opts.values.ensures?.[lookupKey];
|
|
213
|
+
if (!fixture) {
|
|
214
|
+
handleMissing(event.type, lookupKey, `no ensure values for "${lookupKey}"`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const values: Record<string, unknown> = {};
|
|
219
|
+
let acknowledged = false;
|
|
220
|
+
let masterKey: Buffer | null = null;
|
|
221
|
+
let perInputMissing = false;
|
|
222
|
+
|
|
223
|
+
for (const input of payload.inputs) {
|
|
224
|
+
if (input.target.startsWith('config.')) {
|
|
225
|
+
const v = fixture.configValues?.[input.target];
|
|
226
|
+
if (v === undefined) {
|
|
227
|
+
handleMissing(
|
|
228
|
+
event.type,
|
|
229
|
+
lookupKey,
|
|
230
|
+
`ensure "${lookupKey}" missing config value for "${input.target}"`,
|
|
231
|
+
);
|
|
232
|
+
perInputMissing = true;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
values[input.target] = v;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Secret target — read-merge-write the JSON-encoded secret.
|
|
240
|
+
const name = input.target.slice('secret.'.length);
|
|
241
|
+
const v = fixture.secretValues?.[input.target];
|
|
242
|
+
if (v === undefined) {
|
|
243
|
+
handleMissing(
|
|
244
|
+
event.type,
|
|
245
|
+
lookupKey,
|
|
246
|
+
`ensure "${lookupKey}" missing secret value for "${input.target}"`,
|
|
247
|
+
);
|
|
248
|
+
perInputMissing = true;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (!masterKey) masterKey = await getOrCreateMasterKey();
|
|
252
|
+
|
|
253
|
+
let obj: Record<string, unknown> = {};
|
|
254
|
+
const currentRaw = await readModuleSecretKey(payload.provider, name, opts.db, masterKey);
|
|
255
|
+
if (currentRaw) {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(currentRaw);
|
|
258
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
259
|
+
obj = parsed as Record<string, unknown>;
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
/* overwrite */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
obj[input.objectKey] = v;
|
|
266
|
+
await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), opts.db, masterKey);
|
|
267
|
+
acknowledged = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (perInputMissing) return;
|
|
271
|
+
|
|
272
|
+
bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, {
|
|
273
|
+
replyFor: event.id,
|
|
274
|
+
emittedBy: me,
|
|
275
|
+
});
|
|
276
|
+
answered.push({ type: event.type, key: lookupKey });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
lastActivityAt: () => lastActivityAt,
|
|
281
|
+
eventCount: () => answered.length + missed.length,
|
|
282
|
+
answered: () => [...answered],
|
|
283
|
+
missed: () => [...missed],
|
|
284
|
+
seenConfigPayloads: () => [...seenConfig],
|
|
285
|
+
seenSecretPayloads: () => [...seenSecret],
|
|
286
|
+
seenEnsurePayloads: () => [...seenEnsure],
|
|
287
|
+
close: () => {
|
|
288
|
+
configWatch.close();
|
|
289
|
+
secretWatch.close();
|
|
290
|
+
ensureWatch.close();
|
|
291
|
+
bus.close();
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|