create-volt 0.34.0 → 0.36.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 CHANGED
@@ -4,6 +4,31 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.36.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **`--port <n>` and `CONFIG_PORT`.** `--port` overrides the listen port for any
11
+ run; `CONFIG_PORT` in `.env` gives the `--edit`/`--studio` config UI its own
12
+ port, so it never collides with a running app. An in-use config port now prints
13
+ a clear hint instead of a raw `EADDRINUSE` stack.
14
+ - **Hot reload watches `pages/` and `posts/`.** Editing markdown content now
15
+ reloads the browser (content is read per request, so the edit shows). Theme
16
+ files (`_theme.js`) still need a restart — ES modules cache.
17
+
18
+ ### Fixed
19
+ - The `blog` + `docs` templates had drifted from `default`s `server.js`/wizard and
20
+ silently missed recent fixes (including the hot-reload watcher). Re-synced, with
21
+ a test that fails if they drift again.
22
+
23
+ ## [0.35.0] - 2026-06-29
24
+
25
+ ### Fixed
26
+ - **Setup wizard: dependency add-ons now show as checked.** Enabling `auth` pulls
27
+ in `db` + `mailer` (its dependencies), which were added to `VOLT_ADDONS` but
28
+ whose checkboxes stayed *unchecked* — so the generated `.env` looked like it had
29
+ add-ons you never picked. Pulled-in dependencies now render **checked + disabled**
30
+ with a "required by <add-on>" note, so the checkboxes always match `VOLT_ADDONS`.
31
+
7
32
  ## [0.34.0] - 2026-06-29
8
33
 
9
34
  ### Added
@@ -454,6 +479,8 @@ All notable changes to `create-volt` are documented here. The format follows
454
479
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
455
480
  and auto-detects npm / pnpm / yarn / bun for the install step.
456
481
 
482
+ [0.36.0]: https://github.com/MIR-2025/volt/releases/tag/v0.36.0
483
+ [0.35.0]: https://github.com/MIR-2025/volt/releases/tag/v0.35.0
457
484
  [0.34.0]: https://github.com/MIR-2025/volt/releases/tag/v0.34.0
458
485
  [0.33.0]: https://github.com/MIR-2025/volt/releases/tag/v0.33.0
459
486
  [0.32.0]: https://github.com/MIR-2025/volt/releases/tag/v0.32.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.34.0",
