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/CHANGELOG.md +73 -0
- package/README.md +26 -0
- package/addons/auth/files/lib/auth.js +121 -0
- package/addons/auth/meta.json +8 -0
- package/addons/db/files/lib/store.js +42 -0
- package/addons/db/files/lib/stores/memory.js +33 -0
- package/addons/db/files/lib/stores/mongo.js +43 -0
- package/addons/db/files/lib/stores/sql.js +79 -0
- package/addons/db/meta.json +12 -0
- package/addons/mailer/files/lib/mailer.js +33 -0
- package/addons/mailer/meta.json +10 -0
- package/addons/realtime/files/lib/realtime.js +78 -0
- package/addons/realtime/files/public/chat-client.js +30 -0
- package/addons/realtime/meta.json +8 -0
- package/index.js +49 -5
- package/package.json +2 -1
- package/templates/default/README.md +7 -1
- package/templates/default/server.js +273 -42
- package/templates/default/setup/index.html +27 -0
- package/templates/default/setup/setup.js +159 -0
- package/templates/guestbook/server.js +12 -0
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.
|
|
218
|
-
|
|
219
|
-
|
|
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(`\
|
|
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.
|
|
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
|
|
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 —
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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 {
|
|
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
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
81
|
+
return false;
|
|
48
82
|
}
|
|
49
|
-
|
|
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(
|
|
114
|
+
fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
|
|
115
|
+
return;
|
|
52
116
|
} catch {
|
|
53
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
);
|