@sundaeswap/sprinkles 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +19 -7
  2. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  3. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +3 -0
  4. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  5. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +109 -98
  6. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  7. package/dist/cjs/Sprinkle/index.js +474 -177
  8. package/dist/cjs/Sprinkle/index.js.map +1 -1
  9. package/dist/cjs/Sprinkle/types.js.map +1 -1
  10. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +19 -7
  11. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  12. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +3 -0
  13. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  14. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +110 -99
  15. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  16. package/dist/esm/Sprinkle/index.js +477 -180
  17. package/dist/esm/Sprinkle/index.js.map +1 -1
  18. package/dist/esm/Sprinkle/types.js.map +1 -1
  19. package/dist/types/Sprinkle/index.d.ts +29 -0
  20. package/dist/types/Sprinkle/index.d.ts.map +1 -1
  21. package/dist/types/Sprinkle/types.d.ts +6 -0
  22. package/dist/types/Sprinkle/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/Sprinkle/__tests__/enhancements.test.ts +20 -7
  26. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +1 -0
  27. package/src/Sprinkle/__tests__/show-menu.test.ts +132 -116
  28. package/src/Sprinkle/index.ts +546 -186
  29. package/src/Sprinkle/types.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sundaeswap/sprinkles",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "A TypeScript library for building interactive CLI menus and TUI applications with TypeBox schema validation",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -11,6 +11,7 @@ const mockConfirmCancellable = mock();
11
11
  const mockSearchCancellable = mock();
12
12
 
