@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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/commands/down.ts +39 -7
- package/src/commands/link.ts +2 -4
- package/src/commands/logs.ts +2 -4
- package/src/commands/mcp.ts +12 -10
- package/src/commands/secrets.ts +3 -1
- package/src/commands/services.ts +4 -2
- package/src/commands/sync.ts +5 -6
- package/src/lib/auth/client.ts +5 -2
- package/src/lib/binding-validator.ts +39 -3
- package/src/lib/build-helper.ts +18 -19
- package/src/lib/control-plane.ts +1 -0
- package/src/lib/crypto.ts +84 -0
- package/src/lib/deploy-upload.ts +7 -3
- package/src/lib/do-config.ts +110 -0
- package/src/lib/do-export-validator.ts +26 -0
- package/src/lib/hooks.ts +1 -2
- package/src/lib/jsonc-edit.ts +292 -0
- package/src/lib/managed-deploy.ts +36 -1
- package/src/lib/project-link.ts +37 -0
- package/src/lib/project-operations.ts +37 -46
- package/src/lib/prompts.ts +2 -2
- package/src/lib/resources.ts +4 -5
- package/src/lib/schema.ts +8 -12
- package/src/lib/services/db-create.ts +2 -2
- package/src/lib/services/db-execute.ts +9 -6
- package/src/lib/services/db-list.ts +6 -4
- package/src/lib/services/endpoint-test.ts +275 -0
- package/src/lib/services/project-delete.ts +190 -0
- package/src/lib/services/project-environment.ts +457 -0
- package/src/lib/services/storage-config.ts +7 -309
- package/src/lib/services/storage-create.ts +2 -1
- package/src/lib/services/storage-delete.ts +3 -2
- package/src/lib/services/storage-info.ts +2 -1
- package/src/lib/services/storage-list.ts +6 -3
- package/src/lib/services/vectorize-config.ts +7 -264
- package/src/lib/services/vectorize-create.ts +2 -1
- package/src/lib/services/vectorize-delete.ts +6 -4
- package/src/lib/services/vectorize-list.ts +6 -3
- package/src/lib/storage/index.ts +21 -23
- package/src/lib/telemetry.ts +1 -0
- package/src/lib/wrangler-config.ts +43 -312
- package/src/lib/zip-packager.ts +28 -0
- package/src/mcp/test-utils.ts +31 -0
- package/src/mcp/tools/index.ts +271 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/types.ts +4 -0
- package/templates/AI-BINDINGS.md +34 -76
- package/templates/CLAUDE.md +22 -1
- package/templates/ai-chat/src/index.ts +7 -14
- package/templates/ai-chat/src/jack-ai.ts +0 -6
- package/templates/chat/.jack.json +45 -0
- package/templates/chat/bun.lock +1588 -0
- package/templates/chat/components.json +23 -0
- package/templates/chat/index.html +12 -0
- package/templates/chat/package.json +41 -0
- package/templates/chat/src/chat-agent.ts +61 -0
- package/templates/chat/src/client/app.tsx +189 -0
- package/templates/chat/src/client/chat.tsx +222 -0
- package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
- package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
- package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
- package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
- package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
- package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
- package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
- package/templates/chat/src/client/components/ui/button.tsx +38 -0
- package/templates/chat/src/client/lib/utils.ts +6 -0
- package/templates/chat/src/client/main.tsx +11 -0
- package/templates/chat/src/client/styles.css +125 -0
- package/templates/chat/src/index.ts +25 -0
- package/templates/chat/src/jack-ai.ts +94 -0
- package/templates/chat/tsconfig.json +18 -0
- package/templates/chat/vite.config.ts +14 -0
- package/templates/chat/wrangler.jsonc +18 -0
- package/templates/cron/.jack.json +18 -28
- package/templates/cron/schema.sql +10 -20
- package/templates/cron/src/admin.ts +321 -0
- package/templates/cron/src/index.ts +151 -81
- package/templates/cron/src/monitor.ts +124 -0
- package/templates/nextjs-clerk/app/layout.tsx +2 -0
- package/templates/semantic-search/src/index.ts +5 -43
- package/templates/semantic-search/src/jack-ai.ts +0 -6
- package/templates/telegram-bot/.jack.json +56 -0
- package/templates/telegram-bot/bun.lock +41 -0
- package/templates/telegram-bot/package.json +16 -0
- package/templates/telegram-bot/src/index.ts +236 -0
- package/templates/telegram-bot/src/jack-ai.ts +100 -0
- package/templates/telegram-bot/tsconfig.json +11 -0
- package/templates/telegram-bot/wrangler.jsonc +8 -0
- package/templates/cron/src/jobs.ts +0 -139
- package/templates/cron/src/webhooks.ts +0 -95
- 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 =
|
|
51
|
+
const wranglerPath = findWranglerConfig(projectDir);
|
|
51
52
|
|
|
52
|
-
if (!
|
|
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 =
|
|
75
|
+
const wranglerPath = findWranglerConfig(projectDir);
|
|
75
76
|
|
|
76
|
-
if (!
|
|
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 =
|
|
95
|
+
const wranglerPath = findWranglerConfig(projectDir);
|
|
95
96
|
|
|
96
|
-
if (!
|
|
97
|
+
if (!wranglerPath) {
|
|
97
98
|
return null;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
try {
|
|
101
102
|
const content = await Bun.file(wranglerPath).text();
|
|
102
|
-
|
|
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 {
|
|
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 =
|
|
104
|
+
const wranglerPath = findWranglerConfig(projectDir);
|
|
102
105
|
|
|
103
|
-
if (!
|
|
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 =
|
|
125
|
+
const wranglerPath = findWranglerConfig(projectDir);
|
|
123
126
|
|
|
124
|
-
if (!
|
|
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 =
|
|
27
|
+
const wranglerPath = findWranglerConfig(projectDir);
|
|
28
|
+
if (!wranglerPath) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
29
31
|
|
|
30
|
-
// Get existing D1 bindings from wrangler
|
|
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
|
+
}
|