create-volt 0.5.0 → 0.8.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.
@@ -1,64 +1,213 @@
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; 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.
8
6
  //
9
- // npm run dev # start the dev server
10
- // PORT=4000 npm run dev # override the port
7
+ // No build step, no env-file flag: .env is auto-loaded below.
11
8
 
12
9
  import http from "node:http";
13
10
  import fs from "node:fs";
14
11
  import path from "node:path";
15
- import { fileURLToPath } from "node:url";
12
+ import { spawn } from "node:child_process";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
14
  import express from "express";
17
15
  import { Server as SocketServer } from "socket.io";
18
16
 
19
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
- const PORT = Number(process.env.PORT) || 26628;
18
+ const ENV_PATH = path.join(__dirname, ".env");
19
+ const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
21
20
 
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);
21
+ // --- tiny .env loader (no dependency); never overrides an existing env var ---
22
+ function readEnvFile() {
23
+ const out = {};
24
+ if (!fs.existsSync(ENV_PATH)) return out;
25
+ for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
26
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
27
+ if (m) out[m[1]] = m[2];
28
+ }
29
+ return out;
30
+ }
31
+ function loadEnv() {
32
+ for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
33
+ }
28
34
 
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);
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") };
38
40
  }
39
41
 
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) {
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.
45
+ function openBrowser(url) {
46
+ if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
47
+ const plat = process.platform;
48
+ if (plat === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
49
+ const cmd = plat === "darwin" ? "open" : plat === "win32" ? "cmd" : "xdg-open";
50
+ const args = plat === "win32" ? ["/c", "start", "", url] : [url];
43
51
  try {
44
- fs.watch(dir, { recursive: true }, (_event, file) => onChange(file));
45
- return;
52
+ 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
54
+ child.unref();
55
+ return true;
46
56
  } catch {
47
- // Native recursive watch unsupported — fall back to per-directory watchers.
57
+ return false;
48
58
  }
49
- const watchDir = (d) => {
59
+ }
60
+
61
+ // --- the actual app ---
62
+ function startApp() {
63
+ const PORT = Number(process.env.PORT) || DEFAULT_PORT;
64
+ const app = express();
65
+ app.use(express.static(path.join(__dirname, "public")));
66
+ app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
67
+
68
+ const server = http.createServer(app);
69
+ const io = new SocketServer(server);
70
+
71
+ // hot reload: watch views/ + public/, debounce, broadcast a reload
72
+ let timer = null;
73
+ const onChange = (file) => {
74
+ clearTimeout(timer);
75
+ timer = setTimeout(() => {
76
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
77
+ io.emit("volt:reload");
78
+ }, 80);
79
+ };
80
+ const watchRecursive = (dir) => {
50
81
  try {
51
- fs.watch(d, (_event, file) => onChange(file));
82
+ fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
83
+ return;
52
84
  } 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));
85
+ /* fall back to per-directory watchers */
57
86
  }
87
+ const w = (d) => {
88
+ try {
89
+ fs.watch(d, (_e, f) => onChange(f));
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) w(path.join(d, e.name));
94
+ };
95
+ w(dir);
58
96
  };
59
- watchDir(dir);
97
+ for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
98
+
99
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}`));
60
100
  }
61
101
 
62
- for (const dir of watchDirs) watchRecursive(dir);
102
+ // --- the disposable setup wizard (localhost only) ---
103
+ function startSetup() {
104
+ const PORT = Number(process.env.PORT) || DEFAULT_PORT;
105
+ const assets = {
106
+ "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
107
+ "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
108
+ };
109
+ const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
63
110
 
64
- server.listen(PORT, () => console.log(`⚡ Volt dev server → http://localhost:${PORT}`));
111
+ const server = http.createServer((req, res) => {
112
+ const u = new URL(req.url, "http://localhost");
113
+ const p = u.pathname;
114
+ if (req.method === "GET" && p === "/") {
115
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
116
+ return res.end(indexHtml);
117
+ }
118
+ if (req.method === "GET" && assets[p]) {
119
+ res.setHeader("Content-Type", assets[p][0]);
120
+ return res.end(assets[p][1]);
121
+ }
122
+ if (req.method === "GET" && p === "/setup/state") {
123
+ res.setHeader("Content-Type", "application/json");
124
+ return res.end(JSON.stringify({ present: presentAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
125
+ }
126
+ if (req.method === "POST" && p === "/setup/test-db") {
127
+ let body = "";
128
+ req.on("data", (c) => (body += c));
129
+ req.on("end", async () => {
130
+ const keys = ["DB_DRIVER", "MONGODB_URI", "MONGODB_DATABASE", "DATABASE_URL"];
131
+ const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
132
+ try {
133
+ const { env = {} } = JSON.parse(body);
134
+ for (const k of keys) {
135
+ if (env[k]) process.env[k] = env[k];
136
+ else delete process.env[k];
137
+ }
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
141
+ res.setHeader("Content-Type", "application/json");
142
+ res.end(JSON.stringify({ ok: true, driver: store.name }));
143
+ } catch (e) {
144
+ res.setHeader("Content-Type", "application/json");
145
+ res.end(JSON.stringify({ ok: false, error: e.message }));
146
+ } finally {
147
+ for (const k of keys) {
148
+ if (saved[k] == null) delete process.env[k];
149
+ else process.env[k] = saved[k];
150
+ }
151
+ }
152
+ });
153
+ return;
154
+ }
155
+ if (req.method === "POST" && p === "/setup/apply") {
156
+ let body = "";
157
+ req.on("data", (c) => (body += c));
158
+ req.on("end", () => {
159
+ try {
160
+ const { env } = JSON.parse(body);
161
+ 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.
165
+ const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
166
+ const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
167
+ res.setHeader("Content-Type", "application/json");
168
+ res.end(JSON.stringify({ ok: true, port: newPort }));
169
+ console.log("[volt] saved .env — starting the app…");
170
+ res.on("finish", () => {
171
+ server.close(() => {
172
+ loadEnv();
173
+ startApp();
174
+ });
175
+ server.closeIdleConnections?.(); // drop the keep-alive socket so close() fires now
176
+ });
177
+ } catch (e) {
178
+ res.statusCode = 400;
179
+ res.end(JSON.stringify({ ok: false, error: e.message }));
180
+ }
181
+ });
182
+ return;
183
+ }
184
+ res.statusCode = 404;
185
+ res.end("not found");
186
+ });
187
+
188
+ // localhost-only: an unauthenticated write endpoint shouldn't be on the network
189
+ server.listen(PORT, "127.0.0.1", () => {
190
+ const url = `http://localhost:${PORT}`;
191
+ console.log(`\n⚡ Volt setup → ${url}`);
192
+ 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"
194
+ if (ssh) {
195
+ const host = ssh.split(" ")[2];
196
+ const user = process.env.USER || process.env.USERNAME || "you";
197
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
198
+ console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
199
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
200
+ }
201
+ console.log("");
202
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
203
+ });
204
+ }
205
+
206
+ // --- gate: setup on first run / --edit, otherwise the app ---
207
+ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
208
+ if (editMode || !fs.existsSync(ENV_PATH)) {
209
+ startSetup();
210
+ } else {
211
+ loadEnv();
212
+ startApp();
213
+ }
@@ -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,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.
3
+ import { signal, computed, html, mount } from "/volt.js";
4
+
5
+ const { present, current, defaultPort } = await (await fetch("/setup/state")).json();
6
+
7
+ const state = signal({
8
+ port: current.PORT || String(defaultPort),
9
+ dbDriver: current.DB_DRIVER || "memory",
10
+ mongoUri: current.MONGODB_URI || "",
11
+ mongoDb: current.MONGODB_DATABASE || "",
12
+ dbUrl: current.DATABASE_URL || "",
13
+ smtpUrl: current.SMTP_URL || "",
14
+ mailFrom: current.MAIL_FROM || "",
15
+ });
16
+ const set = (patch) => state({ ...state(), ...patch });
17
+ const status = signal("");
18
+
19
+ function genEnv(s) {
20
+ const out = [`PORT=${s.port}`];
21
+ if (present.db) {
22
+ out.push(`DB_DRIVER=${s.dbDriver}`);
23
+ if (s.dbDriver === "mongodb") {
24
+ out.push(`MONGODB_URI=${s.mongoUri}`);
25
+ if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
26
+ } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
27
+ out.push(`DATABASE_URL=${s.dbUrl}`);
28
+ }
29
+ }
30
+ if (present.mailer) {
31
+ if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
32
+ else out.push("# SMTP_URL= # unset → emails print to the console");
33
+ if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
34
+ }
35
+ return out.join("\n") + "\n";
36
+ }
37
+ 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
+ }
61
+
62
+ async function testDb() {
63
+ const s = state();
64
+ const env = { DB_DRIVER: s.dbDriver };
65
+ if (s.dbDriver === "mongodb") {
66
+ env.MONGODB_URI = s.mongoUri;
67
+ env.MONGODB_DATABASE = s.mongoDb;
68
+ } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
69
+ env.DATABASE_URL = s.dbUrl;
70
+ }
71
+ status("Testing connection…");
72
+ 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}`);
76
+ } catch {
77
+ status("Network error testing connection.");
78
+ }
79
+ }
80
+
81
+ const field = (label, key, placeholder = "") =>
82
+ html`<div class="mb-2">
83
+ <label class="form-label small mb-1">${label}</label>
84
+ <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
85
+ </div>`;
86
+
87
+ const dbSettings = () =>
88
+ html`<div class="mb-2">
89
+ <label class="form-label small mb-1">Database (DB_DRIVER)</label>
90
+ <select class="form-select" value=${() => state().dbDriver} onchange=${(e) => set({ dbDriver: e.target.value })}>
91
+ <option value="memory">memory (no setup)</option>
92
+ <option value="mongodb">mongodb</option>
93
+ <option value="mysql">mysql</option>
94
+ <option value="postgres">postgres</option>
95
+ </select>
96
+ </div>
97
+ ${() =>
98
+ state().dbDriver === "mongodb"
99
+ ? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
100
+ : state().dbDriver === "mysql" || state().dbDriver === "postgres"
101
+ ? field("DATABASE_URL", "dbUrl", state().dbDriver + "://user:pass@host/db")
102
+ : null}
103
+ ${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
104
+
105
+ mount(
106
+ "#app",
107
+ html`<div class="card-x p-4 mb-3">
108
+ <h2 class="h6 mb-3">Settings</h2>
109
+ ${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)}
112
+ </div>`,
113
+ html`<div class="card-x p-4 mb-3">
114
+ <div class="d-flex justify-content-between align-items-center mb-2">
115
+ <h2 class="h6 mb-0">.env</h2>
116
+ <button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button>
117
+ </div>
118
+ <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
119
+ </div>`,
120
+ () => (status() ? html`<p class="small accent">${status}</p>` : null),
121
+ );
@@ -8,6 +8,7 @@
8
8
  // Run under PM2 in production: pm2 start server.js --name guestbook ; pm2 log
9
9
 
10
10
  import http from "node:http";
11
+ import fs from "node:fs";
11
12
  import path from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import express from "express";
@@ -17,6 +18,17 @@ import { createMailer } from "./lib/mailer.js";
17
18
  import { createRouter } from "./router.js";
18
19
 
19
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ // Auto-load .env (no --env-file flag needed; works the same on Windows). Never
23
+ // overrides a variable already set in the environment.
24
+ const ENV_PATH = path.join(__dirname, ".env");
25
+ if (fs.existsSync(ENV_PATH)) {
26
+ for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
27
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
28
+ if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
29
+ }
30
+ }
31
+
20
32
  const PORT = Number(process.env.PORT) || 26629;
21
33
 
22
34
  const store = await createStore();