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,513 @@
1
+ import { randomBytes } from "crypto";
2
+ import { awsQueryApi, awsJsonApi, xmlVal, xmlList, xmlBlock } from "../../clouds/aws.js";
3
+ import { getAppConfig, pushAppConfig } from "./app.js";
4
+
5
+ function instanceName(appName) {
6
+ return `relight-${appName}`;
7
+ }
8
+
9
+ function dbName(appName) {
10
+ return `relight_${appName.replace(/-/g, "_")}`;
11
+ }
12
+
13
+ async function connectPg(connectionUrl) {
14
+ var pg = await import("pg");
15
+ var Client = pg.default?.Client || pg.Client;
16
+ var client = new Client({ connectionString: connectionUrl });
17
+ await client.connect();
18
+ return client;
19
+ }
20
+
21
+ // --- Security group for RDS public access ---
22
+
23
+ async function ensureSecurityGroup(cfg) {
24
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
25
+ var sgName = "relight-rds-public";
26
+
27
+ // Check if security group exists
28
+ var descXml = await awsQueryApi(
29
+ "DescribeSecurityGroups",
30
+ { "Filter.1.Name": "group-name", "Filter.1.Value.1": sgName },
31
+ "ec2",
32
+ cr,
33
+ cfg.region
34
+ );
35
+
36
+ var sgBlock = xmlBlock(descXml, "securityGroupInfo");
37
+ var existing = sgBlock ? xmlBlock(sgBlock, "item") : null;
38
+ if (existing) {
39
+ var sgId = xmlVal(existing, "groupId");
40
+ if (sgId) return sgId;
41
+ }
42
+
43
+ // Get default VPC
44
+ var vpcXml = await awsQueryApi(
45
+ "DescribeVpcs",
46
+ { "Filter.1.Name": "isDefault", "Filter.1.Value.1": "true" },
47
+ "ec2",
48
+ cr,
49
+ cfg.region
50
+ );
51
+ var vpcId = xmlVal(vpcXml, "vpcId");
52
+ if (!vpcId) throw new Error("No default VPC found. Create one or specify a VPC.");
53
+
54
+ // Create security group
55
+ var createXml = await awsQueryApi(
56
+ "CreateSecurityGroup",
57
+ {
58
+ GroupName: sgName,
59
+ GroupDescription: "Relight RDS public access on port 5432",
60
+ VpcId: vpcId,
61
+ },
62
+ "ec2",
63
+ cr,
64
+ cfg.region
65
+ );
66
+ var newSgId = xmlVal(createXml, "groupId");
67
+ if (!newSgId) throw new Error("Failed to create security group.");
68
+
69
+ // Authorize inbound on port 5432
70
+ await awsQueryApi(
71
+ "AuthorizeSecurityGroupIngress",
72
+ {
73
+ GroupId: newSgId,
74
+ "IpPermissions.1.IpProtocol": "tcp",
75
+ "IpPermissions.1.FromPort": "5432",
76
+ "IpPermissions.1.ToPort": "5432",
77
+ "IpPermissions.1.IpRanges.1.CidrIp": "0.0.0.0/0",
78
+ },
79
+ "ec2",
80
+ cr,
81
+ cfg.region
82
+ );
83
+
84
+ return newSgId;
85
+ }
86
+
87
+ // --- Get DB password from App Runner env ---
88
+
89
+ async function getDbPassword(cfg, appName) {
90
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
91
+
92
+ // Find App Runner service and extract DB_TOKEN
93
+ var nextToken = null;
94
+ do {
95
+ var params = {};
96
+ if (nextToken) params.NextToken = nextToken;
97
+ var res = await awsJsonApi("AppRunner.ListServices", params, "apprunner", cr, cfg.region);
98
+
99
+ var svcName = `relight-${appName}`;
100
+ var match = (res.ServiceSummaryList || []).find((s) => s.ServiceName === svcName);
101
+ if (match) {
102
+ var descRes = await awsJsonApi(
103
+ "AppRunner.DescribeService",
104
+ { ServiceArn: match.ServiceArn },
105
+ "apprunner",
106
+ cr,
107
+ cfg.region
108
+ );
109
+ var envVars = descRes.Service?.SourceConfiguration?.ImageRepository?.ImageConfiguration?.RuntimeEnvironmentVariables || {};
110
+ if (envVars.DB_TOKEN) return envVars.DB_TOKEN;
111
+ throw new Error("DB_TOKEN not found on service.");
112
+ }
113
+
114
+ nextToken = res.NextToken;
115
+ } while (nextToken);
116
+
117
+ throw new Error(`Service relight-${appName} not found.`);
118
+ }
119
+
120
+ // --- Get connection URL ---
121
+
122
+ async function getConnectionUrl(cfg, appName, password) {
123
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
124
+ var instName = instanceName(appName);
125
+ var database = dbName(appName);
126
+
127
+ var xml = await awsQueryApi(
128
+ "DescribeDBInstances",
129
+ { DBInstanceIdentifier: instName },
130
+ "rds",
131
+ cr,
132
+ cfg.region
133
+ );
134
+
135
+ var endpointBlock = xmlBlock(xml, "Endpoint");
136
+ if (!endpointBlock) throw new Error("No endpoint found for RDS instance.");
137
+
138
+ var host = xmlVal(endpointBlock, "Address");
139
+ var port = xmlVal(endpointBlock, "Port") || "5432";
140
+
141
+ return `postgresql://relight:${encodeURIComponent(password)}@${host}:${port}/${database}`;
142
+ }
143
+
144
+ // --- Poll for RDS instance to become available ---
145
+
146
+ async function waitForInstance(cfg, instName) {
147
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
148
+
149
+ for (var i = 0; i < 180; i++) {
150
+ var xml = await awsQueryApi(
151
+ "DescribeDBInstances",
152
+ { DBInstanceIdentifier: instName },
153
+ "rds",
154
+ cr,
155
+ cfg.region
156
+ );
157
+
158
+ var status = xmlVal(xml, "DBInstanceStatus");
159
+ if (status === "available") return;
160
+ if (status === "failed" || status === "incompatible-parameters") {
161
+ throw new Error(`RDS instance reached status: ${status}`);
162
+ }
163
+
164
+ await new Promise((r) => setTimeout(r, 10000));
165
+ }
166
+ throw new Error("Timed out waiting for RDS instance to become available.");
167
+ }
168
+
169
+ // --- Public API ---
170
+
171
+ export async function createDatabase(cfg, appName, opts = {}) {
172
+ if (!opts.skipAppConfig) {
173
+ var appConfig = await getAppConfig(cfg, appName);
174
+ if (!appConfig) {
175
+ throw new Error(`App ${appName} not found.`);
176
+ }
177
+ if (appConfig.dbId) {
178
+ throw new Error(`App ${appName} already has a database: ${appConfig.dbId}`);
179
+ }
180
+ }
181
+
182
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
183
+ var instName = instanceName(appName);
184
+ var database = dbName(appName);
185
+
186
+ // Ensure security group for public access
187
+ var sgId = await ensureSecurityGroup(cfg);
188
+
189
+ // Generate password
190
+ var password = randomBytes(24).toString("base64url");
191
+
192
+ // Create RDS instance
193
+ await awsQueryApi(
194
+ "CreateDBInstance",
195
+ {
196
+ DBInstanceIdentifier: instName,
197
+ DBInstanceClass: "db.t4g.micro",
198
+ Engine: "postgres",
199
+ EngineVersion: "15",
200
+ MasterUsername: "relight",
201
+ MasterUserPassword: password,
202
+ DBName: database,
203
+ AllocatedStorage: "20",
204
+ PubliclyAccessible: "true",
205
+ "VpcSecurityGroupIds.member.1": sgId,
206
+ BackupRetentionPeriod: "0",
207
+ },
208
+ "rds",
209
+ cr,
210
+ cfg.region
211
+ );
212
+
213
+ // Wait for instance to become available (5-15 min)
214
+ process.stderr.write(" Waiting for RDS instance (this takes 5-15 minutes)...\n");
215
+ await waitForInstance(cfg, instName);
216
+
217
+ // Get connection URL
218
+ var connectionUrl = await getConnectionUrl(cfg, appName, password);
219
+
220
+ if (!opts.skipAppConfig) {
221
+ // Store in app config
222
+ appConfig.dbId = instName;
223
+ appConfig.dbName = database;
224
+
225
+ if (!appConfig.envKeys) appConfig.envKeys = [];
226
+ if (!appConfig.secretKeys) appConfig.secretKeys = [];
227
+ if (!appConfig.env) appConfig.env = {};
228
+
229
+ appConfig.env["DATABASE_URL"] = connectionUrl;
230
+ if (!appConfig.envKeys.includes("DATABASE_URL")) appConfig.envKeys.push("DATABASE_URL");
231
+
232
+ appConfig.env["DB_TOKEN"] = "[hidden]";
233
+ appConfig.secretKeys = appConfig.secretKeys.filter((k) => k !== "DB_TOKEN");
234
+ appConfig.secretKeys.push("DB_TOKEN");
235
+ appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DB_TOKEN");
236
+
237
+ var newSecrets = { DB_TOKEN: password };
238
+ await pushAppConfig(cfg, appName, appConfig, { newSecrets });
239
+ }
240
+
241
+ return {
242
+ dbId: instName,
243
+ dbName: database,
244
+ dbToken: password,
245
+ connectionUrl,
246
+ };
247
+ }
248
+
249
+ export async function destroyDatabase(cfg, appName, opts = {}) {
250
+ var dbId = opts.dbId;
251
+ if (!dbId) {
252
+ var appConfig = await getAppConfig(cfg, appName);
253
+ if (!appConfig || !appConfig.dbId) {
254
+ throw new Error(`App ${appName} does not have a database.`);
255
+ }
256
+ dbId = appConfig.dbId;
257
+ }
258
+
259
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
260
+
261
+ await awsQueryApi(
262
+ "DeleteDBInstance",
263
+ { DBInstanceIdentifier: dbId, SkipFinalSnapshot: "true" },
264
+ "rds",
265
+ cr,
266
+ cfg.region
267
+ );
268
+
269
+ if (!opts.dbId) {
270
+ delete appConfig.dbId;
271
+ delete appConfig.dbName;
272
+
273
+ if (appConfig.env) {
274
+ delete appConfig.env["DATABASE_URL"];
275
+ delete appConfig.env["DB_TOKEN"];
276
+ }
277
+ if (appConfig.envKeys) appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DATABASE_URL");
278
+ if (appConfig.secretKeys) appConfig.secretKeys = appConfig.secretKeys.filter((k) => k !== "DB_TOKEN");
279
+
280
+ await pushAppConfig(cfg, appName, appConfig);
281
+ }
282
+ }
283
+
284
+ export async function getDatabaseInfo(cfg, appName, opts = {}) {
285
+ var dbId = opts.dbId;
286
+ var dbNameVal;
287
+ if (!dbId) {
288
+ var appConfig = await getAppConfig(cfg, appName);
289
+ if (!appConfig || !appConfig.dbId) {
290
+ throw new Error(`App ${appName} does not have a database.`);
291
+ }
292
+ dbId = appConfig.dbId;
293
+ dbNameVal = appConfig.dbName;
294
+ } else {
295
+ dbNameVal = dbName(appName);
296
+ }
297
+
298
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
299
+ var xml = await awsQueryApi(
300
+ "DescribeDBInstances",
301
+ { DBInstanceIdentifier: dbId },
302
+ "rds",
303
+ cr,
304
+ cfg.region
305
+ );
306
+
307
+ var endpointBlock = xmlBlock(xml, "Endpoint");
308
+ var host = endpointBlock ? xmlVal(endpointBlock, "Address") : null;
309
+ var port = endpointBlock ? (xmlVal(endpointBlock, "Port") || "5432") : "5432";
310
+
311
+ var connectionUrl = host
312
+ ? `postgresql://relight:****@${host}:${port}/${dbNameVal}`
313
+ : null;
314
+
315
+ return {
316
+ dbId,
317
+ dbName: dbNameVal,
318
+ connectionUrl,
319
+ size: null,
320
+ numTables: null,
321
+ createdAt: xmlVal(xml, "InstanceCreateTime") || null,
322
+ };
323
+ }
324
+
325
+ export async function queryDatabase(cfg, appName, sql, params, opts = {}) {
326
+ if (!opts.dbId) {
327
+ var appConfig = await getAppConfig(cfg, appName);
328
+ if (!appConfig || !appConfig.dbId) {
329
+ throw new Error(`App ${appName} does not have a database.`);
330
+ }
331
+ }
332
+
333
+ var password = await getDbPassword(cfg, appName);
334
+ var connectionUrl = await getConnectionUrl(cfg, appName, password);
335
+ var client = await connectPg(connectionUrl);
336
+
337
+ try {
338
+ var result = await client.query(sql, params || []);
339
+ return {
340
+ results: result.rows,
341
+ meta: { changes: result.rowCount, rows_read: result.rows.length },
342
+ };
343
+ } finally {
344
+ await client.end();
345
+ }
346
+ }
347
+
348
+ export async function importDatabase(cfg, appName, sqlContent, opts = {}) {
349
+ if (!opts.dbId) {
350
+ var appConfig = await getAppConfig(cfg, appName);
351
+ if (!appConfig || !appConfig.dbId) {
352
+ throw new Error(`App ${appName} does not have a database.`);
353
+ }
354
+ }
355
+
356
+ var password = await getDbPassword(cfg, appName);
357
+ var connectionUrl = await getConnectionUrl(cfg, appName, password);
358
+ var client = await connectPg(connectionUrl);
359
+
360
+ try {
361
+ await client.query(sqlContent);
362
+ } finally {
363
+ await client.end();
364
+ }
365
+ }
366
+
367
+ export async function exportDatabase(cfg, appName, opts = {}) {
368
+ var database;
369
+ if (!opts.dbId) {
370
+ var appConfig = await getAppConfig(cfg, appName);
371
+ if (!appConfig || !appConfig.dbId) {
372
+ throw new Error(`App ${appName} does not have a database.`);
373
+ }
374
+ database = appConfig.dbName;
375
+ } else {
376
+ database = dbName(appName);
377
+ }
378
+
379
+ var password = await getDbPassword(cfg, appName);
380
+ var connectionUrl = await getConnectionUrl(cfg, appName, password);
381
+ var client = await connectPg(connectionUrl);
382
+
383
+ try {
384
+ var tablesRes = await client.query(
385
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
386
+ );
387
+ var tables = tablesRes.rows.map((r) => r.tablename);
388
+
389
+ var dump = [];
390
+ dump.push("-- PostgreSQL dump generated by relight");
391
+ dump.push(`-- Database: ${database}`);
392
+ dump.push(`-- Date: ${new Date().toISOString()}`);
393
+ dump.push("");
394
+
395
+ for (var t of tables) {
396
+ var colsRes = await client.query(
397
+ `SELECT column_name, data_type, is_nullable, column_default
398
+ FROM information_schema.columns
399
+ WHERE table_name = $1 AND table_schema = 'public'
400
+ ORDER BY ordinal_position`,
401
+ [t]
402
+ );
403
+
404
+ var cols = colsRes.rows.map((c) => {
405
+ var def = ` "${c.column_name}" ${c.data_type}`;
406
+ if (c.column_default) def += ` DEFAULT ${c.column_default}`;
407
+ if (c.is_nullable === "NO") def += " NOT NULL";
408
+ return def;
409
+ });
410
+
411
+ dump.push(`CREATE TABLE IF NOT EXISTS "${t}" (`);
412
+ dump.push(cols.join(",\n"));
413
+ dump.push(");");
414
+ dump.push("");
415
+
416
+ var dataRes = await client.query(`SELECT * FROM "${t}"`);
417
+ for (var row of dataRes.rows) {
418
+ var values = Object.values(row).map((v) => {
419
+ if (v === null) return "NULL";
420
+ if (typeof v === "number") return String(v);
421
+ if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
422
+ return "'" + String(v).replace(/'/g, "''") + "'";
423
+ });
424
+ var colNames = Object.keys(row).map((c) => `"${c}"`).join(", ");
425
+ dump.push(`INSERT INTO "${t}" (${colNames}) VALUES (${values.join(", ")});`);
426
+ }
427
+ dump.push("");
428
+ }
429
+
430
+ return dump.join("\n");
431
+ } finally {
432
+ await client.end();
433
+ }
434
+ }
435
+
436
+ export async function rotateToken(cfg, appName, opts = {}) {
437
+ var dbId = opts.dbId;
438
+ if (!dbId) {
439
+ var appConfig = await getAppConfig(cfg, appName);
440
+ if (!appConfig || !appConfig.dbId) {
441
+ throw new Error(`App ${appName} does not have a database.`);
442
+ }
443
+ dbId = appConfig.dbId;
444
+ }
445
+
446
+ var cr = { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
447
+ var newPassword = randomBytes(24).toString("base64url");
448
+
449
+ // Update RDS master password
450
+ await awsQueryApi(
451
+ "ModifyDBInstance",
452
+ {
453
+ DBInstanceIdentifier: dbId,
454
+ MasterUserPassword: newPassword,
455
+ },
456
+ "rds",
457
+ cr,
458
+ cfg.region
459
+ );
460
+
461
+ // Get connection URL with new password
462
+ var connectionUrl = await getConnectionUrl(cfg, appName, newPassword);
463
+
464
+ if (!opts.skipAppConfig) {
465
+ if (!appConfig) {
466
+ appConfig = await getAppConfig(cfg, appName);
467
+ }
468
+
469
+ // Update app config
470
+ if (!appConfig.envKeys) appConfig.envKeys = [];
471
+ if (!appConfig.secretKeys) appConfig.secretKeys = [];
472
+ if (!appConfig.env) appConfig.env = {};
473
+
474
+ appConfig.env["DB_TOKEN"] = "[hidden]";
475
+ if (!appConfig.secretKeys.includes("DB_TOKEN")) appConfig.secretKeys.push("DB_TOKEN");
476
+ appConfig.envKeys = appConfig.envKeys.filter((k) => k !== "DB_TOKEN");
477
+
478
+ appConfig.env["DATABASE_URL"] = connectionUrl;
479
+ if (!appConfig.envKeys.includes("DATABASE_URL")) appConfig.envKeys.push("DATABASE_URL");
480
+
481
+ await pushAppConfig(cfg, appName, appConfig, { newSecrets: { DB_TOKEN: newPassword } });
482
+ }
483
+
484
+ return { dbToken: newPassword, connectionUrl };
485
+ }
486
+
487
+ export async function resetDatabase(cfg, appName, opts = {}) {
488
+ if (!opts.dbId) {
489
+ var appConfig = await getAppConfig(cfg, appName);
490
+ if (!appConfig || !appConfig.dbId) {
491
+ throw new Error(`App ${appName} does not have a database.`);
492
+ }
493
+ }
494
+
495
+ var password = await getDbPassword(cfg, appName);
496
+ var connectionUrl = await getConnectionUrl(cfg, appName, password);
497
+ var client = await connectPg(connectionUrl);
498
+
499
+ try {
500
+ var tablesRes = await client.query(
501
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
502
+ );
503
+ var tables = tablesRes.rows.map((r) => r.tablename);
504
+
505
+ for (var t of tables) {
506
+ await client.query(`DROP TABLE IF EXISTS "${t}" CASCADE`);
507
+ }
508
+
509
+ return tables;
510
+ } finally {
511
+ await client.end();
512
+ }
513
+ }