@sundaeswap/sprinkles 0.8.2 → 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 +3 -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 +474 -177
  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 +3 -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 +477 -180
  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 +1 -0
  27. package/src/Sprinkle/__tests__/show-menu.test.ts +132 -116
  28. package/src/Sprinkle/index.ts +546 -186
  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
  }
@@ -594,8 +749,355 @@ export class Sprinkle<S extends TSchema> {
594
749
 
595
750
  // --- Menu ---
596
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
+
597
984
  async showMenu(menu: IMenu<S>): Promise<void> {
598
- 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
+ }
599
1101
  }
600
1102
 
601
1103
  private async _showMenu(menu: IMenu<S>, main: boolean, path: string[], clearPrevious = false): Promise<void> {
@@ -705,189 +1207,7 @@ export class Sprinkle<S extends TSchema> {
705
1207
  return;
706
1208
  }
707
1209
  if (selection === -5) {
708
- const settingsMenu: IMenu<S> = {
709
- title: "Settings & Profiles",
710
- items: [
711
- {
712
- title: "View settings",
713
- action: async () => {
714
- const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
715
- const jsonLines = jsonStr.split("\n").length;
716
- console.log(jsonStr);
717
-
718
- // Wait for user to press Enter
719
- await selectWithClear({
720
- message: "Press Enter to continue...",
721
- choices: [{ name: "Continue", value: "continue" }],
722
- });
723
-
724
- // Clear the JSON output
725
- process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
726
- },
727
- },
728
- {
729
- title: "Edit settings",
730
- action: async () => {
731
- try {
732
- this.settings = await this.EditStruct(this.type, this.settings);
733
- this.saveSettings();
734
- } catch (e) {
735
- if (e instanceof UserCancelledError) {
736
- return; // User cancelled, return to menu
737
- }
738
- throw e;
739
- }
740
- },
741
- },
742
- {
743
- title: "Switch profile",
744
- action: async () => {
745
- this.saveSettings();
746
- const profiles = this.scanProfiles();
747
- if (profiles.length <= 1) {
748
- console.log(
749
- "No other profiles to switch to. Create a new one first.",
750
- );
751
- } else {
752
- await this.selectProfile(profiles);
753
- }
754
- },
755
- },
756
- {
757
- title: "Create new profile",
758
- action: async () => {
759
- await this.createProfile();
760
- },
761
- },
762
- {
763
- title: "Duplicate current profile",
764
- action: async () => {
765
- await this.duplicateProfile();
766
- },
767
- },
768
- {
769
- title: "Rename current profile",
770
- action: async () => {
771
- await this.renameProfile();
772
- },
773
- },
774
- {
775
- title: "Delete a profile",
776
- action: async () => {
777
- await this.deleteProfile();
778
- },
779
- },
780
- {
781
- title: "Addressbook",
782
- items: [
783
- {
784
- title: "View entries",
785
- action: async () => {
786
- const entries = Object.entries(this.addressbook);
787
- if (entries.length === 0) {
788
- console.log("Addressbook is empty.");
789
- } else {
790
- for (const [name, ms] of entries) {
791
- const json = JSON.stringify(ms, bigIntReplacer, 2);
792
- let hashStr = "unknown";
793
- try {
794
- hashStr = toNativeScript(ms).hash();
795
- } catch { /* skip */ }
796
- console.log(colors.bold(name) + " " + colors.dim(hashStr));
797
- console.log(json);
798
- console.log();
799
- }
800
- }
801
- await selectWithClear({
802
- message: "Press Enter to continue...",
803
- choices: [{ name: "Continue", value: "continue" }],
804
- });
805
- },
806
- },
807
- {
808
- title: "Add entry",
809
- action: async () => {
810
- const name = await inputCancellable({
811
- message: "Entry name:",
812
- validate: (v) =>
813
- v.trim().length > 0 ? true : "Name cannot be empty",
814
- });
815
- if (name === null) return;
816
- if (this.addressbook[name]) {
817
- const overwrite = await confirmCancellable({
818
- message: `Entry "${name}" already exists. Overwrite?`,
819
- default: false,
820
- });
821
- if (!overwrite) return;
822
- }
823
- try {
824
- const script = await this.FillInStruct(MultisigScriptSchema);
825
- this.addressbook[name] = script as TMultisigScript;
826
- this.saveSettings();
827
- console.log(`Added "${name}" to addressbook.`);
828
- } catch (e) {
829
- if (e instanceof UserCancelledError) return;
830
- throw e;
831
- }
832
- },
833
- },
834
- {
835
- title: "Edit entry",
836
- action: async () => {
837
- const entries = Object.keys(this.addressbook);
838
- if (entries.length === 0) {
839
- console.log("Addressbook is empty.");
840
- return;
841
- }
842
- const selected = await selectCancellable({
843
- message: "Select entry to edit:",
844
- choices: entries.map((n) => ({ name: n, value: n })),
845
- });
846
- if (selected === null) return;
847
- const editName = selected as string;
848
- try {
849
- const updated = await this.EditStruct(
850
- MultisigScriptSchema,
851
- this.addressbook[editName] as any,
852
- );
853
- this.addressbook[editName] = updated as TMultisigScript;
854
- this.saveSettings();
855
- console.log(`Updated "${editName}".`);
856
- } catch (e) {
857
- if (e instanceof UserCancelledError) return;
858
- throw e;
859
- }
860
- },
861
- },
862
- {
863
- title: "Delete entry",
864
- action: async () => {
865
- const entries = Object.keys(this.addressbook);
866
- if (entries.length === 0) {
867
- console.log("Addressbook is empty.");
868
- return;
869
- }
870
- const delSelected = await selectCancellable({
871
- message: "Select entry to delete:",
872
- choices: entries.map((n) => ({ name: n, value: n })),
873
- });
874
- if (delSelected === null) return;
875
- const delName = delSelected as string;
876
- const confirmed = await confirmCancellable({
877
- message: `Delete "${delName}"?`,
878
- default: false,
879
- });
880
- if (!confirmed) return;
881
- delete this.addressbook[delName];
882
- this.saveSettings();
883
- console.log(`Deleted "${delName}".`);
884
- },
885
- },
886
- ],
887
- } as IMenu<S>,
888
- ],
889
- };
890
- await this._showMenu(settingsMenu, false, [...path, "Settings & Profiles"], true);
1210
+ await this._showMenu(this.buildSettingsMenu(), false, [...path, "Settings & Profiles"], true);
891
1211
  await this._showMenu(menu, main, path, true);
892
1212
  return;
893
1213
  }
@@ -948,6 +1268,42 @@ export class Sprinkle<S extends TSchema> {
948
1268
  return GetBlazeFn(network, providerSettings, walletSettings);
949
1269
  }
950
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
+
951
1307
  /**
952
1308
  * Generates a new wallet from a BIP39 mnemonic phrase.
953
1309
  * Displays the 24-word recovery phrase and requires user confirmation.
@@ -1578,12 +1934,13 @@ export class Sprinkle<S extends TSchema> {
1578
1934
  }
1579
1935
 
1580
1936
  if (isString(type)) {
1581
- // Special handling for hot wallet private key - offer generation option
1937
+ // Special handling for hot wallet private key - offer multiple sources
1582
1938
  if (type.title === "Hot Wallet Private Key") {
1583
1939
  const choice = await selectWithClear({
1584
1940
  message: "Hot wallet setup:",
1585
1941
  choices: [
1586
1942
  { name: "Enter existing private key", value: "existing" },
1943
+ { name: "Import from recovery phrase", value: "mnemonic" },
1587
1944
  { name: "Generate new wallet", value: "generate" },
1588
1945
  ],
1589
1946
  });
@@ -1594,6 +1951,9 @@ export class Sprinkle<S extends TSchema> {
1594
1951
  if (choice === "generate") {
1595
1952
  return Sprinkle.generateWalletFromMnemonic() as Promise<TExact<U>>;
1596
1953
  }
1954
+ if (choice === "mnemonic") {
1955
+ return Sprinkle.importWalletFromMnemonic() as Promise<TExact<U>>;
1956
+ }
1597
1957
  // Fall through to password prompt for "existing" choice
1598
1958
  const answer = await passwordWithClear({
1599
1959
  message: "Enter your private key:",