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,396 @@
1
+ import { execSync } from "child_process";
2
+ import { readFileSync, unlinkSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ listNodes,
7
+ createNode,
8
+ deleteNode,
9
+ resumeVM,
10
+ healthCheck,
11
+ execInVM,
12
+ uploadToVM,
13
+ } from "../../clouds/slicervm.js";
14
+ import { status } from "../../output.js";
15
+
16
+ // --- Helpers ---
17
+
18
+ var APP_ROOT = "/app-root";
19
+ var CONFIG_PATH = "/home/ubuntu/.relight.json";
20
+
21
+ function findNodeByApp(nodes, appName) {
22
+ return nodes.find(
23
+ (n) => n.tags && n.tags.includes(appName)
24
+ ) || null;
25
+ }
26
+
27
+ function sleep(ms) {
28
+ return new Promise((r) => setTimeout(r, ms));
29
+ }
30
+
31
+ // Parse NDJSON exec response - collect stdout, check exit code
32
+ function parseExecResponse(raw) {
33
+ var text = typeof raw === "string" ? raw : JSON.stringify(raw);
34
+ var stdout = "";
35
+ var stderr = "";
36
+ var exitCode = 0;
37
+ for (var line of text.split("\n")) {
38
+ line = line.trim();
39
+ if (!line) continue;
40
+ try {
41
+ var obj = JSON.parse(line);
42
+ if (obj.stdout) stdout += obj.stdout;
43
+ if (obj.stderr) stderr += obj.stderr;
44
+ if (obj.exit_code !== undefined) exitCode = obj.exit_code;
45
+ } catch {
46
+ // Not JSON - treat as raw output
47
+ stdout += line;
48
+ }
49
+ }
50
+ return { stdout, stderr, exitCode };
51
+ }
52
+
53
+ // Run a command in VM and return stdout. Throws on non-zero exit.
54
+ async function vmExec(cfg, hostname, cmd, args, opts) {
55
+ var raw = await execInVM(cfg, hostname, cmd, args, opts);
56
+ var result = parseExecResponse(raw);
57
+ if (result.exitCode !== 0) {
58
+ throw new Error(`Command failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
59
+ }
60
+ return result.stdout;
61
+ }
62
+
63
+ async function waitForHealth(cfg, hostname, retries = 30) {
64
+ for (var i = 0; i < retries; i++) {
65
+ try {
66
+ await healthCheck(cfg, hostname);
67
+ return;
68
+ } catch {
69
+ await sleep(1000);
70
+ }
71
+ }
72
+ throw new Error(`VM ${hostname} did not become healthy after ${retries}s`);
73
+ }
74
+
75
+ async function readAppConfig(cfg, hostname) {
76
+ try {
77
+ var stdout = await vmExec(cfg, hostname, "cat", [CONFIG_PATH]);
78
+ return JSON.parse(stdout);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ async function writeAppConfig(cfg, hostname, appConfig) {
85
+ var json = JSON.stringify(appConfig, null, 2);
86
+ await vmExec(cfg, hostname, "sh", [
87
+ "-c",
88
+ `cat > ${CONFIG_PATH} << 'RELIGHT_EOF'\n${json}\nRELIGHT_EOF`,
89
+ ]);
90
+ }
91
+
92
+ function inspectImage(localTag) {
93
+ var raw = execSync(
94
+ `docker inspect --format='{{json .Config}}' ${localTag}`,
95
+ { encoding: "utf-8", stdio: "pipe" }
96
+ ).trim();
97
+ // docker inspect wraps in single quotes on some versions
98
+ if (raw.startsWith("'") && raw.endsWith("'")) {
99
+ raw = raw.slice(1, -1);
100
+ }
101
+ return JSON.parse(raw);
102
+ }
103
+
104
+ function buildEntrypoint(imageConfig) {
105
+ var entrypoint = imageConfig.Entrypoint || [];
106
+ var cmd = imageConfig.Cmd || [];
107
+
108
+ // Docker resolution: if ENTRYPOINT is set, CMD is appended as args.
109
+ // If only CMD, it's used directly (with shell form handled by Docker already).
110
+ var parts = [...entrypoint, ...cmd];
111
+ if (parts.length === 0) {
112
+ throw new Error(
113
+ "Docker image has no CMD or ENTRYPOINT. Add a CMD to your Dockerfile."
114
+ );
115
+ }
116
+ return parts;
117
+ }
118
+
119
+ function buildEnvMap(imageConfig, appConfig) {
120
+ var env = {};
121
+
122
+ // Start with image ENV directives
123
+ for (var entry of (imageConfig.Env || [])) {
124
+ var eq = entry.indexOf("=");
125
+ if (eq !== -1) {
126
+ env[entry.substring(0, eq)] = entry.substring(eq + 1);
127
+ }
128
+ }
129
+
130
+ // Overlay relight app env vars
131
+ if (appConfig.env) {
132
+ for (var key of (appConfig.envKeys || [])) {
133
+ if (appConfig.env[key] !== undefined) env[key] = appConfig.env[key];
134
+ }
135
+ }
136
+
137
+ // Always set PORT
138
+ env.PORT = String(appConfig.port || 8080);
139
+
140
+ return env;
141
+ }
142
+
143
+ // --- App config ---
144
+
145
+ export async function getAppConfig(cfg, appName) {
146
+ var nodes = await listNodes(cfg);
147
+ var node = findNodeByApp(nodes, appName);
148
+ if (!node) return null;
149
+ return readAppConfig(cfg, node.hostname);
150
+ }
151
+
152
+ export async function pushAppConfig(cfg, appName, appConfig) {
153
+ var nodes = await listNodes(cfg);
154
+ var node = findNodeByApp(nodes, appName);
155
+ if (!node) throw new Error(`No VM found for app ${appName}`);
156
+ await writeAppConfig(cfg, node.hostname, appConfig);
157
+ }
158
+
159
+ // --- Deploy ---
160
+
161
+ export async function deploy(cfg, appName, imageTag, opts) {
162
+ var appConfig = opts.appConfig;
163
+ var isFirstDeploy = opts.isFirstDeploy;
164
+
165
+ // 1. Inspect Docker image for CMD, ENTRYPOINT, ENV, WORKDIR
166
+ status("Inspecting image...");
167
+ var imageConfig = inspectImage(imageTag);
168
+ var entrypoint = buildEntrypoint(imageConfig);
169
+ var workdir = imageConfig.WorkingDir || "/";
170
+
171
+ // 2. Find or create VM node
172
+ status("Finding VM...");
173
+ var nodes = await listNodes(cfg);
174
+ var node = findNodeByApp(nodes, appName);
175
+
176
+ if (!node) {
177
+ status("Creating VM...");
178
+ node = await createNode(cfg, cfg.hostGroup, {
179
+ tags: [appName],
180
+ vcpu: appConfig.vcpu,
181
+ memory: appConfig.memory,
182
+ });
183
+ }
184
+
185
+ var hostname = node.hostname;
186
+
187
+ // 3. Resume if paused
188
+ if (node.status === "Paused" || node.status === "paused") {
189
+ status("Resuming VM...");
190
+ await resumeVM(cfg, hostname);
191
+ }
192
+
193
+ // 4. Wait for health
194
+ status("Waiting for VM...");
195
+ await waitForHealth(cfg, hostname);
196
+
197
+ // 5. Stop old app if redeploying
198
+ if (!isFirstDeploy) {
199
+ status("Stopping old app...");
200
+ try {
201
+ await vmExec(cfg, hostname, "sh", [
202
+ "-c",
203
+ `kill $(cat /run/relight-app.pid 2>/dev/null) 2>/dev/null; rm -f /run/relight-app.pid; sleep 1`,
204
+ ], { uid: 0 });
205
+ } catch {}
206
+ }
207
+
208
+ // 6. Export Docker image filesystem as tar
209
+ status("Extracting image...");
210
+ var containerId = execSync(`docker create ${imageTag}`, {
211
+ encoding: "utf-8",
212
+ stdio: "pipe",
213
+ }).trim();
214
+
215
+ var tarPath = join(tmpdir(), `relight-${appName}-${Date.now()}.tar`);
216
+ try {
217
+ execSync(`docker export ${containerId} -o ${tarPath}`, { stdio: "pipe" });
218
+ } finally {
219
+ execSync(`docker rm ${containerId}`, { stdio: "pipe" });
220
+ }
221
+
222
+ // 7. Upload tar to VM at /app-root (the chroot target)
223
+ status("Uploading to VM...");
224
+ var tarBuffer = readFileSync(tarPath);
225
+ await uploadToVM(cfg, hostname, APP_ROOT, tarBuffer, {
226
+ mode: "tar",
227
+ });
228
+
229
+ try { unlinkSync(tarPath); } catch {}
230
+
231
+ // 8. Set up chroot: mounts + busybox symlinks (requires root)
232
+ status("Preparing chroot...");
233
+ await vmExec(cfg, hostname, "sh", [
234
+ "-c",
235
+ [
236
+ `mkdir -p ${APP_ROOT}/proc ${APP_ROOT}/sys ${APP_ROOT}/dev ${APP_ROOT}/dev/pts ${APP_ROOT}/tmp`,
237
+ `mountpoint -q ${APP_ROOT}/proc || mount -t proc proc ${APP_ROOT}/proc`,
238
+ `mountpoint -q ${APP_ROOT}/sys || mount -t sysfs sysfs ${APP_ROOT}/sys`,
239
+ `mountpoint -q ${APP_ROOT}/dev || mount --bind /dev ${APP_ROOT}/dev`,
240
+ `mountpoint -q ${APP_ROOT}/dev/pts || mount --bind /dev/pts ${APP_ROOT}/dev/pts`,
241
+ // Copy resolv.conf so DNS works inside chroot
242
+ `cp /etc/resolv.conf ${APP_ROOT}/etc/resolv.conf 2>/dev/null || true`,
243
+ // docker export flattens layers and loses symlinks. Fix up:
244
+ // 1. Alpine: busybox symlinks (sh, ls, etc.)
245
+ `if [ -f ${APP_ROOT}/bin/busybox ] && [ ! -e ${APP_ROOT}/bin/sh ]; then chroot ${APP_ROOT} /bin/busybox --install -s /bin; fi`,
246
+ // 2. Recreate missing .so symlinks (e.g. libstdc++.so.6 -> libstdc++.so.6.0.34)
247
+ `find ${APP_ROOT}/usr/lib ${APP_ROOT}/lib -name '*.so.*.*' -type f 2>/dev/null | while read f; do base=$(basename "$f"); dir=$(dirname "$f"); major=$(echo "$base" | sed 's/\\(.*\\.so\\.[0-9]*\\).*/\\1/'); [ "$major" != "$base" ] && [ ! -e "$dir/$major" ] && ln -sf "$base" "$dir/$major"; done`,
248
+ ].join(" && "),
249
+ ], { uid: 0 });
250
+
251
+ // 9. Write relight config outside the chroot (on the VM root)
252
+ status("Writing config...");
253
+ await writeAppConfig(cfg, hostname, appConfig);
254
+
255
+ // 10. Build env and start command
256
+ var env = buildEnvMap(imageConfig, appConfig);
257
+
258
+ // Build env export commands for inside the chroot
259
+ var envExport = Object.entries(env)
260
+ .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
261
+ .join("; ");
262
+
263
+ var entrypointStr = entrypoint.map(shellQuote).join(" ");
264
+ var innerCmd = `cd ${shellQuote(workdir)} && exec ${entrypointStr}`;
265
+
266
+ // Full command: set env, chroot, run entrypoint, log output, track PID
267
+ // Needs root for chroot
268
+ var chrootCmd = [
269
+ `${envExport}`,
270
+ `chroot ${APP_ROOT} /bin/sh -c '${innerCmd.replace(/'/g, "'\\''")}'`,
271
+ ].join("; ");
272
+
273
+ status("Starting app...");
274
+ await vmExec(cfg, hostname, "sh", [
275
+ "-c",
276
+ `nohup sh -c '${chrootCmd.replace(/'/g, "'\\''")}' > /var/log/relight-app.log 2>&1 & echo $! > /run/relight-app.pid`,
277
+ ], { uid: 0 });
278
+ }
279
+
280
+ function shellQuote(s) {
281
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
282
+ }
283
+
284
+ // --- List apps ---
285
+
286
+ export async function listApps(cfg) {
287
+ var nodes = await listNodes(cfg);
288
+ var apps = [];
289
+ for (var node of nodes) {
290
+ if (!node.tags || node.tags.length === 0) continue;
291
+ var appName = node.tags[0];
292
+ var config = null;
293
+ try {
294
+ config = await readAppConfig(cfg, node.hostname);
295
+ } catch {}
296
+ apps.push({
297
+ name: appName,
298
+ modified: config?.deployedAt || null,
299
+ });
300
+ }
301
+ return apps;
302
+ }
303
+
304
+ // --- Get app info ---
305
+
306
+ export async function getAppInfo(cfg, appName) {
307
+ var appConfig = await getAppConfig(cfg, appName);
308
+ if (!appConfig) return null;
309
+ var url = `https://${appName}.${cfg.baseDomain}`;
310
+ return { appConfig, url };
311
+ }
312
+
313
+ // --- Destroy ---
314
+
315
+ export async function destroyApp(cfg, appName) {
316
+ var nodes = await listNodes(cfg);
317
+ var node = findNodeByApp(nodes, appName);
318
+ if (!node) throw new Error(`No VM found for app ${appName}`);
319
+ await deleteNode(cfg, cfg.hostGroup, node.hostname);
320
+ }
321
+
322
+ // --- Scale ---
323
+
324
+ export async function scale(cfg, appName, opts) {
325
+ var appConfig = opts.appConfig;
326
+ await pushAppConfig(cfg, appName, appConfig);
327
+ }
328
+
329
+ // --- Container status ---
330
+
331
+ export async function getContainerStatus(cfg, appName) {
332
+ var nodes = await listNodes(cfg);
333
+ var node = findNodeByApp(nodes, appName);
334
+ if (!node) return [];
335
+ return [
336
+ {
337
+ dimensions: {
338
+ hostname: node.hostname,
339
+ status: node.status || "Unknown",
340
+ region: "self-hosted",
341
+ },
342
+ avg: {},
343
+ },
344
+ ];
345
+ }
346
+
347
+ // --- App URL ---
348
+
349
+ export async function getAppUrl(cfg, appName) {
350
+ var appConfig = await getAppConfig(cfg, appName);
351
+ if (appConfig?.domains?.length > 0) {
352
+ return `https://${appConfig.domains[0]}`;
353
+ }
354
+ return `https://${appName}.${cfg.baseDomain}`;
355
+ }
356
+
357
+ // --- Costs ---
358
+
359
+ export async function getCosts(cfg, appNames, dateRange) {
360
+ var names = appNames || [];
361
+ if (!appNames) {
362
+ var apps = await listApps(cfg);
363
+ names = apps.map((a) => a.name);
364
+ }
365
+ return names.map((name) => ({
366
+ name,
367
+ usage: {},
368
+ }));
369
+ }
370
+
371
+ // --- Log streaming ---
372
+
373
+ export async function streamLogs(cfg, appName) {
374
+ var nodes = await listNodes(cfg);
375
+ var node = findNodeByApp(nodes, appName);
376
+ if (!node) throw new Error(`No VM found for app ${appName}`);
377
+
378
+ var res = await execInVM(cfg, node.hostname, "tail", ["-f", "/var/log/relight-app.log"], {
379
+ stream: true,
380
+ });
381
+
382
+ return {
383
+ url: null,
384
+ id: node.hostname,
385
+ reader: res.body,
386
+ cleanup: async () => {},
387
+ };
388
+ }
389
+
390
+ // --- Regions ---
391
+
392
+ export function getRegions() {
393
+ return [
394
+ { code: "self-hosted", name: "Self-hosted", location: "Your infrastructure" },
395
+ ];
396
+ }
@@ -0,0 +1,33 @@
1
+ var MSG = "SlicerVM does not include managed databases. Use an external database service.";
2
+
3
+ export async function createDatabase() {
4
+ throw new Error(MSG);
5
+ }
6
+
7
+ export async function destroyDatabase() {
8
+ throw new Error(MSG);
9
+ }
10
+
11
+ export async function getDatabaseInfo() {
12
+ throw new Error(MSG);
13
+ }
14
+
15
+ export async function queryDatabase() {
16
+ throw new Error(MSG);
17
+ }
18
+
19
+ export async function importDatabase() {
20
+ throw new Error(MSG);
21
+ }
22
+
23
+ export async function exportDatabase() {
24
+ throw new Error(MSG);
25
+ }
26
+
27
+ export async function rotateToken() {
28
+ throw new Error(MSG);
29
+ }
30
+
31
+ export async function resetDatabase() {
32
+ throw new Error(MSG);
33
+ }
@@ -0,0 +1,58 @@
1
+ import { listNodes } from "../../clouds/slicervm.js";
2
+ import { getAppConfig, pushAppConfig } from "./app.js";
3
+
4
+ export async function listDomains(cfg, appName) {
5
+ var appConfig = await getAppConfig(cfg, appName);
6
+ var defaultDomain = `${appName}.${cfg.baseDomain}`;
7
+
8
+ return {
9
+ default: defaultDomain,
10
+ custom: appConfig?.domains || [],
11
+ };
12
+ }
13
+
14
+ export async function addDomain(cfg, appName, domain) {
15
+ var appConfig = await getAppConfig(cfg, appName);
16
+ if (!appConfig) {
17
+ throw new Error(`App ${appName} not found.`);
18
+ }
19
+
20
+ if (!appConfig.domains) appConfig.domains = [];
21
+ if (appConfig.domains.includes(domain)) {
22
+ throw new Error(`Domain ${domain} is already attached to ${appName}.`);
23
+ }
24
+
25
+ // Add domain as a tag on the VM node so Caddy can route to it
26
+ var nodes = await listNodes(cfg);
27
+ var node = nodes.find((n) => n.tags && n.tags.includes(appName));
28
+ if (node && !node.tags.includes(domain)) {
29
+ node.tags.push(domain);
30
+ // Tags are updated via the app config - the Caddy module reads tags from the node
31
+ }
32
+
33
+ appConfig.domains.push(domain);
34
+ await pushAppConfig(cfg, appName, appConfig);
35
+
36
+ process.stderr.write(
37
+ `\n Point your DNS A record for ${domain} to your Slicer host IP.\n`
38
+ );
39
+ }
40
+
41
+ export async function removeDomain(cfg, appName, domain) {
42
+ var appConfig = await getAppConfig(cfg, appName);
43
+ if (!appConfig) {
44
+ throw new Error(`App ${appName} not found.`);
45
+ }
46
+
47
+ appConfig.domains = (appConfig.domains || []).filter((d) => d !== domain);
48
+ await pushAppConfig(cfg, appName, appConfig);
49
+ }
50
+
51
+ // SlicerVM doesn't use zones - custom domains use VM tags + manual DNS
52
+ export async function getZones() {
53
+ return [];
54
+ }
55
+
56
+ export function findZoneForHostname() {
57
+ return null;
58
+ }
@@ -0,0 +1,7 @@
1
+ export async function getCredentials() {
2
+ throw new Error("SlicerVM deploys bundles directly - no container registry needed.");
3
+ }
4
+
5
+ export function getImageTag(cfg, appName, tag) {
6
+ return `relight-${appName}:${tag}`;
7
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@relight/worker-template",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Per-app worker template for Relight",
7
+ "dependencies": {
8
+ "@cloudflare/containers": "^0.0.30"
9
+ }
10
+ }