@getjack/jack 0.1.4 → 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 (54) hide show
  1. package/package.json +2 -6
  2. package/src/commands/agents.ts +9 -24
  3. package/src/commands/clone.ts +27 -0
  4. package/src/commands/down.ts +31 -57
  5. package/src/commands/feedback.ts +4 -5
  6. package/src/commands/link.ts +147 -0
  7. package/src/commands/logs.ts +8 -18
  8. package/src/commands/new.ts +7 -1
  9. package/src/commands/projects.ts +162 -105
  10. package/src/commands/secrets.ts +7 -6
  11. package/src/commands/services.ts +5 -4
  12. package/src/commands/tag.ts +282 -0
  13. package/src/commands/unlink.ts +30 -0
  14. package/src/index.ts +46 -1
  15. package/src/lib/auth/index.ts +2 -0
  16. package/src/lib/auth/store.ts +26 -2
  17. package/src/lib/binding-validator.ts +4 -13
  18. package/src/lib/build-helper.ts +93 -5
  19. package/src/lib/control-plane.ts +48 -0
  20. package/src/lib/deploy-mode.ts +1 -1
  21. package/src/lib/managed-deploy.ts +11 -1
  22. package/src/lib/managed-down.ts +7 -20
  23. package/src/lib/paths-index.test.ts +546 -0
  24. package/src/lib/paths-index.ts +310 -0
  25. package/src/lib/project-link.test.ts +459 -0
  26. package/src/lib/project-link.ts +279 -0
  27. package/src/lib/project-list.test.ts +581 -0
  28. package/src/lib/project-list.ts +445 -0
  29. package/src/lib/project-operations.ts +304 -183
  30. package/src/lib/project-resolver.ts +191 -211
  31. package/src/lib/tags.ts +389 -0
  32. package/src/lib/telemetry.ts +81 -168
  33. package/src/lib/zip-packager.ts +9 -0
  34. package/src/templates/index.ts +5 -3
  35. package/templates/api/.jack/template.json +4 -0
  36. package/templates/hello/.jack/template.json +4 -0
  37. package/templates/miniapp/.jack/template.json +4 -0
  38. package/templates/nextjs/.jack.json +28 -0
  39. package/templates/nextjs/app/globals.css +9 -0
  40. package/templates/nextjs/app/isr-test/page.tsx +22 -0
  41. package/templates/nextjs/app/layout.tsx +19 -0
  42. package/templates/nextjs/app/page.tsx +8 -0
  43. package/templates/nextjs/bun.lock +2232 -0
  44. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  45. package/templates/nextjs/next-env.d.ts +6 -0
  46. package/templates/nextjs/next.config.ts +8 -0
  47. package/templates/nextjs/open-next.config.ts +6 -0
  48. package/templates/nextjs/package.json +24 -0
  49. package/templates/nextjs/public/_headers +2 -0
  50. package/templates/nextjs/tsconfig.json +44 -0
  51. package/templates/nextjs/wrangler.jsonc +17 -0
  52. package/src/lib/local-paths.test.ts +0 -902
  53. package/src/lib/local-paths.ts +0 -258
  54. package/src/lib/registry.ts +0 -181
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Project List - data layer and formatters for jack ls
3
+ *
4
+ * Provides:
5
+ * - ProjectListItem interface for display
6
+ * - Conversion from ResolvedProject
7
+ * - Sorting/filtering helpers
8
+ * - Output formatters for grouped and flat views
9
+ */
10
+
11
+ import { homedir } from "node:os";
12
+ import { dirname } from "node:path";
13
+ import type { ResolvedProject } from "./project-resolver.ts";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Display-focused project representation
21
+ */
22
+ export interface ProjectListItem {
23
+ name: string;
24
+ status: "live" | "error" | "local-only" | "syncing" | "auth-expired";
25
+ url: string | null;
26
+ localPath: string | null;
27
+ updatedAt: string | null;
28
+ isLocal: boolean;
29
+ isCloudOnly: boolean;
30
+ errorMessage?: string;
31
+ tags?: string[];
32
+ }
33
+
34
+ /**
35
+ * Projects grouped by display section
36
+ */
37
+ export interface GroupedProjects {
38
+ errors: ProjectListItem[];
39
+ local: ProjectListItem[];
40
+ cloudOnly: ProjectListItem[];
41
+ }
42
+
43
+ // ============================================================================
44
+ // Colors
45
+ // ============================================================================
46
+
47
+ const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
48
+
49
+ export const colors = {
50
+ reset: isColorEnabled ? "\x1b[0m" : "",
51
+ dim: isColorEnabled ? "\x1b[90m" : "",
52
+ green: isColorEnabled ? "\x1b[32m" : "",
53
+ yellow: isColorEnabled ? "\x1b[33m" : "",
54
+ red: isColorEnabled ? "\x1b[31m" : "",
55
+ cyan: isColorEnabled ? "\x1b[36m" : "",
56
+ bold: isColorEnabled ? "\x1b[1m" : "",
57
+ };
58
+
59
+ // Neon tag colors - colorblind-safe, work on light/dark themes
60
+ const TAG_COLORS = isColorEnabled
61
+ ? [
62
+ "\x1b[96m", // bright cyan
63
+ "\x1b[95m", // bright magenta
64
+ "\x1b[94m", // bright blue
65
+ "\x1b[92m", // bright green
66
+ "\x1b[93m", // bright yellow
67
+ "\x1b[97m", // bright white
68
+ ]
69
+ : [];
70
+
71
+ /**
72
+ * Hash a tag name to a consistent color index
73
+ */
74
+ function hashTag(tag: string): number {
75
+ let hash = 0;
76
+ for (const char of tag) {
77
+ hash = (hash << 5) - hash + char.charCodeAt(0);
78
+ hash = hash & hash; // Convert to 32bit integer
79
+ }
80
+ return Math.abs(hash) % TAG_COLORS.length;
81
+ }
82
+
83
+ /**
84
+ * Build a color map for all unique tags across projects
85
+ * Ensures same tag always gets same color
86
+ */
87
+ export function buildTagColorMap(items: ProjectListItem[]): Map<string, string> {
88
+ const colorMap = new Map<string, string>();
89
+ if (!isColorEnabled || TAG_COLORS.length === 0) return colorMap;
90
+
91
+ // Collect all unique tags
92
+ const allTags = new Set<string>();
93
+ for (const item of items) {
94
+ for (const tag of item.tags ?? []) {
95
+ allTags.add(tag);
96
+ }
97
+ }
98
+
99
+ // Assign consistent colors based on hash
100
+ for (const tag of allTags) {
101
+ const colorIndex = hashTag(tag);
102
+ colorMap.set(tag, TAG_COLORS[colorIndex] || "");
103
+ }
104
+
105
+ return colorMap;
106
+ }
107
+
108
+ // ============================================================================
109
+ // Status Icons
110
+ // ============================================================================
111
+
112
+ export const STATUS_ICONS: Record<ProjectListItem["status"], string> = {
113
+ live: "\u25CF", // ●
114
+ error: "\u2716", // ✖
115
+ "local-only": "\u25CC", // ◌
116
+ syncing: "\u25D0", // ◐
117
+ "auth-expired": "\u26A0", // ⚠
118
+ };
119
+
120
+ // ============================================================================
121
+ // Data Transformation
122
+ // ============================================================================
123
+
124
+ /**
125
+ * Convert ResolvedProject[] to ProjectListItem[]
126
+ */
127
+ export function toListItems(projects: ResolvedProject[]): ProjectListItem[] {
128
+ return projects.map((proj) => ({
129
+ name: proj.name,
130
+ status: proj.status as ProjectListItem["status"],
131
+ url: proj.url || null,
132
+ localPath: proj.localPath || null,
133
+ updatedAt: proj.updatedAt || null,
134
+ isLocal: !!proj.localPath && proj.sources.filesystem,
135
+ isCloudOnly: !proj.localPath && proj.sources.controlPlane,
136
+ errorMessage: proj.errorMessage,
137
+ tags: proj.tags,
138
+ }));
139
+ }
140
+
141
+ // ============================================================================
142
+ // Sorting & Filtering
143
+ // ============================================================================
144
+
145
+ /**
146
+ * Sort by updatedAt descending (most recent first)
147
+ * Items without updatedAt are sorted to the end
148
+ */
149
+ export function sortByUpdated(items: ProjectListItem[]): ProjectListItem[] {
150
+ return [...items].sort((a, b) => {
151
+ // Items without dates go to the end
152
+ if (!a.updatedAt && !b.updatedAt) return a.name.localeCompare(b.name);
153
+ if (!a.updatedAt) return 1;
154
+ if (!b.updatedAt) return -1;
155
+
156
+ // Most recent first
157
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Group projects into sections for display
163
+ */
164
+ export function groupProjects(items: ProjectListItem[]): GroupedProjects {
165
+ const errors: ProjectListItem[] = [];
166
+ const local: ProjectListItem[] = [];
167
+ const cloudOnly: ProjectListItem[] = [];
168
+
169
+ for (const item of items) {
170
+ if (item.status === "error" || item.status === "auth-expired") {
171
+ errors.push(item);
172
+ } else if (item.isLocal) {
173
+ local.push(item);
174
+ } else if (item.isCloudOnly) {
175
+ cloudOnly.push(item);
176
+ }
177
+ }
178
+
179
+ return { errors, local, cloudOnly };
180
+ }
181
+
182
+ /**
183
+ * Filter items by status
184
+ */
185
+ export function filterByStatus(items: ProjectListItem[], status: string): ProjectListItem[] {
186
+ // Handle "local" as an alias for "local-only"
187
+ const normalizedStatus = status === "local" ? "local-only" : status;
188
+ return items.filter((item) => item.status === normalizedStatus);
189
+ }
190
+
191
+ /**
192
+ * Filter items by tag
193
+ * When multiple tags provided, uses AND logic (project must have ALL tags)
194
+ */
195
+ export function filterByTag(items: ProjectListItem[], tags: string[]): ProjectListItem[] {
196
+ if (tags.length === 0) return items;
197
+
198
+ return items.filter((item) => {
199
+ const projectTags = item.tags ?? [];
200
+ // AND logic: project must have ALL specified tags
201
+ return tags.every((tag) => projectTags.includes(tag));
202
+ });
203
+ }
204
+
205
+ // ============================================================================
206
+ // Path Utilities
207
+ // ============================================================================
208
+
209
+ /**
210
+ * Replace home directory with ~
211
+ */
212
+ export function shortenPath(path: string): string {
213
+ const home = homedir();
214
+ if (path.startsWith(home)) {
215
+ return `~${path.slice(home.length)}`;
216
+ }
217
+ return path;
218
+ }
219
+
220
+ /**
221
+ * Truncate long paths: ~/very/long/path/here -> ~/very/.../here
222
+ */
223
+ export function truncatePath(path: string, maxLen: number): string {
224
+ if (path.length <= maxLen) return path;
225
+
226
+ // Keep first and last parts
227
+ const parts = path.split("/");
228
+ if (parts.length <= 3) {
229
+ // Too few parts to truncate meaningfully
230
+ return `${path.slice(0, maxLen - 3)}...`;
231
+ }
232
+
233
+ // Try to keep first and last part with ... in middle
234
+ const first = parts[0] || "";
235
+ const last = parts[parts.length - 1] || "";
236
+
237
+ // Check if we have room for first/...last
238
+ const truncated = `${first}/.../${last}`;
239
+ if (truncated.length <= maxLen) {
240
+ return truncated;
241
+ }
242
+
243
+ // Fall back to simple truncation
244
+ return `${path.slice(0, maxLen - 3)}...`;
245
+ }
246
+
247
+ // ============================================================================
248
+ // Formatters
249
+ // ============================================================================
250
+
251
+ /**
252
+ * Format tags for inline display after project name
253
+ * - Returns empty string if no tags
254
+ * - Shows up to 3 tags as #tag with neon colors
255
+ * - Truncates with +N if more: #api #prod +2
256
+ * - Each tag gets consistent color across all projects
257
+ */
258
+ export function formatTagsInline(
259
+ tags: string[] | undefined,
260
+ colorMap?: Map<string, string>,
261
+ ): string {
262
+ if (!tags || tags.length === 0) return "";
263
+
264
+ const maxTags = 3;
265
+ const tagsToShow = tags.length <= maxTags ? tags : tags.slice(0, 2);
266
+
267
+ const formatted = tagsToShow.map((tag) => {
268
+ const tagColor = colorMap?.get(tag) || colors.cyan;
269
+ return `${tagColor}#${tag}${colors.reset}`;
270
+ });
271
+
272
+ if (tags.length > maxTags) {
273
+ const remaining = tags.length - 2;
274
+ formatted.push(`${colors.dim}+${remaining}${colors.reset}`);
275
+ }
276
+
277
+ return formatted.join(" ");
278
+ }
279
+
280
+ export interface FormatLineOptions {
281
+ indent?: number;
282
+ showUrl?: boolean;
283
+ tagColorMap?: Map<string, string>;
284
+ }
285
+
286
+ /**
287
+ * Format a single project line
288
+ */
289
+ export function formatProjectLine(item: ProjectListItem, options: FormatLineOptions = {}): string {
290
+ const { indent = 4, showUrl = true, tagColorMap } = options;
291
+ const padding = " ".repeat(indent);
292
+
293
+ const icon = STATUS_ICONS[item.status];
294
+ const statusColor =
295
+ item.status === "error" || item.status === "auth-expired"
296
+ ? colors.red
297
+ : item.status === "live"
298
+ ? colors.green
299
+ : item.status === "syncing"
300
+ ? colors.yellow
301
+ : colors.dim;
302
+
303
+ const name = item.name.slice(0, 20).padEnd(20);
304
+ const tags = formatTagsInline(item.tags, tagColorMap);
305
+ const status = item.status.padEnd(12);
306
+
307
+ let url = "";
308
+ if (showUrl && item.url) {
309
+ url = item.url.replace("https://", "");
310
+ } else if (showUrl && (item.status === "error" || item.status === "auth-expired") && item.errorMessage) {
311
+ url = `${colors.dim}${item.errorMessage}${colors.reset}`;
312
+ }
313
+
314
+ return `${padding}${statusColor}${icon}${colors.reset} ${name}${tags ? ` ${tags}` : ""} ${statusColor}${status}${colors.reset} ${url}`;
315
+ }
316
+
317
+ export interface FormatErrorSectionOptions {
318
+ tagColorMap?: Map<string, string>;
319
+ }
320
+
321
+ /**
322
+ * Format the "Needs attention" (errors) section
323
+ */
324
+ export function formatErrorSection(
325
+ items: ProjectListItem[],
326
+ options: FormatErrorSectionOptions = {},
327
+ ): string {
328
+ if (items.length === 0) return "";
329
+ const { tagColorMap } = options;
330
+
331
+ const lines: string[] = [];
332
+ lines.push(
333
+ ` ${colors.red}${STATUS_ICONS.error} Needs attention (${items.length})${colors.reset}`,
334
+ );
335
+
336
+ for (const item of items) {
337
+ lines.push(formatProjectLine(item, { indent: 4, tagColorMap }));
338
+ }
339
+
340
+ return lines.join("\n");
341
+ }
342
+
343
+ export interface FormatLocalSectionOptions {
344
+ tagColorMap?: Map<string, string>;
345
+ }
346
+
347
+ /**
348
+ * Format the "Local" section, grouped by parent directory
349
+ */
350
+ export function formatLocalSection(
351
+ items: ProjectListItem[],
352
+ options: FormatLocalSectionOptions = {},
353
+ ): string {
354
+ if (items.length === 0) return "";
355
+ const { tagColorMap } = options;
356
+
357
+ // Group by parent directory
358
+ interface DirGroup {
359
+ displayPath: string;
360
+ projects: ProjectListItem[];
361
+ }
362
+
363
+ const groups = new Map<string, DirGroup>();
364
+
365
+ for (const item of items) {
366
+ if (!item.localPath) continue;
367
+ const parent = dirname(item.localPath);
368
+ if (!groups.has(parent)) {
369
+ groups.set(parent, {
370
+ displayPath: shortenPath(parent),
371
+ projects: [],
372
+ });
373
+ }
374
+ groups.get(parent)?.projects.push(item);
375
+ }
376
+
377
+ const lines: string[] = [];
378
+
379
+ for (const [_parentPath, group] of groups) {
380
+ lines.push(` ${colors.dim}${group.displayPath}/${colors.reset}`);
381
+
382
+ const sortedProjects = group.projects.sort((a, b) => a.name.localeCompare(b.name));
383
+
384
+ for (let i = 0; i < sortedProjects.length; i++) {
385
+ const proj = sortedProjects[i];
386
+ if (!proj) continue;
387
+ const isLast = i === sortedProjects.length - 1;
388
+ const prefix = isLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"; // └── or ├──
389
+
390
+ const icon = STATUS_ICONS[proj.status];
391
+ const statusColor =
392
+ proj.status === "error" || proj.status === "auth-expired"
393
+ ? colors.red
394
+ : proj.status === "live"
395
+ ? colors.green
396
+ : proj.status === "syncing"
397
+ ? colors.yellow
398
+ : colors.dim;
399
+
400
+ const url = proj.url ? proj.url.replace("https://", "") : "";
401
+ const tags = formatTagsInline(proj.tags, tagColorMap);
402
+
403
+ lines.push(
404
+ ` ${colors.dim}${prefix}${colors.reset} ${proj.name}${tags ? ` ${tags}` : ""} ${statusColor}${proj.status}${colors.reset}${url ? ` ${url}` : ""}`,
405
+ );
406
+ }
407
+ }
408
+
409
+ return lines.join("\n");
410
+ }
411
+
412
+ export interface FormatCloudSectionOptions {
413
+ limit: number;
414
+ total: number;
415
+ tagColorMap?: Map<string, string>;
416
+ }
417
+
418
+ /**
419
+ * Format the "Cloud" section
420
+ */
421
+ export function formatCloudSection(
422
+ items: ProjectListItem[],
423
+ options: FormatCloudSectionOptions,
424
+ ): string {
425
+ if (items.length === 0) return "";
426
+
427
+ const { limit, total, tagColorMap } = options;
428
+ const showing = items.slice(0, limit);
429
+ const remaining = total - showing.length;
430
+
431
+ const lines: string[] = [];
432
+ lines.push(
433
+ ` ${colors.green}${STATUS_ICONS.live} Cloud (showing ${showing.length} of ${total})${colors.reset}`,
434
+ );
435
+
436
+ for (const item of showing) {
437
+ lines.push(formatProjectLine(item, { indent: 4, tagColorMap }));
438
+ }
439
+
440
+ if (remaining > 0) {
441
+ lines.push(` ${colors.dim}... ${remaining} more${colors.reset}`);
442
+ }
443
+
444
+ return lines.join("\n");
445
+ }