3
+ "version": "0.36.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": {
@@ -21,6 +21,24 @@ const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
+
25
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
26
+ // --edit/--studio dodge a port the running app already holds, and runs the app
27
+ // itself on a one-off port. Explicit flag wins over PORT in .env.
28
+ function cliPort() {
29
+ const i = process.argv.indexOf("--port");
30
+ const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
31
+ const n = Number(raw);
32
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
33
+ }
34
+
35
+ // Port for the disposable config UI (--edit / --studio): --port wins, then
36
+ // CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
37
+ // then the app's PORT, then the date-port.
38
+ function configPort() {
39
+ const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
40
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
41
+ }
24
42
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
25
43
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
26
44
 
@@ -101,7 +119,7 @@ function openBrowser(url) {
101
119
 
102
120
  // --- the actual app: wires whichever add-ons .env enables ---
103
121
  async function startApp() {
104
- const PORT = Number(process.env.PORT) || DEFAULT_PORT;
122
+ const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
105
123
  const enabled = enabledFrom(process.env);
106
124
  const app = express();
107
125
  app.disable("x-powered-by");
@@ -188,7 +206,12 @@ async function startApp() {
188
206
  };
189
207
  w(dir);
190
208
  };
191
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
209
+ // watch content dirs too (pages/posts markdown is read per request, so a
210
+ // browser reload shows the edit); skip dirs that don't exist.
211
+ for (const d of ["views", "public", "pages", "posts"]) {
212
+ const full = path.join(__dirname, d);
213
+ if (fs.existsSync(full)) watchRecursive(full);
214
+ }
192
215
 
193
216
  const on = [...enabled];
194
217
  server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
@@ -221,7 +244,7 @@ function ensureDriverInstalled(driver) {
221
244
 
222
245
  // --- the disposable setup wizard (localhost only) ---
223
246
  function startSetup() {
224
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
247
+ const PORT = configPort();
225
248
  const assets = {
226
249
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
227
250
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -341,6 +364,7 @@ function startSetup() {
341
364
  res.end("not found");
342
365
  });
343
366
 
367
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
344
368
  server.listen(PORT, "127.0.0.1", () => {
345
369
  const url = `http://localhost:${PORT}`;
346
370
  console.log(`\n⚡ Volt setup → ${url}`);
@@ -379,7 +403,7 @@ async function startStudio() {
379
403
  console.error("Studio: couldn't connect the store — " + e.message);
380
404
  process.exit(1);
381
405
  }
382
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
406
+ const PORT = configPort();
383
407
  const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
384
408
  const assets = {
385
409
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -431,6 +455,7 @@ async function startStudio() {
431
455
  }
432
456
  });
433
457
 
458
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
434
459
  server.listen(PORT, "127.0.0.1", () => {
435
460
  const url = `http://localhost:${PORT}`;
436
461
  console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
@@ -25,6 +25,9 @@ const state = signal({
25
25
  s3Secret: current.S3_SECRET || "",
26
26
  s3PublicBase: current.S3_PUBLIC_BASE || "",
27
27
  port: current.PORT || String(defaultPort),
28
+ // detect the admin's timezone from their browser (the wizard runs here), so
29
+ // dates render in their zone — not the server's (usually UTC on a host).
30
+ tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
28
31
  });
29
32
  const set = (patch) => state({ ...state(), ...patch });
30
33
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
@@ -42,10 +45,28 @@ function effective(s) {
42
45
  return order.filter((n) => want.has(n));
43
46
  }
44
47
 
48
+ // which *enabled* add-ons pull in `name` as a (transitive) dependency
49
+ function requiredBy(s, name) {
50
+ const causes = [];
51
+ for (const n of order) {
52
+ if (n === name || !s.addons[n]) continue;
53
+ const seen = new Set();
54
+ const visit = (x) => {
55
+ if (seen.has(x)) return;
56
+ seen.add(x);
57
+ (depsOf[x] || []).forEach(visit);
58
+ };
59
+ (depsOf[n] || []).forEach(visit);
60
+ if (seen.has(name)) causes.push(n);
61
+ }
62
+ return causes;
63
+ }
64
+
45
65
  const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
46
66
  function genEnv(s) {
47
67
  const eff = effective(s);
48
68
  const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
69
+ if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
49
70
  if (eff.includes("db")) {
50
71
  out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
51
72
  if (s.dbDriver === "mongodb") {
@@ -133,11 +154,18 @@ const field = (label, key, placeholder = "") =>
133
154
  <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
134
155
  </div>`;
135
156
 
157
+ // A dependency pulled in by another enabled add-on shows as checked + disabled
158
+ // (you can't turn it off while something needs it), with a "required by" note —
159
+ // so the .env's VOLT_ADDONS always matches what the boxes show.
136
160
  const addonRow = (a) =>
137
161
  html`<div class="form-check mb-2">
138
- <input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
162
+ <input class="form-check-input" type="checkbox" id=${"x-" + a.name}
163
+ checked=${() => eff().includes(a.name)}
164
+ disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
165
+ onchange=${() => toggle(a.name)} />
139
166
  <label class="form-check-label" for=${"x-" + a.name}>
140
- <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
167
+ <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
168
+ !state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
141
169
  <div class="small text-muted">${a.description}</div>
142
170
  </label>
143
171
  </div>`;
@@ -21,6 +21,24 @@ const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
+
25
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
26
+ // --edit/--studio dodge a port the running app already holds, and runs the app
27
+ // itself on a one-off port. Explicit flag wins over PORT in .env.
28
+ function cliPort() {
29
+ const i = process.argv.indexOf("--port");
30
+ const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
31
+ const n = Number(raw);
32
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
33
+ }
34
+
35
+ // Port for the disposable config UI (--edit / --studio): --port wins, then
36
+ // CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
37
+ // then the app's PORT, then the date-port.
38
+ function configPort() {
39
+ const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
40
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
41
+ }
24
42
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
25
43
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
26
44
 
@@ -101,7 +119,7 @@ function openBrowser(url) {
101
119
 
102
120
  // --- the actual app: wires whichever add-ons .env enables ---
103
121
  async function startApp() {
104
- const PORT = Number(process.env.PORT) || DEFAULT_PORT;
122
+ const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
105
123
  const enabled = enabledFrom(process.env);
106
124
  const app = express();
107
125
  app.disable("x-powered-by");
@@ -188,7 +206,12 @@ async function startApp() {
188
206
  };
189
207
  w(dir);
190
208
  };
191
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
209
+ // watch content dirs too (pages/posts markdown is read per request, so a
210
+ // browser reload shows the edit); skip dirs that don't exist.
211
+ for (const d of ["views", "public", "pages", "posts"]) {
212
+ const full = path.join(__dirname, d);
213
+ if (fs.existsSync(full)) watchRecursive(full);
214
+ }
192
215
 
193
216
  const on = [...enabled];
194
217
  server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
@@ -221,7 +244,7 @@ function ensureDriverInstalled(driver) {
221
244
 
222
245
  // --- the disposable setup wizard (localhost only) ---
223
246
  function startSetup() {
224
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
247
+ const PORT = configPort();
225
248
  const assets = {
226
249
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
227
250
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -341,6 +364,7 @@ function startSetup() {
341
364
  res.end("not found");
342
365
  });
343
366
 
367
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
344
368
  server.listen(PORT, "127.0.0.1", () => {
345
369
  const url = `http://localhost:${PORT}`;
346
370
  console.log(`\n⚡ Volt setup → ${url}`);
@@ -379,7 +403,7 @@ async function startStudio() {
379
403
  console.error("Studio: couldn't connect the store — " + e.message);
380
404
  process.exit(1);
381
405
  }
382
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
406
+ const PORT = configPort();
383
407
  const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
384
408
  const assets = {
385
409
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -431,6 +455,7 @@ async function startStudio() {
431
455
  }
432
456
  });
433
457
 
458
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
434
459
  server.listen(PORT, "127.0.0.1", () => {
435
460
  const url = `http://localhost:${PORT}`;
436
461
  console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
@@ -45,6 +45,23 @@ function effective(s) {
45
45
  return order.filter((n) => want.has(n));
46
46
  }
47
47
 
48
+ // which *enabled* add-ons pull in `name` as a (transitive) dependency
49
+ function requiredBy(s, name) {
50
+ const causes = [];
51
+ for (const n of order) {
52
+ if (n === name || !s.addons[n]) continue;
53
+ const seen = new Set();
54
+ const visit = (x) => {
55
+ if (seen.has(x)) return;
56
+ seen.add(x);
57
+ (depsOf[x] || []).forEach(visit);
58
+ };
59
+ (depsOf[n] || []).forEach(visit);
60
+ if (seen.has(name)) causes.push(n);
61
+ }
62
+ return causes;
63
+ }
64
+
48
65
  const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
49
66
  function genEnv(s) {
50
67
  const eff = effective(s);
@@ -137,11 +154,18 @@ const field = (label, key, placeholder = "") =>
137
154
  <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
138
155
  </div>`;
139
156
 
157
+ // A dependency pulled in by another enabled add-on shows as checked + disabled
158
+ // (you can't turn it off while something needs it), with a "required by" note —
159
+ // so the .env's VOLT_ADDONS always matches what the boxes show.
140
160
  const addonRow = (a) =>
141
161
  html`<div class="form-check mb-2">
142
- <input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
162
+ <input class="form-check-input" type="checkbox" id=${"x-" + a.name}
163
+ checked=${() => eff().includes(a.name)}
164
+ disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
165
+ onchange=${() => toggle(a.name)} />
143
166
  <label class="form-check-label" for=${"x-" + a.name}>
144
- <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
167
+ <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
168
+ !state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
145
169
  <div class="small text-muted">${a.description}</div>
146
170
  </label>
147
171
  </div>`;
@@ -21,6 +21,24 @@ const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
+
25
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
26
+ // --edit/--studio dodge a port the running app already holds, and runs the app
27
+ // itself on a one-off port. Explicit flag wins over PORT in .env.
28
+ function cliPort() {
29
+ const i = process.argv.indexOf("--port");
30
+ const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
31
+ const n = Number(raw);
32
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
33
+ }
34
+
35
+ // Port for the disposable config UI (--edit / --studio): --port wins, then
36
+ // CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
37
+ // then the app's PORT, then the date-port.
38
+ function configPort() {
39
+ const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
40
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
41
+ }
24
42
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
25
43
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
26
44
 
@@ -101,7 +119,7 @@ function openBrowser(url) {
101
119
 
102
120
  // --- the actual app: wires whichever add-ons .env enables ---
103
121
  async function startApp() {
104
- const PORT = Number(process.env.PORT) || DEFAULT_PORT;
122
+ const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
105
123
  const enabled = enabledFrom(process.env);
106
124
  const app = express();
107
125
  app.disable("x-powered-by");
@@ -188,7 +206,12 @@ async function startApp() {
188
206
  };
189
207
  w(dir);
190
208
  };
191
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
209
+ // watch content dirs too (pages/posts markdown is read per request, so a
210
+ // browser reload shows the edit); skip dirs that don't exist.
211
+ for (const d of ["views", "public", "pages", "posts"]) {
212
+ const full = path.join(__dirname, d);
213
+ if (fs.existsSync(full)) watchRecursive(full);
214
+ }
192
215
 
193
216
  const on = [...enabled];
194
217
  server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
@@ -221,7 +244,7 @@ function ensureDriverInstalled(driver) {
221
244
 
222
245
  // --- the disposable setup wizard (localhost only) ---
223
246
  function startSetup() {
224
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
247
+ const PORT = configPort();
225
248
  const assets = {
226
249
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
227
250
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -341,6 +364,7 @@ function startSetup() {
341
364
  res.end("not found");
342
365
  });
343
366
 
367
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
344
368
  server.listen(PORT, "127.0.0.1", () => {
345
369
  const url = `http://localhost:${PORT}`;
346
370
  console.log(`\n⚡ Volt setup → ${url}`);
@@ -379,7 +403,7 @@ async function startStudio() {
379
403
  console.error("Studio: couldn't connect the store — " + e.message);
380
404
  process.exit(1);
381
405
  }
382
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
406
+ const PORT = configPort();
383
407
  const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
384
408
  const assets = {
385
409
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -431,6 +455,7 @@ async function startStudio() {
431
455
  }
432
456
  });
433
457
 
458
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
434
459
  server.listen(PORT, "127.0.0.1", () => {
435
460
  const url = `http://localhost:${PORT}`;
436
461
  console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
@@ -25,6 +25,9 @@ const state = signal({
25
25
  s3Secret: current.S3_SECRET || "",
26
26
  s3PublicBase: current.S3_PUBLIC_BASE || "",
27
27
  port: current.PORT || String(defaultPort),
28
+ // detect the admin's timezone from their browser (the wizard runs here), so
29
+ // dates render in their zone — not the server's (usually UTC on a host).
30
+ tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
28
31
  });
29
32
  const set = (patch) => state({ ...state(), ...patch });
30
33
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
@@ -42,10 +45,28 @@ function effective(s) {
42
45
  return order.filter((n) => want.has(n));
43
46
  }
44
47
 
48
+ // which *enabled* add-ons pull in `name` as a (transitive) dependency
49
+ function requiredBy(s, name) {
50
+ const causes = [];
51
+ for (const n of order) {
52
+ if (n === name || !s.addons[n]) continue;
53
+ const seen = new Set();
54
+ const visit = (x) => {
55
+ if (seen.has(x)) return;
56
+ seen.add(x);
57
+ (depsOf[x] || []).forEach(visit);
58
+ };
59
+ (depsOf[n] || []).forEach(visit);
60
+ if (seen.has(name)) causes.push(n);
61
+ }
62
+ return causes;
63
+ }
64
+
45
65
  const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
46
66
  function genEnv(s) {
47
67
  const eff = effective(s);
48
68
  const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
69
+ if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
49
70
  if (eff.includes("db")) {
50
71
  out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
51
72
  if (s.dbDriver === "mongodb") {
@@ -133,11 +154,18 @@ const field = (label, key, placeholder = "") =>
133
154
  <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
134
155
  </div>`;
135
156
 
157
+ // A dependency pulled in by another enabled add-on shows as checked + disabled
158
+ // (you can't turn it off while something needs it), with a "required by" note —
159
+ // so the .env's VOLT_ADDONS always matches what the boxes show.
136
160
  const addonRow = (a) =>
137
161
  html`<div class="form-check mb-2">
138
- <input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
162
+ <input class="form-check-input" type="checkbox" id=${"x-" + a.name}
163
+ checked=${() => eff().includes(a.name)}
164
+ disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
165
+ onchange=${() => toggle(a.name)} />
139
166
  <label class="form-check-label" for=${"x-" + a.name}>
140
- <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
167
+ <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
168
+ !state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
141
169
  <div class="small text-muted">${a.description}</div>
142
170
  </label>
143
171
  </div>`;
@@ -22,6 +22,24 @@ const ENV_PATH = path.join(__dirname, ".env");
22
22
  const PKG_PATH = path.join(__dirname, "package.json");
23
23
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
+
26
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
27
+ // --edit/--studio dodge a port the running app already holds, and runs the app
28
+ // itself on a one-off port. Explicit flag wins over PORT in .env.
29
+ function cliPort() {
30
+ const i = process.argv.indexOf("--port");
31
+ const raw = i > -1 ? process.argv[i + 1] : (process.argv.find((a) => a.startsWith("--port=")) || "").split("=")[1];
32
+ const n = Number(raw);
33
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
34
+ }
35
+
36
+ // Port for the disposable config UI (--edit / --studio): --port wins, then
37
+ // CONFIG_PORT in .env (run it on its own port so it never clashes with the app),
38
+ // then the app's PORT, then the date-port.
39
+ function configPort() {
40
+ const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
41
+ return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || Number(process.env.PORT) || Number(env.PORT) || DEFAULT_PORT;
42
+ }
25
43
  const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
26
44
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
27
45
 
@@ -102,7 +120,7 @@ function openBrowser(url) {
102
120
 
103
121
  // --- the actual app: wires whichever add-ons .env enables ---
104
122
  async function startApp() {
105
- const PORT = Number(process.env.PORT) || DEFAULT_PORT;
123
+ const PORT = cliPort() || Number(process.env.PORT) || DEFAULT_PORT;
106
124
  const enabled = enabledFrom(process.env);
107
125
  const app = express();
108
126
  app.disable("x-powered-by");
@@ -214,7 +232,12 @@ async function startApp() {
214
232
  };
215
233
  w(dir);
216
234
  };
217
- for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
235
+ // watch content dirs too (pages/posts markdown is read per request, so a
236
+ // browser reload shows the edit); skip dirs that don't exist.
237
+ for (const d of ["views", "public", "pages", "posts"]) {
238
+ const full = path.join(__dirname, d);
239
+ if (fs.existsSync(full)) watchRecursive(full);
240
+ }
218
241
 
219
242
  const on = [...enabled];
220
243
  server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
@@ -247,7 +270,7 @@ function ensureDriverInstalled(driver) {
247
270
 
248
271
  // --- the disposable setup wizard (localhost only) ---
249
272
  function startSetup() {
250
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
273
+ const PORT = configPort();
251
274
  const assets = {
252
275
  "/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
253
276
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -367,6 +390,7 @@ function startSetup() {
367
390
  res.end("not found");
368
391
  });
369
392
 
393
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
370
394
  server.listen(PORT, "127.0.0.1", () => {
371
395
  const url = `http://localhost:${PORT}`;
372
396
  console.log(`\n⚡ Volt setup → ${url}`);
@@ -405,7 +429,7 @@ async function startStudio() {
405
429
  console.error("Studio: couldn't connect the store — " + e.message);
406
430
  process.exit(1);
407
431
  }
408
- const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
432
+ const PORT = configPort();
409
433
  const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
410
434
  const assets = {
411
435
  "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
@@ -457,6 +481,7 @@ async function startStudio() {
457
481
  }
458
482
  });
459
483
 
484
+ server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
460
485
  server.listen(PORT, "127.0.0.1", () => {
461
486
  const url = `http://localhost:${PORT}`;
462
487
  console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
@@ -45,6 +45,23 @@ function effective(s) {
45
45
  return order.filter((n) => want.has(n));
46
46
  }
47
47
 
48
+ // which *enabled* add-ons pull in `name` as a (transitive) dependency
49
+ function requiredBy(s, name) {
50
+ const causes = [];
51
+ for (const n of order) {
52
+ if (n === name || !s.addons[n]) continue;
53
+ const seen = new Set();
54
+ const visit = (x) => {
55
+ if (seen.has(x)) return;
56
+ seen.add(x);
57
+ (depsOf[x] || []).forEach(visit);
58
+ };
59
+ (depsOf[n] || []).forEach(visit);
60
+ if (seen.has(name)) causes.push(n);
61
+ }
62
+ return causes;
63
+ }
64
+
48
65
  const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
49
66
  function genEnv(s) {
50
67
  const eff = effective(s);
@@ -137,11 +154,18 @@ const field = (label, key, placeholder = "") =>
137
154
  <input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
138
155
  </div>`;
139
156
 
157
+ // A dependency pulled in by another enabled add-on shows as checked + disabled
158
+ // (you can't turn it off while something needs it), with a "required by" note —
159
+ // so the .env's VOLT_ADDONS always matches what the boxes show.
140
160
  const addonRow = (a) =>
141
161
  html`<div class="form-check mb-2">
142
- <input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
162
+ <input class="form-check-input" type="checkbox" id=${"x-" + a.name}
163
+ checked=${() => eff().includes(a.name)}
164
+ disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
165
+ onchange=${() => toggle(a.name)} />
143
166
  <label class="form-check-label" for=${"x-" + a.name}>
144
- <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
167
+ <span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
168
+ !state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
145
169
  <div class="small text-muted">${a.description}</div>
146
170
  </label>
147
171
  </div>`;