@just-be/deploy 0.2.0 → 0.3.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.
Files changed (4) hide show
  1. package/README.md +151 -53
  2. package/index.ts +300 -191
  3. package/package.json +10 -5
  4. package/schema.json +89 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @just-be/deploy
2
2
 
3
- Deploy static files to Cloudflare R2 and configure subdomain routing in KV for wildcard subdomain services.
3
+ Deploy static sites and setup routing for the wildcard subdomain service.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -24,84 +24,181 @@ bun install -g @just-be/deploy
24
24
 
25
25
  ## Usage
26
26
 
27
- ### Interactive Mode
27
+ ### Basic Usage
28
+
29
+ Create a `deploy.json` file in your project:
30
+
31
+ ```json
32
+ {
33
+ "rules": [
34
+ {
35
+ "subdomain": "myapp",
36
+ "type": "static",
37
+ "path": "apps/myapp",
38
+ "dir": "./dist",
39
+ "spa": true
40
+ },
41
+ {
42
+ "subdomain": "old-site",
43
+ "type": "redirect",
44
+ "url": "https://new-site.com",
45
+ "permanent": true
46
+ }
47
+ ]
48
+ }
49
+ ```
28
50
 
29
- Simply run without arguments to be prompted for all configuration:
51
+ Then run:
30
52
 
31
53
  ```bash
32
54
  bunx @just-be/deploy
33
55
  ```
34
56
 
35
- The interactive mode will ask you for:
57
+ ### Specify Config Path
36
58
 
37
- - Subdomain name
38
- - R2 path prefix
39
- - Local directory to upload
40
- - Whether to enable SPA mode
41
- - Whether to use a custom fallback file
59
+ ```bash
60
+ bunx @just-be/deploy path/to/config.json
61
+ ```
42
62
 
43
- ### Command-Line Mode
63
+ ## Rule Types
44
64
 
45
- Provide all arguments upfront:
65
+ ### Static Site
46
66
 
47
- ```bash
48
- bunx @just-be/deploy --subdomain=NAME --path=PATH --dir=DIR [--spa] [--fallback=FILE]
67
+ Deploy static files to R2 and serve them via a subdomain.
68
+
69
+ ```json
70
+ {
71
+ "subdomain": "myapp",
72
+ "type": "static",
73
+ "path": "apps/myapp",
74
+ "dir": "./dist",
75
+ "spa": true
76
+ }
49
77
  ```
50
78
 
51
- ### Arguments
79
+ **Options:**
52
80
 
53
- - `--subdomain`: Subdomain name (e.g., "myapp" for myapp.just-be.dev)
54
- - `--path`: R2 path prefix where files will be stored (e.g., "apps/myapp")
55
- - `--dir`: Local directory to upload
56
- - `--spa`: (Optional) Enable SPA mode - all routes serve index.html
57
- - `--fallback`: (Optional) Custom fallback file for 404s (only in non-SPA mode)
81
+ - `subdomain` (required): Subdomain name (e.g., "myapp" for myapp.just-be.dev)
82
+ - `type` (required): Must be "static"
83
+ - `path` (required): R2 path prefix where files will be stored
84
+ - `dir` (required): Local directory containing files to upload
85
+ - `spa` (optional): Enable SPA mode - all routes serve index.html
86
+ - `fallback` (optional): Custom 404 file (cannot be used with `spa`)
58
87
 
59
- ### Examples
88
+ ### Redirect
60
89
 
61
- **Interactive deployment:**
90
+ Configure an HTTP redirect from a subdomain to another URL.
62
91
 
63
- ```bash
64
- bunx @just-be/deploy
92
+ ```json
93
+ {
94
+ "subdomain": "old-site",
95
+ "type": "redirect",
96
+ "url": "https://new-site.com",
97
+ "permanent": true
98
+ }
65
99
  ```
66
100
 
67
- **Deploy a static site:**
101
+ **Options:**
68
102
 
69
- ```bash
70
- bunx @just-be/deploy \
71
- --subdomain=portfolio \
72
- --path=sites/portfolio \
73
- --dir=./dist
74
- ```
103
+ - `subdomain` (required): Subdomain name
104
+ - `type` (required): Must be "redirect"
105
+ - `url` (required): Target URL (must be http/https)
106
+ - `permanent` (optional): Use 301 (permanent) redirect instead of 302 (temporary)
75
107
 
76
- **Deploy a single-page application:**
108
+ ### Rewrite (Reverse Proxy)
77
109
 
