@getjack/jack 0.1.33 → 0.1.35

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 (94) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/down.ts +39 -7
  4. package/src/commands/link.ts +2 -4
  5. package/src/commands/logs.ts +2 -4
  6. package/src/commands/mcp.ts +12 -10
  7. package/src/commands/secrets.ts +3 -1
  8. package/src/commands/services.ts +4 -2
  9. package/src/commands/sync.ts +5 -6
  10. package/src/lib/auth/client.ts +5 -2
  11. package/src/lib/binding-validator.ts +39 -3
  12. package/src/lib/build-helper.ts +18 -19
  13. package/src/lib/control-plane.ts +1 -0
  14. package/src/lib/crypto.ts +84 -0
  15. package/src/lib/deploy-upload.ts +7 -3
  16. package/src/lib/do-config.ts +110 -0
  17. package/src/lib/do-export-validator.ts +26 -0
  18. package/src/lib/hooks.ts +1 -2
  19. package/src/lib/jsonc-edit.ts +292 -0
  20. package/src/lib/managed-deploy.ts +36 -1
  21. package/src/lib/project-link.ts +37 -0
  22. package/src/lib/project-operations.ts +37 -46
  23. package/src/lib/prompts.ts +2 -2
  24. package/src/lib/resources.ts +4 -5
  25. package/src/lib/schema.ts +8 -12
  26. package/src/lib/services/db-create.ts +2 -2
  27. package/src/lib/services/db-execute.ts +9 -6
  28. package/src/lib/services/db-list.ts +6 -4
  29. package/src/lib/services/endpoint-test.ts +275 -0
  30. package/src/lib/services/project-delete.ts +190 -0
  31. package/src/lib/services/project-environment.ts +457 -0
  32. package/src/lib/services/storage-config.ts +7 -309
  33. package/src/lib/services/storage-create.ts +2 -1
  34. package/src/lib/services/storage-delete.ts +3 -2
  35. package/src/lib/services/storage-info.ts +2 -1
  36. package/src/lib/services/storage-list.ts +6 -3
  37. package/src/lib/services/vectorize-config.ts +7 -264
  38. package/src/lib/services/vectorize-create.ts +2 -1
  39. package/src/lib/services/vectorize-delete.ts +6 -4
  40. package/src/lib/services/vectorize-list.ts +6 -3
  41. package/src/lib/storage/index.ts +21 -23
  42. package/src/lib/telemetry.ts +1 -0
  43. package/src/lib/wrangler-config.ts +43 -312
  44. package/src/lib/zip-packager.ts +28 -0
  45. package/src/mcp/test-utils.ts +31 -0
  46. package/src/mcp/tools/index.ts +271 -0
  47. package/src/templates/index.ts +5 -0
  48. package/src/templates/types.ts +4 -0
  49. package/templates/AI-BINDINGS.md +34 -76
  50. package/templates/CLAUDE.md +22 -1
  51. package/templates/ai-chat/src/index.ts +7 -14
  52. package/templates/ai-chat/src/jack-ai.ts +0 -6
  53. package/templates/chat/.jack.json +45 -0
  54. package/templates/chat/bun.lock +1588 -0
  55. package/templates/chat/components.json +23 -0
  56. package/templates/chat/index.html +12 -0
  57. package/templates/chat/package.json +41 -0
  58. package/templates/chat/src/chat-agent.ts +61 -0
  59. package/templates/chat/src/client/app.tsx +189 -0
  60. package/templates/chat/src/client/chat.tsx +222 -0
  61. package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
  62. package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
  63. package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
  64. package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
  65. package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
  66. package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
  67. package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
  68. package/templates/chat/src/client/components/ui/button.tsx +38 -0
  69. package/templates/chat/src/client/lib/utils.ts +6 -0
  70. package/templates/chat/src/client/main.tsx +11 -0
  71. package/templates/chat/src/client/styles.css +125 -0
  72. package/templates/chat/src/index.ts +25 -0
  73. package/templates/chat/src/jack-ai.ts +94 -0
  74. package/templates/chat/tsconfig.json +18 -0
  75. package/templates/chat/vite.config.ts +14 -0
  76. package/templates/chat/wrangler.jsonc +18 -0
  77. package/templates/cron/.jack.json +18 -28
  78. package/templates/cron/schema.sql +10 -20
  79. package/templates/cron/src/admin.ts +321 -0
  80. package/templates/cron/src/index.ts +151 -81
  81. package/templates/cron/src/monitor.ts +124 -0
  82. package/templates/nextjs-clerk/app/layout.tsx +2 -0
  83. package/templates/semantic-search/src/index.ts +5 -43
  84. package/templates/semantic-search/src/jack-ai.ts +0 -6
  85. package/templates/telegram-bot/.jack.json +56 -0
  86. package/templates/telegram-bot/bun.lock +41 -0
  87. package/templates/telegram-bot/package.json +16 -0
  88. package/templates/telegram-bot/src/index.ts +236 -0
  89. package/templates/telegram-bot/src/jack-ai.ts +100 -0
  90. package/templates/telegram-bot/tsconfig.json +11 -0
  91. package/templates/telegram-bot/wrangler.jsonc +8 -0
  92. package/templates/cron/src/jobs.ts +0 -139
  93. package/templates/cron/src/webhooks.ts +0 -95
  94. package/templates/semantic-search/src/jack-vectorize.ts +0 -169
