relight-cli 0.2.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.
@@ -1,13 +1,12 @@
1
1
  import { createInterface } from "readline";
2
- import { randomBytes } from "crypto";
3
2
  import { phase, status, success, hint, fatal, fmt, generateAppName } from "../lib/output.js";
4
3
  import { readLink, linkApp, resolveAppName } from "../lib/link.js";
5
- import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
4
+ import { resolveTarget } from "../lib/providers/resolve.js";
6
5
  import { dockerBuild, dockerTag, dockerPush, dockerLogin } from "../lib/docker.js";
7
6
 
8
7
  export async function deploy(nameOrPath, path, options) {
9
- var cloud = resolveCloudId(options.cloud);
10
- var cfg = getCloudCfg(cloud);
8
+ var target = await resolveTarget(options);
9
+ var cfg = target.cfg;
11
10
 
12
11
  // Smart arg parsing: if first arg looks like a path, shift args
13
12
  var name;
@@ -26,12 +25,12 @@ export async function deploy(nameOrPath, path, options) {
26
25
  var tag = options.tag || `${Date.now()}`;
27
26
 
28
27
  // Get registry credentials and image tag
29
- var registry = await getProvider(cloud, "registry");
28
+ var registry = await target.provider("registry");
30
29
  var remoteTag = await registry.getImageTag(cfg, name, tag);
31
30
  var localTag = `relight-${name}:${tag}`;
32
31
 
33
32
  // Load existing config from deployed worker (null on first deploy)
34
- var appProvider = await getProvider(cloud, "app");
33
+ var appProvider = await target.provider("app");
35
34
  var appConfig;
36
35
  try {
37
36
  appConfig = await appProvider.getAppConfig(cfg, name);
@@ -41,7 +40,7 @@ export async function deploy(nameOrPath, path, options) {
41
40
 
42
41
  var isFirstDeploy = !appConfig;
43
42
 
44
- // Get valid regions for this cloud
43
+ // Get valid regions for this cloud/service
45
44
  var validRegions = appProvider.getRegions();
46
45
  var validCodes = validRegions.map((r) => r.code);
47
46
 
@@ -89,12 +88,13 @@ export async function deploy(nameOrPath, path, options) {
89
88
  }
90
89
  }
91
90
 
92
- var defaultRegion = cloud === "gcp" ? "us-central1" : cloud === "aws" ? "us-east-1" : cloud === "slicervm" ? "self-hosted" : "enam";
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
93
  var regions;
94
94
 
95
95
  if (options.regions) {
96
96
  regions = options.regions.split(",").map((r) => r.trim());
97
- } else if ((cloud === "gcp" || cloud === "aws") && process.stdin.isTTY) {
97
+ } else if (!isService && (target.type === "gcp" || target.type === "aws") && process.stdin.isTTY) {
98
98
  // Interactive region picker for GCP/AWS first deploy
99
99
  var { createInterface: createRL } = await import("readline");
100
100
  var rl = createRL({ input: process.stdin, output: process.stderr });
@@ -130,10 +130,10 @@ export async function deploy(nameOrPath, path, options) {
130
130
  appConfig = {
131
131
  name,
132
132
  regions,
133
- instances: options.instances || (cloud === "slicervm" ? 1 : 2),
133
+ instances: options.instances || (isService ? 1 : 2),
134
134
  port: options.port || 8080,
135
135
  sleepAfter: options.sleep || "30s",
136
- instanceType: options.instanceType || (cloud === "gcp" || cloud === "aws" || cloud === "slicervm" ? undefined : "lite"),
136
+ instanceType: options.instanceType || (isService || target.type === "gcp" || target.type === "aws" ? undefined : "lite"),
137
137
  vcpu: options.vcpu || undefined,
138
138
  memory: options.memory || undefined,
139
139
  disk: options.disk || undefined,
@@ -147,48 +147,7 @@ export async function deploy(nameOrPath, path, options) {
147
147
  };
148
148
  }
149
149
 
150
- // --- D1 database (--db flag, CF only) ---
151
150
  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
151
 
193
152
  // --- Summary & confirmation ---
194
153
  var instanceDesc = appConfig.vcpu
@@ -198,7 +157,11 @@ export async function deploy(nameOrPath, path, options) {
198
157
  process.stderr.write(`\n${fmt.bold("Deploy summary")}\n`);
199
158
  process.stderr.write(`${fmt.dim("-".repeat(40))}\n`);
200
159
  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`);
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
+ }
202
165
  process.stderr.write(` ${fmt.bold("Path:")} ${dockerPath}\n`);
203
166
  process.stderr.write(` ${fmt.bold("Image:")} ${remoteTag}\n`);
204
167
  process.stderr.write(` ${fmt.bold("Regions:")} ${appConfig.regions.join(", ")}\n`);
@@ -225,7 +188,7 @@ export async function deploy(nameOrPath, path, options) {
225
188
 
226
189
  // 1. Build Docker image
227
190
  var platform = "linux/amd64";
228
- if (cloud === "slicervm") {
191
+ if (target.type === "slicervm") {
229
192
  // Match VM architecture
230
193
  var { listNodes } = await import("../lib/clouds/slicervm.js");
231
194
  var nodes = await listNodes(cfg);
@@ -236,8 +199,8 @@ export async function deploy(nameOrPath, path, options) {
236
199
  status(`${localTag} for ${platform}`);
237
200
  dockerBuild(dockerPath, localTag, { platform });
238
201
 
239
- if (cloud === "slicervm") {
240
- // SlicerVM: skip registry push - deploy extracts and uploads the image directly
202
+ if (target.kind === "service") {
203
+ // Service: skip registry push - deploy extracts and uploads the image directly
241
204
  phase("Deploying");
242
205
  await appProvider.deploy(cfg, name, localTag, {
243
206
  appConfig,
@@ -268,21 +231,20 @@ export async function deploy(nameOrPath, path, options) {
268
231
  var url = await appProvider.getAppUrl(cfg, name);
269
232
 
270
233
  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
- );
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));
286
248
  } else {
287
249
  success(`App ${fmt.app(name)} deployed!`);
288
250
  process.stderr.write(` ${fmt.bold("Name:")} ${fmt.app(name)}\n`);
@@ -294,5 +256,9 @@ export async function deploy(nameOrPath, path, options) {
294
256
  }
295
257
 
296
258
  // Link this directory to the app
297
- linkApp(name, cloud, options.dns, options.dbCloud);
259
+ if (target.kind === "service") {
260
+ linkApp(name, null, options.dns, null, target.id);
261
+ } else {
262
+ linkApp(name, target.id, options.dns);
263
+ }
298
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
12
  import { mintAccessToken, verifyProject as gcpVerifyProject, listRegions as gcpListRegions, gcpApi, AR_API, SQLADMIN_API, DNS_API } from "../lib/clouds/gcp.js";
10
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 {
@@ -204,7 +228,7 @@ function check(label, fn) {
204
228
  return true;
205
229
  } catch (e) {
206
230
  process.stderr.write(` ${FAIL} ${label}`);
207
- if (e.message) process.stderr.write(kleur.dim(` ${e.message}`));
231
+ if (e.message) process.stderr.write(kleur.dim(` - ${e.message}`));
208
232
  process.stderr.write("\n");
209
233
  return false;
210
234
  }
@@ -217,7 +241,7 @@ async function asyncCheck(label, fn) {
217
241
  return true;
218
242
  } catch (e) {
219
243
  process.stderr.write(` ${FAIL} ${label}`);
220
- 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)}`));
221
245
  process.stderr.write("\n");
222
246
  return false;
223
247
  }
@@ -1,7 +1,7 @@
1
1
  import { createInterface } from "readline";
2
2
  import { phase, status, success, fatal, hint, fmt } from "../lib/output.js";
3
3
  import { resolveAppName, resolveDns, readLink, linkApp } from "../lib/link.js";
4
- import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
4
+ import { resolveTarget, resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
5
5
  import kleur from "kleur";
6
6
 
7
7
  function prompt(rl, question) {
@@ -10,9 +10,9 @@ function prompt(rl, question) {
10
10
 
11
11
  export async function domainsList(name, options) {
12
12
  name = resolveAppName(name);
13
- var cloud = resolveCloudId(options.cloud);
14
- var cfg = getCloudCfg(cloud);
15
- var dnsProvider = await getProvider(cloud, "dns");
13
+ var target = await resolveTarget(options);
14
+ var cfg = target.cfg;
15
+ var dnsProvider = await target.provider("dns");
16
16
 
17
17
  var result = await dnsProvider.listDomains(cfg, name);
18
18
 
@@ -58,16 +58,16 @@ export async function domainsAdd(args, options) {
58
58
  domain = null;
59
59
  }
60
60
 
61
- var appCloud = resolveCloudId(options.cloud);
62
- var appCfg = getCloudCfg(appCloud);
63
- var appProvider = await getProvider(appCloud, "app");
61
+ var target = await resolveTarget(options);
62
+ var appCfg = target.cfg;
63
+ var appProvider = await target.provider("app");
64
64
 
65
65
  // Cross-cloud DNS: --dns flag or .relight dns field specifies a different cloud for DNS records
66
66
  var dnsFlag = options.dns || resolveDns();
67
- var crossCloud = dnsFlag && resolveCloudId(dnsFlag) !== appCloud;
68
- var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : appCloud;
67
+ var crossCloud = dnsFlag && (target.kind === "service" || resolveCloudId(dnsFlag) !== target.id);
68
+ var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : (target.kind === "cloud" ? target.id : null);
69
69
  var dnsCfg = crossCloud ? getCloudCfg(dnsCloud) : appCfg;
70
- var dnsProvider = await getProvider(dnsCloud, "dns");
70
+ var dnsProvider = crossCloud ? await getProvider(dnsCloud, "dns") : await target.provider("dns");
71
71
 
72
72
  var appConfig = await appProvider.getAppConfig(appCfg, name);
73
73
  if (!appConfig) {
@@ -147,15 +147,15 @@ export async function domainsAdd(args, options) {
147
147
  // Cross-cloud: DNS record on one cloud, app config on another
148
148
  status(`Creating DNS record for ${domain}...`);
149
149
  var appUrl = await appProvider.getAppUrl(appCfg, name);
150
- var target = new URL(appUrl).hostname;
150
+ var dnsTarget = new URL(appUrl).hostname;
151
151
  try {
152
- await dnsProvider.addDnsRecord(dnsCfg, domain, target, zone);
152
+ await dnsProvider.addDnsRecord(dnsCfg, domain, dnsTarget, zone);
153
153
  } catch (e) {
154
154
  fatal(e.message);
155
155
  }
156
156
 
157
- // Update app config on the app cloud
158
- status(`Updating app config on ${appCloud}...`);
157
+ // Update app config on the app cloud/service
158
+ status(`Updating app config...`);
159
159
  if (!appConfig.domains) appConfig.domains = [];
160
160
  if (!appConfig.domains.includes(domain)) {
161
161
  appConfig.domains.push(domain);
@@ -165,7 +165,7 @@ export async function domainsAdd(args, options) {
165
165
  // Persist dns cloud in .relight so future commands don't need --dns
166
166
  var linked = readLink();
167
167
  if (linked && !linked.dns) {
168
- linkApp(linked.app, linked.cloud, dnsCloud);
168
+ linkApp(linked.app, linked.cloud, dnsCloud, undefined, linked.compute);
169
169
  }
170
170
  } else {
171
171
  // Same-cloud: existing flow
@@ -193,22 +193,22 @@ export async function domainsRemove(args, options) {
193
193
  fatal("Usage: relight domains remove [name] <domain>");
194
194
  }
195
195
 
196
- var appCloud = resolveCloudId(options.cloud);
197
- var appCfg = getCloudCfg(appCloud);
196
+ var target = await resolveTarget(options);
197
+ var appCfg = target.cfg;
198
198
 
199
199
  var dnsFlag = options.dns || resolveDns();
200
- var crossCloud = dnsFlag && resolveCloudId(dnsFlag) !== appCloud;
201
- var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : appCloud;
200
+ var crossCloud = dnsFlag && (target.kind === "service" || resolveCloudId(dnsFlag) !== target.id);
201
+ var dnsCloud = crossCloud ? resolveCloudId(dnsFlag) : (target.kind === "cloud" ? target.id : null);
202
202
  var dnsCfg = crossCloud ? getCloudCfg(dnsCloud) : appCfg;
203
- var dnsProvider = await getProvider(dnsCloud, "dns");
203
+ var dnsProvider = crossCloud ? await getProvider(dnsCloud, "dns") : await target.provider("dns");
204
204
 
205
205
  status(`Removing ${domain}...`);
206
206
 
207
207
  if (crossCloud) {
208
- // Cross-cloud: remove DNS record from dns cloud, update app config on app cloud
208
+ // Cross-cloud: remove DNS record from dns cloud, update app config on app cloud/service
209
209
  await dnsProvider.removeDnsRecord(dnsCfg, domain);
210
210
 
211
- var appProvider = await getProvider(appCloud, "app");
211
+ var appProvider = await target.provider("app");
212
212
  var appConfig = await appProvider.getAppConfig(appCfg, name);
213
213
  if (appConfig) {
214
214
  appConfig.domains = (appConfig.domains || []).filter((d) => d !== domain);
@@ -1,13 +1,13 @@
1
1
  import { fatal, fmt } from "../lib/output.js";
2
2
  import { resolveAppName } from "../lib/link.js";
3
- import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
3
+ import { resolveTarget } from "../lib/providers/resolve.js";
4
4
  import kleur from "kleur";
5
5
 
6
6
  export async function logs(name, options) {
7
7
  name = resolveAppName(name);
8
- var cloud = resolveCloudId(options.cloud);
9
- var cfg = getCloudCfg(cloud);
10
- var appProvider = await getProvider(cloud, "app");
8
+ var target = await resolveTarget(options);
9
+ var cfg = target.cfg;
10
+ var appProvider = await target.provider("app");
11
11
 
12
12
  process.stderr.write(
13
13
  `Tailing logs for ${fmt.app(name)}... ${fmt.dim("(ctrl+c to stop)")}\n\n`
@@ -2,13 +2,13 @@ import { execSync } from "child_process";
2
2
  import { platform } from "os";
3
3
  import { fatal, fmt } from "../lib/output.js";
4
4
  import { resolveAppName } from "../lib/link.js";
5
- import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
5
+ import { resolveTarget } from "../lib/providers/resolve.js";
6
6
 
7
7
  export async function open(name, options) {
8
8
  name = resolveAppName(name);
9
- var cloud = resolveCloudId(options.cloud);
10
- var cfg = getCloudCfg(cloud);
11
- var appProvider = await getProvider(cloud, "app");
9
+ var target = await resolveTarget(options);
10
+ var cfg = target.cfg;
11
+ var appProvider = await target.provider("app");
12
12
 
13
13
  var url = await appProvider.getAppUrl(cfg, name);
14
14
 
@@ -1,13 +1,13 @@
1
1
  import { fatal, fmt, table } from "../lib/output.js";
2
2
  import { resolveAppName } from "../lib/link.js";
3
- import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
3
+ import { resolveTarget } from "../lib/providers/resolve.js";
4
4
  import kleur from "kleur";
5
5
 
6
6
  export async function ps(name, options) {
7
7
  name = resolveAppName(name);
8
- var cloud = resolveCloudId(options.cloud);
9
- var cfg = getCloudCfg(cloud);
10
- var appProvider = await getProvider(cloud, "app");
8
+ var target = await resolveTarget(options);
9
+ var cfg = target.cfg;
10
+ var appProvider = await target.provider("app");
11
11
 
12
12
  var appConfig = await appProvider.getAppConfig(cfg, name);
13
13
 
@@ -1,12 +1,12 @@
1
1
  import { success, fatal, fmt } from "../lib/output.js";
2
2
  import { resolveAppName } from "../lib/link.js";
3
- import { resolveCloudId, getCloudCfg, getProvider } from "../lib/providers/resolve.js";
3
+ import { resolveTarget } from "../lib/providers/resolve.js";
4
4
 
5
5
  export async function scale(name, options) {
6
6
  name = resolveAppName(name);
7
- var cloud = resolveCloudId(options.cloud);
8
- var cfg = getCloudCfg(cloud);
9
- var appProvider = await getProvider(cloud, "app");
7
+ var target = await resolveTarget(options);
8
+ var cfg = target.cfg;
9
+ var appProvider = await target.provider("app");
10
10
 
11
11
  var appConfig = await appProvider.getAppConfig(cfg, name);
12
12
 
@@ -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
+ }