78
- ```bash
79
- bunx @just-be/deploy \
80
- --subdomain=myapp \
81
- --path=apps/myapp \
82
- --dir=./build \
83
- --spa
110
+ Proxy requests from a subdomain to another URL.
111
+
112
+ ```json
113
+ {
114
+ "subdomain": "api",
115
+ "type": "rewrite",
116
+ "url": "https://api.example.com",
117
+ "allowedMethods": ["GET", "POST", "PUT", "DELETE"]
118
+ }
84
119
  ```
85
120
 
86
- **Deploy with custom 404 page:**
121
+ **Options:**
122
+
123
+ - `subdomain` (required): Subdomain name
124
+ - `type` (required): Must be "rewrite"
125
+ - `url` (required): Target URL to proxy to (must be http/https)
126
+ - `allowedMethods` (optional): HTTP methods allowed (default: ["GET", "HEAD", "OPTIONS"])
127
+
128
+ ## Examples
129
+
130
+ ### Multiple Static Sites
131
+
132
+ ```json
133
+ {
134
+ "rules": [
135
+ {
136
+ "subdomain": "portfolio",
137
+ "type": "static",
138
+ "path": "sites/portfolio",
139
+ "dir": "./build",
140
+ "fallback": "404.html"
141
+ },
142
+ {
143
+ "subdomain": "docs",
144
+ "type": "static",
145
+ "path": "sites/docs",
146
+ "dir": "./out",
147
+ "spa": true
148
+ },
149
+ {
150
+ "subdomain": "blog",
151
+ "type": "static",
152
+ "path": "sites/blog",
153
+ "dir": "./dist"
154
+ }
155
+ ]
156
+ }
157
+ ```
87
158
 
88
- ```bash
89
- bunx @just-be/deploy \
90
- --subdomain=docs \
91
- --path=sites/docs \
92
- --dir=./out \
93
- --fallback=404.html
159
+ ### Mixed Deployments
160
+
161
+ ```json
162
+ {
163
+ "rules": [
164
+ {
165
+ "subdomain": "app",
166
+ "type": "static",
167
+ "path": "apps/main",
168
+ "dir": "./dist",
169
+ "spa": true
170
+ },
171
+ {
172
+ "subdomain": "legacy",
173
+ "type": "redirect",
174
+ "url": "https://app.just-be.dev",
175
+ "permanent": true
176
+ },
177
+ {
178
+ "subdomain": "api",
179
+ "type": "rewrite",
180
+ "url": "https://backend.example.com",
181
+ "allowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"]
182
+ }
183
+ ]
184
+ }
94
185
  ```
95
186
 
187
+ ## Editor Support
188
+
189
+ The package includes a JSON Schema (`deploy.schema.json`) for editor validation and autocomplete. Many editors will automatically provide validation and suggestions for `deploy.json` files.
190
+
96
191
  ## How It Works
97
192
 
98
- 1. **Parses arguments** using a custom argument parser
99
- 2. **Prompts for missing values** if any required arguments are not provided
100
- 3. **Validates configuration** using Zod schemas from `@just-be/wildcard`
101
- 4. **Scans** the target directory for all files
102
- 5. **Uploads** each file to R2 at `content-bucket/{path}/{relative-path}`
103
- 6. **Creates** a KV entry with routing configuration for the subdomain
104
- 7. **Configures** the wildcard service to serve your site at `https://{subdomain}.just-be.dev`
193
+ 1. **Parses configuration** from `deploy.json`
194
+ 2. **Validates** all rules using Zod schemas from `@just-be/wildcard`
195
+ 3. **For static sites**:
196
+ - Scans the local directory for all files
197
+ - Uploads each file to R2 at `content-bucket/{path}/{relative-path}`
198
+ - Creates a KV entry with routing configuration
199
+ 4. **For redirects/rewrites**:
200
+ - Creates a KV entry with the routing configuration
201
+ 5. **Configures** the wildcard service to route requests for each subdomain
105
202
 
106
203
  ## Configuration
107
204
 
@@ -115,9 +212,10 @@ The script expects your wildcard service to use:
115
212
 
116
213
  Configuration is validated using Zod schemas to ensure:
117
214
 
215
+ - Valid subdomain format (alphanumeric with hyphens, 1-63 characters)
118
216
  - SPA mode and fallback file are not used together
119
- - R2 path is not empty
120
- - All required fields are present
217
+ - Required fields are present for each rule type
218
+ - URLs are safe (http/https only)
121
219
 
122
220
  ## Related Packages
123
221
 
