@getjack/jack 0.1.16 → 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.
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Utilities for modifying wrangler.jsonc while preserving comments
3
+ */
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { parseJsonc } from "./jsonc.ts";
7
+
8
+ export interface D1BindingConfig {
9
+ binding: string; // e.g., "DB" or "ANALYTICS_DB"
10
+ database_name: string; // e.g., "my-app-db"
11
+ database_id: string; // UUID from Cloudflare
12
+ }
13
+
14
+ interface WranglerConfig {
15
+ d1_databases?: Array<{
16
+ binding: string;
17
+ database_name?: string;
18
+ database_id?: string;
19
+ }>;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ /**
24
+ * Get existing D1 bindings from wrangler.jsonc
25
+ */
26
+ export async function getExistingD1Bindings(configPath: string): Promise<D1BindingConfig[]> {
27
+ if (!existsSync(configPath)) {
28
+ throw new Error(`wrangler.jsonc not found at ${configPath}`);
29
+ }
30
+
31
+ const content = await Bun.file(configPath).text();
32
+ const config = parseJsonc<WranglerConfig>(content);
33
+
34
+ if (!config.d1_databases || !Array.isArray(config.d1_databases)) {
35
+ return [];
36
+ }
37
+
38
+ return config.d1_databases
39
+ .filter((db) => db.binding && db.database_name && db.database_id)
40
+ .map((db) => ({
41
+ binding: db.binding,
42
+ database_name: db.database_name as string,
43
+ database_id: db.database_id as string,
44
+ }));
45
+ }
46
+
47
+ /**
48
+ * Add a D1 database binding to wrangler.jsonc while preserving comments.
49
+ *
50
+ * Uses text manipulation to preserve comments rather than full JSON parsing.
51
+ */
52
+ export async function addD1Binding(configPath: string, binding: D1BindingConfig): Promise<void> {
53
+ if (!existsSync(configPath)) {
54
+ throw new Error(
55
+ `wrangler.jsonc not found at ${configPath}. Create a wrangler.jsonc file first or run 'jack new' to create a new project.`,
56
+ );
57
+ }
58
+
59
+ const content = await Bun.file(configPath).text();
60
+
61
+ // Parse to understand existing structure
62
+ const config = parseJsonc<WranglerConfig>(content);
63
+
64
+ // Format the new binding entry
65
+ const bindingJson = formatD1BindingEntry(binding);
66
+
67
+ let newContent: string;
68
+
69
+ if (config.d1_databases && Array.isArray(config.d1_databases)) {
70
+ // d1_databases exists - append to the array
71
+ newContent = appendToD1DatabasesArray(content, bindingJson);
72
+ } else {
73
+ // d1_databases doesn't exist - add it before closing brace
74
+ newContent = addD1DatabasesSection(content, bindingJson);
75
+ }
76
+
77
+ await Bun.write(configPath, newContent);
78
+ }
79
+
80
+ /**
81
+ * Format a D1 binding as a JSON object string with proper indentation
82
+ */
83
+ function formatD1BindingEntry(binding: D1BindingConfig): string {
84
+ return `{
85
+ "binding": "${binding.binding}",
86
+ "database_name": "${binding.database_name}",
87
+ "database_id": "${binding.database_id}"
88
+ }`;
89
+ }
90
+
91
+ /**
92
+ * Append a new entry to an existing d1_databases array.
93
+ * Finds the closing bracket of the array and inserts before it.
94
+ */
95
+ function appendToD1DatabasesArray(content: string, bindingJson: string): string {
96
+ // Find "d1_databases" and then find its closing bracket
97
+ const d1Match = content.match(/"d1_databases"\s*:\s*\[/);
98
+ if (!d1Match || d1Match.index === undefined) {
99
+ throw new Error("Could not find d1_databases array in config");
100
+ }
101
+
102
+ const arrayStartIndex = d1Match.index + d1Match[0].length;
103
+
104
+ // Find the matching closing bracket, accounting for nested structures
105
+ const closingBracketIndex = findMatchingBracket(content, arrayStartIndex - 1, "[", "]");
106
+ if (closingBracketIndex === -1) {
107
+ throw new Error("Could not find closing bracket for d1_databases array");
108
+ }
109
+
110
+ // Check if array is empty or has content
111
+ const arrayContent = content.slice(arrayStartIndex, closingBracketIndex).trim();
112
+ const isEmpty = arrayContent === "" || isOnlyCommentsAndWhitespace(arrayContent);
113
+
114
+ // Build the insertion
115
+ let insertion: string;
116
+ if (isEmpty) {
117
+ // Empty array - just add the entry
118
+ insertion = `\n\t\t${bindingJson}\n\t`;
119
+ } else {
120
+ // Has existing entries - add comma and new entry
121
+ insertion = `,\n\t\t${bindingJson}`;
122
+ }
123
+
124
+ // Find position just before the closing bracket
125
+ // We want to insert after the last non-whitespace content but before the bracket
126
+ const beforeBracket = content.slice(0, closingBracketIndex);
127
+ const afterBracket = content.slice(closingBracketIndex);
128
+
129
+ if (isEmpty) {
130
+ return beforeBracket + insertion + afterBracket;
131
+ }
132
+
133
+ // For non-empty arrays, find the last closing brace of an object in the array
134
+ const lastObjectEnd = findLastObjectEndInArray(content, arrayStartIndex, closingBracketIndex);
135
+ if (lastObjectEnd === -1) {
136
+ // Fallback: insert before closing bracket
137
+ return beforeBracket + insertion + afterBracket;
138
+ }
139
+
140
+ return content.slice(0, lastObjectEnd + 1) + insertion + content.slice(lastObjectEnd + 1);
141
+ }
142
+
143
+ /**
144
+ * Add a new d1_databases section to the config.
145
+ * Inserts before the final closing brace.
146
+ */
147
+ function addD1DatabasesSection(content: string, bindingJson: string): string {
148
+ // Find the last closing brace in the file
149
+ const lastBraceIndex = content.lastIndexOf("}");
150
+ if (lastBraceIndex === -1) {
151
+ throw new Error("Invalid JSON: no closing brace found");
152
+ }
153
+
154
+ // Check what comes before the last brace to determine if we need a comma
155
+ const beforeBrace = content.slice(0, lastBraceIndex);
156
+ const needsComma = shouldAddCommaBefore(beforeBrace);
157
+
158
+ // Build the d1_databases section
159
+ const d1Section = `"d1_databases": [
160
+ ${bindingJson}
161
+ ]`;
162
+
163
+ // Find proper insertion point - look for last non-whitespace content
164
+ const trimmedBefore = beforeBrace.trimEnd();
165
+ const whitespaceAfterContent = beforeBrace.slice(trimmedBefore.length);
166
+
167
+ let insertion: string;
168
+ if (needsComma) {
169
+ insertion = `,\n\t${d1Section}`;
170
+ } else {
171
+ insertion = `\n\t${d1Section}`;
172
+ }
173
+
174
+ // Reconstruct: content before + insertion + newline + closing brace
175
+ return trimmedBefore + insertion + "\n" + content.slice(lastBraceIndex);
176
+ }
177
+
178
+ /**
179
+ * Find the matching closing bracket/brace for an opening one
180
+ */
181
+ function findMatchingBracket(
182
+ content: string,
183
+ startIndex: number,
184
+ openChar: string,
185
+ closeChar: string,
186
+ ): number {
187
+ let depth = 0;
188
+ let inString = false;
189
+ let stringChar = "";
190
+ let escaped = false;
191
+ let inLineComment = false;
192
+ let inBlockComment = false;
193
+
194
+ for (let i = startIndex; i < content.length; i++) {
195
+ const char = content[i] ?? "";
196
+ const next = content[i + 1] ?? "";
197
+
198
+ // Handle line comments
199
+ if (inLineComment) {
200
+ if (char === "\n") {
201
+ inLineComment = false;
202
+ }
203
+ continue;
204
+ }
205
+
206
+ // Handle block comments
207
+ if (inBlockComment) {
208
+ if (char === "*" && next === "/") {
209
+ inBlockComment = false;
210
+ i++;
211
+ }
212
+ continue;
213
+ }
214
+
215
+ // Handle strings
216
+ if (inString) {
217
+ if (escaped) {
218
+ escaped = false;
219
+ continue;
220
+ }
221
+ if (char === "\\") {
222
+ escaped = true;
223
+ continue;
224
+ }
225
+ if (char === stringChar) {
226
+ inString = false;
227
+ stringChar = "";
228
+ }
229
+ continue;
230
+ }
231
+
232
+ // Check for comment start
233
+ if (char === "/" && next === "/") {
234
+ inLineComment = true;
235
+ i++;
236
+ continue;
237
+ }
238
+ if (char === "/" && next === "*") {
239
+ inBlockComment = true;
240
+ i++;
241
+ continue;
242
+ }
243
+
244
+ // Check for string start
245
+ if (char === '"' || char === "'") {
246
+ inString = true;
247
+ stringChar = char;
248
+ continue;
249
+ }
250
+
251
+ // Track bracket depth
252
+ if (char === openChar) {
253
+ depth++;
254
+ } else if (char === closeChar) {
255
+ depth--;
256
+ if (depth === 0) {
257
+ return i;
258
+ }
259
+ }
260
+ }
261
+
262
+ return -1;
263
+ }
264
+
265
+ /**
266
+ * Check if content is only whitespace and comments
267
+ */
268
+ function isOnlyCommentsAndWhitespace(content: string): boolean {
269
+ let inLineComment = false;
270
+ let inBlockComment = false;
271
+
272
+ for (let i = 0; i < content.length; i++) {
273
+ const char = content[i] ?? "";
274
+ const next = content[i + 1] ?? "";
275
+
276
+ if (inLineComment) {
277
+ if (char === "\n") {
278
+ inLineComment = false;
279
+ }
280
+ continue;
281
+ }
282
+
283
+ if (inBlockComment) {
284
+ if (char === "*" && next === "/") {
285
+ inBlockComment = false;
286
+ i++;
287
+ }
288
+ continue;
289
+ }
290
+
291
+ if (char === "/" && next === "/") {
292
+ inLineComment = true;
293
+ i++;
294
+ continue;
295
+ }
296
+
297
+ if (char === "/" && next === "*") {
298
+ inBlockComment = true;
299
+ i++;
300
+ continue;
301
+ }
302
+
303
+ if (!/\s/.test(char)) {
304
+ return false;
305
+ }
306
+ }
307
+
308
+ return true;
309
+ }
310
+
311
+ /**
312
+ * Find the last closing brace of an object within an array range
313
+ */
314
+ function findLastObjectEndInArray(content: string, startIndex: number, endIndex: number): number {
315
+ let lastBraceIndex = -1;
316
+ let inString = false;
317
+ let stringChar = "";
318
+ let escaped = false;
319
+ let inLineComment = false;
320
+ let inBlockComment = false;
321
+
322
+ for (let i = startIndex; i < endIndex; i++) {
323
+ const char = content[i] ?? "";
324
+ const next = content[i + 1] ?? "";
325
+
326
+ if (inLineComment) {
327
+ if (char === "\n") {
328
+ inLineComment = false;
329
+ }
330
+ continue;
331
+ }
332
+
333
+ if (inBlockComment) {
334
+ if (char === "*" && next === "/") {
335
+ inBlockComment = false;
336
+ i++;
337
+ }
338
+ continue;
339
+ }
340
+
341
+ if (inString) {
342
+ if (escaped) {
343
+ escaped = false;
344
+ continue;
345
+ }
346
+ if (char === "\\") {
347
+ escaped = true;
348
+ continue;
349
+ }
350
+ if (char === stringChar) {
351
+ inString = false;
352
+ stringChar = "";
353
+ }
354
+ continue;
355
+ }
356
+
357
+ if (char === "/" && next === "/") {
358
+ inLineComment = true;
359
+ i++;
360
+ continue;
361
+ }
362
+
363
+ if (char === "/" && next === "*") {
364
+ inBlockComment = true;
365
+ i++;
366
+ continue;
367
+ }
368
+
369
+ if (char === '"' || char === "'") {
370
+ inString = true;
371
+ stringChar = char;
372
+ continue;
373
+ }
374
+
375
+ if (char === "}") {
376
+ lastBraceIndex = i;
377
+ }
378
+ }
379
+
380
+ return lastBraceIndex;
381
+ }
382
+
383
+ /**
384
+ * Determine if we need to add a comma before new content.
385
+ * Looks at the last non-whitespace, non-comment character.
386
+ */
387
+ function shouldAddCommaBefore(content: string): boolean {
388
+ // Strip trailing comments and whitespace to find last meaningful char
389
+ let i = content.length - 1;
390
+ let inLineComment = false;
391
+
392
+ // First pass: find where any trailing line comment starts
393
+ for (let j = content.length - 1; j >= 0; j--) {
394
+ if (content[j] === "\n") {
395
+ // Check if there's a // comment on this line
396
+ const lineStart = content.lastIndexOf("\n", j - 1) + 1;
397
+ const line = content.slice(lineStart, j);
398
+ const commentIndex = findLineCommentStart(line);
399
+ if (commentIndex !== -1) {
400
+ i = lineStart + commentIndex - 1;
401
+ }
402
+ break;
403
+ }
404
+ }
405
+
406
+ // Skip whitespace
407
+ while (i >= 0 && /\s/.test(content[i] ?? "")) {
408
+ i--;
409
+ }
410
+
411
+ if (i < 0) return false;
412
+
413
+ const lastChar = content[i];
414
+ // Need comma if last char is }, ], ", number, or identifier char
415
+ // Don't need comma if last char is { or [ or ,
416
+ return lastChar !== "{" && lastChar !== "[" && lastChar !== ",";
417
+ }
418
+
419
+ /**
420
+ * Find the start of a line comment (//) in a string, respecting strings
421
+ */
422
+ function findLineCommentStart(line: string): number {
423
+ let inString = false;
424
+ let stringChar = "";
425
+ let escaped = false;
426
+
427
+ for (let i = 0; i < line.length - 1; i++) {
428
+ const char = line[i] ?? "";
429
+ const next = line[i + 1] ?? "";
430
+
431
+ if (inString) {
432
+ if (escaped) {
433
+ escaped = false;
434
+ continue;
435
+ }
436
+ if (char === "\\") {
437
+ escaped = true;
438
+ continue;
439
+ }
440
+ if (char === stringChar) {
441
+ inString = false;
442
+ stringChar = "";
443
+ }
444
+ continue;
445
+ }
446
+
447
+ if (char === '"' || char === "'") {
448
+ inString = true;
449
+ stringChar = char;
450
+ continue;
451
+ }
452
+
453
+ if (char === "/" && next === "/") {
454
+ return i;
455
+ }
456
+ }
457
+
458
+ return -1;
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
  */