@sundaeswap/sprinkles 0.8.1 → 0.9.0

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.
Files changed (29) hide show
  1. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +19 -7
  2. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  3. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +114 -0
  4. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  5. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +109 -98
  6. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  7. package/dist/cjs/Sprinkle/index.js +480 -179
  8. package/dist/cjs/Sprinkle/index.js.map +1 -1
  9. package/dist/cjs/Sprinkle/types.js.map +1 -1
  10. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +19 -7
  11. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  12. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +114 -0
  13. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  14. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +110 -99
  15. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  16. package/dist/esm/Sprinkle/index.js +483 -182
  17. package/dist/esm/Sprinkle/index.js.map +1 -1
  18. package/dist/esm/Sprinkle/types.js.map +1 -1
  19. package/dist/types/Sprinkle/index.d.ts +29 -0
  20. package/dist/types/Sprinkle/index.d.ts.map +1 -1
  21. package/dist/types/Sprinkle/types.d.ts +6 -0
  22. package/dist/types/Sprinkle/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/Sprinkle/__tests__/enhancements.test.ts +20 -7
  26. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +112 -1
  27. package/src/Sprinkle/__tests__/show-menu.test.ts +132 -116
  28. package/src/Sprinkle/index.ts +552 -188
  29. package/src/Sprinkle/types.ts +6 -0
@@ -5,7 +5,7 @@ import {
5
5
  HotWallet,
6
6
  type Wallet,
7
7
  } from "@blaze-cardano/sdk";
