create-volt 0.40.0 → 0.42.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/package.json +1 -1
- package/templates/blog/ecosystem.config.cjs +5 -0
- package/templates/blog/package.json +5 -1
- package/templates/blog/server.js +96 -26
- package/templates/blog/setup/setup.js +75 -26
- package/templates/default/ecosystem.config.cjs +5 -0
- package/templates/default/package.json +5 -1
- package/templates/default/server.js +96 -26
- package/templates/default/setup/setup.js +75 -26
- package/templates/docs/ecosystem.config.cjs +5 -0
- package/templates/docs/package.json +5 -1
- package/templates/docs/server.js +96 -26
- package/templates/docs/setup/setup.js +75 -26
- package/templates/guestbook/ecosystem.config.cjs +5 -0
- package/templates/guestbook/package.json +5 -1
- package/templates/starter/ecosystem.config.cjs +5 -0
- package/templates/starter/package.json +5 -1
- package/templates/starter/server.js +97 -27
- package/templates/starter/setup/setup.js +75 -26
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// server.js
|
|
1
|
+
// server.js — dev server with a built-in first-run setup wizard.
|
|
2
2
|
//
|
|
3
3
|
// First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
|
|
4
4
|
// config page: tick add-ons, fill settings, Apply. Apply writes .env (a
|
|
5
5
|
// VOLT_ADDONS list + settings) and adds any needed packages to package.json,
|
|
6
|
-
// runs npm install, then starts the app
|
|
6
|
+
// runs npm install, then starts the app — which wires whatever .env enables.
|
|
7
7
|
// Add-on code is bundled under .volt/addons; nothing is copied into your code.
|
|
8
8
|
//
|
|
9
9
|
// No build step, no env-file flag: .env is auto-loaded below.
|
|
@@ -24,7 +24,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
|
|
|
24
24
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
25
25
|
const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
|
|
26
26
|
|
|
27
|
-
// `--port <n>` (or --port=<n>) overrides the listen port for this run
|
|
27
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
28
28
|
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
29
29
|
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
30
30
|
function cliPort() {
|
|
@@ -104,7 +104,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
|
|
|
104
104
|
const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
|
|
105
105
|
|
|
106
106
|
// Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
|
|
107
|
-
// a third-party add-on
|
|
107
|
+
// a third-party add-on — a local .volt/addons/<name>/index.js or an installed
|
|
108
108
|
// npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
|
|
109
109
|
const BUILTINS = new Set(Object.keys(LIB_FILE));
|
|
110
110
|
async function loadAddon(name) {
|
|
@@ -128,7 +128,7 @@ function openBrowser(url) {
|
|
|
128
128
|
const args = plat === "win32" ? ["/c", "start", "", url] : [url];
|
|
129
129
|
try {
|
|
130
130
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
131
|
-
child.on("error", () => {}); // launcher missing
|
|
131
|
+
child.on("error", () => {}); // launcher missing — emits async, don't crash
|
|
132
132
|
child.unref();
|
|
133
133
|
return true;
|
|
134
134
|
} catch {
|
|
@@ -175,8 +175,8 @@ async function startApp() {
|
|
|
175
175
|
app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
// markdown pages (/<slug>
|
|
179
|
-
// blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml)
|
|
178
|
+
// markdown pages (/<slug> → pages/<slug>.md) — mounted last, so app routes win
|
|
179
|
+
// blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
|
|
180
180
|
if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
|
|
181
181
|
if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
|
|
182
182
|
|
|
@@ -184,7 +184,14 @@ async function startApp() {
|
|
|
184
184
|
const io = new SocketServer(server);
|
|
185
185
|
if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
|
|
186
186
|
|
|
187
|
-
//
|
|
187
|
+
// Reload connected browsers on demand — used when a second `npm run dev` finds
|
|
188
|
+
// the app already running (see the EADDRINUSE handler below) instead of crashing.
|
|
189
|
+
app.get("/__volt/reload", (_req, res) => {
|
|
190
|
+
io.emit("volt:reload", { file: "__manual__" });
|
|
191
|
+
res.json({ ok: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
|
|
188
195
|
// are provided so add-ons can gate routes by login.
|
|
189
196
|
let requireAuth = null;
|
|
190
197
|
let sessionFromReq = null;
|
|
@@ -200,7 +207,7 @@ async function startApp() {
|
|
|
200
207
|
if (typeof register === "function") {
|
|
201
208
|
await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
|
|
202
209
|
} else {
|
|
203
|
-
console.warn(`[volt] add-on "${name}" not found or missing a register() export
|
|
210
|
+
console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
|
|
204
211
|
}
|
|
205
212
|
}
|
|
206
213
|
|
|
@@ -208,7 +215,7 @@ async function startApp() {
|
|
|
208
215
|
const onChange = (file) => {
|
|
209
216
|
clearTimeout(timer);
|
|
210
217
|
timer = setTimeout(() => {
|
|
211
|
-
console.log(`[volt] change: ${file ?? "?"}
|
|
218
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
212
219
|
io.emit("volt:reload", { file });
|
|
213
220
|
}, 80);
|
|
214
221
|
};
|
|
@@ -237,7 +244,21 @@ async function startApp() {
|
|
|
237
244
|
}
|
|
238
245
|
|
|
239
246
|
const on = [...enabled];
|
|
240
|
-
|
|
247
|
+
// If the port's taken, the app is likely already running — reload it (tell the
|
|
248
|
+
// running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
|
|
249
|
+
server.on("error", async (e) => {
|
|
250
|
+
if (e.code === "EADDRINUSE") {
|
|
251
|
+
try {
|
|
252
|
+
await fetch(`http://localhost:${PORT}/__volt/reload`);
|
|
253
|
+
} catch {
|
|
254
|
+
/* old instance without the reload route, or not ours */
|
|
255
|
+
}
|
|
256
|
+
console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
throw e;
|
|
260
|
+
});
|
|
261
|
+
server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
// Packages an .env's selections need, beyond what package.json already has.
|
|
@@ -261,7 +282,7 @@ function neededPackages(env) {
|
|
|
261
282
|
function ensureDriverInstalled(driver) {
|
|
262
283
|
const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
|
|
263
284
|
if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
|
|
264
|
-
console.log(`[volt] installing ${pkg} for the connection test
|
|
285
|
+
console.log(`[volt] installing ${pkg} for the connection test…`);
|
|
265
286
|
spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
266
287
|
}
|
|
267
288
|
|
|
@@ -291,6 +312,55 @@ function startSetup() {
|
|
|
291
312
|
res.setHeader("Content-Type", "application/json");
|
|
292
313
|
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
293
314
|
}
|
|
315
|
+
// --- content manager: list / read / write / delete pages + posts ---
|
|
316
|
+
if (req.method === "GET" && p === "/setup/content") {
|
|
317
|
+
const list = (type) => {
|
|
318
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
319
|
+
if (!fs.existsSync(dir)) return [];
|
|
320
|
+
return fs
|
|
321
|
+
.readdirSync(dir)
|
|
322
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
323
|
+
.map((f) => {
|
|
324
|
+
const slug = f.replace(/\.md$/, "");
|
|
325
|
+
const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
|
|
326
|
+
return { type, slug, title: (title || slug).trim() };
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
res.setHeader("Content-Type", "application/json");
|
|
330
|
+
return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
|
|
331
|
+
}
|
|
332
|
+
if (req.method === "GET" && p === "/setup/content/raw") {
|
|
333
|
+
const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
|
|
334
|
+
const slug = u.searchParams.get("slug") || "";
|
|
335
|
+
res.setHeader("Content-Type", "application/json");
|
|
336
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
|
|
337
|
+
const file = path.join(__dirname, type, slug + ".md");
|
|
338
|
+
return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
|
|
339
|
+
}
|
|
340
|
+
if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
|
|
341
|
+
let cbody = "";
|
|
342
|
+
req.on("data", (c) => (cbody += c));
|
|
343
|
+
req.on("end", () => {
|
|
344
|
+
res.setHeader("Content-Type", "application/json");
|
|
345
|
+
try {
|
|
346
|
+
const { type, slug, body } = JSON.parse(cbody || "{}");
|
|
347
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
|
|
348
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
349
|
+
const file = path.join(dir, slug + ".md");
|
|
350
|
+
if (p === "/setup/content/delete") {
|
|
351
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
352
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
353
|
+
}
|
|
354
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
355
|
+
fs.writeFileSync(file, String(body ?? ""));
|
|
356
|
+
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
res.statusCode = 400;
|
|
359
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
294
364
|
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
295
365
|
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
296
366
|
let body = "";
|
|
@@ -384,15 +454,15 @@ function startSetup() {
|
|
|
384
454
|
server.closeIdleConnections?.();
|
|
385
455
|
};
|
|
386
456
|
if (added.length) {
|
|
387
|
-
console.log(`[volt] installing ${added.join(", ")}
|
|
457
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
388
458
|
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
389
459
|
npm.on("error", () => handoff());
|
|
390
460
|
npm.on("close", () => {
|
|
391
|
-
console.log("[volt] saved .env
|
|
461
|
+
console.log("[volt] saved .env — starting the app…");
|
|
392
462
|
handoff();
|
|
393
463
|
});
|
|
394
464
|
} else {
|
|
395
|
-
console.log("[volt] saved .env
|
|
465
|
+
console.log("[volt] saved .env — starting the app…");
|
|
396
466
|
handoff();
|
|
397
467
|
}
|
|
398
468
|
});
|
|
@@ -410,18 +480,18 @@ function startSetup() {
|
|
|
410
480
|
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; });
|
|
411
481
|
server.listen(PORT, "127.0.0.1", () => {
|
|
412
482
|
const url = `http://localhost:${PORT}`;
|
|
413
|
-
console.log(`\
|
|
483
|
+
console.log(`\nVolt setup at ${url}`);
|
|
414
484
|
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
415
485
|
const ssh = process.env.SSH_CONNECTION;
|
|
416
486
|
if (ssh) {
|
|
417
487
|
const host = ssh.split(" ")[2];
|
|
418
488
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
419
|
-
console.log(" Remote box
|
|
489
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
420
490
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
421
|
-
console.log(`
|
|
491
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
422
492
|
}
|
|
423
493
|
console.log("");
|
|
424
|
-
if (openBrowser(url)) console.log(" (opening your browser
|
|
494
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
425
495
|
});
|
|
426
496
|
}
|
|
427
497
|
|
|
@@ -429,8 +499,8 @@ function readEnvFileLines() {
|
|
|
429
499
|
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
430
500
|
}
|
|
431
501
|
|
|
432
|
-
// --- Studio: an ephemeral, localhost-only data browser (
|
|
433
|
-
// Not a route in the running app
|
|
502
|
+
// --- Studio: an ephemeral, localhost-only data browser (— la Prisma Studio).
|
|
503
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
434
504
|
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
435
505
|
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
436
506
|
async function startStudio() {
|
|
@@ -443,7 +513,7 @@ async function startStudio() {
|
|
|
443
513
|
try {
|
|
444
514
|
store = await (await addonMod("db")).createStore();
|
|
445
515
|
} catch (e) {
|
|
446
|
-
console.error("Studio: couldn't connect the store
|
|
516
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
447
517
|
process.exit(1);
|
|
448
518
|
}
|
|
449
519
|
const PORT = configPort();
|
|
@@ -501,15 +571,15 @@ async function startStudio() {
|
|
|
501
571
|
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; });
|
|
502
572
|
server.listen(PORT, "127.0.0.1", () => {
|
|
503
573
|
const url = `http://localhost:${PORT}`;
|
|
504
|
-
console.log(`\
|
|
505
|
-
console.log(" Browse your data. localhost-only, disposable
|
|
574
|
+
console.log(`\nVolt Studio at ${url} (${store.name})`);
|
|
575
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
506
576
|
const ssh = process.env.SSH_CONNECTION;
|
|
507
577
|
if (ssh) {
|
|
508
578
|
const host = ssh.split(" ")[2];
|
|
509
579
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
510
|
-
console.log(" Remote box
|
|
580
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
511
581
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
512
|
-
console.log(`
|
|
582
|
+
console.log(` …then open ${url}.`);
|
|
513
583
|
}
|
|
514
584
|
console.log("");
|
|
515
585
|
openBrowser(url);
|
|
@@ -262,33 +262,82 @@ const aiSettings = () =>
|
|
|
262
262
|
</div>
|
|
263
263
|
</details>`;
|
|
264
264
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
265
|
+
// --- Manage content (a second screen reached via "Manage content →") ---
|
|
266
|
+
const view = signal("config"); // "config" | "manage"
|
|
267
|
+
const items = signal({ pages: [], posts: [] });
|
|
268
|
+
const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
|
|
269
|
+
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
270
|
+
async function editItem(type, slug) {
|
|
271
|
+
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
272
|
+
editing({ type, slug, body: d.body || "", isNew: false });
|
|
273
|
+
}
|
|
274
|
+
function newItem(type) {
|
|
275
|
+
const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
|
|
276
|
+
editing({ type, slug: "", body, isNew: true });
|
|
277
|
+
}
|
|
278
|
+
async function saveItem() {
|
|
279
|
+
const e = editing();
|
|
280
|
+
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
281
|
+
const body = document.querySelector("#mg-body").value;
|
|
282
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
283
|
+
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
284
|
+
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
285
|
+
status("Saved → " + r.file);
|
|
286
|
+
editing(null);
|
|
287
|
+
loadItems();
|
|
288
|
+
}
|
|
289
|
+
async function delItem(type, slug) {
|
|
290
|
+
if (typeof confirm === "function" && !confirm(`Delete ${slug}?`)) return;
|
|
291
|
+
await fetch("/setup/content/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, slug }) });
|
|
292
|
+
status("Deleted " + slug);
|
|
293
|
+
loadItems();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const itemRow = (it) =>
|
|
297
|
+
html`<li class="list-group-item bg-transparent text-light d-flex justify-content-between align-items-center py-1 px-2">
|
|
298
|
+
<span><a href=${"http://localhost:" + state().port + (it.type === "post" ? "/blog/" : "/") + it.slug} target="_blank" rel="noopener">${it.title}</a> <span class="text-muted small">/${it.type === "post" ? "blog/" : ""}${it.slug}</span></span>
|
|
299
|
+
<span><button class="btn btn-sm btn-link p-0 me-3" onclick=${() => editItem(it.type, it.slug)}>edit</button><button class="btn btn-sm btn-link p-0 text-danger" onclick=${() => delItem(it.type, it.slug)}>delete</button></span>
|
|
300
|
+
</li>`;
|
|
301
|
+
const section = (label, type, key) =>
|
|
302
|
+
html`<div class="mb-3">
|
|
303
|
+
<div class="d-flex justify-content-between align-items-center mb-1"><strong>${label}</strong><button class="btn btn-sm btn-outline-secondary" onclick=${() => newItem(type)}>+ New</button></div>
|
|
304
|
+
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
305
|
+
</div>`;
|
|
306
|
+
const editorPanel = () => {
|
|
307
|
+
const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
|
|
308
|
+
return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
|
|
309
|
+
<div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
|
|
310
|
+
<textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px">${e.body}</textarea>
|
|
311
|
+
<div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
|
|
312
|
+
</div>`;
|
|
313
|
+
};
|
|
314
|
+
const manageView = () =>
|
|
286
315
|
html`<div class="card-x p-4 mb-3">
|
|
287
|
-
<div class="d-flex justify-content-between align-items-center mb-
|
|
288
|
-
|
|
289
|
-
|
|
316
|
+
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h6 mb-0">Manage content</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
|
|
317
|
+
${() => (editing() ? editorPanel() : html`${section("Pages", "page", "pages")}${section("Posts", "post", "posts")}<p class="small text-muted mb-0">Pages → <code>/slug</code>, posts → <code>/blog/slug</code>; <code>index</code> page is your home. All rendered in your theme. Edits hot-reload the running app.</p>`)}
|
|
318
|
+
</div>`;
|
|
319
|
+
|
|
320
|
+
const configView = () =>
|
|
321
|
+
html`${available.length ? html`<div class="card-x p-4 mb-3"><h2 class="h6 mb-3">Features</h2>${available.map(addonRow)}<p class="small text-muted mb-0">Enabling a feature wires its backend automatically. Frontend UI (login form, chat) is yours to build — or start from <code>--template guestbook</code>.</p></div>` : ""}
|
|
322
|
+
<div class="card-x p-4 mb-3">
|
|
323
|
+
<h2 class="h6 mb-3">Settings</h2>
|
|
324
|
+
${field("PORT", "port", String(defaultPort))}
|
|
325
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
326
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
327
|
+
${() => (hasDb() ? dbSettings() : null)}
|
|
328
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
329
|
+
${() => (hasMedia() ? mediaSettings() : null)}
|
|
330
|
+
${aiSettings()}
|
|
331
|
+
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
332
|
+
${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
|
|
290
333
|
</div>
|
|
291
|
-
<
|
|
292
|
-
|
|
334
|
+
<div class="card-x p-4 mb-3">
|
|
335
|
+
<div class="d-flex justify-content-between align-items-center mb-2"><h2 class="h6 mb-0">.env</h2><div class="d-flex gap-2">${() => (hasContent() ? html`<button class="btn btn-outline-light btn-sm" onclick=${() => (view("manage"), loadItems())}>Manage content →</button>` : "")}<button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
|
|
336
|
+
<pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
|
|
337
|
+
</div>`;
|
|
338
|
+
|
|
339
|
+
mount(
|
|
340
|
+
"#app",
|
|
341
|
+
() => (view() === "config" ? configView() : manageView()),
|
|
293
342
|
() => (status() ? html`<p class="small accent">${status}</p>` : null),
|
|
294
343
|
);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
|
|
2
|
+
// if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
|
|
3
|
+
// port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
|
|
4
|
+
const { name } = require("./package.json");
|
|
5
|
+
module.exports = { apps: [{ name, script: "server.js" }] };
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
"main": "server.js",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"start": "node server.js",
|
|
10
|
-
"dev": "node server.js"
|
|
10
|
+
"dev": "node server.js",
|
|
11
|
+
"pm2": "npx --yes pm2 start ecosystem.config.cjs",
|
|
12
|
+
"pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
|
|
13
|
+
"pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
|
|
14
|
+
"pm2:logs": "npx --yes pm2 logs"
|
|
11
15
|
},
|
|
12
16
|
"dependencies": {
|
|
13
17
|
"express": "^4.22.2",
|
package/templates/docs/server.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// server.js
|
|
1
|
+
// server.js — dev server with a built-in first-run setup wizard.
|
|
2
2
|
//
|
|
3
3
|
// First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
|
|
4
4
|
// config page: tick add-ons, fill settings, Apply. Apply writes .env (a
|
|
5
5
|
// VOLT_ADDONS list + settings) and adds any needed packages to package.json,
|
|
6
|
-
// runs npm install, then starts the app
|
|
6
|
+
// runs npm install, then starts the app — which wires whatever .env enables.
|
|
7
7
|
// Add-on code is bundled under .volt/addons; nothing is copied into your code.
|
|
8
8
|
//
|
|
9
9
|
// No build step, no env-file flag: .env is auto-loaded below.
|
|
@@ -24,7 +24,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
|
|
|
24
24
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
25
25
|
const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
|
|
26
26
|
|
|
27
|
-
// `--port <n>` (or --port=<n>) overrides the listen port for this run
|
|
27
|
+
// `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
|
|
28
28
|
// --edit/--studio dodge a port the running app already holds, and runs the app
|
|
29
29
|
// itself on a one-off port. Explicit flag wins over PORT in .env.
|
|
30
30
|
function cliPort() {
|
|
@@ -104,7 +104,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
|
|
|
104
104
|
const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
|
|
105
105
|
|
|
106
106
|
// Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
|
|
107
|
-
// a third-party add-on
|
|
107
|
+
// a third-party add-on — a local .volt/addons/<name>/index.js or an installed
|
|
108
108
|
// npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
|
|
109
109
|
const BUILTINS = new Set(Object.keys(LIB_FILE));
|
|
110
110
|
async function loadAddon(name) {
|
|
@@ -128,7 +128,7 @@ function openBrowser(url) {
|
|
|
128
128
|
const args = plat === "win32" ? ["/c", "start", "", url] : [url];
|
|
129
129
|
try {
|
|
130
130
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
131
|
-
child.on("error", () => {}); // launcher missing
|
|
131
|
+
child.on("error", () => {}); // launcher missing — emits async, don't crash
|
|
132
132
|
child.unref();
|
|
133
133
|
return true;
|
|
134
134
|
} catch {
|
|
@@ -175,8 +175,8 @@ async function startApp() {
|
|
|
175
175
|
app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
// markdown pages (/<slug>
|
|
179
|
-
// blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml)
|
|
178
|
+
// markdown pages (/<slug> → pages/<slug>.md) — mounted last, so app routes win
|
|
179
|
+
// blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
|
|
180
180
|
if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
|
|
181
181
|
if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
|
|
182
182
|
|
|
@@ -184,7 +184,14 @@ async function startApp() {
|
|
|
184
184
|
const io = new SocketServer(server);
|
|
185
185
|
if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
|
|
186
186
|
|
|
187
|
-
//
|
|
187
|
+
// Reload connected browsers on demand — used when a second `npm run dev` finds
|
|
188
|
+
// the app already running (see the EADDRINUSE handler below) instead of crashing.
|
|
189
|
+
app.get("/__volt/reload", (_req, res) => {
|
|
190
|
+
io.emit("volt:reload", { file: "__manual__" });
|
|
191
|
+
res.json({ ok: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
|
|
188
195
|
// are provided so add-ons can gate routes by login.
|
|
189
196
|
let requireAuth = null;
|
|
190
197
|
let sessionFromReq = null;
|
|
@@ -200,7 +207,7 @@ async function startApp() {
|
|
|
200
207
|
if (typeof register === "function") {
|
|
201
208
|
await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
|
|
202
209
|
} else {
|
|
203
|
-
console.warn(`[volt] add-on "${name}" not found or missing a register() export
|
|
210
|
+
console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
|
|
204
211
|
}
|
|
205
212
|
}
|
|
206
213
|
|
|
@@ -208,7 +215,7 @@ async function startApp() {
|
|
|
208
215
|
const onChange = (file) => {
|
|
209
216
|
clearTimeout(timer);
|
|
210
217
|
timer = setTimeout(() => {
|
|
211
|
-
console.log(`[volt] change: ${file ?? "?"}
|
|
218
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
212
219
|
io.emit("volt:reload", { file });
|
|
213
220
|
}, 80);
|
|
214
221
|
};
|
|
@@ -237,7 +244,21 @@ async function startApp() {
|
|
|
237
244
|
}
|
|
238
245
|
|
|
239
246
|
const on = [...enabled];
|
|
240
|
-
|
|
247
|
+
// If the port's taken, the app is likely already running — reload it (tell the
|
|
248
|
+
// running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
|
|
249
|
+
server.on("error", async (e) => {
|
|
250
|
+
if (e.code === "EADDRINUSE") {
|
|
251
|
+
try {
|
|
252
|
+
await fetch(`http://localhost:${PORT}/__volt/reload`);
|
|
253
|
+
} catch {
|
|
254
|
+
/* old instance without the reload route, or not ours */
|
|
255
|
+
}
|
|
256
|
+
console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
throw e;
|
|
260
|
+
});
|
|
261
|
+
server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
// Packages an .env's selections need, beyond what package.json already has.
|
|
@@ -261,7 +282,7 @@ function neededPackages(env) {
|
|
|
261
282
|
function ensureDriverInstalled(driver) {
|
|
262
283
|
const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
|
|
263
284
|
if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
|
|
264
|
-
console.log(`[volt] installing ${pkg} for the connection test
|
|
285
|
+
console.log(`[volt] installing ${pkg} for the connection test…`);
|
|
265
286
|
spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
266
287
|
}
|
|
267
288
|
|
|
@@ -291,6 +312,55 @@ function startSetup() {
|
|
|
291
312
|
res.setHeader("Content-Type", "application/json");
|
|
292
313
|
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
293
314
|
}
|
|
315
|
+
// --- content manager: list / read / write / delete pages + posts ---
|
|
316
|
+
if (req.method === "GET" && p === "/setup/content") {
|
|
317
|
+
const list = (type) => {
|
|
318
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
319
|
+
if (!fs.existsSync(dir)) return [];
|
|
320
|
+
return fs
|
|
321
|
+
.readdirSync(dir)
|
|
322
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
323
|
+
.map((f) => {
|
|
324
|
+
const slug = f.replace(/\.md$/, "");
|
|
325
|
+
const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
|
|
326
|
+
return { type, slug, title: (title || slug).trim() };
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
res.setHeader("Content-Type", "application/json");
|
|
330
|
+
return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
|
|
331
|
+
}
|
|
332
|
+
if (req.method === "GET" && p === "/setup/content/raw") {
|
|
333
|
+
const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
|
|
334
|
+
const slug = u.searchParams.get("slug") || "";
|
|
335
|
+
res.setHeader("Content-Type", "application/json");
|
|
336
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
|
|
337
|
+
const file = path.join(__dirname, type, slug + ".md");
|
|
338
|
+
return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
|
|
339
|
+
}
|
|
340
|
+
if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
|
|
341
|
+
let cbody = "";
|
|
342
|
+
req.on("data", (c) => (cbody += c));
|
|
343
|
+
req.on("end", () => {
|
|
344
|
+
res.setHeader("Content-Type", "application/json");
|
|
345
|
+
try {
|
|
346
|
+
const { type, slug, body } = JSON.parse(cbody || "{}");
|
|
347
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
|
|
348
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
349
|
+
const file = path.join(dir, slug + ".md");
|
|
350
|
+
if (p === "/setup/content/delete") {
|
|
351
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
352
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
353
|
+
}
|
|
354
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
355
|
+
fs.writeFileSync(file, String(body ?? ""));
|
|
356
|
+
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
res.statusCode = 400;
|
|
359
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
294
364
|
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
295
365
|
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
296
366
|
let body = "";
|
|
@@ -384,15 +454,15 @@ function startSetup() {
|
|
|
384
454
|
server.closeIdleConnections?.();
|
|
385
455
|
};
|
|
386
456
|
if (added.length) {
|
|
387
|
-
console.log(`[volt] installing ${added.join(", ")}
|
|
457
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
388
458
|
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
389
459
|
npm.on("error", () => handoff());
|
|
390
460
|
npm.on("close", () => {
|
|
391
|
-
console.log("[volt] saved .env
|
|
461
|
+
console.log("[volt] saved .env — starting the app…");
|
|
392
462
|
handoff();
|
|
393
463
|
});
|
|
394
464
|
} else {
|
|
395
|
-
console.log("[volt] saved .env
|
|
465
|
+
console.log("[volt] saved .env — starting the app…");
|
|
396
466
|
handoff();
|
|
397
467
|
}
|
|
398
468
|
});
|
|
@@ -410,18 +480,18 @@ function startSetup() {
|
|
|
410
480
|
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; });
|
|
411
481
|
server.listen(PORT, "127.0.0.1", () => {
|
|
412
482
|
const url = `http://localhost:${PORT}`;
|
|
413
|
-
console.log(`\
|
|
483
|
+
console.log(`\nVolt setup at ${url}`);
|
|
414
484
|
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
415
485
|
const ssh = process.env.SSH_CONNECTION;
|
|
416
486
|
if (ssh) {
|
|
417
487
|
const host = ssh.split(" ")[2];
|
|
418
488
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
419
|
-
console.log(" Remote box
|
|
489
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
420
490
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
421
|
-
console.log(`
|
|
491
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
422
492
|
}
|
|
423
493
|
console.log("");
|
|
424
|
-
if (openBrowser(url)) console.log(" (opening your browser
|
|
494
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
425
495
|
});
|
|
426
496
|
}
|
|
427
497
|
|
|
@@ -429,8 +499,8 @@ function readEnvFileLines() {
|
|
|
429
499
|
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
430
500
|
}
|
|
431
501
|
|
|
432
|
-
// --- Studio: an ephemeral, localhost-only data browser (
|
|
433
|
-
// Not a route in the running app
|
|
502
|
+
// --- Studio: an ephemeral, localhost-only data browser (— la Prisma Studio).
|
|
503
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
434
504
|
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
435
505
|
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
436
506
|
async function startStudio() {
|
|
@@ -443,7 +513,7 @@ async function startStudio() {
|
|
|
443
513
|
try {
|
|
444
514
|
store = await (await addonMod("db")).createStore();
|
|
445
515
|
} catch (e) {
|
|
446
|
-
console.error("Studio: couldn't connect the store
|
|
516
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
447
517
|
process.exit(1);
|
|
448
518
|
}
|
|
449
519
|
const PORT = configPort();
|
|
@@ -501,15 +571,15 @@ async function startStudio() {
|
|
|
501
571
|
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; });
|
|
502
572
|
server.listen(PORT, "127.0.0.1", () => {
|
|
503
573
|
const url = `http://localhost:${PORT}`;
|
|
504
|
-
console.log(`\
|
|
505
|
-
console.log(" Browse your data. localhost-only, disposable
|
|
574
|
+
console.log(`\nVolt Studio at ${url} (${store.name})`);
|
|
575
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
506
576
|
const ssh = process.env.SSH_CONNECTION;
|
|
507
577
|
if (ssh) {
|
|
508
578
|
const host = ssh.split(" ")[2];
|
|
509
579
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
510
|
-
console.log(" Remote box
|
|
580
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
511
581
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
512
|
-
console.log(`
|
|
582
|
+
console.log(` …then open ${url}.`);
|
|
513
583
|
}
|
|
514
584
|
console.log("");
|
|
515
585
|
openBrowser(url);
|