@just-be/deploy 0.1.1 → 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 +158 -53
  2. package/index.ts +344 -166
  3. package/package.json +18 -6
  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,83 +24,186 @@ 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:
36
- - Subdomain name
37
- - R2 path prefix
38
- - Local directory to upload
39
- - Whether to enable SPA mode
40
- - Whether to use a custom fallback file
57
+ ### Specify Config Path
41
58
 
42
- ### Command-Line Mode
59
+ ```bash
60
+ bunx @just-be/deploy path/to/config.json
61
+ ```
43
62
 
44
- Provide all arguments upfront:
63
+ ## Rule Types
45
64
 
46
- ```bash
47
- bunx @just-be/deploy --subdomain=NAME --path=PATH --dir=DIR [--spa] [--fallback=FILE]
65
+ ### Static Site
66
+
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
+ }
48
77
  ```
49
78
 
50
- ### Arguments
79
+ **Options:**
51
80
 
52
- - `--subdomain`: Subdomain name (e.g., "myapp" for myapp.just-be.dev)
53
- - `--path`: R2 path prefix where files will be stored (e.g., "apps/myapp")
54
- - `--dir`: Local directory to upload
55
- - `--spa`: (Optional) Enable SPA mode - all routes serve index.html
56
- - `--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`)
57
87
 
58
- ### Examples
88
+ ### Redirect
59
89
 
