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,111 @@
1
+ import { fatal, fmt } from "../lib/output.js";
2
+ import { resolveAppName } from "../lib/link.js";
3
+ import { resolveTarget } from "../lib/providers/resolve.js";
4
+ import kleur from "kleur";
5
+
6
+ export async function logs(name, options) {
7
+ name = resolveAppName(name);
8
+ var target = await resolveTarget(options);
9
+ var cfg = target.cfg;
10
+ var appProvider = await target.provider("app");
11
+
12
+ process.stderr.write(
13
+ `Tailing logs for ${fmt.app(name)}... ${fmt.dim("(ctrl+c to stop)")}\n\n`
14
+ );
15
+
16
+ var tail;
17
+ try {
18
+ tail = await appProvider.streamLogs(cfg, name);
19
+ } catch (e) {
20
+ fatal(
21
+ `Could not start log tail for ${fmt.app(name)}.`,
22
+ e.message
23
+ );
24
+ }
25
+
26
+ // Non-WebSocket path (GCP polling)
27
+ if (!tail.url) {
28
+ process.on("SIGINT", async function () {
29
+ process.stderr.write(`\n${fmt.dim("Stopping tail...")}\n`);
30
+ await tail.cleanup();
31
+ process.exit(0);
32
+ });
33
+ // Keep process alive while polling happens in the background
34
+ await new Promise(() => {});
35
+ return;
36
+ }
37
+
38
+ // WebSocket path (Cloudflare)
39
+ var ws = new WebSocket(tail.url, "trace-v1");
40
+
41
+ ws.addEventListener("open", function () {
42
+ ws.send(JSON.stringify({}));
43
+ });
44
+
45
+ ws.addEventListener("message", async function (event) {
46
+ var raw = typeof event.data === "string" ? event.data : await event.data.text();
47
+ var data = JSON.parse(raw);
48
+
49
+ for (var evt of Array.isArray(data) ? data : [data]) {
50
+ var ts = evt.eventTimestamp
51
+ ? new Date(evt.eventTimestamp).toISOString()
52
+ : new Date().toISOString();
53
+ var method = evt.event?.request?.method || "";
54
+ var url = evt.event?.request?.url || "";
55
+ var statusCode = evt.event?.response?.status || "";
56
+ var outcome = evt.outcome || "";
57
+
58
+ if (method) {
59
+ var statusColor =
60
+ statusCode >= 500
61
+ ? kleur.red(statusCode)
62
+ : statusCode >= 400
63
+ ? kleur.yellow(statusCode)
64
+ : kleur.green(statusCode);
65
+ console.log(
66
+ `${kleur.dim(ts)} ${kleur.bold(method)} ${url} ${statusColor} ${kleur.dim(`[${outcome}]`)}`
67
+ );
68
+ }
69
+
70
+ if (evt.logs) {
71
+ for (var log of evt.logs) {
72
+ var level = log.level || "log";
73
+ var msg = (log.message || []).join(" ");
74
+ var levelColor =
75
+ level === "error"
76
+ ? kleur.red(`[${level}]`)
77
+ : level === "warn"
78
+ ? kleur.yellow(`[${level}]`)
79
+ : kleur.dim(`[${level}]`);
80
+ console.log(`${kleur.dim(ts)} ${levelColor} ${msg}`);
81
+ }
82
+ }
83
+
84
+ if (evt.exceptions) {
85
+ for (var ex of evt.exceptions) {
86
+ console.error(
87
+ `${kleur.dim(ts)} ${kleur.red("[exception]")} ${ex.name}: ${ex.message}`
88
+ );
89
+ }
90
+ }
91
+ }
92
+ });
93
+
94
+ ws.addEventListener("error", function (event) {
95
+ process.stderr.write(
96
+ `${kleur.red("WebSocket error:")} ${event.message || "connection failed"}\n`
97
+ );
98
+ });
99
+
100
+ ws.addEventListener("close", function () {
101
+ process.stderr.write(`\n${fmt.dim("Tail disconnected.")}\n`);
102
+ process.exit(0);
103
+ });
104
+
105
+ process.on("SIGINT", async function () {
106
+ process.stderr.write(`\n${fmt.dim("Stopping tail...")}\n`);
107
+ ws.close();
108
+ await tail.cleanup();
109
+ process.exit(0);
110
+ });
111
+ }
@@ -0,0 +1,42 @@
1
+ import { execSync } from "child_process";
2
+ import { platform } from "os";
3
+ import { fatal, fmt } from "../lib/output.js";
4
+ import { resolveAppName } from "../lib/link.js";
5
+ import { resolveTarget } from "../lib/providers/resolve.js";
6
+
7
+ export async function open(name, options) {
8
+ name = resolveAppName(name);
9
+ var target = await resolveTarget(options);
10
+ var cfg = target.cfg;
11
+ var appProvider = await target.provider("app");
12
+
13
+ var url = await appProvider.getAppUrl(cfg, name);
14
+
15
+ if (!url) {
16
+ fatal(
17
+ "Could not resolve app URL.",
18
+ "Ensure your app is deployed and has a URL configured."
19
+ );
20
+ }
21
+
22
+ process.stderr.write(`Opening ${fmt.url(url)}...\n`);
23
+
24
+ var cmd;
25
+ switch (platform()) {
26
+ case "darwin":
27
+ cmd = "open";
28
+ break;
29
+ case "win32":
30
+ cmd = "start";
31
+ break;
32
+ default:
33
+ cmd = "xdg-open";
34
+ break;
35
+ }
36
+
37
+ try {
38
+ execSync(`${cmd} "${url}"`, { stdio: "ignore" });
39
+ } catch {
40
+ console.log(url);
41
+ }
42
+ }
@@ -0,0 +1,121 @@
1
+ import { fatal, fmt, table } from "../lib/output.js";
2
+ import { resolveAppName } from "../lib/link.js";
3
+ import { resolveTarget } from "../lib/providers/resolve.js";
4
+ import kleur from "kleur";
5
+
6
+ export async function ps(name, options) {
7
+ name = resolveAppName(name);
8
+ var target = await resolveTarget(options);
9
+ var cfg = target.cfg;
10
+ var appProvider = await target.provider("app");
11
+
12
+ var appConfig = await appProvider.getAppConfig(cfg, name);
13
+
14
+ if (!appConfig) {
15
+ fatal(
16
+ `App ${fmt.app(name)} not found.`,
17
+ `Run ${fmt.cmd(`relight deploy ${name} .`)} first.`
18
+ );
19
+ }
20
+
21
+ var instances = appConfig.instances || 2;
22
+
23
+ // Fetch live metrics
24
+ var metrics = await appProvider.getContainerStatus(cfg, name);
25
+
26
+ // Aggregate: group by region + durableObjectId, keep only active instances
27
+ var containers = [];
28
+ for (var row of metrics) {
29
+ var dim = row.dimensions;
30
+ if (!dim.active) continue;
31
+ var existing = containers.find(
32
+ (c) => c.region === dim.region && c.doId === dim.durableObjectId
33
+ );
34
+ if (existing) {
35
+ existing.cpuSamples++;
36
+ existing.cpuLoad += row.avg?.cpuLoad || 0;
37
+ existing.memory += row.avg?.memory || 0;
38
+ } else {
39
+ containers.push({
40
+ region: dim.region,
41
+ doId: dim.durableObjectId,
42
+ cpuLoad: row.avg?.cpuLoad || 0,
43
+ memory: row.avg?.memory || 0,
44
+ cpuSamples: 1,
45
+ });
46
+ }
47
+ }
48
+ for (var c of containers) {
49
+ c.cpuLoad = c.cpuLoad / c.cpuSamples;
50
+ c.memory = c.memory / c.cpuSamples;
51
+ }
52
+ containers.sort((a, b) => a.region.localeCompare(b.region) || a.doId.localeCompare(b.doId));
53
+
54
+ if (options.json) {
55
+ console.log(
56
+ JSON.stringify(
57
+ {
58
+ name,
59
+ image: appConfig.image,
60
+ regions: appConfig.regions,
61
+ instances,
62
+ containers: containers.map((c) => ({
63
+ region: c.region,
64
+ id: c.doId,
65
+ cpu: +(c.cpuLoad * 100).toFixed(1),
66
+ memoryMiB: +(c.memory / 1024 / 1024).toFixed(0),
67
+ })),
68
+ },
69
+ null,
70
+ 2
71
+ )
72
+ );
73
+ return;
74
+ }
75
+
76
+ var url = await appProvider.getAppUrl(cfg, name);
77
+ var customDomains = appConfig.domains || [];
78
+
79
+ console.log("");
80
+ console.log(`${fmt.bold("App:")} ${fmt.app(name)}`);
81
+ if (customDomains.length > 0) {
82
+ console.log(`${fmt.bold("URL:")} ${fmt.url(`https://${customDomains[0]}`)}`);
83
+ for (var d of customDomains.slice(1)) {
84
+ console.log(` ${fmt.url(`https://${d}`)}`);
85
+ }
86
+ if (url) {
87
+ console.log(` ${fmt.dim(url)}`);
88
+ }
89
+ } else if (url) {
90
+ console.log(`${fmt.bold("URL:")} ${fmt.url(url)}`);
91
+ }
92
+ console.log(
93
+ `${fmt.bold("Image:")} ${appConfig.image || fmt.dim("(not deployed)")}`
94
+ );
95
+ console.log(`${fmt.bold("Regions:")} ${appConfig.regions.join(", ")}`);
96
+ console.log(`${fmt.bold("Instances:")} ${instances} per region`);
97
+
98
+ if (appConfig.deployedAt) {
99
+ console.log(`${fmt.bold("Deployed:")} ${appConfig.deployedAt}`);
100
+ }
101
+
102
+ console.log(`\n${fmt.bold("Containers:")}`);
103
+
104
+ if (containers.length > 0) {
105
+ var headers = ["", "REGION", "ID", "CPU", "MEMORY"];
106
+ var rows = containers.map((c) => [
107
+ kleur.green("*"),
108
+ c.region,
109
+ c.doId.slice(0, 8),
110
+ (c.cpuLoad * 100).toFixed(1) + "%",
111
+ (c.memory / 1024 / 1024).toFixed(0) + " MiB",
112
+ ]);
113
+ console.log(table(headers, rows));
114
+ } else {
115
+ console.log(fmt.dim(" No active containers (app may be sleeping)"));
116
+ }
117
+
118
+ console.log(
119
+ `\n${fmt.dim("Metrics from the last 15 minutes. Expect some delay in reporting.")}`
120
+ );
121
+ }
@@ -0,0 +1,132 @@
1
+ import { success, fatal, fmt } from "../lib/output.js";
2
+ import { resolveAppName } from "../lib/link.js";
3
+ import { resolveTarget } from "../lib/providers/resolve.js";
4
+
5
+ export async function scale(name, options) {
6
+ name = resolveAppName(name);
7
+ var target = await resolveTarget(options);
8
+ var cfg = target.cfg;
9
+ var appProvider = await target.provider("app");
10
+
11
+ var appConfig = await appProvider.getAppConfig(cfg, name);
12
+
13
+ if (!appConfig) {
14
+ fatal(
15
+ `App ${fmt.app(name)} not found.`,
16
+ `Run ${fmt.cmd(`relight deploy ${name} .`)} first.`
17
+ );
18
+ }
19
+
20
+ var changed = false;
21
+
22
+ if (options.regions) {
23
+ var validRegions = appProvider.getRegions();
24
+ var validCodes = validRegions.map((r) => r.code);
25
+ var regions = options.regions.split(",").map((r) => r.trim().toLowerCase());
26
+ for (var r of regions) {
27
+ if (!validCodes.includes(r)) {
28
+ fatal(
29
+ `Invalid location hint '${r}'.`,
30
+ `Valid hints: ${validCodes.join(", ")}`
31
+ );
32
+ }
33
+ }
34
+ appConfig.regions = regions;
35
+ changed = true;
36
+ }
37
+
38
+ if (options.instances) {
39
+ appConfig.instances = options.instances;
40
+ changed = true;
41
+ }
42
+
43
+ if (options.instanceType) {
44
+ appConfig.instanceType = options.instanceType;
45
+ delete appConfig.vcpu;
46
+ delete appConfig.memory;
47
+ delete appConfig.disk;
48
+ changed = true;
49
+ }
50
+ if (options.vcpu) {
51
+ appConfig.vcpu = options.vcpu;
52
+ delete appConfig.instanceType;
53
+ changed = true;
54
+ }
55
+ if (options.memory) {
56
+ appConfig.memory = options.memory;
57
+ delete appConfig.instanceType;
58
+ changed = true;
59
+ }
60
+ if (options.disk) {
61
+ appConfig.disk = options.disk;
62
+ delete appConfig.instanceType;
63
+ changed = true;
64
+ }
65
+
66
+ if (!changed) {
67
+ // Show current scale
68
+ if (options.json) {
69
+ console.log(
70
+ JSON.stringify(
71
+ {
72
+ regions: appConfig.regions,
73
+ instances: appConfig.instances,
74
+ instanceType: appConfig.instanceType,
75
+ vcpu: appConfig.vcpu,
76
+ memory: appConfig.memory,
77
+ disk: appConfig.disk,
78
+ },
79
+ null,
80
+ 2
81
+ )
82
+ );
83
+ return;
84
+ }
85
+
86
+ console.log(`\n${fmt.bold("App:")} ${fmt.app(name)}`);
87
+ console.log(`${fmt.bold("Regions:")} ${appConfig.regions.join(", ")}`);
88
+ console.log(`${fmt.bold("Instances:")} ${appConfig.instances} per region`);
89
+ if (appConfig.vcpu || appConfig.memory || appConfig.disk) {
90
+ if (appConfig.vcpu) console.log(`${fmt.bold("vCPU:")} ${appConfig.vcpu}`);
91
+ if (appConfig.memory) console.log(`${fmt.bold("Memory:")} ${appConfig.memory} MiB`);
92
+ if (appConfig.disk) console.log(`${fmt.bold("Disk:")} ${appConfig.disk} MB`);
93
+ } else {
94
+ console.log(`${fmt.bold("Type:")} ${appConfig.instanceType || "lite"}`);
95
+ }
96
+ console.log(
97
+ `\n${fmt.dim("Geo-routing is automatic - requests route to the closest deployed region.")}`
98
+ );
99
+ return;
100
+ }
101
+
102
+ await appProvider.scale(cfg, name, { appConfig });
103
+
104
+ if (options.json) {
105
+ console.log(
106
+ JSON.stringify(
107
+ {
108
+ regions: appConfig.regions,
109
+ instances: appConfig.instances,
110
+ instanceType: appConfig.instanceType,
111
+ vcpu: appConfig.vcpu,
112
+ memory: appConfig.memory,
113
+ disk: appConfig.disk,
114
+ },
115
+ null,
116
+ 2
117
+ )
118
+ );
119
+ return;
120
+ }
121
+
122
+ success(`Scaled ${fmt.app(name)} (live).`);
123
+ process.stderr.write(` Regions: ${appConfig.regions.join(", ")}\n`);
124
+ process.stderr.write(` Instances: ${appConfig.instances}\n`);
125
+ if (appConfig.vcpu || appConfig.memory || appConfig.disk) {
126
+ if (appConfig.vcpu) process.stderr.write(` vCPU: ${appConfig.vcpu}\n`);
127
+ if (appConfig.memory) process.stderr.write(` Memory: ${appConfig.memory} MiB\n`);
128
+ if (appConfig.disk) process.stderr.write(` Disk: ${appConfig.disk} MB\n`);
129
+ } else {
130
+ process.stderr.write(` Type: ${appConfig.instanceType || "lite"}\n`);
131
+ }
132
+ }
@@ -0,0 +1,227 @@
1
+ import { createInterface } from "readline";
2
+ import { success, fatal, fmt, table } from "../lib/output.js";
3
+ import {
4
+ SERVICE_TYPES,
5
+ getRegisteredServices,
6
+ saveServiceConfig,
7
+ removeServiceConfig,
8
+ normalizeServiceConfig,
9
+ tryGetServiceConfig,
10
+ } from "../lib/config.js";
11
+ import { verifyConnection } from "../lib/clouds/slicervm.js";
12
+ import { verifyApiKey } from "../lib/clouds/neon.js";
13
+ import kleur from "kleur";
14
+
15
+ function prompt(rl, question) {
16
+ return new Promise((resolve) => rl.question(question, resolve));
17
+ }
18
+
19
+ export async function serviceList() {
20
+ var services = getRegisteredServices();
21
+
22
+ if (services.length === 0) {
23
+ process.stderr.write("No services registered.\n");
24
+ process.stderr.write(
25
+ `\n${fmt.dim("Hint:")} ${fmt.cmd("relight service add")} to register one.\n`
26
+ );
27
+ return;
28
+ }
29
+
30
+ var headers = ["NAME", "LAYER", "TYPE", "ENDPOINT"];
31
+ var rows = services.map((a) => [
32
+ fmt.bold(a.name),
33
+ a.layer,
34
+ SERVICE_TYPES[a.type]?.name || a.type,
35
+ a.socketPath || a.apiUrl || (a.type === "neon" ? "console.neon.tech" : "-"),
36
+ ]);
37
+
38
+ console.log(table(headers, rows));
39
+ }
40
+
41
+ export async function serviceAdd(name) {
42
+ var rl = createInterface({ input: process.stdin, output: process.stderr });
43
+
44
+ // 1. Pick layer
45
+ var layers = ["compute", "db"];
46
+ process.stderr.write(`\n${kleur.bold("Register a service")}\n\n`);
47
+ process.stderr.write(` ${kleur.bold("Layer:")}\n\n`);
48
+ for (var i = 0; i < layers.length; i++) {
49
+ process.stderr.write(` ${kleur.bold(`[${i + 1}]`)} ${layers[i]}\n`);
50
+ }
51
+ process.stderr.write("\n");
52
+
53
+ var layerChoice = await prompt(rl, `Select layer [1-${layers.length}]: `);
54
+ var layerIdx = parseInt(layerChoice, 10) - 1;
55
+ if (isNaN(layerIdx) || layerIdx < 0 || layerIdx >= layers.length) {
56
+ rl.close();
57
+ fatal("Invalid selection.");
58
+ }
59
+ var layer = layers[layerIdx];
60
+
61
+ // 2. Pick type (contextual to layer)
62
+ var types = Object.entries(SERVICE_TYPES)
63
+ .filter(([, v]) => v.layer === layer)
64
+ .map(([id, v]) => ({ id, name: v.name }));
65
+
66
+ process.stderr.write(`\n ${kleur.bold("Type:")}\n\n`);
67
+ for (var i = 0; i < types.length; i++) {
68
+ process.stderr.write(
69
+ ` ${kleur.bold(`[${i + 1}]`)} ${types[i].name}\n`
70
+ );
71
+ }
72
+ process.stderr.write("\n");
73
+
74
+ var typeChoice = await prompt(rl, `Select type [1-${types.length}]: `);
75
+ var typeIdx = parseInt(typeChoice, 10) - 1;
76
+ if (isNaN(typeIdx) || typeIdx < 0 || typeIdx >= types.length) {
77
+ rl.close();
78
+ fatal("Invalid selection.");
79
+ }
80
+ var serviceType = types[typeIdx].id;
81
+
82
+ // 3. Connection details (SlicerVM-specific)
83
+ var config = { layer, type: serviceType };
84
+
85
+ if (serviceType === "slicervm") {
86
+ process.stderr.write(`\n ${kleur.bold("Connection mode")}\n\n`);
87
+ process.stderr.write(` ${kleur.bold("[1]")} Unix socket (local dev)\n`);
88
+ process.stderr.write(` ${kleur.bold("[2]")} HTTP API (remote)\n\n`);
89
+
90
+ var modeChoice = await prompt(rl, "Select [1-2]: ");
91
+ var useSocket = modeChoice.trim() === "1";
92
+
93
+ if (useSocket) {
94
+ var defaultSocket = "/var/run/slicer/slicer.sock";
95
+ var socketPath = await prompt(rl, `Socket path [${defaultSocket}]: `);
96
+ socketPath = (socketPath || "").trim() || defaultSocket;
97
+ config.socketPath = socketPath;
98
+ } else {
99
+ var apiUrl = await prompt(
100
+ rl,
101
+ "Slicer API URL (e.g. https://slicer.example.com:8080): "
102
+ );
103
+ apiUrl = (apiUrl || "").trim().replace(/\/+$/, "");
104
+ if (!apiUrl) {
105
+ rl.close();
106
+ fatal("No API URL provided.");
107
+ }
108
+ config.apiUrl = apiUrl;
109
+
110
+ var token = await prompt(rl, "API token: ");
111
+ token = (token || "").trim();
112
+ if (!token) {
113
+ rl.close();
114
+ fatal("No token provided.");
115
+ }
116
+ config.token = token;
117
+ }
118
+
119
+ var hostGroup = await prompt(rl, "Host group [apps]: ");
120
+ config.hostGroup = (hostGroup || "").trim() || "apps";
121
+
122
+ var baseDomain = await prompt(
123
+ rl,
124
+ "Base domain (e.g. apps.example.com) [localhost]: "
125
+ );
126
+ config.baseDomain = (baseDomain || "").trim() || "localhost";
127
+
128
+ // 4. Verify connection
129
+ process.stderr.write("\nVerifying...\n");
130
+ var verifyCfg = normalizeServiceConfig(config);
131
+ try {
132
+ await verifyConnection(verifyCfg);
133
+ } catch (e) {
134
+ rl.close();
135
+ fatal("Connection failed.", e.message);
136
+ }
137
+
138
+ if (useSocket) {
139
+ process.stderr.write(` Socket: ${fmt.bold(config.socketPath)}\n`);
140
+ } else {
141
+ process.stderr.write(` API: ${fmt.bold(config.apiUrl)}\n`);
142
+ }
143
+ process.stderr.write(` Host group: ${fmt.dim(config.hostGroup)}\n`);
144
+ process.stderr.write(` Base domain: ${fmt.dim(config.baseDomain)}\n`);
145
+ } else if (serviceType === "neon") {
146
+ process.stderr.write(`\n ${kleur.bold("Neon API key")}\n\n`);
147
+ process.stderr.write(
148
+ ` ${fmt.dim("Get your API key at https://console.neon.tech/app/settings/api-keys")}\n\n`
149
+ );
150
+
151
+ var apiKey = await prompt(rl, "API key: ");
152
+ apiKey = (apiKey || "").trim();
153
+ if (!apiKey) {
154
+ rl.close();
155
+ fatal("No API key provided.");
156
+ }
157
+ config.apiKey = apiKey;
158
+
159
+ // Verify connection
160
+ process.stderr.write("\nVerifying...\n");
161
+ try {
162
+ var projects = await verifyApiKey(apiKey);
163
+ process.stderr.write(
164
+ ` Authenticated. ${projects.length} existing project${projects.length === 1 ? "" : "s"}.\n`
165
+ );
166
+ } catch (e) {
167
+ rl.close();
168
+ fatal("Authentication failed.", e.message);
169
+ }
170
+ }
171
+
172
+ // 5. Auto-name if not provided
173
+ if (!name) {
174
+ var existing = getRegisteredServices().filter((a) => a.type === serviceType);
175
+ if (existing.length === 0) {
176
+ name = serviceType;
177
+ } else {
178
+ name = `${serviceType}-${existing.length + 1}`;
179
+ }
180
+ var inputName = await prompt(rl, `Service name [${name}]: `);
181
+ name = (inputName || "").trim() || name;
182
+ }
183
+
184
+ // Check for existing
185
+ if (tryGetServiceConfig(name)) {
186
+ var overwrite = await prompt(
187
+ rl,
188
+ `Service '${name}' already exists. Overwrite? [y/N] `
189
+ );
190
+ if (!overwrite.match(/^y(es)?$/i)) {
191
+ rl.close();
192
+ process.stderr.write("Cancelled.\n");
193
+ process.exit(0);
194
+ }
195
+ }
196
+
197
+ rl.close();
198
+
199
+ // 6. Save
200
+ saveServiceConfig(name, config);
201
+
202
+ success(`Service ${fmt.bold(name)} registered!`);
203
+ }
204
+
205
+ export async function serviceRemove(name) {
206
+ if (!name) {
207
+ fatal("Usage: relight service remove <name>");
208
+ }
209
+
210
+ if (!tryGetServiceConfig(name)) {
211
+ fatal(`Service '${name}' not found.`);
212
+ }
213
+
214
+ var rl = createInterface({ input: process.stdin, output: process.stderr });
215
+ var answer = await new Promise((resolve) =>
216
+ rl.question(`Remove service '${name}'? [y/N] `, resolve)
217
+ );
218
+ rl.close();
219
+
220
+ if (!answer.match(/^y(es)?$/i)) {
221
+ process.stderr.write("Cancelled.\n");
222
+ return;
223
+ }
224
+
225
+ removeServiceConfig(name);
226
+ success(`Service ${fmt.bold(name)} removed.`);
227
+ }