create-volt 0.5.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/index.js CHANGED
@@ -35,12 +35,15 @@ ${bold("Usage")}
35
35
  npm create volt@latest <project-directory> [options]
36
36
  npx create-volt <project-directory> [options]
37
37
  npx create-volt@latest update # refresh public/volt.js in an existing app
38
+ npx create-volt@latest config # open the app's setup wizard (edit add-ons + settings)
38
39
 
39
40
  ${bold("Options")}
40
41
  --template <name> Starter template: default | guestbook (default: default)
41
42
  --port <number> Dev port for the app (default: derived from today's date)
42
43
  --skip-install Don't run the package manager install step
43
44
  --no-git Don't initialize a git repository
45
+ --start After scaffolding, run the dev server (opens the setup page)
46
+ --no-open Don't auto-open the browser on start
44
47
  --dry-run Show what would be created without writing anything
45
48
  --force Scaffold into an existing non-empty directory
46
49
  -h, --help Show this help
@@ -81,6 +84,11 @@ const force = flags.has("--force");
81
84
  const dryRun = flags.has("--dry-run");
82
85
  const noGit = flags.has("--no-git");
83
86
 
87
+ // The `add` command was replaced by `config` — catch old muscle memory.
88
+ if (positionals[0] === "add") {
89
+ die(`${cyan("add")} was replaced by ${cyan("create-volt config")} — run that to add integrations.`);
90
+ }
91
+
84
92
  // --- `update` subcommand: refresh public/volt.js in the current app to the
85
93
  // version bundled with this create-volt (so `npx create-volt@latest update`
86
94
  // pulls the latest library). Only touches the library file — never the user's
@@ -107,6 +115,22 @@ if (positionals[0] === "update") {
107
115
  process.exit(0);
108
116
  }
109
117
 
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`. ---
120
+ if (positionals[0] === "config") {
121
+ const cwd = process.cwd();
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,
130
+ });
131
+ process.exit(res.status ?? 0);
132
+ }
133
+
110
134
  // Resolve the dev port: --port wins, else derive it from today's date as
111
135
  // two-digit-year + month (no leading zero) + two-digit-day (house convention),
112
136
  // so apps scaffolded on different days don't collide on the same port.
@@ -206,6 +230,12 @@ if (fs.existsSync(shippedGitignore)) {
206
230
  fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
207
231
  }
208
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
+
209
239
  // --- stamp the project name into package.json ---
210
240
  const appPkgPath = path.join(targetDir, "package.json");
211
241
  const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
@@ -214,10 +244,10 @@ fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
214
244
 
215
245
  // --- stamp the chosen dev port into server.js + README ---
216
246
  const serverPath = path.join(targetDir, "server.js");
217
- fs.writeFileSync(
218
- serverPath,
219
- fs.readFileSync(serverPath, "utf8").replace(/(Number\(process\.env\.PORT\)\s*\|\|\s*)\d+/, `$1${port}`),
220
- );
247
+ let serverSrc = fs.readFileSync(serverPath, "utf8");
248
+ serverSrc = serverSrc.replace(/(const DEFAULT_PORT\s*=\s*)\d+/, `$1${port}`); // default template
249
+ serverSrc = serverSrc.replace(/(Number\(process\.env\.PORT\)\s*\|\|\s*)\d+/, `$1${port}`); // other templates
250
+ fs.writeFileSync(serverPath, serverSrc);
221
251
  const appReadme = path.join(targetDir, "README.md");
222
252
  if (fs.existsSync(appReadme)) {
223
253
  fs.writeFileSync(appReadme, fs.readFileSync(appReadme, "utf8").replace(/localhost:\d+/g, `localhost:${port}`));
@@ -287,4 +317,18 @@ console.log(`\n${green("✔")} ${bold("Done!")} Next steps:\n`);
287
317
  console.log(` ${cyan("cd")} ${projectName}`);
288
318
  if (!installed) console.log(` ${cyan(installCmd)}`);
289
319
  console.log(` ${cyan(runCmd)}`);
290
- console.log(`\nThen open ${cyan("http://localhost:" + port)} and edit ${bold("public/app.js")}.\n`);
320
+ console.log(`\nFirst run opens a quick ${bold("setup")} page at ${cyan("http://localhost:" + port)}, then your app starts.\n`);
321
+
322
+ if (flags.has("--start")) {
323
+ if (!installed) {
324
+ console.log(dim(`(--start needs dependencies — run ${installCmd}, then ${runCmd}.)\n`));
325
+ } else {
326
+ console.log(`${bold("Starting…")}\n`);
327
+ spawnSync(pm, ["run", "dev"], {
328
+ cwd: targetDir,
329
+ stdio: "inherit",
330
+ shell: process.platform === "win32",
331
+ env: flags.has("--no-open") ? { ...process.env, VOLT_NO_OPEN: "1" } : process.env,
332
+ });
333
+ }
334
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.5.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": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "index.js",
11
11
  "templates",
12
+ "addons",
12
13
  "CHANGELOG.md"
13
14
  ],
14
15
  "engines": {
@@ -10,9 +10,15 @@ Not React: no JSX, no virtual DOM, no re-render-the-world. State lives in
10
10
 
11
11
  ```bash
12
12
  npm install # if you scaffolded with --skip-install
13
- npm run dev # → http://localhost:26628 (PORT env to override)
13
+ npm run dev # → http://localhost:26628
14
14
  ```
15
15
 
16
+ The **first run opens a quick setup page** in your browser (configure settings,
17
+ click Apply, and the app starts — the setup page then disappears). On a headless
18
+ or remote box it prints the link instead. Reopen settings anytime with
19
+ `npm run dev -- --edit` (`-e`). `.env` is auto-loaded, so no `--env-file` flag
20
+ is needed — it works the same on Windows.
21
+
16
22
  Edit anything in `public/` or `views/` and save — the dev server pushes a
17
23
  reload over Socket.io and the page refreshes itself.
18
24
 
@@ -1,64 +1,295 @@
1
- // server.js — minimal dev server for a Volt app. Express serves the static
2
- // client and the index view; Socket.io carries the hot-reload signal; a file
3
- // watcher on views/ and public/ broadcasts a (debounced) reload on any save.
1
+ // server.js — dev server with a built-in first-run setup wizard.
4
2
  //
5
- // Cross-platform: paths are resolved relative to this file, and the watcher
6
- // falls back to a manual recursive walk where native recursive fs.watch is
7
- // unavailable (older Linux Node builds).
3
+ // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
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.
8
8
  //
9
- // npm run dev # start the dev server
10
- // PORT=4000 npm run dev # override the port
9
+ // No build step, no env-file flag: .env is auto-loaded below.
11
10
 
12
11
  import http from "node:http";
13
12
  import fs from "node:fs";
14
13
  import path from "node:path";
15
- import { fileURLToPath } from "node:url";
14
+ import { spawn } from "node:child_process";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import express from "express";
17
17
  import { Server as SocketServer } from "socket.io";
18
18
 
19
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
- const PORT = Number(process.env.PORT) || 26628;
21
-
22
- const app = express();
23
- app.use(express.static(path.join(__dirname, "public")));
24
- app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
25
-
26
- const server = http.createServer(app);
27
- const io = new SocketServer(server);
28
-
29
- // --- Hot reload: watch views/ + public/, debounce bursts, broadcast a reload ---
30
- const watchDirs = ["views", "public"].map((d) => path.join(__dirname, d));
31
- let timer = null;
32
- function onChange(file) {
33
- clearTimeout(timer);
34
- timer = setTimeout(() => {
35
- console.log(`[volt] change: ${file ?? "?"} → reload`);
36
- io.emit("volt:reload");
37
- }, 80);
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
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" };
26
+
27
+ // --- tiny .env loader (no dependency); never overrides an existing env var ---
28
+ function readEnvFile() {
29
+ const out = {};
30
+ if (!fs.existsSync(ENV_PATH)) return out;
31
+ for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
32
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
33
+ if (m) out[m[1]] = m[2];
34
+ }
35
+ return out;
36
+ }
37
+ function loadEnv() {
38
+ for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
39
+ }
40
+
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
+ });
38
51
  }
39
52
 
40
- // Watch a directory recursively. Tries native recursive fs.watch first; if the
41
- // platform/runtime doesn't support it, walks the tree and watches each dir.
42
- function watchRecursive(dir) {
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
+
69
+ function openBrowser(url) {
70
+ if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
71
+ const plat = process.platform;
72
+ if (plat === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
73
+ const cmd = plat === "darwin" ? "open" : plat === "win32" ? "cmd" : "xdg-open";
74
+ const args = plat === "win32" ? ["/c", "start", "", url] : [url];
43
75
  try {
44
- fs.watch(dir, { recursive: true }, (_event, file) => onChange(file));
45
- return;
76
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
77
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
78
+ child.unref();
79
+ return true;
46
80
  } catch {
47
- // Native recursive watch unsupported — fall back to per-directory watchers.
81
+ return false;
48
82
  }
49
- const watchDir = (d) => {
83
+ }
84
+
85
+ // --- the actual app: wires whichever add-ons .env enables ---
86
+ async function startApp() {
87
+ const PORT = Number(process.env.PORT) || DEFAULT_PORT;
88
+ const enabled = enabledFrom(process.env);
89
+ const app = express();
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
+
98
+ app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
99
+
100
+ const server = http.createServer(app);
101
+ const io = new SocketServer(server);
102
+ if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
103
+
104
+ let timer = null;
105
+ const onChange = (file) => {
106
+ clearTimeout(timer);
107
+ timer = setTimeout(() => {
108
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
109
+ io.emit("volt:reload");
110
+ }, 80);
111
+ };
112
+ const watchRecursive = (dir) => {
50
113
  try {
51
- fs.watch(d, (_event, file) => onChange(file));
114
+ fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
115
+ return;
52
116
  } catch {
53
- /* directory vanished between walk and watch — ignore */
54
- }
55
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
56
- if (entry.isDirectory()) watchDir(path.join(d, entry.name));
117
+ /* per-dir fallback */
57
118
  }
119
+ const w = (d) => {
120
+ try {
121
+ fs.watch(d, (_e, f) => onChange(f));
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) w(path.join(d, e.name));
126
+ };
127
+ w(dir);
58
128
  };
59
- watchDir(dir);
129
+ for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
130
+
131
+ const on = [...enabled];
132
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
60
133
  }
61
134
 
62
- for (const dir of watchDirs) watchRecursive(dir);
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]);
146
+ }
63
147
 
64
- server.listen(PORT, () => console.log(`⚡ Volt dev server http://localhost:${PORT}`));
148
+ // --- the disposable setup wizard (localhost only) ---
149
+ function startSetup() {
150
+ const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
151
+ const assets = {
152
+ "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
153
+ "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
154
+ };
155
+ const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
156
+
157
+ const server = http.createServer((req, res) => {
158
+ const u = new URL(req.url, "http://localhost");
159
+ const p = u.pathname;
160
+ if (req.method === "GET" && p === "/") {
161
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
162
+ return res.end(indexHtml);
163
+ }
164
+ if (req.method === "GET" && assets[p]) {
165
+ res.setHeader("Content-Type", assets[p][0]);
166
+ return res.end(assets[p][1]);
167
+ }
168
+ if (req.method === "GET" && p === "/setup/state") {
169
+ res.setHeader("Content-Type", "application/json");
170
+ return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
171
+ }
172
+ if (req.method === "POST" && p === "/setup/test-db") {
173
+ let body = "";
174
+ req.on("data", (c) => (body += c));
175
+ req.on("end", async () => {
176
+ const keys = ["DB_DRIVER", "MONGODB_URI", "MONGODB_DATABASE", "DATABASE_URL"];
177
+ const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
178
+ try {
179
+ const { env = {} } = JSON.parse(body);
180
+ for (const k of keys) {
181
+ if (env[k]) process.env[k] = env[k];
182
+ else delete process.env[k];
183
+ }
184
+ const store = await (await addonMod("db")).createStore();
185
+ await store.collection("__voltcheck").all();
186
+ res.setHeader("Content-Type", "application/json");
187
+ res.end(JSON.stringify({ ok: true, driver: store.name }));
188
+ } catch (e) {
189
+ res.setHeader("Content-Type", "application/json");
190
+ res.end(JSON.stringify({ ok: false, error: e.message }));
191
+ } finally {
192
+ for (const k of keys) {
193
+ if (saved[k] == null) delete process.env[k];
194
+ else process.env[k] = saved[k];
195
+ }
196
+ }
197
+ });
198
+ return;
199
+ }
200
+ if (req.method === "POST" && p === "/setup/apply") {
201
+ let body = "";
202
+ req.on("data", (c) => (body += c));
203
+ req.on("end", () => {
204
+ try {
205
+ const { env } = JSON.parse(body);
206
+ if (typeof env !== "string") throw new Error("missing env");
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
+
229
+ const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
230
+ const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
231
+ res.setHeader("Content-Type", "application/json");
232
+ res.end(JSON.stringify({ ok: true, port: newPort, installing: added }));
233
+
234
+ // 3) install (if needed), then hand off to the app
235
+ res.on("finish", () => {
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
+ }
255
+ });
256
+ } catch (e) {
257
+ res.statusCode = 400;
258
+ res.end(JSON.stringify({ ok: false, error: e.message }));
259
+ }
260
+ });
261
+ return;
262
+ }
263
+ res.statusCode = 404;
264
+ res.end("not found");
265
+ });
266
+
267
+ server.listen(PORT, "127.0.0.1", () => {
268
+ const url = `http://localhost:${PORT}`;
269
+ console.log(`\n⚡ Volt setup → ${url}`);
270
+ console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
271
+ const ssh = process.env.SSH_CONNECTION;
272
+ if (ssh) {
273
+ const host = ssh.split(" ")[2];
274
+ const user = process.env.USER || process.env.USERNAME || "you";
275
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
276
+ console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
277
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
278
+ }
279
+ console.log("");
280
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
281
+ });
282
+ }
283
+
284
+ function readEnvFileLines() {
285
+ return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
286
+ }
287
+
288
+ // --- gate: setup on first run / --edit, otherwise the app ---
289
+ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
290
+ if (editMode || !fs.existsSync(ENV_PATH)) {
291
+ startSetup();
292
+ } else {
293
+ loadEnv();
294
+ startApp();
295
+ }
@@ -0,0 +1,27 @@
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>Set up your Volt app</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
+ code { color: #ffd24a; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <main class="container py-5" style="max-width: 640px;">
19
+ <header class="mb-4">
20
+ <h1 class="h3"><span class="accent">⚡ Set up your Volt app</span></h1>
21
+ <p class="text-muted mb-0">Fill these in and the app starts. This page is disposable — it disappears once you click Apply. Re-open it anytime with <code>npm run dev -- --edit</code>.</p>
22
+ </header>
23
+ <div id="app"></div>
24
+ </main>
25
+ <script type="module" src="/setup.js"></script>
26
+ </body>
27
+ </html>
@@ -0,0 +1,159 @@
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.
5
+ import { signal, computed, html, mount } from "/volt.js";
6
+
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));
11
+
12
+ const state = signal({
13
+ addons: Object.fromEntries(available.map((a) => [a.name, enabledNow.has(a.name)])),
14
+ dbDriver: current.DB_DRIVER || "memory",
15
+ mongoUri: current.MONGODB_URI || "",
16
+ mongoDb: current.MONGODB_DATABASE || "",
17
+ dbUrl: current.DATABASE_URL || "",
18
+ smtpUrl: current.SMTP_URL || "",
19
+ mailFrom: current.MAIL_FROM || "",
20
+ port: current.PORT || String(defaultPort),
21
+ });
22
+ const set = (patch) => state({ ...state(), ...patch });
23
+ const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
24
+ const status = signal("");
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
+ function genEnv(s) {
39
+ const eff = effective(s);
40
+ const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${s.port}`];
41
+ if (eff.includes("db")) {
42
+ out.push(`DB_DRIVER=${s.dbDriver}`);
43
+ if (s.dbDriver === "mongodb") {
44
+ out.push(`MONGODB_URI=${s.mongoUri}`);
45
+ if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
46
+ } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
47
+ out.push(`DATABASE_URL=${s.dbUrl}`);
48
+ }
49
+ }
50
+ if (eff.includes("mailer")) {
51
+ if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
52
+ else out.push("# SMTP_URL= # unset → emails print to the console");
53
+ if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
54
+ }
55
+ return out.join("\n") + "\n";
56
+ }
57
+ const env = computed(() => genEnv(state()));
58
+ const eff = computed(() => effective(state()));
59
+
60
+ async function testDb() {
61
+ const s = state();
62
+ const e = { DB_DRIVER: s.dbDriver };
63
+ if (s.dbDriver === "mongodb") {
64
+ e.MONGODB_URI = s.mongoUri;
65
+ e.MONGODB_DATABASE = s.mongoDb;
66
+ } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
67
+ e.DATABASE_URL = s.dbUrl;
68
+ }
69
+ status("Testing connection…");
70
+ try {
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}`);
73
+ } catch {
74
+ status("Network error testing connection.");
75
+ }
76
+ }
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 ---
103
+ const field = (label, key, placeholder = "") =>
104
+ html`<div class="mb-2">
105
+ <label class="form-label small mb-1">${label}</label>
106
+ <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
107
+ </div>`;
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
+
118
+ const dbSettings = () =>
119
+ html`<div class="mb-2">
120
+ <label class="form-label small mb-1">Database (DB_DRIVER)</label>
121
+ <select class="form-select" value=${() => state().dbDriver} onchange=${(e) => set({ dbDriver: e.target.value })}>
122
+ <option value="memory">memory (no setup)</option>
123
+ <option value="mongodb">mongodb</option>
124
+ <option value="mysql">mysql</option>
125
+ <option value="postgres">postgres</option>
126
+ </select>
127
+ </div>
128
+ ${() =>
129
+ state().dbDriver === "mongodb"
130
+ ? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
131
+ : state().dbDriver === "mysql" || state().dbDriver === "postgres"
132
+ ? field("DATABASE_URL", "dbUrl", state().dbDriver + "://user:pass@host/db")
133
+ : null}
134
+ ${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
135
+
136
+ mount(
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,
145
+ html`<div class="card-x p-4 mb-3">
146
+ <h2 class="h6 mb-3">Settings</h2>
147
+ ${field("PORT", "port", String(defaultPort))}
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)}
150
+ </div>`,
151
+ html`<div class="card-x p-4 mb-3">
152
+ <div class="d-flex justify-content-between align-items-center mb-2">
153
+ <h2 class="h6 mb-0">.env</h2>
154
+ <button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button>
155
+ </div>
156
+ <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
157
+ </div>`,
158
+ () => (status() ? html`<p class="small accent">${status}</p>` : null),
159
+ );