create-volt 0.8.0 → 0.10.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,44 @@ 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.10.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - Frontend UI for the user-facing add-ons, auto-mounted when enabled:
11
+ - **auth** → a magic-link sign-in panel (email → link → signed-in state)
12
+ - **realtime** → a live chat panel (rooms, presence, typing, messages)
13
+ Each add-on serves its own `/<name>-ui.js` (only when enabled); `public/app.js`
14
+ mounts them alongside the demo. The server exposes `GET /__volt/addons`.
15
+
16
+ ### Security
17
+ - Security headers on every response: `X-Content-Type-Options: nosniff`,
18
+ `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: same-origin`, `X-Powered-By` off.
19
+ - Hardened forms: typed / length-capped / autocompleted inputs; all user content
20
+ renders through Volt holes (text nodes — HTML-escaped, no innerHTML); server
21
+ validation + caps (email ≤ 320, chat ≤ 500); `.env` values stripped of newlines;
22
+ session cookies `HttpOnly` + `SameSite=Lax`.
23
+
24
+ ## [0.9.0] - 2026-06-28
25
+
26
+ ### Changed
27
+ - The setup wizard is now the single place to configure an app, and it shows
28
+ **all** add-ons: tick db/auth/realtime/mailer + fill their settings. Enabling
29
+ is pure config — **Apply writes `.env`** (a `VOLT_ADDONS` list + settings) and
30
+ **adds any needed packages to `package.json` + runs `npm install`**, then
31
+ starts the app, which **auto-wires** whatever `.env` enables (auth routes,
32
+ realtime sockets, db). Add-on code ships bundled under `.volt/addons`; nothing
33
+ is copied into your `lib/`.
34
+ - `create-volt config` now just opens that in-app wizard (`server.js --edit`) —
35
+ one implementation, localhost-only (shell/SSH access is the auth).
36
+
37
+ ### Removed
38
+ - The standalone create-volt config page and its `--host`/key flags (superseded
39
+ by the in-app wizard, which is localhost-only + SSH-tunnel for remote).
40
+
41
+ ### Note
42
+ - Backend of an enabled add-on is wired automatically; the frontend UI (login
43
+ form, chat) is yours to build — or start from `--template guestbook`.
44
+
7
45
  ## [0.8.0] - 2026-06-28
8
46
 
9
47
  ### Added
@@ -108,6 +146,8 @@ All notable changes to `create-volt` are documented here. The format follows
108
146
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
109
147
  and auto-detects npm / pnpm / yarn / bun for the install step.
110
148
 
