create-volt 0.43.0 → 0.45.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.
@@ -284,6 +284,21 @@ async function doUpgrade() {
284
284
  status("Upgrade request failed.");
285
285
  }
286
286
  }
287
+
288
+ // AI credits — config-only purchase flow (gateway mode). Hidden unless a
289
+ // VOLT_AI_TOKEN is set and the gateway answers.
290
+ const aiCredits = signal(null); // { ok, tier, creditBalanceUsd, payments } | { ok:false }
291
+ fetch("/setup/ai-credits").then((r) => r.json()).then((c) => aiCredits(c)).catch(() => {});
292
+ async function buyCredits(amountUsd) {
293
+ status("Starting checkout…");
294
+ try {
295
+ const r = await (await fetch("/setup/ai-credits/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amountUsd }) })).json();
296
+ if (r.ok && r.url) window.open(r.url, "_blank");
297
+ else status("Checkout failed: " + (r.error || "?"));
298
+ } catch {
299
+ status("Checkout request failed.");
300
+ }
301
+ }
287
302
  const items = signal({ pages: [], posts: [] });
288
303
  const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
289
304
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
@@ -339,6 +354,7 @@ const manageView = () =>
339
354
 
340
355
  const configView = () =>
341
356
  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>` : "")}
357
+ ${() => (aiCredits()?.ok ? html`<div class="card-x p-3 mb-3"><div class="d-flex justify-content-between align-items-center mb-2"><strong>AI credits</strong><span class="small text-muted">${aiCredits().tier}${typeof aiCredits().creditBalanceUsd === "number" ? ` · $${aiCredits().creditBalanceUsd.toFixed(2)} left` : ""}</span></div>${aiCredits().payments ? html`<div class="d-flex gap-2 align-items-center"><span class="small text-muted me-1">Top up:</span>${[10, 25, 50].map((a) => html`<button class="btn btn-sm btn-outline-primary" onclick=${() => buyCredits(a)}>$${a}</button>`)}</div>` : html`<div class="small text-muted">Pay-as-you-go isn't enabled on the gateway yet — using the free tier.</div>`}</div>` : "")}
342
358
  ${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>` : ""}
343
359
  <div class="card-x p-4 mb-3">
344
360
  <h2 class="h6 mb-3">Settings</h2>
@@ -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
 
@@ -340,6 +341,42 @@ function startSetup() {
340
341
  }
341
342
  return;
342
343
  }
344
+ // --- AI credits: in-config purchase flow. Proxies the hosted gateway with
345
+ // the app's VOLT_AI_TOKEN — the buy flow lives here in the (shell-gated)
346
+ // config only, never in the running app. ---
347
+ if (req.method === "GET" && p === "/setup/ai-credits") {
348
+ const env = readEnvFile();
349
+ res.setHeader("Content-Type", "application/json");
350
+ if (!env.VOLT_AI_TOKEN) return res.end(JSON.stringify({ ok: false, error: "no VOLT_AI_TOKEN (BYO or unset)" }));
351
+ const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
352
+ fetch(base + "/api/credits", { headers: { authorization: "Bearer " + env.VOLT_AI_TOKEN } })
353
+ .then((r) => r.json())
354
+ .then((j) => res.end(JSON.stringify(j)))
355
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
356
+ return;
357
+ }
358
+ if (req.method === "POST" && p === "/setup/ai-credits/checkout") {
359
+ let cbody = "";
360
+ req.on("data", (c) => (cbody += c));
361
+ req.on("end", () => {
362
+ const env = readEnvFile();
363
+ res.setHeader("Content-Type", "application/json");
364
+ if (!env.VOLT_AI_TOKEN) return res.end(JSON.stringify({ ok: false, error: "no VOLT_AI_TOKEN" }));
365
+ const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
366
+ let amountUsd = 0;
367
+ try {
368
+ amountUsd = Number(JSON.parse(cbody || "{}").amountUsd) || 0;
369
+ } catch {
370
+ /* bad json */
371
+ }
372
+ const baseUrl = env.SITE_URL || `http://localhost:${configPort()}`;
373
+ fetch(base + "/api/credits/checkout", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + env.VOLT_AI_TOKEN }, body: JSON.stringify({ amountUsd, baseUrl }) })
374
+ .then((r) => r.json())
375
+ .then((j) => res.end(JSON.stringify(j)))
376
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
377
+ });
378
+ return;
379
+ }
343
380
  // --- content manager: list / read / write / delete pages + posts ---
