@sundaeswap/sprinkles 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/cjs/Sprinkle/__tests__/encryption.test.js +20 -8
  2. package/dist/cjs/Sprinkle/__tests__/encryption.test.js.map +1 -1
  3. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +41 -16
  4. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  5. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +85 -38
  6. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  7. package/dist/cjs/Sprinkle/__tests__/settings-persistence.test.js +120 -0
  8. package/dist/cjs/Sprinkle/__tests__/settings-persistence.test.js.map +1 -1
  9. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +93 -7
  10. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  11. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js +21 -0
  12. package/dist/cjs/Sprinkle/__tests__/tx-dialog.test.js.map +1 -1
  13. package/dist/cjs/Sprinkle/encryption.js +131 -0
  14. package/dist/cjs/Sprinkle/encryption.js.map +1 -0
  15. package/dist/cjs/Sprinkle/index.js +318 -352
  16. package/dist/cjs/Sprinkle/index.js.map +1 -1
  17. package/dist/cjs/Sprinkle/prompts.js +393 -0
  18. package/dist/cjs/Sprinkle/prompts.js.map +1 -0
  19. package/dist/cjs/Sprinkle/schemas.js +97 -0
  20. package/dist/cjs/Sprinkle/schemas.js.map +1 -0
  21. package/dist/cjs/Sprinkle/tx-dialog.js +101 -0
  22. package/dist/cjs/Sprinkle/tx-dialog.js.map +1 -0
  23. package/dist/cjs/Sprinkle/type-guards.js +42 -0
  24. package/dist/cjs/Sprinkle/type-guards.js.map +1 -0
  25. package/dist/cjs/Sprinkle/types.js +49 -0
  26. package/dist/cjs/Sprinkle/types.js.map +1 -0
  27. package/dist/cjs/Sprinkle/wallet.js +98 -0
  28. package/dist/cjs/Sprinkle/wallet.js.map +1 -0
  29. package/dist/esm/Sprinkle/__tests__/encryption.test.js +20 -8
  30. package/dist/esm/Sprinkle/__tests__/encryption.test.js.map +1 -1
  31. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +41 -16
  32. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  33. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +85 -38
  34. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  35. package/dist/esm/Sprinkle/__tests__/settings-persistence.test.js +120 -0
  36. package/dist/esm/Sprinkle/__tests__/settings-persistence.test.js.map +1 -1
  37. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +94 -8
  38. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  39. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js +21 -0
  40. package/dist/esm/Sprinkle/__tests__/tx-dialog.test.js.map +1 -1
  41. package/dist/esm/Sprinkle/encryption.js +117 -0
  42. package/dist/esm/Sprinkle/encryption.js.map +1 -0
  43. package/dist/esm/Sprinkle/index.js +172 -337
  44. package/dist/esm/Sprinkle/index.js.map +1 -1
  45. package/dist/esm/Sprinkle/prompts.js +385 -0
  46. package/dist/esm/Sprinkle/prompts.js.map +1 -0
  47. package/dist/esm/Sprinkle/schemas.js +91 -0
  48. package/dist/esm/Sprinkle/schemas.js.map +1 -0
  49. package/dist/esm/Sprinkle/tx-dialog.js +90 -0
  50. package/dist/esm/Sprinkle/tx-dialog.js.map +1 -0
  51. package/dist/esm/Sprinkle/type-guards.js +24 -0
  52. package/dist/esm/Sprinkle/type-guards.js.map +1 -0
  53. package/dist/esm/Sprinkle/types.js +42 -0
  54. package/dist/esm/Sprinkle/types.js.map +1 -0
  55. package/dist/esm/Sprinkle/wallet.js +90 -0
  56. package/dist/esm/Sprinkle/wallet.js.map +1 -0
  57. package/dist/types/Sprinkle/encryption.d.ts +43 -0
  58. package/dist/types/Sprinkle/encryption.d.ts.map +1 -0
  59. package/dist/types/Sprinkle/index.d.ts +13 -174
  60. package/dist/types/Sprinkle/index.d.ts.map +1 -1
  61. package/dist/types/Sprinkle/prompts.d.ts +94 -0
  62. package/dist/types/Sprinkle/prompts.d.ts.map +1 -0
  63. package/dist/types/Sprinkle/schemas.d.ts +125 -0
  64. package/dist/types/Sprinkle/schemas.d.ts.map +1 -0
  65. package/dist/types/Sprinkle/tx-dialog.d.ts +37 -0
  66. package/dist/types/Sprinkle/tx-dialog.d.ts.map +1 -0
  67. package/dist/types/Sprinkle/type-guards.d.ts +22 -0
  68. package/dist/types/Sprinkle/type-guards.d.ts.map +1 -0
  69. package/dist/types/Sprinkle/types.d.ts +62 -0
  70. package/dist/types/Sprinkle/types.d.ts.map +1 -0
  71. package/dist/types/Sprinkle/wallet.d.ts +27 -0
  72. package/dist/types/Sprinkle/wallet.d.ts.map +1 -0
  73. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  74. package/package.json +1 -1
  75. package/src/Sprinkle/__tests__/encryption.test.ts +21 -8
  76. package/src/Sprinkle/__tests__/enhancements.test.ts +41 -15
  77. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +104 -38
  78. package/src/Sprinkle/__tests__/settings-persistence.test.ts +108 -0
  79. package/src/Sprinkle/__tests__/show-menu.test.ts +96 -8
  80. package/src/Sprinkle/__tests__/tx-dialog.test.ts +21 -0
  81. package/src/Sprinkle/encryption.ts +130 -0
  82. package/src/Sprinkle/index.ts +265 -478
  83. package/src/Sprinkle/prompts.ts +481 -0
  84. package/src/Sprinkle/schemas.ts +111 -0
  85. package/src/Sprinkle/tx-dialog.ts +100 -0
  86. package/src/Sprinkle/type-guards.ts +51 -0
  87. package/src/Sprinkle/types.ts +73 -0
  88. package/src/Sprinkle/wallet.ts +133 -0
