@getjack/jack 0.1.28 → 0.1.30

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 (125) hide show
  1. package/package.json +1 -1
  2. package/src/commands/cd.ts +163 -0
  3. package/src/commands/clone.ts +112 -68
  4. package/src/commands/domain.ts +506 -0
  5. package/src/commands/domains.ts +215 -0
  6. package/src/commands/down.ts +18 -12
  7. package/src/commands/hack.ts +185 -8
  8. package/src/commands/init.ts +52 -1
  9. package/src/commands/link.ts +25 -43
  10. package/src/commands/logs.ts +2 -2
  11. package/src/commands/mcp.ts +74 -3
  12. package/src/commands/new.ts +48 -54
  13. package/src/commands/projects.ts +53 -10
  14. package/src/commands/secrets.ts +5 -1
  15. package/src/commands/services.ts +16 -4
  16. package/src/commands/shell-init.ts +43 -0
  17. package/src/commands/ship.ts +2 -11
  18. package/src/commands/skills.ts +335 -0
  19. package/src/commands/update.ts +31 -0
  20. package/src/commands/upgrade.ts +14 -0
  21. package/src/index.ts +116 -24
  22. package/src/lib/agent-integration.ts +1 -2
  23. package/src/lib/agents.ts +2 -2
  24. package/src/lib/auth/login-flow.ts +1 -1
  25. package/src/lib/clone-core.ts +252 -0
  26. package/src/lib/config.ts +22 -0
  27. package/src/lib/control-plane.ts +31 -5
  28. package/src/lib/fuzzy.ts +93 -0
  29. package/src/lib/managed-deploy.ts +4 -1
  30. package/src/lib/managed-down.ts +20 -5
  31. package/src/lib/output.ts +90 -9
  32. package/src/lib/picker.ts +406 -0
  33. package/src/lib/project-detection.ts +5 -2
  34. package/src/lib/project-list.ts +66 -5
  35. package/src/lib/project-operations.ts +68 -6
  36. package/src/lib/prompts.ts +1 -1
  37. package/src/lib/services/db-execute.ts +8 -1
  38. package/src/lib/services/db-list.ts +4 -1
  39. package/src/lib/services/domain-operations.ts +379 -0
  40. package/src/lib/services/storage-config.ts +1 -5
  41. package/src/lib/services/storage-delete.ts +1 -1
  42. package/src/lib/services/storage-info.ts +2 -4
  43. package/src/lib/services/vectorize-config.ts +1 -5
  44. package/src/lib/services/vectorize-create.ts +3 -1
  45. package/src/lib/shell-integration.ts +202 -0
  46. package/src/lib/telemetry-config.ts +50 -4
  47. package/src/lib/telemetry.ts +71 -2
  48. package/src/lib/version-check.ts +1 -3
  49. package/src/lib/wrangler-config.test.ts +2 -2
  50. package/src/lib/wrangler-config.ts +1 -1
  51. package/src/lib/zip-packager.ts +1 -3
  52. package/src/mcp/tools/index.ts +261 -7
  53. package/src/templates/index.ts +10 -1
  54. package/templates/ai-chat/.jack.json +1 -5
  55. package/templates/ai-chat/public/chat.js +130 -130
  56. package/templates/ai-chat/src/index.ts +9 -13
  57. package/templates/ai-chat/src/jack-ai.ts +6 -2
  58. package/templates/saas/.jack.json +6 -1
  59. package/templates/saas/src/auth.ts +8 -4
  60. package/templates/saas/src/client/App.tsx +22 -7
  61. package/templates/saas/src/client/components/ProtectedRoute.tsx +9 -2
  62. package/templates/saas/src/client/components/ThemeToggle.tsx +1 -6
  63. package/templates/saas/src/client/components/ui/accordion.tsx +1 -1
  64. package/templates/saas/src/client/components/ui/alert-dialog.tsx +2 -2
  65. package/templates/saas/src/client/components/ui/alert.tsx +2 -2
  66. package/templates/saas/src/client/components/ui/avatar.tsx +1 -1
  67. package/templates/saas/src/client/components/ui/badge.tsx +2 -2
  68. package/templates/saas/src/client/components/ui/breadcrumb.tsx +1 -1
  69. package/templates/saas/src/client/components/ui/button-group.tsx +2 -2
  70. package/templates/saas/src/client/components/ui/button.tsx +2 -2
  71. package/templates/saas/src/client/components/ui/card.tsx +1 -1
  72. package/templates/saas/src/client/components/ui/carousel.tsx +2 -2
  73. package/templates/saas/src/client/components/ui/checkbox.tsx +1 -1
  74. package/templates/saas/src/client/components/ui/command.tsx +2 -2
  75. package/templates/saas/src/client/components/ui/context-menu.tsx +1 -1
  76. package/templates/saas/src/client/components/ui/dialog.tsx +1 -1
  77. package/templates/saas/src/client/components/ui/drawer.tsx +1 -1
  78. package/templates/saas/src/client/components/ui/dropdown-menu.tsx +1 -1
  79. package/templates/saas/src/client/components/ui/empty.tsx +1 -1
  80. package/templates/saas/src/client/components/ui/field.tsx +2 -2
  81. package/templates/saas/src/client/components/ui/form.tsx +5 -5
  82. package/templates/saas/src/client/components/ui/hover-card.tsx +1 -1
  83. package/templates/saas/src/client/components/ui/input-group.tsx +3 -3
  84. package/templates/saas/src/client/components/ui/input-otp.tsx +1 -1
  85. package/templates/saas/src/client/components/ui/input.tsx +1 -1
  86. package/templates/saas/src/client/components/ui/item.tsx +3 -3
  87. package/templates/saas/src/client/components/ui/label.tsx +1 -1
  88. package/templates/saas/src/client/components/ui/menubar.tsx +1 -1
  89. package/templates/saas/src/client/components/ui/navigation-menu.tsx +1 -1
  90. package/templates/saas/src/client/components/ui/pagination.tsx +2 -2
  91. package/templates/saas/src/client/components/ui/popover.tsx +1 -1
  92. package/templates/saas/src/client/components/ui/progress.tsx +1 -1
  93. package/templates/saas/src/client/components/ui/radio-group.tsx +1 -1
  94. package/templates/saas/src/client/components/ui/resizable.tsx +1 -1
  95. package/templates/saas/src/client/components/ui/scroll-area.tsx +1 -1
  96. package/templates/saas/src/client/components/ui/select.tsx +1 -1
  97. package/templates/saas/src/client/components/ui/separator.tsx +1 -1
  98. package/templates/saas/src/client/components/ui/sheet.tsx +1 -1
  99. package/templates/saas/src/client/components/ui/sidebar.tsx +4 -4
  100. package/templates/saas/src/client/components/ui/slider.tsx +1 -1
  101. package/templates/saas/src/client/components/ui/switch.tsx +1 -1
  102. package/templates/saas/src/client/components/ui/table.tsx +1 -1
  103. package/templates/saas/src/client/components/ui/tabs.tsx +1 -1
  104. package/templates/saas/src/client/components/ui/textarea.tsx +1 -1
  105. package/templates/saas/src/client/components/ui/toggle-group.tsx +3 -3
  106. package/templates/saas/src/client/components/ui/toggle.tsx +2 -2
  107. package/templates/saas/src/client/components/ui/tooltip.tsx +1 -1
  108. package/templates/saas/src/client/hooks/useSubscription.ts +5 -4
  109. package/templates/saas/src/client/lib/auth-client.ts +1 -1
  110. package/templates/saas/src/client/lib/plans.ts +1 -6
  111. package/templates/saas/src/client/lib/utils.ts +1 -1
  112. package/templates/saas/src/client/main.tsx +1 -1
  113. package/templates/saas/src/client/pages/DashboardPage.tsx +41 -9
  114. package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +11 -2
  115. package/templates/saas/src/client/pages/HomePage.tsx +11 -2
  116. package/templates/saas/src/client/pages/LoginPage.tsx +11 -2
  117. package/templates/saas/src/client/pages/PricingPage.tsx +20 -10
  118. package/templates/saas/src/client/pages/ResetPasswordPage.tsx +14 -11
  119. package/templates/saas/src/client/pages/SignupPage.tsx +11 -2
  120. package/templates/saas/src/index.ts +28 -19
  121. package/templates/saas/vite.config.ts +1 -1
  122. package/templates/semantic-search/.jack.json +1 -5
  123. package/templates/semantic-search/src/index.ts +8 -4
  124. package/templates/semantic-search/src/jack-ai.ts +6 -2
  125. package/templates/semantic-search/src/jack-vectorize.ts +5 -1
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Interactive fuzzy project picker
3
+ *
4
+ * Features:
5
+ * - Fuzzy search as you type
6
+ * - Arrow keys/j/k to navigate
7
+ * - Enter to select
8
+ * - Esc to cancel
9
+ * - Cloud-only projects shown separately
10
+ */
11
+
12
+ import { isCancel } from "@clack/core";
13
+ import { formatRelativeTime } from "./format.ts";
14
+ import { fuzzyFilter } from "./fuzzy.ts";
15
+ import { type ProjectListItem, shortenPath, sortByUpdated, toListItems } from "./project-list.ts";
16
+ import { listAllProjects } from "./project-resolver.ts";
17
+ import { restoreTty } from "./tty.ts";
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface PickerResult {
24
+ project: ProjectListItem;
25
+ action: "select";
26
+ }
27
+
28
+ export interface PickerCancelResult {
29
+ action: "cancel";
30
+ }
31
+
32
+ export interface PickProjectOptions {
33
+ cloudOnly?: boolean;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Colors (compatible with project-list.ts)
38
+ // ============================================================================
39
+
40
+ const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
41
+
42
+ const colors = {
43
+ reset: isColorEnabled ? "\x1b[0m" : "",
44
+ dim: isColorEnabled ? "\x1b[90m" : "",
45
+ green: isColorEnabled ? "\x1b[32m" : "",
46
+ yellow: isColorEnabled ? "\x1b[33m" : "",
47
+ red: isColorEnabled ? "\x1b[31m" : "",
48
+ cyan: isColorEnabled ? "\x1b[36m" : "",
49
+ bold: isColorEnabled ? "\x1b[1m" : "",
50
+ inverse: isColorEnabled ? "\x1b[7m" : "",
51
+ // Bright/neon colors for visual pop
52
+ brightCyan: isColorEnabled ? "\x1b[96m" : "",
53
+ brightMagenta: isColorEnabled ? "\x1b[95m" : "",
54
+ brightGreen: isColorEnabled ? "\x1b[92m" : "",
55
+ };
56
+
57
+ // ============================================================================
58
+ // TTY Safety
59
+ // ============================================================================
60
+
61
+ /**
62
+ * Check if we're running in an interactive TTY environment.
63
+ * Only checks stdin - stdout may be a pipe (e.g., shell wrapper capturing output)
64
+ * while still being interactive (user can type, picker UI goes to stderr).
65
+ */
66
+ export function isTTY(): boolean {
67
+ return Boolean(process.stdin.isTTY);
68
+ }
69
+
70
+ /**
71
+ * Exit with error if not running in a TTY
72
+ */
73
+ export function requireTTY(): void {
74
+ if (!isTTY()) {
75
+ console.error("Interactive mode requires a terminal.");
76
+ console.error("Run 'jack ls' to list projects or 'jack cd <name>' to navigate.");
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ // ============================================================================
82
+ // Project Picker Implementation
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Interactive project picker using @clack/core primitives
87
+ * @param options.cloudOnly - If true, only shows cloud-only projects (for linking)
88
+ */
89
+ export async function pickProject(options?: PickProjectOptions): Promise<PickerResult | PickerCancelResult> {
90
+ // Fetch all projects
91
+ let allProjects: ProjectListItem[];
92
+ try {
93
+ const resolved = await listAllProjects();
94
+ allProjects = sortByUpdated(toListItems(resolved));
95
+ } catch {
96
+ console.error("Could not fetch projects. Check your connection.");
97
+ process.exit(1);
98
+ }
99
+
100
+ // Separate local and cloud-only projects
101
+ const cloudOnlyProjects = allProjects.filter((p) => p.isCloudOnly);
102
+ const localProjects = options?.cloudOnly ? [] : allProjects.filter((p) => p.isLocal);
103
+
104
+ // Check for empty state
105
+ if (options?.cloudOnly && cloudOnlyProjects.length === 0) {
106
+ console.error("No cloud-only projects to link.");
107
+ console.error("Run 'jack new <name>' to create a project.");
108
+ process.exit(1);
109
+ }
110
+
111
+ if (!options?.cloudOnly && allProjects.length === 0) {
112
+ console.error("No projects found.");
113
+ console.error("Run 'jack new <name>' to create your first project.");
114
+ process.exit(1);
115
+ }
116
+
117
+ // Run the interactive picker
118
+ const result = await runPicker(localProjects, cloudOnlyProjects);
119
+
120
+ return result;
121
+ }
122
+
123
+ /**
124
+ * Run the interactive picker UI
125
+ */
126
+ async function runPicker(
127
+ localProjects: ProjectListItem[],
128
+ cloudOnlyProjects: ProjectListItem[],
129
+ ): Promise<PickerResult | PickerCancelResult> {
130
+ return new Promise((resolve) => {
131
+ let query = "";
132
+ let cursor = 0;
133
+ let scrollOffset = 0;
134
+ let filteredLocal = localProjects;
135
+ let filteredCloud = cloudOnlyProjects;
136
+
137
+ // Calculate visible window size (leave room for header, footer, cloud header)
138
+ // Use stderr.rows since UI is on stderr (stdout may be a pipe)
139
+ const getMaxVisible = () => Math.max(5, (process.stderr.rows || process.stdout.rows || 20) - 8);
140
+
141
+ // Calculate total items for navigation
142
+ const getTotalItems = () => filteredLocal.length + filteredCloud.length;
143
+
144
+ // Get item at cursor position (across both lists)
145
+ const getItemAtCursor = (): ProjectListItem | null => {
146
+ if (cursor < filteredLocal.length) {
147
+ return filteredLocal[cursor] ?? null;
148
+ }
149
+ const cloudIndex = cursor - filteredLocal.length;
150
+ return filteredCloud[cloudIndex] ?? null;
151
+ };
152
+
153
+ // Update filtered lists based on query
154
+ const updateFilter = () => {
155
+ if (!query) {
156
+ filteredLocal = localProjects;
157
+ filteredCloud = cloudOnlyProjects;
158
+ } else {
159
+ filteredLocal = fuzzyFilter(query, localProjects, (p) => p.name);
160
+ filteredCloud = fuzzyFilter(query, cloudOnlyProjects, (p) => p.name);
161
+ }
162
+ // Reset cursor if out of bounds
163
+ const total = getTotalItems();
164
+ if (cursor >= total) {
165
+ cursor = Math.max(0, total - 1);
166
+ }
167
+ };
168
+
169
+ // Adjust scroll offset to keep cursor visible
170
+ const adjustScroll = () => {
171
+ const maxVisible = getMaxVisible();
172
+ const total = getTotalItems();
173
+
174
+ // If all items fit, no scrolling needed
175
+ if (total <= maxVisible) {
176
+ scrollOffset = 0;
177
+ return;
178
+ }
179
+
180
+ // Keep cursor within visible window
181
+ if (cursor < scrollOffset) {
182
+ scrollOffset = cursor;
183
+ } else if (cursor >= scrollOffset + maxVisible) {
184
+ scrollOffset = cursor - maxVisible + 1;
185
+ }
186
+
187
+ // Clamp scroll offset
188
+ scrollOffset = Math.max(0, Math.min(scrollOffset, total - maxVisible));
189
+ };
190
+
191
+ // Render the picker UI
192
+ const render = () => {
193
+ adjustScroll();
194
+
195
+ // Clear screen and move cursor to top
196
+ process.stderr.write("\x1b[2J\x1b[H");
197
+
198
+ // Header
199
+ process.stderr.write(
200
+ `${colors.brightCyan}${colors.bold}Select a project${colors.reset} ${colors.dim}↑↓ move · enter select · esc cancel${colors.reset}\n\n`,
201
+ );
202
+
203
+ const maxVisible = getMaxVisible();
204
+ const total = getTotalItems();
205
+ const showScrollUp = scrollOffset > 0;
206
+ const showScrollDown = scrollOffset + maxVisible < total;
207
+
208
+ // Show scroll-up indicator
209
+ if (showScrollUp) {
210
+ process.stderr.write(` ${colors.dim}↑ ${scrollOffset} more above${colors.reset}\n`);
211
+ }
212
+
213
+ // Build combined list for scrolling
214
+ const allItems: { project: ProjectListItem; isCloud: boolean; isCloudHeader?: boolean }[] =
215
+ [];
216
+
217
+ for (const project of filteredLocal) {
218
+ allItems.push({ project, isCloud: false });
219
+ }
220
+
221
+ if (filteredCloud.length > 0) {
222
+ // Add cloud header as a special item
223
+ allItems.push({ project: filteredCloud[0]!, isCloud: true, isCloudHeader: true });
224
+ for (const project of filteredCloud) {
225
+ allItems.push({ project, isCloud: true });
226
+ }
227
+ }
228
+
229
+ // Render visible window
230
+ let renderedCount = 0;
231
+ let lineIndex = 0;
232
+ let cloudHeaderShown = false;
233
+
234
+ for (const project of filteredLocal) {
235
+ if (lineIndex >= scrollOffset && renderedCount < maxVisible) {
236
+ const isSelected = lineIndex === cursor;
237
+ const line = formatPickerLine(project, isSelected, false);
238
+ process.stderr.write(`${line}\n`);
239
+ renderedCount++;
240
+ }
241
+ lineIndex++;
242
+ }
243
+
244
+ // Cloud-only section
245
+ if (filteredCloud.length > 0) {
246
+ // Check if cloud header should be visible
247
+ const cloudStartIndex = filteredLocal.length;
248
+ const cloudEndIndex = cloudStartIndex + filteredCloud.length;
249
+
250
+ // Show header if any cloud items are in the visible window
251
+ if (cloudEndIndex > scrollOffset && cloudStartIndex < scrollOffset + maxVisible) {
252
+ // Only show header if we haven't filled up yet and cloud section is visible
253
+ if (renderedCount < maxVisible && lineIndex >= scrollOffset) {
254
+ process.stderr.write(
255
+ `\n ${colors.brightMagenta}☁ cloud-only${colors.reset} ${colors.dim}(will restore on select)${colors.reset}\n`,
256
+ );
257
+ cloudHeaderShown = true;
258
+ }
259
+ }
260
+
261
+ for (const project of filteredCloud) {
262
+ if (lineIndex >= scrollOffset && renderedCount < maxVisible) {
263
+ // Show cloud header just before first visible cloud item if not shown yet
264
+ if (!cloudHeaderShown && lineIndex === cloudStartIndex) {
265
+ process.stderr.write(
266
+ `\n ${colors.brightMagenta}☁ cloud-only${colors.reset} ${colors.dim}(will restore on select)${colors.reset}\n`,
267
+ );
268
+ cloudHeaderShown = true;
269
+ }
270
+ const isSelected = lineIndex === cursor;
271
+ const line = formatPickerLine(project, isSelected, true);
272
+ process.stderr.write(`${line}\n`);
273
+ renderedCount++;
274
+ }
275
+ lineIndex++;
276
+ }
277
+ }
278
+
279
+ // Show scroll-down indicator
280
+ if (showScrollDown) {
281
+ const remaining = total - scrollOffset - maxVisible;
282
+ process.stderr.write(` ${colors.dim}↓ ${remaining} more below${colors.reset}\n`);
283
+ }
284
+
285
+ // Empty state
286
+ if (getTotalItems() === 0) {
287
+ process.stderr.write(` ${colors.dim}No matching projects${colors.reset}\n`);
288
+ }
289
+
290
+ // Search input
291
+ const searchPrompt = query
292
+ ? `${colors.brightCyan}/${colors.reset} ${query}${colors.dim}▌${colors.reset}`
293
+ : `${colors.dim}/ type to filter${colors.reset}`;
294
+ process.stderr.write(`\n ${searchPrompt}\n`);
295
+ };
296
+
297
+ // Format a single picker line
298
+ const formatPickerLine = (
299
+ project: ProjectListItem,
300
+ isSelected: boolean,
301
+ isCloudOnly: boolean,
302
+ ): string => {
303
+ const prefix = isSelected ? `${colors.brightCyan}▸${colors.reset}` : " ";
304
+ const name = project.name.padEnd(22);
305
+ const time = project.updatedAt
306
+ ? colors.dim + formatRelativeTime(project.updatedAt).padEnd(10) + colors.reset
307
+ : "".padEnd(10);
308
+
309
+ let location = "";
310
+ if (!isCloudOnly && project.localPath) {
311
+ location = colors.dim + shortenPath(project.localPath) + colors.reset;
312
+ }
313
+
314
+ const nameColor = isSelected ? colors.brightGreen + colors.bold : "";
315
+ return ` ${prefix} ${nameColor}${name}${colors.reset} ${time} ${location}`;
316
+ };
317
+
318
+ // Handle keyboard input
319
+ const handleKey = (key: Buffer) => {
320
+ const char = key.toString();
321
+ const total = getTotalItems();
322
+
323
+ // Escape - cancel
324
+ if (char === "\x1b" && key.length === 1) {
325
+ cleanup();
326
+ resolve({ action: "cancel" });
327
+ return;
328
+ }
329
+
330
+ // Ctrl+C - cancel
331
+ if (char === "\x03") {
332
+ cleanup();
333
+ resolve({ action: "cancel" });
334
+ return;
335
+ }
336
+
337
+ // Enter - select
338
+ if (char === "\r" || char === "\n") {
339
+ const item = getItemAtCursor();
340
+ if (item) {
341
+ cleanup();
342
+ resolve({ project: item, action: "select" });
343
+ }
344
+ return;
345
+ }
346
+
347
+ // Arrow up or k
348
+ if (char === "\x1b[A" || char === "k") {
349
+ if (total > 0) {
350
+ cursor = cursor > 0 ? cursor - 1 : total - 1;
351
+ render();
352
+ }
353
+ return;
354
+ }
355
+
356
+ // Arrow down or j
357
+ if (char === "\x1b[B" || char === "j") {
358
+ if (total > 0) {
359
+ cursor = cursor < total - 1 ? cursor + 1 : 0;
360
+ render();
361
+ }
362
+ return;
363
+ }
364
+
365
+ // Backspace
366
+ if (char === "\x7f" || char === "\b") {
367
+ if (query.length > 0) {
368
+ query = query.slice(0, -1);
369
+ updateFilter();
370
+ render();
371
+ }
372
+ return;
373
+ }
374
+
375
+ // Regular character input (printable ASCII)
376
+ if (char.length === 1 && char >= " " && char <= "~") {
377
+ // Skip j/k when used for navigation (already handled above)
378
+ // But allow them in query if typed with other chars
379
+ query += char;
380
+ updateFilter();
381
+ render();
382
+ return;
383
+ }
384
+ };
385
+
386
+ // Cleanup function
387
+ const cleanup = () => {
388
+ process.stdin.removeListener("data", handleKey);
389
+ restoreTty();
390
+ // Clear the picker UI
391
+ process.stderr.write("\x1b[2J\x1b[H");
392
+ };
393
+
394
+ // Set up raw mode for keyboard input
395
+ if (process.stdin.isTTY) {
396
+ process.stdin.setRawMode(true);
397
+ }
398
+ process.stdin.resume();
399
+ process.stdin.on("data", handleKey);
400
+
401
+ // Initial render
402
+ render();
403
+ });
404
+ }
405
+
406
+ export { isCancel };
@@ -216,7 +216,8 @@ export function detectProjectType(projectPath: string): DetectionResult {
216
216
  // Check for monorepo - user is in wrong directory
217
217
  if (pkg?.workspaces) {
218
218
  const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages;
219
- const hint = workspaces.length > 0 ? workspaces[0]?.replace("/*", "/your-app") : "apps/your-app";
219
+ const hint =
220
+ workspaces.length > 0 ? workspaces[0]?.replace("/*", "/your-app") : "apps/your-app";
220
221
  return {
221
222
  type: "unknown",
222
223
  error: `This is a monorepo root, not a deployable project.\n\ncd into a package first:\n cd ${hint}\n jack ship`,
@@ -414,7 +415,9 @@ export async function validateProject(projectPath: string): Promise<ValidationRe
414
415
 
415
416
  fileCount++;
416
417
  if (fileCount > MAX_FILES) {
417
- throw new Error(`Project has more than ${MAX_FILES} files (excluding node_modules, .git, etc.)`);
418
+ throw new Error(
419
+ `Project has more than ${MAX_FILES} files (excluding node_modules, .git, etc.)`,
420
+ );
418
421
  }
419
422
 
420
423
  const stats = await stat(absolutePath);
@@ -25,6 +25,8 @@ export interface ProjectListItem {
25
25
  url: string | null;
26
26
  localPath: string | null;
27
27
  updatedAt: string | null;
28
+ createdAt: string | null;
29
+ linkedAt: string | null; // For BYO projects without updatedAt, used as fallback for recency sorting
28
30
  isLocal: boolean;
29
31
  isCloudOnly: boolean;
30
32
  errorMessage?: string;
@@ -131,6 +133,9 @@ export function toListItems(projects: ResolvedProject[]): ProjectListItem[] {
131
133
  url: proj.url || null,
132
134
  localPath: proj.localPath || null,
133
135
  updatedAt: proj.updatedAt || null,
136
+ createdAt: proj.createdAt || null,
137
+ // For BYO projects, createdAt is the linked_at timestamp
138
+ linkedAt: proj.deployMode === "byo" ? proj.createdAt || null : null,
134
139
  isLocal: !!proj.localPath && proj.sources.filesystem,
135
140
  isCloudOnly: !proj.localPath && proj.sources.controlPlane,
136
141
  errorMessage: proj.errorMessage,
@@ -142,22 +147,78 @@ export function toListItems(projects: ResolvedProject[]): ProjectListItem[] {
142
147
  // Sorting & Filtering
143
148
  // ============================================================================
144
149
 
150
+ /**
151
+ * Sort types for project listing
152
+ */
153
+ export type SortOrder = "updated" | "name" | "created";
154
+
155
+ /**
156
+ * Get the effective date for recency sorting
157
+ * Falls back to linkedAt for BYO projects without updatedAt
158
+ */
159
+ function getRecencyDate(item: ProjectListItem): string | null {
160
+ return item.updatedAt || item.linkedAt || null;
161
+ }
162
+
145
163
  /**
146
164
  * Sort by updatedAt descending (most recent first)
147
- * Items without updatedAt are sorted to the end
165
+ * Items without updatedAt fall back to linkedAt (for BYO projects)
166
+ * Items without any date are sorted to the end, then alphabetically
148
167
  */
149
168
  export function sortByUpdated(items: ProjectListItem[]): ProjectListItem[] {
169
+ return [...items].sort((a, b) => {
170
+ const aDate = getRecencyDate(a);
171
+ const bDate = getRecencyDate(b);
172
+
173
+ // Items without dates go to the end
174
+ if (!aDate && !bDate) return a.name.localeCompare(b.name);
175
+ if (!aDate) return 1;
176
+ if (!bDate) return -1;
177
+
178
+ // Most recent first
179
+ return new Date(bDate).getTime() - new Date(aDate).getTime();
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Sort alphabetically by name (ascending)
185
+ */
186
+ export function sortByName(items: ProjectListItem[]): ProjectListItem[] {
187
+ return [...items].sort((a, b) => a.name.localeCompare(b.name));
188
+ }
189
+
190
+ /**
191
+ * Sort by createdAt descending (most recently created first)
192
+ * Items without createdAt are sorted to the end, then alphabetically
193
+ */
194
+ export function sortByCreated(items: ProjectListItem[]): ProjectListItem[] {
150
195
  return [...items].sort((a, b) => {
151
196
  // 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;
197
+ if (!a.createdAt && !b.createdAt) return a.name.localeCompare(b.name);
198
+ if (!a.createdAt) return 1;
199
+ if (!b.createdAt) return -1;
155
200
 
156
201
  // Most recent first
157
- return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
202
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
158
203
  });
159
204
  }
160
205
 
206
+ /**
207
+ * Sort items by the specified order
208
+ */
209
+ export function sortItems(items: ProjectListItem[], order: SortOrder): ProjectListItem[] {
210
+ switch (order) {
211
+ case "updated":
212
+ return sortByUpdated(items);
213
+ case "name":
214
+ return sortByName(items);
215
+ case "created":
216
+ return sortByCreated(items);
217
+ default:
218
+ return sortByUpdated(items);
219
+ }
220
+ }
221
+
161
222
  /**
162
223
  * Group projects into sections for display
163
224
  */
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { existsSync } from "node:fs";
9
+ import { mkdir } from "node:fs/promises";
9
10
  import { join, resolve } from "node:path";
10
11
  import { $ } from "bun";
11
12
  import {
@@ -40,6 +41,7 @@ import {
40
41
  slugify,
41
42
  writeWranglerConfig,
42
43
  } from "./config-generator.ts";
44
+ import { getJackHome } from "./config.ts";
43
45
  import { deleteManagedProject, listManagedProjects } from "./control-plane.ts";
44
46
  import { debug, isDebug, printTimingSummary, timerEnd, timerStart } from "./debug.ts";
45
47
  import { ensureWranglerInstalled, validateModeAvailability } from "./deploy-mode.ts";
@@ -68,7 +70,7 @@ import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
68
70
  import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./schema.ts";
69
71
  import { getSavedSecrets, saveSecrets } from "./secrets.ts";
70
72
  import { getProjectNameFromDir, getRemoteManifest } from "./storage/index.ts";
71
- import { Events, track } from "./telemetry.ts";
73
+ import { Events, track, trackActivationIfFirst } from "./telemetry.ts";
72
74
 
73
75
  // ============================================================================
74
76
  // Type Definitions
@@ -81,6 +83,7 @@ export interface CreateProjectOptions {
81
83
  interactive?: boolean;
82
84
  managed?: boolean; // Force managed deploy mode
83
85
  byo?: boolean; // Force BYO deploy mode
86
+ targetDir?: string; // Explicit target directory (overrides JACK_HOME default)
84
87
  }
85
88
 
86
89
  export interface CreateProjectResult {
@@ -756,6 +759,7 @@ export async function createProject(
756
759
  intent: intentPhrase,
757
760
  reporter: providedReporter,
758
761
  interactive: interactiveOption,
762
+ targetDir: targetDirOption,
759
763
  } = options;
760
764
  const reporter = providedReporter ?? noopReporter;
761
765
  const hasReporter = Boolean(providedReporter);
@@ -772,10 +776,20 @@ export async function createProject(
772
776
 
773
777
  // Fast local validation first - check directory before any network calls
774
778
  const nameWasProvided = name !== undefined;
779
+ const targetDirProvided = targetDirOption !== undefined;
780
+
775
781
  if (nameWasProvided) {
776
- const targetDir = resolve(name);
777
- if (existsSync(targetDir)) {
778
- throw new JackError(JackErrorCode.VALIDATION_ERROR, `Directory ${name} already exists`);
782
+ // Compute the effective target directory for validation
783
+ // Priority: explicit targetDir > JACK_HOME default
784
+ const effectiveTargetDir = targetDirProvided
785
+ ? resolve(targetDirOption, name)
786
+ : join(getJackHome(), name);
787
+ if (existsSync(effectiveTargetDir)) {
788
+ throw new JackError(
789
+ JackErrorCode.VALIDATION_ERROR,
790
+ `Folder exists at ${effectiveTargetDir}/`,
791
+ "Remove it first, or use 'jack ship' if it's a project.",
792
+ );
779
793
  }
780
794
  }
781
795
 
@@ -809,11 +823,35 @@ export async function createProject(
809
823
 
810
824
  // Generate or use provided name
811
825
  const projectName = name ?? generateProjectName();
812
- const targetDir = resolve(projectName);
826
+
827
+ // Compute target directory:
828
+ // - If explicit targetDir provided: resolve(targetDir, projectName)
829
+ // - Otherwise: use JACK_HOME (~/.jack/projects/) as default
830
+ let targetDir: string;
831
+ if (targetDirProvided) {
832
+ targetDir = resolve(targetDirOption, projectName);
833
+ } else {
834
+ // Default: use JACK_HOME
835
+ const jackHome = getJackHome();
836
+ try {
837
+ await mkdir(jackHome, { recursive: true });
838
+ } catch (err) {
839
+ throw new JackError(
840
+ JackErrorCode.VALIDATION_ERROR,
841
+ `Cannot create JACK_HOME at ${jackHome}`,
842
+ "Check permissions or set JACK_HOME environment variable to a writable location.",
843
+ );
844
+ }
845
+ targetDir = join(jackHome, projectName);
846
+ }
813
847
 
814
848
  // Check directory doesn't exist (only needed for auto-generated names now)
815
849
  if (!nameWasProvided && existsSync(targetDir)) {
816
- throw new JackError(JackErrorCode.VALIDATION_ERROR, `Directory ${projectName} already exists`);
850
+ throw new JackError(
851
+ JackErrorCode.VALIDATION_ERROR,
852
+ `Folder exists at ${targetDir}/`,
853
+ "Remove it first, or use 'jack ship' if it's a project.",
854
+ );
817
855
  }
818
856
 
819
857
  // Early slug availability check for managed mode (only if user provided explicit name)
@@ -1374,11 +1412,17 @@ export async function createProject(
1374
1412
  }
1375
1413
  }
1376
1414
 
1415
+ track(Events.BYO_DEPLOY_STARTED, {});
1416
+ const byoDeployStartTime = Date.now();
1417
+
1377
1418
  reporter.start("Deploying...");
1378
1419
 
1379
1420
  const deployResult = await runWranglerDeploy(targetDir);
1380
1421
 
1381
1422
  if (deployResult.exitCode !== 0) {
1423
+ track(Events.BYO_DEPLOY_FAILED, {
1424
+ duration_ms: Date.now() - byoDeployStartTime,
1425
+ });
1382
1426
  reporter.stop();
1383
1427
  reporter.error("Deploy failed");
1384
1428
  throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
@@ -1436,6 +1480,12 @@ export async function createProject(
1436
1480
  // Generate BYO project ID and link locally
1437
1481
  const byoProjectId = generateByoProjectId();
1438
1482
 
1483
+ track(Events.BYO_DEPLOY_COMPLETED, {
1484
+ duration_ms: Date.now() - byoDeployStartTime,
1485
+ project_id: byoProjectId,
1486
+ });
1487
+ await trackActivationIfFirst("byo");
1488
+
1439
1489
  // Link project locally and register path
1440
1490
  try {
1441
1491
  await linkProject(targetDir, byoProjectId, "byo");
@@ -1758,10 +1808,16 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1758
1808
  // Ensure Cloudflare auth before BYO deploy
1759
1809
  await ensureCloudflareAuth(interactive, reporter);
1760
1810
 
1811
+ track(Events.BYO_DEPLOY_STARTED, {});
1812
+ const byoDeployStartTime = Date.now();
1813
+
1761
1814
  const spin = reporter.spinner("Deploying...");
1762
1815
  const result = await runWranglerDeploy(projectPath);
1763
1816
 
1764
1817
  if (result.exitCode !== 0) {
1818
+ track(Events.BYO_DEPLOY_FAILED, {
1819
+ duration_ms: Date.now() - byoDeployStartTime,
1820
+ });
1765
1821
  spin.error("Deploy failed");
1766
1822
  throw new JackError(JackErrorCode.DEPLOY_FAILED, "Deploy failed", undefined, {
1767
1823
  exitCode: result.exitCode ?? 1,
@@ -1775,6 +1831,12 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1775
1831
  const urlMatch = deployOutput.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
1776
1832
  workerUrl = urlMatch ? urlMatch[0] : null;
1777
1833
 
1834
+ track(Events.BYO_DEPLOY_COMPLETED, {
1835
+ duration_ms: Date.now() - byoDeployStartTime,
1836
+ project_id: link?.project_id || null,
1837
+ });
1838
+ await trackActivationIfFirst("byo");
1839
+
1778
1840
  if (workerUrl) {
1779
1841
  spin.success(`Live: ${workerUrl}`);
1780
1842
  } else {
@@ -1,6 +1,6 @@
1
1
  import { text } from "@clack/prompts";
2
- import { isCancel } from "./hooks.ts";
3
2
  import type { DetectedSecret } from "./env-parser.ts";
3
+ import { isCancel } from "./hooks.ts";
4
4
  import { promptSelectValue } from "./hooks.ts";
5
5
  import { info, success, warn } from "./output.ts";
6
6
  import { getSavedSecrets, getSecretsPath, maskSecret, saveSecrets } from "./secrets.ts";