@just-be/deploy 0.10.0 → 0.11.0
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/index.ts +215 -11
- package/package.json +19 -19
- package/schema.json +15 -0
package/index.ts
CHANGED
|
@@ -45,8 +45,9 @@
|
|
|
45
45
|
import { $ } from "bun";
|
|
46
46
|
import { readdir, stat, access } from "fs/promises";
|
|
47
47
|
import { join, relative, resolve } from "path";
|
|
48
|
-
import { intro, outro, spinner } from "@clack/prompts";
|
|
48
|
+
import { intro, outro, spinner, password as passwordPrompt } from "@clack/prompts";
|
|
49
49
|
import { z } from "zod";
|
|
50
|
+
import { createHash } from "crypto";
|
|
50
51
|
import packageJson from "./package.json" with { type: "json" };
|
|
51
52
|
import {
|
|
52
53
|
StaticConfigSchema,
|
|
@@ -80,14 +81,17 @@ function wrangler(...args: string[]) {
|
|
|
80
81
|
const StaticRuleSchema = StaticConfigSchema.safeExtend({
|
|
81
82
|
subdomain: subdomain(),
|
|
82
83
|
dir: z.string().min(1),
|
|
84
|
+
password: z.string().optional(),
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
const RedirectRuleSchema = RedirectConfigSchema.safeExtend({
|
|
86
88
|
subdomain: subdomain(),
|
|
89
|
+
password: z.string().optional(),
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
const RewriteRuleSchema = RewriteConfigSchema.safeExtend({
|
|
90
93
|
subdomain: subdomain(),
|
|
94
|
+
password: z.string().optional(),
|
|
91
95
|
});
|
|
92
96
|
|
|
93
97
|
const RouteRuleSchema = z.discriminatedUnion("type", [
|
|
@@ -97,6 +101,7 @@ const RouteRuleSchema = z.discriminatedUnion("type", [
|
|
|
97
101
|
]);
|
|
98
102
|
|
|
99
103
|
export const DeployConfigSchema = z.object({
|
|
104
|
+
$schema: z.string().optional(),
|
|
100
105
|
rules: z.array(RouteRuleSchema).min(1, "At least one rule is required"),
|
|
101
106
|
});
|
|
102
107
|
|
|
@@ -174,20 +179,140 @@ async function findFiles(dir: string): Promise<string[]> {
|
|
|
174
179
|
return results;
|
|
175
180
|
}
|
|
176
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Get content type based on file extension
|
|
184
|
+
*/
|
|
185
|
+
function getContentType(filePath: string): string | null {
|
|
186
|
+
const ext = filePath.toLowerCase().split(".").pop();
|
|
187
|
+
const contentTypes: Record<string, string> = {
|
|
188
|
+
// Text
|
|
189
|
+
html: "text/html",
|
|
190
|
+
css: "text/css",
|
|
191
|
+
js: "application/javascript",
|
|
192
|
+
json: "application/json",
|
|
193
|
+
xml: "application/xml",
|
|
194
|
+
txt: "text/plain",
|
|
195
|
+
// Images
|
|
196
|
+
png: "image/png",
|
|
197
|
+
jpg: "image/jpeg",
|
|
198
|
+
jpeg: "image/jpeg",
|
|
199
|
+
gif: "image/gif",
|
|
200
|
+
svg: "image/svg+xml",
|
|
201
|
+
webp: "image/webp",
|
|
202
|
+
// Audio
|
|
203
|
+
mp3: "audio/mpeg",
|
|
204
|
+
m4a: "audio/mp4",
|
|
205
|
+
wav: "audio/wav",
|
|
206
|
+
ogg: "audio/ogg",
|
|
207
|
+
aac: "audio/aac",
|
|
208
|
+
flac: "audio/flac",
|
|
209
|
+
// Video
|
|
210
|
+
mp4: "video/mp4",
|
|
211
|
+
webm: "video/webm",
|
|
212
|
+
// Fonts
|
|
213
|
+
woff: "font/woff",
|
|
214
|
+
woff2: "font/woff2",
|
|
215
|
+
ttf: "font/ttf",
|
|
216
|
+
otf: "font/otf",
|
|
217
|
+
};
|
|
218
|
+
return ext ? contentTypes[ext] || null : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Compute MD5 hash of a file
|
|
223
|
+
*/
|
|
224
|
+
async function computeFileMD5(filePath: string): Promise<string> {
|
|
225
|
+
const file = Bun.file(filePath);
|
|
226
|
+
const buffer = await file.arrayBuffer();
|
|
227
|
+
const hash = createHash("md5");
|
|
228
|
+
hash.update(new Uint8Array(buffer));
|
|
229
|
+
return hash.digest("hex");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check if a file needs to be uploaded by comparing local MD5 with remote ETag
|
|
234
|
+
* Returns true if file should be uploaded (missing or different content)
|
|
235
|
+
*/
|
|
236
|
+
async function shouldUploadFile(
|
|
237
|
+
localPath: string,
|
|
238
|
+
r2Key: string,
|
|
239
|
+
subdomain: string,
|
|
240
|
+
): Promise<boolean> {
|
|
241
|
+
try {
|
|
242
|
+
// Compute local file hash
|
|
243
|
+
const localHash = await computeFileMD5(localPath);
|
|
244
|
+
|
|
245
|
+
// Make HEAD request to check remote file (don't follow redirects)
|
|
246
|
+
const url = `https://${subdomain}.just-be.dev/${r2Key.replace(`${subdomain}/`, "")}`;
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const response = await fetch(url, {
|
|
252
|
+
method: "HEAD",
|
|
253
|
+
redirect: "manual",
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
headers: {
|
|
256
|
+
Connection: "close", // Ensure connection is closed
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
clearTimeout(timeoutId);
|
|
261
|
+
|
|
262
|
+
// If file doesn't exist (4xx/5xx), upload it
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Get ETag from response (remove quotes if present)
|
|
268
|
+
const etag = response.headers.get("etag")?.replace(/"/g, "");
|
|
269
|
+
|
|
270
|
+
// If no ETag or ETag doesn't match local hash, upload
|
|
271
|
+
if (!etag || etag !== localHash) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// File exists with same content, skip upload
|
|
276
|
+
return false;
|
|
277
|
+
} catch (fetchError) {
|
|
278
|
+
clearTimeout(timeoutId);
|
|
279
|
+
// On fetch error (timeout, network error), default to uploading
|
|
280
|
+
if (DEBUG) {
|
|
281
|
+
console.error(`\nFetch error checking ${localPath}:`, fetchError);
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// On error, default to uploading
|
|
287
|
+
if (DEBUG) {
|
|
288
|
+
console.error(`\nError checking if upload needed for ${localPath}:`, error);
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
177
294
|
/**
|
|
178
295
|
* Upload a file to R2
|
|
179
296
|
*/
|
|
180
297
|
async function uploadToR2(localPath: string, r2Key: string): Promise<boolean> {
|
|
181
298
|
try {
|
|
182
|
-
|
|
299
|
+
const args = [
|
|
183
300
|
"r2",
|
|
184
301
|
"object",
|
|
185
302
|
"put",
|
|
186
303
|
`${BUCKET_NAME}/${r2Key}`,
|
|
187
304
|
"--file",
|
|
188
305
|
localPath,
|
|
189
|
-
"--remote"
|
|
190
|
-
|
|
306
|
+
"--remote",
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
// Add content-type if we can determine it
|
|
310
|
+
const contentType = getContentType(localPath);
|
|
311
|
+
if (contentType) {
|
|
312
|
+
args.push("--content-type", contentType);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await wrangler(...args);
|
|
191
316
|
return true;
|
|
192
317
|
} catch (error) {
|
|
193
318
|
if (DEBUG) {
|
|
@@ -242,6 +367,52 @@ async function validateKVAccess(): Promise<boolean> {
|
|
|
242
367
|
}
|
|
243
368
|
}
|
|
244
369
|
|
|
370
|
+
/**
|
|
371
|
+
* List secrets configured on the wildcard worker
|
|
372
|
+
*/
|
|
373
|
+
async function listWorkerSecrets(): Promise<string[]> {
|
|
374
|
+
try {
|
|
375
|
+
const output = await wrangler("secret", "list", "--name", "just-be-dev-wildcard").text();
|
|
376
|
+
const secrets = JSON.parse(output);
|
|
377
|
+
return Array.isArray(secrets) ? secrets.map((s: { name: string }) => s.name) : [];
|
|
378
|
+
} catch {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Ensure a secret exists on the wildcard worker, prompting the user if it doesn't
|
|
385
|
+
*/
|
|
386
|
+
async function ensureSecretExists(
|
|
387
|
+
secretName: string,
|
|
388
|
+
s: ReturnType<typeof spinner>,
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
s.start(`Checking if secret "${secretName}" exists`);
|
|
391
|
+
const existingSecrets = await listWorkerSecrets();
|
|
392
|
+
|
|
393
|
+
if (existingSecrets.includes(secretName)) {
|
|
394
|
+
s.stop(`Secret "${secretName}" already exists`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
s.stop(`Secret "${secretName}" not found`);
|
|
399
|
+
|
|
400
|
+
const value = await passwordPrompt({
|
|
401
|
+
message: `Enter the password value for secret "${secretName}":`,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
if (typeof value !== "string" || !value) {
|
|
405
|
+
console.error("Error: No password provided");
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
s.start(`Uploading secret "${secretName}"`);
|
|
410
|
+
await $`echo ${value}`.pipe(
|
|
411
|
+
wrangler("secret", "put", secretName, "--name", "just-be-dev-wildcard"),
|
|
412
|
+
);
|
|
413
|
+
s.stop(`Secret "${secretName}" uploaded`);
|
|
414
|
+
}
|
|
415
|
+
|
|
245
416
|
/**
|
|
246
417
|
* Get the current git branch name
|
|
247
418
|
*/
|
|
@@ -286,7 +457,7 @@ async function createKVEntry(subdomain: string, routeConfig: RouteConfig): Promi
|
|
|
286
457
|
KV_NAMESPACE_ID,
|
|
287
458
|
subdomain,
|
|
288
459
|
configJson,
|
|
289
|
-
"--remote"
|
|
460
|
+
"--remote",
|
|
290
461
|
);
|
|
291
462
|
}
|
|
292
463
|
|
|
@@ -330,12 +501,23 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
|
|
|
330
501
|
|
|
331
502
|
// Upload files to R2
|
|
332
503
|
let uploadCount = 0;
|
|
504
|
+
let skippedCount = 0;
|
|
333
505
|
const failedUploads: string[] = [];
|
|
334
506
|
|
|
335
507
|
for (const filePath of filePaths) {
|
|
336
508
|
const relativePath = relative(rule.dir, filePath);
|
|
337
509
|
const r2Key = `${rule.subdomain}/${relativePath}`;
|
|
338
510
|
|
|
511
|
+
// Check if upload is needed
|
|
512
|
+
s.start(`Checking ${relativePath}`);
|
|
513
|
+
const needsUpload = await shouldUploadFile(filePath, r2Key, rule.subdomain);
|
|
514
|
+
|
|
515
|
+
if (!needsUpload) {
|
|
516
|
+
skippedCount++;
|
|
517
|
+
s.stop(`Skipped ${relativePath} (unchanged)`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
339
521
|
s.start(`Uploading ${relativePath}`);
|
|
340
522
|
const success = await uploadToR2(filePath, r2Key);
|
|
341
523
|
|
|
@@ -352,7 +534,12 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
|
|
|
352
534
|
throw new Error(`Failed to upload ${failedUploads.length} file(s)`);
|
|
353
535
|
}
|
|
354
536
|
|
|
355
|
-
console.log(`✓ Uploaded ${uploadCount} files
|
|
537
|
+
console.log(`✓ Uploaded ${uploadCount} files, skipped ${skippedCount} unchanged files`);
|
|
538
|
+
|
|
539
|
+
// Ensure password secret exists if configured
|
|
540
|
+
if (rule.password) {
|
|
541
|
+
await ensureSecretExists(rule.password, s);
|
|
542
|
+
}
|
|
356
543
|
|
|
357
544
|
// Create KV entry (path is derived from subdomain at runtime)
|
|
358
545
|
const routeConfig: RouteConfig = {
|
|
@@ -361,6 +548,7 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
|
|
|
361
548
|
...(rule.fallback && { fallback: rule.fallback }),
|
|
362
549
|
...(rule.redirects?.length && { redirects: rule.redirects }),
|
|
363
550
|
...(rule.rewrites?.length && { rewrites: rule.rewrites }),
|
|
551
|
+
...(rule.password && { password: rule.password }),
|
|
364
552
|
};
|
|
365
553
|
|
|
366
554
|
s.start(`Creating KV routing entry`);
|
|
@@ -373,17 +561,23 @@ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>)
|
|
|
373
561
|
*/
|
|
374
562
|
async function deployRedirectRule(
|
|
375
563
|
rule: RedirectRule,
|
|
376
|
-
s: ReturnType<typeof spinner
|
|
564
|
+
s: ReturnType<typeof spinner>,
|
|
377
565
|
): Promise<void> {
|
|
378
566
|
console.log(`\n🔀 Configuring redirect: ${rule.subdomain}.just-be.dev`);
|
|
379
567
|
console.log(` Target URL: ${rule.url}`);
|
|
380
568
|
console.log(` Permanent: ${rule.permanent ?? false}`);
|
|
381
569
|
|
|
570
|
+
// Ensure password secret exists if configured
|
|
571
|
+
if (rule.password) {
|
|
572
|
+
await ensureSecretExists(rule.password, s);
|
|
573
|
+
}
|
|
574
|
+
|
|
382
575
|
const routeConfig: RouteConfig = {
|
|
383
576
|
type: "redirect",
|
|
384
577
|
url: rule.url,
|
|
385
578
|
...(rule.permanent !== undefined && { permanent: rule.permanent }),
|
|
386
579
|
...(rule.preservePath !== undefined && { preservePath: rule.preservePath }),
|
|
580
|
+
...(rule.password && { password: rule.password }),
|
|
387
581
|
};
|
|
388
582
|
|
|
389
583
|
s.start(`Creating KV routing entry`);
|
|
@@ -399,10 +593,16 @@ async function deployRewriteRule(rule: RewriteRule, s: ReturnType<typeof spinner
|
|
|
399
593
|
console.log(` Target URL: ${rule.url}`);
|
|
400
594
|
console.log(` Allowed Methods: ${rule.allowedMethods?.join(", ") || "GET, HEAD, OPTIONS"}`);
|
|
401
595
|
|
|
596
|
+
// Ensure password secret exists if configured
|
|
597
|
+
if (rule.password) {
|
|
598
|
+
await ensureSecretExists(rule.password, s);
|
|
599
|
+
}
|
|
600
|
+
|
|
402
601
|
const routeConfig: RouteConfig = {
|
|
403
602
|
type: "rewrite",
|
|
404
603
|
url: rule.url,
|
|
405
604
|
...(rule.allowedMethods && { allowedMethods: rule.allowedMethods }),
|
|
605
|
+
...(rule.password && { password: rule.password }),
|
|
406
606
|
};
|
|
407
607
|
|
|
408
608
|
s.start(`Creating KV routing entry`);
|
|
@@ -562,8 +762,12 @@ if (import.meta.main) {
|
|
|
562
762
|
process.exit(0);
|
|
563
763
|
}
|
|
564
764
|
|
|
565
|
-
deploy()
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
765
|
+
deploy()
|
|
766
|
+
.then(() => {
|
|
767
|
+
process.exit(0);
|
|
768
|
+
})
|
|
769
|
+
.catch((error) => {
|
|
770
|
+
console.error("\n❌ Fatal error:", error);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
});
|
|
569
773
|
}
|
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@just-be/deploy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Deploy static sites to Cloudflare R2 with subdomain routing",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"deploy": "index.ts"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"index.ts",
|
|
11
|
-
"schema.json"
|
|
12
|
-
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"generate-schema": "bun run scripts/generate-schema.ts"
|
|
15
|
-
},
|
|
16
5
|
"keywords": [
|
|
17
6
|
"cloudflare",
|
|
18
|
-
"r2",
|
|
19
7
|
"deploy",
|
|
8
|
+
"r2",
|
|
20
9
|
"static-site"
|
|
21
10
|
],
|
|
22
|
-
"author": "Justin Bennett",
|
|
23
11
|
"license": "MIT",
|
|
12
|
+
"author": "Justin Bennett",
|
|
24
13
|
"repository": {
|
|
25
14
|
"type": "git",
|
|
26
15
|
"url": "git+https://github.com/just-be-dev/just-be.dev.git",
|
|
27
16
|
"directory": "packages/deploy"
|
|
28
17
|
},
|
|
29
|
-
"
|
|
30
|
-
"
|
|
18
|
+
"bin": {
|
|
19
|
+
"deploy": "index.ts"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"schema.json"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"generate-schema": "bun run scripts/generate-schema.ts"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@clack/prompts": "^0.11.0",
|
|
34
|
-
"@just-be/wildcard": "0.
|
|
34
|
+
"@just-be/wildcard": "0.6.0",
|
|
35
35
|
"wrangler": "4.48.0",
|
|
36
36
|
"zod": "^4.1.13"
|
|
37
37
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/schema.json
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"type": "object",
|
|
4
4
|
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
5
8
|
"rules": {
|
|
6
9
|
"minItems": 1,
|
|
7
10
|
"type": "array",
|
|
@@ -75,6 +78,10 @@
|
|
|
75
78
|
"dir": {
|
|
76
79
|
"type": "string",
|
|
77
80
|
"minLength": 1
|
|
81
|
+
},
|
|
82
|
+
"password": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
|
|
78
85
|
}
|
|
79
86
|
},
|
|
80
87
|
"required": ["type", "subdomain", "dir"],
|
|
@@ -99,6 +106,10 @@
|
|
|
99
106
|
},
|
|
100
107
|
"subdomain": {
|
|
101
108
|
"type": "string"
|
|
109
|
+
},
|
|
110
|
+
"password": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
|
|
102
113
|
}
|
|
103
114
|
},
|
|
104
115
|
"required": ["type", "url", "subdomain"],
|
|
@@ -125,6 +136,10 @@
|
|
|
125
136
|
},
|
|
126
137
|
"subdomain": {
|
|
127
138
|
"type": "string"
|
|
139
|
+
},
|
|
140
|
+
"password": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"description": "Name of the Cloudflare Worker secret containing the password for HTTP Basic Auth protection"
|
|
128
143
|
}
|
|
129
144
|
},
|
|
130
145
|
"required": ["type", "url", "allowedMethods", "subdomain"],
|