60
- **Interactive deployment:**
61
- ```bash
62
- bunx @just-be/deploy
90
+ Configure an HTTP redirect from a subdomain to another URL.
91
+
92
+ ```json
93
+ {
94
+ "subdomain": "old-site",
95
+ "type": "redirect",
96
+ "url": "https://new-site.com",
97
+ "permanent": true
98
+ }
63
99
  ```
64
100
 
65
- **Deploy a static site:**
66
- ```bash
67
- bunx @just-be/deploy \
68
- --subdomain=portfolio \
69
- --path=sites/portfolio \
70
- --dir=./dist
101
+ **Options:**
102
+
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)
107
+
108
+ ### Rewrite (Reverse Proxy)
109
+
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
+ }
71
119
  ```
72
120
 
73
- **Deploy a single-page application:**
74
- ```bash
75
- bunx @just-be/deploy \
76
- --subdomain=myapp \
77
- --path=apps/myapp \
78
- --dir=./build \
79
- --spa
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
+ }
80
157
  ```
81
158
 
82
- **Deploy with custom 404 page:**
83
- ```bash
84
- bunx @just-be/deploy \
85
- --subdomain=docs \
86
- --path=sites/docs \
87
- --dir=./out \
88
- --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
+ }
89
185
  ```
90
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
+
91
191
  ## How It Works
92
192
 
93
- 1. **Parses arguments** using Node's `util.parseArgs` API
94
- 2. **Prompts for missing values** if any required arguments are not provided
95
- 3. **Validates configuration** using Zod schemas from `@just-be/wildcard-schemas`
96
- 4. **Scans** the target directory for all files
97
- 5. **Uploads** each file to R2 at `content-bucket/{path}/{relative-path}`
98
- 6. **Creates** a KV entry with routing configuration for the subdomain
99
- 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
100
202
 
101
203
  ## Configuration
102
204
 
103
205
  The script expects your wildcard service to use:
206
+
104
207
  - **R2 Bucket**: `content-bucket`
105
208
  - **KV Binding**: `ROUTING_RULES`
106
209
  - **Wrangler Config**: `services/wildcard/wrangler.toml`
@@ -108,13 +211,15 @@ The script expects your wildcard service to use:
108
211
  ## Validation
109
212
 
110
213
  Configuration is validated using Zod schemas to ensure:
214
+
215
+ - Valid subdomain format (alphanumeric with hyphens, 1-63 characters)
111
216
  - SPA mode and fallback file are not used together
112
- - R2 path is not empty
113
- - All required fields are present
217
+ - Required fields are present for each rule type
218
+ - URLs are safe (http/https only)
114
219
 
115
220
  ## Related Packages
116
221
 
117
- - [`@just-be/wildcard-schemas`](../wildcard-schemas) - Shared Zod schemas for configuration validation
222
+ - [`@just-be/wildcard`](../wildcard) - Shared Zod schemas and routing handlers for wildcard subdomain configuration
118
223
 
119
224
  ## License
120
225
 
package/index.ts CHANGED
@@ -1,155 +1,140 @@
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 { createInterface } from "readline";
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";
24
50
 
25
51
  /**
26
- * Schema for CLI arguments
52
+ * Run wrangler command using bunx to ensure it resolves from package dependencies
27
53
  */
28
- const CliArgsSchema = z.object({
29
- subdomain: z.string().optional(),
30
- path: z.string().optional(),
31
- dir: z.string().optional(),
32
- spa: z.boolean().optional(),
33
- fallback: z.string().optional(),
34
- });
35
-
36
- type CliArgs = z.infer<typeof CliArgsSchema>;
54
+ function wrangler(command: TemplateStringsArray, ...values: unknown[]) {
55
+ const cmd = String.raw(command, ...values);
56
+ return $`bunx wrangler ${cmd}`;
57
+ }
37
58
 
38
59
  /**
39
- * Parse command-line arguments from Bun.argv
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
40
64
  */
41
- function parseCliArgs(): CliArgs {
42
- const args: Record<string, string | boolean> = {};
43
-
44
- for (const arg of Bun.argv.slice(2)) {
45
- if (arg.startsWith("--")) {
46
- const [key, value] = arg.slice(2).split("=");
47
- if (value === undefined) {
48
- // Flag without value (e.g., --spa)
49
- args[key] = true;
50
- } else {
51
- args[key] = value;
52
- }
53
- }
54
- }
65
+ const StaticRuleSchema = StaticConfigSchema.safeExtend({
66
+ subdomain: subdomain(),
67
+ dir: z.string().min(1),
68
+ });
55
69
 
56
- return CliArgsSchema.parse(args);
57
- }
70
+ const RedirectRuleSchema = RedirectConfigSchema.safeExtend({
71
+ subdomain: subdomain(),
72
+ });
58
73
 
59
- interface DeployConfig extends Omit<StaticConfig, "type"> {
60
- subdomain: string;
61
- dir: string;
62
- }
74
+ const RewriteRuleSchema = RewriteConfigSchema.safeExtend({
75
+ subdomain: subdomain(),
76
+ });
63
77
 
64
- /**
65
- * Prompt user for input
66
- */
67
- async function prompt(question: string, defaultValue?: string): Promise<string> {
68
- const rl = createInterface({
69
- input: process.stdin,
70
- output: process.stdout,
71
- });
78
+ const RouteRuleSchema = z.discriminatedUnion("type", [
79
+ StaticRuleSchema,
80
+ RedirectRuleSchema,
81
+ RewriteRuleSchema,
82
+ ]);
72
83
 
73
- return new Promise((resolve) => {
74
- const promptText = defaultValue ? `${question} (${defaultValue}): ` : `${question}: `;
75
- rl.question(promptText, (answer) => {
76
- rl.close();
77
- resolve(answer.trim() || defaultValue || "");
78
- });
79
- });
80
- }
84
+ export const DeployConfigSchema = z.object({
85
+ rules: z.array(RouteRuleSchema).min(1, "At least one rule is required"),
86
+ });
81
87
 
82
- /**
83
- * Prompt user for confirmation
84
- */
85
- async function confirm(question: string): Promise<boolean> {
86
- const answer = await prompt(`${question} (y/n)`, "n");
87
- return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
88
- }
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>;
89
92
 
90
93
  /**
91
- * Parse command-line arguments
94
+ * Find and parse deploy configuration file
92
95
  */
93
- async function getConfig(): Promise<DeployConfig> {
94
- const values = parseCliArgs();
95
-
96
- // Interactive mode if any required argument is missing
97
- const needsInteractive = !values.subdomain || !values.path || !values.dir;
98
-
99
- if (needsInteractive) {
100
- console.log("🚀 Interactive deployment setup\n");
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
+ }
101
115
  }
102
116
 
103
- let subdomain =
104
- values.subdomain || (await prompt("Subdomain name (e.g., 'myapp' for myapp.just-be.dev)"));
117
+ // Parse JSON file
118
+ const content = await Bun.file(configPath).text();
119
+ let parsed: unknown;
105
120
 
106
- // Validate subdomain format
107
- if (!isValidSubdomain(subdomain)) {
108
- console.error(
109
- "\n❌ Invalid subdomain format. Must be alphanumeric with hyphens, 1-63 characters."
110
- );
121
+ try {
122
+ parsed = JSON.parse(content);
123
+ } catch (error) {
124
+ console.error("Error: Failed to parse JSON config file");
125
+ console.error(error);
111
126
  process.exit(1);
112
127
  }
113
128
 
114
- const path = values.path || (await prompt("R2 path prefix (e.g., 'apps/myapp')"));
115
- const dir = values.dir || (await prompt("Local directory to upload", "./dist"));
116
-
117
- let spa = values.spa;
118
- let fallback = values.fallback;
119
-
120
- // Only prompt for spa/fallback if not provided
121
- if (needsInteractive && spa === undefined && fallback === undefined) {
122
- spa = await confirm("Enable SPA mode? (all routes serve index.html)");
123
- if (!spa) {
124
- const useFallback = await confirm("Use a custom fallback file for 404s?");
125
- if (useFallback) {
126
- fallback = await prompt("Fallback file name", "404.html");
127
- }
128
- }
129
- }
130
-
131
- // Validate with Zod schema
132
- const staticConfig: StaticConfig = {
133
- type: "static",
134
- path,
135
- ...(spa && { spa }),
136
- ...(fallback && { fallback }),
137
- };
138
-
139
- const result = StaticConfigSchema.safeParse(staticConfig);
129
+ // Validate with schema
130
+ const result = DeployConfigSchema.safeParse(parsed);
140
131
  if (!result.success) {
141
- console.error("\n❌ Invalid configuration:");
142
- console.error(result.error.format());
132
+ console.error("\nInvalid configuration:");
133
+ console.error(z.prettifyError(result.error));
143
134
  process.exit(1);
144
135
  }
145
136
 
146
- return {
147
- subdomain,
148
- path,
149
- dir,
150
- spa,
151
- fallback,
152
- };
137
+ return result.data;
153
138
  }
154
139
 
155
140
  /**
@@ -177,87 +162,280 @@ async function findFiles(dir: string): Promise<string[]> {
177
162
  /**
178
163
  * Upload a file to R2
179
164
  */
180
- async function uploadToR2(localPath: string, r2Key: string): Promise<void> {
181
- await $`wrangler r2 object put ${BUCKET_NAME}/${r2Key} --file ${localPath}`;
165
+ async function uploadToR2(localPath: string, r2Key: string): Promise<boolean> {
166
+ try {
167
+ await wrangler`r2 object put ${BUCKET_NAME}/${r2Key} --file ${localPath}`;
168
+ return true;
169
+ } catch (error) {
170
+ console.error(`\nFailed to upload ${localPath}:`, error);
171
+ return false;
172
+ }
182
173
  }
183
174
 
184
175
  /**
185
- * Create KV entry for subdomain routing
176
+ * Validate KV access by attempting to list keys
186
177
  */
187
- async function createKVEntry(subdomain: string, config: DeployConfig): Promise<void> {
188
- const routingConfig: StaticConfig = {
189
- type: "static",
190
- path: config.path,
191
- ...(config.spa && { spa: config.spa }),
192
- ...(config.fallback && { fallback: config.fallback }),
193
- };
178
+ async function validateKVAccess(): Promise<boolean> {
179
+ try {
180
+ await wrangler`kv key list --binding ROUTING_RULES --config ${WRANGLER_CONFIG}`.quiet();
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
194
186
 
195
- const configJson = JSON.stringify(routingConfig);
187
+ /**
188
+ * Get the current git branch name
189
+ */
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
+ }
196
198
 
197
- await $`wrangler kv key put --binding ROUTING_RULES ${subdomain} ${configJson} --config ${WRANGLER_CONFIG}`;
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, "");
198
209
  }
199
210
 
200
211
  /**
201
- * Main deployment function
212
+ * Create KV entry for subdomain routing
202
213
  */
203
- async function deploy() {
204
- const config = await getConfig();
205
-
206
- console.log("\n📦 Deploy Configuration:");
207
- console.log(` Subdomain: ${config.subdomain}.just-be.dev`);
208
- console.log(` R2 Path: ${config.path}`);
209
- console.log(` Local Directory: ${config.dir}`);
210
- if (config.spa) {
211
- console.log(` Mode: SPA (all routes serve index.html)`);
212
- } else if (config.fallback) {
213
- console.log(` Fallback: ${config.fallback}`);
214
+ async function createKVEntry(subdomain: string, routeConfig: RouteConfig): Promise<void> {
215
+ const configJson = JSON.stringify(routeConfig);
216
+ await wrangler`kv key put --binding ROUTING_RULES ${subdomain} ${configJson} --config ${WRANGLER_CONFIG}`;
217
+ }
218
+
219
+ /**
220
+ * Deploy a static site rule
221
+ */
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}`);
214
229
  }
