create-volt 0.8.0 → 0.9.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,27 @@ 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.9.0] - 2026-06-28
8
+
9
+ ### Changed
10
+ - The setup wizard is now the single place to configure an app, and it shows
11
+ **all** add-ons: tick db/auth/realtime/mailer + fill their settings. Enabling
12
+ is pure config — **Apply writes `.env`** (a `VOLT_ADDONS` list + settings) and
13
+ **adds any needed packages to `package.json` + runs `npm install`**, then
14
+ starts the app, which **auto-wires** whatever `.env` enables (auth routes,
15
+ realtime sockets, db). Add-on code ships bundled under `.volt/addons`; nothing
16
+ is copied into your `lib/`.
17
+ - `create-volt config` now just opens that in-app wizard (`server.js --edit`) —
18
+ one implementation, localhost-only (shell/SSH access is the auth).
19
+
20
+ ### Removed
21
+ - The standalone create-volt config page and its `--host`/key flags (superseded
22
+ by the in-app wizard, which is localhost-only + SSH-tunnel for remote).
23
+
24
+ ### Note
25
+ - Backend of an enabled add-on is wired automatically; the frontend UI (login
26
+ form, chat) is yours to build — or start from `--template guestbook`.
27
+
7
28
  ## [0.8.0] - 2026-06-28
8
29
 
9
30
  ### Added
@@ -108,6 +129,7 @@ All notable changes to `create-volt` are documented here. The format follows
108
129
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
109
130
  and auto-detects npm / pnpm / yarn / bun for the install step.
110
131
 