149
+ [0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
150
+ [0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
111
151
  [0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
112
152
  [0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
113
153
  [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
 
@@ -72,7 +72,7 @@ export function authRouter({ store, mailer }) {
72
72
 
73
73
  router.post("/api/login", async (req, res) => {
74
74
  const email = normalize(req.body?.email);
75
- if (!validEmail(email)) return res.status(400).json({ ok: false, error: "Enter a valid email." });
75
+ if (!validEmail(email) || email.length > 320) return res.status(400).json({ ok: false, error: "Enter a valid email." });
76
76
  const tok = token();
77
77
  await tokens.put(tok, { email, ua: req.headers["user-agent"] || "", expiresAt: Date.now() + TOKEN_TTL, used: false });
78
78
  const link = `${req.protocol}://${req.get("host")}/verify?token=${tok}`;
@@ -0,0 +1,62 @@
1
+ // auth-ui.js — magic-link sign-in panel (frontend for the auth add-on).
2
+ // Served at /auth-ui.js when auth is enabled; mounted by public/app.js.
3
+ // All dynamic text is rendered through Volt holes, which create text nodes
4
+ // (HTML-escaped) — so emails/notices can't inject markup.
5
+ import { signal, html } from "/volt.js";
6
+
7
+ const api = async (url, body) => {
8
+ const res = await fetch(url, {
9
+ method: body ? "POST" : "GET",
10
+ headers: body ? { "Content-Type": "application/json" } : undefined,
11
+ body: body ? JSON.stringify(body) : undefined,
12
+ });
13
+ return res.json().catch(() => ({}));
14
+ };
15
+
16
+ // local part only, for a friendlier "signed in as" (still escaped on render)
17
+ const shortName = (email) => String(email || "").split("@")[0];
18
+
19
+ export function authPanel() {
20
+ const me = signal(null);
21
+ const email = signal("");
22
+ const notice = signal("");
23
+ const busy = signal(false);
24
+
25
+ api("/api/me").then((r) => me(r.email || null));
26
+
27
+ async function sendLink(e) {
28
+ e?.preventDefault?.();
29
+ const addr = email().trim();
30
+ if (!addr || busy()) return;
31
+ busy(true);
32
+ notice("Sending…");
33
+ const r = await api("/api/login", { email: addr });
34
+ busy(false);
35
+ notice(r.ok ? (r.dev ? "Magic link printed to the server console — open it to sign in." : `Magic link sent to ${addr}.`) : r.error || "Could not send link.");
36
+ }
37
+ async function logout() {
38
+ await api("/api/logout", {});
39
+ me(null);
40
+ notice("");
41
+ }
42
+
43
+ const signedOut = () =>
44
+ html`<form class="d-flex gap-2" onsubmit=${sendLink} autocomplete="on">
45
+ <input class="form-control" type="email" name="email" placeholder="you@example.com"
46
+ maxlength="320" autocomplete="email" inputmode="email" required
47
+ value=${email} oninput=${(e) => email(e.target.value)} />
48
+ <button class="btn btn-primary" type="submit" disabled=${() => busy()}>Send magic link</button>
49
+ </form>`;
50
+
51
+ const signedIn = () =>
52
+ html`<div class="d-flex justify-content-between align-items-center">
53
+ <span class="text-muted small">Signed in as <span class="accent fw-semibold">${() => shortName(me())}</span></span>
54
+ <button class="btn btn-sm btn-outline-secondary" onclick=${logout}>Sign out</button>
55
+ </div>`;
56
+
57
+ return html`<div class="card-x p-4 mb-4">
58
+ <h2 class="h6 mb-3">Account <span class="text-muted small">— magic-link auth</span></h2>
59
+ ${() => (me() ? signedIn() : signedOut())}
60
+ ${() => (notice() ? html`<p class="small text-muted mb-0 mt-2">${notice}</p>` : null)}
61
+ </div>`;
62
+ }
@@ -0,0 +1,116 @@
1
+ // chat-ui.js — live chat panel (frontend for the realtime add-on). Served at
2
+ // /chat-ui.js when realtime is enabled; mounted by public/app.js.
3
+ // Identity comes from the auth session (if auth is on), else "guest-…".
4
+ // All message text and names render through Volt holes → text nodes (escaped),
5
+ // so nothing a user types can inject markup.
6
+ import { signal, computed, html } from "/volt.js";
7
+ import { createChat } from "/chat-client.js";
8
+
9
+ const ROOMS = ["general", "random"];
10
+ const MAX = 500; // keep in sync with the server cap
11
+
12
+ const fmtTime = (ts) => {
13
+ try {
14
+ return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
15
+ } catch {
16
+ return "";
17
+ }
18
+ };
19
+
20
+ export function chatPanel() {
21
+ const room = signal(ROOMS[0]);
22
+ const messages = signal([]);
23
+ const online = signal([]);
24
+ const typers = signal([]);
25
+ const draft = signal("");
26
+
27
+ const timers = new Map();
28
+ const sawTyping = (name) => {
29
+ clearTimeout(timers.get(name));
30
+ if (!typers().includes(name)) typers([...typers(), name]);
31
+ timers.set(
32
+ name,
33
+ setTimeout(() => {
34
+ typers(typers().filter((n) => n !== name));
35
+ timers.delete(name);
36
+ }, 2500),
37
+ );
38
+ };
39
+
40
+ let chat;
41
+ try {
42
+ chat = createChat({
43
+ onHistory: ({ room: r, messages: m }) => {
44
+ if (r === room()) messages(m);
45
+ },
46
+ onMessage: (m) => {
47
+ if (m.room === room()) messages([...messages(), m]);
48
+ },
49
+ onPresence: ({ room: r, users }) => {
50
+ if (r === room()) online(users);
51
+ },
52
+ onTyping: ({ name }) => sawTyping(name),
53
+ });
54
+ chat.join(room());
55
+ } catch {
56
+ /* socket.io client missing — panel still renders, just inert */
57
+ }
58
+
59
+ const switchRoom = (r) => {
60
+ if (r === room() || !chat) return;
61
+ room(r);
62
+ messages([]);
63
+ online([]);
64
+ typers([]);
65
+ chat.join(r);
66
+ };
67
+
68
+ let lastTyped = 0;
69
+ const onType = (e) => {
70
+ draft(e.target.value);
71
+ const now = Date.now();
72
+ if (chat && now - lastTyped > 800) {
73
+ chat.typing(room());
74
+ lastTyped = now;
75
+ }
76
+ };
77
+ const send = () => {
78
+ const body = draft().trim();
79
+ if (!body || !chat) return;
80
+ chat.send(room(), body.slice(0, MAX));
81
+ draft("");
82
+ };
83
+
84
+ const typingLine = computed(() => {
85
+ const t = typers().filter((n) => n);
86
+ if (!t.length) return "";
87
+ return t.length === 1 ? `${t[0]} is typing…` : `${t.length} people are typing…`;
88
+ });
89
+
90
+ const roomTab = (r) =>
91
+ html`<button class=${() => "btn btn-sm " + (room() === r ? "btn-primary" : "btn-outline-secondary")} onclick=${() => switchRoom(r)}>#${r}</button>`;
92
+
93
+ const messageRow = (m) =>
94
+ html`<div class="py-1 small">
95
+ <span class="accent fw-semibold">${m.name}</span>
96
+ <span class="text-muted ms-1">${fmtTime(m.createdAt)}</span>
97
+ <div>${m.body}</div>
98
+ </div>`;
99
+
100
+ return html`<div class="card-x p-4 mb-4">
101
+ <div class="d-flex justify-content-between align-items-center mb-2">
102
+ <h2 class="h6 mb-0">Chat <span class="text-muted small">— realtime</span></h2>
103
+ <div class="d-flex gap-1">${ROOMS.map(roomTab)}</div>
104
+ </div>
105
+ <div class="small text-muted mb-2">Online: ${() => online().join(", ") || "—"}</div>
106
+ <div style="max-height:220px;overflow:auto">
107
+ ${() => (messages().length ? messages().map(messageRow) : html`<span class="text-muted small">No messages yet.</span>`)}
108
+ </div>
109
+ <div style="height:1.2em">${() => (typingLine() ? html`<span class="text-muted small fst-italic">${typingLine}</span>` : null)}</div>
110
+ <div class="input-group mt-2">
111
+ <input class="form-control" placeholder=${() => "Message #" + room() + "…"} maxlength=${String(MAX)}
112
+ value=${draft} oninput=${onType} onkeydown=${(e) => e.key === "Enter" && send()} />
113
+ <button class="btn btn-primary" onclick=${send}>Send</button>
114
+ </div>
115
+ </div>`;
116
+ }
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.10.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": {
@@ -63,4 +63,27 @@ function Todos() {
63
63
  </div>`;
64
64
  }
65
65
 
66
- mount("#app", Counter(), Todos());
66
+ // Mount the demo, plus the UI for any enabled add-ons (auth, realtime, …).
67
+ // Add-ons serve their own /…-ui.js when turned on in the setup wizard.
68
+ const nodes = [Counter(), Todos()];
69
+ let enabled = [];
70
+ try {
71
+ enabled = await (await fetch("/__volt/addons")).json();
72
+ } catch {
73
+ /* older app without the endpoint — just the demo */
74
+ }
75
+ if (enabled.includes("auth")) {
76
+ try {
77
+ nodes.unshift((await import("/auth-ui.js")).authPanel());
78
+ } catch {
79
+ /* auth UI unavailable */
80
+ }
81
+ }
82
+ if (enabled.includes("realtime")) {
83
+ try {
84
+ nodes.push((await import("/chat-ui.js")).chatPanel());
85
+ } catch {
86
+ /* realtime UI unavailable */
87
+ }
88
+ }
89
+ mount("#app", ...nodes);
@@ -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,39 @@ 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();
90
+ app.disable("x-powered-by");
91
+ app.use((_req, res, next) => {
92
+ res.setHeader("X-Content-Type-Options", "nosniff");
93
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
94
+ res.setHeader("Referrer-Policy", "same-origin");
95
+ next();
96
+ });
65
97
  app.use(express.static(path.join(__dirname, "public")));
98
+
99
+ let store = null;
100
+ let mailer = null;
101
+ if (enabled.has("db")) store = await (await addonMod("db")).createStore();
102
+ if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
103
+ if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
104
+
105
+ // expose which add-ons are on, and serve each enabled add-on's frontend assets
106
+ app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
107
+ for (const n of enabled) {
108
+ const pub = path.join(ADDONS_DIR, n, "files", "public");
109
+ if (fs.existsSync(pub)) app.use(express.static(pub));
110
+ }
111
+
66
112
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
67
113
 
68
114
  const server = http.createServer(app);
69
115
  const io = new SocketServer(server);
116
+ if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
70
117
 
71
- // hot reload: watch views/ + public/, debounce, broadcast a reload
72
118
  let timer = null;
73
119
  const onChange = (file) => {
74
120
  clearTimeout(timer);
@@ -82,7 +128,7 @@ function startApp() {
82
128
  fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
83
129
  return;
84
130
  } catch {
85
- /* fall back to per-directory watchers */
131
+ /* per-dir fallback */
86
132
  }
87
133
  const w = (d) => {
88
134
  try {
@@ -96,12 +142,26 @@ function startApp() {
96
142
  };
97
143
  for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
98
144
 
99
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}`));
145
+ const on = [...enabled];
146
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
147
+ }
148
+
149
+ // Packages an .env's selections need, beyond what package.json already has.
150
+ function neededPackages(env) {
151
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
152
+ const deps = pkg.dependencies || {};
153
+ const want = [];
154
+ const driver = (env.match(/^\s*DB_DRIVER\s*=\s*(\w+)/m) || [])[1];
155
+ if (driver === "mongodb") want.push("mongodb");
156
+ if (driver === "mysql") want.push("mysql2");
157
+ if (driver === "postgres") want.push("pg");
158
+ if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
159
+ return want.filter((p) => !deps[p]);
100
160
  }
101
161
 
102
162
  // --- the disposable setup wizard (localhost only) ---
103
163
  function startSetup() {
104
- const PORT = Number(process.env.PORT) || DEFAULT_PORT;
164
+ const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
105
165
  const assets = {
106
166
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
107
167
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -121,7 +181,7 @@ function startSetup() {
121
181
  }
122
182
  if (req.method === "GET" && p === "/setup/state") {
123
183
  res.setHeader("Content-Type", "application/json");
124
- return res.end(JSON.stringify({ present: presentAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
184
+ return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
125
185
  }
126
186
  if (req.method === "POST" && p === "/setup/test-db") {
127
187
  let body = "";
@@ -135,9 +195,8 @@ function startSetup() {
135
195
  if (env[k]) process.env[k] = env[k];
136
196
  else delete process.env[k];
137
197
  }
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
198
+ const store = await (await addonMod("db")).createStore();
199
+ await store.collection("__voltcheck").all();
141
200
  res.setHeader("Content-Type", "application/json");
142
201
  res.end(JSON.stringify({ ok: true, driver: store.name }));
143
202
  } catch (e) {
@@ -159,20 +218,54 @@ function startSetup() {
159
218
  try {
160
219
  const { env } = JSON.parse(body);
161
220
  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.
221
+
222
+ // 1) write .env, preserving any custom keys the form doesn't manage
223
+ let finalEnv = env;
224
+ if (fs.existsSync(ENV_PATH)) {
225
+ const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
226
+ const extra = readEnvFileLines().filter((line) => {
227
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
228
+ return m && !managed.has(m[1]);
229
+ });
230
+ if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
231
+ }
232
+ fs.writeFileSync(ENV_PATH, finalEnv);
233
+
234
+ // 2) declare any needed packages in package.json
235
+ const added = neededPackages(env);
236
+ if (added.length) {
237
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
238
+ pkg.dependencies = pkg.dependencies || {};
239
+ for (const name of added) pkg.dependencies[name] = PKG_VERSIONS[name] || "latest";
240
+ fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
241
+ }
242
+
165
243
  const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
166
244
  const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
167
245
  res.setHeader("Content-Type", "application/json");
168
- res.end(JSON.stringify({ ok: true, port: newPort }));
169
- console.log("[volt] saved .env — starting the app…");
246
+ res.end(JSON.stringify({ ok: true, port: newPort, installing: added }));
247
+
248
+ // 3) install (if needed), then hand off to the app
170
249
  res.on("finish", () => {
171
- server.close(() => {
172
- loadEnv();
173
- startApp();
174
- });
175
- server.closeIdleConnections?.(); // drop the keep-alive socket so close() fires now
250
+ const handoff = () => {
251
+ server.close(() => {
252
+ loadEnv();
253
+ startApp();
254
+ });
255
+ server.closeIdleConnections?.();
256
+ };
257
+ if (added.length) {
258
+ console.log(`[volt] installing ${added.join(", ")}…`);
259
+ const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
260
+ npm.on("error", () => handoff());
261
+ npm.on("close", () => {
262
+ console.log("[volt] saved .env — starting the app…");
263
+ handoff();
264
+ });
265
+ } else {
266
+ console.log("[volt] saved .env — starting the app…");
267
+ handoff();
268
+ }
176
269
  });
177
270
  } catch (e) {
178
271
  res.statusCode = 400;
@@ -185,12 +278,11 @@ function startSetup() {
185
278
  res.end("not found");
186
279
  });
187
280
 
188
- // localhost-only: an unauthenticated write endpoint shouldn't be on the network
189
281
  server.listen(PORT, "127.0.0.1", () => {
190
282
  const url = `http://localhost:${PORT}`;
191
283
  console.log(`\n⚡ Volt setup → ${url}`);
192
284
  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"
285
+ const ssh = process.env.SSH_CONNECTION;
194
286
  if (ssh) {
195
287
  const host = ssh.split(" ")[2];
196
288
  const user = process.env.USER || process.env.USERNAME || "you";
@@ -203,6 +295,10 @@ function startSetup() {
203
295
  });
204
296
  }
205
297
 
298
+ function readEnvFileLines() {
299
+ return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
300
+ }
301
+
206
302
  // --- gate: setup on first run / --edit, otherwise the app ---
207
303
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
208
304
  if (editMode || !fs.existsSync(ENV_PATH)) {
@@ -1,89 +1,121 @@
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
+
38
+ const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
19
39
  function genEnv(s) {
20
- const out = [`PORT=${s.port}`];
21
- if (present.db) {
22
- out.push(`DB_DRIVER=${s.dbDriver}`);
40
+ const eff = effective(s);
41
+ const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
42
+ if (eff.includes("db")) {
43
+ out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
23
44
  if (s.dbDriver === "mongodb") {
24
- out.push(`MONGODB_URI=${s.mongoUri}`);
25
- if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
45
+ out.push(`MONGODB_URI=${clean(s.mongoUri)}`);
46
+ if (s.mongoDb) out.push(`MONGODB_DATABASE=${clean(s.mongoDb)}`);
26
47
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
27
- out.push(`DATABASE_URL=${s.dbUrl}`);
48
+ out.push(`DATABASE_URL=${clean(s.dbUrl)}`);
28
49
  }
29
50
  }
30
- if (present.mailer) {
31
- if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
51
+ if (eff.includes("mailer")) {
52
+ if (s.smtpUrl) out.push(`SMTP_URL=${clean(s.smtpUrl)}`);
32
53
  else out.push("# SMTP_URL= # unset → emails print to the console");
33
- if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
54
+ if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
34
55
  }
35
56
  return out.join("\n") + "\n";
36
57
  }
37
58
  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
- }
59
+ const eff = computed(() => effective(state()));
61
60
 
62
61
  async function testDb() {
63
62
  const s = state();
64
- const env = { DB_DRIVER: s.dbDriver };
63
+ const e = { DB_DRIVER: s.dbDriver };
65
64
  if (s.dbDriver === "mongodb") {
66
- env.MONGODB_URI = s.mongoUri;
67
- env.MONGODB_DATABASE = s.mongoDb;
65
+ e.MONGODB_URI = s.mongoUri;
66
+ e.MONGODB_DATABASE = s.mongoDb;
68
67
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
69
- env.DATABASE_URL = s.dbUrl;
68
+ e.DATABASE_URL = s.dbUrl;
70
69
  }
71
70
  status("Testing connection…");
72
71
  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}`);
72
+ const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
73
+ status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
76
74
  } catch {
77
75
  status("Network error testing connection.");
78
76
  }
79
77
  }
80
78
 
79
+ async function apply() {
80
+ status("Saving…");
81
+ let d;
82
+ try {
83
+ d = await (await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ addons: eff(), env: env() }) })).json();
84
+ } catch {
85
+ return status("Network error.");
86
+ }
87
+ if (!d.ok) return status("Error: " + d.error);
88
+ status(d.installing?.length ? `Installing ${d.installing.join(", ")}, then starting…` : "Starting the app…");
89
+ const target = `http://localhost:${d.port}/`;
90
+ const tries = d.installing?.length ? 90 : 20; // npm install can take a while
91
+ const go = async (n) => {
92
+ try {
93
+ await fetch(target, { mode: "no-cors" });
94
+ location.href = target;
95
+ } catch {
96
+ if (n > 0) setTimeout(() => go(n - 1), 500);
97
+ else location.href = target;
98
+ }
99
+ };
100
+ setTimeout(() => go(tries), 600);
101
+ }
102
+
103
+ // --- views ---
81
104
  const field = (label, key, placeholder = "") =>
82
105
  html`<div class="mb-2">
83
106
  <label class="form-label small mb-1">${label}</label>
84
107
  <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
85
108
  </div>`;
86
109
 
110
+ const addonRow = (a) =>
111
+ html`<div class="form-check mb-2">
112
+ <input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
113
+ <label class="form-check-label" for=${"x-" + a.name}>
114
+ <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
115
+ <div class="small text-muted">${a.description}</div>
116
+ </label>
117
+ </div>`;
118
+
87
119
  const dbSettings = () =>
88
120
  html`<div class="mb-2">
89
121
  <label class="form-label small mb-1">Database (DB_DRIVER)</label>
@@ -104,11 +136,18 @@ const dbSettings = () =>
104
136
 
105
137
  mount(
106
138
  "#app",
139
+ available.length
140
+ ? html`<div class="card-x p-4 mb-3">
141
+ <h2 class="h6 mb-3">Features</h2>
142
+ ${available.map(addonRow)}
143
+ <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>
144
+ </div>`
145
+ : null,
107
146
  html`<div class="card-x p-4 mb-3">
108
147
  <h2 class="h6 mb-3">Settings</h2>
109
148
  ${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)}
149
+ ${() => (eff().includes("db") ? dbSettings() : null)}
150
+ ${() => (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
151
  </div>`,
113
152
  html`<div class="card-x p-4 mb-3">
114
153
  <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>