215
- console.log();
216
230
 
217
231
  // Verify directory exists
218
232
  try {
219
- const dirStat = await stat(config.dir);
233
+ const dirStat = await stat(rule.dir);
220
234
  if (!dirStat.isDirectory()) {
221
- console.error(`❌ Error: ${config.dir} is not a directory`);
222
- process.exit(1);
235
+ throw new Error(`${rule.dir} is not a directory`);
223
236
  }
224
- } catch {
225
- console.error(`❌ Error: Directory ${config.dir} does not exist`);
226
- process.exit(1);
237
+ } catch (error) {
238
+ console.error(`Error: Directory ${rule.dir} does not exist or is not accessible`);
239
+ throw error;
227
240
  }
228
241
 
229
242
  // Find all files to upload
230
- console.log(`📂 Scanning files in: ${config.dir}\n`);
231
- const filePaths = await findFiles(config.dir);
232
- console.log(`Found ${filePaths.length} files to upload\n`);
243
+ s.start(`Scanning files in: ${rule.dir}`);
244
+ const filePaths = await findFiles(rule.dir);
245
+ s.stop(`Found ${filePaths.length} files to upload`);
233
246
 
234
247
  if (filePaths.length === 0) {
235
- console.error("No files found to upload");
236
- process.exit(1);
248
+ throw new Error("No files found to upload");
237
249
  }