344
381
  if (req.method === "GET" && p === "/setup/content") {
345
382
  const list = (type) => {
@@ -614,7 +651,118 @@ async function startStudio() {
614
651
  });
615
652
  }
616
653
 
617
- // --- gate: studio / setup (first run, --edit) / the app ---
654
+ // --- `--logs`: a disposable, localhost-only log viewer (its own port, like
655
+ // --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
656
+ // tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
657
+ // for a remote box, SSH-tunnel the port. ---
658
+ async function startLogs() {
659
+ loadEnv();
660
+ const PORT = configPort();
661
+ const name = (() => {
662
+ try {
663
+ return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
664
+ } catch {
665
+ return "app";
666
+ }
667
+ })();
668
+ const logsDir = path.join(os.homedir(), ".pm2", "logs");
669
+ // Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
670
+ // log; users add more (other apps/servers/mounted or tunneled paths) via
671
+ // .volt/logs.json, editable here in the viewer. Re-read per request so additions
672
+ // show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
673
+ const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
674
+ const readExtra = () => {
675
+ try {
676
+ const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
677
+ return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
678
+ } catch {
679
+ return [];
680
+ }
681
+ };
682
+ const sources = () => {
683
+ const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
684
+ if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
685
+ for (const x of readExtra()) s[x.label] = x.file;
686
+ return s;
687
+ };
688
+ const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
689
+ let parseLine = null;
690
+ try {
691
+ parseLine = (await import("mir-sentinel")).parseLine;
692
+ } catch {
693
+ /* analytics optional */
694
+ }
695
+ const top = (arr, key) => {
696
+ const m = {};
697
+ for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
698
+ return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
699
+ };
700
+ const assets = {
701
+ "/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
702
+ "/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
703
+ };
704
+ const json = (res, o) => {
705
+ res.setHeader("Content-Type", "application/json");
706
+ res.end(JSON.stringify(o));
707
+ };
708
+ const server = http.createServer((req, res) => {
709
+ const u = new URL(req.url, "http://localhost");
710
+ const p = u.pathname;
711
+ if (assets[p]) {
712
+ res.setHeader("Content-Type", assets[p][0]);
713
+ return res.end(assets[p][1]);
714
+ }
715
+ if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
716
+ if (p === "/api/tail") {
717
+ const f = sources()[u.searchParams.get("source")];
718
+ return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
719
+ }
720
+ if (p === "/api/analytics") {
721
+ if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
722
+ const f = sources()[u.searchParams.get("source")];
723
+ if (!f) return json(res, { ok: false });
724
+ const parsed = tail(f, 5000).map((l) => parseLine(l));
725
+ 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 });
726
+ }
727
+ // add/remove a source ("add servers") — written to .volt/logs.json
728
+ if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
729
+ let body = "";
730
+ req.on("data", (c) => (body += c));
731
+ req.on("end", () => {
732
+ try {
733
+ const { label, file } = JSON.parse(body || "{}");
734
+ if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
735
+ let list = readExtra().filter((x) => x.label !== label);
736
+ if (p === "/api/source") list.push({ label, file: String(file || "") });
737
+ fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
738
+ fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
739
+ json(res, { ok: true });
740
+ } catch (e) {
741
+ res.statusCode = 400;
742
+ json(res, { ok: false, error: e.message });
743
+ }
744
+ });
745
+ return;
746
+ }
747
+ res.statusCode = 404;
748
+ res.end("not found");
749
+ });
750
+ server.on("error", (e) => {
751
+ if (e.code === "EADDRINUSE") {
752
+ console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
753
+ process.exit(1);
754
+ }
755
+ throw e;
756
+ });
757
+ server.listen(PORT, "127.0.0.1", () => {
758
+ const url = `http://localhost:${PORT}`;
759
+ console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
760
+ console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
761
+ openBrowser(url);
762
+ });
763
+ }
764
+
765
+ // --- gate: studio / logs / setup (first run, --edit) / the app ---
618
766
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
619
767
  // In production / on a PaaS there's no interactive wizard: config comes from the
620
768
  // platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
@@ -622,6 +770,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
622
770
  const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
623
771
  if (process.argv.includes("--studio")) {
624
772
  startStudio();
773
+ } else if (process.argv.includes("--logs")) {
774
+ startLogs();
625
775
  } else if (editMode || !configured) {
626
776
  startSetup();
627
777
  } 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);
