@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 CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.17",
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
  },
@@ -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
- // Fetch remote manifest
57
- const spin = spinner(`Fetching from jack-storage/${projectName}/...`);
58
- const manifest = await getRemoteManifest(projectName);
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
- if (!manifest) {
61
- spin.error(`Project not found: ${projectName}`);
62
- process.exit(1);
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
- // Show file count and size
66
- const totalSize = manifest.files.reduce((sum, f) => sum + f.size, 0);
67
- spin.success(`Found ${manifest.files.length} file(s) (${formatSize(totalSize)})`);
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
- // Download files
70
- const downloadSpin = spinner("Downloading...");
71
- const result = await cloneFromCloud(projectName, targetDir);
83
+ // Link to control-plane
84
+ await linkProject(targetDir, project.remote.projectId, "managed");
85
+ await registerPath(project.remote.projectId, targetDir);
72
86
 
73
- if (!result.success) {
74
- downloadSpin.error("Clone failed");
75
- error(result.error || "Could not download project files");
76
- info("Check your network connection and try again");
77
- process.exit(1);
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
- downloadSpin.success(`Restored to ./${flags.as ?? projectName}/`);
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
- // Link project to control plane if it's a managed project
83
- try {
84
- const project = await resolveProject(projectName);
85
- if (project?.sources.controlPlane && project.remote?.projectId) {
86
- // Managed project - link with control plane project ID
87
- await linkProject(targetDir, project.remote.projectId, "managed");
88
- await registerPath(project.remote.projectId, targetDir);
89
-
90
- // Fetch and restore tags from control plane
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
- } catch {
102
- // Not a control plane project or offline - continue without linking
123
+
124
+ downloadSpin.success(`Restored to ./${flags.as ?? projectName}/`);
103
125
  }
104
126
 
105
127
  // Show next steps
@@ -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
 
@@ -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
- await deleteDatabase(dbInfo.name);
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
 
@@ -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) {
@@ -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}`);
@@ -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
- reporter?.start("Uploading to jack cloud...");
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
- reporter?.stop();
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
- // Random neon purple for cyberpunk styling
99
- function getRandomPurple(): string {
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}║${pad(bold + title + reset + purple)}║${reset}`);
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 { getSyncConfig } from "./config.ts";
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, syncToCloud } from "./storage/index.ts";
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, select, text } = await import("@clack/prompts");
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(` Setup: ${optionalSecret.setupUrl}`);
1045
+ console.error(` Get it at: \x1b[36m${optionalSecret.setupUrl}\x1b[0m`);
1028
1046
  }
1029
1047
  console.error("");
1030
1048
 
1031
- const choice = await select({
1032
- message: `Add ${optionalSecret.name}?`,
1033
- options: [
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(choice) && choice === "yes") {
1040
- const value = await text({
1041
- message: `Enter ${optionalSecret.name}:`,
1042
- });
1043
-
1044
- if (!isCancel(value) && value.trim()) {
1045
- secretsToUse[optionalSecret.name] = value.trim();
1046
- // Save to global secrets for reuse
1047
- await saveSecrets([
1048
- {
1049
- key: optionalSecret.name,
1050
- value: value.trim(),
1051
- source: "optional-template",
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
- if (includeSync && deployMode !== "byo") {
1773
- const syncConfig = await getSyncConfig();
1774
- if (syncConfig.enabled && syncConfig.autoSync) {
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 paths in the index that no longer exist on disk or don't have valid links.
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 link
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
- // Try to get project name from the path
1961
- let name = projectPath.split("/").pop() || projectId;
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: "worker not deployed",
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
- // Control plane always creates for now; could add reuse logic there too
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: databaseName,
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,
@@ -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
+ }
@@ -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
+ }