@celilo/cli 0.3.3 → 0.3.9

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.
@@ -1,12 +1,5 @@
1
- /**
2
- * Interactive Configuration Interview Service
3
- *
4
- * Prompts users for missing required configuration during deployment
5
- */
6
-
7
- import * as p from '@clack/prompts';
8
1
  import { and, eq } from 'drizzle-orm';
9
- import { log, promptPassword, promptText } from '../cli/prompts';
2
+ import { log, promptPassword } from '../cli/prompts';
10
3
  import type { DbClient } from '../db/client';
11
4
  import { moduleConfigs, modules, secrets } from '../db/schema';
12
5
  import type { Ensure } from '../manifest/schema';
@@ -18,6 +11,10 @@ import {
18
11
  type ConfigReply,
19
12
  type ConfigRequiredPayload,
20
13
  EVENT_TYPES,
14
+ type EnsureReply,
15
+ type EnsureRequiredPayload,
16
+ type SecretAck,
17
+ type SecretRequiredPayload,
21
18
  busInterview,
22
19
  } from './bus-interview';
23
20
  import { getSecretMetadata, loadSecretsSchema } from './secret-schema-loader';
@@ -243,34 +240,25 @@ export async function interviewForMissingConfig(
243
240
  });
244
241
  configured.push(followUpKey);
245
242
  } else {
246
- // Can't derive - prompt for this follow-up
243
+ // Can't derive — bus-mediated prompt (responder
244
+ // races terminal vs `events respond` etc.)
247
245
  const option = variable.options?.find((o) => o.value === selectedVal);
248
246
  const followUpPrompt = variable.per_selection.prompt
249
247
  .replace('{value}', selectedVal)
250
248
  .replace('{label}', option?.label || selectedVal)
251
249
  .replace('{hint}', option?.hint || '');
252
-
253
- const userValue = await promptText({
254
- message: followUpPrompt,
255
- validate: (val) => {
256
- if (!val || val.trim() === '') return 'This field is required';
257
- },
258
- });
259
-
260
- await db
261
- .insert(moduleConfigs)
262
- .values({
263
- moduleId,
264
- key: followUpKey,
265
- value: userValue,
266
- valueJson: null,
267
- createdAt: new Date(),
268
- updatedAt: new Date(),
269
- })
270
- .onConflictDoUpdate({
271
- target: [moduleConfigs.moduleId, moduleConfigs.key],
272
- set: { value: userValue, updatedAt: new Date() },
273
- });
250
+ const followUpPayload: ConfigRequiredPayload = {
251
+ module: moduleId,
252
+ key: followUpKey,
253
+ type: (variable.per_selection.type as ConfigRequiredPayload['type']) ?? 'string',
254
+ required: true,
255
+ description: followUpPrompt,
256
+ };
257
+ const followUpReply = await busInterview<ConfigReply>(
258
+ EVENT_TYPES.configRequired(moduleId, followUpKey),
259
+ followUpPayload,
260
+ );
261
+ await writeModuleConfigKey(moduleId, followUpKey, followUpReply.value, db);
274
262
  configured.push(followUpKey);
275
263
  }
276
264
  }
