@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.
- package/package.json +1 -1
- package/src/commands/cd.ts +163 -0
- package/src/commands/clone.ts +112 -68
- package/src/commands/domain.ts +506 -0
- package/src/commands/domains.ts +215 -0
- package/src/commands/down.ts +18 -12
- package/src/commands/hack.ts +185 -8
- package/src/commands/init.ts +52 -1
- package/src/commands/link.ts +25 -43
- package/src/commands/logs.ts +2 -2
- package/src/commands/mcp.ts +74 -3
- package/src/commands/new.ts +48 -54
- package/src/commands/projects.ts +53 -10
- package/src/commands/secrets.ts +5 -1
- package/src/commands/services.ts +16 -4
- package/src/commands/shell-init.ts +43 -0
- package/src/commands/ship.ts +2 -11
- package/src/commands/skills.ts +335 -0
- package/src/commands/update.ts +31 -0
- package/src/commands/upgrade.ts +14 -0
- package/src/index.ts +116 -24
- package/src/lib/agent-integration.ts +1 -2
- package/src/lib/agents.ts +2 -2
- package/src/lib/auth/login-flow.ts +1 -1
- package/src/lib/clone-core.ts +252 -0
- package/src/lib/config.ts +22 -0
- package/src/lib/control-plane.ts +31 -5
- package/src/lib/fuzzy.ts +93 -0
- package/src/lib/managed-deploy.ts +4 -1
- package/src/lib/managed-down.ts +20 -5
- package/src/lib/output.ts +90 -9
- package/src/lib/picker.ts +406 -0
- package/src/lib/project-detection.ts +5 -2
- package/src/lib/project-list.ts +66 -5
- package/src/lib/project-operations.ts +68 -6
- package/src/lib/prompts.ts +1 -1
- package/src/lib/services/db-execute.ts +8 -1
- package/src/lib/services/db-list.ts +4 -1
- package/src/lib/services/domain-operations.ts +379 -0
- package/src/lib/services/storage-config.ts +1 -5
- package/src/lib/services/storage-delete.ts +1 -1
- package/src/lib/services/storage-info.ts +2 -4
- package/src/lib/services/vectorize-config.ts +1 -5
- package/src/lib/services/vectorize-create.ts +3 -1
- package/src/lib/shell-integration.ts +202 -0
- package/src/lib/telemetry-config.ts +50 -4
- package/src/lib/telemetry.ts +71 -2
- package/src/lib/version-check.ts +1 -3
- package/src/lib/wrangler-config.test.ts +2 -2
- package/src/lib/wrangler-config.ts +1 -1
- package/src/lib/zip-packager.ts +1 -3
- package/src/mcp/tools/index.ts +261 -7
- package/src/templates/index.ts +10 -1
- package/templates/ai-chat/.jack.json +1 -5
- package/templates/ai-chat/public/chat.js +130 -130
- package/templates/ai-chat/src/index.ts +9 -13
- package/templates/ai-chat/src/jack-ai.ts +6 -2
- package/templates/saas/.jack.json +6 -1
- package/templates/saas/src/auth.ts +8 -4
- package/templates/saas/src/client/App.tsx +22 -7
- package/templates/saas/src/client/components/ProtectedRoute.tsx +9 -2
- package/templates/saas/src/client/components/ThemeToggle.tsx +1 -6
- package/templates/saas/src/client/components/ui/accordion.tsx +1 -1
- package/templates/saas/src/client/components/ui/alert-dialog.tsx +2 -2
- package/templates/saas/src/client/components/ui/alert.tsx +2 -2
- package/templates/saas/src/client/components/ui/avatar.tsx +1 -1
- package/templates/saas/src/client/components/ui/badge.tsx +2 -2
- package/templates/saas/src/client/components/ui/breadcrumb.tsx +1 -1
- package/templates/saas/src/client/components/ui/button-group.tsx +2 -2
- package/templates/saas/src/client/components/ui/button.tsx +2 -2
- package/templates/saas/src/client/components/ui/card.tsx +1 -1
- package/templates/saas/src/client/components/ui/carousel.tsx +2 -2
- package/templates/saas/src/client/components/ui/checkbox.tsx +1 -1
- package/templates/saas/src/client/components/ui/command.tsx +2 -2
- package/templates/saas/src/client/components/ui/context-menu.tsx +1 -1
- package/templates/saas/src/client/components/ui/dialog.tsx +1 -1
- package/templates/saas/src/client/components/ui/drawer.tsx +1 -1
- package/templates/saas/src/client/components/ui/dropdown-menu.tsx +1 -1
- package/templates/saas/src/client/components/ui/empty.tsx +1 -1
- package/templates/saas/src/client/components/ui/field.tsx +2 -2
- package/templates/saas/src/client/components/ui/form.tsx +5 -5
- package/templates/saas/src/client/components/ui/hover-card.tsx +1 -1
- package/templates/saas/src/client/components/ui/input-group.tsx +3 -3
- package/templates/saas/src/client/components/ui/input-otp.tsx +1 -1
- package/templates/saas/src/client/components/ui/input.tsx +1 -1
- package/templates/saas/src/client/components/ui/item.tsx +3 -3
- package/templates/saas/src/client/components/ui/label.tsx +1 -1
- package/templates/saas/src/client/components/ui/menubar.tsx +1 -1
- package/templates/saas/src/client/components/ui/navigation-menu.tsx +1 -1
- package/templates/saas/src/client/components/ui/pagination.tsx +2 -2
- package/templates/saas/src/client/components/ui/popover.tsx +1 -1
- package/templates/saas/src/client/components/ui/progress.tsx +1 -1
- package/templates/saas/src/client/components/ui/radio-group.tsx +1 -1
- package/templates/saas/src/client/components/ui/resizable.tsx +1 -1
- package/templates/saas/src/client/components/ui/scroll-area.tsx +1 -1
- package/templates/saas/src/client/components/ui/select.tsx +1 -1
- package/templates/saas/src/client/components/ui/separator.tsx +1 -1
- package/templates/saas/src/client/components/ui/sheet.tsx +1 -1
- package/templates/saas/src/client/components/ui/sidebar.tsx +4 -4
- package/templates/saas/src/client/components/ui/slider.tsx +1 -1
- package/templates/saas/src/client/components/ui/switch.tsx +1 -1
- package/templates/saas/src/client/components/ui/table.tsx +1 -1
- package/templates/saas/src/client/components/ui/tabs.tsx +1 -1
- package/templates/saas/src/client/components/ui/textarea.tsx +1 -1
- package/templates/saas/src/client/components/ui/toggle-group.tsx +3 -3
- package/templates/saas/src/client/components/ui/toggle.tsx +2 -2
- package/templates/saas/src/client/components/ui/tooltip.tsx +1 -1
- package/templates/saas/src/client/hooks/useSubscription.ts +5 -4
- package/templates/saas/src/client/lib/auth-client.ts +1 -1
- package/templates/saas/src/client/lib/plans.ts +1 -6
- package/templates/saas/src/client/lib/utils.ts +1 -1
- package/templates/saas/src/client/main.tsx +1 -1
- package/templates/saas/src/client/pages/DashboardPage.tsx +41 -9
- package/templates/saas/src/client/pages/ForgotPasswordPage.tsx +11 -2
- package/templates/saas/src/client/pages/HomePage.tsx +11 -2
- package/templates/saas/src/client/pages/LoginPage.tsx +11 -2
- package/templates/saas/src/client/pages/PricingPage.tsx +20 -10
- package/templates/saas/src/client/pages/ResetPasswordPage.tsx +14 -11
- package/templates/saas/src/client/pages/SignupPage.tsx +11 -2
- package/templates/saas/src/index.ts +28 -19
- package/templates/saas/vite.config.ts +1 -1
- package/templates/semantic-search/.jack.json +1 -5
- package/templates/semantic-search/src/index.ts +8 -4
- package/templates/semantic-search/src/jack-ai.ts +6 -2
- 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 =
|
|
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(
|
|
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);
|
package/src/lib/project-list.ts
CHANGED
|
@@ -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
|
|
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.
|
|
153
|
-
if (!a.
|
|
154
|
-
if (!b.
|
|
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.
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
package/src/lib/prompts.ts
CHANGED
|
@@ -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";
|