@@ -1,15 +1,24 @@
1
- import { describe, expect, test, mock, beforeEach } from "bun:test";
1
+ import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
2
2
  import { Sprinkle, Type, type IMenu } from "../index.js";
3
3
  import { withProfile } from "./test-helpers.js";
4
4
 
5
5
  const mockSelect = mock();
6
6
  const mockInput = mock();
7
+ const mockSelectCancellable = mock();
8
+ const mockInputCancellable = mock();
7
9
 
8
10
  mock.module("@inquirer/prompts", () => ({
9
11
  select: mockSelect,
10
12
  input: mockInput,
11
13
  }));
12
14
 
15
+ mock.module("../prompts.js", () => ({
16
+ selectCancellable: mockSelectCancellable,
17
+ inputCancellable: mockInputCancellable,
18
+ passwordCancellable: mock(),
19
+ confirmCancellable: mock(),
20
+ }));
21
+
13
22
  describe("showMenu", () => {
14
23
  let sprinkle: Sprinkle<any>;
15
24
 
@@ -18,11 +27,12 @@ describe("showMenu", () => {
18
27
  sprinkle = withProfile(new Sprinkle(schema, "/tmp/test"));
19
28
  sprinkle.settings = { name: "test" } as any;
20
29
  mockSelect.mockClear();
30
+ mockSelectCancellable.mockClear();
21
31
  mockInput.mockClear();
22
32
  });
23
33
 
24
34
  test("exits when Exit is selected on main menu", async () => {
25
- mockSelect.mockResolvedValueOnce(-1);
35
+ mockSelectCancellable.mockResolvedValueOnce(-1);
26
36
 
27
37
  const menu: IMenu<any> = {
28
38
  title: "Test Menu",
@@ -40,7 +50,7 @@ describe("showMenu", () => {
40
50
  test("executes action and re-shows menu", async () => {
41
51
  const actionFn = mock(async () => {});
42
52
 
43
- mockSelect.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
53
+ mockSelectCancellable.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
44
54
 
45
55
  const menu: IMenu<any> = {
46
56
  title: "Test Menu",
@@ -57,7 +67,7 @@ describe("showMenu", () => {
57
67
  });
58
68
 
59
69
  test("main menu includes Settings & Profiles submenu and Exit", async () => {
60
- mockSelect.mockResolvedValueOnce(-1);
70
+ mockSelectCancellable.mockResolvedValueOnce(-1);
61
71
 
62
72
  const menu: IMenu<any> = {
63
73
  title: "Test",
@@ -66,7 +76,7 @@ describe("showMenu", () => {
66
76
 
67
77
  await sprinkle.showMenu(menu);
68
78
 
69
- const choices = mockSelect.mock.calls[0][0].choices;
79
+ const choices = mockSelectCancellable.mock.calls[0][0].choices;
70
80
  const names = choices.map((c: any) => c.name);
71
81
  expect(names).toContain("Settings & Profiles");
72
82
  expect(names).toContain("Exit");
@@ -76,7 +86,7 @@ describe("showMenu", () => {
76
86
  });
77
87
 
78
88
  test("submenu includes Back instead of Exit", async () => {
79
- mockSelect
89
+ mockSelectCancellable
80
90
  .mockResolvedValueOnce(0)
81
91
  .mockResolvedValueOnce(-1)
82
92
  .mockResolvedValueOnce(-1);
@@ -93,7 +103,7 @@ describe("showMenu", () => {
93
103
 
94
104
  await sprinkle.showMenu(menu);
95
105
 
96
- const subChoices = mockSelect.mock.calls[1][0].choices;
106
+ const subChoices = mockSelectCancellable.mock.calls[1][0].choices;
97
107
  const subNames = subChoices.map((c: any) => c.name);
98
108
  expect(subNames).toContain("Back");
99
109
  expect(subNames).not.toContain("Exit");
@@ -102,7 +112,7 @@ describe("showMenu", () => {
102
112
  test("action returning sprinkle instance saves settings", async () => {
103
113
  sprinkle.settings = { name: "original" } as any;
104
114
 
105
- mockSelect.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
115
+ mockSelectCancellable.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
106
116
 
107
117
  const menu: IMenu<any> = {
108
118
  title: "Test",
@@ -120,4 +130,82 @@ describe("showMenu", () => {
120
130
  await sprinkle.showMenu(menu);
121
131
  expect(sprinkle.settings).toEqual({ name: "updated" });
122
132
  });
133
+
134
+ test("Settings submenu includes View settings option", async () => {
135
+ // Select "Settings & Profiles" (-5), then "Back" (-1), then "Exit" (-1)
136
+ mockSelectCancellable
137
+ .mockResolvedValueOnce(-5) // Settings & Profiles
138
+ .mockResolvedValueOnce(-1) // Back
139
+ .mockResolvedValueOnce(-1); // Exit
140
+
141
+ const menu: IMenu<any> = {
142
+ title: "Test",
143
+ items: [{ title: "Action", action: async () => {} }],
144
+ };
145
+
146
+ await sprinkle.showMenu(menu);
147
+
148
+ // Second call is the settings submenu
149
+ const settingsChoices = mockSelectCancellable.mock.calls[1][0].choices;
150
+ const settingsNames = settingsChoices.map((c: any) => c.name);
151
+ expect(settingsNames).toContain("View settings");
152
+ });
153
+
154
+ test("View settings displays masked settings", async () => {
155
+ const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
156
+
157
+ // Select "Settings & Profiles" (-5), then "View settings" (0), then "Back" (-1), then "Exit" (-1)
158
+ mockSelectCancellable
159
+ .mockResolvedValueOnce(-5) // Settings & Profiles
160
+ .mockResolvedValueOnce(0) // View settings (first item)
161
+ .mockResolvedValueOnce(-1) // Back
162
+ .mockResolvedValueOnce(-1); // Exit
163
+
164
+ const menu: IMenu<any> = {
165
+ title: "Test",
166
+ items: [{ title: "Action", action: async () => {} }],
167
+ };
168
+
169
+ await sprinkle.showMenu(menu);
170
+
171
+ // Should have called console.log with the settings
172
+ expect(consoleSpy).toHaveBeenCalled();
173
+ const output = consoleSpy.mock.calls[0][0];
174
+ expect(output).toContain("name");
175
+
176
+ consoleSpy.mockRestore();
177
+ });
178
+
179
+ test("Create profile restores state when FillInStruct is cancelled", async () => {
180
+ // Capture original state
181
+ const originalProfileId = sprinkle.currentProfile?.id;
182
+ const originalSettings = { ...sprinkle.settings };
183
+
184
+ // Settings menu indices: 0=View, 1=Edit, 2=Switch, 3=Create new profile
185
+ mockSelectCancellable
186
+ .mockResolvedValueOnce(-5) // Settings & Profiles
187
+ .mockResolvedValueOnce(3); // Create new profile
188
+
189
+ // promptProfileMeta: name then description
190
+ mockInputCancellable
191
+ .mockResolvedValueOnce("New Profile") // Profile name
192
+ .mockResolvedValueOnce("") // Profile description
193
+ .mockResolvedValueOnce(null); // Cancel during FillInStruct (name field)
194
+
195
+ // After cancellation, back out of menus
196
+ mockSelectCancellable
197
+ .mockResolvedValueOnce(-1) // Back from settings
198
+ .mockResolvedValueOnce(-1); // Exit
199
+
200
+ const menu: IMenu<any> = {
201
+ title: "Test",
202
+ items: [{ title: "Action", action: async () => {} }],
203
+ };
204
+
205
+ await sprinkle.showMenu(menu);
206
+
207
+ // State should be restored to original
208
+ expect(sprinkle.currentProfile?.id).toBe(originalProfileId);
209
+ expect(sprinkle.settings).toEqual(originalSettings);
210
+ });
123
211
  });
@@ -29,6 +29,27 @@ mock.module("@inquirer/prompts", () => ({
29
29
  search: mock(async () => "result"),
30
30
  }));
31
31
 
32
+ // Mock cancellable prompts (used by TxDialog and other methods)
33
+ mock.module("../prompts.js", () => ({
34
+ selectCancellable: mock(async (opts: any) => {
35
+ selectCalls.push(opts);
36
+ const response = selectResponses.shift();
37
+ if (response === undefined) {
38
+ return "cancel";
39
+ }
40
+ return response;
41
+ }),
42
+ inputCancellable: mock(async () => {
43
+ const response = inputResponses.shift();
44
+ return response ?? "";
45
+ }),
46
+ passwordCancellable: mock(async () => "secret"),
47
+ confirmCancellable: mock(async () => {
48
+ const response = confirmResponses.shift();
49
+ return response ?? false;
50
+ }),
51
+ }));
52
+
32
53
  // Mock clipboardy
33
54
  let clipboardContent = "";
34
55
  let clipboardShouldFail = false;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Encryption utilities for sensitive field handling.
3
+ * Functions for encrypting, decrypting, and masking sensitive data in settings.
4
+ */
5
+
6
+ import type { TSchema } from "@sinclair/typebox";
7
+ import { isObject, isUnion, isSensitive } from "./type-guards.js";
8
+ import type { IEncryptionOptions } from "./types.js";
9
+
10
+ /**
11
+ * Recursively collect paths to all sensitive fields in a schema.
12
+ */
13
+ export function collectSensitivePaths(
14
+ type: TSchema,
15
+ prefix: string = "",
16
+ ): string[] {
17
+ const paths: string[] = [];
18
+ if (isObject(type)) {
19
+ const fields = type["properties"] as Record<string, TSchema>;
20
+ for (const [field, fieldType] of Object.entries(fields)) {
21
+ const fieldPath = prefix ? `${prefix}.${field}` : field;
22
+ if (isSensitive(fieldType)) {
23
+ paths.push(fieldPath);
24
+ }
25
+ paths.push(...collectSensitivePaths(fieldType, fieldPath));
26
+ }
27
+ }
28
+ if (isUnion(type)) {
29
+ for (const variant of type.anyOf) {
30
+ paths.push(...collectSensitivePaths(variant, prefix));
31
+ }
32
+ }
33
+ return paths;
34
+ }
35
+
36
+ /**
37
+ * Get a nested value from an object by dot-separated path.
38
+ */
39
+ export function getNestedValue(obj: any, path: string): unknown {
40
+ return path.split(".").reduce((o, k) => o?.[k], obj);
41
+ }
42
+
43
+ /**
44
+ * Set a nested value in an object by dot-separated path.
45
+ */
46
+ export function setNestedValue(obj: any, path: string, value: unknown): void {
47
+ const keys = path.split(".");
48
+ const last = keys.pop()!;
49
+ const parent = keys.reduce((o, k) => o?.[k], obj);
50
+ if (parent && typeof parent === "object") {
51
+ parent[last] = value;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * JSON replacer that converts BigInt to string with 'n' suffix.
57
+ */
58
+ export function bigIntReplacer(_key: string, value: unknown): unknown {
59
+ return typeof value === "bigint" ? `${value.toString()}n` : value;
60
+ }
61
+
62
+ /**
63
+ * JSON reviver that converts strings with 'n' suffix back to BigInt.
64
+ */
65
+ export function bigIntReviver(_key: string, value: unknown): unknown {
66
+ if (typeof value === "string" && /^\d+n$/.test(value)) {
67
+ return BigInt(value.slice(0, -1));
68
+ }
69
+ return value;
70
+ }
71
+
72
+ /**
73
+ * Deep clone an object, preserving BigInt values.
74
+ */
75
+ export function cloneWithBigInt<T>(obj: T): T {
76
+ return JSON.parse(JSON.stringify(obj, bigIntReplacer), bigIntReviver);
77
+ }
78
+
79
+ /**
80
+ * Encrypt all sensitive fields in a settings object.
81
+ */
82
+ export function encryptSensitiveFields<T>(
83
+ settings: T,
84
+ type: TSchema,
85
+ encryption: IEncryptionOptions,
86
+ ): T {
87
+ const clone = cloneWithBigInt(settings);
88
+ const sensitivePaths = collectSensitivePaths(type);
89
+ for (const p of sensitivePaths) {
90
+ const value = getNestedValue(clone, p);
91
+ if (typeof value === "string" && value.length > 0) {
92
+ setNestedValue(clone, p, encryption.encrypt(value));
93
+ }
94
+ }
95
+ return clone;
96
+ }
97
+
98
+ /**
99
+ * Decrypt all sensitive fields in a settings object.
100
+ */
101
+ export async function decryptSensitiveFields<T>(
102
+ settings: T,
103
+ type: TSchema,
104
+ encryption: IEncryptionOptions,
105
+ ): Promise<T> {
106
+ const clone = cloneWithBigInt(settings);
107
+ const sensitivePaths = collectSensitivePaths(type);
108
+ for (const p of sensitivePaths) {
109
+ const value = getNestedValue(clone, p);
110
+ if (typeof value === "string" && value.length > 0) {
111
+ setNestedValue(clone, p, await encryption.decrypt(value));
112
+ }
113
+ }
114
+ return clone;
115
+ }
116
+
117
+ /**
118
+ * Mask all sensitive fields in a settings object for display.
119
+ */
120
+ export function maskSensitiveFields<T>(settings: T, type: TSchema): T {
121
+ const clone = cloneWithBigInt(settings);
122
+ const sensitivePaths = collectSensitivePaths(type);
123
+ for (const p of sensitivePaths) {
124
+ const value = getNestedValue(clone, p);
125
+ if (typeof value === "string" && value.length > 0) {
126
+ setNestedValue(clone, p, "********");
127
+ }
128
+ }
129
+ return clone;
130
+ }