8
- import { CborSet, VkeyWitness, TxCBOR } from "@blaze-cardano/core";
8
+ import { CborSet, VkeyWitness, TxCBOR, wordlist } from "@blaze-cardano/core";
9
9
  import {
10
10
  selectCancellable,
11
11
  selectWithClear,
@@ -217,6 +217,14 @@ export interface IMenu<S extends TSchema> {
217
217
  beforeShow?: (sprinkle: Sprinkle<S>) => Promise<void>;
218
218
  }
219
219
 
220
+ interface ILeaf<S extends TSchema> {
221
+ breadcrumb: string;
222
+ leafTitle: string;
223
+ action: (sprinkle: Sprinkle<S>) => Promise<Sprinkle<S> | void>;
224
+ beforeShowChain: Array<(sprinkle: Sprinkle<S>) => Promise<void>>;
225
+ isExit?: boolean;
226
+ }
227
+
220
228
  export class Sprinkle<S extends TSchema> {
221
229
  storagePath: string;
222
230
  settings: TExact<S> = {} as TExact<S>;
@@ -440,10 +448,157 @@ export class Sprinkle<S extends TSchema> {
440
448
  }
441
449
  }
442
450
 
451
+ // --- Importing profiles from external files ---
452
+
453
+ private static globToRegex(pattern: string): RegExp {
454
+ const escaped = pattern
455
+ .split("*")
456
+ .map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
457
+ .join(".*");
458
+ return new RegExp(`^${escaped}$`);
459
+ }
460
+
461
+ /**
462
+ * Scan the current working directory for files matching `importPattern`.
463
+ * Returns absolute paths. Designed to surface state files committed to a
464
+ * project repo (e.g. `state.preview.json`) that the consumer might want
465
+ * to restore from.
466
+ */
467
+ findImportableProfiles(cwd: string = process.cwd()): string[] {
468
+ if (this.options.importPattern === null) return [];
469
+ const pattern = this.options.importPattern ?? "state.*.json";
470
+ const regex = Sprinkle.globToRegex(pattern);
471
+ try {
472
+ return fs
473
+ .readdirSync(cwd)
474
+ .filter((f) => regex.test(f))
475
+ .map((f) => path.join(cwd, f));
476
+ } catch {
477
+ return [];
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Read a profile or raw-settings JSON file and save it as a new local
483
+ * profile. Prompts for missing sensitive fields before writing.
484
+ */
485
+ async importProfileFromFile(filePath: string): Promise<void> {
486
+ const content = fs.readFileSync(filePath, "utf-8");
487
+ const parsed = JSON.parse(content, Sprinkle.bigIntReviver) as
488
+ | {
489
+ meta?: IProfileMeta;
490
+ settings?: unknown;
491
+ defaults?: Record<string, unknown>;
492
+ }
493
+ | unknown;
494
+
495
+ let importedSettings: TExact<S>;
496
+ let importedMeta: IProfileMeta | undefined;
497
+ let importedDefaults: Record<string, unknown> = {};
498
+
499
+ if (
500
+ parsed &&
501
+ typeof parsed === "object" &&
502
+ "settings" in (parsed as object) &&
503
+ "meta" in (parsed as object)
504
+ ) {
505
+ const obj = parsed as {
506
+ meta: IProfileMeta;
507
+ settings: TExact<S>;
508
+ defaults?: Record<string, unknown>;
509
+ };
510
+ importedMeta = obj.meta;
511
+ importedSettings = obj.settings;
512
+ importedDefaults = obj.defaults ?? {};
513
+ } else {
514
+ importedSettings = parsed as TExact<S>;
515
+ }
516
+
517
+ // If the file came from this same Sprinkle install (same encryption),
518
+ // decrypt sensitive fields. Otherwise we'll re-prompt for missing ones below.
519
+ try {
520
+ importedSettings = await this.decryptSettings(importedSettings);
521
+ } catch {
522
+ // ignore - encrypted with a different key, will re-prompt
523
+ }
524
+
525
+ const baseName = path.basename(filePath).replace(/\.json$/, "");
526
+ const meta = await this.promptProfileMeta(
527
+ importedMeta?.name ?? baseName,
528
+ importedMeta?.description,
529
+ );
530
+ if (meta === null) return; // user cancelled
531
+ const { name, description } = meta;
532
+
533
+ // Re-prompt any sensitive fields that are missing or look encrypted
534
+ const sensitivePaths = collectSensitivePaths(this.type);
535
+ for (const p of sensitivePaths) {
536
+ const value = getNestedValue(importedSettings, p);
537
+ if (typeof value !== "string" || value.length === 0) {
538
+ const entered = await passwordCancellable({
539
+ message: `Enter value for sensitive field "${p}":`,
540
+ });
541
+ if (entered === null) return; // user cancelled
542
+ setNestedValue(importedSettings, p, entered);
543
+ }
544
+ }
545
+
546
+ const profilesDir = Sprinkle.profilesDir(this.storagePath);
547
+ if (!fs.existsSync(profilesDir)) {
548
+ fs.mkdirSync(profilesDir, { recursive: true });
549
+ }
550
+ const id = Sprinkle.findAvailableId(
551
+ profilesDir,
552
+ Sprinkle.sanitizeProfileId(name),
553
+ );
554
+ const now = new Date().toISOString();
555
+ this.profileId = id;
556
+ this.profileMeta = { name, description, createdAt: now, updatedAt: now };
557
+ this.settings = importedSettings;
558
+ this.defaults = importedDefaults;
559
+ this.saveProfile();
560
+ fs.writeFileSync(Sprinkle.activeProfilePath(this.storagePath), id, "utf-8");
561
+ }
562
+
563
+ private async promptImportFromCwd(): Promise<boolean> {
564
+ const importable = this.findImportableProfiles();
565
+ if (importable.length === 0) return false;
566
+
567
+ const cancelToken = "__cancel__";
568
+ const choices: { name: string; value: string }[] = [
569
+ ...importable.map((f) => ({
570
+ name: `Import ${path.basename(f)}`,
571
+ value: f,
572
+ })),
573
+ { name: "Enter a different path...", value: "__custom__" },
574
+ { name: "Skip import", value: cancelToken },
575
+ ];
576
+ const choice = await select({
577
+ message:
578
+ "Found importable state file(s) in this directory. Restore from one?",
579
+ choices,
580
+ });
581
+ if (choice === null || choice === cancelToken) return false;
582
+ let filePath: string = choice;
583
+ if (choice === "__custom__") {
584
+ const entered = await inputCancellable({
585
+ message: "Path to state file:",
586
+ validate: (v: string) =>
587
+ v.trim().length > 0 ? true : "Path cannot be empty",
588
+ });
589
+ if (entered === null) return false;
590
+ filePath = entered;
591
+ }
592
+ await this.importProfileFromFile(filePath);
593
+ return true;
594
+ }
595
+
443
596
  private async selectOrCreateProfile(): Promise<void> {
444
597
  const profiles = this.scanProfiles();
445
598
 
446
599
  if (profiles.length === 0) {
600
+ const imported = await this.promptImportFromCwd();
601
+ if (imported) return;
447
602
  await this.createProfile();
448
603
  return;
449
604
  }
@@ -514,6 +669,7 @@ export class Sprinkle<S extends TSchema> {
514
669
  jsonContent,
515
670
  "utf-8",
516
671
  );
672
+ await this.loadProfile(id);
517
673
  console.log(`Profile "${name}" created as a copy.`);
518
674
  }
519
675
 
@@ -593,8 +749,355 @@ export class Sprinkle<S extends TSchema> {
593
749
 
594
750
  // --- Menu ---
595
751
 
752
+ private async importProfileInteractive(): Promise<void> {
753
+ const importable = this.findImportableProfiles();
754
+ const customToken = "__custom__";
755
+ let filePath: string;
756
+ if (importable.length > 0) {
757
+ const choice = await select({
758
+ message: "Select a file to import:",
759
+ choices: [
760
+ ...importable.map((f) => ({ name: path.basename(f), value: f })),
761
+ { name: "Enter a different path...", value: customToken },
762
+ ],
763
+ });
764
+ if (choice === null) return; // user cancelled
765
+ if (choice === customToken) {
766
+ const entered = await inputCancellable({
767
+ message: "Path to state file:",
768
+ validate: (v: string) =>
769
+ v.trim().length > 0 ? true : "Path cannot be empty",
770
+ });
771
+ if (entered === null) return;
772
+ filePath = entered;
773
+ } else {
774
+ filePath = choice;
775
+ }
776
+ } else {
777
+ const entered = await inputCancellable({
778
+ message: "Path to state file:",
779
+ validate: (v: string) =>
780
+ v.trim().length > 0 ? true : "Path cannot be empty",
781
+ });
782
+ if (entered === null) return;
783
+ filePath = entered;
784
+ }
785
+ try {
786
+ await this.importProfileFromFile(filePath);
787
+ console.log(`Imported profile "${this.profileMeta.name}".`);
788
+ } catch (error) {
789
+ console.error(`Import failed: ${(error as Error).message}`);
790
+ }
791
+ }
792
+
793
+ private buildSettingsMenu(): IMenu<S> {
794
+ return {
795
+ title: "Settings & Profiles",
796
+ items: [
797
+ {
798
+ title: "View settings",
799
+ action: async () => {
800
+ const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
801
+ const jsonLines = jsonStr.split("\n").length;
802
+ console.log(jsonStr);
803
+
804
+ // Wait for user to press Enter
805
+ await selectWithClear({
806
+ message: "Press Enter to continue...",
807
+ choices: [{ name: "Continue", value: "continue" }],
808
+ });
809
+
810
+ // Clear the JSON output
811
+ process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
812
+ },
813
+ },
814
+ {
815
+ title: "Edit settings",
816
+ action: async () => {
817
+ try {
818
+ this.settings = await this.EditStruct(this.type, this.settings);
819
+ this.saveSettings();
820
+ } catch (e) {
821
+ if (e instanceof UserCancelledError) {
822
+ return; // User cancelled, return to menu
823
+ }
824
+ throw e;
825
+ }
826
+ },
827
+ },
828
+ {
829
+ title: "Switch profile",
830
+ action: async () => {
831
+ this.saveSettings();
832
+ const profiles = this.scanProfiles();
833
+ if (profiles.length <= 1) {
834
+ console.log(
835
+ "No other profiles to switch to. Create a new one first.",
836
+ );
837
+ } else {
838
+ await this.selectProfile(profiles);
839
+ }
840
+ },
841
+ },
842
+ {
843
+ title: "Create new profile",
844
+ action: async () => {
845
+ await this.createProfile();
846
+ },
847
+ },
848
+ {
849
+ title: "Duplicate current profile",
850
+ action: async () => {
851
+ await this.duplicateProfile();
852
+ },
853
+ },
854
+ {
855
+ title: "Rename current profile",
856
+ action: async () => {
857
+ await this.renameProfile();
858
+ },
859
+ },
860
+ {
861
+ title: "Delete a profile",
862
+ action: async () => {
863
+ await this.deleteProfile();
864
+ },
865
+ },
866
+ {
867
+ title: "Import profile from file",
868
+ action: async () => {
869
+ await this.importProfileInteractive();
870
+ },
871
+ },
872
+ {
873
+ title: "Addressbook",
874
+ items: [
875
+ {
876
+ title: "View entries",
877
+ action: async () => {
878
+ const entries = Object.entries(this.addressbook);
879
+ if (entries.length === 0) {
880
+ console.log("Addressbook is empty.");
881
+ } else {
882
+ for (const [name, ms] of entries) {
883
+ const json = JSON.stringify(ms, bigIntReplacer, 2);
884
+ let hashStr = "unknown";
885
+ try {
886
+ hashStr = toNativeScript(ms).hash();
887
+ } catch { /* skip */ }
888
+ console.log(colors.bold(name) + " " + colors.dim(hashStr));
889
+ console.log(json);
890
+ console.log();
891
+ }
892
+ }
893
+ await selectWithClear({
894
+ message: "Press Enter to continue...",
895
+ choices: [{ name: "Continue", value: "continue" }],
896
+ });
897
+ },
898
+ },
899
+ {
900
+ title: "Add entry",
901
+ action: async () => {
902
+ const name = await inputCancellable({
903
+ message: "Entry name:",
904
+ validate: (v) =>
905
+ v.trim().length > 0 ? true : "Name cannot be empty",
906
+ });
907
+ if (name === null) return;
908
+ if (this.addressbook[name]) {
909
+ const overwrite = await confirmCancellable({
910
+ message: `Entry "${name}" already exists. Overwrite?`,
911
+ default: false,
912
+ });
913
+ if (!overwrite) return;
914
+ }
915
+ try {
916
+ const script = await this.FillInStruct(MultisigScriptSchema);
917
+ this.addressbook[name] = script as TMultisigScript;
918
+ this.saveSettings();
919
+ console.log(`Added "${name}" to addressbook.`);
920
+ } catch (e) {
921
+ if (e instanceof UserCancelledError) return;
922
+ throw e;
923
+ }
924
+ },
925
+ },
926
+ {
927
+ title: "Edit entry",
928
+ action: async () => {
929
+ const entries = Object.keys(this.addressbook);
930
+ if (entries.length === 0) {
931
+ console.log("Addressbook is empty.");
932
+ return;
933
+ }
934
+ const selected = await selectCancellable({
935
+ message: "Select entry to edit:",
936
+ choices: entries.map((n) => ({ name: n, value: n })),
937
+ });
938
+ if (selected === null) return;
939
+ const editName = selected as string;
940
+ try {
941
+ const updated = await this.EditStruct(
942
+ MultisigScriptSchema,
943
+ this.addressbook[editName] as any,
944
+ );
945
+ this.addressbook[editName] = updated as TMultisigScript;
946
+ this.saveSettings();
947
+ console.log(`Updated "${editName}".`);
948
+ } catch (e) {
949
+ if (e instanceof UserCancelledError) return;
950
+ throw e;
951
+ }
952
+ },
953
+ },
954
+ {
955
+ title: "Delete entry",
956
+ action: async () => {
957
+ const entries = Object.keys(this.addressbook);
958
+ if (entries.length === 0) {
959
+ console.log("Addressbook is empty.");
960
+ return;
961
+ }
962
+ const delSelected = await selectCancellable({
963
+ message: "Select entry to delete:",
964
+ choices: entries.map((n) => ({ name: n, value: n })),
965
+ });
966
+ if (delSelected === null) return;
967
+ const delName = delSelected as string;
968
+ const confirmed = await confirmCancellable({
969
+ message: `Delete "${delName}"?`,
970
+ default: false,
971
+ });
972
+ if (!confirmed) return;
973
+ delete this.addressbook[delName];
974
+ this.saveSettings();
975
+ console.log(`Deleted "${delName}".`);
976
+ },
977
+ },
978
+ ],
979
+ } as IMenu<S>,
980
+ ],
981
+ };
982
+ }
983
+
596
984
  async showMenu(menu: IMenu<S>): Promise<void> {
597
- return this._showMenu(menu, true, [menu.title]);
985
+ return this._searchMenu(menu);
986
+ }
987
+
988
+ // --- Flat search-based top-level menu ---
989
+
990
+ private _flattenLeaves(
991
+ menu: IMenu<S>,
992
+ prefix: string[],
993
+ beforeShowChain: Array<(s: Sprinkle<S>) => Promise<void>>,
994
+ isRoot: boolean,
995
+ ): ILeaf<S>[] {
996
+ // Root's title is omitted from breadcrumbs (no "Main Menu › " prefix on
997
+ // every leaf). Root's beforeShow is fired by the search loop, not as part
998
+ // of the leaf chain (otherwise it would fire twice).
999
+ const chain =
1000
+ !isRoot && menu.beforeShow
1001
+ ? [...beforeShowChain, menu.beforeShow]
1002
+ : beforeShowChain;
1003
+ const breadcrumbPrefix = isRoot ? prefix : [...prefix, menu.title];
1004
+
1005
+ const leaves: ILeaf<S>[] = [];
1006
+ for (const item of menu.items) {
1007
+ if ("action" in item) {
1008
+ leaves.push({
1009
+ breadcrumb: [...breadcrumbPrefix, item.title].join(" › "),
1010
+ leafTitle: item.title,
1011
+ action: item.action,
1012
+ beforeShowChain: chain,
1013
+ });
1014
+ } else {
1015
+ leaves.push(
1016
+ ...this._flattenLeaves(item, breadcrumbPrefix, chain, false),
1017
+ );
1018
+ }
1019
+ }
1020
+ return leaves;
1021
+ }
1022
+
1023
+ private static _scoreLeaf(
1024
+ leaf: { breadcrumb: string; leafTitle: string },
1025
+ tokens: string[],
1026
+ ): number {
1027
+ if (tokens.length === 0) return 1;
1028
+ const crumbLower = leaf.breadcrumb.toLowerCase();
1029
+ const titleLower = leaf.leafTitle.toLowerCase();
1030
+ let score = 0;
1031
+ for (const t of tokens) {
1032
+ if (!crumbLower.includes(t)) return 0; // require every token
1033
+ if (titleLower.includes(t)) {
1034
+ score += 3; // matches in the leaf title outrank ancestor matches
1035
+ } else {
1036
+ score += 1;
1037
+ }
1038
+ }
1039
+ return score;
1040
+ }
1041
+
1042
+ private _filterLeaves(leaves: ILeaf<S>[], term: string): ILeaf<S>[] {
1043
+ const trimmed = term.trim();
1044
+ if (!trimmed) return leaves;
1045
+ const tokens = trimmed.toLowerCase().split(/\s+/);
1046
+ return leaves
1047
+ .map((leaf) => ({ leaf, score: Sprinkle._scoreLeaf(leaf, tokens) }))
1048
+ .filter((x) => x.score > 0)
1049
+ .sort((a, b) => b.score - a.score)
1050
+ .map((x) => x.leaf);
1051
+ }
1052
+
1053
+ private async _searchMenu(menu: IMenu<S>): Promise<void> {
1054
+ const EXIT_TITLE = "Exit";
1055
+ while (true) {
1056
+ // Root beforeShow fires once per iteration so consumers can refresh any
1057
+ // dynamic state before the search prompt is built.
1058
+ if (menu.beforeShow) {
1059
+ await menu.beforeShow(this);
1060
+ }
1061
+
1062
+ const userLeaves = this._flattenLeaves(menu, [], [], true);
1063
+ const settingsLeaves = this._flattenLeaves(
1064
+ this.buildSettingsMenu(),
1065
+ [],
1066
+ [],
1067
+ false,
1068
+ );
1069
+ const exitLeaf: ILeaf<S> = {
1070
+ breadcrumb: EXIT_TITLE,
1071
+ leafTitle: EXIT_TITLE,
1072
+ action: async () => {},
1073
+ beforeShowChain: [],
1074
+ isExit: true,
1075
+ };
1076
+ const allLeaves = [...userLeaves, ...settingsLeaves, exitLeaf];
1077
+
1078
+ const selected = (await searchCancellable({
1079
+ message: "What would you like to do?",
1080
+ source: async (term: string | undefined) => {
1081
+ return this._filterLeaves(allLeaves, term ?? "").map((leaf) => ({
1082
+ name: leaf.breadcrumb,
1083
+ value: leaf,
1084
+ }));
1085
+ },
1086
+ })) as ILeaf<S> | null;
1087
+
1088
+ if (selected === null || selected.isExit) return;
1089
+
1090
+ // Fire submenu beforeShow hooks along the path to the selected leaf
1091
+ for (const beforeShow of selected.beforeShowChain) {
1092
+ await beforeShow(this);
1093
+ }
1094
+
1095
+ const result = await selected.action(this);
1096
+ if (result instanceof Sprinkle) {
1097
+ this.settings = result.settings;
1098
+ this.saveSettings();
1099
+ }
1100
+ }
598
1101
  }
599
1102
 
600
1103
  private async _showMenu(menu: IMenu<S>, main: boolean, path: string[], clearPrevious = false): Promise<void> {
@@ -704,189 +1207,7 @@ export class Sprinkle<S extends TSchema> {
704
1207
  return;
705
1208
  }
706
1209
  if (selection === -5) {
707
- const settingsMenu: IMenu<S> = {
708
- title: "Settings & Profiles",
709
- items: [
710
- {
711
- title: "View settings",
712
- action: async () => {
713
- const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
714
- const jsonLines = jsonStr.split("\n").length;
715
- console.log(jsonStr);
716
-
717
- // Wait for user to press Enter
718
- await selectWithClear({
719
- message: "Press Enter to continue...",
720
- choices: [{ name: "Continue", value: "continue" }],
721
- });
722
-
723
- // Clear the JSON output
724
- process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
725
- },
726
- },
727
- {
728
- title: "Edit settings",
729
- action: async () => {
730
- try {
731
- this.settings = await this.EditStruct(this.type, this.settings);
732
- this.saveSettings();
733
- } catch (e) {
734
- if (e instanceof UserCancelledError) {
735
- return; // User cancelled, return to menu
736
- }
737
- throw e;
738
- }
739
- },
740
- },
741
- {
742
- title: "Switch profile",
743
- action: async () => {
744
- this.saveSettings();
745
- const profiles = this.scanProfiles();
746
- if (profiles.length <= 1) {
747
- console.log(
748
- "No other profiles to switch to. Create a new one first.",
749
- );
750
- } else {
751
- await this.selectProfile(profiles);
752
- }
753
- },
754
- },
755
- {
756
- title: "Create new profile",
757
- action: async () => {
758
- await this.createProfile();
759
- },
760
- },
761
- {
762
- title: "Duplicate current profile",
763
- action: async () => {
764
- await this.duplicateProfile();
765
- },
766
- },
767
- {
768
- title: "Rename current profile",
769
- action: async () => {
770
- await this.renameProfile();
771
- },
772
- },
773
- {
774
- title: "Delete a profile",
775
- action: async () => {
776
- await this.deleteProfile();
777
- },
778
- },
779
- {
780
- title: "Addressbook",
781
- items: [
782
- {
783
- title: "View entries",
784
- action: async () => {
785
- const entries = Object.entries(this.addressbook);
786
- if (entries.length === 0) {
787
- console.log("Addressbook is empty.");
788
- } else {
789
- for (const [name, ms] of entries) {
790
- const json = JSON.stringify(ms, bigIntReplacer, 2);
791
- let hashStr = "unknown";
792
- try {
793
- hashStr = toNativeScript(ms).hash();
794
- } catch { /* skip */ }
795
- console.log(colors.bold(name) + " " + colors.dim(hashStr));
796
- console.log(json);
797
- console.log();
798
- }
799
- }
800
- await selectWithClear({
801
- message: "Press Enter to continue...",
802
- choices: [{ name: "Continue", value: "continue" }],
803
- });
804
- },
805
- },
806
- {
807
- title: "Add entry",
808
- action: async () => {
809
- const name = await inputCancellable({
810
- message: "Entry name:",
811
- validate: (v) =>
812
- v.trim().length > 0 ? true : "Name cannot be empty",
813
- });
814
- if (name === null) return;
815
- if (this.addressbook[name]) {
816
- const overwrite = await confirmCancellable({
817
- message: `Entry "${name}" already exists. Overwrite?`,
818
- default: false,
819
- });
820
- if (!overwrite) return;
821
- }
822
- try {
823
- const script = await this.FillInStruct(MultisigScriptSchema);
824
- this.addressbook[name] = script as TMultisigScript;
825
- this.saveSettings();
826
- console.log(`Added "${name}" to addressbook.`);
827
- } catch (e) {
828
- if (e instanceof UserCancelledError) return;
829
- throw e;
830
- }
831
- },
832
- },
833
- {
834
- title: "Edit entry",
835
- action: async () => {
836
- const entries = Object.keys(this.addressbook);
837
- if (entries.length === 0) {
838
- console.log("Addressbook is empty.");
839
- return;
840
- }
841
- const selected = await selectCancellable({
842
- message: "Select entry to edit:",
843
- choices: entries.map((n) => ({ name: n, value: n })),
844
- });
845
- if (selected === null) return;
846
- const editName = selected as string;
847
- try {
848
- const updated = await this.EditStruct(
849
- MultisigScriptSchema,
850
- this.addressbook[editName] as any,
851
- );
852
- this.addressbook[editName] = updated as TMultisigScript;
853
- this.saveSettings();
854
- console.log(`Updated "${editName}".`);
855
- } catch (e) {
856
- if (e instanceof UserCancelledError) return;
857
- throw e;
858
- }
859
- },
860
- },
861
- {
862
- title: "Delete entry",
863
- action: async () => {
864
- const entries = Object.keys(this.addressbook);
865
- if (entries.length === 0) {
866
- console.log("Addressbook is empty.");
867
- return;
868
- }
869
- const delSelected = await selectCancellable({
870
- message: "Select entry to delete:",
871
- choices: entries.map((n) => ({ name: n, value: n })),
872
- });
873
- if (delSelected === null) return;
874
- const delName = delSelected as string;
875
- const confirmed = await confirmCancellable({
876
- message: `Delete "${delName}"?`,
877
- default: false,
878
- });
879
- if (!confirmed) return;
880
- delete this.addressbook[delName];
881
- this.saveSettings();
882
- console.log(`Deleted "${delName}".`);
883
- },
884
- },
885
- ],
886
- } as IMenu<S>,
887
- ],
888
- };
889
- await this._showMenu(settingsMenu, false, [...path, "Settings & Profiles"], true);
1210
+ await this._showMenu(this.buildSettingsMenu(), false, [...path, "Settings & Profiles"], true);
890
1211
  await this._showMenu(menu, main, path, true);
891
1212
  return;
892
1213
  }
@@ -947,6 +1268,42 @@ export class Sprinkle<S extends TSchema> {
947
1268
  return GetBlazeFn(network, providerSettings, walletSettings);
948
1269
  }
949
1270
 
1271
+ /**
1272
+ * Derive a Bip32 private key hex from a BIP39 mnemonic phrase.
1273
+ * Accepts 12, 15, or 24 word phrases.
1274
+ */
1275
+ private static privateKeyFromMnemonic(phrase: string): string {
1276
+ const normalized = phrase.trim().toLowerCase().split(/\s+/).join(" ");
1277
+ const words = normalized.split(" ");
1278
+ if (![12, 15, 24].includes(words.length)) {
1279
+ throw new Error(
1280
+ `Expected 12, 15, or 24 words but got ${words.length}.`,
1281
+ );
1282
+ }
1283
+ const entropy = Core.mnemonicToEntropy(normalized, wordlist);
1284
+ const masterKey = Core.Bip32PrivateKey.fromBip39Entropy(
1285
+ Buffer.from(entropy),
1286
+ "",
1287
+ );
1288
+ return masterKey.hex();
1289
+ }
1290
+
1291
+ /**
1292
+ * Prompt the user for a recovery phrase and derive the Bip32 private key hex.
1293
+ */
1294
+ private static async importWalletFromMnemonic(): Promise<string> {
1295
+ const phrase = await passwordCancellable({
1296
+ message: "Enter your recovery phrase (12, 15, or 24 words):",
1297
+ });
1298
+ if (phrase === null) {
1299
+ throw new UserCancelledError();
1300
+ }
1301
+ if (phrase.trim().length === 0) {
1302
+ throw new Error("Recovery phrase cannot be empty");
1303
+ }
1304
+ return Sprinkle.privateKeyFromMnemonic(phrase);
1305
+ }
1306
+
950
1307
  /**
951
1308
  * Generates a new wallet from a BIP39 mnemonic phrase.
952
1309
  * Displays the 24-word recovery phrase and requires user confirmation.
@@ -1542,6 +1899,9 @@ export class Sprinkle<S extends TSchema> {
1542
1899
  // if the literal field values in the selected variant match those in `def`.
1543
1900
  // For non-discriminated unions, fall back to structural matching with Value.Check.
1544
1901
  let matchedDef: unknown = undefined;
1902
+ // Build references array for Value.Check — schemas with $id from defs
1903
+ // so TypeBox can resolve $ref inside variant schemas (e.g. MultisigScript).
1904
+ const references = Object.values(defs).filter((s) => s.$id) as TSchema[];
1545
1905
  if (def !== undefined) {
1546
1906
  if (isObject(selection)) {
1547
1907
  // Check if all literal fields in the selected variant match def
@@ -1559,13 +1919,13 @@ export class Sprinkle<S extends TSchema> {
1559
1919
  }
1560
1920
  } else {
1561
1921
  // No literal discriminators - use structural check
1562
- if (Value.Check(selection, def)) {
1922
+ if (Value.Check(selection, references, def)) {
1563
1923
  matchedDef = def;
1564
1924
  }
1565
1925
  }
1566
1926
  } else {
1567
1927
  // Non-object variant - use structural check
1568
- if (Value.Check(selection, def)) {
1928
+ if (Value.Check(selection, references, def)) {
1569
1929
  matchedDef = def;
1570
1930
  }
1571
1931
  }
@@ -1574,12 +1934,13 @@ export class Sprinkle<S extends TSchema> {
1574
1934
  }
1575
1935
 
1576
1936
  if (isString(type)) {
1577
- // Special handling for hot wallet private key - offer generation option
1937
+ // Special handling for hot wallet private key - offer multiple sources
1578
1938
  if (type.title === "Hot Wallet Private Key") {
1579
1939
  const choice = await selectWithClear({
1580
1940
  message: "Hot wallet setup:",
1581
1941
  choices: [
1582
1942
  { name: "Enter existing private key", value: "existing" },
1943
+ { name: "Import from recovery phrase", value: "mnemonic" },
1583
1944
  { name: "Generate new wallet", value: "generate" },
1584
1945
  ],
1585
1946
  });
@@ -1590,6 +1951,9 @@ export class Sprinkle<S extends TSchema> {
1590
1951
  if (choice === "generate") {
1591
1952
  return Sprinkle.generateWalletFromMnemonic() as Promise<TExact<U>>;
1592
1953
  }
1954
+ if (choice === "mnemonic") {
1955
+ return Sprinkle.importWalletFromMnemonic() as Promise<TExact<U>>;
1956
+ }
1593
1957
  // Fall through to password prompt for "existing" choice
1594
1958
  const answer = await passwordWithClear({
1595
1959
  message: "Enter your private key:",