create-volt 0.41.0 → 0.43.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 +28 -0
- package/index.js +30 -12
- package/package.json +1 -1
- package/templates/blog/server.js +103 -26
- package/templates/blog/setup/setup.js +100 -30
- package/templates/default/server.js +103 -26
- package/templates/default/setup/setup.js +100 -30
- package/templates/docs/server.js +103 -26
- package/templates/docs/setup/setup.js +100 -30
- package/templates/starter/server.js +104 -27
- package/templates/starter/setup/setup.js +100 -30
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ 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.43.0] - 2026-06-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Upgrade from the wizard.** Scaffolds record their version in `.volt/version`;
|
|
11
|
+
the `--edit` wizard checks npm and shows an "create-volt X available" notice with
|
|
12
|
+
a one-click **Upgrade** button (runs `npx create-volt@latest update`).
|
|
13
|
+
- **`update` refreshes everything framework-owned** — vendored runtime
|
|
14
|
+
(`volt.js`, `volt-ssr.js`), the setup wizard, and bundled add-ons + themes (was
|
|
15
|
+
just `volt.js`). Your `server.js` + content are left untouched.
|
|
16
|
+
- **Simpler AI setup.** The wizard AI section is clearly optional with a
|
|
17
|
+
per-provider **Get a key →** link (Anthropic / OpenAI / Gemini); leave it blank
|
|
18
|
+
and the editor works without AI.
|
|
19
|
+
|
|
20
|
+
## [0.42.0] - 2026-06-30
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Content manager in the config wizard.** `npm run dev -- --edit` has a
|
|
24
|
+
**Manage content →** view: list, create, edit (raw markdown), and delete pages
|
|
25
|
+
+ posts, via new slug-validated `/setup/content*` endpoints. The config page is
|
|
26
|
+
a content dashboard now, not just settings.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Garbled characters in startup logs.** The `⚡`/`→`/`…`/`—` in server logs and
|
|
30
|
+
source comments had been byte-corrupted (mojibake) by an earlier tooling pass.
|
|
31
|
+
Console output is now clean ASCII ("Volt at http://…", "Volt setup at …").
|
|
32
|
+
|
|
7
33
|
## [0.41.0] - 2026-06-30
|
|
8
34
|
|
|
9
35
|
### Added
|
|
@@ -548,6 +574,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
548
574
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
549
575
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
550
576
|
|
|
577
|
+
[0.43.0]: https://github.com/MIR-2025/volt/releases/tag/v0.43.0
|
|
578
|
+
[0.42.0]: https://github.com/MIR-2025/volt/releases/tag/v0.42.0
|
|
551
579
|
[0.41.0]: https://github.com/MIR-2025/volt/releases/tag/v0.41.0
|
|
552
580
|
[0.40.0]: https://github.com/MIR-2025/volt/releases/tag/v0.40.0
|
|
553
581
|
[0.39.1]: https://github.com/MIR-2025/volt/releases/tag/v0.39.1
|
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
|
|
241
|
-
if (!fs.existsSync(
|
|
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("!")}
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
package/templates/blog/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
|
|
|
@@ -191,7 +191,7 @@ async function startApp() {
|
|
|
191
191
|
res.json({ ok: true });
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
// third-party add-ons
|
|
194
|
+
// third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
|
|
195
195
|
// are provided so add-ons can gate routes by login.
|
|
196
196
|
let requireAuth = null;
|
|
197
197
|
let sessionFromReq = null;
|
|
@@ -207,7 +207,7 @@ async function startApp() {
|
|
|
207
207
|
if (typeof register === "function") {
|
|
208
208
|
await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
|
|
209
209
|
} else {
|
|
210
|
-
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`);
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
@@ -215,7 +215,7 @@ async function startApp() {
|
|
|
215
215
|
const onChange = (file) => {
|
|
216
216
|
clearTimeout(timer);
|
|
217
217
|
timer = setTimeout(() => {
|
|
218
|
-
console.log(`[volt] change: ${file ?? "?"}
|
|
218
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
219
219
|
io.emit("volt:reload", { file });
|
|
220
220
|
}, 80);
|
|
221
221
|
};
|
|
@@ -258,7 +258,7 @@ async function startApp() {
|
|
|
258
258
|
}
|
|
259
259
|
throw e;
|
|
260
260
|
});
|
|
261
|
-
server.listen(PORT, () => console.log(
|
|
261
|
+
server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
// Packages an .env's selections need, beyond what package.json already has.
|
|
@@ -282,7 +282,7 @@ function neededPackages(env) {
|
|
|
282
282
|
function ensureDriverInstalled(driver) {
|
|
283
283
|
const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
|
|
284
284
|
if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
|
|
285
|
-
console.log(`[volt] installing ${pkg} for the connection test
|
|
285
|
+
console.log(`[volt] installing ${pkg} for the connection test…`);
|
|
286
286
|
spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
287
287
|
}
|
|
288
288
|
|
|
@@ -312,6 +312,83 @@ function startSetup() {
|
|
|
312
312
|
res.setHeader("Content-Type", "application/json");
|
|
313
313
|
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
314
314
|
}
|
|
315
|
+
// --- upgrade: compare .volt/version to npm latest, and run the update ---
|
|
316
|
+
if (req.method === "GET" && p === "/setup/upgrade-check") {
|
|
317
|
+
const vf = path.join(__dirname, ".volt", "version");
|
|
318
|
+
const current = (fs.existsSync(vf) ? fs.readFileSync(vf, "utf8").trim() : "") || "?";
|
|
319
|
+
fetch("https://registry.npmjs.org/create-volt/latest")
|
|
320
|
+
.then((r) => r.json())
|
|
321
|
+
.then((j) => {
|
|
322
|
+
const latest = j.version || "?";
|
|
323
|
+
res.setHeader("Content-Type", "application/json");
|
|
324
|
+
res.end(JSON.stringify({ current, latest, available: latest !== "?" && current !== "?" && latest !== current }));
|
|
325
|
+
})
|
|
326
|
+
.catch(() => {
|
|
327
|
+
res.setHeader("Content-Type", "application/json");
|
|
328
|
+
res.end(JSON.stringify({ current, latest: "?", available: false }));
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (req.method === "POST" && p === "/setup/upgrade") {
|
|
333
|
+
res.setHeader("Content-Type", "application/json");
|
|
334
|
+
try {
|
|
335
|
+
const r = spawnSync("npx", ["--yes", "create-volt@latest", "update"], { cwd: __dirname, encoding: "utf8", shell: process.platform === "win32" });
|
|
336
|
+
res.end(JSON.stringify({ ok: r.status === 0, output: ((r.stdout || "") + (r.stderr || "")).slice(-2000) }));
|
|
337
|
+
} catch (e) {
|
|
338
|
+
res.statusCode = 500;
|
|
339
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// --- content manager: list / read / write / delete pages + posts ---
|
|
344
|
+
if (req.method === "GET" && p === "/setup/content") {
|
|
345
|
+
const list = (type) => {
|
|
346
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
347
|
+
if (!fs.existsSync(dir)) return [];
|
|
348
|
+
return fs
|
|
349
|
+
.readdirSync(dir)
|
|
350
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
351
|
+
.map((f) => {
|
|
352
|
+
const slug = f.replace(/\.md$/, "");
|
|
353
|
+
const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
|
|
354
|
+
return { type, slug, title: (title || slug).trim() };
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
res.setHeader("Content-Type", "application/json");
|
|
358
|
+
return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
|
|
359
|
+
}
|
|
360
|
+
if (req.method === "GET" && p === "/setup/content/raw") {
|
|
361
|
+
const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
|
|
362
|
+
const slug = u.searchParams.get("slug") || "";
|
|
363
|
+
res.setHeader("Content-Type", "application/json");
|
|
364
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
|
|
365
|
+
const file = path.join(__dirname, type, slug + ".md");
|
|
366
|
+
return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
|
|
367
|
+
}
|
|
368
|
+
if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
|
|
369
|
+
let cbody = "";
|
|
370
|
+
req.on("data", (c) => (cbody += c));
|
|
371
|
+
req.on("end", () => {
|
|
372
|
+
res.setHeader("Content-Type", "application/json");
|
|
373
|
+
try {
|
|
374
|
+
const { type, slug, body } = JSON.parse(cbody || "{}");
|
|
375
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
|
|
376
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
377
|
+
const file = path.join(dir, slug + ".md");
|
|
378
|
+
if (p === "/setup/content/delete") {
|
|
379
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
380
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
381
|
+
}
|
|
382
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
383
|
+
fs.writeFileSync(file, String(body ?? ""));
|
|
384
|
+
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
385
|
+
} catch (e) {
|
|
386
|
+
res.statusCode = 400;
|
|
387
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
315
392
|
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
316
393
|
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
317
394
|
let body = "";
|
|
@@ -405,15 +482,15 @@ function startSetup() {
|
|
|
405
482
|
server.closeIdleConnections?.();
|
|
406
483
|
};
|
|
407
484
|
if (added.length) {
|
|
408
|
-
console.log(`[volt] installing ${added.join(", ")}
|
|
485
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
409
486
|
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
410
487
|
npm.on("error", () => handoff());
|
|
411
488
|
npm.on("close", () => {
|
|
412
|
-
console.log("[volt] saved .env
|
|
489
|
+
console.log("[volt] saved .env — starting the app…");
|
|
413
490
|
handoff();
|
|
414
491
|
});
|
|
415
492
|
} else {
|
|
416
|
-
console.log("[volt] saved .env
|
|
493
|
+
console.log("[volt] saved .env — starting the app…");
|
|
417
494
|
handoff();
|
|
418
495
|
}
|
|
419
496
|
});
|
|
@@ -431,18 +508,18 @@ function startSetup() {
|
|
|
431
508
|
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; });
|
|
432
509
|
server.listen(PORT, "127.0.0.1", () => {
|
|
433
510
|
const url = `http://localhost:${PORT}`;
|
|
434
|
-
console.log(`\
|
|
511
|
+
console.log(`\nVolt setup at ${url}`);
|
|
435
512
|
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
436
513
|
const ssh = process.env.SSH_CONNECTION;
|
|
437
514
|
if (ssh) {
|
|
438
515
|
const host = ssh.split(" ")[2];
|
|
439
516
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
440
|
-
console.log(" Remote box
|
|
517
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
441
518
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
442
|
-
console.log(`
|
|
519
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
443
520
|
}
|
|
444
521
|
console.log("");
|
|
445
|
-
if (openBrowser(url)) console.log(" (opening your browser
|
|
522
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
446
523
|
});
|
|
447
524
|
}
|
|
448
525
|
|
|
@@ -450,8 +527,8 @@ function readEnvFileLines() {
|
|
|
450
527
|
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
451
528
|
}
|
|
452
529
|
|
|
453
|
-
// --- Studio: an ephemeral, localhost-only data browser (
|
|
454
|
-
// Not a route in the running app
|
|
530
|
+
// --- Studio: an ephemeral, localhost-only data browser (— la Prisma Studio).
|
|
531
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
455
532
|
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
456
533
|
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
457
534
|
async function startStudio() {
|
|
@@ -464,7 +541,7 @@ async function startStudio() {
|
|
|
464
541
|
try {
|
|
465
542
|
store = await (await addonMod("db")).createStore();
|
|
466
543
|
} catch (e) {
|
|
467
|
-
console.error("Studio: couldn't connect the store
|
|
544
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
468
545
|
process.exit(1);
|
|
469
546
|
}
|
|
470
547
|
const PORT = configPort();
|
|
@@ -522,15 +599,15 @@ async function startStudio() {
|
|
|
522
599
|
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; });
|
|
523
600
|
server.listen(PORT, "127.0.0.1", () => {
|
|
524
601
|
const url = `http://localhost:${PORT}`;
|
|
525
|
-
console.log(`\
|
|
526
|
-
console.log(" Browse your data. localhost-only, disposable
|
|
602
|
+
console.log(`\nVolt Studio at ${url} (${store.name})`);
|
|
603
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
527
604
|
const ssh = process.env.SSH_CONNECTION;
|
|
528
605
|
if (ssh) {
|
|
529
606
|
const host = ssh.split(" ")[2];
|
|
530
607
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
531
|
-
console.log(" Remote box
|
|
608
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
532
609
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
533
|
-
console.log(`
|
|
610
|
+
console.log(` …then open ${url}.`);
|
|
534
611
|
}
|
|
535
612
|
console.log("");
|
|
536
613
|
openBrowser(url);
|
|
@@ -249,46 +249,116 @@ 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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
272
|
+
// --- Manage content (a second screen reached via "Manage content →") ---
|
|
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
|
+
}
|
|
287
|
+
const items = signal({ pages: [], posts: [] });
|
|
288
|
+
const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
|
|
289
|
+
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
290
|
+
async function editItem(type, slug) {
|
|
291
|
+
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
292
|
+
editing({ type, slug, body: d.body || "", isNew: false });
|
|
293
|
+
}
|
|
294
|
+
function newItem(type) {
|
|
295
|
+
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";
|
|
296
|
+
editing({ type, slug: "", body, isNew: true });
|
|
297
|
+
}
|
|
298
|
+
async function saveItem() {
|
|
299
|
+
const e = editing();
|
|
300
|
+
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
301
|
+
const body = document.querySelector("#mg-body").value;
|
|
302
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
303
|
+
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
304
|
+
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
305
|
+
status("Saved → " + r.file);
|
|
306
|
+
editing(null);
|
|
307
|
+
loadItems();
|
|
308
|
+
}
|
|
309
|
+
async function delItem(type, slug) {
|
|
310
|
+
if (typeof confirm === "function" && !confirm(`Delete ${slug}?`)) return;
|
|
311
|
+
await fetch("/setup/content/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, slug }) });
|
|
312
|
+
status("Deleted " + slug);
|
|
313
|
+
loadItems();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const itemRow = (it) =>
|
|
317
|
+
html`<li class="list-group-item bg-transparent text-light d-flex justify-content-between align-items-center py-1 px-2">
|
|
318
|
+
<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>
|
|
319
|
+
<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>
|
|
320
|
+
</li>`;
|
|
321
|
+
const section = (label, type, key) =>
|
|
322
|
+
html`<div class="mb-3">
|
|
323
|
+
<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>
|
|
324
|
+
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
325
|
+
</div>`;
|
|
326
|
+
const editorPanel = () => {
|
|
327
|
+
const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
|
|
328
|
+
return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
|
|
329
|
+
<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>
|
|
330
|
+
<textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px">${e.body}</textarea>
|
|
331
|
+
<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>
|
|
332
|
+
</div>`;
|
|
333
|
+
};
|
|
334
|
+
const manageView = () =>
|
|
286
335
|
html`<div class="card-x p-4 mb-3">
|
|
287
|
-
<div class="d-flex justify-content-between align-items-center mb-
|
|
288
|
-
|
|
289
|
-
|
|
336
|
+
<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>
|
|
337
|
+
${() => (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>`)}
|
|
338
|
+
</div>`;
|
|
339
|
+
|
|
340
|
+
const configView = () =>
|
|
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>` : ""}
|
|
343
|
+
<div class="card-x p-4 mb-3">
|
|
344
|
+
<h2 class="h6 mb-3">Settings</h2>
|
|
345
|
+
${field("PORT", "port", String(defaultPort))}
|
|
346
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
347
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
348
|
+
${() => (hasDb() ? dbSettings() : null)}
|
|
349
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
350
|
+
${() => (hasMedia() ? mediaSettings() : null)}
|
|
351
|
+
${aiSettings()}
|
|
352
|
+
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
353
|
+
${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
|
|
290
354
|
</div>
|
|
291
|
-
<
|
|
292
|
-
|
|
355
|
+
<div class="card-x p-4 mb-3">
|
|
356
|
+
<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>
|
|
357
|
+
<pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
|
|
358
|
+
</div>`;
|
|
359
|
+
|
|
360
|
+
mount(
|
|
361
|
+
"#app",
|
|
362
|
+
() => (view() === "config" ? configView() : manageView()),
|
|
293
363
|
() => (status() ? html`<p class="small accent">${status}</p>` : null),
|
|
294
364
|
);
|