package/index.ts CHANGED
@@ -1,23 +1,49 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * Deploy static files to Cloudflare R2 and configure subdomain routing in KV
4
+ * Deploy static sites and setup routing for the wildcard subdomain service
5
5
  *
6
6
  * Usage:
7
- * bunx @just-be/deploy --subdomain=myapp --path=apps/myapp --dir=./dist
8
- * bunx @just-be/deploy --subdomain=portfolio --path=sites/portfolio --dir=./build --fallback=404.html
9
- * bunx @just-be/deploy --subdomain=spa --path=apps/spa --dir=./dist --spa
7
+ * bunx @just-be/deploy # Looks for deploy.json in current directory
8
+ * bunx @just-be/deploy path/to/config.json # Deploy with specific config file
9
+ * bunx @just-be/deploy preview <subdomain> # Preview deploy with branch name suffix
10
10
  *
11
- * Or run interactively without arguments:
12
- * bunx @just-be/deploy
11
+ * Example deploy.json:
12
+ * {
13
+ * "rules": [
14
+ * {
15
+ * "subdomain": "myapp",
16
+ * "type": "static",
17
+ * "dir": "./dist",
18
+ * "spa": true
19
+ * },
20
+ * {
21
+ * "subdomain": "old-site",
22
+ * "type": "redirect",
23
+ * "url": "https://new-site.com",
24
+ * "permanent": true
25
+ * }
26
+ * ]
27
+ * }
28
+ *
29
+ * Note: The subdomain is automatically used as the R2 path prefix.
30
+ * For example, subdomain "myapp" will store files under "myapp/" in R2.
31
+ *
32
+ * Preview deploys append the branch name to the subdomain (e.g., "myapp-feature-branch").
13
33
  */
14
34
 
15
35
  import { $ } from "bun";
16
- import { readdir, stat } from "fs/promises";
17
- import { join, relative } from "path";
18
- import { confirm, intro, outro, spinner, text } from "@clack/prompts";
36
+ import { readdir, stat, access } from "fs/promises";
37
+ import { join, relative, resolve } from "path";
38
+ import { intro, outro, spinner } from "@clack/prompts";
19
39
  import { z } from "zod";
20
- import { StaticConfigSchema, type StaticConfig, isValidSubdomain } from "@just-be/wildcard";
40
+ import {
41
+ StaticConfigSchema,
42
+ RedirectConfigSchema,
43
+ RewriteConfigSchema,
44
+ type RouteConfig,
45
+ subdomain,
46
+ } from "@just-be/wildcard";
21
47
 
22
48
  const BUCKET_NAME = "content-bucket";
23
49
  const WRANGLER_CONFIG = "services/wildcard/wrangler.toml";
@@ -31,138 +57,84 @@ function wrangler(command: TemplateStringsArray, ...values: unknown[]) {
31
57
  }
32
58
 
33
59
  /**
34
- * Schema for CLI arguments
60
+ * Route rules extend RouteConfig with subdomain
61
+ * Static rules also need a `dir` field for the local directory to upload
62
+ * The subdomain is automatically used as the R2 path prefix
63
+ * Using safeExtend because base schemas contain refinements
35
64
  */
