relight-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +77 -34
  2. package/package.json +12 -4
  3. package/src/cli.js +305 -1
  4. package/src/commands/apps.js +128 -0
  5. package/src/commands/auth.js +75 -4
  6. package/src/commands/config.js +282 -0
  7. package/src/commands/cost.js +593 -0
  8. package/src/commands/db.js +531 -0
  9. package/src/commands/deploy.js +298 -0
  10. package/src/commands/doctor.js +41 -9
  11. package/src/commands/domains.js +223 -0
  12. package/src/commands/logs.js +111 -0
  13. package/src/commands/open.js +42 -0
  14. package/src/commands/ps.js +121 -0
  15. package/src/commands/scale.js +132 -0
  16. package/src/lib/clouds/aws.js +309 -35
  17. package/src/lib/clouds/cf.js +401 -2
  18. package/src/lib/clouds/gcp.js +234 -3
  19. package/src/lib/clouds/slicervm.js +139 -0
  20. package/src/lib/config.js +40 -0
  21. package/src/lib/docker.js +34 -0
  22. package/src/lib/link.js +20 -5
  23. package/src/lib/providers/aws/app.js +481 -0
  24. package/src/lib/providers/aws/db.js +513 -0
  25. package/src/lib/providers/aws/dns.js +232 -0
  26. package/src/lib/providers/aws/registry.js +59 -0
  27. package/src/lib/providers/cf/app.js +596 -0
  28. package/src/lib/providers/cf/bundle.js +70 -0
  29. package/src/lib/providers/cf/db.js +279 -0
  30. package/src/lib/providers/cf/dns.js +148 -0
  31. package/src/lib/providers/cf/registry.js +17 -0
  32. package/src/lib/providers/gcp/app.js +429 -0
  33. package/src/lib/providers/gcp/db.js +457 -0
  34. package/src/lib/providers/gcp/dns.js +166 -0
  35. package/src/lib/providers/gcp/registry.js +30 -0
  36. package/src/lib/providers/resolve.js +49 -0
  37. package/src/lib/providers/slicervm/app.js +396 -0
  38. package/src/lib/providers/slicervm/db.js +33 -0
  39. package/src/lib/providers/slicervm/dns.js +58 -0
  40. package/src/lib/providers/slicervm/registry.js +7 -0
  41. package/worker-template/package.json +10 -0
  42. package/worker-template/src/index.js +260 -0
