create-volt 0.42.0 → 0.44.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 CHANGED
@@ -4,6 +4,29 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.44.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **`--logs` — a built-in log viewer** on its own localhost port (like `--studio`):
11
+ `npm run logs`. Tails pm2 stdout/stderr **out of the box**; an Analytics tab
12
+ parses Apache/nginx access logs via `mir-sentinel` (optional dep) → top
13
+ paths/status/IPs + bot/attack counts. **Add more sources** (other apps, servers,
14
+ mounted/tunneled paths) right in the viewer — saved to `.volt/logs.json`.
15
+ Localhost-only; SSH-tunnel the port for a remote box.
16
+
17
+ ## [0.43.0] - 2026-06-30
18
+
19
+ ### Added
20
+ - **Upgrade from the wizard.** Scaffolds record their version in `.volt/version`;
21
+ the `--edit` wizard checks npm and shows an "create-volt X available" notice with
22
+ a one-click **Upgrade** button (runs `npx create-volt@latest update`).
23
+ - **`update` refreshes everything framework-owned** — vendored runtime
24
+ (`volt.js`, `volt-ssr.js`), the setup wizard, and bundled add-ons + themes (was
25
+ just `volt.js`). Your `server.js` + content are left untouched.
26
+ - **Simpler AI setup.** The wizard AI section is clearly optional with a
27
+ per-provider **Get a key →** link (Anthropic / OpenAI / Gemini); leave it blank
28
+ and the editor works without AI.
29
+
7
30
  ## [0.42.0] - 2026-06-30
8
31
 
9
32
  ### Added
@@ -561,6 +584,8 @@ All notable changes to `create-volt` are documented here. The format follows
561
584
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
562
585
  and auto-detects npm / pnpm / yarn / bun for the install step.
563
586
 
587
+ [0.44.0]: https://github.com/MIR-2025/volt/releases/tag/v0.44.0
588
+ [0.43.0]: https://github.com/MIR-2025/volt/releases/tag/v0.43.0
564
589
  [0.42.0]: https://github.com/MIR-2025/volt/releases/tag/v0.42.0
565
590
  [0.41.0]: https://github.com/MIR-2025/volt/releases/tag/v0.41.0
566
591
  [0.40.0]: https://github.com/MIR-2025/volt/releases/tag/v0.40.0