@@ -284,6 +284,21 @@ async function doUpgrade() {
284
284
  status("Upgrade request failed.");
285
285
  }
286
286
  }
287
+
288
+ // AI credits — config-only purchase flow (gateway mode). Hidden unless a
289
+ // VOLT_AI_TOKEN is set and the gateway answers.
290
+ const aiCredits = signal(null); // { ok, tier, creditBalanceUsd, payments } | { ok:false }
291
+ fetch("/setup/ai-credits").then((r) => r.json()).then((c) => aiCredits(c)).catch(() => {});
292
+ async function buyCredits(amountUsd) {
293
+ status("Starting checkout…");
294
+ try {
295
+ const r = await (await fetch("/setup/ai-credits/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amountUsd }) })).json();
296
+ if (r.ok && r.url) window.open(r.url, "_blank");
297
+ else status("Checkout failed: " + (r.error || "?"));
298
+ } catch {
299
+ status("Checkout request failed.");
300
+ }
301
+ }
287
302
  const items = signal({ pages: [], posts: [] });
288
303
  const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
289
304
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
@@ -339,6 +354,7 @@ const manageView = () =>
339
354
 
340
355
  const configView = () =>
341
356
  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>` : "")}
357
+ ${() => (aiCredits()?.ok ? html`<div class="card-x p-3 mb-3"><div class="d-flex justify-content-between align-items-center mb-2"><strong>AI credits</strong><span class="small text-muted">${aiCredits().tier}${typeof aiCredits().creditBalanceUsd === "number" ? ` · $${aiCredits().creditBalanceUsd.toFixed(2)} left` : ""}</span></div>${aiCredits().payments ? html`<div class="d-flex gap-2 align-items-center"><span class="small text-muted me-1">Top up:</span>${[10, 25, 50].map((a) => html`<button class="btn btn-sm btn-outline-primary" onclick=${() => buyCredits(a)}>$${a}</button>`)}</div>` : html`<div class="small text-muted">Pay-as-you-go isn't enabled on the gateway yet — using the free tier.</div>`}</div>` : "")}
342
358
  ${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>` : ""}
343
359
  <div class="card-x p-4 mb-3">
344
360
  <h2 class="h6 mb-3">Settings</h2>
@@ -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",
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import crypto from "node:crypto";
15
15
  import { spawn, spawnSync } from "node:child_process";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
+ import os from "node:os";
17
18
  import express from "express";
18
19
  import { Server as SocketServer } from "socket.io";
19
20
 
@@ -366,6 +367,42 @@ function startSetup() {
366
367
  }
367
368
  return;
368
369
  }