@@ -331,54 +319,29 @@ export async function interviewForMissingConfig(
331
319
 
332
320
  configured.push(`${variable.name} (secret)`);
333
321
  } else if (variable.options && variable.options.length > 0) {
334
- // Multi-select prompt for variables with options
335
- const message = variable.description
336
- ? `${variable.name} - ${variable.description}:`
337
- : `${variable.name}:`;
338
-
339
- const selected = await p.multiselect({
340
- message,
341
- options: variable.options.map((opt) => ({
342
- value: opt.value,
343
- label: opt.label,
344
- hint: opt.hint,
345
- })),
322
+ // Multi-select via bus. The terminal-responder (or any other
323
+ // responder) sees the options[] in the payload and presents a
324
+ // multiselect prompt; the reply value is the selected
325
+ // string[]. Same race + first-reply-wins semantics as the
326
+ // simple-text path.
327
+ const payload: ConfigRequiredPayload = {
328
+ module: moduleId,
329
+ key: variable.name,
330
+ type: 'array',
346
331
  required: true,
347
- });
348
-
349
- if (p.isCancel(selected)) {
350
- return {
351
- success: false,
352
- configured,
353
- error: `Configuration cancelled for ${variable.name}`,
354
- };
355
- }
356
-
357
- const selectedValues = selected as string[];
358
- value = selectedValues.join(',');
359
-
360
- // Store config
361
- await db
362
- .insert(moduleConfigs)
363
- .values({
364
- moduleId,
365
- key: variable.name,
366
- value,
367
- valueJson: null,
368
- createdAt: new Date(),
369
- updatedAt: new Date(),
370
- })
371
- .onConflictDoUpdate({
372
- target: [moduleConfigs.moduleId, moduleConfigs.key],
373
- set: {
374
- value,
375
- updatedAt: new Date(),
376
- },
377
- });
378
-
332
+ description: variable.description,
333
+ options: variable.options,
334
+ };
335
+ const reply = await busInterview<ConfigReply>(
336
+ EVENT_TYPES.configRequired(moduleId, variable.name),
337
+ payload,
338
+ );
339
+ const selectedValues = reply.value as string[];
340
+ await writeModuleConfigKey(moduleId, variable.name, selectedValues, db);
379
341
  configured.push(variable.name);
380
342
 
381
- // Handle per_selection follow-up prompts
343
+ // Handle per_selection follow-up prompts. One bus event per
344
+ // selected option; each races independently.
382
345
  if (variable.per_selection) {
383
346
  for (const selectedVal of selectedValues) {
384
347
  const option = variable.options?.find((o) => o.value === selectedVal);
@@ -388,7 +351,8 @@ export async function interviewForMissingConfig(
388
351
  .replace('{label}', option?.label || selectedVal)
389
352
  .replace('{hint}', option?.hint || '');
390
353
 
391
- // Check if already configured
354
+ // Skip if this follow-up was already answered (e.g., a
355
+ // previous deploy attempt).
392
356
  const existingFollowUp = db
393
357
  .select()
394
358
  .from(moduleConfigs)
@@ -396,27 +360,19 @@ export async function interviewForMissingConfig(
396
360
  .get();
397
361
 
398
362
  if (!existingFollowUp || !existingFollowUp.value) {
399
- const followUpValue = await promptText({
400
- message: followUpPrompt,
401
- validate: (val) => {
402
- if (!val || val.trim() === '') return 'This field is required';
403
- },
404
- });
363
+ const followUpPayload: ConfigRequiredPayload = {
364
+ module: moduleId,
365
+ key: followUpKey,
366
+ type: (variable.per_selection.type as ConfigRequiredPayload['type']) ?? 'string',
367
+ required: true,
368
+ description: followUpPrompt,
369
+ };
370
+ const followUpReply = await busInterview<ConfigReply>(
371
+ EVENT_TYPES.configRequired(moduleId, followUpKey),
372
+ followUpPayload,
373
+ );
405
374
 
406
- await db
407
- .insert(moduleConfigs)
408
- .values({
409
- moduleId,
410
- key: followUpKey,
411
- value: followUpValue,
412
- valueJson: null,
413
- createdAt: new Date(),
414
- updatedAt: new Date(),
415
- })
416
- .onConflictDoUpdate({
417
- target: [moduleConfigs.moduleId, moduleConfigs.key],
418
- set: { value: followUpValue, updatedAt: new Date() },
419
- });
375
+ await writeModuleConfigKey(moduleId, followUpKey, followUpReply.value, db);
420
376
 
421
377
  configured.push(followUpKey);
422
378
  }
@@ -442,29 +398,13 @@ export async function interviewForMissingConfig(
442
398
  payload,
443
399
  );
444
400
 
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);
450
-
451
- await db
452
- .insert(moduleConfigs)
453
- .values({
454
- moduleId,
455
- key: variable.name,
456
- value,
457
- valueJson: null,
458
- createdAt: new Date(),
459
- updatedAt: new Date(),
460
- })
461
- .onConflictDoUpdate({
462
- target: [moduleConfigs.moduleId, moduleConfigs.key],
463
- set: {
464
- value,
465
- updatedAt: new Date(),
466
- },
467
- });
401
+ // Persist via writeModuleConfigKey so the (string value,
402
+ // typed valueJson) pair is set correctly. Downstream readers
403
+ // (validate_config hooks, capability resolvers) deserialize
404
+ // typed values from valueJson earlier I was only writing
405
+ // value with valueJson:null, which broke `array`/`object`
406
+ // typed configs that consumers expected to be JSON-parsed.
407
+ await writeModuleConfigKey(moduleId, variable.name, reply.value, db);
468
408
 
469
409
  configured.push(variable.name);
470
410
  }
@@ -606,7 +546,9 @@ export async function interviewForMissingSecrets(
606
546
  // If no metadata, default to user_provided (safe default)
607
547
  const source = hasManifestGenerate ? 'generated' : metadata?.source || 'user_provided';
608
548
 
609
- let value: string;
549
+ // Whether we need to encrypt + insert here, or whether the
550
+ // bus responder already wrote the value out-of-band.
551
+ let value: string | null = null;
610
552
 
611
553
  // Handle derived secrets first
612
554
  if (metadata?.deriveFrom) {
@@ -661,92 +603,41 @@ export async function interviewForMissingSecrets(
661
603
  value = generateSecret({ format, length });
662
604
 
663
605
  log.message(`Auto-generated ${format} secret: ${variable.name}`);
664
- } else if (source === 'user_provided') {
665
- // Always prompt, required
666
- // Check if we're in interactive mode
667
- if (!process.stdin.isTTY) {
668
- // Non-interactive: skip this secret and continue processing others
669
- // The caller will handle the remaining user-provided secrets
670
- continue;
671
- }
672
-
673
- const message = variable.description
674
- ? `${variable.name} - ${variable.description}:`
675
- : `${variable.name}:`;
676
-
677
- value = await promptPassword({
678
- message,
679
- validate: (val) => {
680
- if (!val || val.trim() === '') {
681
- return 'This field is required';
682
- }
683
- },
684
- });
685
-
686
- log.success(`Saved ${variable.name}`);
687
- } else if (source === 'user_password') {
688
- // Password the user must remember — prompt twice to confirm
689
- if (!process.stdin.isTTY) {
690
- continue;
691
- }
692
-
693
- const message = variable.description
694
- ? `${variable.name} - ${variable.description}:`
695
- : `${variable.name}:`;
696
-
697
- value = await promptPassword({
698
- message,
699
- validate: (val) => {
700
- if (!val || val.trim() === '') {
701
- return 'This field is required';
702
- }
703
- },
704
- });
705
-
706
- const confirmation = await promptPassword({
707
- message: `Confirm ${variable.name}:`,
708
- validate: (val) => {
709
- if (!val || val.trim() === '') {
710
- return 'This field is required';
711
- }
712
- },
713
- });
714
-
715
- if (value !== confirmation) {
716
- log.error('Passwords do not match. Please try again.');
717
- // Re-prompt by decrementing — but we're in a for-of loop, so just return error
718
- return {
719
- success: false,
720
- configured,
721
- error: `Passwords do not match for ${variable.name}. Re-run deploy to try again.`,
722
- };
723
- }
724
-
606
+ } else if (
607
+ source === 'user_provided' ||
608
+ source === 'user_password' ||
609
+ source === 'generated_optional'
610
+ ) {
611
+ // Bus-mediated interview. The responder (terminal-responder
612
+ // when running on a TTY, Claude subagent, or `events respond`
613
+ // from another shell) prompts the user, writes the secret
614
+ // value into the encrypted store out-of-band, then replies
615
+ // with `{ acknowledged: true }`. The value never crosses the
616
+ // bus. See INTERACTIVE_DEPLOYS_VIA_BUS.md.
617
+ const payload: SecretRequiredPayload = {
618
+ module: moduleId,
619
+ key: variable.name,
620
+ // The bus payload narrows to scalar types. Secrets in the
621
+ // wider system can be objects, but those are written via
622
+ // the cross-module ensure flow, not this interview.
623
+ type: 'string',
624
+ required: true,
625
+ description: variable.description,
626
+ style: source,
627
+ generate:
628
+ source === 'generated_optional'
629
+ ? {
630
+ format: metadata?.format || 'base64',
631
+ length: metadata?.length || 32,
632
+ }
633
+ : undefined,
634
+ };
635
+ await busInterview<SecretAck>(EVENT_TYPES.secretRequired(moduleId, variable.name), payload);
725
636
  log.success(`Saved ${variable.name}`);
726
- } else if (source === 'generated_optional') {
727
- // Prompt with auto-generate option
728
- const message = variable.description
729
- ? `${variable.name} - ${variable.description} (Press Enter to auto-generate):`
730
- : `${variable.name} (Press Enter to auto-generate):`;
731
-
732
- const userValue = await promptPassword({
733
- message,
734
- validate: () => undefined, // Allow empty
735
- });
736
-
737
- if (!userValue || userValue.trim() === '') {
738
- // Auto-generate
739
- const format = metadata?.format || 'base64';
740
- const length = metadata?.length || 32;
741
-
742
- value = generateSecret({ format, length });
743
-
744
- log.message(`Auto-generated ${format} secret: ${variable.name}`);
745
- } else {
746
- // Use user-provided value
747
- value = userValue;
748
- log.success(`Saved ${variable.name}`);
749
- }
637
+ configured.push(`${variable.name} (secret)`);
638
+ // Responder already wrote to the encrypted store; skip the
639
+ // encrypt+insert below.
640
+ continue;
750
641
  } else {
751
642
  return {
752
643
  success: false,
@@ -755,7 +646,15 @@ export async function interviewForMissingSecrets(
755
646
  };
756
647
  }
757
648
 
758
- // Encrypt and store secret
649
+ // Encrypt and store secret (deriveFrom + generated paths only;
650
+ // bus-mediated paths above already wrote and `continue`d).
651
+ if (value === null) {
652
+ return {
653
+ success: false,
654
+ configured,
655
+ error: `Internal error: ${variable.name} branch left value unset`,
656
+ };
657
+ }
759
658
  const encrypted = encryptSecret(value, masterKey);
760
659
  await db
761
660
  .insert(secrets)
@@ -814,7 +713,11 @@ function renderEnsureTemplate(template: string, value: string): string {
814
713
  return template.replace(/\{\{\s*value\s*\}\}/g, value);
815
714
  }
816
715
 
817
- async function readModuleConfigKey(moduleId: string, key: string, db: DbClient): Promise<unknown> {
716
+ export async function readModuleConfigKey(
717
+ moduleId: string,
718
+ key: string,
719
+ db: DbClient,
720
+ ): Promise<unknown> {
818
721
  const row = db
819
722
  .select()
820
723
  .from(moduleConfigs)
@@ -831,7 +734,7 @@ async function readModuleConfigKey(moduleId: string, key: string, db: DbClient):
831
734
  return row.value;
832
735
  }
833
736
 
834
- async function writeModuleConfigKey(
737
+ export async function writeModuleConfigKey(
835
738
  moduleId: string,
836
739
  key: string,
837
740
  value: unknown,
@@ -856,7 +759,7 @@ async function writeModuleConfigKey(
856
759
  .run();
857
760
  }
858
761
 
859
- async function readModuleSecretKey(
762
+ export async function readModuleSecretKey(
860
763
  moduleId: string,
861
764
  name: string,
862
765
  db: DbClient,
@@ -874,7 +777,7 @@ async function readModuleSecretKey(
874
777
  );
875
778
  }
876
779
 
877
- async function writeModuleSecretKey(
780
+ export async function writeModuleSecretKey(
878
781
  moduleId: string,
879
782
  name: string,
880
783
  plaintext: string,
@@ -914,12 +817,6 @@ async function writeModuleSecretKey(
914
817
  }
915
818
 
916
819
  export interface EnsureInterviewOptions {
917
- /**
918
- * In non-interactive mode the framework prints the recipe and aborts
919
- * before reaching this function — but tests / scripted callers still
920
- * use this flag to apply changes without prompting for any text input.
921
- */
922
- noInteractive?: boolean;
923
820
  /** Override prompts for testing — return string answers in order. */
924
821
  promptOverride?: (prompt: string, hint?: string) => Promise<string>;
925
822
  }
@@ -934,9 +831,14 @@ export interface EnsureInterviewResult {
934
831
  }
935
832
 
936
833
  /**
937
- * Render the CLI recipe shown in `--no-interactive` mode. Generated
938
- * from the manifest's `ensures` block so it stays correct as modules
939
- * evolve.
834
+ * Render the CLI recipe operators see in `events list-pending` when
835
+ * an ensure interview is waiting for a responder. Generated from the
836
+ * manifest's `ensures` block so it stays correct as modules evolve.
837
+ *
838
+ * Pre-stage 4 this was the abort-message body for `--no-interactive`
839
+ * deploys; now it's a diagnostic for stuck queries (the bus path
840
+ * waits indefinitely, but the recipe tells the operator exactly
841
+ * which CLI commands they could run by hand to satisfy the ensure).
940
842
  */
941
843
  export function renderEnsureRecipe(
942
844
  providerModuleId: string,
@@ -1003,31 +905,46 @@ export async function interviewForEnsureInputs(
1003
905
 
1004
906
  const masterKey = await getOrCreateMasterKey();
1005
907
 
908
+ // 1. Apply append_to_array inputs deterministically. The trigger
909
+ // value is known up-front, so no responder is needed.
1006
910
  for (const input of ensure.inputs) {
911
+ if (input.kind !== 'append_to_array') continue;
1007
912
  const { scope, name } = parseEnsureTarget(input.target);
1008
-
1009
- if (input.kind === 'append_to_array') {
1010
- if (scope !== 'config') {
1011
- return {
1012
- success: false,
1013
- applied,
1014
- error: `append_to_array only supports config targets (got ${input.target})`,
1015
- };
1016
- }
1017
- const current = await readModuleConfigKey(providerModuleId, name, db);
1018
- const arr = Array.isArray(current) ? [...current] : [];
1019
- if (arr.includes(value)) {
1020
- applied.push(`${input.target} already contains "${value}" — skipped`);
1021
- continue;
1022
- }
1023
- arr.push(value);
1024
- await writeModuleConfigKey(providerModuleId, name, arr, db);
1025
- applied.push(`${input.target} ← appended "${value}"`);
1026
- allNoop = false;
913
+ if (scope !== 'config') {
914
+ return {
915
+ success: false,
916
+ applied,
917
+ error: `append_to_array only supports config targets (got ${input.target})`,
918
+ };
919
+ }
920
+ const current = await readModuleConfigKey(providerModuleId, name, db);
921
+ const arr = Array.isArray(current) ? [...current] : [];
922
+ if (arr.includes(value)) {
923
+ applied.push(`${input.target} already contains "${value}" — skipped`);
1027
924
  continue;
1028
925
  }
926
+ arr.push(value);
927
+ await writeModuleConfigKey(providerModuleId, name, arr, db);
928
+ applied.push(`${input.target} ← appended "${value}"`);
929
+ allNoop = false;
930
+ }
931
+
932
+ // 2. Collect set_in_object inputs that aren't already populated.
933
+ // These are the ones that genuinely need user input.
934
+ interface PendingInput {
935
+ target: string;
936
+ name: string;
937
+ scope: 'config' | 'secret';
938
+ objectKey: string;
939
+ prompt: string;
940
+ hint?: string;
941
+ type: string;
942
+ }
943
+ const pending: PendingInput[] = [];
1029
944
 
1030
- // input.kind === 'set_in_object'
945
+ for (const input of ensure.inputs) {
946
+ if (input.kind !== 'set_in_object') continue;
947
+ const { scope, name } = parseEnsureTarget(input.target);
1031
948
  const objectKey = renderEnsureTemplate(input.key, value);
1032
949
  const promptMsg = renderEnsureTemplate(input.prompt, value);
1033
950
 
@@ -1035,60 +952,150 @@ export async function interviewForEnsureInputs(
1035
952
  const current = await readModuleConfigKey(providerModuleId, name, db);
1036
953
  const obj =
1037
954
  current && typeof current === 'object' && !Array.isArray(current)
1038
- ? { ...(current as Record<string, unknown>) }
955
+ ? (current as Record<string, unknown>)
1039
956
  : {};
1040
957
  if (objectKey in obj) {
1041
958
  applied.push(`${input.target}["${objectKey}"] already set — skipped`);
1042
959
  continue;
1043
960
  }
1044
- const promptFn =
1045
- options.promptOverride ??
1046
- (async () => promptText({ message: promptMsg, placeholder: input.hint }));
1047
- const userValue = await promptFn(promptMsg, input.hint);
1048
- obj[objectKey] = userValue;
1049
- await writeModuleConfigKey(providerModuleId, name, obj, db);
1050
- applied.push(`${input.target}["${objectKey}"] set`);
1051
- allNoop = false;
1052
- continue;
1053
- }
1054
-
1055
- // Secret object: stored as a JSON-encoded string secret. Round-trip
1056
- // through parse/serialize so other keys are preserved.
1057
- const currentRaw = await readModuleSecretKey(providerModuleId, name, db, masterKey);
1058
- let obj: Record<string, unknown> = {};
1059
- if (currentRaw) {
1060
- try {
1061
- const parsed = JSON.parse(currentRaw);
1062
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1063
- obj = parsed as Record<string, unknown>;
961
+ } else {
962
+ const currentRaw = await readModuleSecretKey(providerModuleId, name, db, masterKey);
963
+ let obj: Record<string, unknown> = {};
964
+ if (currentRaw) {
965
+ try {
966
+ const parsed = JSON.parse(currentRaw);
967
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
968
+ obj = parsed as Record<string, unknown>;
969
+ }
970
+ } catch {
971
+ // Existing secret isn't a JSON object — overwrite later.
1064
972
  }
1065
- } catch {
1066
- // Existing secret isn't a JSON object — overwrite (the schema
1067
- // declares this secret as an object, so any other shape is stale).
1068
973
  }
974
+ if (objectKey in obj) {
975
+ applied.push(`${input.target}["${objectKey}"] already set — skipped`);
976
+ continue;
977
+ }
978
+ }
979
+
980
+ pending.push({
981
+ target: input.target,
982
+ name,
983
+ scope,
984
+ objectKey,
985
+ prompt: promptMsg,
986
+ hint: input.hint,
987
+ // EnsureInputSchema doesn't carry a type — set_in_object is
988
+ // always a string today. Carry 'string' through to the bus
989
+ // payload's type field for future-proofing.
990
+ type: 'string',
991
+ });
992
+ }
993
+
994
+ if (pending.length === 0) {
995
+ return { success: true, alreadyApplied: allNoop, applied };
996
+ }
997
+
998
+ // 3. Test escape hatch: if a promptOverride is provided, run the
999
+ // legacy direct-prompt path. Lets existing unit tests keep their
1000
+ // inline prompt stubs without setting up a bus responder.
1001
+ if (options.promptOverride) {
1002
+ for (const input of pending) {
1003
+ const userValue = await options.promptOverride(input.prompt, input.hint);
1004
+ await applyEnsureInput(input, userValue, providerModuleId, db, masterKey);
1005
+ applied.push(`${input.target}["${input.objectKey}"] set`);
1006
+ allNoop = false;
1069
1007
  }
1070
- if (objectKey in obj) {
1071
- applied.push(`${input.target}["${objectKey}"] already set — skipped`);
1008
+ return { success: true, alreadyApplied: allNoop, applied };
1009
+ }
1010
+
1011
+ // 4. Bus-mediated interview. One event for the whole ensure; the
1012
+ // responder prompts the user, sets secret-target values
1013
+ // out-of-band, replies with config-target values plus an
1014
+ // `acknowledged: true` flag when secrets were touched.
1015
+ const payload: EnsureRequiredPayload = {
1016
+ consumer: providerModuleId,
1017
+ provider: providerModuleId,
1018
+ ensureId: ensure.id,
1019
+ triggerValue: value,
1020
+ description: ensure.description,
1021
+ inputs: pending.map((i) => ({
1022
+ target: i.target,
1023
+ kind: 'set_in_object',
1024
+ prompt: i.prompt,
1025
+ hint: i.hint,
1026
+ type: i.type,
1027
+ objectKey: i.objectKey,
1028
+ })),
1029
+ };
1030
+ const reply = await busInterview<EnsureReply>(
1031
+ EVENT_TYPES.ensureRequired(providerModuleId, ensure.id),
1032
+ payload,
1033
+ );
1034
+
1035
+ // 5. Apply reply values for config targets. Secret targets aren't
1036
+ // in `values` — the responder wrote them out-of-band; we record
1037
+ // them as applied since the encrypted store is already updated.
1038
+ const replyValues = reply.values ?? {};
1039
+ for (const input of pending) {
1040
+ if (input.scope === 'secret') {
1041
+ applied.push(`${input.target}["${input.objectKey}"] set`);
1042
+ allNoop = false;
1072
1043
  continue;
1073
1044
  }
1074
- const promptFn =
1075
- options.promptOverride ??
1076
- // promptPassword doesn't take a placeholder, so fold the hint
1077
- // into the message — the user only sees the message before typing.
1078
- (async () =>
1079
- promptPassword({
1080
- message: input.hint ? `${promptMsg}\n ${input.hint}` : promptMsg,
1081
- }));
1082
- const userValue = await promptFn(promptMsg, input.hint);
1083
- obj[objectKey] = userValue;
1084
- await writeModuleSecretKey(providerModuleId, name, JSON.stringify(obj), db, masterKey);
1085
- applied.push(`${input.target}["${objectKey}"] set`);
1045
+ const userValue = replyValues[input.target];
1046
+ if (userValue === undefined) {
1047
+ return {
1048
+ success: false,
1049
+ applied,
1050
+ error: `Responder reply missing value for ${input.target}`,
1051
+ };
1052
+ }
1053
+ await applyEnsureInput(input, userValue, providerModuleId, db, masterKey);
1054
+ applied.push(`${input.target}["${input.objectKey}"] set`);
1086
1055
  allNoop = false;
1087
1056
  }
1088
1057
 
1089
1058
  return { success: true, alreadyApplied: allNoop, applied };
1090
1059
  }
1091
1060
 
1061
+ /**
1062
+ * Read-merge-write one set_in_object input. Used by both the
1063
+ * promptOverride test path and the bus-mediated config-target path
1064
+ * after the responder reply is in hand.
1065
+ */
1066
+ async function applyEnsureInput(
1067
+ input: { target: string; name: string; scope: 'config' | 'secret'; objectKey: string },
1068
+ userValue: unknown,
1069
+ providerModuleId: string,
1070
+ db: DbClient,
1071
+ masterKey: Buffer,
1072
+ ): Promise<void> {
1073
+ if (input.scope === 'config') {
1074
+ const current = await readModuleConfigKey(providerModuleId, input.name, db);
1075
+ const obj =
1076
+ current && typeof current === 'object' && !Array.isArray(current)
1077
+ ? { ...(current as Record<string, unknown>) }
1078
+ : {};
1079
+ obj[input.objectKey] = userValue;
1080
+ await writeModuleConfigKey(providerModuleId, input.name, obj, db);
1081
+ return;
1082
+ }
1083
+ const currentRaw = await readModuleSecretKey(providerModuleId, input.name, db, masterKey);
1084
+ let obj: Record<string, unknown> = {};
1085
+ if (currentRaw) {
1086
+ try {
1087
+ const parsed = JSON.parse(currentRaw);
1088
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1089
+ obj = parsed as Record<string, unknown>;
1090
+ }
1091
+ } catch {
1092
+ // Existing secret isn't a JSON object — overwrite.
1093
+ }
1094
+ }
1095
+ obj[input.objectKey] = userValue;
1096
+ await writeModuleSecretKey(providerModuleId, input.name, JSON.stringify(obj), db, masterKey);
1097
+ }
1098
+
1092
1099
  /**
1093
1100
  * Look up an `ensures` block on a provider module's manifest by id.
1094
1101
  * Returns null when the module doesn't exist or doesn't declare a