relight-cli 0.1.0 → 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.
Files changed (45) hide show
  1. package/README.md +77 -34
  2. package/package.json +12 -4
  3. package/src/cli.js +350 -1
  4. package/src/commands/apps.js +128 -0
  5. package/src/commands/auth.js +13 -4
  6. package/src/commands/config.js +282 -0
  7. package/src/commands/cost.js +593 -0
  8. package/src/commands/db.js +775 -0
  9. package/src/commands/deploy.js +264 -0
  10. package/src/commands/doctor.js +69 -13
  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/commands/service.js +227 -0
  17. package/src/lib/clouds/aws.js +309 -35
  18. package/src/lib/clouds/cf.js +401 -2
  19. package/src/lib/clouds/gcp.js +255 -4
  20. package/src/lib/clouds/neon.js +147 -0
  21. package/src/lib/clouds/slicervm.js +139 -0
  22. package/src/lib/config.js +200 -2
  23. package/src/lib/docker.js +34 -0
  24. package/src/lib/link.js +31 -5
  25. package/src/lib/providers/aws/app.js +481 -0
  26. package/src/lib/providers/aws/db.js +504 -0
  27. package/src/lib/providers/aws/dns.js +232 -0
  28. package/src/lib/providers/aws/registry.js +59 -0
  29. package/src/lib/providers/cf/app.js +596 -0
  30. package/src/lib/providers/cf/bundle.js +70 -0
  31. package/src/lib/providers/cf/db.js +181 -0
  32. package/src/lib/providers/cf/dns.js +148 -0
  33. package/src/lib/providers/cf/registry.js +17 -0
  34. package/src/lib/providers/gcp/app.js +429 -0
  35. package/src/lib/providers/gcp/db.js +372 -0
  36. package/src/lib/providers/gcp/dns.js +166 -0
  37. package/src/lib/providers/gcp/registry.js +30 -0
  38. package/src/lib/providers/neon/db.js +306 -0
  39. package/src/lib/providers/resolve.js +79 -0
  40. package/src/lib/providers/slicervm/app.js +396 -0
  41. package/src/lib/providers/slicervm/db.js +33 -0
  42. package/src/lib/providers/slicervm/dns.js +58 -0
  43. package/src/lib/providers/slicervm/registry.js +7 -0
  44. package/worker-template/package.json +10 -0
  45. package/worker-template/src/index.js +260 -0
