@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.
- 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 +114 -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 +480 -179
- 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 +114 -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 +483 -182
- 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 +112 -1
- package/src/Sprinkle/__tests__/show-menu.test.ts +132 -116
- package/src/Sprinkle/index.ts +552 -188
- 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
|
}
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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:",
|