@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.
Files changed (55) hide show
  1. package/README.md +103 -0
  2. package/package.json +2 -6
  3. package/src/commands/agents.ts +9 -24
  4. package/src/commands/clone.ts +27 -0
  5. package/src/commands/down.ts +31 -57
  6. package/src/commands/feedback.ts +4 -5
  7. package/src/commands/link.ts +147 -0
  8. package/src/commands/logs.ts +8 -18
  9. package/src/commands/new.ts +7 -1
  10. package/src/commands/projects.ts +162 -105
  11. package/src/commands/secrets.ts +7 -6
  12. package/src/commands/services.ts +5 -4
  13. package/src/commands/tag.ts +282 -0
  14. package/src/commands/unlink.ts +30 -0
  15. package/src/index.ts +46 -1
  16. package/src/lib/auth/index.ts +2 -0
  17. package/src/lib/auth/store.ts +26 -2
  18. package/src/lib/binding-validator.ts +4 -13
  19. package/src/lib/build-helper.ts +93 -5
  20. package/src/lib/control-plane.ts +48 -0
  21. package/src/lib/deploy-mode.ts +1 -1
  22. package/src/lib/managed-deploy.ts +11 -1
  23. package/src/lib/managed-down.ts +7 -20
  24. package/src/lib/paths-index.test.ts +546 -0
  25. package/src/lib/paths-index.ts +310 -0
  26. package/src/lib/project-link.test.ts +459 -0
  27. package/src/lib/project-link.ts +279 -0
  28. package/src/lib/project-list.test.ts +581 -0
  29. package/src/lib/project-list.ts +445 -0
  30. package/src/lib/project-operations.ts +304 -183
  31. package/src/lib/project-resolver.ts +191 -211
  32. package/src/lib/tags.ts +389 -0
  33. package/src/lib/telemetry.ts +81 -168
  34. package/src/lib/zip-packager.ts +9 -0
  35. package/src/templates/index.ts +5 -3
  36. package/templates/api/.jack/template.json +4 -0
  37. package/templates/hello/.jack/template.json +4 -0
  38. package/templates/miniapp/.jack/template.json +4 -0
  39. package/templates/nextjs/.jack.json +28 -0
  40. package/templates/nextjs/app/globals.css +9 -0
  41. package/templates/nextjs/app/isr-test/page.tsx +22 -0
  42. package/templates/nextjs/app/layout.tsx +19 -0
  43. package/templates/nextjs/app/page.tsx +8 -0
  44. package/templates/nextjs/bun.lock +2232 -0
  45. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  46. package/templates/nextjs/next-env.d.ts +6 -0
  47. package/templates/nextjs/next.config.ts +8 -0
  48. package/templates/nextjs/open-next.config.ts +6 -0
  49. package/templates/nextjs/package.json +24 -0
  50. package/templates/nextjs/public/_headers +2 -0
  51. package/templates/nextjs/tsconfig.json +44 -0
  52. package/templates/nextjs/wrangler.jsonc +17 -0
  53. package/src/lib/local-paths.test.ts +0 -902
  54. package/src/lib/local-paths.ts +0 -258
  55. package/src/lib/registry.ts +0 -181
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Unit tests for project-list.ts
3
+ *
4
+ * Tests the data layer and formatters for jack ls command.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { homedir } from "node:os";
9
+
10
+ import {
11
+ type ProjectListItem,
12
+ filterByStatus,
13
+ groupProjects,
14
+ shortenPath,
15
+ sortByUpdated,
16
+ toListItems,
17
+ truncatePath,
18
+ } from "./project-list.ts";
19
+ import type { ResolvedProject } from "./project-resolver.ts";
20
+
21
+ // ============================================================================
22
+ // Test Data Factories
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Create a mock ResolvedProject for testing
27
+ */
28
+ function createMockResolvedProject(overrides: Partial<ResolvedProject> = {}): ResolvedProject {
29
+ return {
30
+ name: "test-project",
31
+ slug: "test-project",
32
+ status: "live",
33
+ url: "https://test-project.runjack.xyz",
34
+ sources: {
35
+ controlPlane: true,
36
+ filesystem: false,
37
+ },
38
+ createdAt: "2024-01-01T00:00:00.000Z",
39
+ updatedAt: "2024-01-15T10:00:00.000Z",
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Create a mock ProjectListItem for testing
46
+ */
47
+ function createMockListItem(overrides: Partial<ProjectListItem> = {}): ProjectListItem {
48
+ return {
49
+ name: "test-project",
50
+ status: "live",
51
+ url: "https://test-project.runjack.xyz",
52
+ localPath: null,
53
+ updatedAt: "2024-01-15T10:00:00.000Z",
54
+ isLocal: false,
55
+ isCloudOnly: true,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ // ============================================================================
61
+ // Tests
62
+ // ============================================================================
63
+
64
+ describe("project-list", () => {
65
+ describe("toListItems", () => {
66
+ it("converts managed project to list item", () => {
67
+ const managed = createMockResolvedProject({
68
+ name: "my-api",
69
+ status: "live",
70
+ url: "https://my-api.runjack.xyz",
71
+ sources: { controlPlane: true, filesystem: false },
72
+ updatedAt: "2024-01-20T12:00:00.000Z",
73
+ });
74
+
75
+ const [item] = toListItems([managed]);
76
+
77
+ expect(item).toBeDefined();
78
+ expect(item?.name).toBe("my-api");
79
+ expect(item?.status).toBe("live");
80
+ expect(item?.url).toBe("https://my-api.runjack.xyz");
81
+ expect(item?.localPath).toBeNull();
82
+ expect(item?.updatedAt).toBe("2024-01-20T12:00:00.000Z");
83
+ expect(item?.isLocal).toBe(false);
84
+ expect(item?.isCloudOnly).toBe(true);
85
+ });
86
+
87
+ it("converts BYO/local-only project to list item", () => {
88
+ const byo = createMockResolvedProject({
89
+ name: "local-app",
90
+ status: "local-only",
91
+ url: undefined,
92
+ localPath: "/Users/dev/projects/local-app",
93
+ sources: { controlPlane: false, filesystem: true },
94
+ deployMode: "byo",
95
+ });
96
+
97
+ const [item] = toListItems([byo]);
98
+
99
+ expect(item).toBeDefined();
100
+ expect(item?.name).toBe("local-app");
101
+ expect(item?.status).toBe("local-only");
102
+ expect(item?.url).toBeNull();
103
+ expect(item?.localPath).toBe("/Users/dev/projects/local-app");
104
+ expect(item?.isLocal).toBe(true);
105
+ expect(item?.isCloudOnly).toBe(false);
106
+ });
107
+
108
+ it("handles error status with error message", () => {
109
+ const errorProject = createMockResolvedProject({
110
+ name: "broken-project",
111
+ status: "error",
112
+ errorMessage: "deployment failed",
113
+ url: undefined,
114
+ });
115
+
116
+ const [item] = toListItems([errorProject]);
117
+
118
+ expect(item).toBeDefined();
119
+ expect(item?.status).toBe("error");
120
+ expect(item?.errorMessage).toBe("deployment failed");
121
+ });
122
+
123
+ it("handles project with both local and cloud sources", () => {
124
+ const hybrid = createMockResolvedProject({
125
+ name: "hybrid-project",
126
+ status: "live",
127
+ url: "https://hybrid-project.runjack.xyz",
128
+ localPath: "/Users/dev/projects/hybrid-project",
129
+ sources: { controlPlane: true, filesystem: true },
130
+ });
131
+
132
+ const [item] = toListItems([hybrid]);
133
+
134
+ expect(item).toBeDefined();
135
+ expect(item?.isLocal).toBe(true);
136
+ expect(item?.isCloudOnly).toBe(false);
137
+ expect(item?.url).toBe("https://hybrid-project.runjack.xyz");
138
+ expect(item?.localPath).toBe("/Users/dev/projects/hybrid-project");
139
+ });
140
+
141
+ it("converts multiple projects", () => {
142
+ const projects = [
143
+ createMockResolvedProject({ name: "project-a" }),
144
+ createMockResolvedProject({ name: "project-b" }),
145
+ createMockResolvedProject({ name: "project-c" }),
146
+ ];
147
+
148
+ const items = toListItems(projects);
149
+
150
+ expect(items).toHaveLength(3);
151
+ expect(items.map((i) => i.name)).toEqual(["project-a", "project-b", "project-c"]);
152
+ });
153
+
154
+ it("handles empty array", () => {
155
+ const items = toListItems([]);
156
+ expect(items).toHaveLength(0);
157
+ });
158
+
159
+ it("handles syncing status", () => {
160
+ const syncingProject = createMockResolvedProject({
161
+ name: "syncing-project",
162
+ status: "syncing",
163
+ });
164
+
165
+ const [item] = toListItems([syncingProject]);
166
+
167
+ expect(item?.status).toBe("syncing");
168
+ });
169
+ });
170
+
171
+ describe("sortByUpdated", () => {
172
+ it("sorts items by updatedAt descending (most recent first)", () => {
173
+ const items: ProjectListItem[] = [
174
+ createMockListItem({ name: "oldest", updatedAt: "2024-01-01T00:00:00.000Z" }),
175
+ createMockListItem({ name: "newest", updatedAt: "2024-01-20T00:00:00.000Z" }),
176
+ createMockListItem({ name: "middle", updatedAt: "2024-01-10T00:00:00.000Z" }),
177
+ ];
178
+
179
+ const sorted = sortByUpdated(items);
180
+
181
+ expect(sorted.map((i) => i.name)).toEqual(["newest", "middle", "oldest"]);
182
+ });
183
+
184
+ it("puts items without dates at the end", () => {
185
+ const items: ProjectListItem[] = [
186
+ createMockListItem({ name: "no-date-1", updatedAt: null }),
187
+ createMockListItem({ name: "has-date", updatedAt: "2024-01-15T00:00:00.000Z" }),
188
+ createMockListItem({ name: "no-date-2", updatedAt: null }),
189
+ ];
190
+
191
+ const sorted = sortByUpdated(items);
192
+
193
+ expect(sorted[0]?.name).toBe("has-date");
194
+ // Items without dates sorted alphabetically among themselves
195
+ expect(sorted[1]?.name).toBe("no-date-1");
196
+ expect(sorted[2]?.name).toBe("no-date-2");
197
+ });
198
+
199
+ it("sorts items without dates alphabetically by name", () => {
200
+ const items: ProjectListItem[] = [
201
+ createMockListItem({ name: "zebra", updatedAt: null }),
202
+ createMockListItem({ name: "alpha", updatedAt: null }),
203
+ createMockListItem({ name: "beta", updatedAt: null }),
204
+ ];
205
+
206
+ const sorted = sortByUpdated(items);
207
+
208
+ expect(sorted.map((i) => i.name)).toEqual(["alpha", "beta", "zebra"]);
209
+ });
210
+
211
+ it("handles all items having dates", () => {
212
+ const items: ProjectListItem[] = [
213
+ createMockListItem({ name: "a", updatedAt: "2024-01-05T00:00:00.000Z" }),
214
+ createMockListItem({ name: "b", updatedAt: "2024-01-15T00:00:00.000Z" }),
215
+ ];
216
+
217
+ const sorted = sortByUpdated(items);
218
+
219
+ expect(sorted.map((i) => i.name)).toEqual(["b", "a"]);
220
+ });
221
+
222
+ it("handles all items without dates", () => {
223
+ const items: ProjectListItem[] = [
224
+ createMockListItem({ name: "charlie", updatedAt: null }),
225
+ createMockListItem({ name: "alice", updatedAt: null }),
226
+ ];
227
+
228
+ const sorted = sortByUpdated(items);
229
+
230
+ expect(sorted.map((i) => i.name)).toEqual(["alice", "charlie"]);
231
+ });
232
+
233
+ it("does not mutate original array", () => {
234
+ const items: ProjectListItem[] = [
235
+ createMockListItem({ name: "b", updatedAt: "2024-01-01T00:00:00.000Z" }),
236
+ createMockListItem({ name: "a", updatedAt: "2024-01-15T00:00:00.000Z" }),
237
+ ];
238
+
239
+ const originalOrder = items.map((i) => i.name);
240
+ sortByUpdated(items);
241
+
242
+ expect(items.map((i) => i.name)).toEqual(originalOrder);
243
+ });
244
+
245
+ it("handles empty array", () => {
246
+ const sorted = sortByUpdated([]);
247
+ expect(sorted).toHaveLength(0);
248
+ });
249
+ });
250
+
251
+ describe("groupProjects", () => {
252
+ it("groups error projects into errors array", () => {
253
+ const items: ProjectListItem[] = [
254
+ createMockListItem({ name: "error-1", status: "error" }),
255
+ createMockListItem({ name: "error-2", status: "error" }),
256
+ ];
257
+
258
+ const grouped = groupProjects(items);
259
+
260
+ expect(grouped.errors).toHaveLength(2);
261
+ expect(grouped.local).toHaveLength(0);
262
+ expect(grouped.cloudOnly).toHaveLength(0);
263
+ });
264
+
265
+ it("groups projects with localPath into local array", () => {
266
+ const items: ProjectListItem[] = [
267
+ createMockListItem({
268
+ name: "local-1",
269
+ status: "live",
270
+ localPath: "/path/to/local-1",
271
+ isLocal: true,
272
+ isCloudOnly: false,
273
+ }),
274
+ createMockListItem({
275
+ name: "local-2",
276
+ status: "local-only",
277
+ localPath: "/path/to/local-2",
278
+ isLocal: true,
279
+ isCloudOnly: false,
280
+ }),
281
+ ];
282
+
283
+ const grouped = groupProjects(items);
284
+
285
+ expect(grouped.local).toHaveLength(2);
286
+ expect(grouped.errors).toHaveLength(0);
287
+ expect(grouped.cloudOnly).toHaveLength(0);
288
+ });
289
+
290
+ it("groups cloud-only projects into cloudOnly array", () => {
291
+ const items: ProjectListItem[] = [
292
+ createMockListItem({
293
+ name: "cloud-1",
294
+ status: "live",
295
+ localPath: null,
296
+ isLocal: false,
297
+ isCloudOnly: true,
298
+ }),
299
+ createMockListItem({
300
+ name: "cloud-2",
301
+ status: "live",
302
+ localPath: null,
303
+ isLocal: false,
304
+ isCloudOnly: true,
305
+ }),
306
+ ];
307
+
308
+ const grouped = groupProjects(items);
309
+
310
+ expect(grouped.cloudOnly).toHaveLength(2);
311
+ expect(grouped.errors).toHaveLength(0);
312
+ expect(grouped.local).toHaveLength(0);
313
+ });
314
+
315
+ it("groups mixed projects correctly", () => {
316
+ const items: ProjectListItem[] = [
317
+ createMockListItem({ name: "error-proj", status: "error" }),
318
+ createMockListItem({
319
+ name: "local-proj",
320
+ status: "live",
321
+ localPath: "/path/to/local",
322
+ isLocal: true,
323
+ isCloudOnly: false,
324
+ }),
325
+ createMockListItem({
326
+ name: "cloud-proj",
327
+ status: "live",
328
+ localPath: null,
329
+ isLocal: false,
330
+ isCloudOnly: true,
331
+ }),
332
+ ];
333
+
334
+ const grouped = groupProjects(items);
335
+
336
+ expect(grouped.errors).toHaveLength(1);
337
+ expect(grouped.errors[0]?.name).toBe("error-proj");
338
+ expect(grouped.local).toHaveLength(1);
339
+ expect(grouped.local[0]?.name).toBe("local-proj");
340
+ expect(grouped.cloudOnly).toHaveLength(1);
341
+ expect(grouped.cloudOnly[0]?.name).toBe("cloud-proj");
342
+ });
343
+
344
+ it("error status takes precedence over local/cloud categorization", () => {
345
+ const items: ProjectListItem[] = [
346
+ createMockListItem({
347
+ name: "error-with-local-path",
348
+ status: "error",
349
+ localPath: "/path/to/project",
350
+ isLocal: true,
351
+ isCloudOnly: false,
352
+ }),
353
+ ];
354
+
355
+ const grouped = groupProjects(items);
356
+
357
+ expect(grouped.errors).toHaveLength(1);
358
+ expect(grouped.local).toHaveLength(0);
359
+ });
360
+
361
+ it("handles empty array", () => {
362
+ const grouped = groupProjects([]);
363
+
364
+ expect(grouped.errors).toHaveLength(0);
365
+ expect(grouped.local).toHaveLength(0);
366
+ expect(grouped.cloudOnly).toHaveLength(0);
367
+ });
368
+
369
+ it("projects that are neither local nor cloudOnly are excluded", () => {
370
+ const items: ProjectListItem[] = [
371
+ createMockListItem({
372
+ name: "orphan",
373
+ status: "syncing",
374
+ localPath: null,
375
+ isLocal: false,
376
+ isCloudOnly: false,
377
+ }),
378
+ ];
379
+
380
+ const grouped = groupProjects(items);
381
+
382
+ expect(grouped.errors).toHaveLength(0);
383
+ expect(grouped.local).toHaveLength(0);
384
+ expect(grouped.cloudOnly).toHaveLength(0);
385
+ });
386
+ });
387
+
388
+ describe("filterByStatus", () => {
389
+ const mixedItems: ProjectListItem[] = [
390
+ createMockListItem({ name: "live-1", status: "live" }),
391
+ createMockListItem({ name: "live-2", status: "live" }),
392
+ createMockListItem({ name: "error-1", status: "error" }),
393
+ createMockListItem({ name: "local-only-1", status: "local-only" }),
394
+ createMockListItem({ name: "syncing-1", status: "syncing" }),
395
+ ];
396
+
397
+ it("filters by 'error' status", () => {
398
+ const filtered = filterByStatus(mixedItems, "error");
399
+
400
+ expect(filtered).toHaveLength(1);
401
+ expect(filtered[0]?.name).toBe("error-1");
402
+ });
403
+
404
+ it("filters by 'live' status", () => {
405
+ const filtered = filterByStatus(mixedItems, "live");
406
+
407
+ expect(filtered).toHaveLength(2);
408
+ expect(filtered.map((i) => i.name)).toEqual(["live-1", "live-2"]);
409
+ });
410
+
411
+ it("filters by 'local-only' status", () => {
412
+ const filtered = filterByStatus(mixedItems, "local-only");
413
+
414
+ expect(filtered).toHaveLength(1);
415
+ expect(filtered[0]?.name).toBe("local-only-1");
416
+ });
417
+
418
+ it("treats 'local' as alias for 'local-only'", () => {
419
+ const filtered = filterByStatus(mixedItems, "local");
420
+
421
+ expect(filtered).toHaveLength(1);
422
+ expect(filtered[0]?.name).toBe("local-only-1");
423
+ });
424
+
425
+ it("filters by 'syncing' status", () => {
426
+ const filtered = filterByStatus(mixedItems, "syncing");
427
+
428
+ expect(filtered).toHaveLength(1);
429
+ expect(filtered[0]?.name).toBe("syncing-1");
430
+ });
431
+
432
+ it("returns empty array for non-matching status", () => {
433
+ const filtered = filterByStatus(mixedItems, "nonexistent");
434
+
435
+ expect(filtered).toHaveLength(0);
436
+ });
437
+
438
+ it("handles empty array", () => {
439
+ const filtered = filterByStatus([], "live");
440
+
441
+ expect(filtered).toHaveLength(0);
442
+ });
443
+ });
444
+
445
+ describe("shortenPath", () => {
446
+ it("replaces home directory with ~", () => {
447
+ const home = homedir();
448
+ const path = `${home}/projects/my-app`;
449
+
450
+ const shortened = shortenPath(path);
451
+
452
+ expect(shortened).toBe("~/projects/my-app");
453
+ });
454
+
455
+ it("handles home directory root", () => {
456
+ const home = homedir();
457
+
458
+ const shortened = shortenPath(home);
459
+
460
+ expect(shortened).toBe("~");
461
+ });
462
+
463
+ it("leaves paths not in home directory unchanged", () => {
464
+ const path = "/var/www/my-app";
465
+
466
+ const shortened = shortenPath(path);
467
+
468
+ expect(shortened).toBe("/var/www/my-app");
469
+ });
470
+
471
+ it("leaves relative paths unchanged", () => {
472
+ const path = "projects/my-app";
473
+
474
+ const shortened = shortenPath(path);
475
+
476
+ expect(shortened).toBe("projects/my-app");
477
+ });
478
+
479
+ it("handles nested home directory paths", () => {
480
+ const home = homedir();
481
+ const path = `${home}/a/b/c/d/project`;
482
+
483
+ const shortened = shortenPath(path);
484
+
485
+ expect(shortened).toBe("~/a/b/c/d/project");
486
+ });
487
+
488
+ it("handles path starting with home prefix (current behavior replaces)", () => {
489
+ const home = homedir();
490
+ // Note: Current implementation replaces any path starting with home string
491
+ // e.g., if home is /Users/alice, /Users/alicesmith becomes ~smith
492
+ // This tests the current behavior - a stricter implementation would check for /
493
+ const fakePath = `${home}smith/project`;
494
+
495
+ const shortened = shortenPath(fakePath);
496
+
497
+ // Current behavior: replaces the home prefix regardless of directory boundary
498
+ expect(shortened).toBe("~smith/project");
499
+ });
500
+ });
501
+
502
+ describe("truncatePath", () => {
503
+ it("returns short paths unchanged", () => {
504
+ const path = "~/projects/app";
505
+
506
+ const truncated = truncatePath(path, 50);
507
+
508
+ expect(truncated).toBe("~/projects/app");
509
+ });
510
+
511
+ it("truncates long paths with ... in middle", () => {
512
+ const path = "~/very/long/directory/path/to/my/project";
513
+
514
+ const truncated = truncatePath(path, 25);
515
+
516
+ expect(truncated).toContain("...");
517
+ expect(truncated.length).toBeLessThanOrEqual(25);
518
+ });
519
+
520
+ it("keeps first and last parts when truncating", () => {
521
+ const path = "~/first/middle1/middle2/middle3/last";
522
+
523
+ const truncated = truncatePath(path, 20);
524
+
525
+ expect(truncated).toContain("~");
526
+ expect(truncated).toContain("last");
527
+ expect(truncated).toContain("...");
528
+ });
529
+
530
+ it("handles paths with few parts by simple truncation", () => {
531
+ const path = "~/ab/cd";
532
+
533
+ const truncated = truncatePath(path, 5);
534
+
535
+ expect(truncated.length).toBeLessThanOrEqual(5);
536
+ expect(truncated).toContain("...");
537
+ });
538
+
539
+ it("returns path as-is when exactly at max length", () => {
540
+ const path = "~/projects/app";
541
+ const maxLen = path.length;
542
+
543
+ const truncated = truncatePath(path, maxLen);
544
+
545
+ expect(truncated).toBe(path);
546
+ });
547
+
548
+ it("handles empty path", () => {
549
+ const truncated = truncatePath("", 10);
550
+
551
+ expect(truncated).toBe("");
552
+ });
553
+
554
+ it("handles single segment path", () => {
555
+ const path = "verylongsinglesegment";
556
+
557
+ const truncated = truncatePath(path, 10);
558
+
559
+ expect(truncated.length).toBeLessThanOrEqual(10);
560
+ expect(truncated).toContain("...");
561
+ });
562
+
563
+ it("handles absolute paths", () => {
564
+ const path = "/usr/local/bin/very/long/path/to/executable";
565
+
566
+ const truncated = truncatePath(path, 25);
567
+
568
+ expect(truncated).toContain("...");
569
+ expect(truncated.length).toBeLessThanOrEqual(25);
570
+ });
571
+
572
+ it("falls back to simple truncation when first/last is too long", () => {
573
+ const path = "~/a/verylongdirectoryname";
574
+
575
+ const truncated = truncatePath(path, 15);
576
+
577
+ // The truncated result should fit within maxLen
578
+ expect(truncated.length).toBeLessThanOrEqual(15);
579
+ });
580
+ });
581
+ });