package/index.js CHANGED
@@ -237,24 +237,41 @@ if (positionals[0] === "create-theme") {
237
237
  // pulls the latest library). Only touches the library file — never the user's
238
238
  // app.js, server.js, or port. ---
239
239
  if (positionals[0] === "update") {
240
- const target = path.join(process.cwd(), "public", "volt.js");
241
- if (!fs.existsSync(target)) {
240
+ const cwd = process.cwd();
241
+ if (!fs.existsSync(path.join(cwd, "public", "volt.js"))) {
242
242
  die(`No ${cyan("public/volt.js")} here — run ${cyan("create-volt update")} from inside a Volt app.`);
243
243
  }
244
- const latest = fs.readFileSync(path.join(__dirname, "templates", "default", "public", "volt.js"), "utf8");
245
- const current = fs.readFileSync(target, "utf8");
246
- if (current === latest) {
247
- console.log(`\n${green("✔")} ${bold("public/volt.js")} is already current (create-volt ${pkg.version}).\n`);
248
- process.exit(0);
249
- }
250
244
  if (dryRun) {
251
- console.log(`\n${yellow("!")} An update is available for ${bold("public/volt.js")} (create-volt ${pkg.version}).`);
245
+ console.log(`\n${yellow("!")} Would refresh the vendored runtime + bundled add-ons/themes to create-volt ${pkg.version}.`);
252
246
  console.log(` Re-run without ${cyan("--dry-run")} to apply.\n`);
253
247
  process.exit(0);
254
248
  }
255
- fs.writeFileSync(target, latest);
256
- console.log(`\n${green("✔")} Updated ${bold("public/volt.js")} to the version in create-volt ${pkg.version}.`);
257
- console.log(` Review the change with ${cyan("git diff public/volt.js")}.\n`);
249
+ // Refresh the framework-owned files (not your server.js / content): the vendored
250
+ // runtime, the setup wizard, and the bundled add-ons + themes.
251
+ const T = path.join(__dirname, "templates", "default");
252
+ const done = [];
253
+ const copyFile = (rel, src) => {
254
+ if (fs.existsSync(src) && fs.existsSync(path.dirname(path.join(cwd, rel)))) {
255
+ fs.copyFileSync(src, path.join(cwd, rel));
256
+ done.push(rel);
257
+ }
258
+ };
259
+ copyFile("public/volt.js", path.join(T, "public", "volt.js"));
260
+ copyFile("public/volt-ssr.js", path.join(T, "public", "volt-ssr.js"));
261
+ if (fs.existsSync(path.join(cwd, "setup"))) {
262
+ fs.cpSync(path.join(T, "setup"), path.join(cwd, "setup"), { recursive: true });
263
+ done.push("setup/ (wizard)");
264
+ }
265
+ if (fs.existsSync(path.join(cwd, ".volt"))) {
266
+ for (const d of ["addons", "themes"]) {
267
+ fs.cpSync(path.join(__dirname, d), path.join(cwd, ".volt", d), { recursive: true });
268
+ done.push(".volt/" + d);
269
+ }
270
+ }
271
+ fs.mkdirSync(path.join(cwd, ".volt"), { recursive: true });
272
+ fs.writeFileSync(path.join(cwd, ".volt", "version"), pkg.version + "\n");
273
+ console.log(`\n${green("✔")} Updated to create-volt ${pkg.version}: ${done.join(", ")}.`);
274
+ console.log(dim(` Your server.js + content are untouched (re-scaffold to adopt entry-point changes). Restart the app.`));
258
275
  process.exit(0);
259
276
  }
260
277
 
@@ -471,6 +488,7 @@ if (fs.existsSync(path.join(targetDir, "setup"))) {
471
488
  const src = path.join(__dirname, name);
472
489
  if (fs.existsSync(src)) fs.cpSync(src, path.join(targetDir, ".volt", name), { recursive: true });
473
490
  }
491
+ fs.writeFileSync(path.join(targetDir, ".volt", "version"), pkg.version + "\n"); // for the wizard's upgrade check
474
492
  }
475
493
 
476
494
  // --- stamp the project name into package.json ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
12
  "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
13
  "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
- "pm2:logs": "npx --yes pm2 logs"
14
+ "pm2:logs": "npx --yes pm2 logs",
15
+ "logs": "node server.js --logs"
15
16
  },
