@getjack/jack 0.1.17 → 0.1.19
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 +2 -5
- package/src/commands/clone.ts +62 -40
- package/src/commands/init.ts +6 -0
- package/src/commands/services.ts +85 -4
- package/src/commands/sync.ts +9 -0
- package/src/commands/update.ts +10 -0
- package/src/lib/control-plane.ts +62 -0
- package/src/lib/managed-deploy.ts +26 -2
- package/src/lib/output.ts +21 -3
- package/src/lib/progress.ts +160 -0
- package/src/lib/project-operations.ts +103 -62
- package/src/lib/services/db-create.ts +6 -3
- package/src/lib/version-check.ts +14 -0
- package/src/lib/wrangler-config.ts +190 -0
- package/src/lib/zip-packager.ts +38 -0
- package/src/lib/zip-utils.ts +38 -0
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getjack/jack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Ship before you forget why you started. The vibecoder's deployment CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"jack": "./src/index.ts"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
11
|
-
"src",
|
|
12
|
-
"templates"
|
|
13
|
-
],
|
|
10
|
+
"files": ["src", "templates"],
|
|
14
11
|
"engines": {
|
|
15
12
|
"bun": ">=1.0.0"
|
|
16
13
|
},
|
package/src/commands/clone.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { isCancel, select } from "@clack/prompts";
|
|
4
|
-
import { fetchProjectTags } from "../lib/control-plane.ts";
|
|
4
|
+
import { downloadProjectSource, fetchProjectTags } from "../lib/control-plane.ts";
|
|
5
5
|
import { formatSize } from "../lib/format.ts";
|
|
6
6
|
import { box, error, info, spinner, success } from "../lib/output.ts";
|
|
7
7
|
import { registerPath } from "../lib/paths-index.ts";
|
|
8
8
|
import { linkProject, updateProjectLink } from "../lib/project-link.ts";
|
|
9
9
|
import { resolveProject } from "../lib/project-resolver.ts";
|
|
10
10
|
import { cloneFromCloud, getRemoteManifest } from "../lib/storage/index.ts";
|
|
11
|
+
import { extractZipToDirectory } from "../lib/zip-utils.ts";
|
|
11
12
|
|
|
12
13
|
export interface CloneFlags {
|
|
13
14
|
as?: string;
|
|
@@ -53,53 +54,74 @@ export default async function clone(projectName?: string, flags: CloneFlags = {}
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
const spin = spinner(`
|
|
58
|
-
|
|
57
|
+
// Check if this is a managed project (has source in control-plane)
|
|
58
|
+
const spin = spinner(`Looking up ${projectName}...`);
|
|
59
|
+
let project: Awaited<ReturnType<typeof resolveProject>> = null;
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
try {
|
|
62
|
+
project = await resolveProject(projectName);
|
|
63
|
+
} catch {
|
|
64
|
+
// Not found on control-plane, will fall back to User R2
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Managed mode: download from control-plane
|
|
68
|
+
if (project?.sources.controlPlane && project.remote?.projectId) {
|
|
69
|
+
spin.success("Found on jack cloud");
|
|
70
|
+
|
|
71
|
+
const downloadSpin = spinner("Downloading from jack cloud...");
|
|
72
|
+
try {
|
|
73
|
+
const sourceZip = await downloadProjectSource(projectName);
|
|
74
|
+
const fileCount = await extractZipToDirectory(sourceZip, targetDir);
|
|
75
|
+
downloadSpin.success(`Restored ${fileCount} file(s) to ./${flags.as ?? projectName}/`);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
downloadSpin.error("Download failed");
|
|
78
|
+
const message = err instanceof Error ? err.message : "Could not download project source";
|
|
79
|
+
error(message);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
// Link to control-plane
|
|
84
|
+
await linkProject(targetDir, project.remote.projectId, "managed");
|
|
85
|
+
await registerPath(project.remote.projectId, targetDir);
|
|
72
86
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
// Fetch and restore tags from control plane
|
|
88
|
+
try {
|
|
89
|
+
const remoteTags = await fetchProjectTags(project.remote.projectId);
|
|
90
|
+
if (remoteTags.length > 0) {
|
|
91
|
+
await updateProjectLink(targetDir, { tags: remoteTags });
|
|
92
|
+
info(`Restored ${remoteTags.length} tag(s)`);
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Silent fail - tag restoration is non-critical
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// BYO mode: use existing User R2 flow
|
|
99
|
+
spin.stop();
|
|
100
|
+
const fetchSpin = spinner(`Fetching from jack-storage/${projectName}/...`);
|
|
101
|
+
const manifest = await getRemoteManifest(projectName);
|
|
102
|
+
|
|
103
|
+
if (!manifest) {
|
|
104
|
+
fetchSpin.error(`Project not found: ${projectName}`);
|
|
105
|
+
info("For BYO projects, run 'jack sync' first to backup your project.");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
79
108
|
|
|
80
|
-
|
|
109
|
+
// Show file count and size
|
|
110
|
+
const totalSize = manifest.files.reduce((sum, f) => sum + f.size, 0);
|
|
111
|
+
fetchSpin.success(`Found ${manifest.files.length} file(s) (${formatSize(totalSize)})`);
|
|
81
112
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const remoteTags = await fetchProjectTags(project.remote.projectId);
|
|
93
|
-
if (remoteTags.length > 0) {
|
|
94
|
-
await updateProjectLink(targetDir, { tags: remoteTags });
|
|
95
|
-
info(`Restored ${remoteTags.length} tag(s)`);
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
// Silent fail - tag restoration is non-critical
|
|
99
|
-
}
|
|
113
|
+
// Download files
|
|
114
|
+
const downloadSpin = spinner("Downloading...");
|
|
115
|
+
const result = await cloneFromCloud(projectName, targetDir);
|
|
116
|
+
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
downloadSpin.error("Clone failed");
|
|
119
|
+
error(result.error || "Could not download project files");
|
|
120
|
+
info("Check your network connection and try again");
|
|
121
|
+
process.exit(1);
|
|
100
122
|
}
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
|
|
124
|
+
downloadSpin.success(`Restored to ./${flags.as ?? projectName}/`);
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
// Show next steps
|
package/src/commands/init.ts
CHANGED
|
@@ -12,6 +12,12 @@ import { ensureAuth, ensureWrangler, isAuthenticated } from "../lib/wrangler.ts"
|
|
|
12
12
|
export async function isInitialized(): Promise<boolean> {
|
|
13
13
|
const config = await readConfig();
|
|
14
14
|
if (!config?.initialized) return false;
|
|
15
|
+
|
|
16
|
+
// Check Jack Cloud auth first (most common path)
|
|
17
|
+
const { isLoggedIn } = await import("../lib/auth/store.ts");
|
|
18
|
+
if (await isLoggedIn()) return true;
|
|
19
|
+
|
|
20
|
+
// Fall back to wrangler/Cloudflare auth (BYO mode)
|
|
15
21
|
return await isAuthenticated();
|
|
16
22
|
}
|
|
17
23
|
|
package/src/commands/services.ts
CHANGED
|
@@ -277,6 +277,12 @@ async function dbExport(options: ServiceOptions): Promise<void> {
|
|
|
277
277
|
*/
|
|
278
278
|
async function dbDelete(options: ServiceOptions): Promise<void> {
|
|
279
279
|
const projectName = await resolveProjectName(options);
|
|
280
|
+
const projectDir = process.cwd();
|
|
281
|
+
|
|
282
|
+
// Check deploy mode
|
|
283
|
+
const link = await readProjectLink(projectDir);
|
|
284
|
+
const isManaged = link?.deploy_mode === "managed";
|
|
285
|
+
|
|
280
286
|
const dbInfo = await resolveDatabaseInfo(projectName);
|
|
281
287
|
|
|
282
288
|
if (!dbInfo) {
|
|
@@ -314,14 +320,80 @@ async function dbDelete(options: ServiceOptions): Promise<void> {
|
|
|
314
320
|
return;
|
|
315
321
|
}
|
|
316
322
|
|
|
317
|
-
// Delete database
|
|
318
323
|
outputSpinner.start("Deleting database...");
|
|
324
|
+
|
|
325
|
+
// Track binding_name from control plane for matching in wrangler.jsonc
|
|
326
|
+
let controlPlaneBindingName: string | null = null;
|
|
327
|
+
|
|
319
328
|
try {
|
|
320
|
-
|
|
329
|
+
if (isManaged && link) {
|
|
330
|
+
// Managed mode: delete via control plane
|
|
331
|
+
const { fetchProjectResources, deleteProjectResource } = await import(
|
|
332
|
+
"../lib/control-plane.ts"
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Find the resource ID for this database
|
|
336
|
+
const resources = await fetchProjectResources(link.project_id);
|
|
337
|
+
const d1Resource = resources.find(
|
|
338
|
+
(r) => r.resource_type === "d1" && r.resource_name === dbInfo.name,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (d1Resource) {
|
|
342
|
+
// Save binding_name for wrangler.jsonc cleanup
|
|
343
|
+
controlPlaneBindingName = d1Resource.binding_name;
|
|
344
|
+
// Delete via control plane (which also deletes from Cloudflare)
|
|
345
|
+
await deleteProjectResource(link.project_id, d1Resource.id);
|
|
346
|
+
} else {
|
|
347
|
+
// Resource not in control plane - fall back to wrangler for cleanup
|
|
348
|
+
await deleteDatabase(dbInfo.name);
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
// BYO mode: delete via wrangler directly
|
|
352
|
+
await deleteDatabase(dbInfo.name);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Remove binding from wrangler.jsonc (both modes)
|
|
356
|
+
// Note: We need to find the LOCAL database_name from wrangler.jsonc,
|
|
357
|
+
// which may differ from the control plane's resource_name
|
|
358
|
+
const { removeD1Binding, getExistingD1Bindings } = await import("../lib/wrangler-config.ts");
|
|
359
|
+
const configPath = join(projectDir, "wrangler.jsonc");
|
|
360
|
+
|
|
361
|
+
let bindingRemoved = false;
|
|
362
|
+
try {
|
|
363
|
+
// Find the binding by matching (in order of reliability):
|
|
364
|
+
// 1. binding name (e.g., "DB") - if control plane provided it
|
|
365
|
+
// 2. database_id (provider_id from control plane)
|
|
366
|
+
// 3. database_name
|
|
367
|
+
// 4. If managed mode and we successfully deleted, remove first D1 binding
|
|
368
|
+
const existingBindings = await getExistingD1Bindings(configPath);
|
|
369
|
+
let bindingToRemove = existingBindings.find(
|
|
370
|
+
(b) =>
|
|
371
|
+
(controlPlaneBindingName && b.binding === controlPlaneBindingName) ||
|
|
372
|
+
b.database_id === dbInfo.id ||
|
|
373
|
+
b.database_name === dbInfo.name,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// Fallback: if managed mode and we deleted from control plane,
|
|
377
|
+
// remove the first D1 binding (binding_name may be null for older DBs)
|
|
378
|
+
if (!bindingToRemove && isManaged && existingBindings.length > 0) {
|
|
379
|
+
bindingToRemove = existingBindings[0];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (bindingToRemove) {
|
|
383
|
+
bindingRemoved = await removeD1Binding(configPath, bindingToRemove.database_name);
|
|
384
|
+
}
|
|
385
|
+
} catch (bindingErr) {
|
|
386
|
+
// Log but don't fail - the database is already deleted
|
|
387
|
+
// The user can manually clean up wrangler.jsonc if needed
|
|
388
|
+
}
|
|
389
|
+
|
|
321
390
|
outputSpinner.stop();
|
|
322
391
|
|
|
323
392
|
console.error("");
|
|
324
393
|
success("Database deleted");
|
|
394
|
+
if (bindingRemoved) {
|
|
395
|
+
item("Binding removed from wrangler.jsonc");
|
|
396
|
+
}
|
|
325
397
|
console.error("");
|
|
326
398
|
} catch (err) {
|
|
327
399
|
outputSpinner.stop();
|
|
@@ -332,10 +404,11 @@ async function dbDelete(options: ServiceOptions): Promise<void> {
|
|
|
332
404
|
}
|
|
333
405
|
|
|
334
406
|
/**
|
|
335
|
-
* Parse --name flag from args
|
|
336
|
-
* Supports: --name foo, --name=foo
|
|
407
|
+
* Parse --name flag or positional arg from args
|
|
408
|
+
* Supports: --name foo, --name=foo, or first positional arg
|
|
337
409
|
*/
|
|
338
410
|
function parseNameFlag(args: string[]): string | undefined {
|
|
411
|
+
// Check --name flag first (takes priority)
|
|
339
412
|
for (let i = 0; i < args.length; i++) {
|
|
340
413
|
const arg = args[i];
|
|
341
414
|
if (arg === "--name" && args[i + 1]) {
|
|
@@ -345,6 +418,14 @@ function parseNameFlag(args: string[]): string | undefined {
|
|
|
345
418
|
return arg.slice("--name=".length);
|
|
346
419
|
}
|
|
347
420
|
}
|
|
421
|
+
|
|
422
|
+
// Fall back to first positional argument (non-flag)
|
|
423
|
+
for (const arg of args) {
|
|
424
|
+
if (!arg.startsWith("-")) {
|
|
425
|
+
return arg;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
348
429
|
return undefined;
|
|
349
430
|
}
|
|
350
431
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { getSyncConfig } from "../lib/config.ts";
|
|
3
3
|
import { error, info, spinner, success, warn } from "../lib/output.ts";
|
|
4
|
+
import { readProjectLink } from "../lib/project-link.ts";
|
|
4
5
|
import { syncToCloud } from "../lib/storage/index.ts";
|
|
5
6
|
|
|
6
7
|
export interface SyncFlags {
|
|
@@ -25,6 +26,14 @@ export default async function sync(flags: SyncFlags = {}): Promise<void> {
|
|
|
25
26
|
process.exit(1);
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// Check if this is a managed project
|
|
30
|
+
const link = await readProjectLink(process.cwd());
|
|
31
|
+
if (link?.deploy_mode === "managed") {
|
|
32
|
+
info("Managed projects are automatically backed up to jack cloud during deploy.");
|
|
33
|
+
info("Use 'jack clone <project>' on another machine to restore.");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
// Check if sync is enabled
|
|
29
38
|
const syncConfig = await getSyncConfig();
|
|
30
39
|
if (!syncConfig.enabled) {
|
package/src/commands/update.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* jack update - Self-update to the latest version
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { debug } from "../lib/debug.ts";
|
|
5
6
|
import { error, info, success, warn } from "../lib/output.ts";
|
|
6
7
|
import {
|
|
7
8
|
checkForUpdate,
|
|
@@ -13,8 +14,13 @@ import {
|
|
|
13
14
|
export default async function update(): Promise<void> {
|
|
14
15
|
const currentVersion = getCurrentVersion();
|
|
15
16
|
|
|
17
|
+
debug("Update check started");
|
|
18
|
+
debug(`Current version: ${currentVersion}`);
|
|
19
|
+
debug(`Exec path: ${process.argv[1]}`);
|
|
20
|
+
|
|
16
21
|
// Check if running via bunx
|
|
17
22
|
if (isRunningViaBunx()) {
|
|
23
|
+
debug("Detected bunx execution");
|
|
18
24
|
info(`Running via bunx (current: v${currentVersion})`);
|
|
19
25
|
info("bunx automatically uses cached packages.");
|
|
20
26
|
info("To get the latest version, run:");
|
|
@@ -28,7 +34,9 @@ export default async function update(): Promise<void> {
|
|
|
28
34
|
info(`Current version: v${currentVersion}`);
|
|
29
35
|
|
|
30
36
|
// Check for updates
|
|
37
|
+
debug("Fetching latest version from npm...");
|
|
31
38
|
const latestVersion = await checkForUpdate();
|
|
39
|
+
debug(`Latest version from npm: ${latestVersion ?? "none (you're up to date)"}`);
|
|
32
40
|
|
|
33
41
|
if (!latestVersion) {
|
|
34
42
|
success("You're on the latest version!");
|
|
@@ -38,7 +46,9 @@ export default async function update(): Promise<void> {
|
|
|
38
46
|
info(`New version available: v${latestVersion}`);
|
|
39
47
|
info("Updating...");
|
|
40
48
|
|
|
49
|
+
debug("Running: bun add -g @getjack/jack@latest");
|
|
41
50
|
const result = await performUpdate();
|
|
51
|
+
debug(`Update result: ${JSON.stringify(result)}`);
|
|
42
52
|
|
|
43
53
|
if (result.success) {
|
|
44
54
|
success(`Updated to v${result.version ?? latestVersion}`);
|
package/src/lib/control-plane.ts
CHANGED
|
@@ -371,6 +371,46 @@ export async function createProjectResource(
|
|
|
371
371
|
return data.resource;
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
export interface DeleteResourceResponse {
|
|
375
|
+
success: boolean;
|
|
376
|
+
resource_id: string;
|
|
377
|
+
deleted_at: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Delete a resource from a managed project.
|
|
382
|
+
* Uses DELETE /v1/projects/:id/resources/:id endpoint.
|
|
383
|
+
*/
|
|
384
|
+
export async function deleteProjectResource(
|
|
385
|
+
projectId: string,
|
|
386
|
+
resourceId: string,
|
|
387
|
+
): Promise<DeleteResourceResponse> {
|
|
388
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
389
|
+
|
|
390
|
+
const response = await authFetch(
|
|
391
|
+
`${getControlApiUrl()}/v1/projects/${projectId}/resources/${resourceId}`,
|
|
392
|
+
{ method: "DELETE" },
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Handle 404 gracefully - resource may already be deleted
|
|
396
|
+
if (response.status === 404) {
|
|
397
|
+
return {
|
|
398
|
+
success: true,
|
|
399
|
+
resource_id: resourceId,
|
|
400
|
+
deleted_at: new Date().toISOString(),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!response.ok) {
|
|
405
|
+
const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
|
|
406
|
+
message?: string;
|
|
407
|
+
};
|
|
408
|
+
throw new Error(err.message || `Failed to delete resource: ${response.status}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return response.json() as Promise<DeleteResourceResponse>;
|
|
412
|
+
}
|
|
413
|
+
|
|
374
414
|
/**
|
|
375
415
|
* Sync project tags to the control plane.
|
|
376
416
|
* Fire-and-forget: errors are logged but not thrown.
|
|
@@ -580,3 +620,25 @@ export async function publishProject(projectId: string): Promise<PublishProjectR
|
|
|
580
620
|
|
|
581
621
|
return response.json() as Promise<PublishProjectResponse>;
|
|
582
622
|
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Download the source snapshot for a project.
|
|
626
|
+
* Returns the zip file contents as a Buffer.
|
|
627
|
+
* Used by jack clone to restore managed projects.
|
|
628
|
+
*/
|
|
629
|
+
export async function downloadProjectSource(slug: string): Promise<Buffer> {
|
|
630
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
631
|
+
|
|
632
|
+
const response = await authFetch(
|
|
633
|
+
`${getControlApiUrl()}/v1/projects/by-slug/${encodeURIComponent(slug)}/source`,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
if (!response.ok) {
|
|
637
|
+
if (response.status === 404) {
|
|
638
|
+
throw new Error("Project source not found. Deploy first with 'jack ship'.");
|
|
639
|
+
}
|
|
640
|
+
throw new Error(`Failed to download source: ${response.status}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return Buffer.from(await response.arrayBuffer());
|
|
644
|
+
}
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
* Isolates managed deployment logic from BYO (wrangler) path.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
7
8
|
import { validateBindings } from "./binding-validator.ts";
|
|
8
9
|
import { buildProject, parseWranglerConfig } from "./build-helper.ts";
|
|
9
10
|
import { createManagedProject, syncProjectTags, uploadSourceSnapshot } from "./control-plane.ts";
|
|
10
11
|
import { debug } from "./debug.ts";
|
|
11
12
|
import { uploadDeployment } from "./deploy-upload.ts";
|
|
12
13
|
import { JackError, JackErrorCode } from "./errors.ts";
|
|
14
|
+
import { formatSize } from "./format.ts";
|
|
15
|
+
import { createUploadProgress } from "./progress.ts";
|
|
13
16
|
import type { OperationReporter } from "./project-operations.ts";
|
|
14
17
|
import { getProjectTags } from "./tags.ts";
|
|
15
18
|
import { Events, track } from "./telemetry.ts";
|
|
@@ -120,7 +123,28 @@ export async function deployCodeToManagedProject(
|
|
|
120
123
|
reporter?.success("Packaged artifacts");
|
|
121
124
|
|
|
122
125
|
// Step 4: Upload to control plane
|
|
123
|
-
|
|
126
|
+
// Calculate total upload size for progress display
|
|
127
|
+
const fileSizes = await Promise.all([
|
|
128
|
+
stat(pkg.bundleZipPath).then((s) => s.size),
|
|
129
|
+
stat(pkg.sourceZipPath).then((s) => s.size),
|
|
130
|
+
stat(pkg.manifestPath).then((s) => s.size),
|
|
131
|
+
pkg.schemaPath ? stat(pkg.schemaPath).then((s) => s.size) : Promise.resolve(0),
|
|
132
|
+
pkg.secretsPath ? stat(pkg.secretsPath).then((s) => s.size) : Promise.resolve(0),
|
|
133
|
+
pkg.assetsZipPath ? stat(pkg.assetsZipPath).then((s) => s.size) : Promise.resolve(0),
|
|
134
|
+
]);
|
|
135
|
+
const totalUploadSize = fileSizes.reduce((sum, size) => sum + size, 0);
|
|
136
|
+
debug(`Upload size: ${formatSize(totalUploadSize)}`);
|
|
137
|
+
|
|
138
|
+
// Stop the reporter spinner - we'll use our own progress display
|
|
139
|
+
reporter?.stop();
|
|
140
|
+
|
|
141
|
+
// Use custom progress with pulsing bar (since fetch doesn't support upload progress)
|
|
142
|
+
const uploadProgress = createUploadProgress({
|
|
143
|
+
totalSize: totalUploadSize,
|
|
144
|
+
label: "Uploading to jack cloud",
|
|
145
|
+
});
|
|
146
|
+
uploadProgress.start();
|
|
147
|
+
|
|
124
148
|
const result = await uploadDeployment({
|
|
125
149
|
projectId,
|
|
126
150
|
bundleZipPath: pkg.bundleZipPath,
|
|
@@ -132,7 +156,7 @@ export async function deployCodeToManagedProject(
|
|
|
132
156
|
assetManifest: pkg.assetManifest ?? undefined,
|
|
133
157
|
});
|
|
134
158
|
|
|
135
|
-
|
|
159
|
+
uploadProgress.complete();
|
|
136
160
|
reporter?.success("Deployed to jack cloud");
|
|
137
161
|
|
|
138
162
|
// Track success
|
package/src/lib/output.ts
CHANGED
|
@@ -2,6 +2,19 @@ import yoctoSpinner from "yocto-spinner";
|
|
|
2
2
|
|
|
3
3
|
const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* ANSI color codes for terminal output
|
|
7
|
+
*/
|
|
8
|
+
export const colors = {
|
|
9
|
+
green: isColorEnabled ? "\x1b[32m" : "",
|
|
10
|
+
red: isColorEnabled ? "\x1b[31m" : "",
|
|
11
|
+
yellow: isColorEnabled ? "\x1b[33m" : "",
|
|
12
|
+
cyan: isColorEnabled ? "\x1b[36m" : "",
|
|
13
|
+
dim: isColorEnabled ? "\x1b[90m" : "",
|
|
14
|
+
bold: isColorEnabled ? "\x1b[1m" : "",
|
|
15
|
+
reset: isColorEnabled ? "\x1b[0m" : "",
|
|
16
|
+
};
|
|
17
|
+
|
|
5
18
|
let currentSpinner: ReturnType<typeof yoctoSpinner> | null = null;
|
|
6
19
|
|
|
7
20
|
/**
|
|
@@ -95,8 +108,10 @@ export function item(message: string): void {
|
|
|
95
108
|
console.error(` ${message}`);
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Random neon purple for cyberpunk styling
|
|
113
|
+
*/
|
|
114
|
+
export function getRandomPurple(): string {
|
|
100
115
|
const purples = [177, 165, 141, 129];
|
|
101
116
|
const colorCode = purples[Math.floor(Math.random() * purples.length)];
|
|
102
117
|
return `\x1b[38;5;${colorCode}m`;
|
|
@@ -117,12 +132,15 @@ export function box(title: string, lines: string[]): void {
|
|
|
117
132
|
const fill = "▓".repeat(innerWidth);
|
|
118
133
|
const gradient = "░".repeat(innerWidth);
|
|
119
134
|
|
|
135
|
+
// Pad plain text first, then apply colors (ANSI codes break padEnd calculation)
|
|
120
136
|
const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
|
|
137
|
+
const padTitle = (text: string) =>
|
|
138
|
+
` ${bold}${text}${reset}${purple}${" ".repeat(maxLen - text.length)} `;
|
|
121
139
|
|
|
122
140
|
console.error("");
|
|
123
141
|
console.error(` ${purple}╔${bar}╗${reset}`);
|
|
124
142
|
console.error(` ${purple}║${fill}║${reset}`);
|
|
125
|
-
console.error(` ${purple}║${
|
|
143
|
+
console.error(` ${purple}║${padTitle(title)}║${reset}`);
|
|
126
144
|
console.error(` ${purple}║${"─".repeat(innerWidth)}║${reset}`);
|
|
127
145
|
for (const line of lines) {
|
|
128
146
|
console.error(` ${purple}║${pad(line)}║${reset}`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress tracker with delayed bar display
|
|
3
|
+
* Shows a simple spinner initially, then reveals a progress bar
|
|
4
|
+
* after a configurable delay (aligned with SPIRIT.md principles)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatSize } from "./format.ts";
|
|
8
|
+
import { colors, getRandomPurple } from "./output.ts";
|
|
9
|
+
|
|
10
|
+
export interface ProgressOptions {
|
|
11
|
+
total: number;
|
|
12
|
+
delayMs?: number;
|
|
13
|
+
barWidth?: number;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UploadProgressOptions {
|
|
18
|
+
totalSize: number;
|
|
19
|
+
delayMs?: number;
|
|
20
|
+
barWidth?: number;
|
|
21
|
+
label?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a progress tracker that shows a spinner initially,
|
|
28
|
+
* then reveals a progress bar after a delay.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* const progress = createProgressTracker({ total: fileSize });
|
|
32
|
+
* progress.start();
|
|
33
|
+
* // ... during upload
|
|
34
|
+
* progress.update(bytesUploaded);
|
|
35
|
+
* // ... when done
|
|
36
|
+
* progress.complete();
|
|
37
|
+
*/
|
|
38
|
+
export function createProgressTracker(options: ProgressOptions) {
|
|
39
|
+
const { total, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
let frame = 0;
|
|
42
|
+
let intervalId: Timer | null = null;
|
|
43
|
+
let current = 0;
|
|
44
|
+
|
|
45
|
+
function render() {
|
|
46
|
+
const elapsed = Date.now() - startTime;
|
|
47
|
+
const pct = Math.min(Math.round((current / total) * 100), 100);
|
|
48
|
+
|
|
49
|
+
clearLine();
|
|
50
|
+
|
|
51
|
+
if (elapsed < delayMs) {
|
|
52
|
+
// Just spinner for first N seconds
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
`${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
// Show progress bar after delay
|
|
58
|
+
const purple = getRandomPurple();
|
|
59
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
60
|
+
const empty = barWidth - filled;
|
|
61
|
+
const bar = "▓".repeat(filled) + "░".repeat(empty);
|
|
62
|
+
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${pct}% ${colors.dim}(${formatSize(current)} / ${formatSize(total)})${colors.reset}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
start() {
|
|
71
|
+
render();
|
|
72
|
+
intervalId = setInterval(render, 80);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
update(bytes: number) {
|
|
76
|
+
current = bytes;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
complete() {
|
|
80
|
+
if (intervalId) {
|
|
81
|
+
clearInterval(intervalId);
|
|
82
|
+
intervalId = null;
|
|
83
|
+
}
|
|
84
|
+
clearLine();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Creates an upload progress indicator for operations where we know the total
|
|
91
|
+
* size but can't track byte-level progress (e.g., fetch uploads).
|
|
92
|
+
*
|
|
93
|
+
* Shows spinner first, then after delay shows an animated bar with size info.
|
|
94
|
+
* The bar pulses to indicate activity without false progress claims.
|
|
95
|
+
*/
|
|
96
|
+
export function createUploadProgress(options: UploadProgressOptions) {
|
|
97
|
+
const { totalSize, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
|
|
98
|
+
const startTime = Date.now();
|
|
99
|
+
let frame = 0;
|
|
100
|
+
let pulsePos = 0;
|
|
101
|
+
let intervalId: Timer | null = null;
|
|
102
|
+
|
|
103
|
+
function render() {
|
|
104
|
+
const elapsed = Date.now() - startTime;
|
|
105
|
+
const elapsedSec = (elapsed / 1000).toFixed(1);
|
|
106
|
+
|
|
107
|
+
clearLine();
|
|
108
|
+
|
|
109
|
+
if (elapsed < delayMs) {
|
|
110
|
+
// Just spinner for first N seconds
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
// Show pulsing bar after delay (indicates activity without false progress)
|
|
116
|
+
const purple = getRandomPurple();
|
|
117
|
+
|
|
118
|
+
// Create pulsing effect - a bright section that moves across the bar
|
|
119
|
+
const pulseWidth = 5;
|
|
120
|
+
pulsePos = (pulsePos + 1) % (barWidth + pulseWidth);
|
|
121
|
+
|
|
122
|
+
let bar = "";
|
|
123
|
+
for (let i = 0; i < barWidth; i++) {
|
|
124
|
+
const distFromPulse = Math.abs(i - pulsePos);
|
|
125
|
+
if (distFromPulse < pulseWidth) {
|
|
126
|
+
bar += "▓";
|
|
127
|
+
} else {
|
|
128
|
+
bar += "░";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
`${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${colors.dim}${formatSize(totalSize)} • ${elapsedSec}s${colors.reset}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
start() {
|
|
140
|
+
if (process.stderr.isTTY) {
|
|
141
|
+
render();
|
|
142
|
+
intervalId = setInterval(render, 80);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
complete() {
|
|
147
|
+
if (intervalId) {
|
|
148
|
+
clearInterval(intervalId);
|
|
149
|
+
intervalId = null;
|
|
150
|
+
}
|
|
151
|
+
clearLine();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clearLine() {
|
|
157
|
+
if (process.stderr.isTTY) {
|
|
158
|
+
process.stderr.write("\r\x1b[K");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -39,8 +39,7 @@ import {
|
|
|
39
39
|
slugify,
|
|
40
40
|
writeWranglerConfig,
|
|
41
41
|
} from "./config-generator.ts";
|
|
42
|
-
import {
|
|
43
|
-
import { deleteManagedProject } from "./control-plane.ts";
|
|
42
|
+
import { deleteManagedProject, listManagedProjects } from "./control-plane.ts";
|
|
44
43
|
import { debug, isDebug, printTimingSummary, timerEnd, timerStart } from "./debug.ts";
|
|
45
44
|
import { ensureWranglerInstalled, validateModeAvailability } from "./deploy-mode.ts";
|
|
46
45
|
import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
|
|
@@ -67,7 +66,7 @@ import {
|
|
|
67
66
|
import { filterNewSecrets, promptSaveSecrets } from "./prompts.ts";
|
|
68
67
|
import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./schema.ts";
|
|
69
68
|
import { getSavedSecrets, saveSecrets } from "./secrets.ts";
|
|
70
|
-
import { getProjectNameFromDir, getRemoteManifest
|
|
69
|
+
import { getProjectNameFromDir, getRemoteManifest } from "./storage/index.ts";
|
|
71
70
|
import { Events, track } from "./telemetry.ts";
|
|
72
71
|
|
|
73
72
|
// ============================================================================
|
|
@@ -973,6 +972,25 @@ export async function createProject(
|
|
|
973
972
|
const rendered = renderTemplate(template, { name: projectName });
|
|
974
973
|
timings.push({ label: "Template load", duration: timerEnd("template-load") });
|
|
975
974
|
|
|
975
|
+
// Run preCreate hooks (for interactive secret collection, auto-generation, etc.)
|
|
976
|
+
if (template.hooks?.preCreate?.length) {
|
|
977
|
+
timerStart("pre-create-hooks");
|
|
978
|
+
const hookContext = { projectName, projectDir: targetDir };
|
|
979
|
+
const hookResult = await runHook(template.hooks.preCreate, hookContext, {
|
|
980
|
+
interactive,
|
|
981
|
+
output: reporter,
|
|
982
|
+
});
|
|
983
|
+
timings.push({ label: "Pre-create hooks", duration: timerEnd("pre-create-hooks") });
|
|
984
|
+
|
|
985
|
+
if (!hookResult.success) {
|
|
986
|
+
throw new JackError(
|
|
987
|
+
JackErrorCode.VALIDATION_ERROR,
|
|
988
|
+
"Project setup incomplete",
|
|
989
|
+
"Missing required configuration",
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
976
994
|
// Handle template-specific secrets
|
|
977
995
|
const secretsToUse: Record<string, string> = {};
|
|
978
996
|
if (template.secrets?.length) {
|
|
@@ -1018,41 +1036,34 @@ export async function createProject(
|
|
|
1018
1036
|
continue;
|
|
1019
1037
|
}
|
|
1020
1038
|
|
|
1021
|
-
// Prompt user
|
|
1039
|
+
// Prompt user - single text input, empty/Esc to skip
|
|
1022
1040
|
reporter.stop();
|
|
1023
|
-
const { isCancel,
|
|
1041
|
+
const { isCancel, text } = await import("@clack/prompts");
|
|
1024
1042
|
console.error("");
|
|
1025
1043
|
console.error(` ${optionalSecret.description}`);
|
|
1026
1044
|
if (optionalSecret.setupUrl) {
|
|
1027
|
-
console.error(`
|
|
1045
|
+
console.error(` Get it at: \x1b[36m${optionalSecret.setupUrl}\x1b[0m`);
|
|
1028
1046
|
}
|
|
1029
1047
|
console.error("");
|
|
1030
1048
|
|
|
1031
|
-
const
|
|
1032
|
-
message:
|
|
1033
|
-
|
|
1034
|
-
{ label: "Yes", value: "yes" },
|
|
1035
|
-
{ label: "Skip", value: "skip" },
|
|
1036
|
-
],
|
|
1049
|
+
const value = await text({
|
|
1050
|
+
message: `${optionalSecret.name}:`,
|
|
1051
|
+
placeholder: "paste value or press Esc to skip",
|
|
1037
1052
|
});
|
|
1038
1053
|
|
|
1039
|
-
if (!isCancel(
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
},
|
|
1053
|
-
]);
|
|
1054
|
-
reporter.success(`Saved ${optionalSecret.name}`);
|
|
1055
|
-
}
|
|
1054
|
+
if (!isCancel(value) && value.trim()) {
|
|
1055
|
+
secretsToUse[optionalSecret.name] = value.trim();
|
|
1056
|
+
// Save to global secrets for reuse
|
|
1057
|
+
await saveSecrets([
|
|
1058
|
+
{
|
|
1059
|
+
key: optionalSecret.name,
|
|
1060
|
+
value: value.trim(),
|
|
1061
|
+
source: "optional-template",
|
|
1062
|
+
},
|
|
1063
|
+
]);
|
|
1064
|
+
reporter.success(`Saved ${optionalSecret.name}`);
|
|
1065
|
+
} else {
|
|
1066
|
+
reporter.info(`Skipped ${optionalSecret.name}`);
|
|
1056
1067
|
}
|
|
1057
1068
|
|
|
1058
1069
|
reporter.start("Creating project...");
|
|
@@ -1309,6 +1320,23 @@ export async function createProject(
|
|
|
1309
1320
|
if (!urlShownEarly) {
|
|
1310
1321
|
reporter.success(`Deployed: ${workerUrl}`);
|
|
1311
1322
|
}
|
|
1323
|
+
|
|
1324
|
+
// Upload source snapshot for forking (prebuilt path needs this too)
|
|
1325
|
+
try {
|
|
1326
|
+
const { createSourceZip } = await import("./zip-packager.ts");
|
|
1327
|
+
const { uploadSourceSnapshot } = await import("./control-plane.ts");
|
|
1328
|
+
const { rm } = await import("node:fs/promises");
|
|
1329
|
+
|
|
1330
|
+
const sourceZipPath = await createSourceZip(targetDir);
|
|
1331
|
+
await uploadSourceSnapshot(remoteResult.projectId, sourceZipPath);
|
|
1332
|
+
await rm(sourceZipPath, { force: true });
|
|
1333
|
+
debug("Source snapshot uploaded for prebuilt project");
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
debug(
|
|
1336
|
+
"Source snapshot upload failed (prebuilt):",
|
|
1337
|
+
err instanceof Error ? err.message : String(err),
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1312
1340
|
} else {
|
|
1313
1341
|
// Prebuilt not available - fall back to fresh build
|
|
1314
1342
|
if (remoteResult.prebuiltFailed) {
|
|
@@ -1769,28 +1797,9 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1769
1797
|
}
|
|
1770
1798
|
}
|
|
1771
1799
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
const syncSpin = reporter.spinner("Syncing source to jack storage...");
|
|
1776
|
-
try {
|
|
1777
|
-
const syncResult = await syncToCloud(projectPath);
|
|
1778
|
-
if (syncResult.success) {
|
|
1779
|
-
if (syncResult.filesUploaded > 0 || syncResult.filesDeleted > 0) {
|
|
1780
|
-
syncSpin.success(
|
|
1781
|
-
`Synced source to jack storage (${syncResult.filesUploaded} uploaded, ${syncResult.filesDeleted} removed)`,
|
|
1782
|
-
);
|
|
1783
|
-
} else {
|
|
1784
|
-
syncSpin.success("Source already synced");
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
} catch {
|
|
1788
|
-
syncSpin.stop();
|
|
1789
|
-
reporter.warn("Cloud sync failed (deploy succeeded)");
|
|
1790
|
-
reporter.info("Run: jack sync");
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1800
|
+
// Note: Auto-sync to User R2 was removed for managed mode.
|
|
1801
|
+
// Managed projects use control-plane source.zip for clone instead.
|
|
1802
|
+
// BYO users can run 'jack sync' manually if needed.
|
|
1794
1803
|
|
|
1795
1804
|
// Ensure project is linked locally for discovery
|
|
1796
1805
|
try {
|
|
@@ -1936,7 +1945,9 @@ export async function getProjectStatus(
|
|
|
1936
1945
|
|
|
1937
1946
|
/**
|
|
1938
1947
|
* Scan for stale project paths.
|
|
1939
|
-
* Checks for
|
|
1948
|
+
* Checks for:
|
|
1949
|
+
* 1. Paths in the index that no longer have wrangler config (dir deleted/moved)
|
|
1950
|
+
* 2. Managed projects where the cloud project no longer exists (orphaned links)
|
|
1940
1951
|
* Returns total project count and stale entries with reasons.
|
|
1941
1952
|
*/
|
|
1942
1953
|
export async function scanStaleProjects(): Promise<StaleProjectScan> {
|
|
@@ -1945,31 +1956,61 @@ export async function scanStaleProjects(): Promise<StaleProjectScan> {
|
|
|
1945
1956
|
const stale: StaleProject[] = [];
|
|
1946
1957
|
let totalPaths = 0;
|
|
1947
1958
|
|
|
1959
|
+
// Get list of valid managed project IDs (if logged in)
|
|
1960
|
+
let validManagedIds: Set<string> = new Set();
|
|
1961
|
+
try {
|
|
1962
|
+
const { isLoggedIn } = await import("./auth/store.ts");
|
|
1963
|
+
if (await isLoggedIn()) {
|
|
1964
|
+
const managedProjects = await listManagedProjects();
|
|
1965
|
+
validManagedIds = new Set(managedProjects.map((p) => p.id));
|
|
1966
|
+
}
|
|
1967
|
+
} catch {
|
|
1968
|
+
// Control plane unavailable, skip orphan detection
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1948
1971
|
for (const projectId of projectIds) {
|
|
1949
1972
|
const paths = allPaths[projectId] || [];
|
|
1950
1973
|
totalPaths += paths.length;
|
|
1951
1974
|
|
|
1952
1975
|
for (const projectPath of paths) {
|
|
1953
|
-
// Check if path exists and has valid
|
|
1976
|
+
// Check if path exists and has valid wrangler config
|
|
1954
1977
|
const hasWranglerConfig =
|
|
1955
1978
|
existsSync(join(projectPath, "wrangler.jsonc")) ||
|
|
1956
1979
|
existsSync(join(projectPath, "wrangler.toml")) ||
|
|
1957
1980
|
existsSync(join(projectPath, "wrangler.json"));
|
|
1958
1981
|
|
|
1959
1982
|
if (!hasWranglerConfig) {
|
|
1960
|
-
//
|
|
1961
|
-
|
|
1962
|
-
try {
|
|
1963
|
-
name = await getProjectNameFromDir(projectPath);
|
|
1964
|
-
} catch {
|
|
1965
|
-
// Use path basename as fallback
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1983
|
+
// Type 1: No wrangler config at path (dir deleted/moved)
|
|
1984
|
+
const name = projectPath.split("/").pop() || projectId;
|
|
1968
1985
|
stale.push({
|
|
1969
1986
|
name,
|
|
1970
|
-
reason: "
|
|
1987
|
+
reason: "directory missing or no wrangler config",
|
|
1971
1988
|
workerUrl: null,
|
|
1972
1989
|
});
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Check for Type 2: Managed project link pointing to deleted cloud project
|
|
1994
|
+
try {
|
|
1995
|
+
const link = await readProjectLink(projectPath);
|
|
1996
|
+
if (link?.deploy_mode === "managed" && validManagedIds.size > 0) {
|
|
1997
|
+
if (!validManagedIds.has(link.project_id)) {
|
|
1998
|
+
// Orphaned managed link - cloud project doesn't exist
|
|
1999
|
+
let name = projectPath.split("/").pop() || projectId;
|
|
2000
|
+
try {
|
|
2001
|
+
name = await getProjectNameFromDir(projectPath);
|
|
2002
|
+
} catch {
|
|
2003
|
+
// Use path basename as fallback
|
|
2004
|
+
}
|
|
2005
|
+
stale.push({
|
|
2006
|
+
name,
|
|
2007
|
+
reason: "cloud project deleted",
|
|
2008
|
+
workerUrl: null,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
} catch {
|
|
2013
|
+
// Can't read link, skip
|
|
1973
2014
|
}
|
|
1974
2015
|
}
|
|
1975
2016
|
}
|
|
@@ -155,6 +155,8 @@ export async function createDatabase(
|
|
|
155
155
|
let databaseId: string;
|
|
156
156
|
let created = true;
|
|
157
157
|
|
|
158
|
+
let actualDatabaseName = databaseName;
|
|
159
|
+
|
|
158
160
|
if (link.deploy_mode === "managed") {
|
|
159
161
|
// Managed mode: call control plane
|
|
160
162
|
// Note: Control plane will reuse existing DB if name matches
|
|
@@ -163,7 +165,8 @@ export async function createDatabase(
|
|
|
163
165
|
bindingName,
|
|
164
166
|
});
|
|
165
167
|
databaseId = resource.provider_id;
|
|
166
|
-
//
|
|
168
|
+
// Use the actual name from control plane (may differ from CLI-generated name)
|
|
169
|
+
actualDatabaseName = resource.resource_name;
|
|
167
170
|
} else {
|
|
168
171
|
// BYO mode: use wrangler d1 create (checks for existing first)
|
|
169
172
|
const result = await createDatabaseViaWrangler(databaseName);
|
|
@@ -174,12 +177,12 @@ export async function createDatabase(
|
|
|
174
177
|
// Update wrangler.jsonc with the new binding
|
|
175
178
|
await addD1Binding(wranglerPath, {
|
|
176
179
|
binding: bindingName,
|
|
177
|
-
database_name:
|
|
180
|
+
database_name: actualDatabaseName,
|
|
178
181
|
database_id: databaseId,
|
|
179
182
|
});
|
|
180
183
|
|
|
181
184
|
return {
|
|
182
|
-
databaseName,
|
|
185
|
+
databaseName: actualDatabaseName,
|
|
183
186
|
databaseId,
|
|
184
187
|
bindingName,
|
|
185
188
|
created,
|
package/src/lib/version-check.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { $ } from "bun";
|
|
8
8
|
import pkg from "../../package.json";
|
|
9
9
|
import { CONFIG_DIR } from "./config.ts";
|
|
10
|
+
import { debug } from "./debug.ts";
|
|
10
11
|
|
|
11
12
|
const VERSION_CACHE_PATH = join(CONFIG_DIR, "version-cache.json");
|
|
12
13
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
@@ -123,8 +124,17 @@ export async function performUpdate(): Promise<{
|
|
|
123
124
|
}> {
|
|
124
125
|
try {
|
|
125
126
|
// Run bun add -g to update
|
|
127
|
+
debug(`Executing: bun add -g ${PACKAGE_NAME}@latest`);
|
|
126
128
|
const result = await $`bun add -g ${PACKAGE_NAME}@latest`.nothrow().quiet();
|
|
127
129
|
|
|
130
|
+
debug(`Exit code: ${result.exitCode}`);
|
|
131
|
+
if (result.stdout.toString()) {
|
|
132
|
+
debug(`stdout: ${result.stdout.toString()}`);
|
|
133
|
+
}
|
|
134
|
+
if (result.stderr.toString()) {
|
|
135
|
+
debug(`stderr: ${result.stderr.toString()}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
128
138
|
if (result.exitCode !== 0) {
|
|
129
139
|
return {
|
|
130
140
|
success: false,
|
|
@@ -133,12 +143,15 @@ export async function performUpdate(): Promise<{
|
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
// Verify the new version
|
|
146
|
+
debug("Verifying installed version...");
|
|
136
147
|
const newVersionResult = await $`bun pm ls -g`.nothrow().quiet();
|
|
137
148
|
const output = newVersionResult.stdout.toString();
|
|
149
|
+
debug(`bun pm ls -g output: ${output.slice(0, 500)}`);
|
|
138
150
|
|
|
139
151
|
// Try to extract version from output
|
|
140
152
|
const versionMatch = output.match(/@getjack\/jack@(\d+\.\d+\.\d+)/);
|
|
141
153
|
const newVersion = versionMatch?.[1];
|
|
154
|
+
debug(`Extracted version: ${newVersion ?? "not found"}`);
|
|
142
155
|
|
|
143
156
|
// Clear version cache so next check gets fresh data
|
|
144
157
|
try {
|
|
@@ -152,6 +165,7 @@ export async function performUpdate(): Promise<{
|
|
|
152
165
|
version: newVersion,
|
|
153
166
|
};
|
|
154
167
|
} catch (err) {
|
|
168
|
+
debug(`Update error: ${err instanceof Error ? err.message : String(err)}`);
|
|
155
169
|
return {
|
|
156
170
|
success: false,
|
|
157
171
|
error: err instanceof Error ? err.message : "Unknown error",
|
|
@@ -457,3 +457,193 @@ function findLineCommentStart(line: string): number {
|
|
|
457
457
|
|
|
458
458
|
return -1;
|
|
459
459
|
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Remove a D1 database binding from wrangler.jsonc by database_name.
|
|
463
|
+
* Preserves comments and formatting.
|
|
464
|
+
*
|
|
465
|
+
* @returns true if binding was found and removed, false if not found
|
|
466
|
+
*/
|
|
467
|
+
export async function removeD1Binding(configPath: string, databaseName: string): Promise<boolean> {
|
|
468
|
+
if (!existsSync(configPath)) {
|
|
469
|
+
throw new Error(`wrangler.jsonc not found at ${configPath}. Cannot remove binding.`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const content = await Bun.file(configPath).text();
|
|
473
|
+
|
|
474
|
+
// Parse to understand existing structure
|
|
475
|
+
const config = parseJsonc<WranglerConfig>(content);
|
|
476
|
+
|
|
477
|
+
// Check if d1_databases exists and has entries
|
|
478
|
+
if (!config.d1_databases || !Array.isArray(config.d1_databases)) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Find the binding to remove
|
|
483
|
+
const bindingIndex = config.d1_databases.findIndex((db) => db.database_name === databaseName);
|
|
484
|
+
|
|
485
|
+
if (bindingIndex === -1) {
|
|
486
|
+
return false; // Binding not found
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Use text manipulation to remove the binding while preserving formatting
|
|
490
|
+
const newContent = removeD1DatabaseEntryFromContent(content, databaseName);
|
|
491
|
+
|
|
492
|
+
if (newContent === content) {
|
|
493
|
+
return false; // Nothing changed
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
await Bun.write(configPath, newContent);
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Remove a specific D1 database entry from the d1_databases array in content.
|
|
502
|
+
* Handles comma placement and preserves comments.
|
|
503
|
+
*/
|
|
504
|
+
function removeD1DatabaseEntryFromContent(content: string, databaseName: string): string {
|
|
505
|
+
// Find the d1_databases array
|
|
506
|
+
const d1Match = content.match(/"d1_databases"\s*:\s*\[/);
|
|
507
|
+
if (!d1Match || d1Match.index === undefined) {
|
|
508
|
+
return content;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const arrayStartIndex = d1Match.index + d1Match[0].length;
|
|
512
|
+
const closingBracketIndex = findMatchingBracket(content, arrayStartIndex - 1, "[", "]");
|
|
513
|
+
|
|
514
|
+
if (closingBracketIndex === -1) {
|
|
515
|
+
return content;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const arrayContent = content.slice(arrayStartIndex, closingBracketIndex);
|
|
519
|
+
|
|
520
|
+
// Find the object containing this database_name
|
|
521
|
+
const escapedName = databaseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
522
|
+
const dbNamePattern = new RegExp(`"database_name"\\s*:\\s*"${escapedName}"`);
|
|
523
|
+
|
|
524
|
+
const match = dbNamePattern.exec(arrayContent);
|
|
525
|
+
if (!match) {
|
|
526
|
+
return content;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Find the enclosing object boundaries
|
|
530
|
+
const matchPosInArray = match.index;
|
|
531
|
+
const objectStart = findObjectStartBefore(arrayContent, matchPosInArray);
|
|
532
|
+
const objectEnd = findObjectEndAfter(arrayContent, matchPosInArray);
|
|
533
|
+
|
|
534
|
+
if (objectStart === -1 || objectEnd === -1) {
|
|
535
|
+
return content;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Determine comma handling
|
|
539
|
+
let removeStart = objectStart;
|
|
540
|
+
let removeEnd = objectEnd + 1;
|
|
541
|
+
|
|
542
|
+
// Check for trailing comma after the object
|
|
543
|
+
const afterObject = arrayContent.slice(objectEnd + 1);
|
|
544
|
+
const trailingCommaMatch = afterObject.match(/^\s*,/);
|
|
545
|
+
|
|
546
|
+
// Check for leading comma before the object
|
|
547
|
+
const beforeObject = arrayContent.slice(0, objectStart);
|
|
548
|
+
const leadingCommaMatch = beforeObject.match(/,\s*$/);
|
|
549
|
+
|
|
550
|
+
if (trailingCommaMatch) {
|
|
551
|
+
// Remove trailing comma
|
|
552
|
+
removeEnd = objectEnd + 1 + trailingCommaMatch[0].length;
|
|
553
|
+
} else if (leadingCommaMatch) {
|
|
554
|
+
// Remove leading comma
|
|
555
|
+
removeStart = objectStart - leadingCommaMatch[0].length;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Build new array content
|
|
559
|
+
const newArrayContent = arrayContent.slice(0, removeStart) + arrayContent.slice(removeEnd);
|
|
560
|
+
|
|
561
|
+
// Check if array is now effectively empty (only whitespace/comments)
|
|
562
|
+
const trimmedArray = newArrayContent.replace(/\/\/[^\n]*/g, "").trim();
|
|
563
|
+
if (trimmedArray === "" || trimmedArray === "[]") {
|
|
564
|
+
// Remove the entire d1_databases property
|
|
565
|
+
return removeD1DatabasesProperty(content, d1Match.index, closingBracketIndex);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return content.slice(0, arrayStartIndex) + newArrayContent + content.slice(closingBracketIndex);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Find the start of the object (opening brace) before the given position.
|
|
573
|
+
*/
|
|
574
|
+
function findObjectStartBefore(content: string, fromPos: number): number {
|
|
575
|
+
let depth = 0;
|
|
576
|
+
for (let i = fromPos; i >= 0; i--) {
|
|
577
|
+
const char = content[i];
|
|
578
|
+
if (char === "}") depth++;
|
|
579
|
+
if (char === "{") {
|
|
580
|
+
if (depth === 0) return i;
|
|
581
|
+
depth--;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return -1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Find the end of the object (closing brace) after the given position.
|
|
589
|
+
*/
|
|
590
|
+
function findObjectEndAfter(content: string, fromPos: number): number {
|
|
591
|
+
let depth = 0;
|
|
592
|
+
let inString = false;
|
|
593
|
+
let escaped = false;
|
|
594
|
+
|
|
595
|
+
for (let i = fromPos; i < content.length; i++) {
|
|
596
|
+
const char = content[i];
|
|
597
|
+
|
|
598
|
+
if (inString) {
|
|
599
|
+
if (escaped) {
|
|
600
|
+
escaped = false;
|
|
601
|
+
} else if (char === "\\") {
|
|
602
|
+
escaped = true;
|
|
603
|
+
} else if (char === '"') {
|
|
604
|
+
inString = false;
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (char === '"') {
|
|
610
|
+
inString = true;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (char === "{") depth++;
|
|
615
|
+
if (char === "}") {
|
|
616
|
+
if (depth === 0) return i;
|
|
617
|
+
depth--;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return -1;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Remove the entire d1_databases property when it becomes empty.
|
|
625
|
+
*/
|
|
626
|
+
function removeD1DatabasesProperty(
|
|
627
|
+
content: string,
|
|
628
|
+
propertyStart: number,
|
|
629
|
+
arrayEnd: number,
|
|
630
|
+
): string {
|
|
631
|
+
let removeStart = propertyStart;
|
|
632
|
+
let removeEnd = arrayEnd + 1;
|
|
633
|
+
|
|
634
|
+
// Look backward for a comma to remove
|
|
635
|
+
const beforeProperty = content.slice(0, propertyStart);
|
|
636
|
+
const leadingCommaMatch = beforeProperty.match(/,\s*$/);
|
|
637
|
+
|
|
638
|
+
// Look forward for a trailing comma
|
|
639
|
+
const afterProperty = content.slice(arrayEnd + 1);
|
|
640
|
+
const trailingCommaMatch = afterProperty.match(/^\s*,/);
|
|
641
|
+
|
|
642
|
+
if (leadingCommaMatch) {
|
|
643
|
+
removeStart = propertyStart - leadingCommaMatch[0].length;
|
|
644
|
+
} else if (trailingCommaMatch) {
|
|
645
|
+
removeEnd = arrayEnd + 1 + trailingCommaMatch[0].length;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return content.slice(0, removeStart) + content.slice(removeEnd);
|
|
649
|
+
}
|
package/src/lib/zip-packager.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { join, relative } from "node:path";
|
|
|
6
6
|
import archiver from "archiver";
|
|
7
7
|
import { type AssetManifest, computeAssetHash } from "./asset-hash.ts";
|
|
8
8
|
import type { BuildOutput, WranglerConfig } from "./build-helper.ts";
|
|
9
|
+
import { debug } from "./debug.ts";
|
|
10
|
+
import { formatSize } from "./format.ts";
|
|
9
11
|
import { scanProjectFiles } from "./storage/file-filter.ts";
|
|
10
12
|
|
|
11
13
|
export interface ZipPackageResult {
|
|
@@ -82,6 +84,42 @@ async function createZipArchive(
|
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Creates source.zip from project files without building.
|
|
89
|
+
* Used for prebuilt deployments that skip the full package flow.
|
|
90
|
+
* @param projectPath - Absolute path to project directory
|
|
91
|
+
* @returns Path to the created source.zip (caller responsible for cleanup)
|
|
92
|
+
*/
|
|
93
|
+
export async function createSourceZip(projectPath: string): Promise<string> {
|
|
94
|
+
const packageDir = join(tmpdir(), `jack-source-${Date.now()}`);
|
|
95
|
+
await mkdir(packageDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const sourceZipPath = join(packageDir, "source.zip");
|
|
98
|
+
const projectFiles = await scanProjectFiles(projectPath);
|
|
99
|
+
|
|
100
|
+
// Debug output for source file statistics
|
|
101
|
+
const totalSize = projectFiles.reduce((sum, f) => sum + f.size, 0);
|
|
102
|
+
const largest =
|
|
103
|
+
projectFiles.length > 0
|
|
104
|
+
? projectFiles.reduce((max, f) => (f.size > max.size ? f : max), projectFiles[0])
|
|
105
|
+
: null;
|
|
106
|
+
|
|
107
|
+
debug(`Source: ${projectFiles.length} files, ${formatSize(totalSize)} uncompressed`);
|
|
108
|
+
if (largest) {
|
|
109
|
+
debug(`Largest: ${largest.path} (${formatSize(largest.size)})`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sourceFiles = projectFiles.map((f) => f.path);
|
|
113
|
+
await createZipArchive(sourceZipPath, projectPath, sourceFiles);
|
|
114
|
+
|
|
115
|
+
// Debug output for compression statistics
|
|
116
|
+
const zipStats = await stat(sourceZipPath);
|
|
117
|
+
const ratio = totalSize > 0 ? ((1 - zipStats.size / totalSize) * 100).toFixed(0) : 0;
|
|
118
|
+
debug(`Compressed: ${formatSize(zipStats.size)} (${ratio}% reduction)`);
|
|
119
|
+
|
|
120
|
+
return sourceZipPath;
|
|
121
|
+
}
|
|
122
|
+
|
|
85
123
|
/**
|
|
86
124
|
* Recursively collects all file paths in a directory
|
|
87
125
|
*/
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zip utility functions for extracting zip archives
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { unzipSync } from "fflate";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a zip buffer to a target directory.
|
|
11
|
+
* Creates directories as needed and writes files preserving relative paths.
|
|
12
|
+
*
|
|
13
|
+
* @param zipBuffer - The zip file contents as a Buffer
|
|
14
|
+
* @param targetDir - The directory to extract files to
|
|
15
|
+
* @returns The number of files extracted
|
|
16
|
+
*/
|
|
17
|
+
export async function extractZipToDirectory(zipBuffer: Buffer, targetDir: string): Promise<number> {
|
|
18
|
+
const unzipped = unzipSync(new Uint8Array(zipBuffer));
|
|
19
|
+
let fileCount = 0;
|
|
20
|
+
|
|
21
|
+
for (const [path, content] of Object.entries(unzipped)) {
|
|
22
|
+
// Skip directories (they end with /)
|
|
23
|
+
if (path.endsWith("/")) continue;
|
|
24
|
+
|
|
25
|
+
// Security: prevent path traversal by removing any .. segments
|
|
26
|
+
const normalizedPath = path.replace(/\.\./g, "");
|
|
27
|
+
const fullPath = join(targetDir, normalizedPath);
|
|
28
|
+
|
|
29
|
+
// Ensure directory exists
|
|
30
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
31
|
+
|
|
32
|
+
// Write file
|
|
33
|
+
await writeFile(fullPath, content);
|
|
34
|
+
fileCount++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return fileCount;
|
|
38
|
+
}
|