@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.
- package/README.md +158 -53
- package/index.ts +344 -166
- package/package.json +18 -6
- 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
|
|
|
@@ -24,83 +24,186 @@ bun install -g @just-be/deploy
|
|
|
24
24
|
|
|
25
25
|
## Usage
|
|
26
26
|
|
|
27
|
-
###
|
|
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
|
-
|
|
51
|
+
Then run:
|
|
30
52
|
|
|
31
53
|
```bash
|
|
32
54
|
bunx @just-be/deploy
|
|
33
55
|
```
|
|
34
56
|
|
|
35
|
-
|
|
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
|
-
|
|
59
|
+
```bash
|
|
60
|
+
bunx @just-be/deploy path/to/config.json
|
|
61
|
+
```
|
|
43
62
|
|
|
44
|
-
|
|
63
|
+
## Rule Types
|
|
45
64
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
79
|
+
**Options:**
|
|
51
80
|
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
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
|
-
###
|
|
88
|
+
### Redirect
|
|
59
89
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
94
|
-
2. **
|
|
95
|
-
3. **
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
-
|
|
113
|
-
-
|
|
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
|
|
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
|
|
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
48
|
const BUCKET_NAME = "content-bucket";
|
|
23
49
|
const WRANGLER_CONFIG = "services/wildcard/wrangler.toml";
|
|
24
50
|
|
|
25
51
|
/**
|
|
26
|
-
*
|
|
52
|
+
* Run wrangler command using bunx to ensure it resolves from package dependencies
|
|
27
53
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
57
|
-
|
|
70
|
+
const RedirectRuleSchema = RedirectConfigSchema.safeExtend({
|
|
71
|
+
subdomain: subdomain(),
|
|
72
|
+
});
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
subdomain:
|
|
61
|
-
|
|
62
|
-
}
|
|
74
|
+
const RewriteRuleSchema = RewriteConfigSchema.safeExtend({
|
|
75
|
+
subdomain: subdomain(),
|
|
76
|
+
});
|
|
63
77
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
*
|
|
94
|
+
* Find and parse deploy configuration file
|
|
92
95
|
*/
|
|
93
|
-
async function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
117
|
+
// Parse JSON file
|
|
118
|
+
const content = await Bun.file(configPath).text();
|
|
119
|
+
let parsed: unknown;
|
|
105
120
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
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("\
|
|
142
|
-
console.error(result.error
|
|
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<
|
|
181
|
-
|
|
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
|
-
*
|
|
176
|
+
* Validate KV access by attempting to list keys
|
|
186
177
|
*/
|
|
187
|
-
async function
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
212
|
+
* Create KV entry for subdomain routing
|
|
202
213
|
*/
|
|
203
|
-
async function
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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(
|
|
233
|
+
const dirStat = await stat(rule.dir);
|
|
220
234
|
if (!dirStat.isDirectory()) {
|
|
221
|
-
|
|
222
|
-
process.exit(1);
|
|
235
|
+
throw new Error(`${rule.dir} is not a directory`);
|
|
223
236
|
}
|
|
224
|
-
} catch {
|
|
225
|
-
console.error(
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
const filePaths = await findFiles(
|
|
232
|
-
|
|
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
|
-
|
|
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(
|
|
243
|
-
const r2Key = `${
|
|
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
|
-
|
|
246
|
-
|
|
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(
|
|
275
|
+
console.log(`✓ Uploaded ${uploadCount} files to R2`);
|
|
251
276
|
|
|
252
|
-
// Create KV entry
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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.
|
|
4
|
-
"description": "Deploy static
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Deploy static sites to Cloudflare R2 with subdomain routing",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin":
|
|
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/
|
|
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
|
-
"@
|
|
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
|
+
}
|