@somewhatabstract/x 0.0.1 → 0.1.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.
@@ -0,0 +1,506 @@
1
+ import {beforeEach, describe, expect, it, vi} from "vitest";
2
+ import type {PackageInfo} from "../discover-packages";
3
+ import {findMatchingBins} from "../find-matching-bins";
4
+
5
+ // Mock the fs module
6
+ vi.mock("node:fs/promises", () => ({
7
+ readFile: vi.fn(),
8
+ }));
9
+
10
+ import * as fs from "node:fs/promises";
11
+
12
+ describe("findMatchingBins", () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ vi.spyOn(console, "warn").mockImplementation(() => {
16
+ /* shh */
17
+ });
18
+ });
19
+
20
+ it("should find one matching bin with object-style bin configuration", async () => {
21
+ // Arrange
22
+ const packages: PackageInfo[] = [
23
+ {
24
+ name: "@somewhatabstract/x",
25
+ path: "/test/path",
26
+ version: "1.0.0",
27
+ },
28
+ ];
29
+
30
+ vi.mocked(fs.readFile).mockResolvedValue(
31
+ JSON.stringify({
32
+ name: "@somewhatabstract/x",
33
+ bin: {
34
+ x: "./dist/x.mjs",
35
+ },
36
+ }),
37
+ );
38
+
39
+ // Act
40
+ const matches = await findMatchingBins(packages, "x");
41
+
42
+ // Assert
43
+ expect(matches).toHaveLength(1);
44
+ });
45
+
46
+ it("should match the correct package name", async () => {
47
+ // Arrange
48
+ const packages: PackageInfo[] = [
49
+ {
50
+ name: "@somewhatabstract/x",
51
+ path: "/test/path",
52
+ version: "1.0.0",
53
+ },
54
+ ];
55
+
56
+ vi.mocked(fs.readFile).mockResolvedValue(
57
+ JSON.stringify({
58
+ name: "@somewhatabstract/x",
59
+ bin: {
60
+ x: "./dist/x.mjs",
61
+ },
62
+ }),
63
+ );
64
+
65
+ // Act
66
+ const matches = await findMatchingBins(packages, "x");
67
+
68
+ // Assert
69
+ expect(matches[0].packageName).toBe("@somewhatabstract/x");
70
+ });
71
+
72
+ it("should match the correct bin name", async () => {
73
+ // Arrange
74
+ const packages: PackageInfo[] = [
75
+ {
76
+ name: "@somewhatabstract/x",
77
+ path: "/test/path",
78
+ version: "1.0.0",
79
+ },
80
+ ];
81
+
82
+ vi.mocked(fs.readFile).mockResolvedValue(
83
+ JSON.stringify({
84
+ name: "@somewhatabstract/x",
85
+ bin: {
86
+ x: "./dist/x.mjs",
87
+ },
88
+ }),
89
+ );
90
+
91
+ // Act
92
+ const matches = await findMatchingBins(packages, "x");
93
+
94
+ // Assert
95
+ expect(matches[0].binName).toBe("x");
96
+ });
97
+
98
+ it("should include the bin path in the match", async () => {
99
+ // Arrange
100
+ const packages: PackageInfo[] = [
101
+ {
102
+ name: "@somewhatabstract/x",
103
+ path: "/test/path",
104
+ version: "1.0.0",
105
+ },
106
+ ];
107
+
108
+ vi.mocked(fs.readFile).mockResolvedValue(
109
+ JSON.stringify({
110
+ name: "@somewhatabstract/x",
111
+ bin: {
112
+ x: "./dist/x.mjs",
113
+ },
114
+ }),
115
+ );
116
+
117
+ // Act
118
+ const matches = await findMatchingBins(packages, "x");
119
+
120
+ // Assert
121
+ expect(matches[0].binPath).toBe("/test/path/dist/x.mjs");
122
+ });
123
+
124
+ it("should return empty array when no bins match", async () => {
125
+ // Arrange
126
+ const packages: PackageInfo[] = [
127
+ {
128
+ name: "@somewhatabstract/x",
129
+ path: "/test/path",
130
+ version: "1.0.0",
131
+ },
132
+ ];
133
+
134
+ vi.mocked(fs.readFile).mockResolvedValue(
135
+ JSON.stringify({
136
+ name: "@somewhatabstract/x",
137
+ bin: {
138
+ x: "./dist/x.mjs",
139
+ },
140
+ }),
141
+ );
142
+
143
+ // Act
144
+ const matches = await findMatchingBins(packages, "nonexistent");
145
+
146
+ // Assert
147
+ expect(matches).toHaveLength(0);
148
+ });
149
+
150
+ it("should handle packages without bin field", async () => {
151
+ // Arrange
152
+ const packages: PackageInfo[] = [
153
+ {
154
+ name: "no-bin-pkg",
155
+ path: "/test/path",
156
+ version: "1.0.0",
157
+ },
158
+ ];
159
+
160
+ vi.mocked(fs.readFile).mockResolvedValue(
161
+ JSON.stringify({
162
+ name: "no-bin-pkg",
163
+ }),
164
+ );
165
+
166
+ // Act
167
+ const matches = await findMatchingBins(packages, "anything");
168
+
169
+ // Assert
170
+ expect(matches).toHaveLength(0);
171
+ });
172
+
173
+ it("should handle packages with string-style bin configuration", async () => {
174
+ // Arrange
175
+ const packages: PackageInfo[] = [
176
+ {
177
+ name: "my-cli",
178
+ path: "/test/path",
179
+ version: "1.0.0",
180
+ },
181
+ ];
182
+
183
+ vi.mocked(fs.readFile).mockResolvedValue(
184
+ JSON.stringify({
185
+ name: "my-cli",
186
+ bin: "./bin/cli.js",
187
+ }),
188
+ );
189
+
190
+ // Act
191
+ const matches = await findMatchingBins(packages, "my-cli");
192
+
193
+ // Assert
194
+ expect(matches).toHaveLength(1);
195
+ });
196
+
197
+ it("should skip packages that cannot be read", async () => {
198
+ // Arrange
199
+ const packages: PackageInfo[] = [
200
+ {
201
+ name: "unreadable-pkg",
202
+ path: "/test/path",
203
+ version: "1.0.0",
204
+ },
205
+ ];
206
+
207
+ vi.mocked(fs.readFile).mockRejectedValue(
208
+ new Error("ENOENT: no such file or directory"),
209
+ );
210
+
211
+ // Act
212
+ const matches = await findMatchingBins(packages, "anything");
213
+
214
+ // Assert
215
+ expect(matches).toHaveLength(0);
216
+ });
217
+
218
+ it("should not match string-style bin when package name differs from binName", async () => {
219
+ // Arrange
220
+ const packages: PackageInfo[] = [
221
+ {
222
+ name: "my-package",
223
+ path: "/test/path",
224
+ version: "1.0.0",
225
+ },
226
+ ];
227
+
228
+ vi.mocked(fs.readFile).mockResolvedValue(
229
+ JSON.stringify({
230
+ name: "my-package",
231
+ bin: "./bin/script.js",
232
+ }),
233
+ );
234
+
235
+ // Act
236
+ const matches = await findMatchingBins(packages, "different-name");
237
+
238
+ // Assert
239
+ expect(matches).toHaveLength(0);
240
+ });
241
+
242
+ it("should not match object-style bin when binName not in object", async () => {
243
+ // Arrange
244
+ const packages: PackageInfo[] = [
245
+ {
246
+ name: "my-package",
247
+ path: "/test/path",
248
+ version: "1.0.0",
249
+ },
250
+ ];
251
+
252
+ vi.mocked(fs.readFile).mockResolvedValue(
253
+ JSON.stringify({
254
+ name: "my-package",
255
+ bin: {
256
+ "other-bin": "./bin/other.js",
257
+ "another-bin": "./bin/another.js",
258
+ },
259
+ }),
260
+ );
261
+
262
+ // Act
263
+ const matches = await findMatchingBins(packages, "missing-bin");
264
+
265
+ // Assert
266
+ expect(matches).toHaveLength(0);
267
+ });
268
+
269
+ it("should reject path traversal attempt with parent directory in string-style bin", async () => {
270
+ // Arrange
271
+ const packages: PackageInfo[] = [
272
+ {
273
+ name: "malicious-package",
274
+ path: "/test/package",
275
+ version: "1.0.0",
276
+ },
277
+ ];
278
+
279
+ vi.mocked(fs.readFile).mockResolvedValue(
280
+ JSON.stringify({
281
+ name: "malicious-package",
282
+ bin: "../../../etc/passwd",
283
+ }),
284
+ );
285
+
286
+ // Act
287
+ const matches = await findMatchingBins(packages, "malicious-package");
288
+
289
+ // Assert
290
+ expect(matches).toHaveLength(0);
291
+ });
292
+
293
+ it("should reject path traversal attempt with parent directory in object-style bin", async () => {
294
+ // Arrange
295
+ const packages: PackageInfo[] = [
296
+ {
297
+ name: "malicious-package",
298
+ path: "/test/package",
299
+ version: "1.0.0",
300
+ },
301
+ ];
302
+
303
+ vi.mocked(fs.readFile).mockResolvedValue(
304
+ JSON.stringify({
305
+ name: "malicious-package",
306
+ bin: {
307
+ "evil-script": "../../../usr/bin/malicious",
308
+ },
309
+ }),
310
+ );
311
+
312
+ // Act
313
+ const matches = await findMatchingBins(packages, "evil-script");
314
+
315
+ // Assert
316
+ expect(matches).toHaveLength(0);
317
+ });
318
+
319
+ it("should reject absolute path in string-style bin", async () => {
320
+ // Arrange
321
+ const packages: PackageInfo[] = [
322
+ {
323
+ name: "malicious-package",
324
+ path: "/test/package",
325
+ version: "1.0.0",
326
+ },
327
+ ];
328
+
329
+ vi.mocked(fs.readFile).mockResolvedValue(
330
+ JSON.stringify({
331
+ name: "malicious-package",
332
+ bin: "/usr/bin/malicious",
333
+ }),
334
+ );
335
+
336
+ // Act
337
+ const matches = await findMatchingBins(packages, "malicious-package");
338
+
339
+ // Assert
340
+ expect(matches).toHaveLength(0);
341
+ });
342
+
343
+ it("should reject absolute path in object-style bin", async () => {
344
+ // Arrange
345
+ const packages: PackageInfo[] = [
346
+ {
347
+ name: "malicious-package",
348
+ path: "/test/package",
349
+ version: "1.0.0",
350
+ },
351
+ ];
352
+
353
+ vi.mocked(fs.readFile).mockResolvedValue(
354
+ JSON.stringify({
355
+ name: "malicious-package",
356
+ bin: {
357
+ "evil-script": "/usr/bin/malicious",
358
+ },
359
+ }),
360
+ );
361
+
362
+ // Act
363
+ const matches = await findMatchingBins(packages, "evil-script");
364
+
365
+ // Assert
366
+ expect(matches).toHaveLength(0);
367
+ });
368
+
369
+ it("should accept bin path in subdirectory", async () => {
370
+ // Arrange
371
+ const packages: PackageInfo[] = [
372
+ {
373
+ name: "good-package",
374
+ path: "/test/package",
375
+ version: "1.0.0",
376
+ },
377
+ ];
378
+
379
+ vi.mocked(fs.readFile).mockResolvedValue(
380
+ JSON.stringify({
381
+ name: "good-package",
382
+ bin: {
383
+ "good-script": "./bin/nested/script.js",
384
+ },
385
+ }),
386
+ );
387
+
388
+ // Act
389
+ const matches = await findMatchingBins(packages, "good-script");
390
+
391
+ // Assert
392
+ expect(matches).toHaveLength(1);
393
+ });
394
+
395
+ it("should accept bin path at package root", async () => {
396
+ // Arrange
397
+ const packages: PackageInfo[] = [
398
+ {
399
+ name: "good-package",
400
+ path: "/test/package",
401
+ version: "1.0.0",
402
+ },
403
+ ];
404
+
405
+ vi.mocked(fs.readFile).mockResolvedValue(
406
+ JSON.stringify({
407
+ name: "good-package",
408
+ bin: {
409
+ "good-script": "./script.js",
410
+ },
411
+ }),
412
+ );
413
+
414
+ // Act
415
+ const matches = await findMatchingBins(packages, "good-script");
416
+
417
+ // Assert
418
+ expect(matches).toHaveLength(1);
419
+ });
420
+
421
+ it("should return empty array when package.json contains invalid JSON", async () => {
422
+ // Arrange
423
+ const packages: PackageInfo[] = [
424
+ {
425
+ name: "invalid-json-pkg",
426
+ path: "/test/path",
427
+ version: "1.0.0",
428
+ },
429
+ ];
430
+
431
+ vi.mocked(fs.readFile).mockResolvedValue("{ this is not valid json");
432
+
433
+ // Act
434
+ const matches = await findMatchingBins(packages, "invalid-json-pkg");
435
+
436
+ // Assert
437
+ expect(matches).toHaveLength(0);
438
+ });
439
+
440
+ it("should warn when package.json contains invalid JSON", async () => {
441
+ // Arrange
442
+ const packages: PackageInfo[] = [
443
+ {
444
+ name: "invalid-json-pkg",
445
+ path: "/test/path",
446
+ version: "1.0.0",
447
+ },
448
+ ];
449
+
450
+ vi.mocked(fs.readFile).mockResolvedValue("{ this is not valid json");
451
+
452
+ // Act
453
+ await findMatchingBins(packages, "invalid-json-pkg");
454
+
455
+ // Assert
456
+ expect(console.warn).toHaveBeenCalledWith(
457
+ expect.stringContaining("invalid JSON"),
458
+ );
459
+ });
460
+
461
+ it("should return empty array when package.json file is not found", async () => {
462
+ // Arrange
463
+ const packages: PackageInfo[] = [
464
+ {
465
+ name: "missing-pkg",
466
+ path: "/test/path",
467
+ version: "1.0.0",
468
+ },
469
+ ];
470
+
471
+ const enoentError = Object.assign(
472
+ new Error("ENOENT: no such file or directory"),
473
+ {code: "ENOENT"},
474
+ );
475
+ vi.mocked(fs.readFile).mockRejectedValue(enoentError);
476
+
477
+ // Act
478
+ const matches = await findMatchingBins(packages, "missing-pkg");
479
+
480
+ // Assert
481
+ expect(matches).toHaveLength(0);
482
+ });
483
+
484
+ it("should not warn when package.json file is not found", async () => {
485
+ // Arrange
486
+ const packages: PackageInfo[] = [
487
+ {
488
+ name: "missing-pkg",
489
+ path: "/test/path",
490
+ version: "1.0.0",
491
+ },
492
+ ];
493
+
494
+ const enoentError = Object.assign(
495
+ new Error("ENOENT: no such file or directory"),
496
+ {code: "ENOENT"},
497
+ );
498
+ vi.mocked(fs.readFile).mockRejectedValue(enoentError);
499
+
500
+ // Act
501
+ await findMatchingBins(packages, "missing-pkg");
502
+
503
+ // Assert
504
+ expect(console.warn).not.toHaveBeenCalled();
505
+ });
506
+ });
@@ -0,0 +1,73 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {afterEach, beforeEach, describe, expect, it} from "vitest";
4
+ import {HandledError} from "../errors";
5
+ import {findWorkspaceRoot} from "../find-workspace-root";
6
+
7
+ describe("findWorkspaceRoot", () => {
8
+ const testDir = path.join("/tmp", "test-workspace-root");
9
+ const workspaceDir = path.join(testDir, "workspace");
10
+ const nestedDir = path.join(workspaceDir, "nested", "deep");
11
+
12
+ beforeEach(async () => {
13
+ // Create test directory structure
14
+ await fs.mkdir(nestedDir, {recursive: true});
15
+ await fs.writeFile(
16
+ path.join(workspaceDir, "pnpm-workspace.yaml"),
17
+ "packages:\n - 'packages/*'\n",
18
+ );
19
+ });
20
+
21
+ afterEach(async () => {
22
+ // Clean up
23
+ await fs.rm(testDir, {recursive: true, force: true});
24
+ });
25
+
26
+ it("should find workspace root from nested directory", async () => {
27
+ // Arrange
28
+ const searchDir = nestedDir;
29
+
30
+ // Act
31
+ const result = await findWorkspaceRoot(searchDir);
32
+
33
+ // Assert
34
+ expect(result).toBe(workspaceDir);
35
+ });
36
+
37
+ it("should find workspace root from workspace directory itself", async () => {
38
+ // Arrange
39
+ const searchDir = workspaceDir;
40
+
41
+ // Act
42
+ const result = await findWorkspaceRoot(searchDir);
43
+
44
+ // Assert
45
+ expect(result).toBe(workspaceDir);
46
+ });
47
+
48
+ it("should throw HandledError when not in a workspace", async () => {
49
+ // Arrange
50
+ const nonWorkspaceDir = path.join(testDir, "non-workspace");
51
+ await fs.mkdir(nonWorkspaceDir, {recursive: true});
52
+
53
+ // Act
54
+ const underTest = () => findWorkspaceRoot(nonWorkspaceDir);
55
+
56
+ // Assert
57
+ await expect(underTest).rejects.toThrow(HandledError);
58
+ });
59
+
60
+ it("should provide meaningful error message when not in a workspace", async () => {
61
+ // Arrange
62
+ const nonWorkspaceDir = path.join(testDir, "non-workspace");
63
+ await fs.mkdir(nonWorkspaceDir, {recursive: true});
64
+
65
+ // Act
66
+ const underTest = () => findWorkspaceRoot(nonWorkspaceDir);
67
+
68
+ // Assert
69
+ await expect(underTest).rejects.toThrow(
70
+ "Could not find workspace root",
71
+ );
72
+ });
73
+ });
@@ -0,0 +1,125 @@
1
+ import {describe, expect, it} from "vitest";
2
+ import {isNodeExecutable} from "../is-node-executable";
3
+
4
+ describe("isNodeExecutable", () => {
5
+ it("should return true for .js files", () => {
6
+ // Arrange
7
+ const binPath = "/path/to/script.js";
8
+
9
+ // Act
10
+ const result = isNodeExecutable(binPath);
11
+
12
+ // Assert
13
+ expect(result).toBe(true);
14
+ });
15
+
16
+ it("should return true for .mjs files", () => {
17
+ // Arrange
18
+ const binPath = "/path/to/script.mjs";
19
+
20
+ // Act
21
+ const result = isNodeExecutable(binPath);
22
+
23
+ // Assert
24
+ expect(result).toBe(true);
25
+ });
26
+
27
+ it("should return true for .cjs files", () => {
28
+ // Arrange
29
+ const binPath = "/path/to/script.cjs";
30
+
31
+ // Act
32
+ const result = isNodeExecutable(binPath);
33
+
34
+ // Assert
35
+ expect(result).toBe(true);
36
+ });
37
+
38
+ it("should return true for .JS files (case-insensitive)", () => {
39
+ // Arrange
40
+ const binPath = "/path/to/script.JS";
41
+
42
+ // Act
43
+ const result = isNodeExecutable(binPath);
44
+
45
+ // Assert
46
+ expect(result).toBe(true);
47
+ });
48
+
49
+ it("should return true for .Js files (case-insensitive)", () => {
50
+ // Arrange
51
+ const binPath = "/path/to/script.Js";
52
+
53
+ // Act
54
+ const result = isNodeExecutable(binPath);
55
+
56
+ // Assert
57
+ expect(result).toBe(true);
58
+ });
59
+
60
+ it("should return true for .MJS files (case-insensitive)", () => {
61
+ // Arrange
62
+ const binPath = "/path/to/script.MJS";
63
+
64
+ // Act
65
+ const result = isNodeExecutable(binPath);
66
+
67
+ // Assert
68
+ expect(result).toBe(true);
69
+ });
70
+
71
+ it("should return true for .CJS files (case-insensitive)", () => {
72
+ // Arrange
73
+ const binPath = "/path/to/script.CJS";
74
+
75
+ // Act
76
+ const result = isNodeExecutable(binPath);
77
+
78
+ // Assert
79
+ expect(result).toBe(true);
80
+ });
81
+
82
+ it("should return true for .Mjs files (case-insensitive)", () => {
83
+ // Arrange
84
+ const binPath = "/path/to/script.Mjs";
85
+
86
+ // Act
87
+ const result = isNodeExecutable(binPath);
88
+
89
+ // Assert
90
+ expect(result).toBe(true);
91
+ });
92
+
93
+ it("should return false for files with no extension", () => {
94
+ // Arrange
95
+ const binPath = "/path/to/script";
96
+
97
+ // Act
98
+ const result = isNodeExecutable(binPath);
99
+
100
+ // Assert
101
+ expect(result).toBe(false);
102
+ });
103
+
104
+ it("should return false for .sh files", () => {
105
+ // Arrange
106
+ const binPath = "/path/to/script.sh";
107
+
108
+ // Act
109
+ const result = isNodeExecutable(binPath);
110
+
111
+ // Assert
112
+ expect(result).toBe(false);
113
+ });
114
+
115
+ it("should return false for files with .js in the middle (e.g. script.js.bak)", () => {
116
+ // Arrange
117
+ const binPath = "/path/to/script.js.bak";
118
+
119
+ // Act
120
+ const result = isNodeExecutable(binPath);
121
+
122
+ // Assert
123
+ expect(result).toBe(false);
124
+ });
125
+ });