@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.
- package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +19 -7
- package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -1
- package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +3 -0
- package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
- package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +109 -98
- package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
- package/dist/cjs/Sprinkle/index.js +474 -177
- package/dist/cjs/Sprinkle/index.js.map +1 -1
- package/dist/cjs/Sprinkle/types.js.map +1 -1
- package/dist/esm/Sprinkle/__tests__/enhancements.test.js +19 -7
- package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
- package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +3 -0
- package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
- package/dist/esm/Sprinkle/__tests__/show-menu.test.js +110 -99
- package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
- package/dist/esm/Sprinkle/index.js +477 -180
- package/dist/esm/Sprinkle/index.js.map +1 -1
- package/dist/esm/Sprinkle/types.js.map +1 -1
- package/dist/types/Sprinkle/index.d.ts +29 -0
- package/dist/types/Sprinkle/index.d.ts.map +1 -1
- package/dist/types/Sprinkle/types.d.ts +6 -0
- package/dist/types/Sprinkle/types.d.ts.map +1 -1
- package/dist/types/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/Sprinkle/__tests__/enhancements.test.ts +20 -7
- package/src/Sprinkle/__tests__/fill-in-struct.test.ts +1 -0
- package/src/Sprinkle/__tests__/show-menu.test.ts +132 -116
- package/src/Sprinkle/index.ts +546 -186
- package/src/Sprinkle/types.ts +6 -0
package/src/Sprinkle/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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:",
|