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 +22 -0
- package/README.md +13 -11
- package/index.js +18 -161
- package/package.json +1 -2
- package/templates/default/server.js +115 -33
- package/templates/default/setup/setup.js +77 -39
- package/config/config-app.js +0 -168
- package/config/index.html +0 -29
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
|
-
|
|
46
|
-
|
|
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
|
|
49
|
+
npm run dev -- --edit # or: npx create-volt config
|
|
51
50
|
```
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
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 (
|
|
61
|
-
| `realtime` | Socket.io chat: rooms, presence, typing (
|
|
61
|
+
| `auth` | magic-link login + sessions (pulls in db + mailer) |
|
|
62
|
+
| `realtime` | Socket.io chat: rooms, presence, typing (pulls in db) |
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 #
|
|
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:
|
|
125
|
-
//
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
5
|
-
//
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
|
|
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({
|
|
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
|
|
139
|
-
|
|
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
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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;
|
|
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 —
|
|
2
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
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 (
|
|
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
|
|
62
|
+
const e = { DB_DRIVER: s.dbDriver };
|
|
65
63
|
if (s.dbDriver === "mongodb") {
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
e.MONGODB_URI = s.mongoUri;
|
|
65
|
+
e.MONGODB_DATABASE = s.mongoDb;
|
|
68
66
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
${() => (
|
|
111
|
-
${() => (
|
|
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">
|
package/config/config-app.js
DELETED
|
@@ -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>
|