@@ -0,0 +1,264 @@
1
+ import { createInterface } from "readline";
2
+ import { phase, status, success, hint, fatal, fmt, generateAppName } from "../lib/output.js";
3
+ import { readLink, linkApp, resolveAppName } from "../lib/link.js";
4
+ import { resolveTarget } from "../lib/providers/resolve.js";
5
+ import { dockerBuild, dockerTag, dockerPush, dockerLogin } from "../lib/docker.js";
6
+
7
+ export async function deploy(nameOrPath, path, options) {
8
+ var target = await resolveTarget(options);
9
+ var cfg = target.cfg;
10
+
11
+ // Smart arg parsing: if first arg looks like a path, shift args
12
+ var name;
13
+ var dockerPath;
14
+ if (!nameOrPath) {
15
+ name = readLink()?.app || generateAppName();
16
+ dockerPath = ".";
17
+ } else if (nameOrPath.startsWith(".") || nameOrPath.startsWith("/") || nameOrPath.startsWith("~")) {
18
+ name = readLink()?.app || generateAppName();
19
+ dockerPath = nameOrPath;
20
+ } else {
21
+ name = nameOrPath;
22
+ dockerPath = path || ".";
23
+ }
24
+
25
+ var tag = options.tag || `${Date.now()}`;
26
+
27
+ // Get registry credentials and image tag
28
+ var registry = await target.provider("registry");
29
+ var remoteTag = await registry.getImageTag(cfg, name, tag);
30
+ var localTag = `relight-${name}:${tag}`;
31
+
32
+ // Load existing config from deployed worker (null on first deploy)
33
+ var appProvider = await target.provider("app");
34
+ var appConfig;
35
+ try {
36
+ appConfig = await appProvider.getAppConfig(cfg, name);
37
+ } catch {
38
+ appConfig = null;
39
+ }
40
+
41
+ var isFirstDeploy = !appConfig;
42
+
43
+ // Get valid regions for this cloud/service
44
+ var validRegions = appProvider.getRegions();
45
+ var validCodes = validRegions.map((r) => r.code);
46
+
47
+ if (appConfig) {
48
+ // Existing app - update image, merge any flags
49
+ appConfig.image = remoteTag;
50
+ appConfig.deployedAt = new Date().toISOString();
51
+
52
+ if (options.env) {
53
+ if (!appConfig.envKeys) appConfig.envKeys = [];
54
+ if (!appConfig.secretKeys) appConfig.secretKeys = [];
55
+ if (!appConfig.env) appConfig.env = {};
56
+ for (var v of options.env) {
57
+ var eq = v.indexOf("=");
58
+ if (eq !== -1) {
59
+ var k = v.substring(0, eq);
60
+ appConfig.env[k] = v.substring(eq + 1);
61
+ appConfig.secretKeys = appConfig.secretKeys.filter((s) => s !== k);
62
+ if (!appConfig.envKeys.includes(k)) appConfig.envKeys.push(k);
63
+ }
64
+ }
65
+ }
66
+ if (options.regions)
67
+ appConfig.regions = options.regions.split(",").map((r) => r.trim());
68
+ if (options.instances) appConfig.instances = options.instances;
69
+ if (options.port) appConfig.port = options.port;
70
+ if (options.sleep) appConfig.sleepAfter = options.sleep;
71
+ if (options.instanceType) appConfig.instanceType = options.instanceType;
72
+ if (options.vcpu) appConfig.vcpu = options.vcpu;
73
+ if (options.memory) appConfig.memory = options.memory;
74
+ if (options.disk) appConfig.disk = options.disk;
75
+ if (options.observability === false) appConfig.observability = false;
76
+ } else {
77
+ // First deploy - build config from flags + defaults
78
+ var env = {};
79
+ var envKeys = [];
80
+ if (options.env) {
81
+ for (var v of options.env) {
82
+ var eq = v.indexOf("=");
83
+ if (eq !== -1) {
84
+ var k = v.substring(0, eq);
85
+ env[k] = v.substring(eq + 1);
86
+ envKeys.push(k);
87
+ }
88
+ }
89
+ }
90
+
91
+ var isService = target.kind === "service";
92
+ var defaultRegion = isService ? "self-hosted" : target.type === "gcp" ? "us-central1" : target.type === "aws" ? "us-east-1" : "enam";
93
+ var regions;
94
+
95
+ if (options.regions) {
96
+ regions = options.regions.split(",").map((r) => r.trim());
97
+ } else if (!isService && (target.type === "gcp" || target.type === "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 || (isService ? 1 : 2),
134
+ port: options.port || 8080,
135
+ sleepAfter: options.sleep || "30s",
136
+ instanceType: options.instanceType || (isService || target.type === "gcp" || target.type === "aws" ? 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
+ var newSecrets = {};
151
+
152
+ // --- Summary & confirmation ---
153
+ var instanceDesc = appConfig.vcpu
154
+ ? `${appConfig.vcpu} vCPU, ${appConfig.memory || "default"} MiB`
155
+ : appConfig.instanceType || "lite";
156
+
157
+ process.stderr.write(`\n${fmt.bold("Deploy summary")}\n`);
158
+ process.stderr.write(`${fmt.dim("-".repeat(40))}\n`);
159
+ process.stderr.write(` ${fmt.bold("App:")} ${fmt.app(name)}${isFirstDeploy ? fmt.dim(" (new)") : ""}\n`);
160
+ if (target.kind === "service") {
161
+ process.stderr.write(` ${fmt.bold("Service:")} ${fmt.cloud(target.id)} ${fmt.dim(`(${target.type})`)}\n`);
162
+ } else {
163
+ process.stderr.write(` ${fmt.bold("Cloud:")} ${fmt.cloud(target.id)}\n`);
164
+ }
165
+ process.stderr.write(` ${fmt.bold("Path:")} ${dockerPath}\n`);
166
+ process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
167
+ process.stderr.write(` ${fmt.bold("Regions:")} ${appConfig.regions.join(", ")}\n`);
168
+ process.stderr.write(` ${fmt.bold("Instances:")} ${appConfig.instances || 2} per region\n`);
169
+ process.stderr.write(` ${fmt.bold("Type:")} ${instanceDesc}\n`);
170
+ process.stderr.write(` ${fmt.bold("Port:")} ${appConfig.port || 8080}\n`);
171
+ process.stderr.write(` ${fmt.bold("Sleep:")} ${appConfig.sleepAfter || "30s"}\n`);
172
+ if (appConfig.dbId) {
173
+ process.stderr.write(` ${fmt.bold("Database:")} ${appConfig.dbName}\n`);
174
+ }
175
+ process.stderr.write(`${fmt.dim("-".repeat(40))}\n`);
176
+
177
+ if (!options.yes) {
178
+ var rl = createInterface({ input: process.stdin, output: process.stderr });
179
+ var answer = await new Promise((resolve) =>
180
+ rl.question("\nProceed? [Y/n] ", resolve)
181
+ );
182
+ rl.close();
183
+ if (answer && !answer.match(/^y(es)?$/i)) {
184
+ process.stderr.write("Deploy cancelled.\n");
185
+ process.exit(0);
186
+ }
187
+ }
188
+
189
+ // 1. Build Docker image
190
+ var platform = "linux/amd64";
191
+ if (target.type === "slicervm") {
192
+ // Match VM architecture
193
+ var { listNodes } = await import("../lib/clouds/slicervm.js");
194
+ var nodes = await listNodes(cfg);
195
+ var vmArch = nodes[0]?.arch;
196
+ if (vmArch === "arm64" || vmArch === "aarch64") platform = "linux/arm64";
197
+ }
198
+ phase("Building image");
199
+ status(`${localTag} for ${platform}`);
200
+ dockerBuild(dockerPath, localTag, { platform });
201
+
202
+ if (target.kind === "service") {
203
+ // Service: skip registry push - deploy extracts and uploads the image directly
204
+ phase("Deploying");
205
+ await appProvider.deploy(cfg, name, localTag, {
206
+ appConfig,
207
+ isFirstDeploy,
208
+ newSecrets,
209
+ });
210
+ } else {
211
+ // 2. Push to registry
212
+ phase("Pushing to registry");
213
+ status("Authenticating...");
214
+ var creds = await registry.getCredentials(cfg);
215
+ dockerLogin(creds.registry, creds.username, creds.password);
216
+ if (registry.ensureRepository) await registry.ensureRepository(cfg, name);
217
+ status(`Pushing ${remoteTag}...`);
218
+ dockerTag(localTag, remoteTag);
219
+ dockerPush(remoteTag);
220
+
221
+ // 3. Deploy via provider
222
+ phase("Deploying");
223
+ await appProvider.deploy(cfg, name, remoteTag, {
224
+ appConfig,
225
+ isFirstDeploy,
226
+ newSecrets,
227
+ });
228
+ }
229
+
230
+ // 4. Resolve URL and report
231
+ var url = await appProvider.getAppUrl(cfg, name);
232
+
233
+ if (options.json) {
234
+ var result = {
235
+ name,
236
+ image: remoteTag,
237
+ url,
238
+ regions: appConfig.regions,
239
+ instances: appConfig.instances,
240
+ firstDeploy: isFirstDeploy,
241
+ };
242
+ if (target.kind === "service") {
243
+ result.service = target.id;
244
+ } else {
245
+ result.cloud = target.id;
246
+ }
247
+ console.log(JSON.stringify(result, null, 2));
248
+ } else {
249
+ success(`App ${fmt.app(name)} deployed!`);
250
+ process.stderr.write(` ${fmt.bold("Name:")} ${fmt.app(name)}\n`);
251
+ process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
252
+ process.stderr.write(
253
+ ` ${fmt.bold("URL:")} ${url ? fmt.url(url) : fmt.dim("(configure workers.dev subdomain to see URL)")}\n`
254
+ );
255
+ hint("Next", `relight open ${name}`);
256
+ }
257
+
258
+ // Link this directory to the app
259
+ if (target.kind === "service") {
260
+ linkApp(name, null, options.dns, null, target.id);
261
+ } else {
262
+ linkApp(name, target.id, options.dns);
263
+ }
264
+ }
@@ -4,10 +4,14 @@ import {
4
4
  tryGetConfig,
5
5
  CONFIG_PATH,
6
6
  CLOUD_NAMES,
7
+ SERVICE_TYPES,
8
+ getRegisteredServices,
9
+ normalizeServiceConfig,
7
10
  } from "../lib/config.js";
8
11
  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";
12
+ import { mintAccessToken, verifyProject as gcpVerifyProject, listRegions as gcpListRegions, gcpApi, AR_API, SQLADMIN_API, DNS_API } from "../lib/clouds/gcp.js";
13
+ import { verifyCredentials as awsVerify, checkAppRunner, awsJsonApi, awsQueryApi, awsRestXmlApi } from "../lib/clouds/aws.js";
14
+ import { verifyConnection as slicerVerify } from "../lib/clouds/slicervm.js";
11
15
  import kleur from "kleur";
12
16
 
13
17
  var PASS = kleur.green("[ok]");
@@ -16,7 +20,7 @@ var SKIP = kleur.yellow("[--]");
16
20
 
17
21
  export async function doctor() {
18
22
  process.stderr.write(`\n${kleur.bold("relight doctor")}\n`);
19
- process.stderr.write(`${kleur.dim("".repeat(50))}\n\n`);
23
+ process.stderr.write(`${kleur.dim("-".repeat(50))}\n\n`);
20
24
  var allGood = true;
21
25
 
22
26
  // --- General checks ---
@@ -73,9 +77,29 @@ export async function doctor() {
73
77
  }
74
78
  }
75
79
 
80
+ // --- Services ---
81
+
82
+ var services = getRegisteredServices();
83
+ if (services.length > 0) {
84
+ process.stderr.write(`\n${kleur.bold(" Services")}\n`);
85
+
86
+ for (var service of services) {
87
+ var typeName = SERVICE_TYPES[service.type]?.name || service.type;
88
+ var endpoint = service.socketPath || service.apiUrl || "unknown";
89
+
90
+ allGood =
91
+ (await asyncCheck(`${service.name} (${typeName} - ${endpoint})`, async () => {
92
+ if (service.type === "slicervm") {
93
+ var cfg = normalizeServiceConfig(service);
94
+ await slicerVerify(cfg);
95
+ }
96
+ })) && allGood;
97
+ }
98
+ }
99
+
76
100
  // --- Summary ---
77
101
 
78
- process.stderr.write(`\n${kleur.dim("".repeat(50))}\n`);
102
+ process.stderr.write(`\n${kleur.dim("-".repeat(50))}\n`);
79
103
  if (allGood) {
80
104
  process.stderr.write(kleur.green("All checks passed.\n\n"));
81
105
  } else {
@@ -134,6 +158,21 @@ async function checkGCP(cfg) {
134
158
  (await asyncCheck("Cloud Run API reachable", async () => {
135
159
  await gcpListRegions(token, cfg.project);
136
160
  })) && ok;
161
+
162
+ ok =
163
+ (await asyncCheck("Artifact Registry API reachable", async () => {
164
+ await gcpApi("GET", `${AR_API}/projects/${cfg.project}/locations/us/repositories`, null, token);
165
+ })) && ok;
166
+
167
+ ok =
168
+ (await asyncCheck("Cloud SQL Admin API reachable", async () => {
169
+ await gcpApi("GET", `${SQLADMIN_API}/projects/${cfg.project}/instances`, null, token);
170
+ })) && ok;
171
+
172
+ ok =
173
+ (await asyncCheck("Cloud DNS API reachable", async () => {
174
+ await gcpApi("GET", `${DNS_API}/projects/${cfg.project}/managedZones`, null, token);
175
+ })) && ok;
137
176
  }
138
177
 
139
178
  return ok;
@@ -143,23 +182,40 @@ async function checkGCP(cfg) {
143
182
 
144
183
  async function checkAWS(cfg) {
145
184
  var ok = true;
185
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
146
186
 
147
187
  ok =
148
188
  (await asyncCheck("Credentials valid (STS)", async () => {
149
- await awsVerify(
150
- { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
151
- cfg.region
152
- );
189
+ await awsVerify(cr, cfg.region);
153
190
  })) && ok;
154
191
 
155
192
  ok =
156
193
  (await asyncCheck("App Runner accessible", async () => {
157
- await checkAppRunner(
158
- { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
159
- cfg.region
194
+ await checkAppRunner(cr, cfg.region);
195
+ })) && ok;
196
+
197
+ ok =
198
+ (await asyncCheck("ECR accessible", async () => {
199
+ await awsJsonApi(
200
+ "AmazonEC2ContainerRegistry_V20150921.DescribeRepositories",
201
+ {},
202
+ "ecr",
203
+ cr,
204
+ cfg.region,
205
+ `api.ecr.${cfg.region}.amazonaws.com`
160
206
  );
161
207
  })) && ok;
162
208
 
209
+ ok =
210
+ (await asyncCheck("RDS accessible", async () => {
211
+ await awsQueryApi("DescribeDBInstances", {}, "rds", cr, cfg.region);
212
+ })) && ok;
213
+
214
+ ok =
215
+ (await asyncCheck("Route 53 accessible", async () => {
216
+ await awsRestXmlApi("GET", "/2013-04-01/hostedzone", null, cr);
217
+ })) && ok;
218
+
163
219
  return ok;
164
220
  }
165
221
 
@@ -172,7 +228,7 @@ function check(label, fn) {
172
228
  return true;
173
229
  } catch (e) {
174
230
  process.stderr.write(` ${FAIL} ${label}`);
175
- if (e.message) process.stderr.write(kleur.dim(` ${e.message}`));
231
+ if (e.message) process.stderr.write(kleur.dim(` - ${e.message}`));
176
232
  process.stderr.write("\n");
177
233
  return false;
178
234
  }
@@ -185,7 +241,7 @@ async function asyncCheck(label, fn) {
185
241
  return true;
186
242
  } catch (e) {
187
243
  process.stderr.write(` ${FAIL} ${label}`);
188
- if (e.message) process.stderr.write(kleur.dim(` ${truncate(e.message, 80)}`));
244
+ if (e.message) process.stderr.write(kleur.dim(` - ${truncate(e.message, 80)}`));
189
245
  process.stderr.write("\n");
190
246
  return false;
191
247
  }
@@ -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 { resolveTarget, 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 target = await resolveTarget(options);
14
+ var cfg = target.cfg;
15
+ var dnsProvider = await target.provider("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 target = await resolveTarget(options);
62
+ var appCfg = target.cfg;
63
+ var appProvider = await target.provider("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 && (target.kind === "service" || resolveCloudId(dnsFlag) !== target.id);
68
+ var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : (target.kind === "cloud" ? target.id : null);
69
+ var dnsCfg = crossCloud ? getCloudCfg(dnsCloud) : appCfg;
70
+ var dnsProvider = crossCloud ? await getProvider(dnsCloud, "dns") : await target.provider("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 dnsTarget = new URL(appUrl).hostname;
151
+ try {
152
+ await dnsProvider.addDnsRecord(dnsCfg, domain, dnsTarget, zone);
153
+ } catch (e) {
154
+ fatal(e.message);
155
+ }
156
+
157
+ // Update app config on the app cloud/service
158
+ status(`Updating app config...`);
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, undefined, linked.compute);
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 target = await resolveTarget(options);
197
+ var appCfg = target.cfg;
198
+
199
+ var dnsFlag = options.dns || resolveDns();
200
+ var crossCloud = dnsFlag && (target.kind === "service" || resolveCloudId(dnsFlag) !== target.id);
201
+ var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : (target.kind === "cloud" ? target.id : null);
202
+ var dnsCfg = crossCloud ? getCloudCfg(dnsCloud) : appCfg;
203
+ var dnsProvider = crossCloud ? await getProvider(dnsCloud, "dns") : await target.provider("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/service
209
+ await dnsProvider.removeDnsRecord(dnsCfg, domain);
210
+
211
+ var appProvider = await target.provider("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
+ }