@getjack/jack 0.1.3 → 0.1.5
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/README.md +103 -0
- package/package.json +2 -6
- package/src/commands/agents.ts +9 -24
- package/src/commands/clone.ts +27 -0
- package/src/commands/down.ts +31 -57
- package/src/commands/feedback.ts +4 -5
- package/src/commands/link.ts +147 -0
- package/src/commands/logs.ts +8 -18
- package/src/commands/new.ts +7 -1
- package/src/commands/projects.ts +162 -105
- package/src/commands/secrets.ts +7 -6
- package/src/commands/services.ts +5 -4
- package/src/commands/tag.ts +282 -0
- package/src/commands/unlink.ts +30 -0
- package/src/index.ts +46 -1
- package/src/lib/auth/index.ts +2 -0
- package/src/lib/auth/store.ts +26 -2
- package/src/lib/binding-validator.ts +4 -13
- package/src/lib/build-helper.ts +93 -5
- package/src/lib/control-plane.ts +48 -0
- package/src/lib/deploy-mode.ts +1 -1
- package/src/lib/managed-deploy.ts +11 -1
- package/src/lib/managed-down.ts +7 -20
- package/src/lib/paths-index.test.ts +546 -0
- package/src/lib/paths-index.ts +310 -0
- package/src/lib/project-link.test.ts +459 -0
- package/src/lib/project-link.ts +279 -0
- package/src/lib/project-list.test.ts +581 -0
- package/src/lib/project-list.ts +445 -0
- package/src/lib/project-operations.ts +304 -183
- package/src/lib/project-resolver.ts +191 -211
- package/src/lib/tags.ts +389 -0
- package/src/lib/telemetry.ts +81 -168
- package/src/lib/zip-packager.ts +9 -0
- package/src/templates/index.ts +5 -3
- package/templates/api/.jack/template.json +4 -0
- package/templates/hello/.jack/template.json +4 -0
- package/templates/miniapp/.jack/template.json +4 -0
- package/templates/nextjs/.jack.json +28 -0
- package/templates/nextjs/app/globals.css +9 -0
- package/templates/nextjs/app/isr-test/page.tsx +22 -0
- package/templates/nextjs/app/layout.tsx +19 -0
- package/templates/nextjs/app/page.tsx +8 -0
- package/templates/nextjs/bun.lock +2232 -0
- package/templates/nextjs/cloudflare-env.d.ts +3 -0
- package/templates/nextjs/next-env.d.ts +6 -0
- package/templates/nextjs/next.config.ts +8 -0
- package/templates/nextjs/open-next.config.ts +6 -0
- package/templates/nextjs/package.json +24 -0
- package/templates/nextjs/public/_headers +2 -0
- package/templates/nextjs/tsconfig.json +44 -0
- package/templates/nextjs/wrangler.jsonc +17 -0
- package/src/lib/local-paths.test.ts +0 -902
- package/src/lib/local-paths.ts +0 -258
- package/src/lib/registry.ts +0 -181
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for paths-index.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests the paths index that tracks where projects live locally, keyed by project_id.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type DiscoveredProject,
|
|
15
|
+
type PathsIndex,
|
|
16
|
+
findProjectIdByPath,
|
|
17
|
+
getAllPaths,
|
|
18
|
+
getIndexPath,
|
|
19
|
+
getPathsForProject,
|
|
20
|
+
readPathsIndex,
|
|
21
|
+
registerDiscoveredProjects,
|
|
22
|
+
registerPath,
|
|
23
|
+
scanAndRegisterProjects,
|
|
24
|
+
unregisterPath,
|
|
25
|
+
writePathsIndex,
|
|
26
|
+
} from "./paths-index.ts";
|
|
27
|
+
|
|
28
|
+
import { linkProject } from "./project-link.ts";
|
|
29
|
+
|
|
30
|
+
let testDir: string;
|
|
31
|
+
let testConfigDir: string;
|
|
32
|
+
let originalPathsIndex: string | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a unique temp directory for each test
|
|
36
|
+
*/
|
|
37
|
+
async function createTestDir(): Promise<string> {
|
|
38
|
+
const timestamp = Date.now();
|
|
39
|
+
const random = Math.random().toString(36).substring(7);
|
|
40
|
+
const dir = join(tmpdir(), `jack-paths-test-${timestamp}-${random}`);
|
|
41
|
+
await mkdir(dir, { recursive: true });
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a mock linked project
|
|
47
|
+
*/
|
|
48
|
+
async function createLinkedProject(
|
|
49
|
+
parentDir: string,
|
|
50
|
+
name: string,
|
|
51
|
+
projectId: string,
|
|
52
|
+
deployMode: "managed" | "byo" = "managed",
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
const projectDir = join(parentDir, name);
|
|
55
|
+
await mkdir(projectDir, { recursive: true });
|
|
56
|
+
await linkProject(projectDir, projectId, deployMode);
|
|
57
|
+
return projectDir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Save the current paths index state for restoration after tests
|
|
62
|
+
*/
|
|
63
|
+
async function savePathsIndex(): Promise<void> {
|
|
64
|
+
const indexPath = getIndexPath();
|
|
65
|
+
if (existsSync(indexPath)) {
|
|
66
|
+
originalPathsIndex = await readFile(indexPath, "utf-8");
|
|
67
|
+
} else {
|
|
68
|
+
originalPathsIndex = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Restore the paths index to its original state
|
|
74
|
+
*/
|
|
75
|
+
async function restorePathsIndex(): Promise<void> {
|
|
76
|
+
const indexPath = getIndexPath();
|
|
77
|
+
if (originalPathsIndex !== null) {
|
|
78
|
+
await writeFile(indexPath, originalPathsIndex);
|
|
79
|
+
} else if (existsSync(indexPath)) {
|
|
80
|
+
// Clear the index if it didn't exist before
|
|
81
|
+
await writePathsIndex({ version: 1, paths: {}, updatedAt: "" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("paths-index", () => {
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
testDir = await createTestDir();
|
|
88
|
+
testConfigDir = join(testDir, "config");
|
|
89
|
+
await mkdir(testConfigDir, { recursive: true });
|
|
90
|
+
// Save current index state and start fresh
|
|
91
|
+
await savePathsIndex();
|
|
92
|
+
await writePathsIndex({ version: 1, paths: {}, updatedAt: "" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await rm(testDir, { recursive: true, force: true });
|
|
97
|
+
// Restore original index state
|
|
98
|
+
await restorePathsIndex();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("PathsIndex structure", () => {
|
|
102
|
+
it("validates correct structure", () => {
|
|
103
|
+
const validIndex: PathsIndex = {
|
|
104
|
+
version: 1,
|
|
105
|
+
paths: {
|
|
106
|
+
proj_abc123: ["/path/to/project"],
|
|
107
|
+
proj_def456: ["/path/to/fork1", "/path/to/fork2"],
|
|
108
|
+
},
|
|
109
|
+
updatedAt: "2024-01-15T10:00:00.000Z",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
expect(validIndex.version).toBe(1);
|
|
113
|
+
expect(typeof validIndex.paths).toBe("object");
|
|
114
|
+
expect(Array.isArray(validIndex.paths.proj_abc123)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("readPathsIndex / writePathsIndex", () => {
|
|
119
|
+
it("returns empty index when file does not exist", async () => {
|
|
120
|
+
// Note: This test uses the real CONFIG_DIR, but we're testing the pattern
|
|
121
|
+
const index = await readPathsIndex();
|
|
122
|
+
|
|
123
|
+
expect(index.version).toBe(1);
|
|
124
|
+
expect(typeof index.paths).toBe("object");
|
|
125
|
+
expect(index.updatedAt).toBeDefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("preserves data through write/read cycle", async () => {
|
|
129
|
+
const testIndex: PathsIndex = {
|
|
130
|
+
version: 1,
|
|
131
|
+
paths: {
|
|
132
|
+
proj_test: ["/test/path"],
|
|
133
|
+
},
|
|
134
|
+
updatedAt: "",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await writePathsIndex(testIndex);
|
|
138
|
+
const readBack = await readPathsIndex();
|
|
139
|
+
|
|
140
|
+
expect(readBack.paths.proj_test).toContain("/test/path");
|
|
141
|
+
expect(readBack.updatedAt).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("registerPath", () => {
|
|
146
|
+
it("registers a new path for a new project", async () => {
|
|
147
|
+
const projectDir = await createLinkedProject(testDir, "my-project", "proj_abc123");
|
|
148
|
+
|
|
149
|
+
await registerPath("proj_abc123", projectDir);
|
|
150
|
+
|
|
151
|
+
const index = await readPathsIndex();
|
|
152
|
+
expect(index.paths.proj_abc123).toContain(projectDir);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles duplicate paths (idempotent)", async () => {
|
|
156
|
+
const projectDir = await createLinkedProject(testDir, "my-project", "proj_abc123");
|
|
157
|
+
|
|
158
|
+
await registerPath("proj_abc123", projectDir);
|
|
159
|
+
await registerPath("proj_abc123", projectDir);
|
|
160
|
+
|
|
161
|
+
const index = await readPathsIndex();
|
|
162
|
+
expect(index.paths.proj_abc123).toHaveLength(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("allows multiple paths for the same project", async () => {
|
|
166
|
+
const path1 = await createLinkedProject(testDir, "project1", "proj_abc123");
|
|
167
|
+
const path2 = await createLinkedProject(join(testDir, "forks"), "project2", "proj_abc123");
|
|
168
|
+
|
|
169
|
+
await registerPath("proj_abc123", path1);
|
|
170
|
+
await registerPath("proj_abc123", path2);
|
|
171
|
+
|
|
172
|
+
const index = await readPathsIndex();
|
|
173
|
+
expect(index.paths.proj_abc123).toHaveLength(2);
|
|
174
|
+
expect(index.paths.proj_abc123).toContain(path1);
|
|
175
|
+
expect(index.paths.proj_abc123).toContain(path2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("converts relative paths to absolute", async () => {
|
|
179
|
+
const projectDir = await createLinkedProject(testDir, "rel-project", "proj_rel");
|
|
180
|
+
|
|
181
|
+
// Use relative path
|
|
182
|
+
const originalCwd = process.cwd();
|
|
183
|
+
process.chdir(testDir);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await registerPath("proj_rel", "rel-project");
|
|
187
|
+
const index = await readPathsIndex();
|
|
188
|
+
|
|
189
|
+
// Should store absolute path
|
|
190
|
+
expect(index.paths.proj_rel[0].startsWith("/")).toBe(true);
|
|
191
|
+
} finally {
|
|
192
|
+
process.chdir(originalCwd);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("unregisterPath", () => {
|
|
198
|
+
it("removes an existing path", async () => {
|
|
199
|
+
const projectDir = await createLinkedProject(testDir, "my-project", "proj_abc123");
|
|
200
|
+
|
|
201
|
+
await registerPath("proj_abc123", projectDir);
|
|
202
|
+
await unregisterPath("proj_abc123", projectDir);
|
|
203
|
+
|
|
204
|
+
const index = await readPathsIndex();
|
|
205
|
+
expect(index.paths.proj_abc123).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("handles removing non-existent path gracefully", async () => {
|
|
209
|
+
// Should not throw
|
|
210
|
+
await unregisterPath("proj_nonexistent", "/some/path");
|
|
211
|
+
|
|
212
|
+
const index = await readPathsIndex();
|
|
213
|
+
expect(index.paths.proj_nonexistent).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("keeps other paths when removing one", async () => {
|
|
217
|
+
const path1 = await createLinkedProject(testDir, "project1", "proj_abc123");
|
|
218
|
+
const path2 = await createLinkedProject(join(testDir, "forks"), "project2", "proj_abc123");
|
|
219
|
+
|
|
220
|
+
await registerPath("proj_abc123", path1);
|
|
221
|
+
await registerPath("proj_abc123", path2);
|
|
222
|
+
await unregisterPath("proj_abc123", path1);
|
|
223
|
+
|
|
224
|
+
const index = await readPathsIndex();
|
|
225
|
+
expect(index.paths.proj_abc123).toHaveLength(1);
|
|
226
|
+
expect(index.paths.proj_abc123).toContain(path2);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("getPathsForProject", () => {
|
|
231
|
+
it("returns empty array for unknown project", async () => {
|
|
232
|
+
const paths = await getPathsForProject("proj_unknown");
|
|
233
|
+
expect(paths).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("returns valid paths", async () => {
|
|
237
|
+
const projectDir = await createLinkedProject(testDir, "my-project", "proj_abc123");
|
|
238
|
+
await registerPath("proj_abc123", projectDir);
|
|
239
|
+
|
|
240
|
+
const paths = await getPathsForProject("proj_abc123");
|
|
241
|
+
expect(paths).toHaveLength(1);
|
|
242
|
+
expect(paths).toContain(projectDir);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("prunes paths where .jack/project.json is missing", async () => {
|
|
246
|
+
const validProject = await createLinkedProject(testDir, "valid", "proj_abc123");
|
|
247
|
+
const invalidPath = join(testDir, "invalid");
|
|
248
|
+
await mkdir(invalidPath, { recursive: true });
|
|
249
|
+
// No .jack/project.json in invalidPath
|
|
250
|
+
|
|
251
|
+
// Manually add both to index
|
|
252
|
+
const index = await readPathsIndex();
|
|
253
|
+
index.paths.proj_abc123 = [validProject, invalidPath];
|
|
254
|
+
await writePathsIndex(index);
|
|
255
|
+
|
|
256
|
+
const paths = await getPathsForProject("proj_abc123");
|
|
257
|
+
expect(paths).toHaveLength(1);
|
|
258
|
+
expect(paths).toContain(validProject);
|
|
259
|
+
expect(paths).not.toContain(invalidPath);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("prunes paths where project_id does not match", async () => {
|
|
263
|
+
const path1 = await createLinkedProject(testDir, "project1", "proj_abc123");
|
|
264
|
+
const path2 = await createLinkedProject(testDir, "project2", "proj_different");
|
|
265
|
+
|
|
266
|
+
// Manually add mismatched path to index
|
|
267
|
+
const index = await readPathsIndex();
|
|
268
|
+
index.paths.proj_abc123 = [path1, path2]; // path2 has different project_id
|
|
269
|
+
await writePathsIndex(index);
|
|
270
|
+
|
|
271
|
+
const paths = await getPathsForProject("proj_abc123");
|
|
272
|
+
expect(paths).toHaveLength(1);
|
|
273
|
+
expect(paths).toContain(path1);
|
|
274
|
+
expect(paths).not.toContain(path2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("removes project entry when all paths are invalid", async () => {
|
|
278
|
+
const invalidPath = join(testDir, "invalid");
|
|
279
|
+
await mkdir(invalidPath, { recursive: true });
|
|
280
|
+
|
|
281
|
+
const index = await readPathsIndex();
|
|
282
|
+
index.paths.proj_abc123 = [invalidPath];
|
|
283
|
+
await writePathsIndex(index);
|
|
284
|
+
|
|
285
|
+
const paths = await getPathsForProject("proj_abc123");
|
|
286
|
+
expect(paths).toHaveLength(0);
|
|
287
|
+
|
|
288
|
+
const updatedIndex = await readPathsIndex();
|
|
289
|
+
expect(updatedIndex.paths.proj_abc123).toBeUndefined();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("getAllPaths", () => {
|
|
294
|
+
it("returns empty object when no projects registered", async () => {
|
|
295
|
+
// Clear any existing paths
|
|
296
|
+
await writePathsIndex({ version: 1, paths: {}, updatedAt: "" });
|
|
297
|
+
|
|
298
|
+
const allPaths = await getAllPaths();
|
|
299
|
+
expect(Object.keys(allPaths)).toHaveLength(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("returns all valid paths for all projects", async () => {
|
|
303
|
+
const project1 = await createLinkedProject(testDir, "project1", "proj_one");
|
|
304
|
+
const project2 = await createLinkedProject(testDir, "project2", "proj_two");
|
|
305
|
+
|
|
306
|
+
await registerPath("proj_one", project1);
|
|
307
|
+
await registerPath("proj_two", project2);
|
|
308
|
+
|
|
309
|
+
const allPaths = await getAllPaths();
|
|
310
|
+
expect(allPaths.proj_one).toContain(project1);
|
|
311
|
+
expect(allPaths.proj_two).toContain(project2);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("prunes invalid paths across all projects", async () => {
|
|
315
|
+
const validProject = await createLinkedProject(testDir, "valid", "proj_valid");
|
|
316
|
+
const invalidPath = join(testDir, "invalid");
|
|
317
|
+
await mkdir(invalidPath, { recursive: true });
|
|
318
|
+
|
|
319
|
+
const index = await readPathsIndex();
|
|
320
|
+
index.paths.proj_valid = [validProject];
|
|
321
|
+
index.paths.proj_invalid = [invalidPath];
|
|
322
|
+
await writePathsIndex(index);
|
|
323
|
+
|
|
324
|
+
const allPaths = await getAllPaths();
|
|
325
|
+
expect(allPaths.proj_valid).toBeDefined();
|
|
326
|
+
expect(allPaths.proj_invalid).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("scanAndRegisterProjects", () => {
|
|
331
|
+
it("discovers linked projects", async () => {
|
|
332
|
+
await createLinkedProject(testDir, "project1", "proj_one", "managed");
|
|
333
|
+
await createLinkedProject(testDir, "project2", "proj_two", "byo");
|
|
334
|
+
|
|
335
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
336
|
+
|
|
337
|
+
expect(discovered).toHaveLength(2);
|
|
338
|
+
const ids = discovered.map((p) => p.projectId).sort();
|
|
339
|
+
expect(ids).toEqual(["proj_one", "proj_two"]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("ignores directories without .jack/project.json", async () => {
|
|
343
|
+
await createLinkedProject(testDir, "linked", "proj_linked");
|
|
344
|
+
|
|
345
|
+
// Create directory without .jack
|
|
346
|
+
const unlinkedDir = join(testDir, "unlinked");
|
|
347
|
+
await mkdir(unlinkedDir, { recursive: true });
|
|
348
|
+
// Add wrangler.jsonc to simulate old-style project
|
|
349
|
+
await writeFile(join(unlinkedDir, "wrangler.jsonc"), JSON.stringify({ name: "unlinked" }));
|
|
350
|
+
|
|
351
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
352
|
+
|
|
353
|
+
expect(discovered).toHaveLength(1);
|
|
354
|
+
expect(discovered[0].projectId).toBe("proj_linked");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("respects maxDepth", async () => {
|
|
358
|
+
const deepPath = join(testDir, "a", "b", "c", "d");
|
|
359
|
+
await mkdir(deepPath, { recursive: true });
|
|
360
|
+
await linkProject(deepPath, "proj_deep", "managed");
|
|
361
|
+
|
|
362
|
+
const shallowProject = await createLinkedProject(testDir, "shallow", "proj_shallow");
|
|
363
|
+
|
|
364
|
+
// maxDepth=2: 0=testDir, 1=shallow|a, 2=b
|
|
365
|
+
// Should not find deep project at depth 4
|
|
366
|
+
const discovered = await scanAndRegisterProjects(testDir, 2);
|
|
367
|
+
|
|
368
|
+
expect(discovered).toHaveLength(1);
|
|
369
|
+
expect(discovered[0].projectId).toBe("proj_shallow");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("skips node_modules", async () => {
|
|
373
|
+
const nodeModulesProject = join(testDir, "node_modules", "some-package");
|
|
374
|
+
await mkdir(nodeModulesProject, { recursive: true });
|
|
375
|
+
await linkProject(nodeModulesProject, "proj_skip", "managed");
|
|
376
|
+
|
|
377
|
+
const validProject = await createLinkedProject(testDir, "valid", "proj_valid");
|
|
378
|
+
|
|
379
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
380
|
+
|
|
381
|
+
expect(discovered).toHaveLength(1);
|
|
382
|
+
expect(discovered[0].projectId).toBe("proj_valid");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("skips .git directory", async () => {
|
|
386
|
+
const gitProject = join(testDir, ".git", "hooks");
|
|
387
|
+
await mkdir(gitProject, { recursive: true });
|
|
388
|
+
await linkProject(gitProject, "proj_git", "managed");
|
|
389
|
+
|
|
390
|
+
const validProject = await createLinkedProject(testDir, "valid", "proj_valid");
|
|
391
|
+
|
|
392
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
393
|
+
|
|
394
|
+
expect(discovered).toHaveLength(1);
|
|
395
|
+
expect(discovered[0].projectId).toBe("proj_valid");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("does not recurse into linked projects", async () => {
|
|
399
|
+
const parentProject = await createLinkedProject(testDir, "parent", "proj_parent");
|
|
400
|
+
|
|
401
|
+
// Create nested project inside parent
|
|
402
|
+
const nestedDir = join(parentProject, "packages", "child");
|
|
403
|
+
await mkdir(nestedDir, { recursive: true });
|
|
404
|
+
await linkProject(nestedDir, "proj_child", "managed");
|
|
405
|
+
|
|
406
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
407
|
+
|
|
408
|
+
// Should only find parent, not nested child
|
|
409
|
+
expect(discovered).toHaveLength(1);
|
|
410
|
+
expect(discovered[0].projectId).toBe("proj_parent");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("registers discovered projects in index", async () => {
|
|
414
|
+
await createLinkedProject(testDir, "project1", "proj_one");
|
|
415
|
+
|
|
416
|
+
await scanAndRegisterProjects(testDir);
|
|
417
|
+
|
|
418
|
+
const paths = await getPathsForProject("proj_one");
|
|
419
|
+
expect(paths).toHaveLength(1);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("returns deploy mode info", async () => {
|
|
423
|
+
await createLinkedProject(testDir, "managed-project", "proj_managed", "managed");
|
|
424
|
+
await createLinkedProject(testDir, "byo-project", "proj_byo", "byo");
|
|
425
|
+
|
|
426
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
427
|
+
|
|
428
|
+
const managedProject = discovered.find((p) => p.projectId === "proj_managed");
|
|
429
|
+
const byoProject = discovered.find((p) => p.projectId === "proj_byo");
|
|
430
|
+
|
|
431
|
+
expect(managedProject?.deployMode).toBe("managed");
|
|
432
|
+
expect(byoProject?.deployMode).toBe("byo");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("registerDiscoveredProjects", () => {
|
|
437
|
+
it("registers multiple projects efficiently", async () => {
|
|
438
|
+
const project1 = await createLinkedProject(testDir, "project1", "proj_one");
|
|
439
|
+
const project2 = await createLinkedProject(testDir, "project2", "proj_two");
|
|
440
|
+
|
|
441
|
+
const discovered: DiscoveredProject[] = [
|
|
442
|
+
{ projectId: "proj_one", path: project1, deployMode: "managed" },
|
|
443
|
+
{ projectId: "proj_two", path: project2, deployMode: "byo" },
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
await registerDiscoveredProjects(discovered);
|
|
447
|
+
|
|
448
|
+
const index = await readPathsIndex();
|
|
449
|
+
expect(index.paths.proj_one).toContain(project1);
|
|
450
|
+
expect(index.paths.proj_two).toContain(project2);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("merges with existing paths", async () => {
|
|
454
|
+
const existing = await createLinkedProject(testDir, "existing", "proj_one");
|
|
455
|
+
const newProject = await createLinkedProject(join(testDir, "fork"), "new", "proj_one");
|
|
456
|
+
|
|
457
|
+
// Register existing first
|
|
458
|
+
await registerPath("proj_one", existing);
|
|
459
|
+
|
|
460
|
+
// Then bulk register including same project_id
|
|
461
|
+
await registerDiscoveredProjects([
|
|
462
|
+
{ projectId: "proj_one", path: newProject, deployMode: "managed" },
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
const index = await readPathsIndex();
|
|
466
|
+
expect(index.paths.proj_one).toHaveLength(2);
|
|
467
|
+
expect(index.paths.proj_one).toContain(existing);
|
|
468
|
+
expect(index.paths.proj_one).toContain(newProject);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("findProjectIdByPath", () => {
|
|
473
|
+
it("returns null for unregistered path", async () => {
|
|
474
|
+
const id = await findProjectIdByPath("/some/unknown/path");
|
|
475
|
+
expect(id).toBeNull();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("returns project ID for registered path", async () => {
|
|
479
|
+
const projectDir = await createLinkedProject(testDir, "my-project", "proj_abc123");
|
|
480
|
+
await registerPath("proj_abc123", projectDir);
|
|
481
|
+
|
|
482
|
+
const id = await findProjectIdByPath(projectDir);
|
|
483
|
+
expect(id).toBe("proj_abc123");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("handles relative paths", async () => {
|
|
487
|
+
const projectDir = await createLinkedProject(testDir, "rel-project", "proj_rel");
|
|
488
|
+
await registerPath("proj_rel", projectDir);
|
|
489
|
+
|
|
490
|
+
// Test that absolute path works (relative path resolution is cwd-dependent)
|
|
491
|
+
const id = await findProjectIdByPath(projectDir);
|
|
492
|
+
expect(id).toBe("proj_rel");
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe("getIndexPath", () => {
|
|
497
|
+
it("returns the index file path", () => {
|
|
498
|
+
const path = getIndexPath();
|
|
499
|
+
expect(path).toContain("paths.json");
|
|
500
|
+
expect(path).toContain(".config");
|
|
501
|
+
expect(path).toContain("jack");
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("edge cases", () => {
|
|
506
|
+
it("handles empty scan directory", async () => {
|
|
507
|
+
const emptyDir = join(testDir, "empty");
|
|
508
|
+
await mkdir(emptyDir, { recursive: true });
|
|
509
|
+
|
|
510
|
+
const discovered = await scanAndRegisterProjects(emptyDir);
|
|
511
|
+
expect(discovered).toHaveLength(0);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("handles paths with spaces", async () => {
|
|
515
|
+
const spacePath = join(testDir, "path with spaces");
|
|
516
|
+
await createLinkedProject(spacePath, "project", "proj_spaces");
|
|
517
|
+
|
|
518
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
519
|
+
expect(discovered).toHaveLength(1);
|
|
520
|
+
expect(discovered[0].projectId).toBe("proj_spaces");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("handles permission errors gracefully", async () => {
|
|
524
|
+
// Create accessible project
|
|
525
|
+
await createLinkedProject(testDir, "accessible", "proj_accessible");
|
|
526
|
+
|
|
527
|
+
// Scan should work even if some directories are inaccessible
|
|
528
|
+
const discovered = await scanAndRegisterProjects(testDir);
|
|
529
|
+
expect(discovered.some((p) => p.projectId === "proj_accessible")).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("updates updatedAt on write", async () => {
|
|
533
|
+
const before = new Date().toISOString();
|
|
534
|
+
|
|
535
|
+
// Small delay to ensure different timestamp
|
|
536
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
537
|
+
|
|
538
|
+
await writePathsIndex({ version: 1, paths: {}, updatedAt: "" });
|
|
539
|
+
|
|
540
|
+
const index = await readPathsIndex();
|
|
541
|
+
expect(new Date(index.updatedAt).getTime()).toBeGreaterThanOrEqual(
|
|
542
|
+
new Date(before).getTime(),
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|