@@ -0,0 +1,298 @@
1
+ import { createInterface } from "readline";
2
+ import { randomBytes } from "crypto";
3
+ import { phase, status, success, hint, fatal, fmt, generateAppName } from "../lib/output.js";
4
+ import { readLink, linkApp, resolveAppName } from "../lib/link.js";
5
+ import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
6
+ import { dockerBuild, dockerTag, dockerPush, dockerLogin } from "../lib/docker.js";
7
+
8
+ export async function deploy(nameOrPath, path, options) {
9
+ var cloud = resolveCloudId(options.cloud);
10
+ var cfg = getCloudCfg(cloud);
11
+
12
+ // Smart arg parsing: if first arg looks like a path, shift args
13
+ var name;
14
+ var dockerPath;
15
+ if (!nameOrPath) {
16
+ name = readLink()?.app || generateAppName();
17
+ dockerPath = ".";
18
+ } else if (nameOrPath.startsWith(".") || nameOrPath.startsWith("/") || nameOrPath.startsWith("~")) {
19
+ name = readLink()?.app || generateAppName();
20
+ dockerPath = nameOrPath;
21
+ } else {
22
+ name = nameOrPath;
23
+ dockerPath = path || ".";
24
+ }
25
+
26
+ var tag = options.tag || `${Date.now()}`;
27
+
28
+ // Get registry credentials and image tag
29
+ var registry = await getProvider(cloud, "registry");
30
+ var remoteTag = await registry.getImageTag(cfg, name, tag);
31
+ var localTag = `relight-${name}:${tag}`;
32
+
33
+ // Load existing config from deployed worker (null on first deploy)
34
+ var appProvider = await getProvider(cloud, "app");
35
+ var appConfig;
36
+ try {
37
+ appConfig = await appProvider.getAppConfig(cfg, name);
38
+ } catch {
39
+ appConfig = null;
40
+ }
41
+
42
+ var isFirstDeploy = !appConfig;
43
+
44
+ // Get valid regions for this cloud
45
+ var validRegions = appProvider.getRegions();
46
+ var validCodes = validRegions.map((r) => r.code);
47
+
48
+ if (appConfig) {
49
+ // Existing app - update image, merge any flags
50
+ appConfig.image = remoteTag;
51
+ appConfig.deployedAt = new Date().toISOString();
52
+
53
+ if (options.env) {
54
+ if (!appConfig.envKeys) appConfig.envKeys = [];
55
+ if (!appConfig.secretKeys) appConfig.secretKeys = [];
56
+ if (!appConfig.env) appConfig.env = {};
57
+ for (var v of options.env) {
58
+ var eq = v.indexOf("=");
59
+ if (eq !== -1) {
60
+ var k = v.substring(0, eq);
61
+ appConfig.env[k] = v.substring(eq + 1);
62
+ appConfig.secretKeys = appConfig.secretKeys.filter((s) => s !== k);
63
+ if (!appConfig.envKeys.includes(k)) appConfig.envKeys.push(k);
64
+ }
65
+ }
66
+ }
67
+ if (options.regions)
68
+ appConfig.regions = options.regions.split(",").map((r) => r.trim());
69
+ if (options.instances) appConfig.instances = options.instances;
70
+ if (options.port) appConfig.port = options.port;
71
+ if (options.sleep) appConfig.sleepAfter = options.sleep;
72
+ if (options.instanceType) appConfig.instanceType = options.instanceType;
73
+ if (options.vcpu) appConfig.vcpu = options.vcpu;
74
+ if (options.memory) appConfig.memory = options.memory;
75
+ if (options.disk) appConfig.disk = options.disk;
76
+ if (options.observability === false) appConfig.observability = false;
77
+ } else {
78
+ // First deploy - build config from flags + defaults
79
+ var env = {};
80
+ var envKeys = [];
81
+ if (options.env) {
82
+ for (var v of options.env) {
83
+ var eq = v.indexOf("=");
84
+ if (eq !== -1) {
85
+ var k = v.substring(0, eq);
86
+ env[k] = v.substring(eq + 1);
87
+ envKeys.push(k);
88
+ }
89
+ }
90
+ }
91
+
92
+ var defaultRegion = cloud === "gcp" ? "us-central1" : cloud === "aws" ? "us-east-1" : cloud === "slicervm" ? "self-hosted" : "enam";
93
+ var regions;
94
+
95
+ if (options.regions) {
96
+ regions = options.regions.split(",").map((r) => r.trim());
97
+ } else if ((cloud === "gcp" || cloud === "aws") && process.stdin.isTTY) {
98
+ // Interactive region picker for GCP/AWS first deploy
99
+ var { createInterface: createRL } = await import("readline");
100
+ var rl = createRL({ input: process.stdin, output: process.stderr });
101
+ process.stderr.write(`\n${fmt.bold("Select a region:")}\n\n`);
102
+ for (var i = 0; i < validRegions.length; i++) {
103
+ process.stderr.write(
104
+ ` ${fmt.bold(`[${i + 1}]`)} ${validRegions[i].code} ${fmt.dim(`(${validRegions[i].name})`)}\n`
105
+ );
106
+ }
107
+ process.stderr.write("\n");
108
+ var choice = await new Promise((resolve) =>
109
+ rl.question(`Region [1-${validRegions.length}] (default: 1): `, resolve)
110
+ );
111
+ rl.close();
112
+ var idx = choice.trim() ? parseInt(choice, 10) - 1 : 0;
113
+ if (isNaN(idx) || idx < 0 || idx >= validRegions.length) {
114
+ fatal("Invalid region selection.");
115
+ }
116
+ regions = [validRegions[idx].code];
117
+ } else {
118
+ regions = [defaultRegion];
119
+ }
120
+
121
+ for (var r of regions) {
122
+ if (!validCodes.includes(r)) {
123
+ fatal(
124
+ `Invalid region '${r}'.`,
125
+ `Valid regions: ${validCodes.join(", ")}`
126
+ );
127
+ }
128
+ }
129
+
130
+ appConfig = {
131
+ name,
132
+ regions,
133
+ instances: options.instances || (cloud === "slicervm" ? 1 : 2),
134
+ port: options.port || 8080,
135
+ sleepAfter: options.sleep || "30s",
136
+ instanceType: options.instanceType || (cloud === "gcp" || cloud === "aws" || cloud === "slicervm" ? undefined : "lite"),
137
+ vcpu: options.vcpu || undefined,
138
+ memory: options.memory || undefined,
139
+ disk: options.disk || undefined,
140
+ env,
141
+ envKeys,
142
+ secretKeys: [],
143
+ domains: [],
144
+ image: remoteTag,
145
+ createdAt: new Date().toISOString(),
146
+ deployedAt: new Date().toISOString(),
147
+ };
148
+ }
149
+
150
+ // --- D1 database (--db flag, CF only) ---
151
+ var newSecrets = {};
152
+ if (options.db && !appConfig.dbId) {
153
+ if (cloud === "gcp") {
154
+ process.stderr.write(
155
+ `\n${fmt.dim("Cloud SQL provisioning takes 3-10 minutes. Use 'relight db create' separately after deploy.")}\n\n`
156
+ );
157
+ } else if (cloud === "aws") {
158
+ process.stderr.write(
159
+ `\n${fmt.dim("RDS provisioning takes 5-15 minutes. Use 'relight db create' separately after deploy.")}\n\n`
160
+ );
161
+ } else if (cloud === "cf") {
162
+ var { createD1Database, getWorkersSubdomain } = await import("../lib/clouds/cf.js");
163
+ var dbResult = await createD1Database(cfg.accountId, cfg.apiToken, `relight-${name}`, {
164
+ locationHint: options.dbLocation,
165
+ jurisdiction: options.dbJurisdiction,
166
+ });
167
+ appConfig.dbId = dbResult.uuid;
168
+ appConfig.dbName = `relight-${name}`;
169
+
170
+ if (!appConfig.envKeys) appConfig.envKeys = [];
171
+ if (!appConfig.secretKeys) appConfig.secretKeys = [];
172
+ if (!appConfig.env) appConfig.env = {};
173
+
174
+ var subdomain = await getWorkersSubdomain(cfg.accountId, cfg.apiToken);
175
+ var dbUrl = subdomain
176
+ ? `https://relight-${name}.${subdomain}.workers.dev`
177
+ : null;
178
+
179
+ if (dbUrl) {
180
+ appConfig.env["DB_URL"] = dbUrl;
181
+ if (!appConfig.envKeys.includes("DB_URL")) appConfig.envKeys.push("DB_URL");
182
+ }
183
+
184
+ appConfig.env["DB_TOKEN"] = "[hidden]";
185
+ appConfig.secretKeys = appConfig.secretKeys.filter((k) => k !== "DB_TOKEN");
186
+ appConfig.secretKeys.push("DB_TOKEN");
187
+ appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DB_TOKEN");
188
+
189
+ newSecrets.DB_TOKEN = randomBytes(32).toString("hex");
190
+ }
191
+ }
192
+
193
+ // --- Summary & confirmation ---
194
+ var instanceDesc = appConfig.vcpu
195
+ ? `${appConfig.vcpu} vCPU, ${appConfig.memory || "default"} MiB`
196
+ : appConfig.instanceType || "lite";
197
+
198
+ process.stderr.write(`\n${fmt.bold("Deploy summary")}\n`);
199
+ process.stderr.write(`${fmt.dim("-".repeat(40))}\n`);
200
+ process.stderr.write(` ${fmt.bold("App:")} ${fmt.app(name)}${isFirstDeploy ? fmt.dim(" (new)") : ""}\n`);
201
+ process.stderr.write(` ${fmt.bold("Cloud:")} ${fmt.cloud(cloud)}\n`);
202
+ process.stderr.write(` ${fmt.bold("Path:")} ${dockerPath}\n`);
203
+ process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
204
+ process.stderr.write(` ${fmt.bold("Regions:")} ${appConfig.regions.join(", ")}\n`);
205
+ process.stderr.write(` ${fmt.bold("Instances:")} ${appConfig.instances || 2} per region\n`);
206
+ process.stderr.write(` ${fmt.bold("Type:")} ${instanceDesc}\n`);
207
+ process.stderr.write(` ${fmt.bold("Port:")} ${appConfig.port || 8080}\n`);
208
+ process.stderr.write(` ${fmt.bold("Sleep:")} ${appConfig.sleepAfter || "30s"}\n`);
209
+ if (appConfig.dbId) {
210
+ process.stderr.write(` ${fmt.bold("Database:")} ${appConfig.dbName}\n`);
211
+ }
212
+ process.stderr.write(`${fmt.dim("-".repeat(40))}\n`);
213
+
214
+ if (!options.yes) {
215
+ var rl = createInterface({ input: process.stdin, output: process.stderr });
216
+ var answer = await new Promise((resolve) =>
217
+ rl.question("\nProceed? [Y/n] ", resolve)
218
+ );
219
+ rl.close();
220
+ if (answer && !answer.match(/^y(es)?$/i)) {
221
+ process.stderr.write("Deploy cancelled.\n");
222
+ process.exit(0);
223
+ }
224
+ }
225
+
226
+ // 1. Build Docker image
227
+ var platform = "linux/amd64";
228
+ if (cloud === "slicervm") {
229
+ // Match VM architecture
230
+ var { listNodes } = await import("../lib/clouds/slicervm.js");
231
+ var nodes = await listNodes(cfg);
232
+ var vmArch = nodes[0]?.arch;
233
+ if (vmArch === "arm64" || vmArch === "aarch64") platform = "linux/arm64";
234
+ }
235
+ phase("Building image");
236
+ status(`${localTag} for ${platform}`);
237
+ dockerBuild(dockerPath, localTag, { platform });
238
+
239
+ if (cloud === "slicervm") {
240
+ // SlicerVM: skip registry push - deploy extracts and uploads the image directly
241
+ phase("Deploying");
242
+ await appProvider.deploy(cfg, name, localTag, {
243
+ appConfig,
244
+ isFirstDeploy,
245
+ newSecrets,
246
+ });
247
+ } else {
248
+ // 2. Push to registry
249
+ phase("Pushing to registry");
250
+ status("Authenticating...");
251
+ var creds = await registry.getCredentials(cfg);
252
+ dockerLogin(creds.registry, creds.username, creds.password);
253
+ if (registry.ensureRepository) await registry.ensureRepository(cfg, name);
254
+ status(`Pushing ${remoteTag}...`);
255
+ dockerTag(localTag, remoteTag);
256
+ dockerPush(remoteTag);
257
+
258
+ // 3. Deploy via provider
259
+ phase("Deploying");
260
+ await appProvider.deploy(cfg, name, remoteTag, {
261
+ appConfig,
262
+ isFirstDeploy,
263
+ newSecrets,
264
+ });
265
+ }
266
+
267
+ // 4. Resolve URL and report
268
+ var url = await appProvider.getAppUrl(cfg, name);
269
+
270
+ if (options.json) {
271
+ console.log(
272
+ JSON.stringify(
273
+ {
274
+ name,
275
+ cloud,
276
+ image: remoteTag,
277
+ url,
278
+ regions: appConfig.regions,
279
+ instances: appConfig.instances,
280
+ firstDeploy: isFirstDeploy,
281
+ },
282
+ null,
283
+ 2
284
+ )
285
+ );
286
+ } else {
287
+ success(`App ${fmt.app(name)} deployed!`);
288
+ process.stderr.write(` ${fmt.bold("Name:")} ${fmt.app(name)}\n`);
289
+ process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
290
+ process.stderr.write(
291
+ ` ${fmt.bold("URL:")} ${url ? fmt.url(url) : fmt.dim("(configure workers.dev subdomain to see URL)")}\n`
292
+ );
293
+ hint("Next", `relight open ${name}`);
294
+ }
295
+
296
+ // Link this directory to the app
297
+ linkApp(name, cloud, options.dns, options.dbCloud);
298
+ }
@@ -6,8 +6,8 @@ import {
6
6
  CLOUD_NAMES,
7
7
  } from "../lib/config.js";
