create-volt 0.3.2 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +49 -0
  3. package/addons/auth/files/lib/auth.js +121 -0
  4. package/addons/auth/meta.json +8 -0
  5. package/addons/db/files/lib/store.js +42 -0
  6. package/addons/db/files/lib/stores/memory.js +33 -0
  7. package/addons/db/files/lib/stores/mongo.js +43 -0
  8. package/addons/db/files/lib/stores/sql.js +79 -0
  9. package/addons/db/meta.json +12 -0
  10. package/addons/mailer/files/lib/mailer.js +33 -0
  11. package/addons/mailer/meta.json +10 -0
  12. package/addons/realtime/files/lib/realtime.js +78 -0
  13. package/addons/realtime/files/public/chat-client.js +30 -0
  14. package/addons/realtime/meta.json +8 -0
  15. package/config/config-app.js +168 -0
  16. package/config/index.html +29 -0
  17. package/index.js +242 -16
  18. package/package.json +5 -2
  19. package/{template → templates/default}/README.md +20 -1
  20. package/templates/default/server.js +213 -0
  21. package/templates/default/setup/index.html +27 -0
  22. package/templates/default/setup/setup.js +121 -0
  23. package/{template → templates/default}/views/index.html +3 -0
  24. package/templates/guestbook/README.md +71 -0
  25. package/templates/guestbook/gitignore +5 -0
  26. package/templates/guestbook/lib/auth.js +63 -0
  27. package/templates/guestbook/lib/mailer.js +39 -0
  28. package/templates/guestbook/lib/store.js +43 -0
  29. package/templates/guestbook/lib/stores/memory.js +49 -0
  30. package/templates/guestbook/lib/stores/mongo.js +68 -0
  31. package/templates/guestbook/lib/stores/sql.js +120 -0
  32. package/templates/guestbook/package.json +21 -0
  33. package/templates/guestbook/public/app.js +112 -0
  34. package/templates/guestbook/public/volt.js +265 -0
  35. package/templates/guestbook/router.js +110 -0
  36. package/templates/guestbook/server.js +48 -0
  37. package/templates/guestbook/views/confirm.html +53 -0
  38. package/templates/guestbook/views/index.html +38 -0
  39. package/templates/guestbook/views/partials/header.html +4 -0
  40. package/template/server.js +0 -64
  41. /package/{template → templates/default}/gitignore +0 -0
  42. /package/{template → templates/default}/package.json +0 -0
  43. /package/{template → templates/default}/public/app.js +0 -0
  44. /package/{template → templates/default}/public/volt.js +0 -0
@@ -0,0 +1,30 @@
1
+ // chat-client.js — thin browser wrapper around the Socket.io chat events.
2
+ // Pair it with Volt signals in your app.js. Requires the socket.io client
3
+ // (load /socket.io/socket.io.js before your module).
4
+ //
5
+ // import { createChat } from "/chat-client.js";
6
+ // const chat = createChat({
7
+ // onHistory: ({ room, messages }) => ...,
8
+ // onMessage: (msg) => ...,
9
+ // onPresence: ({ room, users }) => ...,
10
+ // onTyping: ({ name }) => ...,
11
+ // });
12
+ // chat.join("general");
13
+ // chat.send("general", "hello");
14
+ // chat.typing("general");
15
+
16
+ export function createChat({ onHistory, onMessage, onPresence, onTyping } = {}) {
17
+ if (!window.io) throw new Error("Socket.io client not loaded — add <script src=\"/socket.io/socket.io.js\"></script>");
18
+ const socket = window.io();
19
+ if (onHistory) socket.on("chat:history", onHistory);
20
+ if (onMessage) socket.on("chat:message", onMessage);
21
+ if (onPresence) socket.on("chat:presence", onPresence);
22
+ if (onTyping) socket.on("chat:typing", onTyping);
23
+
24
+ return {
25
+ socket,
26
+ join: (room) => socket.emit("chat:join", { room }),
27
+ send: (room, body) => socket.emit("chat:message", { room, body }),
28
+ typing: (room) => socket.emit("chat:typing", { room }),
29
+ };
30
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Socket.io live chat: rooms, presence (who's online), typing indicators. Persists messages via db; shows emails when auth is present.",
3
+ "dependsOn": ["db"],
4
+ "sentinel": "lib/realtime.js",
5
+ "install": ["socket.io"],
6
+ "optional": {},
7
+ "wiring": "import http from \"node:http\";\nimport { Server as SocketServer } from \"socket.io\";\nimport { createStore } from \"./lib/store.js\";\nimport { attachRealtime } from \"./lib/realtime.js\";\n\nconst server = http.createServer(app); // wrap the Express app\nconst io = new SocketServer(server);\nattachRealtime(io, { store: await createStore() });\nserver.listen(PORT); // listen on `server`, not `app`\n\nIn the page: load /socket.io/socket.io.js, then in your module:\n import { createChat } from \"/chat-client.js\";\n const chat = createChat({ onMessage, onHistory, onPresence, onTyping });\n chat.join(\"general\"); chat.send(\"general\", \"hi\");"
8
+ }
@@ -0,0 +1,168 @@
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
+ );
@@ -0,0 +1,29 @@
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>
package/index.js CHANGED
@@ -10,6 +10,9 @@
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";
13
16
  import { spawnSync } from "node:child_process";
