@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.
- package/package.json +2 -2
- 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 +57 -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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
//
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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 (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
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(
|
|
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
|
|
922
|
-
*
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
}
|
|
1050
|
-
|
|
1051
|
-
|
|
973
|
+
}
|
|
974
|
+
if (objectKey in obj) {
|
|
975
|
+
applied.push(`${input.target}["${objectKey}"] already set — skipped`);
|
|
976
|
+
continue;
|
|
1052
977
|
}
|
|
1053
978
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|