8
8
  import { verifyToken as cfVerify, getWorkersSubdomain } from "../lib/clouds/cf.js";
9
- import { mintAccessToken, verifyProject as gcpVerifyProject, listRegions as gcpListRegions } from "../lib/clouds/gcp.js";
10
- import { verifyCredentials as awsVerify, checkAppRunner } from "../lib/clouds/aws.js";
9
+ import { mintAccessToken, verifyProject as gcpVerifyProject, listRegions as gcpListRegions, gcpApi, AR_API, SQLADMIN_API, DNS_API } from "../lib/clouds/gcp.js";
10
+ import { verifyCredentials as awsVerify, checkAppRunner, awsJsonApi, awsQueryApi, awsRestXmlApi } from "../lib/clouds/aws.js";
11
11
  import kleur from "kleur";
12
12
 
13
13
  var PASS = kleur.green("[ok]");
@@ -134,6 +134,21 @@ async function checkGCP(cfg) {
134
134
  (await asyncCheck("Cloud Run API reachable", async () => {
135
135
  await gcpListRegions(token, cfg.project);
136
136
  })) && ok;
137
+
138
+ ok =
139
+ (await asyncCheck("Artifact Registry API reachable", async () => {
140
+ await gcpApi("GET", `${AR_API}/projects/${cfg.project}/locations/us/repositories`, null, token);
141
+ })) && ok;
142
+
143
+ ok =
144
+ (await asyncCheck("Cloud SQL Admin API reachable", async () => {
145
+ await gcpApi("GET", `${SQLADMIN_API}/projects/${cfg.project}/instances`, null, token);
146
+ })) && ok;
147
+
148
+ ok =
149
+ (await asyncCheck("Cloud DNS API reachable", async () => {
150
+ await gcpApi("GET", `${DNS_API}/projects/${cfg.project}/managedZones`, null, token);
151
+ })) && ok;
137
152
  }