14
17
  import { fileURLToPath } from "node:url";
15
18
  import { createRequire } from "node:module";
@@ -34,11 +37,16 @@ ${bold("⚡ create-volt")} — scaffold a new Volt app
34
37
  ${bold("Usage")}
35
38
  npm create volt@latest <project-directory> [options]
36
39
  npx create-volt <project-directory> [options]
40
+ npx create-volt@latest update # refresh public/volt.js in an existing app
41
+ npx create-volt@latest config # disposable page: add db/auth/realtime/mailer + write .env
37
42
 
38
43
  ${bold("Options")}
39
- --port <number> Dev port for the app (default: derived from today's date)
44
+ --template <name> Starter template: default | guestbook (default: default)
45
+ --port <number> Dev port for the app (default: derived from today's date)
40
46
  --skip-install Don't run the package manager install step
41
47
  --no-git Don't initialize a git repository
48
+ --start After scaffolding, run the dev server (opens the setup page)
49
+ --no-open Don't auto-open the browser on start
42
50
  --dry-run Show what would be created without writing anything
43
51
  --force Scaffold into an existing non-empty directory
44
52
  -h, --help Show this help
@@ -54,10 +62,16 @@ const argv = process.argv.slice(2);
54
62
  const flags = new Set();
55
63
  const positionals = [];
56
64
  let portArg = null;
65
+ let templateArg = null;
66
+ let hostArg = null;
57
67
  for (let i = 0; i < argv.length; i++) {
58
68
  const a = argv[i];
59
69
  if (a === "--port") portArg = argv[++i];
60
70
  else if (a.startsWith("--port=")) portArg = a.slice("--port=".length);
71
+ else if (a === "--template") templateArg = argv[++i];
72
+ 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);
61
75
  else if (a.startsWith("-")) flags.add(a);
62
76
  else positionals.push(a);
63
77
  }
@@ -76,6 +90,196 @@ const force = flags.has("--force");
76
90
  const dryRun = flags.has("--dry-run");
77
91
  const noGit = flags.has("--no-git");
78
92
 
93
+ // The `add` command was replaced by `config` — catch old muscle memory.
94
+ if (positionals[0] === "add") {
95
+ die(`${cyan("add")} was replaced by ${cyan("create-volt config")} — run that to add integrations.`);
96
+ }
97
+
98
+ // --- `update` subcommand: refresh public/volt.js in the current app to the
99
+ // version bundled with this create-volt (so `npx create-volt@latest update`
100
+ // pulls the latest library). Only touches the library file — never the user's
101
+ // app.js, server.js, or port. ---
102
+ if (positionals[0] === "update") {
103
+ const target = path.join(process.cwd(), "public", "volt.js");
104
+ if (!fs.existsSync(target)) {
105
+ die(`No ${cyan("public/volt.js")} here — run ${cyan("create-volt update")} from inside a Volt app.`);
106
+ }
107
+ const latest = fs.readFileSync(path.join(__dirname, "templates", "default", "public", "volt.js"), "utf8");
108
+ const current = fs.readFileSync(target, "utf8");
109
+ if (current === latest) {
110
+ console.log(`\n${green("✔")} ${bold("public/volt.js")} is already current (create-volt ${pkg.version}).\n`);
111
+ process.exit(0);
112
+ }
113
+ if (dryRun) {
114
+ console.log(`\n${yellow("!")} An update is available for ${bold("public/volt.js")} (create-volt ${pkg.version}).`);
115
+ console.log(` Re-run without ${cyan("--dry-run")} to apply.\n`);
116
+ process.exit(0);
117
+ }
118
+ fs.writeFileSync(target, latest);
119
+ console.log(`\n${green("✔")} Updated ${bold("public/volt.js")} to the version in create-volt ${pkg.version}.`);
120
+ console.log(` Review the change with ${cyan("git diff public/volt.js")}.\n`);
121
+ process.exit(0);
122
+ }
123
+
124
+ // --- `config` subcommand: a disposable local page to configure add-ons and
125
+ // write a .env. Dependency-free (node:http); the page is built with Volt. ---
126
+ if (positionals[0] === "config") {
127
+ const cwd = process.cwd();
128
+ const addonsDir = path.join(__dirname, "addons");
129
+ const addonList = fs
130
+ .readdirSync(addonsDir, { withFileTypes: true })
131
+ .filter((e) => e.isDirectory())
132
+ .map((e) => {
133
+ const m = JSON.parse(fs.readFileSync(path.join(addonsDir, e.name, "meta.json"), "utf8"));
134
+ return {
135
+ name: e.name,
136
+ description: m.description,
137
+ install: m.install || [],
138
+ dependsOn: m.dependsOn || [],
139
+ wiring: m.wiring,
140
+ installed: fs.existsSync(path.join(cwd, m.sentinel)),
141
+ };
142
+ });
143
+ const assets = {
144
+ "/config-app.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "config", "config-app.js"))],
145
+ "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "templates", "default", "public", "volt.js"))],
146
+ };
147
+ const indexHtml = fs.readFileSync(path.join(__dirname, "config", "index.html"));
148
+ // localhost by default — shell/SSH access is the auth. --host exposes it on the
149
+ // network, and only then do we mint a random key to gate it.
150
+ const host = hostArg || "127.0.0.1";
151
+ const exposed = host !== "127.0.0.1" && host !== "localhost";
152
+ const key = exposed ? crypto.randomBytes(12).toString("hex") : null;
153
+
154
+ const server = http.createServer((req, res) => {
155
+ const u = new URL(req.url, "http://localhost");
156
+ const p = u.pathname;
157
+ if (req.method === "GET" && p === "/") {
158
+ if (key && u.searchParams.get("key") !== key) {
159
+ res.statusCode = 403;
160
+ return res.end("Forbidden — open the ?key=… link printed in the terminal.");
161
+ }
162
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
163
+ return res.end(indexHtml);
164
+ }
165
+ if (req.method === "GET" && assets[p]) {
166
+ res.setHeader("Content-Type", assets[p][0]);
167
+ return res.end(assets[p][1]);
168
+ }
169
+ if (req.method === "GET" && p === "/addons.json") {
170
+ res.setHeader("Content-Type", "application/json");
171
+ return res.end(JSON.stringify(addonList));
172
+ }
173
+ if (req.method === "GET" && p === "/current.json") {
174
+ const current = {};
175
+ const envPath = path.join(cwd, ".env");
176
+ if (fs.existsSync(envPath)) {
177
+ for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
178
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
179
+ if (m) current[m[1]] = m[2];
180
+ }
181
+ }
182
+ res.setHeader("Content-Type", "application/json");
183
+ return res.end(JSON.stringify(current));
184
+ }
185
+ if (req.method === "POST" && p === "/apply") {
186
+ if (key && req.headers["x-config-key"] !== key) {
187
+ res.statusCode = 403;
188
+ return res.end(JSON.stringify({ ok: false, error: "bad or missing key" }));
189
+ }
190
+ let body = "";
191
+ req.on("data", (c) => (body += c));
192
+ req.on("end", () => {
193
+ try {
194
+ const { addons = [], env } = JSON.parse(body);
195
+ const valid = new Set(addonList.map((a) => a.name));
196
+ const depsOf = Object.fromEntries(addonList.map((a) => [a.name, a.dependsOn]));
197
+ // expand selection to include required dependencies (deps before dependents)
198
+ const want = [];
199
+ const seen = new Set();
200
+ const visit = (n) => {
201
+ if (!valid.has(n) || seen.has(n)) return;
202
+ seen.add(n);
203
+ for (const d of depsOf[n] || []) visit(d);
204
+ want.push(n);
205
+ };
206
+ for (const n of addons) visit(n);
207
+
208
+ const copied = [];
209
+ const skipped = [];
210
+ for (const n of want) {
211
+ const filesDir = path.join(addonsDir, n, "files");
212
+ for (const f of listTemplateFiles(filesDir)) {
213
+ const dest = path.join(cwd, f);
214
+ if (fs.existsSync(dest)) {
215
+ skipped.push(f);
216
+ continue;
217
+ }
218
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
219
+ fs.copyFileSync(path.join(filesDir, f), dest);
220
+ copied.push(f);
221
+ }
222
+ }
223
+ if (typeof env === "string") {
224
+ // preserve any custom keys already in .env that this form doesn't manage
225
+ const envPath = path.join(cwd, ".env");
226
+ let finalEnv = env;
227
+ if (fs.existsSync(envPath)) {
228
+ const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
229
+ const extra = fs
230
+ .readFileSync(envPath, "utf8")
231
+ .split("\n")
232
+ .filter((line) => {
233
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
234
+ return m && !managed.has(m[1]);
235
+ });
236
+ if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
237
+ }
238
+ fs.writeFileSync(envPath, finalEnv);
239
+ }
240
+ console.log(`${green("✔")} applied [${want.join(", ")}] — ${copied.length} file(s) copied, .env written`);
241
+ res.setHeader("Content-Type", "application/json");
242
+ res.end(JSON.stringify({ ok: true, copied, skipped, applied: want }));
243
+ } catch (e) {
244
+ res.statusCode = 400;
245
+ res.end(JSON.stringify({ ok: false, error: e.message }));
246
+ }
247
+ });
248
+ return;
249
+ }
250
+ res.statusCode = 404;
251
+ res.end("not found");
252
+ });
253
+
254
+ const wanted = portArg ? Number(portArg) : 0;
255
+ server.listen(Number.isInteger(wanted) && wanted > 0 ? wanted : 0, host, () => {
256
+ const port = server.address().port;
257
+ const q = key ? `/?key=${key}` : "/";
258
+ console.log(`\n${bold("⚡ create-volt config")} — add-ons for ${dim(cwd)}\n`);
259
+ if (exposed) {
260
+ const lan = [];
261
+ for (const iface of Object.values(os.networkInterfaces())) {
262
+ for (const a of iface || []) if (a.family === "IPv4" && !a.internal) lan.push(a.address);
263
+ }
264
+ console.log(" Open (key-gated, reachable on your network):");
265
+ for (const ip of lan) console.log(" " + cyan(`http://${ip}:${port}${q}`));
266
+ console.log(" " + cyan(`http://localhost:${port}${q}`) + dim(" (this box)"));
267
+ } else {
268
+ const url = `http://localhost:${port}${q}`;
269
+ console.log(" Open: " + cyan(url));
270
+ const ssh = process.env.SSH_CONNECTION; // "clientIP clientPort serverIP serverPort"
271
+ const user = process.env.USER || process.env.USERNAME || "you";
272
+ const sshHost = ssh ? ssh.split(" ")[2] : os.hostname();
273
+ console.log(` ${dim(ssh ? "Remote box — the server is up here; from your LOCAL machine run:" : "Remote box? from your local machine run:")}`);
274
+ console.log(" " + dim(`ssh -N -L 127.0.0.1:${port}:localhost:${port} ${user}@${sshHost}`));
275
+ console.log(` ${dim(`…then open ${url} on your machine — shell access is the auth.`)}`);
276
+ console.log(` ${dim("(LAN access instead: --host 0.0.0.0 — adds a key)")}`);
277
+ }
278
+ console.log(`\n ${dim("Applies add-ons + writes .env here · Ctrl-C when done (disposable).")}\n`);
279
+ });
280
+ await new Promise(() => {}); // keep the server up; never fall through to scaffolding
281
+ }
282
+
79
283
  // Resolve the dev port: --port wins, else derive it from today's date as