36
- const CliArgsSchema = z.object({
37
- subdomain: z.string().optional(),
38
- path: z.string().optional(),
39
- dir: z.string().optional(),
40
- spa: z.boolean().optional(),
41
- fallback: z.string().optional(),
65
+ const StaticRuleSchema = StaticConfigSchema.safeExtend({
66
+ subdomain: subdomain(),
67
+ dir: z.string().min(1),
42
68
  });
43
69
 
44
- type CliArgs = z.infer<typeof CliArgsSchema>;
70
+ const RedirectRuleSchema = RedirectConfigSchema.safeExtend({
71
+ subdomain: subdomain(),
72
+ });
45
73
 
46
- /**
47
- * Parse command-line arguments from Bun.argv using a custom parser
48
- */
49
- function parseCliArgs(): CliArgs {
50
- const args: Record<string, string | boolean> = {};
51
-
52
- for (const arg of Bun.argv.slice(2)) {
53
- if (arg.startsWith("--")) {
54
- const [key, value] = arg.slice(2).split("=");
55
- if (value === undefined) {
56
- // Flag without value (e.g., --spa)
57
- args[key] = true;
58
- } else {
59
- args[key] = value;
60
- }
61
- }
62
- }
74
+ const RewriteRuleSchema = RewriteConfigSchema.safeExtend({
75
+ subdomain: subdomain(),
76
+ });
63
77
 
64
- return CliArgsSchema.parse(args);
65
- }
78
+ const RouteRuleSchema = z.discriminatedUnion("type", [
79
+ StaticRuleSchema,
80
+ RedirectRuleSchema,
81
+ RewriteRuleSchema,
82
+ ]);
66
83
 
67
- interface DeployConfig extends Omit<StaticConfig, "type"> {
68
- subdomain: string;
69
- dir: string;
70
- }
84
+ export const DeployConfigSchema = z.object({
85
+ rules: z.array(RouteRuleSchema).min(1, "At least one rule is required"),
86
+ });
87
+
88
+ type StaticRule = z.infer<typeof StaticRuleSchema>;
89
+ type RedirectRule = z.infer<typeof RedirectRuleSchema>;
90
+ type RewriteRule = z.infer<typeof RewriteRuleSchema>;
91
+ type DeployConfig = z.infer<typeof DeployConfigSchema>;
71
92
 
72
93
  /**
73
- * Parse command-line arguments
94
+ * Find and parse deploy configuration file
74
95
  */
75
- async function getConfig(): Promise<DeployConfig> {
76
- const values = parseCliArgs();
77
-
78
- // Interactive mode if any required argument is missing
79
- const needsInteractive = !values.subdomain || !values.path || !values.dir;
80
-
81
- if (needsInteractive) {
82
- intro("Interactive deployment setup");
96
+ async function findAndParseConfig(providedPath?: string): Promise<DeployConfig> {
97
+ let configPath: string;
98
+
99
+ if (providedPath) {
100
+ // Use provided path
101
+ configPath = resolve(providedPath);
102
+ } else {
103
+ // Look for deploy.json in current directory
104
+ configPath = join(process.cwd(), "deploy.json");
105
+
106
+ const hasJson = await access(configPath)
107
+ .then(() => true)
108
+ .catch(() => false);
109
+
110
+ if (!hasJson) {
111
+ console.error("Error: No deploy.json found in current directory");
112
+ console.error("Run 'bunx @just-be/deploy path/to/config.json' to specify a config file");
113
+ process.exit(1);
114
+ }
83
115
  }
84
116
 
85
- let subdomain =
86
- values.subdomain ||
87
- ((await text({
88
- message: "Subdomain name (e.g., 'myapp' for myapp.just-be.dev)",
89
- validate: (value) => {
90
- if (!value) return "Subdomain is required";
91
- if (!isValidSubdomain(value as string)) {
92
- return "Invalid subdomain format. Must be alphanumeric with hyphens, 1-63 characters.";
93
- }
94
- },
95
- })) as string);
96
-
97
- // Validate subdomain format (in case it came from CLI args)
98
- if (!isValidSubdomain(subdomain)) {
99
- console.error(
100
- "\nInvalid subdomain format. Must be alphanumeric with hyphens, 1-63 characters."
101
- );
102
- process.exit(1);
103
- }
117
+ // Parse JSON file
118
+ const content = await Bun.file(configPath).text();
119
+ let parsed: unknown;
104
120
 
105
- const path =
106
- values.path ||
107
- ((await text({
108
- message: "R2 path prefix (e.g., 'apps/myapp')",
109
- validate: (value) => {
110
- if (!value) return "Path is required";
111
- },
112
- })) as string);
113
-
114
- const dir =
115
- values.dir ||
116
- ((await text({
117
- message: "Local directory to upload",
118
- placeholder: "./dist",
119
- defaultValue: "./dist",
120
- })) as string);
121
-
122
- let spa = values.spa;
123
- let fallback = values.fallback;
124
-
125
- // Only prompt for spa/fallback if not provided
126
- if (needsInteractive && spa === undefined && fallback === undefined) {
127
- spa = (await confirm({
128
- message: "Enable SPA mode? (all routes serve index.html)",
129
- })) as boolean;
130
- if (!spa) {
131
- const useFallback = (await confirm({
132
- message: "Use a custom fallback file for 404s?",
133
- })) as boolean;
134
- if (useFallback) {
135
- fallback = (await text({
136
- message: "Fallback file name",
137
- placeholder: "404.html",
138
- defaultValue: "404.html",
139
- })) as string;
140
- }
141
- }
121
+ try {
122
+ parsed = JSON.parse(content);
123
+ } catch (error) {
124
+ console.error("Error: Failed to parse JSON config file");
125
+ console.error(error);
126
+ process.exit(1);
142
127
  }
143
128
 
144
- // Validate with Zod schema
145
- const staticConfig: StaticConfig = {
146
- type: "static",
147
- path,
148
- ...(spa && { spa }),
149
- ...(fallback && { fallback }),
150
- };
151
-
152
- const result = StaticConfigSchema.safeParse(staticConfig);
129
+ // Validate with schema
130
+ const result = DeployConfigSchema.safeParse(parsed);
153
131
  if (!result.success) {
154
132
  console.error("\nInvalid configuration:");
155
- console.error(result.error.format());
133
+ console.error(z.prettifyError(result.error));
156
134
  process.exit(1);
157
135
  }
158
136
 
159
- return {
160
- subdomain,
161
- path,
162
- dir,
163
- spa,
164
- fallback,
165
- };
137
+ return result.data;
166
138
  }
167
139
 
168
140
  /**
@@ -189,7 +161,6 @@ async function findFiles(dir: string): Promise<string[]> {
189
161
 
190
162
  /**
191
163
  * Upload a file to R2
192
- * @returns true if upload succeeded, false otherwise
193
164
  */
194
165
  async function uploadToR2(localPath: string, r2Key: string): Promise<boolean> {
195
166
  try {
@@ -214,78 +185,76 @@ async function validateKVAccess(): Promise<boolean> {
214
185
  }
215
186
 
216
187
  /**
217
- * Create KV entry for subdomain routing
188
+ * Get the current git branch name
218
189
  */
219
- async function createKVEntry(subdomain: string, config: DeployConfig): Promise<void> {
220
- const routingConfig: StaticConfig = {
221
- type: "static",
222
- path: config.path,
223
- ...(config.spa && { spa: config.spa }),
224
- ...(config.fallback && { fallback: config.fallback }),
225
- };
190
+ async function getCurrentBranch(): Promise<string> {
191
+ try {
192
+ const result = await $`git rev-parse --abbrev-ref HEAD`.text();
193
+ return result.trim();
194
+ } catch (error) {
195
+ throw new Error("Failed to get current git branch. Are you in a git repository?");
196
+ }
197
+ }
226
198
 
227
- const configJson = JSON.stringify(routingConfig);
199
+ /**
200
+ * Sanitize branch name for use in subdomain
201
+ * Replaces invalid characters with hyphens and converts to lowercase
202
+ */
203
+ function sanitizeBranchName(branch: string): string {
204
+ return branch
205
+ .toLowerCase()
206
+ .replace(/[^a-z0-9-]/g, "-")
207
+ .replace(/-+/g, "-")
208
+ .replace(/^-|-$/g, "");
209
+ }
228
210
 
211
+ /**
212
+ * Create KV entry for subdomain routing
213
+ */
214
+ async function createKVEntry(subdomain: string, routeConfig: RouteConfig): Promise<void> {
215
+ const configJson = JSON.stringify(routeConfig);
229
216
  await wrangler`kv key put --binding ROUTING_RULES ${subdomain} ${configJson} --config ${WRANGLER_CONFIG}`;
230
217
  }
231
218
 
232
219
  /**
233
- * Main deployment function
220
+ * Deploy a static site rule
234
221
  */
235
- async function deploy() {
236
- const config = await getConfig();
237
-
238
- console.log("\nDeploy Configuration:");
239
- console.log(` Subdomain: ${config.subdomain}.just-be.dev`);
240
- console.log(` R2 Path: ${config.path}`);
241
- console.log(` Local Directory: ${config.dir}`);
242
- if (config.spa) {
243
- console.log(` Mode: SPA (all routes serve index.html)`);
244
- } else if (config.fallback) {
245
- console.log(` Fallback: ${config.fallback}`);
222
+ async function deployStaticRule(rule: StaticRule, s: ReturnType<typeof spinner>): Promise<void> {
223
+ console.log(`\n📦 Deploying static site: ${rule.subdomain}.just-be.dev`);
224
+ console.log(` Local Directory: ${rule.dir}`);
225
+ if (rule.spa) {
226
+ console.log(` Mode: SPA`);
227
+ } else if (rule.fallback) {
228
+ console.log(` Fallback: ${rule.fallback}`);
246
229
  }
247
- console.log();
248
230
 
249
231
  // Verify directory exists
250
232
  try {
251
- const dirStat = await stat(config.dir);
233
+ const dirStat = await stat(rule.dir);
252
234
  if (!dirStat.isDirectory()) {
253
- console.error(`Error: ${config.dir} is not a directory`);
254
- process.exit(1);
235
+ throw new Error(`${rule.dir} is not a directory`);
255
236
  }
256
- } catch {
257
- console.error(`Error: Directory ${config.dir} does not exist`);
258
- process.exit(1);
237
+ } catch (error) {
238
+ console.error(`Error: Directory ${rule.dir} does not exist or is not accessible`);
239
+ throw error;
259
240
  }
260
241
 
261
242
  // Find all files to upload
262
- const s = spinner();
263
- s.start(`Scanning files in: ${config.dir}`);
264
- const filePaths = await findFiles(config.dir);
243
+ s.start(`Scanning files in: ${rule.dir}`);
244
+ const filePaths = await findFiles(rule.dir);
265
245
  s.stop(`Found ${filePaths.length} files to upload`);
266
246
 
267
247
  if (filePaths.length === 0) {
268
- console.error("No files found to upload");
269
- process.exit(1);
270
- }
271
-
272
- // Validate KV access before starting uploads
273
- s.start("Validating KV access");
274
- const hasKVAccess = await validateKVAccess();
275
- if (!hasKVAccess) {
276
- s.stop("Error: Cannot access KV namespace");
277
- console.error("Check wrangler configuration and permissions.");
278
- process.exit(1);
248
+ throw new Error("No files found to upload");
279
249
  }
280
- s.stop("KV access validated");
281
250
 
282
251
  // Upload files to R2
283
252
  let uploadCount = 0;
284
253
  const failedUploads: string[] = [];
285
254
 
286
255
  for (const filePath of filePaths) {
287
- const relativePath = relative(config.dir, filePath);
288
- const r2Key = `${config.path}/${relativePath}`;
256
+ const relativePath = relative(rule.dir, filePath);
257
+ const r2Key = `${rule.subdomain}/${relativePath}`;
289
258
 
290
259
  s.start(`Uploading ${relativePath}`);
291
260
  const success = await uploadToR2(filePath, r2Key);
@@ -299,34 +268,174 @@ async function deploy() {
299
268
  }
300
269
  }
301
270
 
302
- // Report upload results
303
271
  if (failedUploads.length > 0) {
304
- console.error(`\nFailed to upload ${failedUploads.length} file(s):`);
305
- for (const file of failedUploads) {
306
- console.error(` - ${file}`);
307
- }
308
- process.exit(1);
272
+ throw new Error(`Failed to upload ${failedUploads.length} file(s)`);
309
273
  }
310
274
 
311
- console.log(`\nUploaded ${uploadCount} files to R2\n`);
275
+ console.log(`✓ Uploaded ${uploadCount} files to R2`);
312
276
 
313
- // Create KV entry for routing
314
- s.start(`Creating KV routing entry for subdomain: ${config.subdomain}`);
315
- try {
316
- await createKVEntry(config.subdomain, config);
317
- s.stop("KV routing entry created");
318
- } catch (error) {
319
- s.stop("Error: Failed to create KV entry");
320
- console.error("Files were uploaded but routing is not configured.");
321
- console.error(`Uploaded files location: ${config.path}`);
322
- console.error(`Error:`, error);
277
+ // Create KV entry (path is derived from subdomain at runtime)
278
+ const routeConfig: RouteConfig = {
279
+ type: "static",
280
+ ...(rule.spa && { spa: rule.spa }),
281
+ ...(rule.fallback && { fallback: rule.fallback }),
282
+ };
283
+
284
+ s.start(`Creating KV routing entry`);
285
+ await createKVEntry(rule.subdomain, routeConfig);
286
+ s.stop(`✓ KV routing entry created`);
287
+ }
288
+
289
+ /**
290
+ * Deploy a redirect rule
291
+ */
292
+ async function deployRedirectRule(
293
+ rule: RedirectRule,
294
+ s: ReturnType<typeof spinner>
295
+ ): Promise<void> {
296
+ console.log(`\n🔀 Configuring redirect: ${rule.subdomain}.just-be.dev`);
297
+ console.log(` Target URL: ${rule.url}`);
298
+ console.log(` Permanent: ${rule.permanent ?? false}`);
299
+
300
+ const routeConfig: RouteConfig = {
301
+ type: "redirect",
302
+ url: rule.url,
303
+ ...(rule.permanent !== undefined && { permanent: rule.permanent }),
304
+ };
305
+
306
+ s.start(`Creating KV routing entry`);
307
+ await createKVEntry(rule.subdomain, routeConfig);
308
+ s.stop(`✓ KV routing entry created`);
309
+ }
310
+
311
+ /**
312
+ * Deploy a rewrite rule
313
+ */
314
+ async function deployRewriteRule(rule: RewriteRule, s: ReturnType<typeof spinner>): Promise<void> {
315
+ console.log(`\n🔄 Configuring rewrite: ${rule.subdomain}.just-be.dev`);
316
+ console.log(` Target URL: ${rule.url}`);
317
+ console.log(` Allowed Methods: ${rule.allowedMethods?.join(", ") || "GET, HEAD, OPTIONS"}`);
318
+
319
+ const routeConfig: RouteConfig = {
320
+ type: "rewrite",
321
+ url: rule.url,
322
+ ...(rule.allowedMethods && { allowedMethods: rule.allowedMethods }),
323
+ };
324
+
325
+ s.start(`Creating KV routing entry`);
326
+ await createKVEntry(rule.subdomain, routeConfig);
327
+ s.stop(`✓ KV routing entry created`);
328
+ }
329
+
330
+ /**
331
+ * Main deployment function
332
+ */
333
+ async function deploy() {
334
+ intro("🚀 Just-Be Deploy");
335
+
336
+ // Parse CLI arguments
337
+ const args = Bun.argv.slice(2);
338
+ const isPreview = args[0] === "preview";
339
+
340
+ let config: DeployConfig;
341
+ let rulesToDeploy: (StaticRule | RedirectRule | RewriteRule)[];
342
+
343
+ if (isPreview) {
344
+ // Preview mode: deploy preview <subdomain> [config-path]
345
+ const targetSubdomain = args[1];
346
+ const configPath = args[2];
347
+
348
+ if (!targetSubdomain) {
349
+ console.error("Error: Subdomain is required for preview deploy");
350
+ console.error("Usage: bunx @just-be/deploy preview <subdomain> [config-path]");
351
+ process.exit(1);
352
+ }
353
+
354
+ // Get current branch
355
+ const branch = await getCurrentBranch();
356
+ const sanitizedBranch = sanitizeBranchName(branch);
357
+
358
+ console.log(`\n📋 Preview Deploy Mode`);
359
+ console.log(` Branch: ${branch}`);
360
+ console.log(` Sanitized: ${sanitizedBranch}`);
361
+
362
+ // Load config
363
+ config = await findAndParseConfig(configPath);
364
+
365
+ // Find the matching rule
366
+ const matchingRule = config.rules.find((rule) => rule.subdomain === targetSubdomain);
367
+
368
+ if (!matchingRule) {
369
+ console.error(`\nError: No rule found with subdomain "${targetSubdomain}"`);
370
+ console.error(`\nAvailable subdomains: ${config.rules.map((r) => r.subdomain).join(", ")}`);
371
+ process.exit(1);
372
+ }
373
+
374
+ if (matchingRule.type !== "static") {
375
+ console.error(`\nError: Preview deploys only support static rules`);
376
+ console.error(`The rule "${targetSubdomain}" is of type "${matchingRule.type}"`);
377
+ process.exit(1);
378
+ }
379
+
380
+ // Clone rule and modify subdomain
381
+ const previewSubdomain = `${targetSubdomain}-${sanitizedBranch}`;
382
+ const previewRule: StaticRule = {
383
+ ...matchingRule,
384
+ subdomain: previewSubdomain,
385
+ };
386
+
387
+ rulesToDeploy = [previewRule];
388
+ console.log(` Preview subdomain: ${previewSubdomain}.just-be.dev\n`);
389
+ } else {
390
+ // Normal mode: deploy all rules
391
+ const configPath = args[0];
392
+ config = await findAndParseConfig(configPath);
393
+ rulesToDeploy = config.rules;
394
+ console.log(`\nFound ${config.rules.length} rule(s) to deploy`);
395
+ }
396
+
397
+ // Validate KV access before starting
398
+ const s = spinner();
399
+ s.start("Validating KV access");
400
+ const hasKVAccess = await validateKVAccess();
401
+ if (!hasKVAccess) {
402
+ s.stop("Error: Cannot access KV namespace");
403
+ console.error("Check wrangler configuration and permissions.");
323
404
  process.exit(1);
324
405
  }
406
+ s.stop("✓ KV access validated");
407
+
408
+ // Deploy each rule
409
+ const deployedSubdomains: string[] = [];
410
+ for (const rule of rulesToDeploy) {
411
+ try {
412
+ switch (rule.type) {
413
+ case "static":
414
+ await deployStaticRule(rule, s);
415
+ deployedSubdomains.push(`https://${rule.subdomain}.just-be.dev`);
416
+ break;
417
+ case "redirect":
418
+ await deployRedirectRule(rule, s);
419
+ deployedSubdomains.push(`https://${rule.subdomain}.just-be.dev → ${rule.url}`);
420
+ break;
421
+ case "rewrite":
422
+ await deployRewriteRule(rule, s);
423
+ deployedSubdomains.push(`https://${rule.subdomain}.just-be.dev ⟲ ${rule.url}`);
424
+ break;
425
+ }
426
+ } catch (error) {
427
+ console.error(`\n❌ Failed to deploy ${rule.subdomain}:`, error);
428
+ process.exit(1);
429
+ }
430
+ }
325
431
 
326
- outro(`Your site is available at: https://${config.subdomain}.just-be.dev`);
432
+ outro("\n✅ Deployment complete!\n\nDeployed sites:\n" + deployedSubdomains.join("\n"));
327
433
  }
328
434
 
329
- deploy().catch((error) => {
330
- console.error("\nFatal error:", error);
331
- process.exit(1);
332
- });
435
+ // Only run deploy() when this file is executed directly, not when imported
436
+ if (import.meta.main) {
437
+ deploy().catch((error) => {
438
+ console.error("\n❌ Fatal error:", error);
439
+ process.exit(1);
440
+ });
441
+ }
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@just-be/deploy",
3
- "version": "0.2.0",
4
- "description": "Deploy static files to Cloudflare R2 and configure subdomain routing",
3
+ "version": "0.3.0",
4
+ "description": "Deploy static sites to Cloudflare R2 with subdomain routing",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "deploy": "index.ts"
8
8
  },
9
9
  "files": [
10
- "index.ts"
10
+ "index.ts",
11
+ "schema.json"
11
12
  ],
13
+ "scripts": {
14
+ "generate-schema": "bun run scripts/generate-schema.ts"
15
+ },
12
16
  "keywords": [
13
17
  "cloudflare",
14
18
  "r2",
@@ -27,8 +31,9 @@
27
31
  },
28
32
  "dependencies": {
29
33
  "@clack/prompts": "^0.11.0",
30
- "@just-be/wildcard": "0.1.1",
31
- "wrangler": "4.48.0"
34
+ "@just-be/wildcard": "0.2.0",
35
+ "wrangler": "4.48.0",
36
+ "zod": "^4.1.13"
32
37
  },
33
38
  "publishConfig": {
34
39
  "access": "public"
package/schema.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "rules": {
6
+ "minItems": 1,
7
+ "type": "array",
8
+ "items": {
9
+ "oneOf": [
10
+ {
11
+ "type": "object",
12
+ "properties": {
13
+ "type": {
14
+ "type": "string",
15
+ "const": "static"
16
+ },
17
+ "spa": {
18
+ "type": "boolean"
19
+ },
20
+ "fallback": {
21
+ "type": "string"
22
+ },
23
+ "subdomain": {
24
+ "type": "string"
25
+ },
26
+ "dir": {
27
+ "type": "string",
28
+ "minLength": 1
29
+ }
30
+ },
31
+ "required": ["type", "subdomain", "dir"],
32
+ "additionalProperties": false
33
+ },
34
+ {
35
+ "type": "object",
36
+ "properties": {
37
+ "type": {
38
+ "type": "string",
39
+ "const": "redirect"
40
+ },
41
+ "url": {
42
+ "type": "string",
43
+ "format": "uri"
44
+ },
45
+ "permanent": {
46
+ "type": "boolean"
47
+ },
48
+ "subdomain": {
49
+ "type": "string"
50
+ }
51
+ },
52
+ "required": ["type", "url", "subdomain"],
53
+ "additionalProperties": false
54
+ },
55
+ {
56
+ "type": "object",
57
+ "properties": {
58
+ "type": {
59
+ "type": "string",
60
+ "const": "rewrite"
61
+ },
62
+ "url": {
63
+ "type": "string",
64
+ "format": "uri"
65
+ },
66
+ "allowedMethods": {
67
+ "default": ["GET", "HEAD", "OPTIONS"],
68
+ "type": "array",
69
+ "items": {
70
+ "type": "string",
71
+ "enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
72
+ }
73
+ },
74
+ "subdomain": {
75
+ "type": "string"
76
+ }
77
+ },
78
+ "required": ["type", "url", "allowedMethods", "subdomain"],
79
+ "additionalProperties": false
80
+ }
81
+ ]
82
+ }
83
+ }
84
+ },
85
+ "required": ["rules"],
86
+ "additionalProperties": false,
87
+ "title": "Deploy Configuration",
88
+ "description": "Configuration for deploying static sites and setting up routing for the wildcard subdomain service"
89
+ }