flarepilot 0.1.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/package.json +32 -0
- package/src/cli.js +223 -0
- package/src/commands/apps.js +139 -0
- package/src/commands/auth.js +91 -0
- package/src/commands/config.js +225 -0
- package/src/commands/deploy.js +289 -0
- package/src/commands/doctor.js +93 -0
- package/src/commands/domains.js +273 -0
- package/src/commands/logs.js +100 -0
- package/src/commands/open.js +48 -0
- package/src/commands/ps.js +86 -0
- package/src/commands/scale.js +158 -0
- package/src/lib/bundle.js +70 -0
- package/src/lib/cf.js +450 -0
- package/src/lib/docker.js +33 -0
- package/src/lib/link.js +33 -0
- package/src/lib/output.js +102 -0
- package/worker-template/package.json +9 -0
- package/worker-template/src/index.js +103 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getConfig,
|
|
3
|
+
getAppConfig,
|
|
4
|
+
pushAppConfig,
|
|
5
|
+
getRegistryCredentials,
|
|
6
|
+
uploadWorker,
|
|
7
|
+
buildWorkerMetadata,
|
|
8
|
+
getWorkersSubdomain,
|
|
9
|
+
enableWorkerSubdomain,
|
|
10
|
+
getDONamespaceId,
|
|
11
|
+
findContainerApp,
|
|
12
|
+
createContainerApp,
|
|
13
|
+
modifyContainerApp,
|
|
14
|
+
createRollout,
|
|
15
|
+
CF_REGISTRY,
|
|
16
|
+
} from "../lib/cf.js";
|
|
17
|
+
import { dockerBuild, dockerTag, dockerPush, dockerLogin } from "../lib/docker.js";
|
|
18
|
+
import { createInterface } from "readline";
|
|
19
|
+
import { getWorkerBundle, templateHash } from "../lib/bundle.js";
|
|
20
|
+
import { phase, status, success, hint, fatal, fmt, generateAppName } from "../lib/output.js";
|
|
21
|
+
import { readLink, linkApp } from "../lib/link.js";
|
|
22
|
+
|
|
23
|
+
function buildContainerConfig(appConfig) {
|
|
24
|
+
var cfg = {
|
|
25
|
+
image: appConfig.image,
|
|
26
|
+
observability: { logs: { enabled: appConfig.observability !== false } },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Explicit resources take priority over instance_type
|
|
30
|
+
if (appConfig.vcpu || appConfig.memory || appConfig.disk) {
|
|
31
|
+
if (appConfig.vcpu) cfg.vcpu = appConfig.vcpu;
|
|
32
|
+
if (appConfig.memory) cfg.memory_mib = appConfig.memory;
|
|
33
|
+
if (appConfig.disk) cfg.disk = { size_mb: appConfig.disk };
|
|
34
|
+
} else {
|
|
35
|
+
cfg.instance_type = appConfig.instanceType || "lite";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return cfg;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var VALID_HINTS = [
|
|
42
|
+
"wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export async function deploy(nameOrPath, path, options) {
|
|
46
|
+
var config = getConfig();
|
|
47
|
+
|
|
48
|
+
// If first arg looks like a path, shift args and auto-generate name
|
|
49
|
+
var name;
|
|
50
|
+
var dockerPath;
|
|
51
|
+
if (!nameOrPath) {
|
|
52
|
+
// No args — use linked name or auto-generate
|
|
53
|
+
name = readLink() || generateAppName();
|
|
54
|
+
dockerPath = ".";
|
|
55
|
+
} else if (nameOrPath.startsWith(".") || nameOrPath.startsWith("/") || nameOrPath.startsWith("~")) {
|
|
56
|
+
// First arg is a path — use linked name or auto-generate
|
|
57
|
+
name = readLink() || generateAppName();
|
|
58
|
+
dockerPath = nameOrPath;
|
|
59
|
+
} else {
|
|
60
|
+
name = nameOrPath;
|
|
61
|
+
dockerPath = path || ".";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var tag = options.tag || `${Date.now()}`;
|
|
65
|
+
var localTag = `flarepilot-${name}:${tag}`;
|
|
66
|
+
var remoteTag = `${CF_REGISTRY}/${config.accountId}/flarepilot-${name}:${tag}`;
|
|
67
|
+
|
|
68
|
+
// Load existing config from deployed worker (null on first deploy)
|
|
69
|
+
var appConfig;
|
|
70
|
+
try {
|
|
71
|
+
appConfig = await getAppConfig(config, name);
|
|
72
|
+
} catch {
|
|
73
|
+
appConfig = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
var isFirstDeploy = !appConfig;
|
|
77
|
+
|
|
78
|
+
if (appConfig) {
|
|
79
|
+
// Existing app — update image, merge any flags
|
|
80
|
+
appConfig.image = remoteTag;
|
|
81
|
+
appConfig.deployedAt = new Date().toISOString();
|
|
82
|
+
|
|
83
|
+
if (options.env) {
|
|
84
|
+
for (var v of options.env) {
|
|
85
|
+
var eq = v.indexOf("=");
|
|
86
|
+
if (eq !== -1) appConfig.env[v.substring(0, eq)] = v.substring(eq + 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (options.regions)
|
|
90
|
+
appConfig.regions = options.regions.split(",").map((r) => r.trim());
|
|
91
|
+
if (options.instances) appConfig.instances = options.instances;
|
|
92
|
+
if (options.port) appConfig.port = options.port;
|
|
93
|
+
if (options.sleep) appConfig.sleepAfter = options.sleep;
|
|
94
|
+
if (options.instanceType) appConfig.instanceType = options.instanceType;
|
|
95
|
+
if (options.vcpu) appConfig.vcpu = options.vcpu;
|
|
96
|
+
if (options.memory) appConfig.memory = options.memory;
|
|
97
|
+
if (options.disk) appConfig.disk = options.disk;
|
|
98
|
+
if (options.observability === false) appConfig.observability = false;
|
|
99
|
+
} else {
|
|
100
|
+
// First deploy — build config from flags + defaults
|
|
101
|
+
var env = {};
|
|
102
|
+
if (options.env) {
|
|
103
|
+
for (var v of options.env) {
|
|
104
|
+
var eq = v.indexOf("=");
|
|
105
|
+
if (eq !== -1) env[v.substring(0, eq)] = v.substring(eq + 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
var regions = options.regions
|
|
110
|
+
? options.regions.split(",").map((r) => r.trim())
|
|
111
|
+
: ["enam"];
|
|
112
|
+
|
|
113
|
+
for (var r of regions) {
|
|
114
|
+
if (!VALID_HINTS.includes(r)) {
|
|
115
|
+
fatal(
|
|
116
|
+
`Invalid region '${r}'.`,
|
|
117
|
+
`Valid regions: ${VALID_HINTS.join(", ")}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
appConfig = {
|
|
123
|
+
name,
|
|
124
|
+
regions,
|
|
125
|
+
instances: options.instances || 2,
|
|
126
|
+
port: options.port || 8080,
|
|
127
|
+
sleepAfter: options.sleep || "30s",
|
|
128
|
+
instanceType: options.instanceType || "lite",
|
|
129
|
+
vcpu: options.vcpu || undefined,
|
|
130
|
+
memory: options.memory || undefined,
|
|
131
|
+
disk: options.disk || undefined,
|
|
132
|
+
env,
|
|
133
|
+
domains: [],
|
|
134
|
+
image: remoteTag,
|
|
135
|
+
createdAt: new Date().toISOString(),
|
|
136
|
+
deployedAt: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Summary & confirmation ---
|
|
142
|
+
var instanceDesc = appConfig.vcpu
|
|
143
|
+
? `${appConfig.vcpu} vCPU, ${appConfig.memory || "default"} MiB`
|
|
144
|
+
: appConfig.instanceType || "lite";
|
|
145
|
+
|
|
146
|
+
process.stderr.write(`\n${fmt.bold("Deploy summary")}\n`);
|
|
147
|
+
process.stderr.write(`${fmt.dim("─".repeat(40))}\n`);
|
|
148
|
+
process.stderr.write(` ${fmt.bold("App:")} ${fmt.app(name)}${isFirstDeploy ? fmt.dim(" (new)") : ""}\n`);
|
|
149
|
+
process.stderr.write(` ${fmt.bold("Path:")} ${dockerPath}\n`);
|
|
150
|
+
process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
|
|
151
|
+
process.stderr.write(` ${fmt.bold("Regions:")} ${appConfig.regions.join(", ")}\n`);
|
|
152
|
+
process.stderr.write(` ${fmt.bold("Instances:")} ${appConfig.instances || 2} per region\n`);
|
|
153
|
+
process.stderr.write(` ${fmt.bold("Type:")} ${instanceDesc}\n`);
|
|
154
|
+
process.stderr.write(` ${fmt.bold("Port:")} ${appConfig.port || 8080}\n`);
|
|
155
|
+
process.stderr.write(` ${fmt.bold("Sleep:")} ${appConfig.sleepAfter || "30s"}\n`);
|
|
156
|
+
process.stderr.write(`${fmt.dim("─".repeat(40))}\n`);
|
|
157
|
+
|
|
158
|
+
if (!options.yes) {
|
|
159
|
+
var rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
160
|
+
var answer = await new Promise((resolve) =>
|
|
161
|
+
rl.question("\nProceed? [Y/n] ", resolve)
|
|
162
|
+
);
|
|
163
|
+
rl.close();
|
|
164
|
+
if (answer && !answer.match(/^y(es)?$/i)) {
|
|
165
|
+
process.stderr.write("Deploy cancelled.\n");
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 1. Build Docker image
|
|
171
|
+
phase("Building image");
|
|
172
|
+
status(`${localTag} for linux/amd64`);
|
|
173
|
+
dockerBuild(dockerPath, localTag);
|
|
174
|
+
|
|
175
|
+
// 2. Push to Cloudflare Registry
|
|
176
|
+
phase("Pushing to Cloudflare Registry");
|
|
177
|
+
status("Authenticating with registry.cloudflare.com...");
|
|
178
|
+
var creds = await getRegistryCredentials(config);
|
|
179
|
+
dockerLogin(CF_REGISTRY, creds.username, creds.password);
|
|
180
|
+
status(`Pushing ${remoteTag}...`);
|
|
181
|
+
dockerTag(localTag, remoteTag);
|
|
182
|
+
dockerPush(remoteTag);
|
|
183
|
+
|
|
184
|
+
// 3. Deploy worker
|
|
185
|
+
phase("Deploying worker");
|
|
186
|
+
var scriptName = `flarepilot-${name}`;
|
|
187
|
+
var currentHash = templateHash();
|
|
188
|
+
var needsWorkerUpload = isFirstDeploy || appConfig.templateHash !== currentHash;
|
|
189
|
+
|
|
190
|
+
if (needsWorkerUpload) {
|
|
191
|
+
status("Bundling worker template...");
|
|
192
|
+
var bundledCode = getWorkerBundle();
|
|
193
|
+
appConfig.templateHash = currentHash;
|
|
194
|
+
status(`Uploading ${scriptName}...`);
|
|
195
|
+
var metadata = buildWorkerMetadata(appConfig, { firstDeploy: isFirstDeploy });
|
|
196
|
+
await uploadWorker(config, scriptName, bundledCode, metadata);
|
|
197
|
+
} else {
|
|
198
|
+
status("Updating app config...");
|
|
199
|
+
await pushAppConfig(config, name, appConfig);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 4. Deploy container application
|
|
203
|
+
phase("Deploying container");
|
|
204
|
+
|
|
205
|
+
status("Resolving DO namespace...");
|
|
206
|
+
var namespaceId = await getDONamespaceId(config, scriptName, "AppContainer");
|
|
207
|
+
if (!namespaceId) {
|
|
208
|
+
fatal(
|
|
209
|
+
"Could not find Durable Object namespace for AppContainer.",
|
|
210
|
+
"The worker upload may have failed. Try again."
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
var existingApp = await findContainerApp(config, scriptName);
|
|
215
|
+
|
|
216
|
+
var maxInstances = (appConfig.regions?.length || 1) * (appConfig.instances || 2);
|
|
217
|
+
|
|
218
|
+
if (existingApp) {
|
|
219
|
+
// Update max_instances if changed
|
|
220
|
+
if (existingApp.max_instances !== maxInstances) {
|
|
221
|
+
status("Updating max instances...");
|
|
222
|
+
await modifyContainerApp(config, existingApp.id, {
|
|
223
|
+
max_instances: maxInstances,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// Roll out new image + config
|
|
227
|
+
status("Rolling out new version...");
|
|
228
|
+
await createRollout(config, existingApp.id, {
|
|
229
|
+
description: `Deploy ${remoteTag}`,
|
|
230
|
+
strategy: "rolling",
|
|
231
|
+
kind: "full_auto",
|
|
232
|
+
step_percentage: 100,
|
|
233
|
+
target_configuration: buildContainerConfig(appConfig),
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
// Create new container app
|
|
237
|
+
status("Creating container application...");
|
|
238
|
+
await createContainerApp(config, {
|
|
239
|
+
name: scriptName,
|
|
240
|
+
scheduling_policy: "default",
|
|
241
|
+
instances: 0,
|
|
242
|
+
max_instances: maxInstances,
|
|
243
|
+
configuration: buildContainerConfig(appConfig),
|
|
244
|
+
durable_objects: {
|
|
245
|
+
namespace_id: namespaceId,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 5. Enable workers.dev route
|
|
251
|
+
status("Enabling workers.dev subdomain...");
|
|
252
|
+
try {
|
|
253
|
+
await enableWorkerSubdomain(config, scriptName);
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
// 6. Resolve URL and report
|
|
257
|
+
var subdomain = await getWorkersSubdomain(config);
|
|
258
|
+
var url = subdomain
|
|
259
|
+
? `https://flarepilot-${name}.${subdomain}.workers.dev`
|
|
260
|
+
: null;
|
|
261
|
+
|
|
262
|
+
if (options.json) {
|
|
263
|
+
console.log(
|
|
264
|
+
JSON.stringify(
|
|
265
|
+
{
|
|
266
|
+
name,
|
|
267
|
+
image: remoteTag,
|
|
268
|
+
url,
|
|
269
|
+
regions: appConfig.regions,
|
|
270
|
+
instances: appConfig.instances,
|
|
271
|
+
firstDeploy: isFirstDeploy,
|
|
272
|
+
},
|
|
273
|
+
null,
|
|
274
|
+
2
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
} else {
|
|
278
|
+
success(`App ${fmt.app(name)} deployed!`);
|
|
279
|
+
process.stderr.write(` ${fmt.bold("Name:")} ${fmt.app(name)}\n`);
|
|
280
|
+
process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
|
|
281
|
+
process.stderr.write(
|
|
282
|
+
` ${fmt.bold("URL:")} ${url ? fmt.url(url) : fmt.dim("(configure workers.dev subdomain to see URL)")}\n`
|
|
283
|
+
);
|
|
284
|
+
hint("Next", `flarepilot open ${name}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Link this directory to the app
|
|
288
|
+
linkApp(name);
|
|
289
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import {
|
|
6
|
+
cfApi,
|
|
7
|
+
tryGetConfig,
|
|
8
|
+
getWorkersSubdomain,
|
|
9
|
+
getRegistryCredentials,
|
|
10
|
+
} from "../lib/cf.js";
|
|
11
|
+
import kleur from "kleur";
|
|
12
|
+
|
|
13
|
+
var CONFIG_PATH = join(homedir(), ".flarepilot", "config.json");
|
|
14
|
+
|
|
15
|
+
var PASS = kleur.green("[ok]");
|
|
16
|
+
var FAIL = kleur.red("[!!]");
|
|
17
|
+
|
|
18
|
+
export async function doctor() {
|
|
19
|
+
process.stderr.write(`\n${kleur.bold("flarepilot doctor")}\n`);
|
|
20
|
+
process.stderr.write(`${kleur.dim("─".repeat(40))}\n\n`);
|
|
21
|
+
var allGood = true;
|
|
22
|
+
|
|
23
|
+
allGood =
|
|
24
|
+
check("Docker installed", () => {
|
|
25
|
+
execSync("docker --version", { stdio: "pipe" });
|
|
26
|
+
}) && allGood;
|
|
27
|
+
|
|
28
|
+
allGood =
|
|
29
|
+
check("Docker daemon running", () => {
|
|
30
|
+
execSync("docker info", { stdio: "pipe", timeout: 5000 });
|
|
31
|
+
}) && allGood;
|
|
32
|
+
|
|
33
|
+
allGood =
|
|
34
|
+
check("Auth config exists", () => {
|
|
35
|
+
if (!existsSync(CONFIG_PATH)) throw new Error("Not found");
|
|
36
|
+
}) && allGood;
|
|
37
|
+
|
|
38
|
+
var config = tryGetConfig();
|
|
39
|
+
if (config) {
|
|
40
|
+
allGood =
|
|
41
|
+
(await asyncCheck("API token valid", async () => {
|
|
42
|
+
await cfApi("GET", "/user/tokens/verify", null, config.apiToken);
|
|
43
|
+
})) && allGood;
|
|
44
|
+
|
|
45
|
+
allGood =
|
|
46
|
+
(await asyncCheck("Account accessible", async () => {
|
|
47
|
+
var res = await cfApi("GET", "/accounts", null, config.apiToken);
|
|
48
|
+
if (!res.result?.length) throw new Error("No accounts");
|
|
49
|
+
})) && allGood;
|
|
50
|
+
|
|
51
|
+
allGood =
|
|
52
|
+
(await asyncCheck("Workers subdomain configured", async () => {
|
|
53
|
+
var sub = await getWorkersSubdomain(config);
|
|
54
|
+
if (!sub) throw new Error("Not configured");
|
|
55
|
+
})) && allGood;
|
|
56
|
+
|
|
57
|
+
allGood =
|
|
58
|
+
(await asyncCheck("Registry credentials obtainable", async () => {
|
|
59
|
+
await getRegistryCredentials(config);
|
|
60
|
+
})) && allGood;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.stderr.write(`\n${kleur.dim("─".repeat(40))}\n`);
|
|
64
|
+
if (allGood) {
|
|
65
|
+
process.stderr.write(kleur.green("All checks passed.\n\n"));
|
|
66
|
+
} else {
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
kleur.yellow("Some checks failed. Fix the issues above and re-run.\n\n")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function check(label, fn) {
|
|
74
|
+
try {
|
|
75
|
+
fn();
|
|
76
|
+
process.stderr.write(` ${PASS} ${label}\n`);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
process.stderr.write(` ${FAIL} ${label}\n`);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function asyncCheck(label, fn) {
|
|
85
|
+
try {
|
|
86
|
+
await fn();
|
|
87
|
+
process.stderr.write(` ${PASS} ${label}\n`);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
process.stderr.write(` ${FAIL} ${label}\n`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
import {
|
|
3
|
+
getConfig,
|
|
4
|
+
getAppConfig,
|
|
5
|
+
pushAppConfig,
|
|
6
|
+
getWorkersSubdomain,
|
|
7
|
+
listZones,
|
|
8
|
+
findZoneForHostname,
|
|
9
|
+
addWorkerDomain,
|
|
10
|
+
removeWorkerDomain,
|
|
11
|
+
listWorkerDomainsForService,
|
|
12
|
+
listDnsRecords,
|
|
13
|
+
createDnsRecord,
|
|
14
|
+
deleteDnsRecord,
|
|
15
|
+
} from "../lib/cf.js";
|
|
16
|
+
import { phase, status, success, fatal, hint, fmt } from "../lib/output.js";
|
|
17
|
+
import { resolveAppName } from "../lib/link.js";
|
|
18
|
+
import kleur from "kleur";
|
|
19
|
+
|
|
20
|
+
function prompt(rl, question) {
|
|
21
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function domainsList(name, options) {
|
|
25
|
+
name = resolveAppName(name);
|
|
26
|
+
var config = getConfig();
|
|
27
|
+
var scriptName = `flarepilot-${name}`;
|
|
28
|
+
|
|
29
|
+
var subdomain = await getWorkersSubdomain(config);
|
|
30
|
+
var defaultDomain = subdomain
|
|
31
|
+
? `flarepilot-${name}.${subdomain}.workers.dev`
|
|
32
|
+
: null;
|
|
33
|
+
|
|
34
|
+
// Get live domains from CF API
|
|
35
|
+
var domains = await listWorkerDomainsForService(config, scriptName);
|
|
36
|
+
|
|
37
|
+
if (options.json) {
|
|
38
|
+
console.log(
|
|
39
|
+
JSON.stringify(
|
|
40
|
+
{
|
|
41
|
+
default: defaultDomain,
|
|
42
|
+
custom: domains.map((d) => d.hostname),
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (defaultDomain) {
|
|
52
|
+
console.log(
|
|
53
|
+
`\n${fmt.bold("Default:")} ${fmt.url(`https://${defaultDomain}`)}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (domains.length === 0) {
|
|
58
|
+
console.log(`${fmt.bold("Custom:")} ${fmt.dim("(none)")}`);
|
|
59
|
+
hint("Add", `flarepilot domains add ${name}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`\n${fmt.bold("Custom domains:")}`);
|
|
64
|
+
for (var d of domains) {
|
|
65
|
+
console.log(` ${d.hostname}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function domainsAdd(args) {
|
|
70
|
+
var name, domain;
|
|
71
|
+
|
|
72
|
+
// Parse args: 0 args = interactive, 1 arg = domain or name, 2 args = name + domain
|
|
73
|
+
if (args.length === 2) {
|
|
74
|
+
name = args[0];
|
|
75
|
+
domain = args[1];
|
|
76
|
+
} else if (args.length === 1) {
|
|
77
|
+
// Could be a domain or just an app name — check if it looks like a domain
|
|
78
|
+
if (args[0].includes(".")) {
|
|
79
|
+
name = resolveAppName(null);
|
|
80
|
+
domain = args[0];
|
|
81
|
+
} else {
|
|
82
|
+
name = args[0];
|
|
83
|
+
domain = null;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
name = resolveAppName(null);
|
|
87
|
+
domain = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var config = getConfig();
|
|
91
|
+
var scriptName = `flarepilot-${name}`;
|
|
92
|
+
|
|
93
|
+
var appConfig = await getAppConfig(config, name);
|
|
94
|
+
if (!appConfig) {
|
|
95
|
+
fatal(
|
|
96
|
+
`App ${fmt.app(name)} not found.`,
|
|
97
|
+
`Run ${fmt.cmd(`flarepilot deploy ${name} .`)} first.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fetch zones
|
|
102
|
+
status("Loading zones...");
|
|
103
|
+
var zones = await listZones(config);
|
|
104
|
+
|
|
105
|
+
if (zones.length === 0) {
|
|
106
|
+
fatal(
|
|
107
|
+
"No active zones found in this account.",
|
|
108
|
+
"Add a domain to your Cloudflare account first."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
113
|
+
var zone;
|
|
114
|
+
|
|
115
|
+
if (domain) {
|
|
116
|
+
// Domain provided — find matching zone
|
|
117
|
+
zone = findZoneForHostname(zones, domain);
|
|
118
|
+
if (!zone) {
|
|
119
|
+
rl.close();
|
|
120
|
+
var zoneList = zones.map((z) => ` ${z.name}`).join("\n");
|
|
121
|
+
fatal(
|
|
122
|
+
`No zone found for '${domain}'.`,
|
|
123
|
+
`Available zones:\n${zoneList}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
// Interactive — pick zone
|
|
128
|
+
process.stderr.write(`\n${kleur.bold("Available zones:")}\n\n`);
|
|
129
|
+
for (var i = 0; i < zones.length; i++) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
` ${kleur.bold(`[${i + 1}]`)} ${zones[i].name}\n`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
process.stderr.write("\n");
|
|
135
|
+
|
|
136
|
+
var zoneChoice = await prompt(rl, `Select zone [1-${zones.length}]: `);
|
|
137
|
+
var zoneIdx = parseInt(zoneChoice, 10) - 1;
|
|
138
|
+
if (isNaN(zoneIdx) || zoneIdx < 0 || zoneIdx >= zones.length) {
|
|
139
|
+
rl.close();
|
|
140
|
+
fatal("Invalid selection.");
|
|
141
|
+
}
|
|
142
|
+
zone = zones[zoneIdx];
|
|
143
|
+
|
|
144
|
+
// Pick root or subdomain
|
|
145
|
+
process.stderr.write(`\n${kleur.bold("Route type:")}\n\n`);
|
|
146
|
+
process.stderr.write(` ${kleur.bold("[1]")} Root domain (${zone.name})\n`);
|
|
147
|
+
process.stderr.write(` ${kleur.bold("[2]")} Subdomain (*.${zone.name})\n`);
|
|
148
|
+
process.stderr.write("\n");
|
|
149
|
+
|
|
150
|
+
var routeChoice = await prompt(rl, "Select [1-2]: ");
|
|
151
|
+
|
|
152
|
+
if (routeChoice.trim() === "1") {
|
|
153
|
+
domain = zone.name;
|
|
154
|
+
} else if (routeChoice.trim() === "2") {
|
|
155
|
+
var sub = await prompt(rl, `Subdomain: ${fmt.dim("___." + zone.name + " → ")} `);
|
|
156
|
+
sub = (sub || "").trim();
|
|
157
|
+
if (!sub) {
|
|
158
|
+
rl.close();
|
|
159
|
+
fatal("No subdomain provided.");
|
|
160
|
+
}
|
|
161
|
+
domain = `${sub}.${zone.name}`;
|
|
162
|
+
} else {
|
|
163
|
+
rl.close();
|
|
164
|
+
fatal("Invalid selection.");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
rl.close();
|
|
169
|
+
|
|
170
|
+
// Check for existing DNS records — never overwrite
|
|
171
|
+
status(`Checking existing DNS records for ${domain}...`);
|
|
172
|
+
var existing = await listDnsRecords(config, zone.id, { name: domain });
|
|
173
|
+
|
|
174
|
+
if (existing.length > 0) {
|
|
175
|
+
var types = existing.map((r) => `${r.type} → ${r.content}`).join("\n ");
|
|
176
|
+
fatal(
|
|
177
|
+
`DNS record already exists for ${domain}.`,
|
|
178
|
+
`Existing records:\n ${types}\n\nRemove the existing record first, or choose a different domain.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Attach domain to worker via CF API first (validates no external conflicts)
|
|
183
|
+
status(`Attaching ${domain} to ${scriptName} (zone: ${zone.name})...`);
|
|
184
|
+
try {
|
|
185
|
+
await addWorkerDomain(config, scriptName, domain, zone.id);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e.message.includes("already has externally managed DNS records")) {
|
|
188
|
+
fatal(
|
|
189
|
+
`DNS record already exists for ${domain} (externally managed).`,
|
|
190
|
+
"Remove the existing DNS record first, or choose a different domain."
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Create CNAME record pointing to workers.dev (only after domain is attached)
|
|
197
|
+
var subdomain = await getWorkersSubdomain(config);
|
|
198
|
+
var target = subdomain ? `flarepilot-${name}.${subdomain}.workers.dev` : null;
|
|
199
|
+
|
|
200
|
+
if (target) {
|
|
201
|
+
status(`Creating CNAME ${domain} → ${target}...`);
|
|
202
|
+
try {
|
|
203
|
+
await createDnsRecord(config, zone.id, {
|
|
204
|
+
type: "CNAME",
|
|
205
|
+
name: domain,
|
|
206
|
+
content: target,
|
|
207
|
+
proxied: true,
|
|
208
|
+
});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// Not fatal — worker domain is already attached, CNAME is optional
|
|
211
|
+
if (!e.message.includes("already exists")) {
|
|
212
|
+
process.stderr.write(` ${fmt.dim(`Warning: could not create CNAME: ${e.message}`)}\n`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Update app config metadata
|
|
218
|
+
if (!appConfig.domains) appConfig.domains = [];
|
|
219
|
+
if (!appConfig.domains.includes(domain)) {
|
|
220
|
+
appConfig.domains.push(domain);
|
|
221
|
+
await pushAppConfig(config, name, appConfig);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
success(`Domain ${fmt.bold(domain)} added to ${fmt.app(name)}.`);
|
|
225
|
+
process.stderr.write(` ${fmt.url(`https://${domain}`)}\n`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function domainsRemove(args) {
|
|
229
|
+
// 1 arg = domain (resolve name from link). 2 args = name + domain.
|
|
230
|
+
var name, domain;
|
|
231
|
+
if (args.length === 2) {
|
|
232
|
+
name = args[0];
|
|
233
|
+
domain = args[1];
|
|
234
|
+
} else if (args.length === 1) {
|
|
235
|
+
name = resolveAppName(null);
|
|
236
|
+
domain = args[0];
|
|
237
|
+
} else {
|
|
238
|
+
fatal("Usage: flarepilot domains remove [name] <domain>");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
var config = getConfig();
|
|
242
|
+
|
|
243
|
+
var appConfig = await getAppConfig(config, name);
|
|
244
|
+
if (!appConfig) {
|
|
245
|
+
fatal(
|
|
246
|
+
`App ${fmt.app(name)} not found.`,
|
|
247
|
+
`Run ${fmt.cmd(`flarepilot deploy ${name} .`)} first.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Remove Worker Domain route
|
|
252
|
+
status(`Removing ${domain}...`);
|
|
253
|
+
await removeWorkerDomain(config, domain);
|
|
254
|
+
|
|
255
|
+
// Remove CNAME record if it exists
|
|
256
|
+
var zones = await listZones(config);
|
|
257
|
+
var zone = findZoneForHostname(zones, domain);
|
|
258
|
+
if (zone) {
|
|
259
|
+
var records = await listDnsRecords(config, zone.id, {
|
|
260
|
+
type: "CNAME",
|
|
261
|
+
name: domain,
|
|
262
|
+
});
|
|
263
|
+
for (var record of records) {
|
|
264
|
+
await deleteDnsRecord(config, zone.id, record.id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Update app config metadata
|
|
269
|
+
appConfig.domains = (appConfig.domains || []).filter((d) => d !== domain);
|
|
270
|
+
await pushAppConfig(config, name, appConfig);
|
|
271
|
+
|
|
272
|
+
success(`Domain ${fmt.bold(domain)} removed from ${fmt.app(name)}.`);
|
|
273
|
+
}
|