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.
@@ -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
+ }