16
17
  "dependencies": {
17
18
  "express": "^4.22.2",
@@ -13,6 +13,7 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { spawn, spawnSync } from "node:child_process";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import os from "node:os";
16
17
  import express from "express";
17
18
  import { Server as SocketServer } from "socket.io";
18
19
 
@@ -312,6 +313,34 @@ function startSetup() {
312
313
  res.setHeader("Content-Type", "application/json");
313
314
  return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
314
315
  }
316
+ // --- upgrade: compare .volt/version to npm latest, and run the update ---
317
+ if (req.method === "GET" && p === "/setup/upgrade-check") {
318
+ const vf = path.join(__dirname, ".volt", "version");
319
+ const current = (fs.existsSync(vf) ? fs.readFileSync(vf, "utf8").trim() : "") || "?";
320
+ fetch("https://registry.npmjs.org/create-volt/latest")
321
+ .then((r) => r.json())
322
+ .then((j) => {
323
+ const latest = j.version || "?";
324
+ res.setHeader("Content-Type", "application/json");
325
+ res.end(JSON.stringify({ current, latest, available: latest !== "?" && current !== "?" && latest !== current }));
326
+ })
327
+ .catch(() => {
328
+ res.setHeader("Content-Type", "application/json");
329
+ res.end(JSON.stringify({ current, latest: "?", available: false }));
330
+ });
331
+ return;
332
+ }
333
+ if (req.method === "POST" && p === "/setup/upgrade") {
334
+ res.setHeader("Content-Type", "application/json");
335
+ try {
336
+ const r = spawnSync("npx", ["--yes", "create-volt@latest", "update"], { cwd: __dirname, encoding: "utf8", shell: process.platform === "win32" });
337
+ res.end(JSON.stringify({ ok: r.status === 0, output: ((r.stdout || "") + (r.stderr || "")).slice(-2000) }));
338
+ } catch (e) {
339
+ res.statusCode = 500;
340
+ res.end(JSON.stringify({ ok: false, error: e.message }));
341
+ }
342
+ return;
343
+ }
315
344
  // --- content manager: list / read / write / delete pages + posts ---
316
345
  if (req.method === "GET" && p === "/setup/content") {
317
346
  const list = (type) => {
@@ -586,7 +615,118 @@ async function startStudio() {
586
615
  });
587
616
  }
588
617
 
589
- // --- gate: studio / setup (first run, --edit) / the app ---
618
+ // --- `--logs`: a disposable, localhost-only log viewer (its own port, like
619
+ // --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
620
+ // tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
621
+ // for a remote box, SSH-tunnel the port. ---
622
+ async function startLogs() {
623
+ loadEnv();
624
+ const PORT = configPort();
625
+ const name = (() => {
626
+ try {
627
+ return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
628
+ } catch {
629
+ return "app";
630
+ }
631
+ })();
632
+ const logsDir = path.join(os.homedir(), ".pm2", "logs");
633
+ // Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
634
+ // log; users add more (other apps/servers/mounted or tunneled paths) via
635
+ // .volt/logs.json, editable here in the viewer. Re-read per request so additions
636
+ // show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
637
+ const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
638
+ const readExtra = () => {
639
+ try {
640
+ const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
641
+ return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
642
+ } catch {
643
+ return [];
644
+ }
645
+ };
646
+ const sources = () => {
647
+ const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
648
+ if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
649
+ for (const x of readExtra()) s[x.label] = x.file;
650
+ return s;
651
+ };
652
+ const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
653
+ let parseLine = null;
654
+ try {
655
+ parseLine = (await import("mir-sentinel")).parseLine;
656
+ } catch {
657
+ /* analytics optional */
658
+ }
659
+ const top = (arr, key) => {
660
+ const m = {};
661
+ for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
662
+ return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
663
+ };
664
+ const assets = {
665
+ "/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
666
+ "/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
667
+ };
668
+ const json = (res, o) => {
669
+ res.setHeader("Content-Type", "application/json");
670
+ res.end(JSON.stringify(o));
671
+ };
672
+ const server = http.createServer((req, res) => {
673
+ const u = new URL(req.url, "http://localhost");
674
+ const p = u.pathname;
675
+ if (assets[p]) {
676
+ res.setHeader("Content-Type", assets[p][0]);
677
+ return res.end(assets[p][1]);
678
+ }
679
+ if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
680
+ if (p === "/api/tail") {
681
+ const f = sources()[u.searchParams.get("source")];
682
+ return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
683
+ }
684
+ if (p === "/api/analytics") {
685
+ if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
686
+ const f = sources()[u.searchParams.get("source")];
687
+ if (!f) return json(res, { ok: false });
688
+ const parsed = tail(f, 5000).map((l) => parseLine(l));
689
+ 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.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
690
+ }
691
+ // add/remove a source ("add servers") — written to .volt/logs.json
692
+ if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
693
+ let body = "";
694
+ req.on("data", (c) => (body += c));
695
+ req.on("end", () => {
696
+ try {
697
+ const { label, file } = JSON.parse(body || "{}");
698
+ if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
699
+ let list = readExtra().filter((x) => x.label !== label);
700
+ if (p === "/api/source") list.push({ label, file: String(file || "") });
701
+ fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
702
+ fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
703
+ json(res, { ok: true });
704
+ } catch (e) {
705
+ res.statusCode = 400;
706
+ json(res, { ok: false, error: e.message });
707
+ }
708
+ });
709
+ return;
710
+ }
711
+ res.statusCode = 404;
712
+ res.end("not found");
713
+ });
714
+ server.on("error", (e) => {
715
+ if (e.code === "EADDRINUSE") {
716
+ console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
717
+ process.exit(1);
718
+ }
719
+ throw e;
720
+ });
721
+ server.listen(PORT, "127.0.0.1", () => {
722
+ const url = `http://localhost:${PORT}`;
723
+ console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
724
+ console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
725
+ openBrowser(url);
726
+ });
727
+ }
728
+
729
+ // --- gate: studio / logs / setup (first run, --edit) / the app ---
590
730
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
591
731
  // In production / on a PaaS there's no interactive wizard: config comes from the
