relight-cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -2,6 +2,8 @@ import { createSign } from "crypto";
2
2
  import { readFileSync } from "fs";
3
3
 
4
4
  export var RUN_API = "https://run.googleapis.com/v2";
5
+ var FIREBASE_API = "https://firebase.googleapis.com/v1beta1";
6
+ var FIREBASE_HOSTING_API = "https://firebasehosting.googleapis.com/v1beta1";
5
7
  var CRM_API = "https://cloudresourcemanager.googleapis.com/v1";
6
8
  var TOKEN_URI = "https://oauth2.googleapis.com/token";
7
9
  var SCOPE = "https://www.googleapis.com/auth/cloud-platform";
@@ -59,7 +61,7 @@ export async function mintAccessToken(clientEmail, privateKey) {
59
61
  var res = await fetch(TOKEN_URI, {
60
62
  method: "POST",
61
63
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
62
- body: `grant_type=${encodeURIComponent("urn:ietf:params:oauth:grant_type:jwt-bearer")}&assertion=${encodeURIComponent(jwt)}`,
64
+ body: `grant_type=${encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer")}&assertion=${encodeURIComponent(jwt)}`,
63
65
  });
64
66
 
65
67
  if (!res.ok) {
@@ -98,6 +100,8 @@ export async function gcpApi(method, url, body, token) {
98
100
  throw new Error(`GCP API ${method} ${url}: ${res.status} ${text}`);
99
101
  }
100
102
 
103
+ var contentType = res.headers.get("content-type") || "";
104
+ if (!contentType.includes("json")) return {};
101
105
  return res.json();
102
106
  }
103
107
 
@@ -302,6 +306,26 @@ export async function updateSqlUser(token, project, instanceName, userName, pass
302
306
  return waitForSqlOperation(token, project, op.name);
303
307
  }
304
308
 
309
+ export async function deleteSqlUser(token, project, instanceName, userName) {
310
+ var op = await gcpApi(
311
+ "DELETE",
312
+ `${SQLADMIN_API}/projects/${project}/instances/${instanceName}/users?name=${encodeURIComponent(userName)}`,
313
+ null,
314
+ token
315
+ );
316
+ return waitForSqlOperation(token, project, op.name);
317
+ }
318
+
319
+ export async function listSqlDatabases(token, project, instanceName) {
320
+ var res = await gcpApi(
321
+ "GET",
322
+ `${SQLADMIN_API}/projects/${project}/instances/${instanceName}/databases`,
323
+ null,
324
+ token
325
+ );
326
+ return res.items || [];
327
+ }
328
+
305
329
  // --- Cloud DNS ---
306
330
 
307
331
  export async function listManagedZones(token, project) {
@@ -333,6 +357,78 @@ export async function listResourceRecordSets(token, project, zoneName) {
333
357
  return res.rrsets || [];
334
358
  }
335
359
 
360
+ // --- Firebase ---
361
+
362
+ export async function ensureFirebaseProject(token, project) {
363
+ try {
364
+ await gcpApi("GET", `${FIREBASE_API}/projects/${project}`, null, token);
365
+ return;
366
+ } catch {}
367
+
368
+ // Try to add Firebase programmatically
369
+ try {
370
+ var op = await gcpApi("POST", `${FIREBASE_API}/projects/${project}:addFirebase`, {}, token);
371
+ if (op.name && !op.done) {
372
+ while (true) {
373
+ var status = await gcpApi("GET", `${FIREBASE_API}/${op.name}`, null, token);
374
+ if (status.done) {
375
+ if (status.error) throw new Error(status.error.message);
376
+ return;
377
+ }
378
+ await new Promise((r) => setTimeout(r, 2000));
379
+ }
380
+ }
381
+ return;
382
+ } catch {
383
+ throw new Error(
384
+ "Could not enable Firebase for this project.\n" +
385
+ " This usually means the Firebase Terms of Service have not been accepted.\n" +
386
+ " Visit https://console.firebase.google.com/ and add your GCP project there first."
387
+ );
388
+ }
389
+ }
390
+
391
+ // --- Firebase Hosting ---
392
+
393
+ export async function createHostingSite(token, project, siteId) {
394
+ return gcpApi("POST", `${FIREBASE_HOSTING_API}/projects/${project}/sites?siteId=${siteId}`, {}, token);
395
+ }
396
+
397
+ export async function getHostingSite(token, project, siteId) {
398
+ return gcpApi("GET", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}`, null, token);
399
+ }
400
+
401
+ export async function deleteHostingSite(token, project, siteId) {
402
+ return gcpApi("DELETE", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}`, null, token);
403
+ }
404
+
405
+ export async function deployHostingProxy(token, siteId, serviceId, region) {
406
+ // Create version with Cloud Run rewrite
407
+ var version = await gcpApi("POST", `${FIREBASE_HOSTING_API}/sites/${siteId}/versions`, {
408
+ config: {
409
+ rewrites: [{ glob: "**", run: { serviceId, region } }],
410
+ },
411
+ }, token);
412
+
413
+ var versionId = version.name.split("/").pop();
414
+
415
+ // Finalize version
416
+ await gcpApi("PATCH", `${FIREBASE_HOSTING_API}/sites/${siteId}/versions/${versionId}?update_mask=status`, {
417
+ status: "FINALIZED",
418
+ }, token);
419
+
420
+ // Create release
421
+ await gcpApi("POST", `${FIREBASE_HOSTING_API}/sites/${siteId}/releases?versionName=sites/${siteId}/versions/${versionId}`, {}, token);
422
+ }
423
+
424
+ export async function addHostingCustomDomain(token, project, siteId, domain) {
425
+ return gcpApi("POST", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}/customDomains?customDomainId=${domain}`, {}, token);
426
+ }
427
+
428
+ export async function deleteHostingCustomDomain(token, project, siteId, domain) {
429
+ return gcpApi("DELETE", `${FIREBASE_HOSTING_API}/projects/${project}/sites/${siteId}/customDomains/${domain}`, null, token);
430
+ }
431
+
336
432
  // --- Cloud Logging ---
337
433
 
338
434
  export async function listLogEntries(token, body) {
@@ -0,0 +1,147 @@
1
+ var API_BASE = "https://console.neon.tech/api/v2";
2
+
3
+ export async function neonApi(apiKey, method, path, body) {
4
+ var headers = {
5
+ Authorization: `Bearer ${apiKey}`,
6
+ Accept: "application/json",
7
+ };
8
+
9
+ if (body && typeof body === "object") {
10
+ headers["Content-Type"] = "application/json";
11
+ body = JSON.stringify(body);
12
+ }
13
+
14
+ var res = await fetch(`${API_BASE}${path}`, {
15
+ method,
16
+ headers,
17
+ body: method === "GET" || method === "DELETE" ? undefined : body,
18
+ });
19
+
20
+ if (!res.ok) {
21
+ var text = await res.text();
22
+ throw new Error(`Neon API ${method} ${path}: ${res.status} ${text}`);
23
+ }
24
+
25
+ if (res.status === 204) return null;
26
+ return res.json();
27
+ }
28
+
29
+ // --- Projects ---
30
+
31
+ export async function listProjects(apiKey) {
32
+ var data = await neonApi(apiKey, "GET", "/projects");
33
+ return data.projects || [];
34
+ }
35
+
36
+ export async function createProject(apiKey, opts = {}) {
37
+ var body = {
38
+ project: {
39
+ name: opts.name || "relight",
40
+ pg_version: opts.pgVersion || 16,
41
+ },
42
+ };
43
+ if (opts.regionId) body.project.region_id = opts.regionId;
44
+ return neonApi(apiKey, "POST", "/projects", body);
45
+ }
46
+
47
+ export async function getProject(apiKey, projectId) {
48
+ return neonApi(apiKey, "GET", `/projects/${projectId}`);
49
+ }
50
+
51
+ export async function deleteProject(apiKey, projectId) {
52
+ return neonApi(apiKey, "DELETE", `/projects/${projectId}`);
53
+ }
54
+
55
+ // --- Branches ---
56
+
57
+ export async function listBranches(apiKey, projectId) {
58
+ var data = await neonApi(apiKey, "GET", `/projects/${projectId}/branches`);
59
+ return data.branches || [];
60
+ }
61
+
62
+ // --- Databases ---
63
+
64
+ export async function listDatabases(apiKey, projectId, branchId) {
65
+ var data = await neonApi(
66
+ apiKey,
67
+ "GET",
68
+ `/projects/${projectId}/branches/${branchId}/databases`
69
+ );
70
+ return data.databases || [];
71
+ }
72
+
73
+ export async function createDatabase(apiKey, projectId, branchId, dbName, ownerName) {
74
+ return neonApi(
75
+ apiKey,
76
+ "POST",
77
+ `/projects/${projectId}/branches/${branchId}/databases`,
78
+ { database: { name: dbName, owner_name: ownerName } }
79
+ );
80
+ }
81
+
82
+ export async function deleteDatabase(apiKey, projectId, branchId, dbName) {
83
+ return neonApi(
84
+ apiKey,
85
+ "DELETE",
86
+ `/projects/${projectId}/branches/${branchId}/databases/${dbName}`
87
+ );
88
+ }
89
+
90
+ // --- Roles ---
91
+
92
+ export async function createRole(apiKey, projectId, branchId, roleName) {
93
+ return neonApi(
94
+ apiKey,
95
+ "POST",
96
+ `/projects/${projectId}/branches/${branchId}/roles`,
97
+ { role: { name: roleName } }
98
+ );
99
+ }
100
+
101
+ export async function deleteRole(apiKey, projectId, branchId, roleName) {
102
+ return neonApi(
103
+ apiKey,
104
+ "DELETE",
105
+ `/projects/${projectId}/branches/${branchId}/roles/${roleName}`
106
+ );
107
+ }
108
+
109
+ export async function getRolePassword(apiKey, projectId, branchId, roleName) {
110
+ var data = await neonApi(
111
+ apiKey,
112
+ "GET",
113
+ `/projects/${projectId}/branches/${branchId}/roles/${roleName}/reveal_password`
114
+ );
115
+ return data.password;
116
+ }
117
+
118
+ export async function resetRolePassword(apiKey, projectId, branchId, roleName) {
119
+ var data = await neonApi(
120
+ apiKey,
121
+ "POST",
122
+ `/projects/${projectId}/branches/${branchId}/roles/${roleName}/reset_password`
123
+ );
124
+ return data.password;
125
+ }
126
+
127
+ // --- Connection URI ---
128
+
129
+ export async function getConnectionUri(apiKey, projectId, dbName, roleName) {
130
+ var params = new URLSearchParams({
131
+ database_name: dbName,
132
+ role_name: roleName,
133
+ });
134
+ var data = await neonApi(
135
+ apiKey,
136
+ "GET",
137
+ `/projects/${projectId}/connection_uri?${params}`
138
+ );
139
+ return data.uri;
140
+ }
141
+
142
+ // --- Verification ---
143
+
144
+ export async function verifyApiKey(apiKey) {
145
+ var projects = await listProjects(apiKey);
146
+ return projects;
147
+ }