create-volt 0.55.1 → 0.56.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.
- package/CHANGELOG.md +26 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/templates/blog/public/volt.js +7 -2
- package/templates/blog/server.js +87 -3
- package/templates/blog/setup/setup.js +59 -10
- package/templates/business/Dockerfile +20 -0
- package/templates/business/Procfile +1 -0
- package/templates/business/README.md +25 -0
- package/templates/business/dockerignore +6 -0
- package/templates/business/ecosystem.config.cjs +5 -0
- package/templates/business/env +2 -0
- package/templates/business/fly.toml +15 -0
- package/templates/business/gitignore +5 -0
- package/templates/business/package.json +21 -0
- package/templates/business/pages/_theme.js +65 -0
- package/templates/business/pages/about.md +30 -0
- package/templates/business/pages/contact.md +23 -0
- package/templates/business/pages/index.md +41 -0
- package/templates/business/pages/products.md +27 -0
- package/templates/business/public/app.js +89 -0
- package/templates/business/public/favicon.webp +0 -0
- package/templates/business/public/logo.webp +0 -0
- package/templates/business/public/volt-ssr.js +63 -0
- package/templates/business/public/volt.js +355 -0
- package/templates/business/render.yaml +15 -0
- package/templates/business/server.js +1051 -0
- package/templates/business/setup/index.html +46 -0
- package/templates/business/setup/logs.html +29 -0
- package/templates/business/setup/logs.js +58 -0
- package/templates/business/setup/setup.js +509 -0
- package/templates/business/setup/studio.html +29 -0
- package/templates/business/views/index.html +42 -0
- package/templates/default/public/volt.js +7 -2
- package/templates/default/server.js +87 -3
- package/templates/default/setup/setup.js +59 -10
- package/templates/docs/public/volt.js +7 -2
- package/templates/docs/server.js +87 -3
- package/templates/docs/setup/setup.js +59 -10
- package/templates/guestbook/public/volt.js +7 -2
- package/templates/starter/public/volt.js +7 -2
- package/templates/starter/server.js +86 -3
- package/templates/starter/setup/setup.js +59 -10
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
// server.js — dev server with a built-in first-run setup wizard.
|
|
2
|
+
//
|
|
3
|
+
// First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
|
|
4
|
+
// config page: tick add-ons, fill settings, Apply. Apply writes .env (a
|
|
5
|
+
// VOLT_ADDONS list + settings) and adds any needed packages to package.json,
|
|
6
|
+
// runs npm install, then starts the app — which wires whatever .env enables.
|
|
7
|
+
// Add-on code is bundled under .volt/addons; nothing is copied into your code.
|
|
8
|
+
//
|
|
9
|
+
// No build step, no env-file flag: .env is auto-loaded below.
|
|
10
|
+
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import express from "express";
|
|
19
|
+
import { Server as SocketServer } from "socket.io";
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const ENV_PATH = path.join(__dirname, ".env");
|
|
23
|
+
const PKG_PATH = path.join(__dirname, "package.json");
|
|
24
|
+
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
25
|
+
const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes the wizard can pick
|
|
26
|
+
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
27
|
+
const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
|
|
28
|
+
|
|
29
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
30
|
+
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
31
|
+
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
32
|
+
function cliPort() {
|
|
33
|
+
const i = process.argv.indexOf("--port");
|
|
34
|
+
const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Port for the disposable config UI (--edit / --studio): --port wins, then
|
|
40
|
+
// CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
|
|
41
|
+
// then the app's PORT, then the date-port.
|
|
42
|
+
function configPort() {
|
|
43
|
+
const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
|
|
44
|
+
return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
|
|
45
|
+
}
|
|
46
|
+
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^9.0.3", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
|
|
47
|
+
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
|
|
48
|
+
|
|
49
|
+
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
50
|
+
function readEnvFile() {
|
|
51
|
+
const out = {};
|
|
52
|
+
if (!fs.existsSync(ENV_PATH)) return out;
|
|
53
|
+
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
|
|
54
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
55
|
+
if (m) {
|
|
56
|
+
const v = m[2];
|
|
57
|
+
out[m[1]] = (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ? v.slice(1, -1) : v.replace(/(?:^|\s+)#.*$/, "");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function loadEnv() {
|
|
63
|
+
for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Add-ons available to enable (bundled under .volt/addons by create-volt).
|
|
67
|
+
function availableAddons() {
|
|
68
|
+
if (!fs.existsSync(ADDONS_DIR)) return [];
|
|
69
|
+
return fs
|
|
70
|
+
.readdirSync(ADDONS_DIR, { withFileTypes: true })
|
|
71
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(ADDONS_DIR, e.name, "meta.json"))) // skip local 3rd-party add-ons (no meta)
|
|
72
|
+
.map((e) => {
|
|
73
|
+
const m = JSON.parse(fs.readFileSync(path.join(ADDONS_DIR, e.name, "meta.json"), "utf8"));
|
|
74
|
+
return { name: e.name, description: m.description, dependsOn: m.dependsOn || [] };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Themes bundled by create-volt (under .volt/themes), pickable in the wizard.
|
|
79
|
+
function availableThemes() {
|
|
80
|
+
if (!fs.existsSync(THEMES_DIR)) return [];
|
|
81
|
+
return fs
|
|
82
|
+
.readdirSync(THEMES_DIR, { withFileTypes: true })
|
|
83
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, "index.js")))
|
|
84
|
+
.map((e) => {
|
|
85
|
+
let description = "";
|
|
86
|
+
try {
|
|
87
|
+
description = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, e.name, "meta.json"), "utf8")).description;
|
|
88
|
+
} catch {
|
|
89
|
+
/* no meta */
|
|
90
|
+
}
|
|
91
|
+
return { name: e.name, description };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
96
|
+
function enabledFrom(env) {
|
|
97
|
+
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
98
|
+
const out = new Set();
|
|
99
|
+
const visit = (n) => {
|
|
100
|
+
if (out.has(n)) return; // include third-party names too, not just bundled ones
|
|
101
|
+
out.add(n);
|
|
102
|
+
for (const d of metas[n]?.dependsOn || []) visit(d);
|
|
103
|
+
};
|
|
104
|
+
for (const n of String(env.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean)) visit(n);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
|
|
109
|
+
const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
|
|
110
|
+
|
|
111
|
+
// Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
|
|
112
|
+
// a third-party add-on — a local .volt/addons/<name>/index.js or an installed
|
|
113
|
+
// npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
|
|
114
|
+
const BUILTINS = new Set(Object.keys(LIB_FILE));
|
|
115
|
+
async function loadAddon(name) {
|
|
116
|
+
const local = path.join(__dirname, ".volt", "addons", name, "index.js");
|
|
117
|
+
if (fs.existsSync(local)) return imp(path.join(".volt", "addons", name, "index.js"));
|
|
118
|
+
for (const id of [`volt-addon-${name}`, name]) {
|
|
119
|
+
try {
|
|
120
|
+
return await import(id);
|
|
121
|
+
} catch {
|
|
122
|
+
/* try next */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function openBrowser(url) {
|
|
129
|
+
if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
|
|
130
|
+
const plat = process.platform;
|
|
131
|
+
if (plat === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
132
|
+
const cmd = plat === "darwin" ? "open" : plat === "win32" ? "cmd" : "xdg-open";
|
|
133
|
+
const args = plat === "win32" ? ["/c", "start", "", url] : [url];
|
|
134
|
+
try {
|
|
135
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
136
|
+
child.on("error", () => {}); // launcher missing — emits async, don't crash
|
|
137
|
+
child.unref();
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- the actual app: wires whichever add-ons .env enables ---
|
|
145
|
+
async function startApp() {
|
|
146
|
+
const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
|
|
147
|
+
const enabled = enabledFrom(process.env);
|
|
148
|
+
const app = express();
|
|
149
|
+
app.disable("x-powered-by");
|
|
150
|
+
app.use((_req, res, next) => {
|
|
151
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
152
|
+
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
153
|
+
res.setHeader("Referrer-Policy", "same-origin");
|
|
154
|
+
next();
|
|
155
|
+
});
|
|
156
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
157
|
+
|
|
158
|
+
let store = null;
|
|
159
|
+
let mailer = null;
|
|
160
|
+
if (enabled.has("db")) store = await (await addonMod("db")).createStore();
|
|
161
|
+
if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
|
|
162
|
+
if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
|
|
163
|
+
|
|
164
|
+
// expose which add-ons are on, and serve each enabled add-on's frontend assets
|
|
165
|
+
app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
|
|
166
|
+
for (const n of enabled) {
|
|
167
|
+
const pub = path.join(ADDONS_DIR, n, "files", "public");
|
|
168
|
+
if (fs.existsSync(pub)) app.use(express.static(pub));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
app.get("/", (_req, res, next) => {
|
|
172
|
+
// a themed home page (pages/index.md) takes over "/" — else the app's index.html
|
|
173
|
+
if (enabled.has("pages") && fs.existsSync(path.join(__dirname, "pages", "index.md"))) return next();
|
|
174
|
+
res.sendFile(path.join(__dirname, "views", "index.html"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// media uploads (POST /api/media, auth-gated; local files served at /media)
|
|
178
|
+
if (enabled.has("media") && store) {
|
|
179
|
+
const requireAuth = (await addonMod("auth")).requireAuth(store);
|
|
180
|
+
app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// markdown pages (/<slug> → pages/<slug>.md) — mounted last, so app routes win
|
|
184
|
+
// blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
|
|
185
|
+
if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
|
|
186
|
+
if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
|
|
187
|
+
|
|
188
|
+
const server = http.createServer(app);
|
|
189
|
+
const io = new SocketServer(server);
|
|
190
|
+
if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
|
|
191
|
+
|
|
192
|
+
// Reload connected browsers on demand — used when a second `npm run dev` finds
|
|
193
|
+
// the app already running (see the EADDRINUSE handler below) instead of crashing.
|
|
194
|
+
app.get("/__volt/reload", (_req, res) => {
|
|
195
|
+
io.emit("volt:reload", { file: "__manual__" });
|
|
196
|
+
res.json({ ok: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
|
|
200
|
+
// are provided so add-ons can gate routes by login.
|
|
201
|
+
let requireAuth = null;
|
|
202
|
+
let sessionFromReq = null;
|
|
203
|
+
if (enabled.has("auth") && store) {
|
|
204
|
+
const a = await addonMod("auth");
|
|
205
|
+
requireAuth = a.requireAuth(store);
|
|
206
|
+
sessionFromReq = (req) => a.sessionFromReq(store, req);
|
|
207
|
+
}
|
|
208
|
+
for (const name of enabled) {
|
|
209
|
+
if (BUILTINS.has(name)) continue;
|
|
210
|
+
const mod = await loadAddon(name);
|
|
211
|
+
const register = mod && (mod.register || mod.default);
|
|
212
|
+
if (typeof register === "function") {
|
|
213
|
+
await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
|
|
214
|
+
} else {
|
|
215
|
+
console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let timer = null;
|
|
220
|
+
const onChange = (file) => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
timer = setTimeout(() => {
|
|
223
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
224
|
+
io.emit("volt:reload", { file });
|
|
225
|
+
}, 80);
|
|
226
|
+
};
|
|
227
|
+
const watchRecursive = (dir) => {
|
|
228
|
+
try {
|
|
229
|
+
fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
|
|
230
|
+
return;
|
|
231
|
+
} catch {
|
|
232
|
+
/* per-dir fallback */
|
|
233
|
+
}
|
|
234
|
+
const w = (d) => {
|
|
235
|
+
try {
|
|
236
|
+
fs.watch(d, (_e, f) => onChange(f));
|
|
237
|
+
} catch {
|
|
238
|
+
/* ignore */
|
|
239
|
+
}
|
|
240
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) w(path.join(d, e.name));
|
|
241
|
+
};
|
|
242
|
+
w(dir);
|
|
243
|
+
};
|
|
244
|
+
// watch content dirs too (pages/posts markdown is read per request, so a
|
|
245
|
+
// browser reload shows the edit); skip dirs that don't exist.
|
|
246
|
+
for (const d of ["views", "public", "pages", "posts"]) {
|
|
247
|
+
const full = path.join(__dirname, d);
|
|
248
|
+
if (fs.existsSync(full)) watchRecursive(full);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const on = [...enabled];
|
|
252
|
+
// If the port's taken, the app is likely already running — reload it (tell the
|
|
253
|
+
// running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
|
|
254
|
+
server.on("error", async (e) => {
|
|
255
|
+
if (e.code === "EADDRINUSE") {
|
|
256
|
+
try {
|
|
257
|
+
await fetch(`http://localhost:${PORT}/__volt/reload`);
|
|
258
|
+
} catch {
|
|
259
|
+
/* old instance without the reload route, or not ours */
|
|
260
|
+
}
|
|
261
|
+
console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
|
|
262
|
+
process.exit(0);
|
|
263
|
+
}
|
|
264
|
+
throw e;
|
|
265
|
+
});
|
|
266
|
+
server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Packages an .env's selections need, beyond what package.json already has.
|
|
270
|
+
function neededPackages(env) {
|
|
271
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
|
|
272
|
+
const deps = pkg.dependencies || {};
|
|
273
|
+
const want = [];
|
|
274
|
+
const driver = (env.match(/^\s*DB_DRIVER\s*=\s*(\w+)/m) || [])[1];
|
|
275
|
+
if (driver === "mongodb") want.push("mongodb");
|
|
276
|
+
if (driver === "mysql") want.push("mysql2");
|
|
277
|
+
if (driver === "postgres") want.push("pg");
|
|
278
|
+
if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
|
|
279
|
+
if (/^\s*VOLT_ADDONS\s*=.*\b(pages|posts)\b/m.test(env)) want.push("marked");
|
|
280
|
+
if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
|
|
281
|
+
if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
|
|
282
|
+
return want.filter((p) => !deps[p]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Install a DB driver's package on demand (pinned), so the wizard's "Test
|
|
286
|
+
// connection" works before Apply has installed it.
|
|
287
|
+
function ensureDriverInstalled(driver) {
|
|
288
|
+
const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
|
|
289
|
+
if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
|
|
290
|
+
console.log(`[volt] installing ${pkg} for the connection test…`);
|
|
291
|
+
spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- the disposable setup wizard (localhost only) ---
|
|
295
|
+
function startSetup() {
|
|
296
|
+
const PORT = configPort();
|
|
297
|
+
const assets = {
|
|
298
|
+
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
299
|
+
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
300
|
+
"/logo.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "logo.webp"))],
|
|
301
|
+
"/favicon.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "favicon.webp"))],
|
|
302
|
+
};
|
|
303
|
+
const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
|
|
304
|
+
|
|
305
|
+
const server = http.createServer((req, res) => {
|
|
306
|
+
const u = new URL(req.url, "http://localhost");
|
|
307
|
+
const p = u.pathname;
|
|
308
|
+
if (req.method === "GET" && p === "/") {
|
|
309
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
310
|
+
return res.end(indexHtml);
|
|
311
|
+
}
|
|
312
|
+
if (req.method === "GET" && assets[p]) {
|
|
313
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
314
|
+
return res.end(assets[p][1]);
|
|
315
|
+
}
|
|
316
|
+
if (req.method === "GET" && p === "/setup/state") {
|
|
317
|
+
res.setHeader("Content-Type", "application/json");
|
|
318
|
+
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
319
|
+
}
|
|
320
|
+
// --- upgrade: compare .volt/version to npm latest, and run the update ---
|
|
321
|
+
if (req.method === "GET" && p === "/setup/upgrade-check") {
|
|
322
|
+
const vf = path.join(__dirname, ".volt", "version");
|
|
323
|
+
const current = (fs.existsSync(vf) ? fs.readFileSync(vf, "utf8").trim() : "") || "?";
|
|
324
|
+
fetch("https://registry.npmjs.org/create-volt/latest")
|
|
325
|
+
.then((r) => r.json())
|
|
326
|
+
.then((j) => {
|
|
327
|
+
const latest = j.version || "?";
|
|
328
|
+
res.setHeader("Content-Type", "application/json");
|
|
329
|
+
res.end(JSON.stringify({ current, latest, available: latest !== "?" && current !== "?" && latest !== current }));
|
|
330
|
+
})
|
|
331
|
+
.catch(() => {
|
|
332
|
+
res.setHeader("Content-Type", "application/json");
|
|
333
|
+
res.end(JSON.stringify({ current, latest: "?", available: false }));
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (req.method === "POST" && p === "/setup/upgrade") {
|
|
338
|
+
res.setHeader("Content-Type", "application/json");
|
|
339
|
+
try {
|
|
340
|
+
const r = spawnSync("npx", ["--yes", "create-volt@latest", "update"], { cwd: __dirname, encoding: "utf8", shell: process.platform === "win32" });
|
|
341
|
+
res.end(JSON.stringify({ ok: r.status === 0, output: ((r.stdout || "") + (r.stderr || "")).slice(-2000) }));
|
|
342
|
+
} catch (e) {
|
|
343
|
+
res.statusCode = 500;
|
|
344
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// --- generate a free hosted-AI token from the gateway (self-service) ---
|
|
349
|
+
if (req.method === "POST" && p === "/setup/gen-token") {
|
|
350
|
+
res.setHeader("Content-Type", "application/json");
|
|
351
|
+
const env = readEnvFile();
|
|
352
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
353
|
+
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
354
|
+
.then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
|
|
355
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
356
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
360
|
+
// (BYO) when set; otherwise falls back to the voltjs.com gateway via
|
|
361
|
+
// VOLT_AI_TOKEN (free-capped, then pay-as-you-go on the host's key). The
|
|
362
|
+
// key/token never reaches the browser. ---
|
|
363
|
+
if (req.method === "POST" && p === "/setup/ai") {
|
|
364
|
+
let cbody = "";
|
|
365
|
+
req.on("data", (c) => (cbody += c));
|
|
366
|
+
req.on("end", async () => {
|
|
367
|
+
res.setHeader("Content-Type", "application/json");
|
|
368
|
+
try {
|
|
369
|
+
const env = readEnvFile();
|
|
370
|
+
const body = JSON.parse(cbody || "{}");
|
|
371
|
+
const provider = body._provider || env.AI_PROVIDER || "anthropic";
|
|
372
|
+
delete body._provider;
|
|
373
|
+
const localKey = { anthropic: env.ANTHROPIC_API_KEY, openai: env.OPENAI_API_KEY, gemini: env.GEMINI_API_KEY }[provider];
|
|
374
|
+
let url, headers, payload = body;
|
|
375
|
+
if (localKey) {
|
|
376
|
+
if (provider === "anthropic") {
|
|
377
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
378
|
+
headers = { "x-api-key": localKey, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
379
|
+
} else if (provider === "openai") {
|
|
380
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
381
|
+
headers = { authorization: "Bearer " + localKey, "content-type": "application/json" };
|
|
382
|
+
} else if (provider === "gemini") {
|
|
383
|
+
const model = body.model || "gemini-2.0-flash";
|
|
384
|
+
delete body.model;
|
|
385
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${localKey}`;
|
|
386
|
+
headers = { "content-type": "application/json" };
|
|
387
|
+
} else throw new Error("unknown AI provider: " + provider);
|
|
388
|
+
} else if (env.VOLT_AI_TOKEN) {
|
|
389
|
+
// no local key → host gateway (free-capped, then pay-as-you-go)
|
|
390
|
+
url = env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
391
|
+
headers = { authorization: "Bearer " + env.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
392
|
+
payload = { messages: body.messages, system: body.system, max_tokens: body.max_tokens, model: body.model };
|
|
393
|
+
} else {
|
|
394
|
+
throw new Error("No AI key in .env and no VOLT_AI_TOKEN — add a provider key, or a gateway token to use the hosted tier.");
|
|
395
|
+
}
|
|
396
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
397
|
+
const text = await r.text();
|
|
398
|
+
res.statusCode = r.status;
|
|
399
|
+
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
400
|
+
res.end(text);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
res.statusCode = 400;
|
|
403
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// --- AI credits: in-config purchase flow. Proxies the hosted gateway with
|
|
409
|
+
// the app's VOLT_AI_TOKEN — the buy flow lives here in the (shell-gated)
|
|
410
|
+
// config only, never in the running app. ---
|
|
411
|
+
if (req.method === "GET" && p === "/setup/ai-credits") {
|
|
412
|
+
const env = readEnvFile();
|
|
413
|
+
res.setHeader("Content-Type", "application/json");
|
|
414
|
+
if (!env.VOLT_AI_TOKEN) return res.end(JSON.stringify({ ok: false, error: "no VOLT_AI_TOKEN (BYO or unset)" }));
|
|
415
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
416
|
+
fetch(base + "/api/credits", { headers: { authorization: "Bearer " + env.VOLT_AI_TOKEN } })
|
|
417
|
+
.then((r) => r.json())
|
|
418
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
419
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (req.method === "POST" && p === "/setup/ai-credits/checkout") {
|
|
423
|
+
let cbody = "";
|
|
424
|
+
req.on("data", (c) => (cbody += c));
|
|
425
|
+
req.on("end", () => {
|
|
426
|
+
const env = readEnvFile();
|
|
427
|
+
res.setHeader("Content-Type", "application/json");
|
|
428
|
+
if (!env.VOLT_AI_TOKEN) return res.end(JSON.stringify({ ok: false, error: "no VOLT_AI_TOKEN" }));
|
|
429
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
430
|
+
let amountUsd = 0;
|
|
431
|
+
try {
|
|
432
|
+
amountUsd = Number(JSON.parse(cbody || "{}").amountUsd) || 0;
|
|
433
|
+
} catch {
|
|
434
|
+
/* bad json */
|
|
435
|
+
}
|
|
436
|
+
const baseUrl = env.SITE_URL || `http://localhost:${configPort()}`;
|
|
437
|
+
fetch(base + "/api/credits/checkout", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + env.VOLT_AI_TOKEN }, body: JSON.stringify({ amountUsd, baseUrl }) })
|
|
438
|
+
.then((r) => r.json())
|
|
439
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
440
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// --- active theme's CSS, so the in-config editor renders pages themed ---
|
|
445
|
+
if (req.method === "GET" && p === "/setup/theme-css") {
|
|
446
|
+
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
447
|
+
(async () => {
|
|
448
|
+
const theme = (readEnvFile().THEME || "").trim();
|
|
449
|
+
const load = async (rel) => {
|
|
450
|
+
try {
|
|
451
|
+
return (await imp(rel)).css || "";
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
let css = null;
|
|
457
|
+
if (theme) css = await load(path.join(".volt", "themes", theme, "index.js"));
|
|
458
|
+
if (css == null && fs.existsSync(path.join(__dirname, "pages", "_theme.js"))) css = await load(path.join("pages", "_theme.js"));
|
|
459
|
+
if (css == null && theme) {
|
|
460
|
+
try {
|
|
461
|
+
css = (await import(`volt-theme-${theme}`)).css || "";
|
|
462
|
+
} catch {
|
|
463
|
+
css = null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
res.end(css || "");
|
|
467
|
+
})();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// --- media library: list / upload / delete files in public/media (served at
|
|
471
|
+
// /media/<name>). Shell-gated (config only). ---
|
|
472
|
+
if (req.method === "GET" && p === "/setup/media") {
|
|
473
|
+
res.setHeader("Content-Type", "application/json");
|
|
474
|
+
const dir = path.join(__dirname, "public", "media");
|
|
475
|
+
let items = [];
|
|
476
|
+
try {
|
|
477
|
+
items = fs
|
|
478
|
+
.readdirSync(dir)
|
|
479
|
+
.filter((f) => !f.startsWith("."))
|
|
480
|
+
.map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
|
|
481
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
482
|
+
} catch {
|
|
483
|
+
/* no media dir yet */
|
|
484
|
+
}
|
|
485
|
+
return res.end(JSON.stringify({ items }));
|
|
486
|
+
}
|
|
487
|
+
if (req.method === "POST" && p === "/setup/media/upload") {
|
|
488
|
+
res.setHeader("Content-Type", "application/json");
|
|
489
|
+
const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
|
|
490
|
+
if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
|
|
491
|
+
const dir = path.join(__dirname, "public", "media");
|
|
492
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
493
|
+
const chunks = [];
|
|
494
|
+
let size = 0;
|
|
495
|
+
let tooBig = false;
|
|
496
|
+
req.on("data", (c) => {
|
|
497
|
+
if (tooBig) return;
|
|
498
|
+
size += c.length;
|
|
499
|
+
if (size > 100 * 1024 * 1024) tooBig = true;
|
|
500
|
+
else chunks.push(c);
|
|
501
|
+
});
|
|
502
|
+
req.on("end", () => {
|
|
503
|
+
if (tooBig) {
|
|
504
|
+
res.statusCode = 413;
|
|
505
|
+
return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
|
|
509
|
+
res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
|
|
510
|
+
} catch (e) {
|
|
511
|
+
res.statusCode = 400;
|
|
512
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (req.method === "POST" && p === "/setup/media/delete") {
|
|
518
|
+
let mbody = "";
|
|
519
|
+
req.on("data", (c) => (mbody += c));
|
|
520
|
+
req.on("end", () => {
|
|
521
|
+
res.setHeader("Content-Type", "application/json");
|
|
522
|
+
try {
|
|
523
|
+
const { name } = JSON.parse(mbody || "{}");
|
|
524
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
|
|
525
|
+
const f = path.join(__dirname, "public", "media", name);
|
|
526
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
527
|
+
res.end(JSON.stringify({ ok: true }));
|
|
528
|
+
} catch (e) {
|
|
529
|
+
res.statusCode = 400;
|
|
530
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// --- content manager: list / read / write / delete pages + posts ---
|
|
536
|
+
if (req.method === "GET" && p === "/setup/content") {
|
|
537
|
+
const list = (type) => {
|
|
538
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
539
|
+
if (!fs.existsSync(dir)) return [];
|
|
540
|
+
return fs
|
|
541
|
+
.readdirSync(dir)
|
|
542
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
543
|
+
.map((f) => {
|
|
544
|
+
const slug = f.replace(/\.md$/, "");
|
|
545
|
+
const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
|
|
546
|
+
return { type, slug, title: (title || slug).trim() };
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
res.setHeader("Content-Type", "application/json");
|
|
550
|
+
return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
|
|
551
|
+
}
|
|
552
|
+
if (req.method === "GET" && p === "/setup/content/raw") {
|
|
553
|
+
const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
|
|
554
|
+
const slug = u.searchParams.get("slug") || "";
|
|
555
|
+
res.setHeader("Content-Type", "application/json");
|
|
556
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
|
|
557
|
+
const file = path.join(__dirname, type, slug + ".md");
|
|
558
|
+
return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
|
|
559
|
+
}
|
|
560
|
+
if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
|
|
561
|
+
let cbody = "";
|
|
562
|
+
req.on("data", (c) => (cbody += c));
|
|
563
|
+
req.on("end", () => {
|
|
564
|
+
res.setHeader("Content-Type", "application/json");
|
|
565
|
+
try {
|
|
566
|
+
const { type, slug, body } = JSON.parse(cbody || "{}");
|
|
567
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
|
|
568
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
569
|
+
const file = path.join(dir, slug + ".md");
|
|
570
|
+
if (p === "/setup/content/delete") {
|
|
571
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
572
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
573
|
+
}
|
|
574
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
575
|
+
// RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
|
|
576
|
+
// Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
|
|
577
|
+
// stay lean and the uploads land in the media library.
|
|
578
|
+
const mediaDir = path.join(__dirname, "public", "media");
|
|
579
|
+
const extFor = (mime) =>
|
|
580
|
+
({ "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg", "video/mp4": "mp4", "video/webm": "webm", "video/ogg": "ogv", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav" }[mime.toLowerCase()] || (mime.split("/")[1] || "bin").replace(/[^a-z0-9]+/gi, "").slice(0, 8) || "bin");
|
|
581
|
+
const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
|
|
582
|
+
try {
|
|
583
|
+
const buf = Buffer.from(b64, "base64");
|
|
584
|
+
const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
|
|
585
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
586
|
+
const dest = path.join(mediaDir, name);
|
|
587
|
+
if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
|
|
588
|
+
return pre + "/media/" + name + post;
|
|
589
|
+
} catch {
|
|
590
|
+
return m;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
fs.writeFileSync(file, finalBody);
|
|
594
|
+
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
595
|
+
} catch (e) {
|
|
596
|
+
res.statusCode = 400;
|
|
597
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
603
|
+
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
604
|
+
let body = "";
|
|
605
|
+
req.on("data", (c) => (body += c));
|
|
606
|
+
req.on("end", () => {
|
|
607
|
+
res.setHeader("Content-Type", "application/json");
|
|
608
|
+
try {
|
|
609
|
+
const { theme } = JSON.parse(body || "{}");
|
|
610
|
+
const src = path.join(THEMES_DIR, String(theme || ""), "index.js");
|
|
611
|
+
if (!theme || !/^[a-z0-9-]+$/i.test(theme) || !fs.existsSync(src)) return res.end(JSON.stringify({ ok: false, error: "unknown theme" }));
|
|
612
|
+
const dest = path.join(__dirname, "pages", "_theme.js");
|
|
613
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
614
|
+
fs.copyFileSync(src, dest);
|
|
615
|
+
res.end(JSON.stringify({ ok: true, path: "pages/_theme.js" }));
|
|
616
|
+
} catch (e) {
|
|
617
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (req.method === "POST" && p === "/setup/test-db") {
|
|
623
|
+
let body = "";
|
|
624
|
+
req.on("data", (c) => (body += c));
|
|
625
|
+
req.on("end", async () => {
|
|
626
|
+
const keys = ["DB_DRIVER", "MONGODB_URI", "MONGODB_DATABASE", "DATABASE_URL"];
|
|
627
|
+
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
628
|
+
try {
|
|
629
|
+
const { env = {} } = JSON.parse(body);
|
|
630
|
+
for (const k of keys) {
|
|
631
|
+
if (env[k]) process.env[k] = env[k];
|
|
632
|
+
else delete process.env[k];
|
|
633
|
+
}
|
|
634
|
+
ensureDriverInstalled(process.env.DB_DRIVER); // install the driver first, so the test can connect
|
|
635
|
+
const store = await (await addonMod("db")).createStore();
|
|
636
|
+
await store.collection("__voltcheck").all();
|
|
637
|
+
res.setHeader("Content-Type", "application/json");
|
|
638
|
+
res.end(JSON.stringify({ ok: true, driver: store.name }));
|
|
639
|
+
} catch (e) {
|
|
640
|
+
res.setHeader("Content-Type", "application/json");
|
|
641
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
642
|
+
} finally {
|
|
643
|
+
for (const k of keys) {
|
|
644
|
+
if (saved[k] == null) delete process.env[k];
|
|
645
|
+
else process.env[k] = saved[k];
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// verify SMTP creds (form values merged over the saved .env) — auth check via
|
|
652
|
+
// nodemailer if available, else a TCP reachability check.
|
|
653
|
+
if (req.method === "POST" && p === "/setup/test-smtp") {
|
|
654
|
+
let body = "";
|
|
655
|
+
req.on("data", (c) => (body += c));
|
|
656
|
+
req.on("end", async () => {
|
|
657
|
+
res.setHeader("Content-Type", "application/json");
|
|
658
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
659
|
+
try {
|
|
660
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
661
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
662
|
+
const url = cfg.SMTP_URL;
|
|
663
|
+
const host = cfg.SMTP_HOST;
|
|
664
|
+
if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
|
|
665
|
+
let nodemailer;
|
|
666
|
+
try {
|
|
667
|
+
nodemailer = (await import("nodemailer")).default;
|
|
668
|
+
} catch {
|
|
669
|
+
/* not installed */
|
|
670
|
+
}
|
|
671
|
+
if (nodemailer) {
|
|
672
|
+
const transport = url
|
|
673
|
+
? nodemailer.createTransport(url)
|
|
674
|
+
: nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
|
|
675
|
+
await transport.verify();
|
|
676
|
+
return done({ ok: true, detail: "connection + auth OK" });
|
|
677
|
+
}
|
|
678
|
+
const net = await import("node:net");
|
|
679
|
+
let h = host;
|
|
680
|
+
let prt = Number(cfg.SMTP_PORT) || 587;
|
|
681
|
+
if (url) {
|
|
682
|
+
const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
|
|
683
|
+
h = u.hostname;
|
|
684
|
+
prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
|
|
685
|
+
}
|
|
686
|
+
await new Promise((resolve, reject) => {
|
|
687
|
+
const s = net.connect(prt, h, () => {
|
|
688
|
+
s.end();
|
|
689
|
+
resolve();
|
|
690
|
+
});
|
|
691
|
+
s.setTimeout(5000, () => {
|
|
692
|
+
s.destroy();
|
|
693
|
+
reject(new Error("timeout"));
|
|
694
|
+
});
|
|
695
|
+
s.on("error", reject);
|
|
696
|
+
});
|
|
697
|
+
done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
|
|
698
|
+
} catch (e) {
|
|
699
|
+
done({ ok: false, error: e.message });
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// verify the AI provider key (or gateway token) with a 1-token live call.
|
|
705
|
+
if (req.method === "POST" && p === "/setup/test-ai") {
|
|
706
|
+
let body = "";
|
|
707
|
+
req.on("data", (c) => (body += c));
|
|
708
|
+
req.on("end", async () => {
|
|
709
|
+
res.setHeader("Content-Type", "application/json");
|
|
710
|
+
const done = (o) => res.end(JSON.stringify(o));
|
|
711
|
+
try {
|
|
712
|
+
const { env = {} } = JSON.parse(body || "{}");
|
|
713
|
+
const cfg = { ...readEnvFile(), ...env };
|
|
714
|
+
const provider = cfg.AI_PROVIDER || "anthropic";
|
|
715
|
+
const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
|
|
716
|
+
let url, headers, payload, label;
|
|
717
|
+
if (key) {
|
|
718
|
+
label = provider + " key";
|
|
719
|
+
if (provider === "anthropic") {
|
|
720
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
721
|
+
headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
722
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
723
|
+
} else if (provider === "openai") {
|
|
724
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
725
|
+
headers = { authorization: "Bearer " + key, "content-type": "application/json" };
|
|
726
|
+
payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
727
|
+
} else {
|
|
728
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
|
|
729
|
+
headers = { "content-type": "application/json" };
|
|
730
|
+
payload = { contents: [{ parts: [{ text: "hi" }] }] };
|
|
731
|
+
}
|
|
732
|
+
} else if (cfg.VOLT_AI_TOKEN) {
|
|
733
|
+
label = "hosted gateway";
|
|
734
|
+
url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
735
|
+
headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
736
|
+
payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
|
|
737
|
+
} else {
|
|
738
|
+
return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
|
|
739
|
+
}
|
|
740
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
741
|
+
if (r.ok) return done({ ok: true, detail: `${label} works` });
|
|
742
|
+
done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
|
|
743
|
+
} catch (e) {
|
|
744
|
+
done({ ok: false, error: e.message });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (req.method === "POST" && p === "/setup/apply") {
|
|
750
|
+
let body = "";
|
|
751
|
+
req.on("data", (c) => (body += c));
|
|
752
|
+
req.on("end", () => {
|
|
753
|
+
try {
|
|
754
|
+
const { env } = JSON.parse(body);
|
|
755
|
+
if (typeof env !== "string") throw new Error("missing env");
|
|
756
|
+
|
|
757
|
+
// 1) write .env, preserving any custom keys the form doesn't manage
|
|
758
|
+
let finalEnv = env;
|
|
759
|
+
if (fs.existsSync(ENV_PATH)) {
|
|
760
|
+
const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
|
|
761
|
+
const extra = readEnvFileLines().filter((line) => {
|
|
762
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
|
|
763
|
+
return m && !managed.has(m[1]);
|
|
764
|
+
});
|
|
765
|
+
if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
|
|
766
|
+
}
|
|
767
|
+
fs.writeFileSync(ENV_PATH, finalEnv);
|
|
768
|
+
|
|
769
|
+
// 2) declare any needed packages in package.json
|
|
770
|
+
const added = neededPackages(env);
|
|
771
|
+
if (added.length) {
|
|
772
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
|
|
773
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
774
|
+
for (const name of added) pkg.dependencies[name] = PKG_VERSIONS[name] || "latest";
|
|
775
|
+
fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
|
|
779
|
+
const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
|
|
780
|
+
res.setHeader("Content-Type", "application/json");
|
|
781
|
+
res.end(JSON.stringify({ ok: true, port: newPort, installing: added }));
|
|
782
|
+
|
|
783
|
+
// 3) install (if needed), then hand off to the app
|
|
784
|
+
res.on("finish", () => {
|
|
785
|
+
const handoff = () => {
|
|
786
|
+
server.close(() => {
|
|
787
|
+
loadEnv();
|
|
788
|
+
startApp();
|
|
789
|
+
});
|
|
790
|
+
server.closeIdleConnections?.();
|
|
791
|
+
};
|
|
792
|
+
if (added.length) {
|
|
793
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
794
|
+
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
795
|
+
npm.on("error", () => handoff());
|
|
796
|
+
npm.on("close", () => {
|
|
797
|
+
console.log("[volt] saved .env — starting the app…");
|
|
798
|
+
handoff();
|
|
799
|
+
});
|
|
800
|
+
} else {
|
|
801
|
+
console.log("[volt] saved .env — starting the app…");
|
|
802
|
+
handoff();
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
} catch (e) {
|
|
806
|
+
res.statusCode = 400;
|
|
807
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
res.statusCode = 404;
|
|
813
|
+
res.end("not found");
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
817
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
818
|
+
const url = `http://localhost:${PORT}`;
|
|
819
|
+
console.log(`\nVolt setup at ${url}`);
|
|
820
|
+
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
821
|
+
const ssh = process.env.SSH_CONNECTION;
|
|
822
|
+
if (ssh) {
|
|
823
|
+
const host = ssh.split(" ")[2];
|
|
824
|
+
const user = process.env.USER || process.env.USERNAME || "you";
|
|
825
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
826
|
+
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
827
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
828
|
+
}
|
|
829
|
+
console.log("");
|
|
830
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function readEnvFileLines() {
|
|
835
|
+
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// --- Studio: an ephemeral, localhost-only data browser (— la Prisma Studio).
|
|
839
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
840
|
+
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
841
|
+
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
842
|
+
async function startStudio() {
|
|
843
|
+
loadEnv();
|
|
844
|
+
if (!enabledFrom(process.env).has("db")) {
|
|
845
|
+
console.error("Studio needs the db add-on. Enable it: npm run dev -- --edit");
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
let store;
|
|
849
|
+
try {
|
|
850
|
+
store = await (await addonMod("db")).createStore();
|
|
851
|
+
} catch (e) {
|
|
852
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
853
|
+
process.exit(1);
|
|
854
|
+
}
|
|
855
|
+
const PORT = configPort();
|
|
856
|
+
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
857
|
+
const assets = {
|
|
858
|
+
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
859
|
+
"/logo.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "logo.webp"))],
|
|
860
|
+
"/favicon.webp": ["image/webp", fs.readFileSync(path.join(__dirname, "public", "favicon.webp"))],
|
|
861
|
+
"/db-admin-ui.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(ADDONS_DIR, "db", "files", "public", "db-admin-ui.js"))],
|
|
862
|
+
};
|
|
863
|
+
const studioHtml = fs.readFileSync(path.join(__dirname, "setup", "studio.html"));
|
|
864
|
+
const json = (res, code, obj) => {
|
|
865
|
+
res.statusCode = code;
|
|
866
|
+
res.setHeader("Content-Type", "application/json");
|
|
867
|
+
res.end(JSON.stringify(obj));
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const server = http.createServer(async (req, res) => {
|
|
871
|
+
const u = new URL(req.url, "http://localhost");
|
|
872
|
+
const p = u.pathname;
|
|
873
|
+
try {
|
|
874
|
+
if (req.method === "GET" && p === "/") {
|
|
875
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
876
|
+
return res.end(studioHtml);
|
|
877
|
+
}
|
|
878
|
+
if (req.method === "GET" && assets[p]) {
|
|
879
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
880
|
+
return res.end(assets[p][1]);
|
|
881
|
+
}
|
|
882
|
+
if (req.method === "GET" && p === "/admin/db/collections") {
|
|
883
|
+
const all = (await store.collections()) || [];
|
|
884
|
+
return json(res, 200, { driver: store.name, collections: all.filter(visible) });
|
|
885
|
+
}
|
|
886
|
+
if (req.method === "GET" && p === "/admin/db/collection") {
|
|
887
|
+
const name = u.searchParams.get("name") || "";
|
|
888
|
+
if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
|
|
889
|
+
const docs = (await store.collection(name).all()).slice(0, 500);
|
|
890
|
+
return json(res, 200, { ok: true, name, docs });
|
|
891
|
+
}
|
|
892
|
+
if (req.method === "DELETE" && p === "/admin/db/doc") {
|
|
893
|
+
const name = u.searchParams.get("name") || "";
|
|
894
|
+
const id = u.searchParams.get("id") || "";
|
|
895
|
+
if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
|
|
896
|
+
if (!id) return json(res, 400, { ok: false, error: "missing id" });
|
|
897
|
+
await store.collection(name).delete(id);
|
|
898
|
+
return json(res, 200, { ok: true });
|
|
899
|
+
}
|
|
900
|
+
res.statusCode = 404;
|
|
901
|
+
res.end("not found");
|
|
902
|
+
} catch (e) {
|
|
903
|
+
json(res, 500, { ok: false, error: e.message });
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
|
|
908
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
909
|
+
const url = `http://localhost:${PORT}`;
|
|
910
|
+
console.log(`\nVolt Studio at ${url} (${store.name})`);
|
|
911
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
912
|
+
const ssh = process.env.SSH_CONNECTION;
|
|
913
|
+
if (ssh) {
|
|
914
|
+
const host = ssh.split(" ")[2];
|
|
915
|
+
const user = process.env.USER || process.env.USERNAME || "you";
|
|
916
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
917
|
+
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
918
|
+
console.log(` …then open ${url}.`);
|
|
919
|
+
}
|
|
920
|
+
console.log("");
|
|
921
|
+
openBrowser(url);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// --- `--logs`: a disposable, localhost-only log viewer (its own port, like
|
|
926
|
+
// --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
|
|
927
|
+
// tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
|
|
928
|
+
// for a remote box, SSH-tunnel the port. ---
|
|
929
|
+
async function startLogs() {
|
|
930
|
+
loadEnv();
|
|
931
|
+
const PORT = configPort();
|
|
932
|
+
const name = (() => {
|
|
933
|
+
try {
|
|
934
|
+
return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
|
|
935
|
+
} catch {
|
|
936
|
+
return "app";
|
|
937
|
+
}
|
|
938
|
+
})();
|
|
939
|
+
const logsDir = path.join(os.homedir(), ".pm2", "logs");
|
|
940
|
+
// Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
|
|
941
|
+
// log; users add more (other apps/servers/mounted or tunneled paths) via
|
|
942
|
+
// .volt/logs.json, editable here in the viewer. Re-read per request so additions
|
|
943
|
+
// show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
|
|
944
|
+
const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
|
|
945
|
+
const readExtra = () => {
|
|
946
|
+
try {
|
|
947
|
+
const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
|
|
948
|
+
return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
|
|
949
|
+
} catch {
|
|
950
|
+
return [];
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
const sources = () => {
|
|
954
|
+
const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
|
|
955
|
+
if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
|
|
956
|
+
for (const x of readExtra()) s[x.label] = x.file;
|
|
957
|
+
return s;
|
|
958
|
+
};
|
|
959
|
+
const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
|
|
960
|
+
let parseLine = null;
|
|
961
|
+
try {
|
|
962
|
+
parseLine = (await import("mir-sentinel")).parseLine;
|
|
963
|
+
} catch {
|
|
964
|
+
/* analytics optional */
|
|
965
|
+
}
|
|
966
|
+
const top = (arr, key) => {
|
|
967
|
+
const m = {};
|
|
968
|
+
for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
969
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
970
|
+
};
|
|
971
|
+
const assets = {
|
|
972
|
+
"/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
|
|
973
|
+
"/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
|
|
974
|
+
};
|
|
975
|
+
const json = (res, o) => {
|
|
976
|
+
res.setHeader("Content-Type", "application/json");
|
|
977
|
+
res.end(JSON.stringify(o));
|
|
978
|
+
};
|
|
979
|
+
const server = http.createServer((req, res) => {
|
|
980
|
+
const u = new URL(req.url, "http://localhost");
|
|
981
|
+
const p = u.pathname;
|
|
982
|
+
if (assets[p]) {
|
|
983
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
984
|
+
return res.end(assets[p][1]);
|
|
985
|
+
}
|
|
986
|
+
if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
|
|
987
|
+
if (p === "/api/tail") {
|
|
988
|
+
const f = sources()[u.searchParams.get("source")];
|
|
989
|
+
return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
|
|
990
|
+
}
|
|
991
|
+
if (p === "/api/analytics") {
|
|
992
|
+
if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
|
|
993
|
+
const f = sources()[u.searchParams.get("source")];
|
|
994
|
+
if (!f) return json(res, { ok: false });
|
|
995
|
+
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
996
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.isBot).length, attacks: parsed.filter((x) => x && x.isAttack).length });
|
|
997
|
+
}
|
|
998
|
+
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
999
|
+
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
1000
|
+
let body = "";
|
|
1001
|
+
req.on("data", (c) => (body += c));
|
|
1002
|
+
req.on("end", () => {
|
|
1003
|
+
try {
|
|
1004
|
+
const { label, file } = JSON.parse(body || "{}");
|
|
1005
|
+
if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
|
|
1006
|
+
let list = readExtra().filter((x) => x.label !== label);
|
|
1007
|
+
if (p === "/api/source") list.push({ label, file: String(file || "") });
|
|
1008
|
+
fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
|
|
1009
|
+
fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
|
|
1010
|
+
json(res, { ok: true });
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
res.statusCode = 400;
|
|
1013
|
+
json(res, { ok: false, error: e.message });
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
res.statusCode = 404;
|
|
1019
|
+
res.end("not found");
|
|
1020
|
+
});
|
|
1021
|
+
server.on("error", (e) => {
|
|
1022
|
+
if (e.code === "EADDRINUSE") {
|
|
1023
|
+
console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
}
|
|
1026
|
+
throw e;
|
|
1027
|
+
});
|
|
1028
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
1029
|
+
const url = `http://localhost:${PORT}`;
|
|
1030
|
+
console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
|
|
1031
|
+
console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
|
|
1032
|
+
openBrowser(url);
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// --- gate: studio / logs / setup (first run, --edit) / the app ---
|
|
1037
|
+
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
1038
|
+
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
1039
|
+
// platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
|
|
1040
|
+
// the first-run wizard when nothing is configured and we're not in production.
|
|
1041
|
+
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
1042
|
+
if (process.argv.includes("--studio")) {
|
|
1043
|
+
startStudio();
|
|
1044
|
+
} else if (process.argv.includes("--logs")) {
|
|
1045
|
+
startLogs();
|
|
1046
|
+
} else if (editMode || !configured) {
|
|
1047
|
+
startSetup();
|
|
1048
|
+
} else {
|
|
1049
|
+
loadEnv();
|
|
1050
|
+
startApp();
|
|
1051
|
+
}
|