370
+ // --- AI credits: in-config purchase flow. Proxies the hosted gateway with
371
+ // the app's VOLT_AI_TOKEN — the buy flow lives here in the (shell-gated)
372
+ // config only, never in the running app. ---
373
+ if (req.method === "GET" && p === "/setup/ai-credits") {
374
+ const env = readEnvFile();
375
+ res.setHeader("Content-Type", "application/json");
376
+ if (!env.VOLT_AI_TOKEN) return res.end(JSON.stringify({ ok: false, error: "no VOLT_AI_TOKEN (BYO or unset)" }));
377
+ const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
378
+ fetch(base + "/api/credits", { headers: { authorization: "Bearer " + env.VOLT_AI_TOKEN } })
379
+ .then((r) => r.json())
380
+ .then((j) => res.end(JSON.stringify(j)))
381
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
382
+ return;
383
+ }
384
+ if (req.method === "POST" && p === "/setup/ai-credits/checkout") {
385
+ let cbody = "";
386
+ req.on("data", (c) => (cbody += c));
387
+ req.on("end", () => {
388
+ const env = readEnvFile();
389
+ res.setHeader("Content-Type", "application/json");
390
+ if (!env.VOLT_AI_TOKEN) return res.end(JSON.stringify({ ok: false, error: "no VOLT_AI_TOKEN" }));
391
+ const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
392
+ let amountUsd = 0;
393
+ try {
394
+ amountUsd = Number(JSON.parse(cbody || "{}").amountUsd) || 0;
395
+ } catch {
396
+ /* bad json */
397
+ }
398
+ const baseUrl = env.SITE_URL || `http://localhost:${configPort()}`;
399
+ fetch(base + "/api/credits/checkout", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer " + env.VOLT_AI_TOKEN }, body: JSON.stringify({ amountUsd, baseUrl }) })
400
+ .then((r) => r.json())
401
+ .then((j) => res.end(JSON.stringify(j)))
402
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
403
+ });
404
+ return;
405
+ }
369
406
  // --- content manager: list / read / write / delete pages + posts ---
370
407
  if (req.method === "GET" && p === "/setup/content") {
371
408
  const list = (type) => {
@@ -640,6 +677,117 @@ async function startStudio() {
640
677
  });
641
678
  }
642
679
 
680
+ // --- `--logs`: a disposable, localhost-only log viewer (its own port, like
681
+ // --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
682
+ // tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
683
+ // for a remote box, SSH-tunnel the port. ---
684
+ async function startLogs() {
685
+ loadEnv();
686
+ const PORT = configPort();
687
+ const name = (() => {
688
+ try {
689
+ return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
690
+ } catch {
691
+ return "app";
692
+ }
693
+ })();
694
+ const logsDir = path.join(os.homedir(), ".pm2", "logs");
695
+ // Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
696
+ // log; users add more (other apps/servers/mounted or tunneled paths) via
697
+ // .volt/logs.json, editable here in the viewer. Re-read per request so additions
698
+ // show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
699
+ const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
700
+ const readExtra = () => {
701
+ try {
702
+ const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
703
+ return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
704
+ } catch {
705
+ return [];
706
+ }
707
+ };
708
+ const sources = () => {
709
+ const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
710
+ if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
711
+ for (const x of readExtra()) s[x.label] = x.file;
712
+ return s;
713
+ };
714
+ const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
715
+ let parseLine = null;
716
+ try {
717
+ parseLine = (await import("mir-sentinel")).parseLine;
718
+ } catch {
719
+ /* analytics optional */
720
+ }
721
+ const top = (arr, key) => {
722
+ const m = {};
723
+ for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
724
+ return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
725
+ };
726
+ const assets = {
727
+ "/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
728
+ "/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
729
+ };
730
+ const json = (res, o) => {
731
+ res.setHeader("Content-Type", "application/json");
732
+ res.end(JSON.stringify(o));
733
+ };
734
+ const server = http.createServer((req, res) => {
735
+ const u = new URL(req.url, "http://localhost");
736
+ const p = u.pathname;
737
+ if (assets[p]) {
738
+ res.setHeader("Content-Type", assets[p][0]);
739
+ return res.end(assets[p][1]);
740
+ }
741
+ if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
742
+ if (p === "/api/tail") {
743
+ const f = sources()[u.searchParams.get("source")];
744
+ return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
745
+ }
746
+ if (p === "/api/analytics") {
747
+ if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
748
+ const f = sources()[u.searchParams.get("source")];
749
+ if (!f) return json(res, { ok: false });
750
+ const parsed = tail(f, 5000).map((l) => parseLine(l));
751
+ 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 });
752
+ }
753
+ // add/remove a source ("add servers") — written to .volt/logs.json
754
+ if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
755
+ let body = "";
756
+ req.on("data", (c) => (body += c));
757
+ req.on("end", () => {
758
+ try {
759
+ const { label, file } = JSON.parse(body || "{}");
760
+ if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
761
+ let list = readExtra().filter((x) => x.label !== label);
762
+ if (p === "/api/source") list.push({ label, file: String(file || "") });
763
+ fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
764
+ fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
765
+ json(res, { ok: true });
766
+ } catch (e) {
767
+ res.statusCode = 400;
768
+ json(res, { ok: false, error: e.message });
769
+ }
770
+ });
771
+ return;
772
+ }
773
+ res.statusCode = 404;
774
+ res.end("not found");
775
+ });
776
+ server.on("error", (e) => {
777
+ if (e.code === "EADDRINUSE") {
778
+ console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
779
+ process.exit(1);
780
+ }
781
+ throw e;
782
+ });
783
+ server.listen(PORT, "127.0.0.1", () => {
784
+ const url = `http://localhost:${PORT}`;
785
+ console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
786
+ console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
787
+ openBrowser(url);
788
+ });
789
+ }
790
+
643
791
  // --- gate: studio / setup (first run, --edit) / the app ---
644
792
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
645
793
  // In production / on a PaaS there's no interactive wizard: config comes from the
@@ -648,6 +796,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
648
796
  const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
649
797
  if (process.argv.includes("--studio")) {
650
798
  startStudio();
799
+ } else if (process.argv.includes("--logs")) {
800
+ startLogs();
651
801
  } else if (editMode || !configured) {
652
802
  startSetup();
653
803
  } 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);