@just-be/deploy 0.2.0 → 0.4.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/README.md +181 -58
- package/index.ts +304 -195
- package/package.json +10 -5
- package/schema.json +89 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @just-be/deploy
|
|
2
2
|
|
|
3
|
-
Deploy static
|
|
3
|
+
Deploy static sites and setup routing for the wildcard subdomain service.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
@@ -8,6 +8,30 @@ Deploy static files to Cloudflare R2 and configure subdomain routing in KV for w
|
|
|
8
8
|
- [Wrangler](https://developers.cloudflare.com/workers/wrangler/) CLI configured with Cloudflare credentials
|
|
9
9
|
- A Cloudflare Workers wildcard subdomain service with R2 and KV configured
|
|
10
10
|
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
The deploy script requires access to:
|
|
14
|
+
|
|
15
|
+
- **KV namespace** for routing rules (default: `6118ae3b937c4883b3c582dfef8a0c05`)
|
|
16
|
+
- **R2 bucket** for static file storage (default: `content-bucket`)
|
|
17
|
+
|
|
18
|
+
### Environment Variables
|
|
19
|
+
|
|
20
|
+
To use different resources, set these environment variables:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export KV_NAMESPACE_ID="your-kv-namespace-id"
|
|
24
|
+
export R2_BUCKET_NAME="your-bucket-name"
|
|
25
|
+
bunx @just-be/deploy
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
You can find your KV namespace ID and R2 buckets by running:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
wrangler kv namespace list
|
|
32
|
+
wrangler r2 bucket list
|
|
33
|
+
```
|
|
34
|
+
|
|
11
35
|
## Installation
|
|
12
36
|
|
|
13
37
|
No installation needed! Run directly with `bunx`:
|
|
@@ -24,100 +48,199 @@ bun install -g @just-be/deploy
|
|
|
24
48
|
|
|
25
49
|
## Usage
|
|
26
50
|
|
|
27
|
-
###
|
|
51
|
+
### Basic Usage
|
|
52
|
+
|
|
53
|
+
Create a `deploy.json` file in your project:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"rules": [
|
|
58
|
+
{
|
|
59
|
+
"subdomain": "myapp",
|
|
60
|
+
"type": "static",
|
|
61
|
+
"path": "apps/myapp",
|
|
62
|
+
"dir": "./dist",
|
|
63
|
+
"spa": true
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"subdomain": "old-site",
|
|
67
|
+
"type": "redirect",
|
|
68
|
+
"url": "https://new-site.com",
|
|
69
|
+
"permanent": true
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
28
74
|
|
|
29
|
-
|
|
75
|
+
Then run:
|
|
30
76
|
|
|
31
77
|
```bash
|
|
32
78
|
bunx @just-be/deploy
|
|
33
79
|
```
|
|
34
80
|
|
|
35
|
-
|
|
81
|
+
### Specify Config Path
|
|
36
82
|
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
- Whether to enable SPA mode
|
|
41
|
-
- Whether to use a custom fallback file
|
|
83
|
+
```bash
|
|
84
|
+
bunx @just-be/deploy path/to/config.json
|
|
85
|
+
```
|
|
42
86
|
|
|
43
|
-
|
|
87
|
+
## Rule Types
|
|
44
88
|
|
|
45
|
-
|
|
89
|
+
### Static Site
|
|
46
90
|
|
|
47
|
-
|
|
48
|
-
|
|
91
|
+
Deploy static files to R2 and serve them via a subdomain.
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"subdomain": "myapp",
|
|
96
|
+
"type": "static",
|
|
97
|
+
"path": "apps/myapp",
|
|
98
|
+
"dir": "./dist",
|
|
99
|
+
"spa": true
|
|
100
|
+
}
|
|
49
101
|
```
|
|
50
102
|
|
|
51
|
-
|
|
103
|
+
**Options:**
|
|
52
104
|
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
105
|
+
- `subdomain` (required): Subdomain name (e.g., "myapp" for myapp.just-be.dev)
|
|
106
|
+
- `type` (required): Must be "static"
|
|
107
|
+
- `path` (required): R2 path prefix where files will be stored
|
|
108
|
+
- `dir` (required): Local directory containing files to upload
|
|
109
|
+
- `spa` (optional): Enable SPA mode - all routes serve index.html
|
|
110
|
+
- `fallback` (optional): Custom 404 file (cannot be used with `spa`)
|
|
58
111
|
|
|
59
|
-
###
|
|
112
|
+
### Redirect
|
|
60
113
|
|
|
61
|
-
|
|
114
|
+
Configure an HTTP redirect from a subdomain to another URL.
|
|
62
115
|
|
|
63
|
-
```
|
|
64
|
-
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"subdomain": "old-site",
|
|
119
|
+
"type": "redirect",
|
|
120
|
+
"url": "https://new-site.com",
|
|
121
|
+
"permanent": true
|
|
122
|
+
}
|
|
65
123
|
```
|
|
66
124
|
|
|
67
|
-
**
|
|
125
|
+
**Options:**
|
|
68
126
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
--dir=./dist
|
|
74
|
-
```
|
|
127
|
+
- `subdomain` (required): Subdomain name
|
|
128
|
+
- `type` (required): Must be "redirect"
|
|
129
|
+
- `url` (required): Target URL (must be http/https)
|
|
130
|
+
- `permanent` (optional): Use 301 (permanent) redirect instead of 302 (temporary)
|
|
75
131
|
|
|
76
|
-
|
|
132
|
+
### Rewrite (Reverse Proxy)
|
|
77
133
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
134
|
+
Proxy requests from a subdomain to another URL.
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"subdomain": "api",
|
|
139
|
+
"type": "rewrite",
|
|
140
|
+
"url": "https://api.example.com",
|
|
141
|
+
"allowedMethods": ["GET", "POST", "PUT", "DELETE"]
|
|
142
|
+
}
|
|
84
143
|
```
|
|
85
144
|
|
|
86
|
-
**
|
|
145
|
+
**Options:**
|
|
146
|
+
|
|
147
|
+
- `subdomain` (required): Subdomain name
|
|
148
|
+
- `type` (required): Must be "rewrite"
|
|
149
|
+
- `url` (required): Target URL to proxy to (must be http/https)
|
|
150
|
+
- `allowedMethods` (optional): HTTP methods allowed (default: ["GET", "HEAD", "OPTIONS"])
|
|
151
|
+
|
|
152
|
+
## Examples
|
|
153
|
+
|
|
154
|
+
### Multiple Static Sites
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"rules": [
|
|
159
|
+
{
|
|
160
|
+
"subdomain": "portfolio",
|
|
161
|
+
"type": "static",
|
|
162
|
+
"path": "sites/portfolio",
|
|
163
|
+
"dir": "./build",
|
|
164
|
+
"fallback": "404.html"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"subdomain": "docs",
|
|
168
|
+
"type": "static",
|
|
169
|
+
"path": "sites/docs",
|
|
170
|
+
"dir": "./out",
|
|
171
|
+
"spa": true
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"subdomain": "blog",
|
|
175
|
+
"type": "static",
|
|
176
|
+
"path": "sites/blog",
|
|
177
|
+
"dir": "./dist"
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
87
182
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
183
|
+
### Mixed Deployments
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"rules": [
|
|
188
|
+
{
|
|
189
|
+
"subdomain": "app",
|
|
190
|
+
"type": "static",
|
|
191
|
+
"path": "apps/main",
|
|
192
|
+
"dir": "./dist",
|
|
193
|
+
"spa": true
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"subdomain": "legacy",
|
|
197
|
+
"type": "redirect",
|
|
198
|
+
"url": "https://app.just-be.dev",
|
|
199
|
+
"permanent": true
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"subdomain": "api",
|
|
203
|
+
"type": "rewrite",
|
|
204
|
+
"url": "https://backend.example.com",
|
|
205
|
+
"allowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
}
|
|
94
209
|
```
|
|
95
210
|
|
|
211
|
+
## Editor Support
|
|
212
|
+
|
|
213
|
+
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.
|
|
214
|
+
|
|
96
215
|
## How It Works
|
|
97
216
|
|
|
98
|
-
1. **Parses
|
|
99
|
-
2. **
|
|
100
|
-
3. **
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
217
|
+
1. **Parses configuration** from `deploy.json`
|
|
218
|
+
2. **Validates** all rules using Zod schemas from `@just-be/wildcard`
|
|
219
|
+
3. **For static sites**:
|
|
220
|
+
- Scans the local directory for all files
|
|
221
|
+
- Uploads each file to R2 at `content-bucket/{path}/{relative-path}`
|
|
222
|
+
- Creates a KV entry with routing configuration
|
|
223
|
+
4. **For redirects/rewrites**:
|
|
224
|
+
- Creates a KV entry with the routing configuration
|
|
225
|
+
5. **Configures** the wildcard service to route requests for each subdomain
|
|
105
226
|
|
|
106
|
-
##
|
|
227
|
+
## Wildcard Service Requirements
|
|
228
|
+
|
|
229
|
+
The script works with any wildcard service that has:
|
|
107
230
|
|
|
108
|
-
|
|
231
|
+
- **R2 Bucket**: For storing static files (default: `content-bucket`)
|
|
232
|
+
- **KV Namespace**: For routing rules (default: `6118ae3b937c4883b3c582dfef8a0c05`)
|
|
109
233
|
|
|
110
|
-
-
|
|
111
|
-
- **KV Binding**: `ROUTING_RULES`
|
|
112
|
-
- **Wrangler Config**: `services/wildcard/wrangler.toml`
|
|
234
|
+
No local `wrangler.toml` file is required - the script accesses Cloudflare resources directly via the Wrangler CLI.
|
|
113
235
|
|
|
114
236
|
## Validation
|
|
115
237
|
|
|
116
238
|
Configuration is validated using Zod schemas to ensure:
|
|
117
239
|
|
|
240
|
+
- Valid subdomain format (alphanumeric with hyphens, 1-63 characters)
|
|
118
241
|
- SPA mode and fallback file are not used together
|
|
119
|
-
-
|
|
120
|
-
-
|
|
242
|
+
- Required fields are present for each rule type
|
|
243
|
+
- URLs are safe (http/https only)
|
|
121
244
|
|
|
122
245
|
## Related Packages
|
|
123
246
|
|
package/index.ts
CHANGED
|
@@ -1,26 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Deploy static
|
|
4
|
+
* Deploy static sites and setup routing for the wildcard subdomain service
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
|
-
* bunx @just-be/deploy
|
|
8
|
-
* bunx @just-be/deploy
|
|
9
|
-
* bunx @just-be/deploy
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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 {
|
|
40
|
+
import {
|
|
41
|
+
StaticConfigSchema,
|
|
42
|
+
RedirectConfigSchema,
|
|
43
|
+
RewriteConfigSchema,
|
|
44
|
+
type RouteConfig,
|
|
45
|
+
subdomain,
|
|
46
|
+
} from "@just-be/wildcard";
|
|
21
47
|
|
|
22
|
-
const BUCKET_NAME = "content-bucket";
|
|
23
|
-
const
|
|
48
|
+
const BUCKET_NAME = process.env.R2_BUCKET_NAME || "content-bucket";
|
|
49
|
+
const KV_NAMESPACE_ID = process.env.KV_NAMESPACE_ID || "6118ae3b937c4883b3c582dfef8a0c05";
|
|
24
50
|
|
|
25
51
|
/**
|
|
26
52
|
* Run wrangler command using bunx to ensure it resolves from package dependencies
|
|
@@ -31,138 +57,84 @@ function wrangler(command: TemplateStringsArray, ...values: unknown[]) {
|
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
/**
|
|
34
|
-
*
|
|
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
|
|
37
|
-
subdomain:
|
|
38
|
-
|
|
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
|
-
|
|
70
|
+
const RedirectRuleSchema = RedirectConfigSchema.safeExtend({
|
|
71
|
+
subdomain: subdomain(),
|
|
72
|
+
});
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
65
|
-
|
|
78
|
+
const RouteRuleSchema = z.discriminatedUnion("type", [
|
|
79
|
+
StaticRuleSchema,
|
|
80
|
+
RedirectRuleSchema,
|
|
81
|
+
RewriteRuleSchema,
|
|
82
|
+
]);
|
|
66
83
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
94
|
+
* Find and parse deploy configuration file
|
|
74
95
|
*/
|
|
75
|
-
async function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
145
|
-
const
|
|
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
|
|
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 {
|
|
@@ -206,7 +177,7 @@ async function uploadToR2(localPath: string, r2Key: string): Promise<boolean> {
|
|
|
206
177
|
*/
|
|
207
178
|
async function validateKVAccess(): Promise<boolean> {
|
|
208
179
|
try {
|
|
209
|
-
await wrangler`kv key list --
|
|
180
|
+
await wrangler`kv key list --namespace-id ${KV_NAMESPACE_ID}`.quiet();
|
|
210
181
|
return true;
|
|
211
182
|
} catch {
|
|
212
183
|
return false;
|
|
@@ -214,78 +185,76 @@ async function validateKVAccess(): Promise<boolean> {
|
|
|
214
185
|
}
|
|
215
186
|
|
|
216
187
|
/**
|
|
217
|
-
*
|
|
188
|
+
* Get the current git branch name
|
|
218
189
|
*/
|
|
219
|
-
async function
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
|
229
|
-
|
|
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);
|
|
216
|
+
await wrangler`kv key put --namespace-id ${KV_NAMESPACE_ID} ${subdomain} ${configJson}`;
|
|
230
217
|
}
|
|
231
218
|
|
|
232
219
|
/**
|
|
233
|
-
*
|
|
220
|
+
* Deploy a static site rule
|
|
234
221
|
*/
|
|
235
|
-
async function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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(
|
|
233
|
+
const dirStat = await stat(rule.dir);
|
|
252
234
|
if (!dirStat.isDirectory()) {
|
|
253
|
-
|
|
254
|
-
process.exit(1);
|
|
235
|
+
throw new Error(`${rule.dir} is not a directory`);
|
|
255
236
|
}
|
|
256
|
-
} catch {
|
|
257
|
-
console.error(`Error: Directory ${
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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(
|
|
288
|
-
const r2Key = `${
|
|
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
|
-
|
|
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(
|
|
275
|
+
console.log(`✓ Uploaded ${uploadCount} files to R2`);
|
|
312
276
|
|
|
313
|
-
// Create KV entry
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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(
|
|
432
|
+
outro("\n✅ Deployment complete!\n\nDeployed sites:\n" + deployedSubdomains.join("\n"));
|
|
327
433
|
}
|
|
328
434
|
|
|
329
|
-
deploy()
|
|
330
|
-
|
|
331
|
-
|
|
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.
|
|
4
|
-
"description": "Deploy static
|
|
3
|
+
"version": "0.4.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.
|
|
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
|
+
}
|