238
250
 
239
251
  // Upload files to R2
240
252
  let uploadCount = 0;
253
+ const failedUploads: string[] = [];
254
+
241
255
  for (const filePath of filePaths) {
242
- const relativePath = relative(config.dir, filePath);
243
- const r2Key = `${config.path}/${relativePath}`;
256
+ const relativePath = relative(rule.dir, filePath);
257
+ const r2Key = `${rule.subdomain}/${relativePath}`;
258
+
259
+ s.start(`Uploading ${relativePath}`);
260
+ const success = await uploadToR2(filePath, r2Key);
261
+
262
+ if (success) {
263
+ uploadCount++;
264
+ s.stop(`Uploaded ${relativePath}`);
265
+ } else {
266
+ s.stop(`Failed to upload ${relativePath}`);
267
+ failedUploads.push(relativePath);
268
+ }
269
+ }
244
270
 
245
- console.log(`⬆️ ${relativePath} ${r2Key}`);
246
- await uploadToR2(filePath, r2Key);
247
- uploadCount++;
271
+ if (failedUploads.length > 0) {
272
+ throw new Error(`Failed to upload ${failedUploads.length} file(s)`);
248
273
  }
249
274
 
250
- console.log(`\n✅ Uploaded ${uploadCount} files to R2\n`);
275
+ console.log(`✓ Uploaded ${uploadCount} files to R2`);
251
276
 
252
- // Create KV entry for routing
253
- console.log(`🔧 Creating KV routing entry for subdomain: ${config.subdomain}`);
254
- await createKVEntry(config.subdomain, config);
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
+ };
255
283
 
256
- console.log(`\n✅ Deployment complete!`);
257
- console.log(`\n🌐 Your site is available at: https://${config.subdomain}.just-be.dev`);
284
+ s.start(`Creating KV routing entry`);
285
+ await createKVEntry(rule.subdomain, routeConfig);
286
+ s.stop(`✓ KV routing entry created`);
258
287
  }
259
288
 
260
- deploy().catch((error) => {
261
- console.error("\n❌ Fatal error:", error);
262
- process.exit(1);
263
- });
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.");
404
+ process.exit(1);
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
+ }
431
+
432
+ outro("\n✅ Deployment complete!\n\nDeployed sites:\n" + deployedSubdomains.join("\n"));
433
+ }
434
+
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,12 +1,18 @@
1
1
  {
2
2
  "name": "@just-be/deploy",
3
- "version": "0.1.1",
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
- "bin": "./index.ts",
6
+ "bin": {
7
+ "deploy": "index.ts"
8
+ },
7
9
  "files": [
8
- "index.ts"
10
+ "index.ts",
11
+ "schema.json"
9
12
  ],
13
+ "scripts": {
14
+ "generate-schema": "bun run scripts/generate-schema.ts"
15
+ },
10
16
  "keywords": [
11
17
  "cloudflare",
12
18
  "r2",
@@ -17,13 +23,19 @@
17
23
  "license": "MIT",
18
24
  "repository": {
19
25
  "type": "git",
20
- "url": "https://github.com/justbejyk/just-be.dev.git",
26
+ "url": "git+https://github.com/just-be-dev/just-be.dev.git",
21
27
  "directory": "packages/deploy"
22
28
  },
23
29
  "engines": {
24
30
  "bun": ">=1.0.0"
25
31
  },
26
32
  "dependencies": {
27
- "@just-be/wildcard": "0.1.0"
33
+ "@clack/prompts": "^0.11.0",
34
+ "@just-be/wildcard": "0.2.0",
35
+ "wrangler": "4.48.0",
36
+ "zod": "^4.1.13"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
28
40
  }
29
41
  }
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
+ }