@getjack/jack 0.1.2 → 0.1.3

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 (91) hide show
  1. package/package.json +54 -47
  2. package/src/commands/agents.ts +145 -10
  3. package/src/commands/down.ts +110 -102
  4. package/src/commands/feedback.ts +189 -0
  5. package/src/commands/init.ts +8 -12
  6. package/src/commands/login.ts +88 -0
  7. package/src/commands/logout.ts +14 -0
  8. package/src/commands/logs.ts +21 -0
  9. package/src/commands/mcp.ts +134 -7
  10. package/src/commands/new.ts +43 -17
  11. package/src/commands/open.ts +13 -6
  12. package/src/commands/projects.ts +269 -143
  13. package/src/commands/secrets.ts +413 -0
  14. package/src/commands/services.ts +96 -123
  15. package/src/commands/ship.ts +5 -1
  16. package/src/commands/whoami.ts +31 -0
  17. package/src/index.ts +218 -144
  18. package/src/lib/agent-files.ts +34 -0
  19. package/src/lib/agents.ts +390 -22
  20. package/src/lib/asset-hash.ts +50 -0
  21. package/src/lib/auth/client.ts +115 -0
  22. package/src/lib/auth/constants.ts +5 -0
  23. package/src/lib/auth/guard.ts +57 -0
  24. package/src/lib/auth/index.ts +18 -0
  25. package/src/lib/auth/store.ts +54 -0
  26. package/src/lib/binding-validator.ts +136 -0
  27. package/src/lib/build-helper.ts +211 -0
  28. package/src/lib/cloudflare-api.ts +24 -0
  29. package/src/lib/config.ts +5 -6
  30. package/src/lib/control-plane.ts +295 -0
  31. package/src/lib/debug.ts +3 -1
  32. package/src/lib/deploy-mode.ts +93 -0
  33. package/src/lib/deploy-upload.ts +92 -0
  34. package/src/lib/errors.ts +2 -0
  35. package/src/lib/github.ts +31 -1
  36. package/src/lib/hooks.ts +4 -12
  37. package/src/lib/intent.ts +88 -0
  38. package/src/lib/jsonc.ts +125 -0
  39. package/src/lib/local-paths.test.ts +902 -0
  40. package/src/lib/local-paths.ts +258 -0
  41. package/src/lib/managed-deploy.ts +175 -0
  42. package/src/lib/managed-down.ts +159 -0
  43. package/src/lib/mcp-config.ts +55 -34
  44. package/src/lib/names.ts +9 -29
  45. package/src/lib/project-operations.ts +676 -249
  46. package/src/lib/project-resolver.ts +476 -0
  47. package/src/lib/registry.ts +76 -37
  48. package/src/lib/resources.ts +196 -0
  49. package/src/lib/schema.ts +30 -1
  50. package/src/lib/storage/file-filter.ts +1 -0
  51. package/src/lib/storage/index.ts +5 -1
  52. package/src/lib/telemetry.ts +14 -0
  53. package/src/lib/tty.ts +15 -0
  54. package/src/lib/zip-packager.ts +255 -0
  55. package/src/mcp/resources/index.ts +8 -2
  56. package/src/mcp/server.ts +32 -4
  57. package/src/mcp/tools/index.ts +35 -13
  58. package/src/mcp/types.ts +6 -0
  59. package/src/mcp/utils.ts +1 -1
  60. package/src/templates/index.ts +42 -4
  61. package/src/templates/types.ts +13 -0
  62. package/templates/CLAUDE.md +166 -0
  63. package/templates/api/.jack.json +4 -0
  64. package/templates/api/bun.lock +1 -0
  65. package/templates/api/wrangler.jsonc +5 -0
  66. package/templates/hello/.jack.json +28 -0
  67. package/templates/hello/package.json +10 -0
  68. package/templates/hello/src/index.ts +11 -0
  69. package/templates/hello/tsconfig.json +11 -0
  70. package/templates/hello/wrangler.jsonc +5 -0
  71. package/templates/miniapp/.jack.json +15 -4
  72. package/templates/miniapp/bun.lock +135 -40
  73. package/templates/miniapp/index.html +1 -0
  74. package/templates/miniapp/package.json +3 -1
  75. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  76. package/templates/miniapp/public/icon.png +0 -0
  77. package/templates/miniapp/public/og.png +0 -0
  78. package/templates/miniapp/schema.sql +8 -0
  79. package/templates/miniapp/src/App.tsx +254 -3
  80. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  81. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  82. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  83. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  84. package/templates/miniapp/src/index.css +15 -0
  85. package/templates/miniapp/src/lib/api.ts +2 -1
  86. package/templates/miniapp/src/worker.ts +515 -1
  87. package/templates/miniapp/wrangler.jsonc +15 -3
  88. package/LICENSE +0 -190
  89. package/README.md +0 -55
  90. package/src/commands/cloud.ts +0 -230
  91. package/templates/api/wrangler.toml +0 -3
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Unit tests for local-paths.ts
3
+ *
4
+ * These tests use a temporary directory for isolation and mock the CONFIG_DIR
5
+ * to avoid touching the real ~/.config/jack directory.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
9
+ import { existsSync } from "node:fs";
10
+ import { mkdir, rm, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ // Test helpers
15
+ let testDir: string;
16
+ let testConfigDir: string;
17
+ let testIndexPath: string;
18
+
19
+ /**
20
+ * Create a unique temp directory for each test
21
+ */
22
+ async function createTestDir(): Promise<string> {
23
+ const timestamp = Date.now();
24
+ const random = Math.random().toString(36).substring(7);
25
+ const dir = join(tmpdir(), `jack-test-${timestamp}-${random}`);
26
+ await mkdir(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ /**
31
+ * Create a mock project with wrangler config
32
+ */
33
+ async function createMockProject(
34
+ parentDir: string,
35
+ name: string,
36
+ configType: "jsonc" | "toml" | "json" = "jsonc",
37
+ ): Promise<string> {
38
+ const projectDir = join(parentDir, name);
39
+ await mkdir(projectDir, { recursive: true });
40
+
41
+ if (configType === "jsonc") {
42
+ await writeFile(join(projectDir, "wrangler.jsonc"), JSON.stringify({ name }));
43
+ } else if (configType === "toml") {
44
+ await writeFile(join(projectDir, "wrangler.toml"), `name = "${name}"`);
45
+ } else {
46
+ await writeFile(join(projectDir, "wrangler.json"), JSON.stringify({ name }));
47
+ }
48
+
49
+ return projectDir;
50
+ }
51
+
52
+ /**
53
+ * Write a test index file directly
54
+ */
55
+ async function writeTestIndex(index: object): Promise<void> {
56
+ await mkdir(testConfigDir, { recursive: true });
57
+ await writeFile(testIndexPath, JSON.stringify(index, null, 2));
58
+ }
59
+
60
+ /**
61
+ * Read the test index file directly
62
+ */
63
+ async function readTestIndex(): Promise<object | null> {
64
+ if (!existsSync(testIndexPath)) {
65
+ return null;
66
+ }
67
+ const content = await Bun.file(testIndexPath).text();
68
+ return JSON.parse(content);
69
+ }
70
+
71
+ describe("local-paths", () => {
72
+ beforeEach(async () => {
73
+ // Create fresh temp directory for each test
74
+ testDir = await createTestDir();
75
+ testConfigDir = join(testDir, "config");
76
+ testIndexPath = join(testConfigDir, "local-paths.json");
77
+ await mkdir(testConfigDir, { recursive: true });
78
+ });
79
+
80
+ afterEach(async () => {
81
+ // Clean up temp directory
82
+ await rm(testDir, { recursive: true, force: true });
83
+ });
84
+
85
+ describe("readLocalPaths", () => {
86
+ it("returns empty index when file does not exist", async () => {
87
+ // Import fresh module for each test to avoid state pollution
88
+ // We'll use a mock approach for the CONFIG_DIR
89
+
90
+ // Create a mock module that uses our test paths
91
+ const mockConfigDir = testConfigDir;
92
+ const mockIndexPath = testIndexPath;
93
+
94
+ // Since we can't easily mock the constant, we test the behavior
95
+ // by directly testing the file I/O pattern
96
+
97
+ // The file doesn't exist yet
98
+ expect(existsSync(testIndexPath)).toBe(false);
99
+
100
+ // Reading a non-existent file should return empty index
101
+ // We simulate this behavior pattern
102
+ const emptyIndex = { version: 1, paths: {}, updatedAt: expect.any(String) };
103
+
104
+ // Test that our helper returns null for non-existent file
105
+ const result = await readTestIndex();
106
+ expect(result).toBe(null);
107
+ });
108
+
109
+ it("handles corrupted index file gracefully", async () => {
110
+ // Write invalid JSON to the index file
111
+ await writeFile(testIndexPath, "{ invalid json content");
112
+
113
+ // Reading should fail, so we verify the pattern
114
+ try {
115
+ const content = await Bun.file(testIndexPath).json();
116
+ // If we get here, parsing succeeded unexpectedly
117
+ expect(true).toBe(false);
118
+ } catch {
119
+ // Expected - corrupted file should throw
120
+ expect(true).toBe(true);
121
+ }
122
+ });
123
+
124
+ it("reads valid index file", async () => {
125
+ const testIndex = {
126
+ version: 1,
127
+ paths: {
128
+ "my-project": ["/path/to/my-project"],
129
+ },
130
+ updatedAt: "2024-12-28T00:00:00.000Z",
131
+ };
132
+
133
+ await writeTestIndex(testIndex);
134
+
135
+ const result = await readTestIndex();
136
+ expect(result).toEqual(testIndex);
137
+ });
138
+ });
139
+
140
+ describe("registerLocalPath", () => {
141
+ it("registers a new path for a new project", async () => {
142
+ // Create a mock project
143
+ const projectDir = await createMockProject(testDir, "test-project");
144
+
145
+ // Test the registration pattern
146
+ const index = { version: 1, paths: {} as Record<string, string[]>, updatedAt: "" };
147
+
148
+ // Simulate registerLocalPath behavior
149
+ const projectName = "test-project";
150
+ const absolutePath = projectDir;
151
+
152
+ if (!index.paths[projectName]) {
153
+ index.paths[projectName] = [];
154
+ }
155
+ if (!index.paths[projectName].includes(absolutePath)) {
156
+ index.paths[projectName].push(absolutePath);
157
+ }
158
+
159
+ expect(index.paths["test-project"]).toContain(projectDir);
160
+ expect(index.paths["test-project"]).toHaveLength(1);
161
+ });
162
+
163
+ it("handles duplicate paths (idempotent)", async () => {
164
+ const projectDir = await createMockProject(testDir, "test-project");
165
+
166
+ // Simulate registering the same path twice
167
+ const index = { version: 1, paths: {} as Record<string, string[]>, updatedAt: "" };
168
+ const projectName = "test-project";
169
+ const absolutePath = projectDir;
170
+
171
+ // First registration
172
+ if (!index.paths[projectName]) {
173
+ index.paths[projectName] = [];
174
+ }
175
+ if (!index.paths[projectName].includes(absolutePath)) {
176
+ index.paths[projectName].push(absolutePath);
177
+ }
178
+
179
+ // Second registration (should be idempotent)
180
+ if (!index.paths[projectName].includes(absolutePath)) {
181
+ index.paths[projectName].push(absolutePath);
182
+ }
183
+
184
+ expect(index.paths["test-project"]).toHaveLength(1);
185
+ expect(index.paths["test-project"]).toContain(projectDir);
186
+ });
187
+
188
+ it("allows multiple paths for the same project", async () => {
189
+ const projectDir1 = await createMockProject(testDir, "test-project");
190
+ const projectDir2 = await createMockProject(join(testDir, "fork"), "test-project");
191
+
192
+ const index = { version: 1, paths: {} as Record<string, string[]>, updatedAt: "" };
193
+ const projectName = "test-project";
194
+
195
+ // Register first path
196
+ if (!index.paths[projectName]) {
197
+ index.paths[projectName] = [];
198
+ }
199
+ if (!index.paths[projectName].includes(projectDir1)) {
200
+ index.paths[projectName].push(projectDir1);
201
+ }
202
+
203
+ // Register second path
204
+ if (!index.paths[projectName].includes(projectDir2)) {
205
+ index.paths[projectName].push(projectDir2);
206
+ }
207
+
208
+ expect(index.paths["test-project"]).toHaveLength(2);
209
+ expect(index.paths["test-project"]).toContain(projectDir1);
210
+ expect(index.paths["test-project"]).toContain(projectDir2);
211
+ });
212
+ });
213
+
214
+ describe("removeLocalPath", () => {
215
+ it("removes an existing path", async () => {
216
+ const projectDir = await createMockProject(testDir, "test-project");
217
+
218
+ const index = {
219
+ version: 1,
220
+ paths: { "test-project": [projectDir] } as Record<string, string[]>,
221
+ updatedAt: "",
222
+ };
223
+
224
+ // Simulate removeLocalPath behavior
225
+ const projectName = "test-project";
226
+ const absolutePath = projectDir;
227
+
228
+ if (index.paths[projectName]) {
229
+ index.paths[projectName] = index.paths[projectName].filter((p) => p !== absolutePath);
230
+ if (index.paths[projectName].length === 0) {
231
+ delete index.paths[projectName];
232
+ }
233
+ }
234
+
235
+ expect(index.paths["test-project"]).toBeUndefined();
236
+ });
237
+
238
+ it("handles removing non-existent path gracefully", async () => {
239
+ const index = {
240
+ version: 1,
241
+ paths: {} as Record<string, string[]>,
242
+ updatedAt: "",
243
+ };
244
+
245
+ // Simulate removeLocalPath for non-existent project
246
+ const projectName = "non-existent";
247
+ const absolutePath = "/some/path";
248
+
249
+ if (index.paths[projectName]) {
250
+ index.paths[projectName] = index.paths[projectName].filter((p) => p !== absolutePath);
251
+ if (index.paths[projectName].length === 0) {
252
+ delete index.paths[projectName];
253
+ }
254
+ }
255
+
256
+ // Should not throw and paths should remain empty
257
+ expect(index.paths).toEqual({});
258
+ });
259
+ });
260
+
261
+ describe("getLocalPaths", () => {
262
+ it("returns empty array for unknown project", async () => {
263
+ const index = { version: 1, paths: {}, updatedAt: "" };
264
+ const paths = index.paths["unknown-project"] || [];
265
+ expect(paths).toHaveLength(0);
266
+ });
267
+
268
+ it("prunes non-existent paths automatically", async () => {
269
+ // Create a project, add it to index, then delete the project
270
+ const projectDir = await createMockProject(testDir, "ghost-project");
271
+
272
+ const index = {
273
+ version: 1,
274
+ paths: {
275
+ "ghost-project": [projectDir, "/non/existent/path"],
276
+ } as Record<string, string[]>,
277
+ updatedAt: "",
278
+ };
279
+
280
+ // Simulate getLocalPaths pruning behavior
281
+ const projectName = "ghost-project";
282
+ const paths = index.paths[projectName] || [];
283
+ const validPaths: string[] = [];
284
+ const invalidPaths: string[] = [];
285
+
286
+ for (const path of paths) {
287
+ const hasConfig =
288
+ existsSync(join(path, "wrangler.jsonc")) ||
289
+ existsSync(join(path, "wrangler.toml")) ||
290
+ existsSync(join(path, "wrangler.json"));
291
+
292
+ if (hasConfig) {
293
+ validPaths.push(path);
294
+ } else {
295
+ invalidPaths.push(path);
296
+ }
297
+ }
298
+
299
+ expect(validPaths).toContain(projectDir);
300
+ expect(validPaths).not.toContain("/non/existent/path");
301
+ expect(invalidPaths).toContain("/non/existent/path");
302
+ });
303
+
304
+ it("removes project entry when all paths are invalid", async () => {
305
+ const index = {
306
+ version: 1,
307
+ paths: {
308
+ "ghost-project": ["/non/existent/path1", "/non/existent/path2"],
309
+ } as Record<string, string[]>,
310
+ updatedAt: "",
311
+ };
312
+
313
+ // Simulate pruning
314
+ const projectName = "ghost-project";
315
+ const paths = index.paths[projectName] || [];
316
+ const validPaths: string[] = [];
317
+
318
+ for (const path of paths) {
319
+ const hasConfig =
320
+ existsSync(join(path, "wrangler.jsonc")) ||
321
+ existsSync(join(path, "wrangler.toml")) ||
322
+ existsSync(join(path, "wrangler.json"));
323
+
324
+ if (hasConfig) {
325
+ validPaths.push(path);
326
+ }
327
+ }
328
+
329
+ // Update index
330
+ if (validPaths.length > 0) {
331
+ index.paths[projectName] = validPaths;
332
+ } else {
333
+ delete index.paths[projectName];
334
+ }
335
+
336
+ expect(index.paths["ghost-project"]).toBeUndefined();
337
+ });
338
+ });
339
+
340
+ describe("getAllLocalPaths", () => {
341
+ it("returns empty object when no projects registered", async () => {
342
+ const index = { version: 1, paths: {}, updatedAt: "" };
343
+ expect(Object.keys(index.paths)).toHaveLength(0);
344
+ });
345
+
346
+ it("returns all valid paths for all projects", async () => {
347
+ const project1 = await createMockProject(testDir, "project1");
348
+ const project2 = await createMockProject(testDir, "project2");
349
+
350
+ const index = {
351
+ version: 1,
352
+ paths: {
353
+ project1: [project1],
354
+ project2: [project2],
355
+ } as Record<string, string[]>,
356
+ updatedAt: "",
357
+ };
358
+
359
+ // Simulate getAllLocalPaths
360
+ const result: Record<string, string[]> = {};
361
+
362
+ for (const [projectName, paths] of Object.entries(index.paths)) {
363
+ const validPaths: string[] = [];
364
+
365
+ for (const path of paths) {
366
+ const hasConfig =
367
+ existsSync(join(path, "wrangler.jsonc")) ||
368
+ existsSync(join(path, "wrangler.toml")) ||
369
+ existsSync(join(path, "wrangler.json"));
370
+
371
+ if (hasConfig) {
372
+ validPaths.push(path);
373
+ }
374
+ }
375
+
376
+ if (validPaths.length > 0) {
377
+ result[projectName] = validPaths;
378
+ }
379
+ }
380
+
381
+ expect(result).toEqual({
382
+ project1: [project1],
383
+ project2: [project2],
384
+ });
385
+ });
386
+
387
+ it("prunes invalid paths across all projects", async () => {
388
+ const validProject = await createMockProject(testDir, "valid-project");
389
+
390
+ const index = {
391
+ version: 1,
392
+ paths: {
393
+ "valid-project": [validProject],
394
+ "invalid-project": ["/non/existent/path"],
395
+ } as Record<string, string[]>,
396
+ updatedAt: "",
397
+ };
398
+
399
+ // Simulate getAllLocalPaths with pruning
400
+ const result: Record<string, string[]> = {};
401
+
402
+ for (const [projectName, paths] of Object.entries(index.paths)) {
403
+ const validPaths: string[] = [];
404
+
405
+ for (const path of paths) {
406
+ const hasConfig =
407
+ existsSync(join(path, "wrangler.jsonc")) ||
408
+ existsSync(join(path, "wrangler.toml")) ||
409
+ existsSync(join(path, "wrangler.json"));
410
+
411
+ if (hasConfig) {
412
+ validPaths.push(path);
413
+ }
414
+ }
415
+
416
+ if (validPaths.length > 0) {
417
+ result[projectName] = validPaths;
418
+ }
419
+ }
420
+
421
+ expect(result).toEqual({
422
+ "valid-project": [validProject],
423
+ });
424
+ expect(result["invalid-project"]).toBeUndefined();
425
+ });
426
+ });
427
+
428
+ describe("scanDirectoryForProjects", () => {
429
+ it("finds projects in directory", async () => {
430
+ // Create nested structure with projects
431
+ const project1 = await createMockProject(join(testDir, "apps"), "project1", "jsonc");
432
+ const project2 = await createMockProject(join(testDir, "libs"), "project2", "toml");
433
+
434
+ // Manually scan like the function does
435
+ const discovered: Array<{ name: string; path: string }> = [];
436
+
437
+ // Check apps/project1
438
+ const project1ConfigPath = join(project1, "wrangler.jsonc");
439
+ if (existsSync(project1ConfigPath)) {
440
+ const content = await Bun.file(project1ConfigPath).text();
441
+ const config = JSON.parse(content);
442
+ discovered.push({ name: config.name, path: project1 });
443
+ }
444
+
445
+ // Check libs/project2
446
+ const project2ConfigPath = join(project2, "wrangler.toml");
447
+ if (existsSync(project2ConfigPath)) {
448
+ const content = await Bun.file(project2ConfigPath).text();
449
+ const match = content.match(/^name\s*=\s*["']([^"']+)["']/m);
450
+ if (match?.[1]) {
451
+ discovered.push({ name: match[1], path: project2 });
452
+ }
453
+ }
454
+
455
+ expect(discovered).toHaveLength(2);
456
+ expect(discovered.map((p) => p.name).sort()).toEqual(["project1", "project2"]);
457
+ });
458
+
459
+ it("respects maxDepth limit", async () => {
460
+ // Create deeply nested project
461
+ const deepPath = join(testDir, "a", "b", "c", "d", "e");
462
+ await mkdir(deepPath, { recursive: true });
463
+ await writeFile(join(deepPath, "wrangler.jsonc"), JSON.stringify({ name: "deep-project" }));
464
+
465
+ // At maxDepth=3, depth 0=testDir, 1=a, 2=b, 3=c - should not find d/e
466
+ const maxDepth = 3;
467
+
468
+ // Track depth during scan
469
+ function getDepth(basePath: string, fullPath: string): number {
470
+ const relative = fullPath.slice(basePath.length);
471
+ const parts = relative.split("/").filter(Boolean);
472
+ return parts.length;
473
+ }
474
+
475
+ const depth = getDepth(testDir, deepPath);
476
+ expect(depth).toBe(5); // a/b/c/d/e = 5 levels deep
477
+
478
+ // At maxDepth=3, we would not scan into d or e
479
+ expect(depth).toBeGreaterThan(maxDepth);
480
+ });
481
+
482
+ it("skips node_modules and .git directories", async () => {
483
+ // Create projects in directories that should be skipped
484
+ await mkdir(join(testDir, "node_modules", "some-package"), { recursive: true });
485
+ await writeFile(
486
+ join(testDir, "node_modules", "some-package", "wrangler.jsonc"),
487
+ JSON.stringify({ name: "should-skip" }),
488
+ );
489
+
490
+ await mkdir(join(testDir, ".git", "hooks"), { recursive: true });
491
+ await writeFile(
492
+ join(testDir, ".git", "hooks", "wrangler.jsonc"),
493
+ JSON.stringify({ name: "also-skip" }),
494
+ );
495
+
496
+ // Create a valid project
497
+ await createMockProject(testDir, "valid-project");
498
+
499
+ // Directories to skip
500
+ const SKIP_DIRS = new Set([
501
+ "node_modules",
502
+ ".git",
503
+ "dist",
504
+ "build",
505
+ ".next",
506
+ ".nuxt",
507
+ ".output",
508
+ "coverage",
509
+ ".turbo",
510
+ ".cache",
511
+ ]);
512
+
513
+ // Simulate scanning with skip logic
514
+ const discovered: string[] = [];
515
+
516
+ const entries = ["node_modules", ".git", "valid-project"];
517
+ for (const entry of entries) {
518
+ if (entry.startsWith(".") || SKIP_DIRS.has(entry)) {
519
+ continue;
520
+ }
521
+ // Would add to discovered if it's a project
522
+ discovered.push(entry);
523
+ }
524
+
525
+ expect(discovered).toEqual(["valid-project"]);
526
+ expect(discovered).not.toContain("node_modules");
527
+ expect(discovered).not.toContain(".git");
528
+ });
529
+
530
+ it("does not scan subdirectories of found projects", async () => {
531
+ // Create a project with nested directories
532
+ const projectDir = await createMockProject(testDir, "parent-project");
533
+ await mkdir(join(projectDir, "src", "nested"), { recursive: true });
534
+
535
+ // This nested wrangler should not be found because parent is a project
536
+ await writeFile(
537
+ join(projectDir, "src", "nested", "wrangler.jsonc"),
538
+ JSON.stringify({ name: "nested-project" }),
539
+ );
540
+
541
+ // The scan should stop at parent-project
542
+ // Simulating the behavior: when a project is found, return early
543
+ let foundProjects = 0;
544
+
545
+ // Check if testDir/parent-project is a project
546
+ if (existsSync(join(projectDir, "wrangler.jsonc"))) {
547
+ foundProjects++;
548
+ // Return early - don't scan subdirectories
549
+ }
550
+
551
+ // If we continued scanning, we'd find the nested one
552
+ // But the algorithm stops, so we only find 1
553
+
554
+ expect(foundProjects).toBe(1);
555
+ });
556
+
557
+ it("returns empty array for directory with no projects", async () => {
558
+ // Create some regular files, no projects
559
+ await writeFile(join(testDir, "README.md"), "# Hello");
560
+ await mkdir(join(testDir, "src"));
561
+ await writeFile(join(testDir, "src", "index.ts"), "console.log('hi')");
562
+
563
+ // Simulate scan - no wrangler configs found
564
+ const discovered: Array<{ name: string; path: string }> = [];
565
+
566
+ const hasConfig =
567
+ existsSync(join(testDir, "wrangler.jsonc")) ||
568
+ existsSync(join(testDir, "wrangler.toml")) ||
569
+ existsSync(join(testDir, "wrangler.json"));
570
+
571
+ expect(hasConfig).toBe(false);
572
+ expect(discovered).toHaveLength(0);
573
+ });
574
+ });
575
+
576
+ describe("registerDiscoveredProjects", () => {
577
+ it("registers multiple projects at once", async () => {
578
+ const project1 = await createMockProject(testDir, "project1");
579
+ const project2 = await createMockProject(testDir, "project2");
580
+
581
+ const projects = [
582
+ { name: "project1", path: project1 },
583
+ { name: "project2", path: project2 },
584
+ ];
585
+
586
+ const index = { version: 1, paths: {} as Record<string, string[]>, updatedAt: "" };
587
+
588
+ // Simulate registerDiscoveredProjects
589
+ for (const { name, path } of projects) {
590
+ if (!index.paths[name]) {
591
+ index.paths[name] = [];
592
+ }
593
+ if (!index.paths[name].includes(path)) {
594
+ index.paths[name].push(path);
595
+ }
596
+ }
597
+
598
+ expect(Object.keys(index.paths)).toHaveLength(2);
599
+ expect(index.paths.project1).toContain(project1);
600
+ expect(index.paths.project2).toContain(project2);
601
+ });
602
+
603
+ it("merges with existing paths", async () => {
604
+ const existingProject = await createMockProject(join(testDir, "existing"), "project1");
605
+ const newProject = await createMockProject(join(testDir, "new"), "project1");
606
+
607
+ // Start with existing path
608
+ const index = {
609
+ version: 1,
610
+ paths: { project1: [existingProject] } as Record<string, string[]>,
611
+ updatedAt: "",
612
+ };
613
+
614
+ // Register new discovery
615
+ const projects = [{ name: "project1", path: newProject }];
616
+
617
+ for (const { name, path } of projects) {
618
+ if (!index.paths[name]) {
619
+ index.paths[name] = [];
620
+ }
621
+ if (!index.paths[name].includes(path)) {
622
+ index.paths[name].push(path);
623
+ }
624
+ }
625
+
626
+ expect(index.paths.project1).toHaveLength(2);
627
+ expect(index.paths.project1).toContain(existingProject);
628
+ expect(index.paths.project1).toContain(newProject);
629
+ });
630
+ });
631
+
632
+ describe("hasWranglerConfig helper", () => {
633
+ it("detects wrangler.jsonc", async () => {
634
+ const projectDir = await createMockProject(testDir, "jsonc-project", "jsonc");
635
+ expect(existsSync(join(projectDir, "wrangler.jsonc"))).toBe(true);
636
+ });
637
+
638
+ it("detects wrangler.toml", async () => {
639
+ const projectDir = await createMockProject(testDir, "toml-project", "toml");
640
+ expect(existsSync(join(projectDir, "wrangler.toml"))).toBe(true);
641
+ });
642
+
643
+ it("detects wrangler.json", async () => {
644
+ const projectDir = await createMockProject(testDir, "json-project", "json");
645
+ expect(existsSync(join(projectDir, "wrangler.json"))).toBe(true);
646
+ });
647
+
648
+ it("returns false for directory without config", async () => {
649
+ const emptyDir = join(testDir, "empty");
650
+ await mkdir(emptyDir);
651
+
652
+ const hasConfig =
653
+ existsSync(join(emptyDir, "wrangler.jsonc")) ||
654
+ existsSync(join(emptyDir, "wrangler.toml")) ||
655
+ existsSync(join(emptyDir, "wrangler.json"));
656
+
657
+ expect(hasConfig).toBe(false);
658
+ });
659
+ });
660
+
661
+ describe("edge cases", () => {
662
+ it("handles empty index file gracefully", async () => {
663
+ await writeFile(testIndexPath, "");
664
+
665
+ try {
666
+ await Bun.file(testIndexPath).json();
667
+ expect(true).toBe(false); // Should not reach here
668
+ } catch {
669
+ // Empty file is not valid JSON
670
+ expect(true).toBe(true);
671
+ }
672
+ });
673
+
674
+ it("handles index with empty paths object", async () => {
675
+ await writeTestIndex({ version: 1, paths: {}, updatedAt: "2024-12-28T00:00:00.000Z" });
676
+
677
+ const result = await readTestIndex();
678
+ expect(result).toEqual({
679
+ version: 1,
680
+ paths: {},
681
+ updatedAt: "2024-12-28T00:00:00.000Z",
682
+ });
683
+ });
684
+
685
+ it("converts relative paths to absolute", async () => {
686
+ // The resolve() function converts relative to absolute
687
+ const { resolve } = await import("node:path");
688
+
689
+ const relativePath = "./my-project";
690
+ const absolutePath = resolve(relativePath);
691
+
692
+ expect(absolutePath).not.toBe(relativePath);
693
+ expect(absolutePath.startsWith("/")).toBe(true);
694
+ });
695
+
696
+ it("stores absolute paths in index", async () => {
697
+ const projectDir = await createMockProject(testDir, "test-project");
698
+
699
+ const index = { version: 1, paths: {} as Record<string, string[]>, updatedAt: "" };
700
+
701
+ // Use resolve to ensure absolute path
702
+ const { resolve } = await import("node:path");
703
+ const absolutePath = resolve(projectDir);
704
+
705
+ if (!index.paths["test-project"]) {
706
+ index.paths["test-project"] = [];
707
+ }
708
+ index.paths["test-project"].push(absolutePath);
709
+
710
+ // Verify it's an absolute path
711
+ expect(index.paths["test-project"][0].startsWith("/")).toBe(true);
712
+ });
713
+ });
714
+ });
715
+
716
+ /**
717
+ * Integration tests that use the actual module functions
718
+ * Note: scanDirectoryForProjects is safe to test as it doesn't depend on INDEX_PATH
719
+ */
720
+ describe("local-paths integration", () => {
721
+ let testDir: string;
722
+
723
+ beforeEach(async () => {
724
+ // Create fresh temp directory for each test
725
+ const timestamp = Date.now();
726
+ const random = Math.random().toString(36).substring(7);
727
+ testDir = join(tmpdir(), `jack-integration-${timestamp}-${random}`);
728
+ await mkdir(testDir, { recursive: true });
729
+ });
730
+
731
+ afterEach(async () => {
732
+ await rm(testDir, { recursive: true, force: true });
733
+ });
734
+
735
+ describe("scanDirectoryForProjects - real function", () => {
736
+ it("discovers projects with wrangler.jsonc", async () => {
737
+ // Create a real project structure
738
+ const projectDir = join(testDir, "my-api");
739
+ await mkdir(projectDir, { recursive: true });
740
+ await writeFile(
741
+ join(projectDir, "wrangler.jsonc"),
742
+ JSON.stringify({ name: "my-api", main: "src/index.ts" }),
743
+ );
744
+
745
+ // Import and call the real function
746
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
747
+ const discovered = await scanDirectoryForProjects(testDir);
748
+
749
+ expect(discovered).toHaveLength(1);
750
+ expect(discovered[0].name).toBe("my-api");
751
+ expect(discovered[0].path).toBe(projectDir);
752
+ });
753
+
754
+ it("discovers projects with wrangler.toml", async () => {
755
+ const projectDir = join(testDir, "toml-project");
756
+ await mkdir(projectDir, { recursive: true });
757
+ await writeFile(
758
+ join(projectDir, "wrangler.toml"),
759
+ 'name = "toml-project"\nmain = "src/index.ts"',
760
+ );
761
+
762
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
763
+ const discovered = await scanDirectoryForProjects(testDir);
764
+
765
+ expect(discovered).toHaveLength(1);
766
+ expect(discovered[0].name).toBe("toml-project");
767
+ });
768
+
769
+ it("discovers multiple nested projects", async () => {
770
+ // Create multiple projects in subdirectories
771
+ const project1 = join(testDir, "apps", "api");
772
+ const project2 = join(testDir, "apps", "web");
773
+ const project3 = join(testDir, "services", "worker");
774
+
775
+ await mkdir(project1, { recursive: true });
776
+ await mkdir(project2, { recursive: true });
777
+ await mkdir(project3, { recursive: true });
778
+
779
+ await writeFile(join(project1, "wrangler.jsonc"), JSON.stringify({ name: "api" }));
780
+ await writeFile(join(project2, "wrangler.jsonc"), JSON.stringify({ name: "web" }));
781
+ await writeFile(join(project3, "wrangler.toml"), 'name = "worker"');
782
+
783
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
784
+ const discovered = await scanDirectoryForProjects(testDir);
785
+
786
+ expect(discovered).toHaveLength(3);
787
+ const names = discovered.map((p) => p.name).sort();
788
+ expect(names).toEqual(["api", "web", "worker"]);
789
+ });
790
+
791
+ it("respects maxDepth parameter", async () => {
792
+ // Create a deeply nested project
793
+ const deepProject = join(testDir, "level1", "level2", "level3", "level4", "deep-project");
794
+ await mkdir(deepProject, { recursive: true });
795
+ await writeFile(
796
+ join(deepProject, "wrangler.jsonc"),
797
+ JSON.stringify({ name: "deep-project" }),
798
+ );
799
+
800
+ // Also create a shallow project
801
+ const shallowProject = join(testDir, "shallow");
802
+ await mkdir(shallowProject, { recursive: true });
803
+ await writeFile(join(shallowProject, "wrangler.jsonc"), JSON.stringify({ name: "shallow" }));
804
+
805
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
806
+
807
+ // With maxDepth=2, should only find shallow project
808
+ const discovered = await scanDirectoryForProjects(testDir, 2);
809
+
810
+ expect(discovered).toHaveLength(1);
811
+ expect(discovered[0].name).toBe("shallow");
812
+ });
813
+
814
+ it("skips node_modules directory", async () => {
815
+ // Create a project in node_modules (should be skipped)
816
+ const nodeModulesProject = join(testDir, "node_modules", "some-package");
817
+ await mkdir(nodeModulesProject, { recursive: true });
818
+ await writeFile(
819
+ join(nodeModulesProject, "wrangler.jsonc"),
820
+ JSON.stringify({ name: "should-skip" }),
821
+ );
822
+
823
+ // Create a regular project
824
+ const realProject = join(testDir, "real-project");
825
+ await mkdir(realProject, { recursive: true });
826
+ await writeFile(
827
+ join(realProject, "wrangler.jsonc"),
828
+ JSON.stringify({ name: "real-project" }),
829
+ );
830
+
831
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
832
+ const discovered = await scanDirectoryForProjects(testDir);
833
+
834
+ expect(discovered).toHaveLength(1);
835
+ expect(discovered[0].name).toBe("real-project");
836
+ });
837
+
838
+ it("does not recurse into found projects", async () => {
839
+ // Create a project with a nested sub-project
840
+ const parentProject = join(testDir, "parent");
841
+ await mkdir(parentProject, { recursive: true });
842
+ await writeFile(join(parentProject, "wrangler.jsonc"), JSON.stringify({ name: "parent" }));
843
+
844
+ // Create a nested project inside the parent
845
+ const nestedProject = join(parentProject, "packages", "child");
846
+ await mkdir(nestedProject, { recursive: true });
847
+ await writeFile(join(nestedProject, "wrangler.jsonc"), JSON.stringify({ name: "child" }));
848
+
849
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
850
+ const discovered = await scanDirectoryForProjects(testDir);
851
+
852
+ // Should only find parent, not child
853
+ expect(discovered).toHaveLength(1);
854
+ expect(discovered[0].name).toBe("parent");
855
+ });
856
+
857
+ it("returns empty array for directory with no projects", async () => {
858
+ // Create some files but no wrangler configs
859
+ await writeFile(join(testDir, "README.md"), "# Hello");
860
+ await mkdir(join(testDir, "src"));
861
+ await writeFile(join(testDir, "src", "index.ts"), "export {}");
862
+
863
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
864
+ const discovered = await scanDirectoryForProjects(testDir);
865
+
866
+ expect(discovered).toHaveLength(0);
867
+ });
868
+
869
+ it("handles permission errors gracefully", async () => {
870
+ // Create a normal project
871
+ const project = join(testDir, "accessible");
872
+ await mkdir(project, { recursive: true });
873
+ await writeFile(join(project, "wrangler.jsonc"), JSON.stringify({ name: "accessible" }));
874
+
875
+ const { scanDirectoryForProjects } = await import("./local-paths.ts");
876
+ const discovered = await scanDirectoryForProjects(testDir);
877
+
878
+ // Should find the accessible project
879
+ expect(discovered.length).toBeGreaterThanOrEqual(1);
880
+ expect(discovered.some((p) => p.name === "accessible")).toBe(true);
881
+ });
882
+ });
883
+
884
+ describe("LocalPathsIndex structure", () => {
885
+ it("validates index structure", () => {
886
+ // Test the expected structure
887
+ const validIndex = {
888
+ version: 1 as const,
889
+ paths: {
890
+ "project-a": ["/path/to/a"],
891
+ "project-b": ["/path/to/b1", "/path/to/b2"],
892
+ },
893
+ updatedAt: "2024-12-28T00:00:00.000Z",
894
+ };
895
+
896
+ expect(validIndex.version).toBe(1);
897
+ expect(typeof validIndex.paths).toBe("object");
898
+ expect(typeof validIndex.updatedAt).toBe("string");
899
+ expect(Array.isArray(validIndex.paths["project-a"])).toBe(true);
900
+ });
901
+ });
902
+ });