package/src/lib/schema.ts CHANGED
@@ -4,6 +4,7 @@ import { $ } from "bun";
4
4
  import { debug } from "./debug.ts";
5
5
  import { parseJsonc } from "./jsonc.ts";
6
6
  import { output } from "./output.ts";
7
+ import { findWranglerConfig } from "./wrangler-config.ts";
7
8
 
8
9
  /**
9
10
  * Execute schema.sql on a D1 database after deploy
@@ -47,9 +48,9 @@ export async function applySchema(bindingOrDbName: string, projectDir: string):
47
48
  * Check if project has D1 database configured (has d1_databases in wrangler config)
48
49
  */
49
50
  export async function hasD1Config(projectDir: string): Promise<boolean> {
50
- const wranglerPath = join(projectDir, "wrangler.jsonc");
51
+ const wranglerPath = findWranglerConfig(projectDir);
51
52
 
52
- if (!existsSync(wranglerPath)) {
53
+ if (!wranglerPath) {
53
54
  return false;
54
55
  }
55
56
 
@@ -71,9 +72,9 @@ export interface D1Binding {
71
72
  * Read D1 bindings from wrangler.jsonc
72
73
  */
73
74
  export async function getD1Bindings(projectDir: string): Promise<D1Binding[]> {
74
- const wranglerPath = join(projectDir, "wrangler.jsonc");
75
+ const wranglerPath = findWranglerConfig(projectDir);
75
76
 
76
- if (!existsSync(wranglerPath)) {
77
+ if (!wranglerPath) {
77
78
  return [];
78
79
  }
79
80
 
@@ -91,20 +92,15 @@ export async function getD1Bindings(projectDir: string): Promise<D1Binding[]> {
91
92
  * Returns the database_name field which is needed for wrangler d1 execute
92
93
  */
93
94
  export async function getD1DatabaseName(projectDir: string): Promise<string | null> {
94
- const wranglerPath = join(projectDir, "wrangler.jsonc");
95
+ const wranglerPath = findWranglerConfig(projectDir);
95
96
 
96
- if (!existsSync(wranglerPath)) {
97
+ if (!wranglerPath) {
97
98
  return null;
98
99
  }
99
100
 
100
101
  try {
101
102
  const content = await Bun.file(wranglerPath).text();
102
- // Strip comments for parsing
103
- // Note: Only remove line comments at the start of a line to avoid breaking URLs
104
- const cleaned = content
105
- .replace(/\/\*[\s\S]*?\*\//g, "") // block comments
106
- .replace(/^\s*\/\/.*$/gm, ""); // line comments at start of line only
107
- const config = JSON.parse(cleaned);
103
+ const config = parseJsonc<{ d1_databases?: { database_name?: string }[] }>(content);
108
104
 
109
105
  return config.d1_databases?.[0]?.database_name || null;
110
106
  } catch {
@@ -9,7 +9,7 @@ import { $ } from "bun";
9
9
  import { createProjectResource } from "../control-plane.ts";
10
10
  import { readProjectLink } from "../project-link.ts";
11
11
  import { getProjectNameFromDir } from "../storage/index.ts";
12
- import { addD1Binding, getExistingD1Bindings } from "../wrangler-config.ts";
12
+ import { addD1Binding, findWranglerConfig, getExistingD1Bindings } from "../wrangler-config.ts";
13
13
 
14
14
  export interface CreateDatabaseOptions {
15
15
  name?: string;
@@ -135,7 +135,7 @@ export async function createDatabase(
135
135
  const projectName = await getProjectNameFromDir(projectDir);
136
136
 
137
137
  // Get existing D1 bindings to determine naming
138
- const wranglerPath = join(projectDir, "wrangler.jsonc");
138
+ const wranglerPath = findWranglerConfig(projectDir) ?? join(projectDir, "wrangler.jsonc");
139
139
  const existingBindings = await getExistingD1Bindings(wranglerPath);
140
140
  const existingCount = existingBindings.length;
141
141
 
@@ -9,11 +9,14 @@
9
9
  */
10
10
 
11
11
  import { existsSync } from "node:fs";
12
- import { join } from "node:path";
13
12
  import { $ } from "bun";
14
13
  import { type ExecuteSqlResponse, executeManagedSql } from "../control-plane.ts";
15
14
  import { readProjectLink } from "../project-link.ts";
16
- import { type D1BindingConfig, getExistingD1Bindings } from "../wrangler-config.ts";
15
+ import {
16
+ type D1BindingConfig,
17
+ findWranglerConfig,
18
+ getExistingD1Bindings,
19
+ } from "../wrangler-config.ts";
17
20
  import {
18
21
  type ClassifiedStatement,
19
22
  type RiskLevel,
@@ -98,9 +101,9 @@ export class DestructiveOperationError extends Error {
98
101
  * Get the first D1 database configured for a project
99
102
  */
100
103
  export async function getDefaultDatabase(projectDir: string): Promise<D1BindingConfig | null> {
101
- const wranglerPath = join(projectDir, "wrangler.jsonc");
104
+ const wranglerPath = findWranglerConfig(projectDir);
102
105
 
103
- if (!existsSync(wranglerPath)) {
106
+ if (!wranglerPath) {
104
107
  return null;
105
108
  }
106
109
 
@@ -119,9 +122,9 @@ export async function getDatabaseByName(
119
122
  projectDir: string,
120
123
  databaseName: string,
121
124
  ): Promise<D1BindingConfig | null> {
122
- const wranglerPath = join(projectDir, "wrangler.jsonc");
125
+ const wranglerPath = findWranglerConfig(projectDir);
123
126
 
124
- if (!existsSync(wranglerPath)) {
127
+ if (!wranglerPath) {
125
128
  return null;
126
129
  }
127
130
 
@@ -5,9 +5,8 @@
5
5
  * For managed projects, fetches metadata via control plane instead of wrangler.
6
6
  */
7
7
 
8
- import { join } from "node:path";
9
8
  import { readProjectLink } from "../project-link.ts";
10
- import { getExistingD1Bindings } from "../wrangler-config.ts";
9
+ import { findWranglerConfig, getExistingD1Bindings } from "../wrangler-config.ts";
11
10
  import { getDatabaseInfo } from "./db.ts";
12
11
 
13
12
  export interface DatabaseListEntry {
@@ -25,9 +24,12 @@ export interface DatabaseListEntry {
25
24
  * For BYO projects: reads bindings from wrangler.jsonc and fetches metadata via wrangler.
26
25
  */
27
26
  export async function listDatabases(projectDir: string): Promise<DatabaseListEntry[]> {
28
- const wranglerPath = join(projectDir, "wrangler.jsonc");
27
+ const wranglerPath = findWranglerConfig(projectDir);
28
+ if (!wranglerPath) {
29
+ return [];
30
+ }
29
31
 
30
- // Get existing D1 bindings from wrangler.jsonc
32
+ // Get existing D1 bindings from wrangler config
31
33
  const bindings = await getExistingD1Bindings(wranglerPath);
32
34
 
33
35
  if (bindings.length === 0) {
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Endpoint testing service
3
+ *
4
+ * Makes HTTP requests to deployed workers and optionally captures
5
+ * runtime logs during the request. Used by MCP (test_endpoint tool).
6
+ */
7
+
8
+ import { readProjectLink } from "../project-link.ts";
9
+ import { getProjectStatus } from "../project-operations.ts";
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ export interface TestEndpointOptions {
16
+ /** Path to project directory */
17
+ projectDir: string;
18
+ /** URL path to test, e.g. /api/todos */
19
+ path: string;
20
+ /** HTTP method */
21
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
22
+ /** Request headers */
23
+ headers?: Record<string, string>;
24
+ /** Request body (JSON string for POST/PUT/PATCH) */
25
+ body?: string;
26
+ /** Capture runtime logs during the request (managed mode only) */
27
+ includeLogs?: boolean;
28
+ }
29
+
30
+ export interface TestEndpointResult {
31
+ request: {
32
+ method: string;
33
+ url: string;
34
+ headers: Record<string, string>;
35
+ body: string | null;
36
+ };
37
+ response: {
38
+ status: number;
39
+ status_text: string;
40
+ headers: Record<string, string>;
41
+ body: string;
42
+ duration_ms: number;
43
+ };
44
+ logs: LogEntry[];
45
+ }
46
+
47
+ export interface LogEntry {
48
+ level: string;
49
+ message: unknown[];
50
+ timestamp: string;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Main Function
55
+ // ============================================================================
56
+
57
+ const REQUEST_TIMEOUT_MS = 30_000;
58
+ const LOG_SETTLE_DELAY_MS = 500;
59
+ const LOG_FLUSH_DELAY_MS = 1_500;
60
+ const LOG_COLLECT_DURATION_MS = 3_000;
61
+ const MAX_RESPONSE_BODY_SIZE = 1_000_000; // 1MB
62
+
63
+ /**
64
+ * Test a deployed endpoint by making an HTTP request and optionally capturing logs.
65
+ */
66
+ export async function testEndpoint(options: TestEndpointOptions): Promise<TestEndpointResult> {
67
+ const { projectDir, path, method = "GET", headers = {}, body, includeLogs = true } = options;
68
+
69
+ // 1. Resolve project URL
70
+ const link = await readProjectLink(projectDir);
71
+ const status = await getProjectStatus(undefined, projectDir);
72
+
73
+ if (!status?.workerUrl) {
74
+ throw new Error("Project has no deployed URL. Deploy first with 'jack ship'.");
75
+ }
76
+
77
+ const baseUrl = status.workerUrl.replace(/\/$/, "");
78
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
79
+
80
+ // Validate path stays on the same origin (prevent URL manipulation)
81
+ const resolvedUrl = new URL(normalizedPath, baseUrl);
82
+ if (resolvedUrl.origin !== new URL(baseUrl).origin) {
83
+ throw new Error("Path must not redirect to a different host");
84
+ }
85
+ const fullUrl = resolvedUrl.href;
86
+
87
+ // 2. Optionally start log session (managed only)
88
+ let logSessionCleanup: (() => void) | null = null;
89
+ let logCollector: Promise<LogEntry[]> | null = null;
90
+
91
+ const isManaged = link?.deploy_mode === "managed" && link.project_id;
92
+ if (includeLogs && isManaged) {
93
+ const logResult = await startLogCollection(link.project_id);
94
+ logSessionCleanup = logResult.cleanup;
95
+ logCollector = logResult.collector;
96
+
97
+ // Small delay for SSE connection to establish
98
+ await sleep(LOG_SETTLE_DELAY_MS);
99
+ }
100
+
101
+ // 3. Make the HTTP request
102
+ const requestHeaders: Record<string, string> = { ...headers };
103
+ if (body && !requestHeaders["content-type"]) {
104
+ requestHeaders["content-type"] = "application/json";
105
+ }
106
+
107
+ const requestStart = Date.now();
108
+ let response: Response;
109
+ try {
110
+ const controller = new AbortController();
111
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
112
+
113
+ response = await fetch(fullUrl, {
114
+ method,
115
+ headers: requestHeaders,
116
+ body: body || undefined,
117
+ signal: controller.signal,
118
+ redirect: "follow",
119
+ });
120
+
121
+ clearTimeout(timeout);
122
+ } catch (error) {
123
+ logSessionCleanup?.();
124
+ if (error instanceof Error && error.name === "AbortError") {
125
+ throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
126
+ }
127
+ throw new Error(`Request failed: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+
130
+ const durationMs = Date.now() - requestStart;
131
+
132
+ // 4. Read response + collect logs, ensuring cleanup on any failure
133
+ try {
134
+ const rawBody = await response.text();
135
+ // Truncate large responses to prevent MCP message bloat (1MB limit)
136
+ const responseBody =
137
+ rawBody.length > MAX_RESPONSE_BODY_SIZE
138
+ ? `${rawBody.slice(0, MAX_RESPONSE_BODY_SIZE)}\n... [truncated, ${rawBody.length} bytes total]`
139
+ : rawBody;
140
+ const responseHeaders: Record<string, string> = {};
141
+ response.headers.forEach((value, key) => {
142
+ responseHeaders[key] = value;
143
+ });
144
+
145
+ // 5. Collect logs if started
146
+ let logs: LogEntry[] = [];
147
+ if (logCollector) {
148
+ // Wait for logs to flush from the worker
149
+ await sleep(LOG_FLUSH_DELAY_MS);
150
+ logSessionCleanup?.();
151
+ logSessionCleanup = null;
152
+ logs = await logCollector;
153
+ }
154
+
155
+ return {
156
+ request: {
157
+ method,
158
+ url: fullUrl,
159
+ headers: requestHeaders,
160
+ body: body ?? null,
161
+ },
162
+ response: {
163
+ status: response.status,
164
+ status_text: response.statusText,
165
+ headers: responseHeaders,
166
+ body: responseBody,
167
+ duration_ms: durationMs,
168
+ },
169
+ logs,
170
+ };
171
+ } finally {
172
+ // Ensure log session is always cleaned up
173
+ logSessionCleanup?.();
174
+ }
175
+ }
176
+
177
+ // ============================================================================
178
+ // Internal Helpers
179
+ // ============================================================================
180
+
181
+ /**
182
+ * Start collecting logs from a managed project's SSE stream.
183
+ * Returns a collector promise that resolves with accumulated log entries
184
+ * and a cleanup function to stop the stream.
185
+ */
186
+ async function startLogCollection(
187
+ projectId: string,
188
+ ): Promise<{ collector: Promise<LogEntry[]>; cleanup: () => void }> {
189
+ const { startLogSession, getControlApiUrl } = await import("../control-plane.ts");
190
+ const { authFetch } = await import("../auth/index.ts");
191
+
192
+ const session = await startLogSession(projectId, "endpoint-test");
193
+ const streamUrl = `${getControlApiUrl()}${session.stream.url}`;
194
+
195
+ const controller = new AbortController();
196
+ const cleanup = () => controller.abort();
197
+
198
+ const collector = collectLogEvents(streamUrl, controller, authFetch);
199
+
200
+ return { collector, cleanup };
201
+ }
202
+
203
+ /**
204
+ * Read log events from an SSE stream until aborted or timeout.
205
+ */
206
+ async function collectLogEvents(
207
+ streamUrl: string,
208
+ controller: AbortController,
209
+ authFetch: (url: string, init?: RequestInit) => Promise<Response>,
210
+ ): Promise<LogEntry[]> {
211
+ const events: LogEntry[] = [];
212
+
213
+ // Auto-timeout for safety
214
+ const timeout = setTimeout(() => controller.abort(), LOG_COLLECT_DURATION_MS);
215
+
216
+ try {
217
+ const response = await authFetch(streamUrl, {
218
+ method: "GET",
219
+ headers: { Accept: "text/event-stream" },
220
+ signal: controller.signal,
221
+ });
222
+
223
+ if (!response.ok || !response.body) {
224
+ return events;
225
+ }
226
+
227
+ const reader = response.body.getReader();
228
+ const decoder = new TextDecoder();
229
+ let buffer = "";
230
+
231
+ while (true) {
232
+ const { done, value } = await reader.read();
233
+ if (done) break;
234
+
235
+ buffer += decoder.decode(value, { stream: true });
236
+ const lines = buffer.split("\n");
237
+ buffer = lines.pop() || "";
238
+
239
+ for (const line of lines) {
240
+ if (!line.startsWith("data:")) continue;
241
+ const data = line.slice(5).trim();
242
+ if (!data) continue;
243
+
244
+ try {
245
+ const parsed = JSON.parse(data) as {
246
+ type?: string;
247
+ level?: string;
248
+ message?: unknown[];
249
+ timestamp?: string;
250
+ };
251
+ if (parsed.type === "event") {
252
+ events.push({
253
+ level: parsed.level ?? "log",
254
+ message: parsed.message ?? [],
255
+ timestamp: parsed.timestamp ?? new Date().toISOString(),
256
+ });
257
+ }
258
+ } catch {}
259
+ }
260
+ }
261
+ } catch (error) {
262
+ // Abort is normal (cleanup or timeout). Non-abort errors: return whatever we collected.
263
+ if (error instanceof Error && error.name !== "AbortError") {
264
+ // Silently swallow — partial logs are better than no response
265
+ }
266
+ } finally {
267
+ clearTimeout(timeout);
268
+ }
269
+
270
+ return events;
271
+ }
272
+
273
+ function sleep(ms: number): Promise<void> {
274
+ return new Promise((resolve) => setTimeout(resolve, ms));
275
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Project deletion service — shared between CLI (jack down --force) and MCP (delete_project).
3
+ *
4
+ * Handles both managed (control plane) and BYO (wrangler) deploy modes.
5
+ * Non-fatal failures (export, individual resource) populate warnings[], not thrown.
6
+ */
7
+
8
+ import { mkdirSync } from "node:fs";
9
+ import { writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import {
12
+ checkWorkerExists,
13
+ deleteDatabase,
14
+ deleteWorker,
15
+ exportDatabase,
16
+ } from "../cloudflare-api.ts";
17
+ import { getJackHome } from "../config.ts";
18
+ import {
19
+ deleteManagedProject,
20
+ exportManagedDatabase,
21
+ fetchProjectResources,
22
+ } from "../control-plane.ts";
23
+ import { readProjectLink } from "../project-link.ts";
24
+ import { parseWranglerResources } from "../resources.ts";
25
+ import { getProjectNameFromDir } from "../storage/index.ts";
26
+
27
+ export interface DeleteProjectOptions {
28
+ exportDatabase?: boolean;
29
+ exportDir?: string;
30
+ }
31
+
32
+ export interface DeleteProjectResult {
33
+ projectName: string;
34
+ deployMode: "managed" | "byo";
35
+ workerDeleted: boolean;
36
+ databaseDeleted: boolean;
37
+ databaseName: string | null;
38
+ databaseExportPath: string | null;
39
+ resourceResults?: Array<{ resource: string; success: boolean; error?: string }>;
40
+ warnings: string[];
41
+ }
42
+
43
+ export async function deleteProject(
44
+ projectDir: string,
45
+ options?: DeleteProjectOptions,
46
+ ): Promise<DeleteProjectResult> {
47
+ const exportDb = options?.exportDatabase ?? false;
48
+ const warnings: string[] = [];
49
+
50
+ const projectName = await getProjectNameFromDir(projectDir);
51
+ const link = await readProjectLink(projectDir);
52
+ const deployMode = link?.deploy_mode ?? "byo";
53
+
54
+ if (deployMode === "managed" && link) {
55
+ return deleteManagedFlow(link.project_id, projectName, projectDir, exportDb, options, warnings);
56
+ }
57
+
58
+ return deleteByoFlow(projectName, projectDir, exportDb, options, warnings);
59
+ }
60
+
61
+ async function deleteManagedFlow(
62
+ projectId: string,
63
+ projectName: string,
64
+ projectDir: string,
65
+ exportDb: boolean,
66
+ options: DeleteProjectOptions | undefined,
67
+ warnings: string[],
68
+ ): Promise<DeleteProjectResult> {
69
+ let databaseName: string | null = null;
70
+ let databaseExportPath: string | null = null;
71
+
72
+ // Resolve database name from control plane
73
+ try {
74
+ const resources = await fetchProjectResources(projectId);
75
+ const d1 = resources.find((r) => r.resource_type === "d1");
76
+ databaseName = d1?.resource_name ?? null;
77
+ } catch {
78
+ // Can't resolve — continue without DB info
79
+ }
80
+
81
+ // Optional export before deletion
82
+ if (exportDb && databaseName) {
83
+ const exportDir = options?.exportDir ?? projectDir ?? join(getJackHome(), projectName);
84
+ mkdirSync(exportDir, { recursive: true });
85
+ const exportPath = join(exportDir, `${projectName}-backup.sql`);
86
+
87
+ try {
88
+ const exportResult = await exportManagedDatabase(projectId);
89
+ const response = await fetch(exportResult.download_url);
90
+ if (!response.ok) {
91
+ throw new Error(`Failed to download export: ${response.statusText}`);
92
+ }
93
+ const sqlContent = await response.text();
94
+ await writeFile(exportPath, sqlContent, "utf-8");
95
+ databaseExportPath = exportPath;
96
+ } catch (err) {
97
+ warnings.push(`Database export failed: ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ }
100
+
101
+ // Delete everything via control plane
102
+ const result = await deleteManagedProject(projectId);
103
+
104
+ for (const resource of result.resources) {
105
+ if (!resource.success) {
106
+ warnings.push(`Failed to delete ${resource.resource}: ${resource.error}`);
107
+ }
108
+ }
109
+
110
+ return {
111
+ projectName,
112
+ deployMode: "managed",
113
+ workerDeleted: true,
114
+ databaseDeleted: databaseName !== null,
115
+ databaseName,
116
+ databaseExportPath,
117
+ resourceResults: result.resources,
118
+ warnings,
119
+ };
120
+ }
121
+
122
+ async function deleteByoFlow(
123
+ projectName: string,
124
+ projectDir: string,
125
+ exportDb: boolean,
126
+ options: DeleteProjectOptions | undefined,
127
+ warnings: string[],
128
+ ): Promise<DeleteProjectResult> {
129
+ let databaseName: string | null = null;
130
+ let databaseExportPath: string | null = null;
131
+ let databaseDeleted = false;
132
+ let workerDeleted = false;
133
+
134
+ // Resolve DB name from wrangler.jsonc
135
+ try {
136
+ const resources = await parseWranglerResources(projectDir);
137
+ databaseName = resources.d1?.name ?? null;
138
+ } catch {
139
+ // Can't parse — continue without DB info
140
+ }
141
+
142
+ // Optional export before deletion
143
+ if (exportDb && databaseName) {
144
+ const exportDir = options?.exportDir ?? projectDir ?? join(getJackHome(), projectName);
145
+ mkdirSync(exportDir, { recursive: true });
146
+ const exportPath = join(exportDir, `${databaseName}-backup.sql`);
147
+
148
+ try {
149
+ await exportDatabase(databaseName, exportPath);
150
+ databaseExportPath = exportPath;
151
+ } catch (err) {
152
+ warnings.push(`Database export failed: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ }
155
+
156
+ // Delete worker
157
+ const workerExists = await checkWorkerExists(projectName);
158
+ if (workerExists) {
159
+ try {
160
+ await deleteWorker(projectName);
161
+ workerDeleted = true;
162
+ } catch (err) {
163
+ warnings.push(`Worker deletion failed: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ } else {
166
+ warnings.push(`Worker '${projectName}' not found — may already be deleted`);
167
+ }
168
+
169
+ // Delete database
170
+ if (databaseName) {
171
+ try {
172
+ await deleteDatabase(databaseName);
173
+ databaseDeleted = true;
174
+ } catch (err) {
175
+ warnings.push(
176
+ `Database deletion failed: ${err instanceof Error ? err.message : String(err)}`,
177
+ );
178
+ }
179
+ }
180
+
181
+ return {
182
+ projectName,
183
+ deployMode: "byo",
184
+ workerDeleted,
185
+ databaseDeleted,
186
+ databaseName,
187
+ databaseExportPath,
188
+ warnings,
189
+ };
190
+ }