138
153
 
139
154
  return ok;
@@ -143,23 +158,40 @@ async function checkGCP(cfg) {
143
158
 
144
159
  async function checkAWS(cfg) {
145
160
  var ok = true;
161
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
146
162
 
147
163
  ok =
148
164
  (await asyncCheck("Credentials valid (STS)", async () => {
149
- await awsVerify(
150
- { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
151
- cfg.region
152
- );
165
+ await awsVerify(cr, cfg.region);
153
166
  })) && ok;
154
167
 
155
168
  ok =
156
169
  (await asyncCheck("App Runner accessible", async () => {
157
- await checkAppRunner(
158
- { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
159
- cfg.region
170
+ await checkAppRunner(cr, cfg.region);
171
+ })) && ok;
172
+
173
+ ok =
174
+ (await asyncCheck("ECR accessible", async () => {
175
+ await awsJsonApi(
176
+ "AmazonEC2ContainerRegistry_V20150921.DescribeRepositories",
177
+ {},
178
+ "ecr",
179
+ cr,
180
+ cfg.region,
181
+ `api.ecr.${cfg.region}.amazonaws.com`
160
182
  );
161
183
  })) && ok;
162
184
 
185
+ ok =
186
+ (await asyncCheck("RDS accessible", async () => {
187
+ await awsQueryApi("DescribeDBInstances", {}, "rds", cr, cfg.region);
188
+ })) && ok;
189
+
190
+ ok =
191
+ (await asyncCheck("Route 53 accessible", async () => {
192
+ await awsRestXmlApi("GET", "/2013-04-01/hostedzone", null, cr);
193
+ })) && ok;
194
+
163
195
  return ok;
164
196
  }
165
197
 
@@ -0,0 +1,223 @@
1
+ import { createInterface } from "readline";
2
+ import { phase, status, success, fatal, hint, fmt } from "../lib/output.js";
3
+ import { resolveAppName, resolveDns, readLink, linkApp } from "../lib/link.js";
4
+ import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
5
+ import kleur from "kleur";
6
+
7
+ function prompt(rl, question) {
8
+ return new Promise((resolve) => rl.question(question, resolve));
9
+ }
10
+
11
+ export async function domainsList(name, options) {
12
+ name = resolveAppName(name);
13
+ var cloud = resolveCloudId(options.cloud);
14
+ var cfg = getCloudCfg(cloud);
15
+ var dnsProvider = await getProvider(cloud, "dns");
16
+
17
+ var result = await dnsProvider.listDomains(cfg, name);
18
+
19
+ if (options.json) {
20
+ console.log(JSON.stringify(result, null, 2));
21
+ return;
22
+ }
23
+
24
+ if (result.default) {
25
+ console.log(
26
+ `\n${fmt.bold("Default:")} ${fmt.url(`https://${result.default}`)}`
27
+ );
28
+ }
29
+
30
+ if (result.custom.length === 0) {
31
+ console.log(`${fmt.bold("Custom:")} ${fmt.dim("(none)")}`);
32
+ hint("Add", `relight domains add ${name}`);
33
+ return;
34
+ }
35
+
36
+ console.log(`\n${fmt.bold("Custom domains:")}`);
37
+ for (var d of result.custom) {
38
+ console.log(` ${d}`);
39
+ }
40
+ }
41
+
42
+ export async function domainsAdd(args, options) {
43
+ var name, domain;
44
+
45
+ if (args.length === 2) {
46
+ name = args[0];
47
+ domain = args[1];
48
+ } else if (args.length === 1) {
49
+ if (args[0].includes(".")) {
50
+ name = resolveAppName(null);
51
+ domain = args[0];
52
+ } else {
53
+ name = args[0];
54
+ domain = null;
55
+ }
56
+ } else {
57
+ name = resolveAppName(null);
58
+ domain = null;
59
+ }
60
+
61
+ var appCloud = resolveCloudId(options.cloud);
62
+ var appCfg = getCloudCfg(appCloud);
63
+ var appProvider = await getProvider(appCloud, "app");
64
+
65
+ // Cross-cloud DNS: --dns flag or .relight dns field specifies a different cloud for DNS records
66
+ var dnsFlag = options.dns || resolveDns();
67
+ var crossCloud = dnsFlag && resolveCloudId(dnsFlag) !== appCloud;
68
+ var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : appCloud;
69
+ var dnsCfg = crossCloud ? getCloudCfg(dnsCloud) : appCfg;
70
+ var dnsProvider = await getProvider(dnsCloud, "dns");
71
+
72
+ var appConfig = await appProvider.getAppConfig(appCfg, name);
73
+ if (!appConfig) {
74
+ fatal(
75
+ `App ${fmt.app(name)} not found.`,
76
+ `Run ${fmt.cmd(`relight deploy ${name} .`)} first.`
77
+ );
78
+ }
79
+
80
+ // Fetch zones from the DNS provider
81
+ status("Loading zones...");
82
+ var zones = await dnsProvider.getZones(dnsCfg);
83
+
84
+ if (zones.length === 0) {
85
+ fatal(
86
+ "No active zones found in the DNS account.",
87
+ "Add a domain to your DNS provider first."
88
+ );
89
+ }
90
+
91
+ var rl = createInterface({ input: process.stdin, output: process.stderr });
92
+ var zone;
93
+
94
+ if (domain) {
95
+ zone = dnsProvider.findZoneForHostname(zones, domain);
96
+ if (!zone) {
97
+ rl.close();
98
+ var zoneList = zones.map((z) => ` ${z.name}`).join("\n");
99
+ fatal(
100
+ `No zone found for '${domain}'.`,
101
+ `Available zones:\n${zoneList}`
102
+ );
103
+ }
104
+ } else {
105
+ process.stderr.write(`\n${kleur.bold("Available zones:")}\n\n`);
106
+ for (var i = 0; i < zones.length; i++) {
107
+ process.stderr.write(
108
+ ` ${kleur.bold(`[${i + 1}]`)} ${zones[i].name}\n`
109
+ );
110
+ }
111
+ process.stderr.write("\n");
112
+
113
+ var zoneChoice = await prompt(rl, `Select zone [1-${zones.length}]: `);
114
+ var zoneIdx = parseInt(zoneChoice, 10) - 1;
115
+ if (isNaN(zoneIdx) || zoneIdx < 0 || zoneIdx >= zones.length) {
116
+ rl.close();
117
+ fatal("Invalid selection.");
118
+ }
119
+ zone = zones[zoneIdx];
120
+
121
+ process.stderr.write(`\n${kleur.bold("Route type:")}\n\n`);
122
+ process.stderr.write(` ${kleur.bold("[1]")} Root domain (${zone.name})\n`);
123
+ process.stderr.write(` ${kleur.bold("[2]")} Subdomain (*.${zone.name})\n`);
124
+ process.stderr.write("\n");
125
+
126
+ var routeChoice = await prompt(rl, "Select [1-2]: ");
127
+
128
+ if (routeChoice.trim() === "1") {
129
+ domain = zone.name;
130
+ } else if (routeChoice.trim() === "2") {
131
+ var sub = await prompt(rl, `Subdomain: ${fmt.dim("___." + zone.name + " -> ")} `);
132
+ sub = (sub || "").trim();
133
+ if (!sub) {
134
+ rl.close();
135
+ fatal("No subdomain provided.");
136
+ }
137
+ domain = `${sub}.${zone.name}`;
138
+ } else {
139
+ rl.close();
140
+ fatal("Invalid selection.");
141
+ }
142
+ }
143
+
144
+ rl.close();
145
+
146
+ if (crossCloud) {
147
+ // Cross-cloud: DNS record on one cloud, app config on another
148
+ status(`Creating DNS record for ${domain}...`);
149
+ var appUrl = await appProvider.getAppUrl(appCfg, name);
150
+ var target = new URL(appUrl).hostname;
151
+ try {
152
+ await dnsProvider.addDnsRecord(dnsCfg, domain, target, zone);
153
+ } catch (e) {
154
+ fatal(e.message);
155
+ }
156
+
157
+ // Update app config on the app cloud
158
+ status(`Updating app config on ${appCloud}...`);
159
+ if (!appConfig.domains) appConfig.domains = [];
160
+ if (!appConfig.domains.includes(domain)) {
161
+ appConfig.domains.push(domain);
162
+ await appProvider.pushAppConfig(appCfg, name, appConfig);
163
+ }
164
+
165
+ // Persist dns cloud in .relight so future commands don't need --dns
166
+ var linked = readLink();
167
+ if (linked && !linked.dns) {
168
+ linkApp(linked.app, linked.cloud, dnsCloud);
169
+ }
170
+ } else {
171
+ // Same-cloud: existing flow
172
+ status(`Attaching ${domain} to relight-${name}...`);
173
+ try {
174
+ await dnsProvider.addDomain(dnsCfg, name, domain, { zone, zones });
175
+ } catch (e) {
176
+ fatal(e.message);
177
+ }
178
+ }
179
+
180
+ success(`Domain ${fmt.bold(domain)} added to ${fmt.app(name)}.`);
181
+ process.stderr.write(` ${fmt.url(`https://${domain}`)}\n`);
182
+ }
183
+
184
+ export async function domainsRemove(args, options) {
185
+ var name, domain;
186
+ if (args.length === 2) {
187
+ name = args[0];
188
+ domain = args[1];
189
+ } else if (args.length === 1) {
190
+ name = resolveAppName(null);
191
+ domain = args[0];
192
+ } else {
193
+ fatal("Usage: relight domains remove [name] <domain>");
194
+ }
195
+
196
+ var appCloud = resolveCloudId(options.cloud);
197
+ var appCfg = getCloudCfg(appCloud);
198
+
199
+ var dnsFlag = options.dns || resolveDns();
200
+ var crossCloud = dnsFlag && resolveCloudId(dnsFlag) !== appCloud;
201
+ var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : appCloud;
202
+ var dnsCfg = crossCloud ? getCloudCfg(dnsCloud) : appCfg;
203
+ var dnsProvider = await getProvider(dnsCloud, "dns");
204
+
205
+ status(`Removing ${domain}...`);
206
+
207
+ if (crossCloud) {
208
+ // Cross-cloud: remove DNS record from dns cloud, update app config on app cloud
209
+ await dnsProvider.removeDnsRecord(dnsCfg, domain);
210
+
211
+ var appProvider = await getProvider(appCloud, "app");
212
+ var appConfig = await appProvider.getAppConfig(appCfg, name);
213
+ if (appConfig) {
214
+ appConfig.domains = (appConfig.domains || []).filter((d) => d !== domain);
215
+ await appProvider.pushAppConfig(appCfg, name, appConfig);
216
+ }
217
+ } else {
218
+ // Same-cloud: existing flow
219
+ await dnsProvider.removeDomain(dnsCfg, name, domain);
220
+ }
221
+
222
+ success(`Domain ${fmt.bold(domain)} removed from ${fmt.app(name)}.`);
223
+ }