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 +25 -0
- package/index.js +30 -12
- package/package.json +1 -1
- package/templates/blog/package.json +2 -1
- package/templates/blog/server.js +143 -1
- package/templates/blog/setup/logs.html +29 -0
- package/templates/blog/setup/logs.js +58 -0
- package/templates/blog/setup/setup.js +26 -5
- package/templates/default/package.json +2 -1
- package/templates/default/server.js +143 -1
- package/templates/default/setup/logs.html +29 -0
- package/templates/default/setup/logs.js +58 -0
- package/templates/default/setup/setup.js +26 -5
- package/templates/docs/package.json +2 -1
- package/templates/docs/server.js +143 -1
- package/templates/docs/setup/logs.html +29 -0
- package/templates/docs/setup/logs.js +58 -0
- package/templates/docs/setup/setup.js +26 -5
- package/templates/starter/package.json +2 -1
- package/templates/starter/server.js +142 -0
- package/templates/starter/setup/logs.html +29 -0
- package/templates/starter/setup/logs.js +58 -0
- package/templates/starter/setup/setup.js +26 -5
|
@@ -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
|
|
|
@@ -338,6 +339,34 @@ function startSetup() {
|
|
|
338
339
|
res.setHeader("Content-Type", "application/json");
|
|
339
340
|
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
340
341
|
}
|
|
342
|
+
// --- upgrade: compare .volt/version to npm latest, and run the update ---
|
|
343
|
+
if (req.method === "GET" && p === "/setup/upgrade-check") {
|
|
344
|
+
const vf = path.join(__dirname, ".volt", "version");
|
|
345
|
+
const current = (fs.existsSync(vf) ? fs.readFileSync(vf, "utf8").trim() : "") || "?";
|
|
346
|
+
fetch("https://registry.npmjs.org/create-volt/latest")
|
|
347
|
+
.then((r) => r.json())
|
|
348
|
+
.then((j) => {
|
|
349
|
+
const latest = j.version || "?";
|
|
350
|
+
res.setHeader("Content-Type", "application/json");
|
|
351
|
+
res.end(JSON.stringify({ current, latest, available: latest !== "?" && current !== "?" && latest !== current }));
|
|
352
|
+
})
|
|
353
|
+
.catch(() => {
|
|
354
|
+
res.setHeader("Content-Type", "application/json");
|
|
355
|
+
res.end(JSON.stringify({ current, latest: "?", available: false }));
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (req.method === "POST" && p === "/setup/upgrade") {
|
|
360
|
+
res.setHeader("Content-Type", "application/json");
|
|
361
|
+
try {
|
|
362
|
+
const r = spawnSync("npx", ["--yes", "create-volt@latest", "update"], { cwd: __dirname, encoding: "utf8", shell: process.platform === "win32" });
|
|
363
|
+
res.end(JSON.stringify({ ok: r.status === 0, output: ((r.stdout || "") + (r.stderr || "")).slice(-2000) }));
|
|
364
|
+
} catch (e) {
|
|
365
|
+
res.statusCode = 500;
|
|
366
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
341
370
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
342
371
|
if (req.method === "GET" && p === "/setup/content") {
|
|
343
372
|
const list = (type) => {
|
|
@@ -612,6 +641,117 @@ async function startStudio() {
|
|
|
612
641
|
});
|
|
613
642
|
}
|
|
614
643
|
|
|
644
|
+
// --- `--logs`: a disposable, localhost-only log viewer (its own port, like
|
|
645
|
+
// --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
|
|
646
|
+
// tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
|
|
647
|
+
// for a remote box, SSH-tunnel the port. ---
|
|
648
|
+
async function startLogs() {
|
|
649
|
+
loadEnv();
|
|
650
|
+
const PORT = configPort();
|
|
651
|
+
const name = (() => {
|
|
652
|
+
try {
|
|
653
|
+
return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
|
|
654
|
+
} catch {
|
|
655
|
+
return "app";
|
|
656
|
+
}
|
|
657
|
+
})();
|
|
658
|
+
const logsDir = path.join(os.homedir(), ".pm2", "logs");
|
|
659
|
+
// Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
|
|
660
|
+
// log; users add more (other apps/servers/mounted or tunneled paths) via
|
|
661
|
+
// .volt/logs.json, editable here in the viewer. Re-read per request so additions
|
|
662
|
+
// show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
|
|
663
|
+
const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
|
|
664
|
+
const readExtra = () => {
|
|
665
|
+
try {
|
|
666
|
+
const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
|
|
667
|
+
return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
|
|
668
|
+
} catch {
|
|
669
|
+
return [];
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
const sources = () => {
|
|
673
|
+
const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
|
|
674
|
+
if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
|
|
675
|
+
for (const x of readExtra()) s[x.label] = x.file;
|
|
676
|
+
return s;
|
|
677
|
+
};
|
|
678
|
+
const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
|
|
679
|
+
let parseLine = null;
|
|
680
|
+
try {
|
|
681
|
+
parseLine = (await import("mir-sentinel")).parseLine;
|
|
682
|
+
} catch {
|
|
683
|
+
/* analytics optional */
|
|
684
|
+
}
|
|
685
|
+
const top = (arr, key) => {
|
|
686
|
+
const m = {};
|
|
687
|
+
for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
688
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
689
|
+
};
|
|
690
|
+
const assets = {
|
|
691
|
+
"/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
|
|
692
|
+
"/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
|
|
693
|
+
};
|
|
694
|
+
const json = (res, o) => {
|
|
695
|
+
res.setHeader("Content-Type", "application/json");
|
|
696
|
+
res.end(JSON.stringify(o));
|
|
697
|
+
};
|
|
698
|
+
const server = http.createServer((req, res) => {
|
|
699
|
+
const u = new URL(req.url, "http://localhost");
|
|
700
|
+
const p = u.pathname;
|
|
701
|
+
if (assets[p]) {
|
|
702
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
703
|
+
return res.end(assets[p][1]);
|
|
704
|
+
}
|
|
705
|
+
if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
|
|
706
|
+
if (p === "/api/tail") {
|
|
707
|
+
const f = sources()[u.searchParams.get("source")];
|
|
708
|
+
return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
|
|
709
|
+
}
|
|
710
|
+
if (p === "/api/analytics") {
|
|
711
|
+
if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
|
|
712
|
+
const f = sources()[u.searchParams.get("source")];
|
|
713
|
+
if (!f) return json(res, { ok: false });
|
|
714
|
+
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
715
|
+
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 });
|
|
716
|
+
}
|
|
717
|
+
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
718
|
+
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
719
|
+
let body = "";
|
|
720
|
+
req.on("data", (c) => (body += c));
|
|
721
|
+
req.on("end", () => {
|
|
722
|
+
try {
|
|
723
|
+
const { label, file } = JSON.parse(body || "{}");
|
|
724
|
+
if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
|
|
725
|
+
let list = readExtra().filter((x) => x.label !== label);
|
|
726
|
+
if (p === "/api/source") list.push({ label, file: String(file || "") });
|
|
727
|
+
fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
|
|
728
|
+
fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
|
|
729
|
+
json(res, { ok: true });
|
|
730
|
+
} catch (e) {
|
|
731
|
+
res.statusCode = 400;
|
|
732
|
+
json(res, { ok: false, error: e.message });
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
res.statusCode = 404;
|
|
738
|
+
res.end("not found");
|
|
739
|
+
});
|
|
740
|
+
server.on("error", (e) => {
|
|
741
|
+
if (e.code === "EADDRINUSE") {
|
|
742
|
+
console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
throw e;
|
|
746
|
+
});
|
|
747
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
748
|
+
const url = `http://localhost:${PORT}`;
|
|
749
|
+
console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
|
|
750
|
+
console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
|
|
751
|
+
openBrowser(url);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
615
755
|
// --- gate: studio / setup (first run, --edit) / the app ---
|
|
616
756
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
617
757
|
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
@@ -620,6 +760,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
|
620
760
|
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
621
761
|
if (process.argv.includes("--studio")) {
|
|
622
762
|
startStudio();
|
|
763
|
+
} else if (process.argv.includes("--logs")) {
|
|
764
|
+
startLogs();
|
|
623
765
|
} else if (editMode || !configured) {
|
|
624
766
|
startSetup();
|
|
625
767
|
} 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) => ({ "&": "&", "<": "<", ">": ">" })[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
|
|
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
|
-
<
|
|
256
|
-
<
|
|
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
|
-
${
|
|
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
|
|
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))}
|