13
13
  mock.module("@inquirer/prompts", () => ({
14
+ search: mockSearchCancellable,
14
15
  select: mockSelect,
15
16
  input: mockInput,
16
17
  password: mockPassword,
@@ -28,24 +29,36 @@ mock.module("../prompts.js", () => ({
28
29
  describe("beforeShow hook (2.2)", () => {
29
30
  let sprinkle: Sprinkle<any>;
30
31
 
32
+ const pickExit = async (opts: any) => {
33
+ const choices = await opts.source(undefined);
34
+ return choices.find((c: any) => c.name === "Exit").value;
35
+ };
36
+
31
37
  beforeEach(() => {
32
38
  const schema = Type.Object({ name: Type.String() });
33
39
  sprinkle = new Sprinkle(schema, "/tmp/test");
34
40
  sprinkle.settings = { name: "test" } as any;
41
+ sprinkle.profileId = "test";
42
+ sprinkle.profileMeta = {
43
+ name: "Test",
44
+ createdAt: new Date().toISOString(),
45
+ updatedAt: new Date().toISOString(),
46
+ };
47
+ mockSearchCancellable.mockClear();
35
48
  mockSelect.mockClear();
36
49
  mockSelectCancellable.mockClear();
37
50
  });
38
51
 
39
- test("calls beforeShow before rendering menu", async () => {
52
+ test("calls root beforeShow before rendering the search prompt", async () => {
40
53
  const callOrder: string[] = [];
41
54
 
42
55
  const beforeShowFn = mock(async () => {
43
56
  callOrder.push("beforeShow");
44
57
  });
45
58
 
46
- mockSelectCancellable.mockImplementation(async () => {
47
- callOrder.push("select");
48
- return -1; // exit
59
+ mockSearchCancellable.mockImplementation(async (opts: any) => {
60
+ callOrder.push("search");
61
+ return pickExit(opts);
49
62
  });
50
63
 
51
64
  const menu: IMenu<any> = {
@@ -58,13 +71,13 @@ describe("beforeShow hook (2.2)", () => {
58
71
 
59
72
  expect(beforeShowFn).toHaveBeenCalledTimes(1);
60
73
  expect(callOrder[0]).toBe("beforeShow");
61
- expect(callOrder[1]).toBe("select");
74
+ expect(callOrder[1]).toBe("search");
62
75
  });
63
76
 
64
77
  test("passes sprinkle instance to beforeShow", async () => {
65
78
  let receivedSprinkle: any;
66
79
 
67
- mockSelectCancellable.mockResolvedValueOnce(-1);
80
+ mockSearchCancellable.mockImplementationOnce(pickExit);
68
81
 
69
82
  const menu: IMenu<any> = {
70
83
  title: "Test",
@@ -79,7 +92,7 @@ describe("beforeShow hook (2.2)", () => {
79
92
  });
80
93
 
81
94
  test("menu works without beforeShow", async () => {
82
- mockSelectCancellable.mockResolvedValueOnce(-1);
95
+ mockSearchCancellable.mockImplementationOnce(pickExit);
83
96
 
84
97
  const menu: IMenu<any> = {
85
98
  title: "Test",
@@ -588,6 +588,7 @@ describe("FillInStruct", () => {
588
588
  );
589
589
  expect(mockSelectCancellable.mock.calls[0][0].choices).toEqual([
590
590
  { name: "Enter existing private key", value: "existing" },
591
+ { name: "Import from recovery phrase", value: "mnemonic" },
591
592
  { name: "Generate new wallet", value: "generate" },
592
593
  ]);
593
594
  expect(result).toBe("my-private-key");
@@ -2,12 +2,14 @@ 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
+ const mockSearch = mock();
5
6
  const mockSelect = mock();
6
7
  const mockInput = mock();
7
8
  const mockSelectCancellable = mock();
8
9
  const mockInputCancellable = mock();
9
10
 
10
11
  mock.module("@inquirer/prompts", () => ({
12
+ search: mockSearch,
11
13
  select: mockSelect,
12
14
  input: mockInput,
13
15
  }));
@@ -17,104 +19,165 @@ mock.module("../prompts.js", () => ({
17
19
  inputCancellable: mockInputCancellable,
18
20
  passwordCancellable: mock(),
19
21
  confirmCancellable: mock(),
20
- searchCancellable: mock(),
22
+ searchCancellable: mockSearch,
21
23
  select: mockSelectCancellable,
22
24
  }));
23
25
 
24
- describe("showMenu", () => {
26
+ type SearchSource = (
27
+ term: string | undefined,
28
+ ) => Promise<{ name: string; value: any }[]>;
29
+
30
+ async function collectChoices(call: any): Promise<{ name: string; value: any }[]> {
31
+ const source: SearchSource = call[0].source;
32
+ return source(undefined);
33
+ }
34
+
35
+ function pickByName(
36
+ choices: { name: string; value: any }[],
37
+ name: string,
38
+ ): any {
39
+ const match = choices.find((c) => c.name === name);
40
+ if (!match) {
41
+ throw new Error(
42
+ `No choice named "${name}". Available: ${choices.map((c) => c.name).join(", ")}`,
43
+ );
44
+ }
45
+ return match.value;
46
+ }
47
+
48
+ describe("showMenu (search-based)", () => {
25
49
  let sprinkle: Sprinkle<any>;
26
50
 
27
51
  beforeEach(() => {
28
52
  const schema = Type.Object({ name: Type.String() });
29
53
  sprinkle = withProfile(new Sprinkle(schema, "/tmp/test"));
30
54
  sprinkle.settings = { name: "test" } as any;
55
+ mockSearch.mockClear();
31
56
  mockSelect.mockClear();
32
57
  mockSelectCancellable.mockClear();
33
58
  mockInput.mockClear();
34
59
  });
35
60
 
36
- test("exits when Exit is selected on main menu", async () => {
37
- mockSelectCancellable.mockResolvedValueOnce(-1);
61
+ test("exits when the Exit leaf is selected", async () => {
62
+ mockSearch.mockImplementationOnce(async (opts: any) => {
63
+ const choices = await opts.source(undefined);
64
+ return pickByName(choices, "Exit");
65
+ });
38
66
 
39
67
  const menu: IMenu<any> = {
40
68
  title: "Test Menu",
41
- items: [
42
- {
43
- title: "Action 1",
44
- action: async () => {},
45
- },
46
- ],
69
+ items: [{ title: "Action 1", action: async () => {} }],
47
70
  };
48
71
 
49
72
  await sprinkle.showMenu(menu);
73
+ expect(mockSearch).toHaveBeenCalledTimes(1);
50
74
  });
51
75
 
52
- test("executes action and re-shows menu", async () => {
76
+ test("executes a leaf action then loops back to the search prompt", async () => {
53
77
  const actionFn = mock(async () => {});
54
78
 
55
- mockSelectCancellable.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
79
+ mockSearch
80
+ .mockImplementationOnce(async (opts: any) => {
81
+ const choices = await opts.source(undefined);
82
+ return pickByName(choices, "My Action");
83
+ })
84
+ .mockImplementationOnce(async (opts: any) => {
85
+ const choices = await opts.source(undefined);
86
+ return pickByName(choices, "Exit");
87
+ });
56
88
 
57
89
  const menu: IMenu<any> = {
58
90
  title: "Test Menu",
59
- items: [
60
- {
61
- title: "My Action",
62
- action: actionFn,
63
- },
64
- ],
91
+ items: [{ title: "My Action", action: actionFn }],
65
92
  };
66
93
 
67
94
  await sprinkle.showMenu(menu);
68
95
  expect(actionFn).toHaveBeenCalledTimes(1);
96
+ expect(mockSearch).toHaveBeenCalledTimes(2);
69
97
  });
70
98
 
71
- test("main menu includes Settings & Profiles submenu and Exit", async () => {
72
- mockSelectCancellable.mockResolvedValueOnce(-1);
99
+ test("flattens submenus into breadcrumb leaves and surfaces Settings & Profiles + Exit", async () => {
100
+ mockSearch.mockImplementationOnce(async (opts: any) => {
101
+ const choices = await opts.source(undefined);
102
+ return pickByName(choices, "Exit");
103
+ });
73
104
 
74
105
  const menu: IMenu<any> = {
75
- title: "Test",
76
- items: [{ title: "Action", action: async () => {} }],
106
+ title: "Main",
107
+ items: [
108
+ {
109
+ title: "Strategies",
110
+ items: [
111
+ { title: "Create limit order", action: async () => {} },
112
+ { title: "List strategies", action: async () => {} },
113
+ ],
114
+ },
115
+ { title: "Quit (custom)", action: async () => {} },
116
+ ],
77
117
  };
78
118
 
79
119
  await sprinkle.showMenu(menu);
80
120
 
81
- const choices = mockSelectCancellable.mock.calls[0][0].choices;
82
- const names = choices.map((c: any) => c.name);
83
- expect(names).toContain("Settings & Profiles");
121
+ const choices = await collectChoices(mockSearch.mock.calls[0]);
122
+ const names = choices.map((c) => c.name);
123
+ expect(names).toContain("Strategies Create limit order");
124
+ expect(names).toContain("Strategies › List strategies");
125
+ expect(names).toContain("Quit (custom)");
126
+ expect(names).toContain("Settings & Profiles › Edit settings");
127
+ expect(names).toContain("Settings & Profiles › Import profile from file");
84
128
  expect(names).toContain("Exit");
85
- expect(names).not.toContain("Switch profile");
86
- expect(names).not.toContain("Manage profiles");
87
- expect(names).not.toContain("Edit settings");
88
129
  });
89
130
 
90
- test("submenu includes Back instead of Exit", async () => {
91
- mockSelectCancellable
92
- .mockResolvedValueOnce(0)
93
- .mockResolvedValueOnce(-1)
94
- .mockResolvedValueOnce(-1);
131
+ test("filters leaves by typed term (case-insensitive, all tokens, leaf-title boost)", async () => {
132
+ let filtered: { name: string; value: any }[] = [];
133
+
134
+ mockSearch.mockImplementationOnce(async (opts: any) => {
135
+ filtered = await opts.source("strateg");
136
+ // pick Exit so the loop terminates
137
+ const exitChoice = (await opts.source(undefined)).find(
138
+ (c: any) => c.name === "Exit",
139
+ );
140
+ return exitChoice.value;
141
+ });
95
142
 
96
143
  const menu: IMenu<any> = {
97
144
  title: "Main",
98
145
  items: [
99
146
  {
100
- title: "Sub",
101
- items: [{ title: "Sub Action", action: async () => {} }],
147
+ title: "Strategies",
148
+ items: [
149
+ { title: "Create limit order", action: async () => {} },
150
+ { title: "Cancel strategy", action: async () => {} },
151
+ ],
102
152
  },
153
+ { title: "Send payment", action: async () => {} },
103
154
  ],
104
155
  };
105
156
 
106
157
  await sprinkle.showMenu(menu);
107
158
 
108
- const subChoices = mockSelectCancellable.mock.calls[1][0].choices;
109
- const subNames = subChoices.map((c: any) => c.name);
110
- expect(subNames).toContain("Back");
111
- expect(subNames).not.toContain("Exit");
159
+ const filteredNames = filtered.map((c) => c.name);
160
+ // Everything under "Strategies" matches via the breadcrumb token
161
+ expect(filteredNames).toContain("Strategies › Create limit order");
162
+ expect(filteredNames).toContain("Strategies › Cancel strategy");
163
+ // Direct leaf-title hit outranks ancestor-only match
164
+ expect(filteredNames[0]).toBe("Strategies › Cancel strategy");
165
+ // Unrelated items are filtered out
166
+ expect(filteredNames).not.toContain("Send payment");
112
167
  });
113
168
 
114
- test("action returning sprinkle instance saves settings", async () => {
169
+ test("action returning a Sprinkle persists updated settings", async () => {
115
170
  sprinkle.settings = { name: "original" } as any;
116
171
 
117
- mockSelectCancellable.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
172
+ mockSearch
173
+ .mockImplementationOnce(async (opts: any) => {
174
+ const choices = await opts.source(undefined);
175
+ return pickByName(choices, "Update");
176
+ })
177
+ .mockImplementationOnce(async (opts: any) => {
178
+ const choices = await opts.source(undefined);
179
+ return pickByName(choices, "Exit");
180
+ });
118
181
 
119
182
  const menu: IMenu<any> = {
120
183
  title: "Test",
@@ -133,85 +196,38 @@ describe("showMenu", () => {
133
196
  expect(sprinkle.settings).toEqual({ name: "updated" });
134
197
  });
135
198
 
136
- test("Settings submenu includes View settings option", async () => {
137
- // Select "Settings & Profiles" (-5), then "Back" (-1), then "Exit" (-1)
138
- mockSelectCancellable
139
- .mockResolvedValueOnce(-5) // Settings & Profiles
140
- .mockResolvedValueOnce(-1) // Back
141
- .mockResolvedValueOnce(-1); // Exit
142
-
143
- const menu: IMenu<any> = {
144
- title: "Test",
145
- items: [{ title: "Action", action: async () => {} }],
146
- };
147
-
148
- await sprinkle.showMenu(menu);
149
-
150
- // Second call is the settings submenu
151
- const settingsChoices = mockSelectCancellable.mock.calls[1][0].choices;
152
- const settingsNames = settingsChoices.map((c: any) => c.name);
153
- expect(settingsNames).toContain("View settings");
154
- });
155
-
156
- test("View settings displays masked settings", async () => {
157
- const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
158
-
159
- // Select "Settings & Profiles" (-5), then "View settings" (0),
160
- // then "Continue" for the press Enter prompt, then "Back" (-1), then "Exit" (-1)
161
- mockSelectCancellable
162
- .mockResolvedValueOnce(-5) // Settings & Profiles
163
- .mockResolvedValueOnce(0) // View settings (first item)
164
- .mockResolvedValueOnce("continue") // Press Enter to continue
165
- .mockResolvedValueOnce(-1) // Back
166
- .mockResolvedValueOnce(-1); // Exit
167
-
168
- const menu: IMenu<any> = {
169
- title: "Test",
170
- items: [{ title: "Action", action: async () => {} }],
171
- };
172
-
173
- await sprinkle.showMenu(menu);
174
-
175
- // Should have called console.log with the settings (find the JSON output among breadcrumbs)
176
- expect(consoleSpy).toHaveBeenCalled();
177
- const jsonOutput = consoleSpy.mock.calls.find((call: any[]) =>
178
- call[0] && typeof call[0] === "string" && call[0].includes('"name"')
179
- );
180
- expect(jsonOutput).toBeDefined();
181
-
182
- consoleSpy.mockRestore();
183
- });
184
-
185
- test("Create profile restores state when FillInStruct is cancelled", async () => {
186
- // Capture original state
187
- const originalProfileId = sprinkle.currentProfile?.id;
188
- const originalSettings = { ...sprinkle.settings };
189
-
190
- // Settings menu indices: 0=View, 1=Edit, 2=Switch, 3=Create new profile
191
- mockSelectCancellable
192
- .mockResolvedValueOnce(-5) // Settings & Profiles
193
- .mockResolvedValueOnce(3); // Create new profile
194
-
195
- // promptProfileMeta: name then description
196
- mockInputCancellable
197
- .mockResolvedValueOnce("New Profile") // Profile name
198
- .mockResolvedValueOnce("") // Profile description
199
- .mockResolvedValueOnce(null); // Cancel during FillInStruct (name field)
200
-
201
- // After cancellation, back out of menus
202
- mockSelectCancellable
203
- .mockResolvedValueOnce(-1) // Back from settings
204
- .mockResolvedValueOnce(-1); // Exit
199
+ test("submenu beforeShow fires only when a leaf under it is selected", async () => {
200
+ const order: string[] = [];
201
+ const subBeforeShow = mock(async () => {
202
+ order.push("sub.beforeShow");
203
+ });
204
+ const actionFn = mock(async () => {
205
+ order.push("action");
206
+ });
207
+
208
+ mockSearch
209
+ .mockImplementationOnce(async (opts: any) => {
210
+ const choices = await opts.source(undefined);
211
+ return pickByName(choices, "Strategies › Create");
212
+ })
213
+ .mockImplementationOnce(async (opts: any) => {
214
+ const choices = await opts.source(undefined);
215
+ return pickByName(choices, "Exit");
216
+ });
205
217
 
206
218
  const menu: IMenu<any> = {
207
- title: "Test",
208
- items: [{ title: "Action", action: async () => {} }],
219
+ title: "Main",
220
+ items: [
221
+ {
222
+ title: "Strategies",
223
+ beforeShow: subBeforeShow,
224
+ items: [{ title: "Create", action: actionFn }],
225
+ },
226
+ ],
209
227
  };
210
228
 
211
229
  await sprinkle.showMenu(menu);
212
-
213
- // State should be restored to original
214
- expect(sprinkle.currentProfile?.id).toBe(originalProfileId);
215
- expect(sprinkle.settings).toEqual(originalSettings);
230
+ expect(subBeforeShow).toHaveBeenCalledTimes(1);
231
+ expect(order).toEqual(["sub.beforeShow", "action"]);
216
232
  });
217
233
  });