592
732
  // platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
@@ -594,6 +734,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
594
734
  const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
595
735
  if (process.argv.includes("--studio")) {
596
736
  startStudio();
737
+ } else if (process.argv.includes("--logs")) {
738
+ startLogs();
597
739
  } else if (editMode || !configured) {
598
740
  startSetup();
599
741
  } else {
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
5
+ <title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
6
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
7
+ <style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:72vh;overflow:auto;font-size:12.5px;white-space:pre-wrap}</style>
8
+ </head>
9
+ <body>
10
+ <div class="container-fluid py-3">
11
+ <div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
12
+ <strong class="me-2">Volt logs</strong>
13
+ <select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
14
+ <select id="view" class="form-select form-select-sm" style="max-width:160px"><option value="tail">Raw tail</option><option value="analytics">Analytics</option></select>
15
+ <button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
16
+ <button id="toggleadd" class="btn btn-sm btn-outline-secondary" title="Add another log source (server, app, mounted/tunneled path)">+ source</button>
17
+ <label class="form-check-label small ms-1"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
18
+ <input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
19
+ </div>
20
+ <div id="addbar" class="d-flex gap-2 mb-2" style="display:none">
21
+ <input id="newlabel" class="form-control form-control-sm" style="max-width:160px" placeholder="label (e.g. prod)" />
22
+ <input id="newfile" class="form-control form-control-sm" style="max-width:420px" placeholder="/var/log/nginx/access.log (local, mounted, or shipped path)" />
23
+ <button id="addsrc" class="btn btn-sm btn-primary">Add</button>
24
+ </div>
25
+ <div id="out"></div>
26
+ </div>
27
+ <script type="module" src="/logs.js"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,58 @@
1
+ // logs.js — the --logs viewer client (raw tail + mir-sentinel analytics).
2
+ const $ = (s) => document.querySelector(s);
3
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
4
+ let timer = null;
5
+
6
+ async function loadSources() {
7
+ const { sources = [] } = await (await fetch("/api/sources")).json();
8
+ $("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
9
+ }
10
+
11
+ async function render() {
12
+ const src = $("#src").value;
13
+ if (!src) return;
14
+ if ($("#view").value === "analytics") {
15
+ const a = await (await fetch(`/api/analytics?source=${encodeURIComponent(src)}`)).json();
16
+ if (!a.ok) {
17
+ $("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
18
+ return;
19
+ }
20
+ const tbl = (title, rows) => `<h6 class="mt-3">${title}</h6><table class="table table-dark table-sm mb-0"><tbody>${(rows || []).map(([k, v]) => `<tr><td>${esc(String(k))}</td><td class="text-end">${v}</td></tr>`).join("")}</tbody></table>`;
21
+ $("#out").innerHTML = `<div class="small text-muted mb-2">${a.total} lines · ${a.bots} bot · ${a.attacks} attack</div>` + tbl("Top paths", a.paths) + tbl("Status codes", a.statuses) + tbl("Top IPs", a.ips);
22
+ } else {
23
+ const { lines = [] } = await (await fetch(`/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
24
+ const filter = $("#filter").value.toLowerCase();
25
+ const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
26
+ const pre = document.createElement("pre");
27
+ pre.textContent = shown.join("\n") || "(empty)";
28
+ $("#out").innerHTML = "";
29
+ $("#out").appendChild(pre);
30
+ pre.scrollTop = pre.scrollHeight;
31
+ }
32
+ }
33
+
34
+ $("#toggleadd").onclick = () => {
35
+ const b = $("#addbar");
36
+ b.style.display = b.style.display === "none" ? "flex" : "none";
37
+ };
38
+ $("#addsrc").onclick = async () => {
39
+ const label = $("#newlabel").value.trim();
40
+ const file = $("#newfile").value.trim();
41
+ if (!label || !file) return;
42
+ const r = await (await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label, file }) })).json();
43
+ if (!r.ok) return alert(r.error || "could not add source");
44
+ $("#newlabel").value = $("#newfile").value = "";
45
+ $("#addbar").style.display = "none";
46
+ await loadSources();
47
+ $("#src").value = label;
48
+ render();
49
+ };
50
+ $("#refresh").onclick = render;
51
+ $("#src").onchange = render;
52
+ $("#view").onchange = render;
53
+ $("#filter").oninput = render;
54
+ $("#follow").onchange = (e) => {
55
+ clearInterval(timer);
56
+ if (e.target.checked) timer = setInterval(render, 3000);
57
+ };
58
+ loadSources().then(render);
@@ -249,21 +249,41 @@ const themePicker = () =>
249
249
  </div>`;
250
250
 
251
251
  // AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
252
+ const AI_KEY_URL = {
253
+ anthropic: "https://console.anthropic.com/settings/keys",
254
+ openai: "https://platform.openai.com/api-keys",
255
+ gemini: "https://aistudio.google.com/app/apikey",
256
+ };
252
257
  const aiSettings = () =>
253
- html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI keys (optional) — for the editor's assistant</summary>
258
+ html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI assistant for the editor (optional)</summary>
254
259
  <div class="mt-2">
255
- <label class="form-label small mb-1">Provider (AI_PROVIDER)</label>
256
- <select class="form-select mb-2" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
260
+ <p class="small text-muted mb-2">Powers the WYSIWYG editor's "write with AI" button. <strong>Totally optional</strong> — leave the key blank and the editor still works, just without AI.</p>
261
+ <label class="form-label small mb-1">Provider</label>
262
+ <select class="form-select mb-1" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
257
263
  <option value="anthropic">Anthropic (Claude)</option>
258
264
  <option value="openai">OpenAI</option>
259
265
  <option value="gemini">Google Gemini</option>
260
266
  </select>
261
- ${field("API key stays server-side, written to .env", "aiKey", "sk-…")}
267
+ ${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
268
+ ${field("API key", "aiKey", "sk-…")}
262
269
  </div>
263
270
  </details>`;
264
271
 
265
272
  // --- Manage content (a second screen reached via "Manage content →") ---
266
273
  const view = signal("config"); // "config" | "manage"
274
+ // upgrade check: compare bundled version to npm latest; offer a one-click upgrade
275
+ const upgrade = signal(null); // { current, latest, available }
276
+ fetch("/setup/upgrade-check").then((r) => r.json()).then((u) => upgrade(u)).catch(() => {});
277
+ async function doUpgrade() {
278
+ status("Upgrading via npx create-volt@latest update…");
279
+ try {
280
+ const r = await (await fetch("/setup/upgrade", { method: "POST" })).json();
281
+ status(r.ok ? "Upgraded — restart the wizard/app to load the new version." : "Upgrade failed (see terminal).");
282
+ if (r.ok) upgrade({ ...upgrade(), available: false });
283
+ } catch {
284
+ status("Upgrade request failed.");
285
+ }
286
+ }
267
287
  const items = signal({ pages: [], posts: [] });
268
288
  const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
269
289
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
@@ -318,7 +338,8 @@ const manageView = () =>
318
338
  </div>`;
319
339
 
320
340
  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>` : ""}
341
+ html`${() => (upgrade()?.available ? html`<div class="card-x p-3 mb-3 d-flex justify-content-between align-items-center"><span class="small">⬆ <strong>create-volt ${upgrade().latest}</strong> is available you have ${upgrade().current}.</span><button class="btn btn-sm btn-primary" onclick=${doUpgrade}>Upgrade</button></div>` : "")}
342
+ ${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
343
  <div class="card-x p-4 mb-3">
323
344
  <h2 class="h6 mb-3">Settings</h2>
324
345
  ${field("PORT", "port", String(defaultPort))}
@@ -11,7 +11,8 @@
11
11
  "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
12
  "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
13
  "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
- "pm2:logs": "npx --yes pm2 logs"
14
+ "pm2:logs": "npx --yes pm2 logs",
15
+ "logs": "node server.js --logs"
15
16
  },
16
17
  "dependencies": {
17
18
  "express": "^4.22.2",
@@ -13,6 +13,7 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { spawn, spawnSync } from "node:child_process";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import os from "node:os";
16
17
  import express from "express";
17
18
  import { Server as SocketServer } from "socket.io";
18
19
 
@@ -312,6 +313,34 @@ function startSetup() {
312
313
  res.setHeader("Content-Type", "application/json");
313
314
  return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
314
315
  }
316
+ // --- upgrade: compare .volt/version to npm latest, and run the update ---
317
+ if (req.method === "GET" && p === "/setup/upgrade-check") {
318
+ const vf = path.join(__dirname, ".volt", "version");
319
+ const current = (fs.existsSync(vf) ? fs.readFileSync(vf, "utf8").trim() : "") || "?";
320
+ fetch("https://registry.npmjs.org/create-volt/latest")
321
+ .then((r) => r.json())
322
+ .then((j) => {
323
+ const latest = j.version || "?";
324
+ res.setHeader("Content-Type", "application/json");
325
+ res.end(JSON.stringify({ current, latest, available: latest !== "?" && current !== "?" && latest !== current }));
326
+ })
327
+ .catch(() => {
328
+ res.setHeader("Content-Type", "application/json");
329
+ res.end(JSON.stringify({ current, latest: "?", available: false }));
330
+ });
331
+ return;
332
+ }
333
+ if (req.method === "POST" && p === "/setup/upgrade") {
334
+ res.setHeader("Content-Type", "application/json");
335
+ try {
336
+ const r = spawnSync("npx", ["--yes", "create-volt@latest", "update"], { cwd: __dirname, encoding: "utf8", shell: process.platform === "win32" });
337
+ res.end(JSON.stringify({ ok: r.status === 0, output: ((r.stdout || "") + (r.stderr || "")).slice(-2000) }));
338
+ } catch (e) {
339
+ res.statusCode = 500;
340
+ res.end(JSON.stringify({ ok: false, error: e.message }));
341
+ }
342
+ return;
343
+ }
315
344
  // --- content manager: list / read / write / delete pages + posts ---
316
345
  if (req.method === "GET" && p === "/setup/content") {
317
346
  const list = (type) => {
@@ -586,7 +615,118 @@ async function startStudio() {
586
615
  });
587
616
  }
588
617
 
589
- // --- gate: studio / setup (first run, --edit) / the app ---
618
+ // --- `--logs`: a disposable, localhost-only log viewer (its own port, like
619
+ // --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
620
+ // tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
621
+ // for a remote box, SSH-tunnel the port. ---
622
+ async function startLogs() {
623
+ loadEnv();
624
+ const PORT = configPort();
625
+ const name = (() => {
626
+ try {
627
+ return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
628
+ } catch {
629
+ return "app";
630
+ }
631
+ })();
632
+ const logsDir = path.join(os.homedir(), ".pm2", "logs");
633
+ // Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
634
+ // log; users add more (other apps/servers/mounted or tunneled paths) via
635
+ // .volt/logs.json, editable here in the viewer. Re-read per request so additions
636
+ // show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
637
+ const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
638
+ const readExtra = () => {
639
+ try {
640
+ const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
641
+ return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
642
+ } catch {
643
+ return [];
644
+ }
645
+ };
646
+ const sources = () => {
647
+ const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
648
+ if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
649
+ for (const x of readExtra()) s[x.label] = x.file;
650
+ return s;
651
+ };
652
+ const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
653
+ let parseLine = null;
654
+ try {
655
+ parseLine = (await import("mir-sentinel")).parseLine;
656
+ } catch {
657
+ /* analytics optional */
658
+ }
659
+ const top = (arr, key) => {
660
+ const m = {};
661
+ for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
662
+ return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
663
+ };
664
+ const assets = {
665
+ "/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
666
+ "/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
667
+ };
668
+ const json = (res, o) => {
669
+ res.setHeader("Content-Type", "application/json");
670
+ res.end(JSON.stringify(o));
671
+ };
672
+ const server = http.createServer((req, res) => {
673
+ const u = new URL(req.url, "http://localhost");
674
+ const p = u.pathname;
675
+ if (assets[p]) {
676
+ res.setHeader("Content-Type", assets[p][0]);
677
+ return res.end(assets[p][1]);
678
+ }
679
+ if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
680
+ if (p === "/api/tail") {
681
+ const f = sources()[u.searchParams.get("source")];
682
+ return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
683
+ }
684
+ if (p === "/api/analytics") {
685
+ if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
686
+ const f = sources()[u.searchParams.get("source")];
687
+ if (!f) return json(res, { ok: false });
688
+ const parsed = tail(f, 5000).map((l) => parseLine(l));
689
+ 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.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
690
+ }
691
+ // add/remove a source ("add servers") — written to .volt/logs.json
692
+ if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
693
+ let body = "";
694
+ req.on("data", (c) => (body += c));
695
+ req.on("end", () => {
696
+ try {
697
+ const { label, file } = JSON.parse(body || "{}");
698
+ if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
699
+ let list = readExtra().filter((x) => x.label !== label);
700
+ if (p === "/api/source") list.push({ label, file: String(file || "") });
701
+ fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
702
+ fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
703
+ json(res, { ok: true });
704
+ } catch (e) {
705
+ res.statusCode = 400;
706
+ json(res, { ok: false, error: e.message });
707
+ }
708
+ });
709
+ return;
710
+ }
711
+ res.statusCode = 404;
712
+ res.end("not found");
713
+ });
714
+ server.on("error", (e) => {
715
+ if (e.code === "EADDRINUSE") {
716
+ console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
717
+ process.exit(1);
718
+ }
719
+ throw e;
720
+ });
721
+ server.listen(PORT, "127.0.0.1", () => {
722
+ const url = `http://localhost:${PORT}`;
723
+ console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
724
+ console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
725
+ openBrowser(url);
726
+ });
727
+ }
728
+
729
+ // --- gate: studio / logs / setup (first run, --edit) / the app ---
590
730
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
591
731
  // In production / on a PaaS there's no interactive wizard: config comes from the
592
732
  // platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
@@ -594,6 +734,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
594
734
  const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
595
735
  if (process.argv.includes("--studio")) {
596
736
  startStudio();
737
+ } else if (process.argv.includes("--logs")) {
738
+ startLogs();
597
739
  } else if (editMode || !configured) {
598
740
  startSetup();
599
741
  } else {