80
284
  // two-digit-year + month (no leading zero) + two-digit-day (house convention),
81
285
  // so apps scaffolded on different days don't collide on the same port.
@@ -112,7 +316,19 @@ if (!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(path.basena
112
316
 
113
317
  const targetDir = path.resolve(process.cwd(), projectName);
114
318
  const appName = path.basename(targetDir);
115
- const templateDir = path.join(__dirname, "template");
319
+
320
+ // Resolve the starter template (default | guestbook | …).
321
+ const templatesDir = path.join(__dirname, "templates");
322
+ const templateName = templateArg || "default";
323
+ const templateDir = path.join(templatesDir, templateName);
324
+ if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) {
325
+ const available = fs
326
+ .readdirSync(templatesDir, { withFileTypes: true })
327
+ .filter((e) => e.isDirectory())
328
+ .map((e) => e.name)
329
+ .join(", ");
330
+ die(`Unknown template "${templateName}". Available: ${available}.`);
331
+ }
116
332
 
117
333
  // List every file in the template, relative to its root (for dry-run preview).
118
334
  function listTemplateFiles(dir, base = dir) {
@@ -135,7 +351,7 @@ if (fs.existsSync(targetDir)) {
135
351
 
136
352
  // --- dry run: print the plan and exit without writing anything ---
137
353
  if (dryRun) {
138
- console.log(`\n${bold("⚡ Dry run")} — would create a Volt app in ${cyan(targetDir)}\n`);
354
+ console.log(`\n${bold("⚡ Dry run")} — would create a ${cyan(templateName)} Volt app in ${cyan(targetDir)}\n`);
139
355
  console.log("Would write:");
140
356
  for (const f of listTemplateFiles(templateDir).sort()) {
141
357
  // the shipped "gitignore" is renamed to ".gitignore" on scaffold
@@ -171,23 +387,19 @@ fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
171
387
 
172
388
  // --- stamp the chosen dev port into server.js + README ---
173
389
  const serverPath = path.join(targetDir, "server.js");
174
- fs.writeFileSync(
175
- serverPath,
176
- fs.readFileSync(serverPath, "utf8").replace(/(Number\(process\.env\.PORT\)\s*\|\|\s*)\d+/, `$1${port}`),
177
- );
390
+ let serverSrc = fs.readFileSync(serverPath, "utf8");
391
+ serverSrc = serverSrc.replace(/(const DEFAULT_PORT\s*=\s*)\d+/, `$1${port}`); // default template
392
+ serverSrc = serverSrc.replace(/(Number\(process\.env\.PORT\)\s*\|\|\s*)\d+/, `$1${port}`); // other templates
393
+ fs.writeFileSync(serverPath, serverSrc);
178
394
  const appReadme = path.join(targetDir, "README.md");
179
395
  if (fs.existsSync(appReadme)) {
180
396
  fs.writeFileSync(appReadme, fs.readFileSync(appReadme, "utf8").replace(/localhost:\d+/g, `localhost:${port}`));
181
397
  }
182
398
 
183
- const created = [
184
- "public/volt.js — the Volt library (no build step)",
185
- "public/app.js — your app (Counter + Todos demo)",
186
- "views/index.html — the HTML shell",
187
- "server.js — dev server with hot reload",
188
- ];
189
- console.log(green("✔") + " Files created:");
190
- for (const line of created) console.log(" " + dim(line));
399
+ console.log(green("✔") + ` Created a ${cyan(templateName)} app — files:`);
400
+ for (const f of listTemplateFiles(templateDir).sort()) {
401
+ console.log(" " + dim(f === "gitignore" ? ".gitignore" : f));
402
+ }
191
403
  console.log("");
192
404
 
193
405
  // --- detect the package manager that invoked us (npm / pnpm / yarn / bun) ---
@@ -248,4 +460,18 @@ console.log(`\n${green("✔")} ${bold("Done!")} Next steps:\n`);
248
460
  console.log(` ${cyan("cd")} ${projectName}`);
249
461
  if (!installed) console.log(` ${cyan(installCmd)}`);
250
462
  console.log(` ${cyan(runCmd)}`);
251
- console.log(`\nThen open ${cyan("http://localhost:" + port)} and edit ${bold("public/app.js")}.\n`);
463
+ console.log(`\nFirst run opens a quick ${bold("setup")} page at ${cyan("http://localhost:" + port)}, then your app starts.\n`);
464
+
465
+ if (flags.has("--start")) {
466
+ if (!installed) {
467
+ console.log(dim(`(--start needs dependencies — run ${installCmd}, then ${runCmd}.)\n`));
468
+ } else {
469
+ console.log(`${bold("Starting…")}\n`);
470
+ spawnSync(pm, ["run", "dev"], {
471
+ cwd: targetDir,
472
+ stdio: "inherit",
473
+ shell: process.platform === "win32",
474
+ env: flags.has("--no-open") ? { ...process.env, VOLT_NO_OPEN: "1" } : process.env,
475
+ });
476
+ }
477
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.3.2",
3
+ "version": "0.8.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": {
@@ -8,7 +8,10 @@
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
- "template"
11
+ "templates",
12
+ "addons",
13
+ "config",
14
+ "CHANGELOG.md"
12
15
  ],
13
16
  "engines": {
14
17
  "node": ">=16.7.0"
@@ -4,13 +4,21 @@ A tiny, **no-build**, signals-based UI app with **Socket.io hot reload**.
4
4
  Not React: no JSX, no virtual DOM, no re-render-the-world. State lives in
5
5
  *signals*; only the exact text/attribute that changed updates.
6
6
 
7
+ 📖 **[How to build a Volt app →](https://github.com/MIR-2025/volt#readme)**
8
+
7
9
  ## Run
8
10
 
9
11
  ```bash
10
12
  npm install # if you scaffolded with --skip-install
11
- npm run dev # → http://localhost:26628 (PORT env to override)
13
+ npm run dev # → http://localhost:26628
12
14
  ```
13
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
+
14
22
  Edit anything in `public/` or `views/` and save — the dev server pushes a
15
23
  reload over Socket.io and the page refreshes itself.
16
24
 
@@ -30,6 +38,17 @@ date and takes `--port <number>` to avoid collisions on the same day:
30
38
  npm create volt@latest api-app -- --port 26630
31
39
  ```
32
40
 
41
+ ## Updating Volt
42
+
43
+ `public/volt.js` is a vendored file, not an npm dependency. Pull the latest
44
+ library version with:
45
+
46
+ ```bash
47
+ npx create-volt@latest update
48
+ ```
49
+
50
+ This rewrites only `public/volt.js` — your app code and port stay as-is.
51
+
33
52
  ## Project layout
34
53
 
35
54
  ```