@celilo/cli 0.3.4 → 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
  }
@@ -590,7 +546,9 @@ export async function interviewForMissingSecrets(
590
546
  // If no metadata, default to user_provided (safe default)
591
547
  const source = hasManifestGenerate ? 'generated' : metadata?.source || 'user_provided';
592
548
 
593
- 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;
594
552
 
595
553
  // Handle derived secrets first
596
554
  if (metadata?.deriveFrom) {
@@ -645,92 +603,41 @@ export async function interviewForMissingSecrets(
645
603
  value = generateSecret({ format, length });
646
604
 
647
605
  log.message(`Auto-generated ${format} secret: ${variable.name}`);
648
- } else if (source === 'user_provided') {
649
- // Always prompt, required
650
- // Check if we're in interactive mode
651
- if (!process.stdin.isTTY) {
652
- // Non-interactive: skip this secret and continue processing others
653
- // The caller will handle the remaining user-provided secrets
654
- continue;
655
- }
656
-
657
- const message = variable.description
658
- ? `${variable.name} - ${variable.description}:`
659
- : `${variable.name}:`;
660
-
661
- value = await promptPassword({
662
- message,
663
- validate: (val) => {
664
- if (!val || val.trim() === '') {
665
- return 'This field is required';
666
- }
667
- },
668
- });
669
-
670
- log.success(`Saved ${variable.name}`);
671
- } else if (source === 'user_password') {
672
- // Password the user must remember — prompt twice to confirm
673
- if (!process.stdin.isTTY) {
674
- continue;
675
- }
676
-
677
- const message = variable.description
678
- ? `${variable.name} - ${variable.description}:`
679
- : `${variable.name}:`;
680
-
681
- value = await promptPassword({
682
- message,
683
- validate: (val) => {
684
- if (!val || val.trim() === '') {
685
- return 'This field is required';
686
- }
687
- },
688
- });
689
-
690
- const confirmation = await promptPassword({
691
- message: `Confirm ${variable.name}:`,
692
- validate: (val) => {
693
- if (!val || val.trim() === '') {
694
- return 'This field is required';
695
- }
696
- },
697
- });
698
-
699
- if (value !== confirmation) {
700
- log.error('Passwords do not match. Please try again.');
701
- // Re-prompt by decrementing — but we're in a for-of loop, so just return error
702
- return {
703
- success: false,
704
- configured,
705
- error: `Passwords do not match for ${variable.name}. Re-run deploy to try again.`,
706
- };
707
- }
708
-
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);
709
636
  log.success(`Saved ${variable.name}`);
710
- } else if (source === 'generated_optional') {
711
- // Prompt with auto-generate option
712
- const message = variable.description
713
- ? `${variable.name} - ${variable.description} (Press Enter to auto-generate):`
714
- : `${variable.name} (Press Enter to auto-generate):`;
715
-
716
- const userValue = await promptPassword({
717
- message,
718
- validate: () => undefined, // Allow empty
719
- });
720
-
721
- if (!userValue || userValue.trim() === '') {
722
- // Auto-generate
723
- const format = metadata?.format || 'base64';
724
- const length = metadata?.length || 32;
725
-
726
- value = generateSecret({ format, length });
727
-
728
- log.message(`Auto-generated ${format} secret: ${variable.name}`);
729
- } else {
730
- // Use user-provided value
731
- value = userValue;
732
- log.success(`Saved ${variable.name}`);
733
- }
637
+ configured.push(`${variable.name} (secret)`);
638
+ // Responder already wrote to the encrypted store; skip the
639
+ // encrypt+insert below.
640
+ continue;
734
641
  } else {
735
642
  return {
736
643
  success: false,
@@ -739,7 +646,15 @@ export async function interviewForMissingSecrets(
739
646
  };
740
647
  }
741
648
 
742
- // 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
+ }
743
658
  const encrypted = encryptSecret(value, masterKey);
744
659
  await db
745
660
  .insert(secrets)
@@ -798,7 +713,11 @@ function renderEnsureTemplate(template: string, value: string): string {
798
713
  return template.replace(/\{\{\s*value\s*\}\}/g, value);
799
714
  }
800
715
 
801
- 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> {
802
721
  const row = db
803
722
  .select()
804
723
  .from(moduleConfigs)
@@ -815,7 +734,7 @@ async function readModuleConfigKey(moduleId: string, key: string, db: DbClient):
815
734
  return row.value;
816
735
  }
817
736
 
818
- async function writeModuleConfigKey(
737
+ export async function writeModuleConfigKey(
819
738
  moduleId: string,
820
739
  key: string,
821
740
  value: unknown,
@@ -840,7 +759,7 @@ async function writeModuleConfigKey(
840
759
  .run();
841
760
  }
842
761
 
843
- async function readModuleSecretKey(
762
+ export async function readModuleSecretKey(
844
763
  moduleId: string,
845
764
  name: string,
846
765
  db: DbClient,
@@ -858,7 +777,7 @@ async function readModuleSecretKey(
858
777
  );
859
778
  }
860
779
 
861
- async function writeModuleSecretKey(
780
+ export async function writeModuleSecretKey(
862
781
  moduleId: string,
863
782
  name: string,
864
783
  plaintext: string,
@@ -898,12 +817,6 @@ async function writeModuleSecretKey(
898
817
  }
899
818
 
900
819
  export interface EnsureInterviewOptions {
901
- /**
902
- * In non-interactive mode the framework prints the recipe and aborts
903
- * before reaching this function — but tests / scripted callers still
904
- * use this flag to apply changes without prompting for any text input.
905
- */
906
- noInteractive?: boolean;
907
820
  /** Override prompts for testing — return string answers in order. */
908
821
  promptOverride?: (prompt: string, hint?: string) => Promise<string>;
909
822
  }
@@ -918,9 +831,14 @@ export interface EnsureInterviewResult {
918
831
  }
919
832
 
920
833
  /**
921
- * Render the CLI recipe shown in `--no-interactive` mode. Generated
922
- * from the manifest's `ensures` block so it stays correct as modules
923
- * 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).
924
842
  */
925
843
  export function renderEnsureRecipe(
926
844
  providerModuleId: string,
@@ -987,31 +905,46 @@ export async function interviewForEnsureInputs(
987
905
 
988
906
  const masterKey = await getOrCreateMasterKey();
989
907
 
908
+ // 1. Apply append_to_array inputs deterministically. The trigger
909
+ // value is known up-front, so no responder is needed.
990
910
  for (const input of ensure.inputs) {
911
+ if (input.kind !== 'append_to_array') continue;
991
912
  const { scope, name } = parseEnsureTarget(input.target);
992
-
993
- if (input.kind === 'append_to_array') {
994
- if (scope !== 'config') {
995
- return {
996
- success: false,
997
- applied,
998
- error: `append_to_array only supports config targets (got ${input.target})`,
999
- };
1000
- }
1001
- const current = await readModuleConfigKey(providerModuleId, name, db);
1002
- const arr = Array.isArray(current) ? [...current] : [];
1003
- if (arr.includes(value)) {
1004
- applied.push(`${input.target} already contains "${value}" — skipped`);
1005
- continue;
1006
- }
1007
- arr.push(value);
1008
- await writeModuleConfigKey(providerModuleId, name, arr, db);
1009
- applied.push(`${input.target} ← appended "${value}"`);
1010
- 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`);
1011
924
  continue;
1012
925
  }
926
+ arr.push(value);
927
+ await writeModuleConfigKey(providerModuleId, name, arr, db);
928
+ applied.push(`${input.target} ← appended "${value}"`);
929
+ allNoop = false;
930
+ }
1013
931
 
1014
- // input.kind === 'set_in_object'
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[] = [];
944
+
945
+ for (const input of ensure.inputs) {
946
+ if (input.kind !== 'set_in_object') continue;
947
+ const { scope, name } = parseEnsureTarget(input.target);
1015
948
  const objectKey = renderEnsureTemplate(input.key, value);
1016
949
  const promptMsg = renderEnsureTemplate(input.prompt, value);
1017
950
 
@@ -1019,60 +952,150 @@ export async function interviewForEnsureInputs(
1019
952
  const current = await readModuleConfigKey(providerModuleId, name, db);
1020
953
  const obj =
1021
954
  current && typeof current === 'object' && !Array.isArray(current)
1022
- ? { ...(current as Record<string, unknown>) }
955
+ ? (current as Record<string, unknown>)
1023
956
  : {};
1024
957
  if (objectKey in obj) {
1025
958
  applied.push(`${input.target}["${objectKey}"] already set — skipped`);
1026
959
  continue;
1027
960
  }
1028
- const promptFn =
1029
- options.promptOverride ??
1030
- (async () => promptText({ message: promptMsg, placeholder: input.hint }));
1031
- const userValue = await promptFn(promptMsg, input.hint);
1032
- obj[objectKey] = userValue;
1033
- await writeModuleConfigKey(providerModuleId, name, obj, db);
1034
- applied.push(`${input.target}["${objectKey}"] set`);
1035
- allNoop = false;
1036
- continue;
1037
- }
1038
-
1039
- // Secret object: stored as a JSON-encoded string secret. Round-trip
1040
- // through parse/serialize so other keys are preserved.
1041
- const currentRaw = await readModuleSecretKey(providerModuleId, name, db, masterKey);
1042
- let obj: Record<string, unknown> = {};
1043
- if (currentRaw) {
1044
- try {
1045
- const parsed = JSON.parse(currentRaw);
1046
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1047
- 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.
1048
972
  }
1049
- } catch {
1050
- // Existing secret isn't a JSON object — overwrite (the schema
1051
- // declares this secret as an object, so any other shape is stale).
973
+ }
974
+ if (objectKey in obj) {
975
+ applied.push(`${input.target}["${objectKey}"] already set skipped`);
976
+ continue;
1052
977
  }
1053
978
  }
1054
- if (objectKey in obj) {
1055
- applied.push(`${input.target}["${objectKey}"] already set — skipped`);
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;
1007
+ }
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;
1056
1043
  continue;
1057
1044
  }
1058
- const promptFn =
1059
- options.promptOverride ??
1060
- // promptPassword doesn't take a placeholder, so fold the hint
1061
- // into the message — the user only sees the message before typing.
1062
- (async () =>
1063
- promptPassword({
1064
- message: input.hint ? `${promptMsg}\n ${input.hint}` : promptMsg,
1065
- }));
1066
- const userValue = await promptFn(promptMsg, input.hint);
1067
- obj[objectKey] = userValue;
1068
- await writeModuleSecretKey(providerModuleId, name, JSON.stringify(obj), db, masterKey);
1069
- 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`);
1070
1055
  allNoop = false;
1071
1056
  }
1072
1057
 
1073
1058
  return { success: true, alreadyApplied: allNoop, applied };
1074
1059
  }
1075
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
+
1076
1099
  /**
1077
1100
  * Look up an `ensures` block on a provider module's manifest by id.
1078
1101
  * Returns null when the module doesn't exist or doesn't declare a