@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
|
@@ -2,8 +2,8 @@ function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object
|
|
|
2
2
|
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
|
|
3
3
|
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
|
|
4
4
|
import { Core, HotWallet } from "@blaze-cardano/sdk";
|
|
5
|
-
import { CborSet, VkeyWitness, TxCBOR } from "@blaze-cardano/core";
|
|
6
|
-
import { selectCancellable, selectWithClear, inputCancellable, inputWithClear, passwordWithClear, confirmCancellable, searchCancellable } from "./prompts.js";
|
|
5
|
+
import { CborSet, VkeyWitness, TxCBOR, wordlist } from "@blaze-cardano/core";
|
|
6
|
+
import { selectCancellable, selectWithClear, inputCancellable, inputWithClear, passwordCancellable, passwordWithClear, confirmCancellable, searchCancellable, select } from "./prompts.js";
|
|
7
7
|
import colors from "yoctocolors-cjs";
|
|
8
8
|
import { OptionalKind } from "@sinclair/typebox";
|
|
9
9
|
import { Value } from "@sinclair/typebox/value";
|
|
@@ -31,7 +31,7 @@ import { GetProvider as GetProviderFn, GetWallet as GetWalletFn, GetBlaze as Get
|
|
|
31
31
|
export { GetProvider, GetWallet, GetBlaze } from "./wallet.js";
|
|
32
32
|
|
|
33
33
|
// Import encryption utilities
|
|
34
|
-
import { bigIntReplacer, bigIntReviver, encryptSensitiveFields, decryptSensitiveFields, maskSensitiveFields } from "./encryption.js";
|
|
34
|
+
import { collectSensitivePaths, getNestedValue, setNestedValue, bigIntReplacer, bigIntReviver, encryptSensitiveFields, decryptSensitiveFields, maskSensitiveFields } from "./encryption.js";
|
|
35
35
|
|
|
36
36
|
// Import tx-dialog utilities
|
|
37
37
|
import { countSignatures, getRequiredSigners, getTxBodyHash, formatHash, mergeSignatures } from "./tx-dialog.js";
|
|
@@ -249,9 +249,133 @@ export class Sprinkle {
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
|
+
|
|
253
|
+
// --- Importing profiles from external files ---
|
|
254
|
+
|
|
255
|
+
static globToRegex(pattern) {
|
|
256
|
+
const escaped = pattern.split("*").map(p => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
257
|
+
return new RegExp(`^${escaped}$`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Scan the current working directory for files matching `importPattern`.
|
|
262
|
+
* Returns absolute paths. Designed to surface state files committed to a
|
|
263
|
+
* project repo (e.g. `state.preview.json`) that the consumer might want
|
|
264
|
+
* to restore from.
|
|
265
|
+
*/
|
|
266
|
+
findImportableProfiles(cwd = process.cwd()) {
|
|
267
|
+
if (this.options.importPattern === null) return [];
|
|
268
|
+
const pattern = this.options.importPattern ?? "state.*.json";
|
|
269
|
+
const regex = Sprinkle.globToRegex(pattern);
|
|
270
|
+
try {
|
|
271
|
+
return fs.readdirSync(cwd).filter(f => regex.test(f)).map(f => path.join(cwd, f));
|
|
272
|
+
} catch {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read a profile or raw-settings JSON file and save it as a new local
|
|
279
|
+
* profile. Prompts for missing sensitive fields before writing.
|
|
280
|
+
*/
|
|
281
|
+
async importProfileFromFile(filePath) {
|
|
282
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
283
|
+
const parsed = JSON.parse(content, Sprinkle.bigIntReviver);
|
|
284
|
+
let importedSettings;
|
|
285
|
+
let importedMeta;
|
|
286
|
+
let importedDefaults = {};
|
|
287
|
+
if (parsed && typeof parsed === "object" && "settings" in parsed && "meta" in parsed) {
|
|
288
|
+
const obj = parsed;
|
|
289
|
+
importedMeta = obj.meta;
|
|
290
|
+
importedSettings = obj.settings;
|
|
291
|
+
importedDefaults = obj.defaults ?? {};
|
|
292
|
+
} else {
|
|
293
|
+
importedSettings = parsed;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// If the file came from this same Sprinkle install (same encryption),
|
|
297
|
+
// decrypt sensitive fields. Otherwise we'll re-prompt for missing ones below.
|
|
298
|
+
try {
|
|
299
|
+
importedSettings = await this.decryptSettings(importedSettings);
|
|
300
|
+
} catch {
|
|
301
|
+
// ignore - encrypted with a different key, will re-prompt
|
|
302
|
+
}
|
|
303
|
+
const baseName = path.basename(filePath).replace(/\.json$/, "");
|
|
304
|
+
const meta = await this.promptProfileMeta(importedMeta?.name ?? baseName, importedMeta?.description);
|
|
305
|
+
if (meta === null) return; // user cancelled
|
|
306
|
+
const {
|
|
307
|
+
name,
|
|
308
|
+
description
|
|
309
|
+
} = meta;
|
|
310
|
+
|
|
311
|
+
// Re-prompt any sensitive fields that are missing or look encrypted
|
|
312
|
+
const sensitivePaths = collectSensitivePaths(this.type);
|
|
313
|
+
for (const p of sensitivePaths) {
|
|
314
|
+
const value = getNestedValue(importedSettings, p);
|
|
315
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
316
|
+
const entered = await passwordCancellable({
|
|
317
|
+
message: `Enter value for sensitive field "${p}":`
|
|
318
|
+
});
|
|
319
|
+
if (entered === null) return; // user cancelled
|
|
320
|
+
setNestedValue(importedSettings, p, entered);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const profilesDir = Sprinkle.profilesDir(this.storagePath);
|
|
324
|
+
if (!fs.existsSync(profilesDir)) {
|
|
325
|
+
fs.mkdirSync(profilesDir, {
|
|
326
|
+
recursive: true
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const id = Sprinkle.findAvailableId(profilesDir, Sprinkle.sanitizeProfileId(name));
|
|
330
|
+
const now = new Date().toISOString();
|
|
331
|
+
this.profileId = id;
|
|
332
|
+
this.profileMeta = {
|
|
333
|
+
name,
|
|
334
|
+
description,
|
|
335
|
+
createdAt: now,
|
|
336
|
+
updatedAt: now
|
|
337
|
+
};
|
|
338
|
+
this.settings = importedSettings;
|
|
339
|
+
this.defaults = importedDefaults;
|
|
340
|
+
this.saveProfile();
|
|
341
|
+
fs.writeFileSync(Sprinkle.activeProfilePath(this.storagePath), id, "utf-8");
|
|
342
|
+
}
|
|
343
|
+
async promptImportFromCwd() {
|
|
344
|
+
const importable = this.findImportableProfiles();
|
|
345
|
+
if (importable.length === 0) return false;
|
|
346
|
+
const cancelToken = "__cancel__";
|
|
347
|
+
const choices = [...importable.map(f => ({
|
|
348
|
+
name: `Import ${path.basename(f)}`,
|
|
349
|
+
value: f
|
|
350
|
+
})), {
|
|
351
|
+
name: "Enter a different path...",
|
|
352
|
+
value: "__custom__"
|
|
353
|
+
}, {
|
|
354
|
+
name: "Skip import",
|
|
355
|
+
value: cancelToken
|
|
356
|
+
}];
|
|
357
|
+
const choice = await select({
|
|
358
|
+
message: "Found importable state file(s) in this directory. Restore from one?",
|
|
359
|
+
choices
|
|
360
|
+
});
|
|
361
|
+
if (choice === null || choice === cancelToken) return false;
|
|
362
|
+
let filePath = choice;
|
|
363
|
+
if (choice === "__custom__") {
|
|
364
|
+
const entered = await inputCancellable({
|
|
365
|
+
message: "Path to state file:",
|
|
366
|
+
validate: v => v.trim().length > 0 ? true : "Path cannot be empty"
|
|
367
|
+
});
|
|
368
|
+
if (entered === null) return false;
|
|
369
|
+
filePath = entered;
|
|
370
|
+
}
|
|
371
|
+
await this.importProfileFromFile(filePath);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
252
374
|
async selectOrCreateProfile() {
|
|
253
375
|
const profiles = this.scanProfiles();
|
|
254
376
|
if (profiles.length === 0) {
|
|
377
|
+
const imported = await this.promptImportFromCwd();
|
|
378
|
+
if (imported) return;
|
|
255
379
|
await this.createProfile();
|
|
256
380
|
return;
|
|
257
381
|
}
|
|
@@ -312,6 +436,7 @@ export class Sprinkle {
|
|
|
312
436
|
addressbook: this.addressbook
|
|
313
437
|
}, bigIntReplacer, 2);
|
|
314
438
|
fs.writeFileSync(path.join(profilesDir, `${id}.json`), jsonContent, "utf-8");
|
|
439
|
+
await this.loadProfile(id);
|
|
315
440
|
console.log(`Profile "${name}" created as a copy.`);
|
|
316
441
|
}
|
|
317
442
|
async renameProfile() {
|
|
@@ -373,8 +498,318 @@ export class Sprinkle {
|
|
|
373
498
|
|
|
374
499
|
// --- Menu ---
|
|
375
500
|
|
|
501
|
+
async importProfileInteractive() {
|
|
502
|
+
const importable = this.findImportableProfiles();
|
|
503
|
+
const customToken = "__custom__";
|
|
504
|
+
let filePath;
|
|
505
|
+
if (importable.length > 0) {
|
|
506
|
+
const choice = await select({
|
|
507
|
+
message: "Select a file to import:",
|
|
508
|
+
choices: [...importable.map(f => ({
|
|
509
|
+
name: path.basename(f),
|
|
510
|
+
value: f
|
|
511
|
+
})), {
|
|
512
|
+
name: "Enter a different path...",
|
|
513
|
+
value: customToken
|
|
514
|
+
}]
|
|
515
|
+
});
|
|
516
|
+
if (choice === null) return; // user cancelled
|
|
517
|
+
if (choice === customToken) {
|
|
518
|
+
const entered = await inputCancellable({
|
|
519
|
+
message: "Path to state file:",
|
|
520
|
+
validate: v => v.trim().length > 0 ? true : "Path cannot be empty"
|
|
521
|
+
});
|
|
522
|
+
if (entered === null) return;
|
|
523
|
+
filePath = entered;
|
|
524
|
+
} else {
|
|
525
|
+
filePath = choice;
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
const entered = await inputCancellable({
|
|
529
|
+
message: "Path to state file:",
|
|
530
|
+
validate: v => v.trim().length > 0 ? true : "Path cannot be empty"
|
|
531
|
+
});
|
|
532
|
+
if (entered === null) return;
|
|
533
|
+
filePath = entered;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
await this.importProfileFromFile(filePath);
|
|
537
|
+
console.log(`Imported profile "${this.profileMeta.name}".`);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error(`Import failed: ${error.message}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
buildSettingsMenu() {
|
|
543
|
+
return {
|
|
544
|
+
title: "Settings & Profiles",
|
|
545
|
+
items: [{
|
|
546
|
+
title: "View settings",
|
|
547
|
+
action: async () => {
|
|
548
|
+
const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
|
|
549
|
+
const jsonLines = jsonStr.split("\n").length;
|
|
550
|
+
console.log(jsonStr);
|
|
551
|
+
|
|
552
|
+
// Wait for user to press Enter
|
|
553
|
+
await selectWithClear({
|
|
554
|
+
message: "Press Enter to continue...",
|
|
555
|
+
choices: [{
|
|
556
|
+
name: "Continue",
|
|
557
|
+
value: "continue"
|
|
558
|
+
}]
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Clear the JSON output
|
|
562
|
+
process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
|
|
563
|
+
}
|
|
564
|
+
}, {
|
|
565
|
+
title: "Edit settings",
|
|
566
|
+
action: async () => {
|
|
567
|
+
try {
|
|
568
|
+
this.settings = await this.EditStruct(this.type, this.settings);
|
|
569
|
+
this.saveSettings();
|
|
570
|
+
} catch (e) {
|
|
571
|
+
if (e instanceof UserCancelledError) {
|
|
572
|
+
return; // User cancelled, return to menu
|
|
573
|
+
}
|
|
574
|
+
throw e;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}, {
|
|
578
|
+
title: "Switch profile",
|
|
579
|
+
action: async () => {
|
|
580
|
+
this.saveSettings();
|
|
581
|
+
const profiles = this.scanProfiles();
|
|
582
|
+
if (profiles.length <= 1) {
|
|
583
|
+
console.log("No other profiles to switch to. Create a new one first.");
|
|
584
|
+
} else {
|
|
585
|
+
await this.selectProfile(profiles);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}, {
|
|
589
|
+
title: "Create new profile",
|
|
590
|
+
action: async () => {
|
|
591
|
+
await this.createProfile();
|
|
592
|
+
}
|
|
593
|
+
}, {
|
|
594
|
+
title: "Duplicate current profile",
|
|
595
|
+
action: async () => {
|
|
596
|
+
await this.duplicateProfile();
|
|
597
|
+
}
|
|
598
|
+
}, {
|
|
599
|
+
title: "Rename current profile",
|
|
600
|
+
action: async () => {
|
|
601
|
+
await this.renameProfile();
|
|
602
|
+
}
|
|
603
|
+
}, {
|
|
604
|
+
title: "Delete a profile",
|
|
605
|
+
action: async () => {
|
|
606
|
+
await this.deleteProfile();
|
|
607
|
+
}
|
|
608
|
+
}, {
|
|
609
|
+
title: "Import profile from file",
|
|
610
|
+
action: async () => {
|
|
611
|
+
await this.importProfileInteractive();
|
|
612
|
+
}
|
|
613
|
+
}, {
|
|
614
|
+
title: "Addressbook",
|
|
615
|
+
items: [{
|
|
616
|
+
title: "View entries",
|
|
617
|
+
action: async () => {
|
|
618
|
+
const entries = Object.entries(this.addressbook);
|
|
619
|
+
if (entries.length === 0) {
|
|
620
|
+
console.log("Addressbook is empty.");
|
|
621
|
+
} else {
|
|
622
|
+
for (const [name, ms] of entries) {
|
|
623
|
+
const json = JSON.stringify(ms, bigIntReplacer, 2);
|
|
624
|
+
let hashStr = "unknown";
|
|
625
|
+
try {
|
|
626
|
+
hashStr = toNativeScript(ms).hash();
|
|
627
|
+
} catch {/* skip */}
|
|
628
|
+
console.log(colors.bold(name) + " " + colors.dim(hashStr));
|
|
629
|
+
console.log(json);
|
|
630
|
+
console.log();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
await selectWithClear({
|
|
634
|
+
message: "Press Enter to continue...",
|
|
635
|
+
choices: [{
|
|
636
|
+
name: "Continue",
|
|
637
|
+
value: "continue"
|
|
638
|
+
}]
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}, {
|
|
642
|
+
title: "Add entry",
|
|
643
|
+
action: async () => {
|
|
644
|
+
const name = await inputCancellable({
|
|
645
|
+
message: "Entry name:",
|
|
646
|
+
validate: v => v.trim().length > 0 ? true : "Name cannot be empty"
|
|
647
|
+
});
|
|
648
|
+
if (name === null) return;
|
|
649
|
+
if (this.addressbook[name]) {
|
|
650
|
+
const overwrite = await confirmCancellable({
|
|
651
|
+
message: `Entry "${name}" already exists. Overwrite?`,
|
|
652
|
+
default: false
|
|
653
|
+
});
|
|
654
|
+
if (!overwrite) return;
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
const script = await this.FillInStruct(MultisigScriptSchema);
|
|
658
|
+
this.addressbook[name] = script;
|
|
659
|
+
this.saveSettings();
|
|
660
|
+
console.log(`Added "${name}" to addressbook.`);
|
|
661
|
+
} catch (e) {
|
|
662
|
+
if (e instanceof UserCancelledError) return;
|
|
663
|
+
throw e;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}, {
|
|
667
|
+
title: "Edit entry",
|
|
668
|
+
action: async () => {
|
|
669
|
+
const entries = Object.keys(this.addressbook);
|
|
670
|
+
if (entries.length === 0) {
|
|
671
|
+
console.log("Addressbook is empty.");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const selected = await selectCancellable({
|
|
675
|
+
message: "Select entry to edit:",
|
|
676
|
+
choices: entries.map(n => ({
|
|
677
|
+
name: n,
|
|
678
|
+
value: n
|
|
679
|
+
}))
|
|
680
|
+
});
|
|
681
|
+
if (selected === null) return;
|
|
682
|
+
const editName = selected;
|
|
683
|
+
try {
|
|
684
|
+
const updated = await this.EditStruct(MultisigScriptSchema, this.addressbook[editName]);
|
|
685
|
+
this.addressbook[editName] = updated;
|
|
686
|
+
this.saveSettings();
|
|
687
|
+
console.log(`Updated "${editName}".`);
|
|
688
|
+
} catch (e) {
|
|
689
|
+
if (e instanceof UserCancelledError) return;
|
|
690
|
+
throw e;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}, {
|
|
694
|
+
title: "Delete entry",
|
|
695
|
+
action: async () => {
|
|
696
|
+
const entries = Object.keys(this.addressbook);
|
|
697
|
+
if (entries.length === 0) {
|
|
698
|
+
console.log("Addressbook is empty.");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const delSelected = await selectCancellable({
|
|
702
|
+
message: "Select entry to delete:",
|
|
703
|
+
choices: entries.map(n => ({
|
|
704
|
+
name: n,
|
|
705
|
+
value: n
|
|
706
|
+
}))
|
|
707
|
+
});
|
|
708
|
+
if (delSelected === null) return;
|
|
709
|
+
const delName = delSelected;
|
|
710
|
+
const confirmed = await confirmCancellable({
|
|
711
|
+
message: `Delete "${delName}"?`,
|
|
712
|
+
default: false
|
|
713
|
+
});
|
|
714
|
+
if (!confirmed) return;
|
|
715
|
+
delete this.addressbook[delName];
|
|
716
|
+
this.saveSettings();
|
|
717
|
+
console.log(`Deleted "${delName}".`);
|
|
718
|
+
}
|
|
719
|
+
}]
|
|
720
|
+
}]
|
|
721
|
+
};
|
|
722
|
+
}
|
|
376
723
|
async showMenu(menu) {
|
|
377
|
-
return this.
|
|
724
|
+
return this._searchMenu(menu);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --- Flat search-based top-level menu ---
|
|
728
|
+
|
|
729
|
+
_flattenLeaves(menu, prefix, beforeShowChain, isRoot) {
|
|
730
|
+
// Root's title is omitted from breadcrumbs (no "Main Menu › " prefix on
|
|
731
|
+
// every leaf). Root's beforeShow is fired by the search loop, not as part
|
|
732
|
+
// of the leaf chain (otherwise it would fire twice).
|
|
733
|
+
const chain = !isRoot && menu.beforeShow ? [...beforeShowChain, menu.beforeShow] : beforeShowChain;
|
|
734
|
+
const breadcrumbPrefix = isRoot ? prefix : [...prefix, menu.title];
|
|
735
|
+
const leaves = [];
|
|
736
|
+
for (const item of menu.items) {
|
|
737
|
+
if ("action" in item) {
|
|
738
|
+
leaves.push({
|
|
739
|
+
breadcrumb: [...breadcrumbPrefix, item.title].join(" › "),
|
|
740
|
+
leafTitle: item.title,
|
|
741
|
+
action: item.action,
|
|
742
|
+
beforeShowChain: chain
|
|
743
|
+
});
|
|
744
|
+
} else {
|
|
745
|
+
leaves.push(...this._flattenLeaves(item, breadcrumbPrefix, chain, false));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return leaves;
|
|
749
|
+
}
|
|
750
|
+
static _scoreLeaf(leaf, tokens) {
|
|
751
|
+
if (tokens.length === 0) return 1;
|
|
752
|
+
const crumbLower = leaf.breadcrumb.toLowerCase();
|
|
753
|
+
const titleLower = leaf.leafTitle.toLowerCase();
|
|
754
|
+
let score = 0;
|
|
755
|
+
for (const t of tokens) {
|
|
756
|
+
if (!crumbLower.includes(t)) return 0; // require every token
|
|
757
|
+
if (titleLower.includes(t)) {
|
|
758
|
+
score += 3; // matches in the leaf title outrank ancestor matches
|
|
759
|
+
} else {
|
|
760
|
+
score += 1;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return score;
|
|
764
|
+
}
|
|
765
|
+
_filterLeaves(leaves, term) {
|
|
766
|
+
const trimmed = term.trim();
|
|
767
|
+
if (!trimmed) return leaves;
|
|
768
|
+
const tokens = trimmed.toLowerCase().split(/\s+/);
|
|
769
|
+
return leaves.map(leaf => ({
|
|
770
|
+
leaf,
|
|
771
|
+
score: Sprinkle._scoreLeaf(leaf, tokens)
|
|
772
|
+
})).filter(x => x.score > 0).sort((a, b) => b.score - a.score).map(x => x.leaf);
|
|
773
|
+
}
|
|
774
|
+
async _searchMenu(menu) {
|
|
775
|
+
const EXIT_TITLE = "Exit";
|
|
776
|
+
while (true) {
|
|
777
|
+
// Root beforeShow fires once per iteration so consumers can refresh any
|
|
778
|
+
// dynamic state before the search prompt is built.
|
|
779
|
+
if (menu.beforeShow) {
|
|
780
|
+
await menu.beforeShow(this);
|
|
781
|
+
}
|
|
782
|
+
const userLeaves = this._flattenLeaves(menu, [], [], true);
|
|
783
|
+
const settingsLeaves = this._flattenLeaves(this.buildSettingsMenu(), [], [], false);
|
|
784
|
+
const exitLeaf = {
|
|
785
|
+
breadcrumb: EXIT_TITLE,
|
|
786
|
+
leafTitle: EXIT_TITLE,
|
|
787
|
+
action: async () => {},
|
|
788
|
+
beforeShowChain: [],
|
|
789
|
+
isExit: true
|
|
790
|
+
};
|
|
791
|
+
const allLeaves = [...userLeaves, ...settingsLeaves, exitLeaf];
|
|
792
|
+
const selected = await searchCancellable({
|
|
793
|
+
message: "What would you like to do?",
|
|
794
|
+
source: async term => {
|
|
795
|
+
return this._filterLeaves(allLeaves, term ?? "").map(leaf => ({
|
|
796
|
+
name: leaf.breadcrumb,
|
|
797
|
+
value: leaf
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
if (selected === null || selected.isExit) return;
|
|
802
|
+
|
|
803
|
+
// Fire submenu beforeShow hooks along the path to the selected leaf
|
|
804
|
+
for (const beforeShow of selected.beforeShowChain) {
|
|
805
|
+
await beforeShow(this);
|
|
806
|
+
}
|
|
807
|
+
const result = await selected.action(this);
|
|
808
|
+
if (result instanceof Sprinkle) {
|
|
809
|
+
this.settings = result.settings;
|
|
810
|
+
this.saveSettings();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
378
813
|
}
|
|
379
814
|
async _showMenu(menu, main, path, clearPrevious = false) {
|
|
380
815
|
// Clear previous breadcrumb if coming back from action/submenu
|
|
@@ -483,181 +918,7 @@ export class Sprinkle {
|
|
|
483
918
|
return;
|
|
484
919
|
}
|
|
485
920
|
if (selection === -5) {
|
|
486
|
-
|
|
487
|
-
title: "Settings & Profiles",
|
|
488
|
-
items: [{
|
|
489
|
-
title: "View settings",
|
|
490
|
-
action: async () => {
|
|
491
|
-
const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
|
|
492
|
-
const jsonLines = jsonStr.split("\n").length;
|
|
493
|
-
console.log(jsonStr);
|
|
494
|
-
|
|
495
|
-
// Wait for user to press Enter
|
|
496
|
-
await selectWithClear({
|
|
497
|
-
message: "Press Enter to continue...",
|
|
498
|
-
choices: [{
|
|
499
|
-
name: "Continue",
|
|
500
|
-
value: "continue"
|
|
501
|
-
}]
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
// Clear the JSON output
|
|
505
|
-
process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
|
|
506
|
-
}
|
|
507
|
-
}, {
|
|
508
|
-
title: "Edit settings",
|
|
509
|
-
action: async () => {
|
|
510
|
-
try {
|
|
511
|
-
this.settings = await this.EditStruct(this.type, this.settings);
|
|
512
|
-
this.saveSettings();
|
|
513
|
-
} catch (e) {
|
|
514
|
-
if (e instanceof UserCancelledError) {
|
|
515
|
-
return; // User cancelled, return to menu
|
|
516
|
-
}
|
|
517
|
-
throw e;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}, {
|
|
521
|
-
title: "Switch profile",
|
|
522
|
-
action: async () => {
|
|
523
|
-
this.saveSettings();
|
|
524
|
-
const profiles = this.scanProfiles();
|
|
525
|
-
if (profiles.length <= 1) {
|
|
526
|
-
console.log("No other profiles to switch to. Create a new one first.");
|
|
527
|
-
} else {
|
|
528
|
-
await this.selectProfile(profiles);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}, {
|
|
532
|
-
title: "Create new profile",
|
|
533
|
-
action: async () => {
|
|
534
|
-
await this.createProfile();
|
|
535
|
-
}
|
|
536
|
-
}, {
|
|
537
|
-
title: "Duplicate current profile",
|
|
538
|
-
action: async () => {
|
|
539
|
-
await this.duplicateProfile();
|
|
540
|
-
}
|
|
541
|
-
}, {
|
|
542
|
-
title: "Rename current profile",
|
|
543
|
-
action: async () => {
|
|
544
|
-
await this.renameProfile();
|
|
545
|
-
}
|
|
546
|
-
}, {
|
|
547
|
-
title: "Delete a profile",
|
|
548
|
-
action: async () => {
|
|
549
|
-
await this.deleteProfile();
|
|
550
|
-
}
|
|
551
|
-
}, {
|
|
552
|
-
title: "Addressbook",
|
|
553
|
-
items: [{
|
|
554
|
-
title: "View entries",
|
|
555
|
-
action: async () => {
|
|
556
|
-
const entries = Object.entries(this.addressbook);
|
|
557
|
-
if (entries.length === 0) {
|
|
558
|
-
console.log("Addressbook is empty.");
|
|
559
|
-
} else {
|
|
560
|
-
for (const [name, ms] of entries) {
|
|
561
|
-
const json = JSON.stringify(ms, bigIntReplacer, 2);
|
|
562
|
-
let hashStr = "unknown";
|
|
563
|
-
try {
|
|
564
|
-
hashStr = toNativeScript(ms).hash();
|
|
565
|
-
} catch {/* skip */}
|
|
566
|
-
console.log(colors.bold(name) + " " + colors.dim(hashStr));
|
|
567
|
-
console.log(json);
|
|
568
|
-
console.log();
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
await selectWithClear({
|
|
572
|
-
message: "Press Enter to continue...",
|
|
573
|
-
choices: [{
|
|
574
|
-
name: "Continue",
|
|
575
|
-
value: "continue"
|
|
576
|
-
}]
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
}, {
|
|
580
|
-
title: "Add entry",
|
|
581
|
-
action: async () => {
|
|
582
|
-
const name = await inputCancellable({
|
|
583
|
-
message: "Entry name:",
|
|
584
|
-
validate: v => v.trim().length > 0 ? true : "Name cannot be empty"
|
|
585
|
-
});
|
|
586
|
-
if (name === null) return;
|
|
587
|
-
if (this.addressbook[name]) {
|
|
588
|
-
const overwrite = await confirmCancellable({
|
|
589
|
-
message: `Entry "${name}" already exists. Overwrite?`,
|
|
590
|
-
default: false
|
|
591
|
-
});
|
|
592
|
-
if (!overwrite) return;
|
|
593
|
-
}
|
|
594
|
-
try {
|
|
595
|
-
const script = await this.FillInStruct(MultisigScriptSchema);
|
|
596
|
-
this.addressbook[name] = script;
|
|
597
|
-
this.saveSettings();
|
|
598
|
-
console.log(`Added "${name}" to addressbook.`);
|
|
599
|
-
} catch (e) {
|
|
600
|
-
if (e instanceof UserCancelledError) return;
|
|
601
|
-
throw e;
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}, {
|
|
605
|
-
title: "Edit entry",
|
|
606
|
-
action: async () => {
|
|
607
|
-
const entries = Object.keys(this.addressbook);
|
|
608
|
-
if (entries.length === 0) {
|
|
609
|
-
console.log("Addressbook is empty.");
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
const selected = await selectCancellable({
|
|
613
|
-
message: "Select entry to edit:",
|
|
614
|
-
choices: entries.map(n => ({
|
|
615
|
-
name: n,
|
|
616
|
-
value: n
|
|
617
|
-
}))
|
|
618
|
-
});
|
|
619
|
-
if (selected === null) return;
|
|
620
|
-
const editName = selected;
|
|
621
|
-
try {
|
|
622
|
-
const updated = await this.EditStruct(MultisigScriptSchema, this.addressbook[editName]);
|
|
623
|
-
this.addressbook[editName] = updated;
|
|
624
|
-
this.saveSettings();
|
|
625
|
-
console.log(`Updated "${editName}".`);
|
|
626
|
-
} catch (e) {
|
|
627
|
-
if (e instanceof UserCancelledError) return;
|
|
628
|
-
throw e;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}, {
|
|
632
|
-
title: "Delete entry",
|
|
633
|
-
action: async () => {
|
|
634
|
-
const entries = Object.keys(this.addressbook);
|
|
635
|
-
if (entries.length === 0) {
|
|
636
|
-
console.log("Addressbook is empty.");
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
const delSelected = await selectCancellable({
|
|
640
|
-
message: "Select entry to delete:",
|
|
641
|
-
choices: entries.map(n => ({
|
|
642
|
-
name: n,
|
|
643
|
-
value: n
|
|
644
|
-
}))
|
|
645
|
-
});
|
|
646
|
-
if (delSelected === null) return;
|
|
647
|
-
const delName = delSelected;
|
|
648
|
-
const confirmed = await confirmCancellable({
|
|
649
|
-
message: `Delete "${delName}"?`,
|
|
650
|
-
default: false
|
|
651
|
-
});
|
|
652
|
-
if (!confirmed) return;
|
|
653
|
-
delete this.addressbook[delName];
|
|
654
|
-
this.saveSettings();
|
|
655
|
-
console.log(`Deleted "${delName}".`);
|
|
656
|
-
}
|
|
657
|
-
}]
|
|
658
|
-
}]
|
|
659
|
-
};
|
|
660
|
-
await this._showMenu(settingsMenu, false, [...path, "Settings & Profiles"], true);
|
|
921
|
+
await this._showMenu(this.buildSettingsMenu(), false, [...path, "Settings & Profiles"], true);
|
|
661
922
|
await this._showMenu(menu, main, path, true);
|
|
662
923
|
return;
|
|
663
924
|
}
|
|
@@ -699,6 +960,37 @@ export class Sprinkle {
|
|
|
699
960
|
return GetBlazeFn(network, providerSettings, walletSettings);
|
|
700
961
|
}
|
|
701
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Derive a Bip32 private key hex from a BIP39 mnemonic phrase.
|
|
965
|
+
* Accepts 12, 15, or 24 word phrases.
|
|
966
|
+
*/
|
|
967
|
+
static privateKeyFromMnemonic(phrase) {
|
|
968
|
+
const normalized = phrase.trim().toLowerCase().split(/\s+/).join(" ");
|
|
969
|
+
const words = normalized.split(" ");
|
|
970
|
+
if (![12, 15, 24].includes(words.length)) {
|
|
971
|
+
throw new Error(`Expected 12, 15, or 24 words but got ${words.length}.`);
|
|
972
|
+
}
|
|
973
|
+
const entropy = Core.mnemonicToEntropy(normalized, wordlist);
|
|
974
|
+
const masterKey = Core.Bip32PrivateKey.fromBip39Entropy(Buffer.from(entropy), "");
|
|
975
|
+
return masterKey.hex();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Prompt the user for a recovery phrase and derive the Bip32 private key hex.
|
|
980
|
+
*/
|
|
981
|
+
static async importWalletFromMnemonic() {
|
|
982
|
+
const phrase = await passwordCancellable({
|
|
983
|
+
message: "Enter your recovery phrase (12, 15, or 24 words):"
|
|
984
|
+
});
|
|
985
|
+
if (phrase === null) {
|
|
986
|
+
throw new UserCancelledError();
|
|
987
|
+
}
|
|
988
|
+
if (phrase.trim().length === 0) {
|
|
989
|
+
throw new Error("Recovery phrase cannot be empty");
|
|
990
|
+
}
|
|
991
|
+
return Sprinkle.privateKeyFromMnemonic(phrase);
|
|
992
|
+
}
|
|
993
|
+
|
|
702
994
|
/**
|
|
703
995
|
* Generates a new wallet from a BIP39 mnemonic phrase.
|
|
704
996
|
* Displays the 24-word recovery phrase and requires user confirmation.
|
|
@@ -1250,6 +1542,9 @@ export class Sprinkle {
|
|
|
1250
1542
|
// if the literal field values in the selected variant match those in `def`.
|
|
1251
1543
|
// For non-discriminated unions, fall back to structural matching with Value.Check.
|
|
1252
1544
|
let matchedDef = undefined;
|
|
1545
|
+
// Build references array for Value.Check — schemas with $id from defs
|
|
1546
|
+
// so TypeBox can resolve $ref inside variant schemas (e.g. MultisigScript).
|
|
1547
|
+
const references = Object.values(defs).filter(s => s.$id);
|
|
1253
1548
|
if (def !== undefined) {
|
|
1254
1549
|
if (isObject(selection)) {
|
|
1255
1550
|
// Check if all literal fields in the selected variant match def
|
|
@@ -1262,13 +1557,13 @@ export class Sprinkle {
|
|
|
1262
1557
|
}
|
|
1263
1558
|
} else {
|
|
1264
1559
|
// No literal discriminators - use structural check
|
|
1265
|
-
if (Value.Check(selection, def)) {
|
|
1560
|
+
if (Value.Check(selection, references, def)) {
|
|
1266
1561
|
matchedDef = def;
|
|
1267
1562
|
}
|
|
1268
1563
|
}
|
|
1269
1564
|
} else {
|
|
1270
1565
|
// Non-object variant - use structural check
|
|
1271
|
-
if (Value.Check(selection, def)) {
|
|
1566
|
+
if (Value.Check(selection, references, def)) {
|
|
1272
1567
|
matchedDef = def;
|
|
1273
1568
|
}
|
|
1274
1569
|
}
|
|
@@ -1276,13 +1571,16 @@ export class Sprinkle {
|
|
|
1276
1571
|
return this._fillInStruct(selection, path, defs, matchedDef);
|
|
1277
1572
|
}
|
|
1278
1573
|
if (isString(type)) {
|
|
1279
|
-
// Special handling for hot wallet private key - offer
|
|
1574
|
+
// Special handling for hot wallet private key - offer multiple sources
|
|
1280
1575
|
if (type.title === "Hot Wallet Private Key") {
|
|
1281
1576
|
const choice = await selectWithClear({
|
|
1282
1577
|
message: "Hot wallet setup:",
|
|
1283
1578
|
choices: [{
|
|
1284
1579
|
name: "Enter existing private key",
|
|
1285
1580
|
value: "existing"
|
|
1581
|
+
}, {
|
|
1582
|
+
name: "Import from recovery phrase",
|
|
1583
|
+
value: "mnemonic"
|
|
1286
1584
|
}, {
|
|
1287
1585
|
name: "Generate new wallet",
|
|
1288
1586
|
value: "generate"
|
|
@@ -1294,6 +1592,9 @@ export class Sprinkle {
|
|
|
1294
1592
|
if (choice === "generate") {
|
|
1295
1593
|
return Sprinkle.generateWalletFromMnemonic();
|
|
1296
1594
|
}
|
|
1595
|
+
if (choice === "mnemonic") {
|
|
1596
|
+
return Sprinkle.importWalletFromMnemonic();
|
|
1597
|
+
}
|
|
1297
1598
|
// Fall through to password prompt for "existing" choice
|
|
1298
1599
|
const answer = await passwordWithClear({
|
|
1299
1600
|
message: "Enter your private key:"
|