132
+ [0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
111
133
  [0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
112
134
  [0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
113
135
  [0.6.0]: https://github.com/MIR-2025/volt/releases/tag/v0.6.0
package/README.md CHANGED
@@ -42,27 +42,29 @@ Edit `public/app.js` and save — the page hot-reloads itself.
42
42
 
43
43
  ## Add-on integrations
44
44
 
45
- Layer features into an existing app from a disposable local page — pick the
46
- add-ons, fill in their settings, and it copies the files **and** writes your
47
- `.env`. Run from the app directory:
45
+ Apps ship with the add-ons **bundled** (under `.volt/addons`) but off. The
46
+ **setup wizard** turns them on it opens on first run, or anytime with:
48
47
 
49
48
  ```bash
50
- npx create-volt@latest config
49
+ npm run dev -- --edit # or: npx create-volt config
51
50
  ```
52
51
 
53
- It prints a `localhost` link (plus LAN links and an SSH-tunnel hint for remote
54
- boxes), key-gated for safety. Available add-ons:
52
+ Tick the features you want, fill in their settings, and **Apply**. Enabling is
53
+ pure config: it writes `.env` (a `VOLT_ADDONS` list + settings) and adds any
54
+ needed packages to `package.json` + runs `npm install`. The app then auto-wires
55
+ whatever's enabled. Available add-ons:
55
56
 
56
57
  | Add-on | What it gives you |
57
58
  | ---------- | ------------------------------------------------------------------- |
58
59
  | `db` | document store: memory / MongoDB / MySQL / Postgres, one interface |
59
60
  | `mailer` | console (dev) / SMTP (prod) email |
60
- | `auth` | magic-link login + sessions (uses db + mailer) |
61
- | `realtime` | Socket.io chat: rooms, presence, typing (uses db) |
61
+ | `auth` | magic-link login + sessions (pulls in db + mailer) |
62
+ | `realtime` | Socket.io chat: rooms, presence, typing (pulls in db) |
62
63
 
63
- Tick **All** to add the whole stack at once. The page only writes into the
64
- current folder, applies once, and disappears when you Ctrl-C it. Afterwards run
65
- your app with `node --env-file=.env server.js`.
64
+ The wizard is localhost-only (shell/SSH access is the auth; it prints an
65
+ SSH-tunnel hint on a remote box). Enabling an add-on wires its **backend**
66
+ automatically the **frontend** UI (login form, chat) is yours to build, or
67
+ start from `--template guestbook`, which has it wired end-to-end.
66
68
 
67
69
  ## Updating Volt
68
70
 
package/index.js CHANGED
@@ -10,9 +10,6 @@
10
10
 
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
- import http from "node:http";
14
- import os from "node:os";
15
- import crypto from "node:crypto";
16
13
  import { spawnSync } from "node:child_process";
17
14
  import { fileURLToPath } from "node:url";
18
15
  import { createRequire } from "node:module";
@@ -38,7 +35,7 @@ ${bold("Usage")}
38
35
  npm create volt@latest <project-directory> [options]
39
36
  npx create-volt <project-directory> [options]
40
37
  npx create-volt@latest update # refresh public/volt.js in an existing app
41
- npx create-volt@latest config # disposable page: add db/auth/realtime/mailer + write .env
38
+ npx create-volt@latest config # open the app's setup wizard (edit add-ons + settings)
42
39
 
43
40
  ${bold("Options")}
44
41
  --template <name> Starter template: default | guestbook (default: default)
@@ -63,15 +60,12 @@ const flags = new Set();
63
60
  const positionals = [];
64
61
  let portArg = null;
65
62
  let templateArg = null;
66
- let hostArg = null;
67
63
  for (let i = 0; i < argv.length; i++) {
68
64
  const a = argv[i];
69
65
  if (a === "--port") portArg = argv[++i];
70
66
  else if (a.startsWith("--port=")) portArg = a.slice("--port=".length);
71
67
  else if (a === "--template") templateArg = argv[++i];
72
68
  else if (a.startsWith("--template=")) templateArg = a.slice("--template=".length);
73
- else if (a === "--host") hostArg = argv[++i];
74
- else if (a.startsWith("--host=")) hostArg = a.slice("--host=".length);
75
69
  else if (a.startsWith("-")) flags.add(a);
76
70
  else positionals.push(a);
77
71
  }
@@ -121,163 +115,20 @@ if (positionals[0] === "update") {
121
115
  process.exit(0);
122
116
  }
123
117
 
124
- // --- `config` subcommand: a disposable local page to configure add-ons and
125
- // write a .env. Dependency-free (node:http); the page is built with Volt. ---
118
+ // --- `config` subcommand: open the app's setup wizard to edit add-ons/settings.
119
+ // One implementation delegates to the in-app wizard via `server.js --edit`. ---
126
120
  if (positionals[0] === "config") {
127
121
  const cwd = process.cwd();
128
- const addonsDir = path.join(__dirname, "addons");
129
- const addonList = fs
130
- .readdirSync(addonsDir, { withFileTypes: true })
131
- .filter((e) => e.isDirectory())
132
- .map((e) => {
133
- const m = JSON.parse(fs.readFileSync(path.join(addonsDir, e.name, "meta.json"), "utf8"));
134
- return {
135
- name: e.name,
136
- description: m.description,
137
- install: m.install || [],
138
- dependsOn: m.dependsOn || [],
139
- wiring: m.wiring,
140
- installed: fs.existsSync(path.join(cwd, m.sentinel)),
141
- };
142
- });
143
- const assets = {
144
- "/config-app.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "config", "config-app.js"))],
145
- "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "templates", "default", "public", "volt.js"))],
146
- };
147
- const indexHtml = fs.readFileSync(path.join(__dirname, "config", "index.html"));
148
- // localhost by default — shell/SSH access is the auth. --host exposes it on the
149
- // network, and only then do we mint a random key to gate it.
150
- const host = hostArg || "127.0.0.1";
151
- const exposed = host !== "127.0.0.1" && host !== "localhost";
152
- const key = exposed ? crypto.randomBytes(12).toString("hex") : null;
153
-
154
- const server = http.createServer((req, res) => {
155
- const u = new URL(req.url, "http://localhost");
156
- const p = u.pathname;
157
- if (req.method === "GET" && p === "/") {
158
- if (key && u.searchParams.get("key") !== key) {
159
- res.statusCode = 403;
160
- return res.end("Forbidden — open the ?key=… link printed in the terminal.");
161
- }
162
- res.setHeader("Content-Type", "text/html; charset=utf-8");
163
- return res.end(indexHtml);
164
- }
165
- if (req.method === "GET" && assets[p]) {
166
- res.setHeader("Content-Type", assets[p][0]);
167
- return res.end(assets[p][1]);
168
- }
169
- if (req.method === "GET" && p === "/addons.json") {
170
- res.setHeader("Content-Type", "application/json");
171
- return res.end(JSON.stringify(addonList));
172
- }
173
- if (req.method === "GET" && p === "/current.json") {
174
- const current = {};
175
- const envPath = path.join(cwd, ".env");
176
- if (fs.existsSync(envPath)) {
177
- for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
178
- const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
179
- if (m) current[m[1]] = m[2];
180
- }
181
- }
182
- res.setHeader("Content-Type", "application/json");
183
- return res.end(JSON.stringify(current));
184
- }
185
- if (req.method === "POST" && p === "/apply") {
186
- if (key && req.headers["x-config-key"] !== key) {
187
- res.statusCode = 403;
188
- return res.end(JSON.stringify({ ok: false, error: "bad or missing key" }));
189
- }
190
- let body = "";
191
- req.on("data", (c) => (body += c));
192
- req.on("end", () => {
193
- try {
194
- const { addons = [], env } = JSON.parse(body);
195
- const valid = new Set(addonList.map((a) => a.name));
196
- const depsOf = Object.fromEntries(addonList.map((a) => [a.name, a.dependsOn]));
197
- // expand selection to include required dependencies (deps before dependents)
198
- const want = [];
199
- const seen = new Set();
200
- const visit = (n) => {
201
- if (!valid.has(n) || seen.has(n)) return;
202
- seen.add(n);
203
- for (const d of depsOf[n] || []) visit(d);
204
- want.push(n);
205
- };
206
- for (const n of addons) visit(n);
207
-
208
- const copied = [];
209
- const skipped = [];
210
- for (const n of want) {
211
- const filesDir = path.join(addonsDir, n, "files");
212
- for (const f of listTemplateFiles(filesDir)) {
213
- const dest = path.join(cwd, f);
214
- if (fs.existsSync(dest)) {
215
- skipped.push(f);
216
- continue;
217
- }
218
- fs.mkdirSync(path.dirname(dest), { recursive: true });
219
- fs.copyFileSync(path.join(filesDir, f), dest);
220
- copied.push(f);
221
- }
222
- }
223
- if (typeof env === "string") {
224
- // preserve any custom keys already in .env that this form doesn't manage
225
- const envPath = path.join(cwd, ".env");
226
- let finalEnv = env;
227
- if (fs.existsSync(envPath)) {
228
- const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
229
- const extra = fs
230
- .readFileSync(envPath, "utf8")
231
- .split("\n")
232
- .filter((line) => {
233
- const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
234
- return m && !managed.has(m[1]);
235
- });
236
- if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
237
- }
238
- fs.writeFileSync(envPath, finalEnv);
239
- }
240
- console.log(`${green("✔")} applied [${want.join(", ")}] — ${copied.length} file(s) copied, .env written`);
241
- res.setHeader("Content-Type", "application/json");
242
- res.end(JSON.stringify({ ok: true, copied, skipped, applied: want }));
243
- } catch (e) {
244
- res.statusCode = 400;
245
- res.end(JSON.stringify({ ok: false, error: e.message }));
246
- }
247
- });
248
- return;
249
- }
250
- res.statusCode = 404;
251
- res.end("not found");
252
- });
253
-
254
- const wanted = portArg ? Number(portArg) : 0;
255
- server.listen(Number.isInteger(wanted) && wanted > 0 ? wanted : 0, host, () => {
256
- const port = server.address().port;
257
- const q = key ? `/?key=${key}` : "/";
258
- console.log(`\n${bold("⚡ create-volt config")} — add-ons for ${dim(cwd)}\n`);
259
- if (exposed) {
260
- const lan = [];
261
- for (const iface of Object.values(os.networkInterfaces())) {
262
- for (const a of iface || []) if (a.family === "IPv4" && !a.internal) lan.push(a.address);
263
- }
264
- console.log(" Open (key-gated, reachable on your network):");
265
- for (const ip of lan) console.log(" " + cyan(`http://${ip}:${port}${q}`));
266
- console.log(" " + cyan(`http://localhost:${port}${q}`) + dim(" (this box)"));
267
- } else {
268
- const url = `http://localhost:${port}${q}`;
269
- console.log(" Open: " + cyan(url));
270
- const ssh = process.env.SSH_CONNECTION; // "clientIP clientPort serverIP serverPort"
271
- const user = process.env.USER || process.env.USERNAME || "you";
272
- const sshHost = ssh ? ssh.split(" ")[2] : os.hostname();
273
- console.log(` ${dim(ssh ? "Remote box — the server is up here; from your LOCAL machine run:" : "Remote box? from your local machine run:")}`);
274
- console.log(" " + dim(`ssh -N -L 127.0.0.1:${port}:localhost:${port} ${user}@${sshHost}`));
275
- console.log(` ${dim(`…then open ${url} on your machine — shell access is the auth.`)}`);
276
- console.log(` ${dim("(LAN access instead: --host 0.0.0.0 — adds a key)")}`);
277
- }
278
- console.log(`\n ${dim("Applies add-ons + writes .env here · Ctrl-C when done (disposable).")}\n`);
122
+ if (!fs.existsSync(path.join(cwd, "server.js"))) {
123
+ die(`No ${cyan("server.js")} here — run ${cyan("create-volt config")} from inside a Volt app.`);
124
+ }
125
+ if (portArg) process.env.PORT = String(Number(portArg) || "");
126
+ const res = spawnSync(process.execPath, ["server.js", "--edit"], {
127
+ cwd,
128
+ stdio: "inherit",
129
+ env: flags.has("--no-open") ? { ...process.env, VOLT_NO_OPEN: "1" } : process.env,
279
130
  });
280
- await new Promise(() => {}); // keep the server up; never fall through to scaffolding
131
+ process.exit(res.status ?? 0);
281
132
  }
282
133
 
283
134
  // Resolve the dev port: --port wins, else derive it from today's date as
@@ -379,6 +230,12 @@ if (fs.existsSync(shippedGitignore)) {
379
230
  fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
380
231
  }
381
232
 
233
+ // Bundle the add-on sources so the app's setup wizard can enable them later
234
+ // (only for templates that ship the wizard, i.e. have a setup/ dir).
235
+ if (fs.existsSync(path.join(targetDir, "setup"))) {
236
+ fs.cpSync(path.join(__dirname, "addons"), path.join(targetDir, ".volt", "addons"), { recursive: true });
237
+ }
238
+
382
239
  // --- stamp the project name into package.json ---
383
240
  const appPkgPath = path.join(targetDir, "package.json");
384
241
  const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
@@ -10,7 +10,6 @@
10
10
  "index.js",
11
11
  "templates",
12
12
  "addons",
13
- "config",
14
13
  "CHANGELOG.md"
15
14
  ],
16
15
  "engines": {
@@ -1,8 +1,10 @@
1
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
- // config page; click Apply and it writes .env, loads it, and starts the app
5
- // in-process then the setup page is gone. Normal runs just start the app.
4
+ // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
+ // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
+ // Add-on code is bundled under .volt/addons; nothing is copied into your code.
6
8
  //
7
9
  // No build step, no env-file flag: .env is auto-loaded below.
8
10
 
@@ -16,7 +18,11 @@ import { Server as SocketServer } from "socket.io";
16
18
 
17
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
20
  const ENV_PATH = path.join(__dirname, ".env");
21
+ const PKG_PATH = path.join(__dirname, "package.json");
22
+ const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
19
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
+ const PKG_VERSIONS = { mongodb: "^6.8.0", mysql2: "^3.11.0", pg: "^8.12.0", nodemailer: "^6.9.0" };
25
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js" };
20
26
 
21
27
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
22
28
  function readEnvFile() {
@@ -32,16 +38,34 @@ function loadEnv() {
32
38
  for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
33
39
  }
34
40
 
35
- // Which add-ons are present? (detected by their files, so the wizard only asks
36
- // for settings that actually apply.)
37
- function presentAddons() {
38
- const has = (f) => fs.existsSync(path.join(__dirname, f));
39
- return { db: has("lib/store.js"), auth: has("lib/auth.js"), mailer: has("lib/mailer.js"), realtime: has("lib/realtime.js") };
41
+ // Add-ons available to enable (bundled under .volt/addons by create-volt).
42
+ function availableAddons() {
43
+ if (!fs.existsSync(ADDONS_DIR)) return [];
44
+ return fs
45
+ .readdirSync(ADDONS_DIR, { withFileTypes: true })
46
+ .filter((e) => e.isDirectory())
47
+ .map((e) => {
48
+ const m = JSON.parse(fs.readFileSync(path.join(ADDONS_DIR, e.name, "meta.json"), "utf8"));
49
+ return { name: e.name, description: m.description, dependsOn: m.dependsOn || [] };
50
+ });
40
51
  }
41
52
 
42
- // Open the default browser at `url` but only when there's a desktop to open
43
- // it on. Headless / remote (no DISPLAY) just keeps the printed link. Opt out
44
- // with VOLT_NO_OPEN=1 or --no-open.
53
+ // Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
54
+ function enabledFrom(env) {
55
+ const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
56
+ const out = new Set();
57
+ const visit = (n) => {
58
+ if (!metas[n] || out.has(n)) return;
59
+ out.add(n);
60
+ for (const d of metas[n].dependsOn) visit(d);
61
+ };
62
+ for (const n of String(env.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean)) visit(n);
63
+ return out;
64
+ }
65
+
66
+ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
67
+ const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
68
+
45
69
  function openBrowser(url) {
46
70
  if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
47
71
  const plat = process.platform;
@@ -50,7 +74,7 @@ function openBrowser(url) {
50
74
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
51
75
  try {
52
76
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
53
- child.on("error", () => {}); // launcher missing (e.g. no xdg-open) emits async don't crash
77
+ child.on("error", () => {}); // launcher missing emits async, don't crash
54
78
  child.unref();
55
79
  return true;
56
80
  } catch {
@@ -58,17 +82,25 @@ function openBrowser(url) {
58
82
  }
59
83
  }
60
84
 
61
- // --- the actual app ---
62
- function startApp() {
85
+ // --- the actual app: wires whichever add-ons .env enables ---
86
+ async function startApp() {
63
87
  const PORT = Number(process.env.PORT) || DEFAULT_PORT;
88
+ const enabled = enabledFrom(process.env);
64
89
  const app = express();
65
90
  app.use(express.static(path.join(__dirname, "public")));
91
+
92
+ let store = null;
93
+ let mailer = null;
94
+ if (enabled.has("db")) store = await (await addonMod("db")).createStore();
95
+ if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
96
+ if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
97
+
66
98
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
67
99
 
68
100
  const server = http.createServer(app);
69
101
  const io = new SocketServer(server);
102
+ if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
70
103
 
71
- // hot reload: watch views/ + public/, debounce, broadcast a reload
72
104
  let timer = null;
73
105
  const onChange = (file) => {
74
106
  clearTimeout(timer);
@@ -82,7 +114,7 @@ function startApp() {
82
114
  fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
83
115
  return;
84
116
  } catch {
85
- /* fall back to per-directory watchers */
117
+ /* per-dir fallback */
86
118
  }
87
119
  const w = (d) => {
88
120
  try {
@@ -96,12 +128,26 @@ function startApp() {
96
128
  };
97
129
  for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
98
130
 
99
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}`));
131
+ const on = [...enabled];
132
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
133
+ }
134
+
135
+ // Packages an .env's selections need, beyond what package.json already has.
136
+ function neededPackages(env) {
137
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
138
+ const deps = pkg.dependencies || {};
139
+ const want = [];
140
+ const driver = (env.match(/^\s*DB_DRIVER\s*=\s*(\w+)/m) || [])[1];
141
+ if (driver === "mongodb") want.push("mongodb");
142
+ if (driver === "mysql") want.push("mysql2");
143
+ if (driver === "postgres") want.push("pg");
144
+ if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
145
+ return want.filter((p) => !deps[p]);
100
146
  }
101
147
 
102
148
  // --- the disposable setup wizard (localhost only) ---
103
149
  function startSetup() {
104
- const PORT = Number(process.env.PORT) || DEFAULT_PORT;
150
+ const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
105
151
  const assets = {
106
152
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
107
153
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -121,7 +167,7 @@ function startSetup() {
121
167
  }
122
168
  if (req.method === "GET" && p === "/setup/state") {
123
169
  res.setHeader("Content-Type", "application/json");
124
- return res.end(JSON.stringify({ present: presentAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
170
+ return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
125
171
  }
126
172
  if (req.method === "POST" && p === "/setup/test-db") {
127
173
  let body = "";
@@ -135,9 +181,8 @@ function startSetup() {
135
181
  if (env[k]) process.env[k] = env[k];
136
182
  else delete process.env[k];
137
183
  }
138
- const { createStore } = await import(pathToFileURL(path.join(__dirname, "lib", "store.js")).href);
139
- const store = await createStore();
140
- await store.collection("__voltcheck").all(); // actually touch the connection
184
+ const store = await (await addonMod("db")).createStore();
185
+ await store.collection("__voltcheck").all();
141
186
  res.setHeader("Content-Type", "application/json");
142
187
  res.end(JSON.stringify({ ok: true, driver: store.name }));
143
188
  } catch (e) {
@@ -159,20 +204,54 @@ function startSetup() {
159
204
  try {
160
205
  const { env } = JSON.parse(body);
161
206
  if (typeof env !== "string") throw new Error("missing env");
162
- fs.writeFileSync(ENV_PATH, env);
163
- // The app binds process.env.PORT if it's already set (loadEnv won't
164
- // override it), else the .env PORT, else the default — redirect there.
207
+
208
+ // 1) write .env, preserving any custom keys the form doesn't manage
209
+ let finalEnv = env;
210
+ if (fs.existsSync(ENV_PATH)) {
211
+ const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
212
+ const extra = readEnvFileLines().filter((line) => {
213
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
214
+ return m && !managed.has(m[1]);
215
+ });
216
+ if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
217
+ }
218
+ fs.writeFileSync(ENV_PATH, finalEnv);
219
+
220
+ // 2) declare any needed packages in package.json
221
+ const added = neededPackages(env);
222
+ if (added.length) {
223
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
224
+ pkg.dependencies = pkg.dependencies || {};
225
+ for (const name of added) pkg.dependencies[name] = PKG_VERSIONS[name] || "latest";
226
+ fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
227
+ }
228
+
165
229
  const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
166
230
  const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
167
231
  res.setHeader("Content-Type", "application/json");
168
- res.end(JSON.stringify({ ok: true, port: newPort }));
169
- console.log("[volt] saved .env — starting the app…");
232
+ res.end(JSON.stringify({ ok: true, port: newPort, installing: added }));
233
+
234
+ // 3) install (if needed), then hand off to the app
170
235
  res.on("finish", () => {
171
- server.close(() => {
172
- loadEnv();
173
- startApp();
174
- });
175
- server.closeIdleConnections?.(); // drop the keep-alive socket so close() fires now
236
+ const handoff = () => {
237
+ server.close(() => {
238
+ loadEnv();
239
+ startApp();
240
+ });
241
+ server.closeIdleConnections?.();
242
+ };
243
+ if (added.length) {
244
+ console.log(`[volt] installing ${added.join(", ")}…`);
245
+ const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
246
+ npm.on("error", () => handoff());
247
+ npm.on("close", () => {
248
+ console.log("[volt] saved .env — starting the app…");
249
+ handoff();
250
+ });
251
+ } else {
252
+ console.log("[volt] saved .env — starting the app…");
253
+ handoff();
254
+ }
176
255
  });
177
256
  } catch (e) {
178
257
  res.statusCode = 400;
@@ -185,12 +264,11 @@ function startSetup() {
185
264
  res.end("not found");
186
265
  });
187
266
 
188
- // localhost-only: an unauthenticated write endpoint shouldn't be on the network
189
267
  server.listen(PORT, "127.0.0.1", () => {
190
268
  const url = `http://localhost:${PORT}`;
191
269
  console.log(`\n⚡ Volt setup → ${url}`);
192
270
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
193
- const ssh = process.env.SSH_CONNECTION; // "clientIP clientPort serverIP serverPort"
271
+ const ssh = process.env.SSH_CONNECTION;
194
272
  if (ssh) {
195
273
  const host = ssh.split(" ")[2];
196
274
  const user = process.env.USER || process.env.USERNAME || "you";
@@ -203,6 +281,10 @@ function startSetup() {
203
281
  });
204
282
  }
205
283
 
284
+ function readEnvFileLines() {
285
+ return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
286
+ }
287
+
206
288
  // --- gate: setup on first run / --edit, otherwise the app ---
207
289
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
208
290
  if (editMode || !fs.existsSync(ENV_PATH)) {
@@ -1,24 +1,44 @@
1
- // setup.js — the first-run / --edit settings wizard, built with Volt. Asks only
2
- // for the settings the present add-ons need, writes .env, then the app starts.
1
+ // setup.js — first-run / --edit wizard, built with Volt. Tick add-ons + fill
2
+ // settings writes .env (a VOLT_ADDONS list + settings), adds any needed
3
+ // packages, installs, and starts the app. Add-on code is bundled; enabling is
4
+ // just config.
3
5
  import { signal, computed, html, mount } from "/volt.js";
4
6
 
5
- const { present, current, defaultPort } = await (await fetch("/setup/state")).json();
7
+ const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
8
+ const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
9
+ const order = available.map((a) => a.name);
10
+ const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
6
11
 
7
12
  const state = signal({
8
- port: current.PORT || String(defaultPort),
13
+ addons: Object.fromEntries(available.map((a) => [a.name, enabledNow.has(a.name)])),
9
14
  dbDriver: current.DB_DRIVER || "memory",
10
15
  mongoUri: current.MONGODB_URI || "",
11
16
  mongoDb: current.MONGODB_DATABASE || "",
12
17
  dbUrl: current.DATABASE_URL || "",
13
18
  smtpUrl: current.SMTP_URL || "",
14
19
  mailFrom: current.MAIL_FROM || "",
20
+ port: current.PORT || String(defaultPort),
15
21
  });
16
22
  const set = (patch) => state({ ...state(), ...patch });
23
+ const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
17
24
  const status = signal("");
18
25
 
26
+ // selected add-ons, dependencies expanded, in display order
27
+ function effective(s) {
28
+ const want = new Set();
29
+ const visit = (n) => {
30
+ if (want.has(n)) return;
31
+ want.add(n);
32
+ (depsOf[n] || []).forEach(visit);
33
+ };
34
+ for (const n of order) if (s.addons[n]) visit(n);
35
+ return order.filter((n) => want.has(n));
36
+ }
37
+
19
38
  function genEnv(s) {
20
- const out = [`PORT=${s.port}`];
21
- if (present.db) {
39
+ const eff = effective(s);
40
+ const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${s.port}`];
41
+ if (eff.includes("db")) {
22
42
  out.push(`DB_DRIVER=${s.dbDriver}`);
23
43
  if (s.dbDriver === "mongodb") {
24
44
  out.push(`MONGODB_URI=${s.mongoUri}`);
@@ -27,7 +47,7 @@ function genEnv(s) {
27
47
  out.push(`DATABASE_URL=${s.dbUrl}`);
28
48
  }
29
49
  }
30
- if (present.mailer) {
50
+ if (eff.includes("mailer")) {
31
51
  if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
32
52
  else out.push("# SMTP_URL= # unset → emails print to the console");
33
53
  if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
@@ -35,55 +55,66 @@ function genEnv(s) {
35
55
  return out.join("\n") + "\n";
36
56
  }
37
57
  const env = computed(() => genEnv(state()));
38
-
39
- async function apply() {
40
- status("Saving & starting…");
41
- try {
42
- const r = await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: env() }) });
43
- const d = await r.json();
44
- if (!d.ok) return status("Error: " + d.error);
45
- status("Starting the app…");
46
- const target = `http://localhost:${d.port}/`;
47
- const go = async (n) => {
48
- try {
49
- await fetch(target, { mode: "no-cors" }); // app is listening
50
- location.href = target;
51
- } catch {
52
- if (n > 0) setTimeout(() => go(n - 1), 400);
53
- else location.href = target;
54
- }
55
- };
56
- setTimeout(() => go(20), 500);
57
- } catch {
58
- status("Network error — try again.");
59
- }
60
- }
58
+ const eff = computed(() => effective(state()));
61
59
 
62
60
  async function testDb() {
63
61
  const s = state();
64
- const env = { DB_DRIVER: s.dbDriver };
62
+ const e = { DB_DRIVER: s.dbDriver };
65
63
  if (s.dbDriver === "mongodb") {
66
- env.MONGODB_URI = s.mongoUri;
67
- env.MONGODB_DATABASE = s.mongoDb;
64
+ e.MONGODB_URI = s.mongoUri;
65
+ e.MONGODB_DATABASE = s.mongoDb;
68
66
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
69
- env.DATABASE_URL = s.dbUrl;
67
+ e.DATABASE_URL = s.dbUrl;
70
68
  }
71
69
  status("Testing connection…");
72
70
  try {
73
- const r = await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env }) });
74
- const d = await r.json();
75
- status(d.ok ? `✓ Connected (${d.driver}).` : `✗ ${d.error}`);
71
+ const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
72
+ status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
76
73
  } catch {
77
74
  status("Network error testing connection.");
78
75
  }
79
76
  }
80
77
 
78
+ async function apply() {
79
+ status("Saving…");
80
+ let d;
81
+ try {
82
+ d = await (await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ addons: eff(), env: env() }) })).json();
83
+ } catch {
84
+ return status("Network error.");
85
+ }
86
+ if (!d.ok) return status("Error: " + d.error);
87
+ status(d.installing?.length ? `Installing ${d.installing.join(", ")}, then starting…` : "Starting the app…");
88
+ const target = `http://localhost:${d.port}/`;
89
+ const tries = d.installing?.length ? 90 : 20; // npm install can take a while
90
+ const go = async (n) => {
91
+ try {
92
+ await fetch(target, { mode: "no-cors" });
93
+ location.href = target;
94
+ } catch {
95
+ if (n > 0) setTimeout(() => go(n - 1), 500);
96
+ else location.href = target;
97
+ }
98
+ };
99
+ setTimeout(() => go(tries), 600);
100
+ }
101
+
102
+ // --- views ---
81
103
  const field = (label, key, placeholder = "") =>
82
104
  html`<div class="mb-2">
83
105
  <label class="form-label small mb-1">${label}</label>
84
106
  <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
85
107
  </div>`;
86
108
 
109
+ const addonRow = (a) =>
110
+ html`<div class="form-check mb-2">
111
+ <input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
112
+ <label class="form-check-label" for=${"x-" + a.name}>
113
+ <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
114
+ <div class="small text-muted">${a.description}</div>
115
+ </label>
116
+ </div>`;
117
+
87
118
  const dbSettings = () =>
88
119
  html`<div class="mb-2">
89
120
  <label class="form-label small mb-1">Database (DB_DRIVER)</label>
@@ -104,11 +135,18 @@ const dbSettings = () =>
104
135
 
105
136
  mount(
106
137
  "#app",
138
+ available.length
139
+ ? html`<div class="card-x p-4 mb-3">
140
+ <h2 class="h6 mb-3">Features</h2>
141
+ ${available.map(addonRow)}
142
+ <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>
143
+ </div>`
144
+ : null,
107
145
  html`<div class="card-x p-4 mb-3">
108
146
  <h2 class="h6 mb-3">Settings</h2>
109
147
  ${field("PORT", "port", String(defaultPort))}
110
- ${() => (present.db ? dbSettings() : null)}
111
- ${() => (present.mailer ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
148
+ ${() => (eff().includes("db") ? dbSettings() : null)}
149
+ ${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
112
150
  </div>`,
113
151
  html`<div class="card-x p-4 mb-3">
114
152
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -1,168 +0,0 @@
1
- // config-app.js — the disposable add-on configurator, built with Volt itself.
2
- import { signal, computed, html, mount } from "/volt.js";
3
-
4
- const datePort = () => {
5
- const d = new Date();
6
- return `${String(d.getFullYear()).slice(-2)}${d.getMonth() + 1}${String(d.getDate()).padStart(2, "0")}`;
7
- };
8
-
9
- const addons = await (await fetch("/addons.json")).json();
10
- const current = await (await fetch("/current.json")).json(); // existing .env, so Apply doesn't clobber it
11
- const byName = (n) => addons.find((a) => a.name === n);
12
-
13
- const state = signal({
14
- addons: Object.fromEntries(addons.map((a) => [a.name, a.installed])),
15
- dbDriver: current.DB_DRIVER || "memory",
16
- mongoUri: current.MONGODB_URI || "",
17
- mongoDb: current.MONGODB_DATABASE || "",
18
- dbUrl: current.DATABASE_URL || "",
19
- smtpUrl: current.SMTP_URL || "",
20
- mailFrom: current.MAIL_FROM || "",
21
- port: current.PORT || datePort(),
22
- });
23
- const set = (patch) => state({ ...state(), ...patch });
24
- const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
25
- const status = signal("");
26
-
27
- // --- generators ---
28
- function genEnv(s) {
29
- const out = [];
30
- if (s.port) out.push(`PORT=${s.port}`);
31
- if (s.addons.db) {
32
- out.push(`DB_DRIVER=${s.dbDriver}`);
33
- if (s.dbDriver === "mongodb") {
34
- out.push(`MONGODB_URI=${s.mongoUri}`);
35
- if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
36
- } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
37
- out.push(`DATABASE_URL=${s.dbUrl}`);
38
- }
39
- }
40
- if (s.addons.mailer) {
41
- if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
42
- else out.push(`# SMTP_URL= # unset → emails print to the console`);
43
- if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
44
- }
45
- return out.join("\n") + "\n";
46
- }
47
- function genInstall(s) {
48
- const deps = new Set();
49
- for (const a of addons) if (s.addons[a.name]) (a.install || []).forEach((d) => deps.add(d));
50
- if (s.addons.db) {
51
- if (s.dbDriver === "mongodb") deps.add("mongodb");
52
- if (s.dbDriver === "mysql") deps.add("mysql2");
53
- if (s.dbDriver === "postgres") deps.add("pg");
54
- }
55
- if (s.addons.mailer && s.smtpUrl) deps.add("nodemailer");
56
- return deps.size ? `npm install ${[...deps].join(" ")}` : "# no extra packages needed";
57
- }
58
- function genWiring(s) {
59
- const parts = [];
60
- for (const name of ["db", "mailer", "auth", "realtime"]) {
61
- if (s.addons[name]) parts.push(`// ── ${name} ──\n${byName(name).wiring}`);
62
- }
63
- return parts.join("\n\n") || "// select add-ons above";
64
- }
65
- function warnings(s) {
66
- const w = [];
67
- if (s.addons.auth && (!s.addons.db || !s.addons.mailer)) w.push("auth needs db + mailer");
68
- if (s.addons.realtime && !s.addons.db) w.push("realtime needs db (for message persistence)");
69
- return w;
70
- }
71
-
72
- const env = computed(() => genEnv(state()));
73
-
74
- const KEY = new URLSearchParams(location.search).get("key") || "";
75
- const copy = (text) => navigator.clipboard.writeText(text).then(() => (status("Copied."), setTimeout(() => status(""), 1500)));
76
- const setAll = (v) => state({ ...state(), addons: Object.fromEntries(addons.map((a) => [a.name, v])) });
77
- async function apply() {
78
- const selected = Object.keys(state().addons).filter((n) => state().addons[n]);
79
- status("Applying…");
80
- const r = await fetch("/apply", { method: "POST", headers: { "Content-Type": "application/json", "x-config-key": KEY }, body: JSON.stringify({ addons: selected, env: env() }) });
81
- const d = await r.json();
82
- status(d.ok ? `Applied ${selected.join(", ") || "(none)"} — copied ${d.copied.length} file(s) + wrote .env. Next: ${genInstall(state())} · npm run dev` : `Error: ${d.error}`);
83
- setTimeout(() => status(""), 9000);
84
- }
85
-
86
- // --- views ---
87
- const addonToggles = () =>
88
- html`<div class="card-x p-4 mb-3">
89
- <div class="d-flex justify-content-between align-items-center mb-3">
90
- <h2 class="h6 mb-0">Add-ons</h2>
91
- <div>
92
- <button class="btn btn-sm btn-outline-secondary" onclick=${() => setAll(true)}>All</button>
93
- <button class="btn btn-sm btn-outline-secondary ms-1" onclick=${() => setAll(false)}>None</button>
94
- </div>
95
- </div>
96
- ${addons.map(
97
- (a) => html`<div class="form-check mb-2">
98
- <input class="form-check-input" type="checkbox" id=${"x-" + a.name}
99
- checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
100
- <label class="form-check-label" for=${"x-" + a.name}>
101
- <span class="accent">${a.name}</span>${a.installed ? " · installed" : ""}
102
- <div class="small text-muted">${a.description}</div>
103
- </label>
104
- </div>`,
105
- )}
106
- </div>`;
107
-
108
- const field = (label, key, placeholder = "") =>
109
- html`<div class="mb-2">
110
- <label class="form-label small mb-1">${label}</label>
111
- <input class="form-control" placeholder=${placeholder}
112
- value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
113
- </div>`;
114
-
115
- const settings = () =>
116
- html`<div class="card-x p-4 mb-3">
117
- <h2 class="h6 mb-3">Settings</h2>
118
- ${field("PORT", "port", "26628")}
119
- ${() =>
120
- state().addons.db
121
- ? html`<div class="mb-2">
122
- <label class="form-label small mb-1">DB_DRIVER</label>
123
- <select class="form-select" value=${() => state().dbDriver} onchange=${(e) => set({ dbDriver: e.target.value })}>
124
- <option value="memory">memory (no setup)</option>
125
- <option value="mongodb">mongodb</option>
126
- <option value="mysql">mysql</option>
127
- <option value="postgres">postgres</option>
128
- </select>
129
- </div>
130
- ${() =>
131
- state().dbDriver === "mongodb"
132
- ? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
133
- : state().dbDriver === "mysql" || state().dbDriver === "postgres"
134
- ? field("DATABASE_URL", "dbUrl", state().dbDriver + "://user:pass@host/db")
135
- : null}`
136
- : null}
137
- ${() => (state().addons.mailer ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
138
- </div>`;
139
-
140
- const warnRow = () =>
141
- html`${() => {
142
- const w = warnings(state());
143
- return w.length ? html`<div class="alert alert-warning py-2 small">⚠ ${w.join(" · ")}</div>` : null;
144
- }}`;
145
-
146
- const output = (title, getText, withWrite = false) =>
147
- html`<div class="card-x p-4 mb-3">
148
- <div class="d-flex justify-content-between align-items-center mb-2">
149
- <h2 class="h6 mb-0">${title}</h2>
150
- <div>
151
- <button class="btn btn-sm btn-outline-secondary" onclick=${() => copy(getText())}>Copy</button>
152
- ${withWrite ? html`<button class="btn btn-sm btn-primary ms-2" onclick=${apply}>Apply</button>` : null}
153
- </div>
154
- </div>
155
- <pre class="mb-0">${getText}</pre>
156
- </div>`;
157
-
158
- mount(
159
- "#app",
160
- addonToggles(),
161
- settings(),
162
- warnRow(),
163
- output(".env", env, true),
164
- html`<p class="small text-muted mb-3">The app auto-loads <code>.env</code> on start — just run <code>npm run dev</code> (no flags; same on Windows).</p>`,
165
- output("Install", computed(() => genInstall(state()))),
166
- output("server.js wiring", computed(() => genWiring(state()))),
167
- () => (status() ? html`<p class="small accent">${status}</p>` : null),
168
- );
package/config/index.html DELETED
@@ -1,29 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>create-volt · configure add-ons</title>
7
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
8
- <style>
9
- body { background: #0f1115; color: #e7e9ee; }
10
- .accent { color: #ffd24a; }
11
- .card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
12
- .form-control, .form-select { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
13
- .form-control:focus, .form-select:focus { background: #0f1115; color: #e7e9ee; border-color: #ffd24a; box-shadow: none; }
14
- pre { background: #0b0d11; border: 1px solid #232a36; border-radius: 10px; padding: 12px; color: #cfe3ff; white-space: pre-wrap; }
15
- a { color: #ffd24a; }
16
- code { color: #ffd24a; }
17
- </style>
18
- </head>
19
- <body>
20
- <main class="container py-5" style="max-width: 760px;">
21
- <header class="mb-4">
22
- <h1 class="h3"><span class="accent">⚡ create-volt</span> · configure add-ons</h1>
23
- <p class="text-muted mb-0">Pick add-ons, fill in settings, write your <code>.env</code>. This page is disposable — close the terminal when you're done.</p>
24
- </header>
25
- <div id="app"></div>
26
- </main>
27
- <script type="module" src="/config-app.js"></script>
28
- </body>
29
- </html>