create-volt 0.27.0 → 0.29.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,24 @@ 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.29.0] - 2026-06-29
8
+
9
+ ### Fixed
10
+ - **Date-derived default port is now valid year-round.** For Oct–Dec the
11
+ readable YY+M+DD form is 6 digits (e.g. `261010`) and exceeds the max TCP port
12
+ 65535, so create-volt previously refused to scaffold without `--port`. It now
13
+ falls back to a deterministic in-range port `1024 + (YYYYMMDD % 64512)` when the
14
+ readable form overflows; Jan–Sep keep the readable port unchanged. Extracted to
15
+ `lib/date-port.js` and unit-tested.
16
+
17
+ ## [0.28.0] - 2026-06-29
18
+
19
+ ### Fixed
20
+ - Wizard **Test connection** now installs the selected DB driver on demand
21
+ (mongodb / mysql2 / pg, at the pinned version) before testing — it used to fail
22
+ with "<driver> isn't installed" because the package is only added on Apply.
23
+ Also adds the missing `spawnSync` import.
24
+
7
25
  ## [0.27.0] - 2026-06-29
8
26
 
9
27
  ### Fixed
@@ -381,6 +399,8 @@ All notable changes to `create-volt` are documented here. The format follows
381
399
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
382
400
  and auto-detects npm / pnpm / yarn / bun for the install step.
383
401
 
402
+ [0.29.0]: https://github.com/MIR-2025/volt/releases/tag/v0.29.0
403
+ [0.28.0]: https://github.com/MIR-2025/volt/releases/tag/v0.28.0
384
404
  [0.27.0]: https://github.com/MIR-2025/volt/releases/tag/v0.27.0
385
405
  [0.26.0]: https://github.com/MIR-2025/volt/releases/tag/v0.26.0
386
406
  [0.25.0]: https://github.com/MIR-2025/volt/releases/tag/v0.25.0
package/index.js CHANGED
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { spawnSync } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { createRequire } from "node:module";
16
+ import { datePort } from "./lib/date-port.js";
16
17
 
17
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
19
  const require = createRequire(import.meta.url);
@@ -364,12 +365,6 @@ if (positionals[0] === "studio") {
364
365
  // Resolve the dev port: --port wins, else derive it from today's date as
365
366
  // two-digit-year + month (no leading zero) + two-digit-day (house convention),
366
367
  // so apps scaffolded on different days don't collide on the same port.
367
- function datePort(d) {
368
- const yy = String(d.getFullYear()).slice(-2);
369
- const mm = String(d.getMonth() + 1);
370
- const dd = String(d.getDate()).padStart(2, "0");
371
- return Number(`${yy}${mm}${dd}`);
372
- }
373
368
  let port;
374
369
  if (portArg != null) {
375
370
  port = Number(portArg);
@@ -377,10 +372,7 @@ if (portArg != null) {
377
372
  die(`Invalid --port "${portArg}" — use a whole number between 1 and 65535.`);
378
373
  }
379
374
  } else {
380
- port = datePort(new Date());
381
- if (port > 65535) {
382
- die(`The date-derived port (${port}) is above 65535 — pass --port <1-65535>.`);
383
- }
375
+ port = datePort(new Date()); // always 1–65535 (see lib/date-port.js)
384
376
  }
385
377
 
386
378
  const rawName = positionals[0];
@@ -0,0 +1,21 @@
1
+ // date-port.js — derive an app's dev port from its start date.
2
+ //
3
+ // Convention: two-digit year + month (no leading zero) + two-digit day,
4
+ // concatenated → e.g. 2026-06-28 → 26628. Readable, and unique per date
5
+ // (year and day are fixed-width, so the month's 1-vs-2-digit width is
6
+ // unambiguous).
7
+ //
8
+ // Caveat: for Oct–Dec the month is two digits, so the value is 6 digits
9
+ // (e.g. 2026-10-10 → 261010) — above the max TCP port (65535). When the
10
+ // readable form overflows, fall back to a deterministic in-range port
11
+ // derived from the full date: 1024 + (YYYYMMDD % 64512) ∈ [1024, 65535].
12
+ // Always valid, always the same for a given date.
13
+ export function datePort(d) {
14
+ const yy = String(d.getFullYear()).slice(-2);
15
+ const mm = String(d.getMonth() + 1); // no leading zero
16
+ const dd = String(d.getDate()).padStart(2, "0");
17
+ const readable = Number(`${yy}${mm}${dd}`);
18
+ if (readable <= 65535) return readable;
19
+ const ymd = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
20
+ return 1024 + (ymd % 64512);
21
+ }
@@ -0,0 +1,77 @@
1
+ // import-wp-db.js — import WordPress content by reading its MySQL/MariaDB
2
+ // database directly. The niche path for when the REST API is disabled but you
3
+ // have DB access (e.g. on the server or over an SSH tunnel). Reuses the WXR
4
+ // converter (transform → markdown). mysql2 is loaded lazily so create-volt stays
5
+ // dependency-free; the DB connection is injectable for testing.
6
+ import { transform } from "./import-wxr.js";
7
+
8
+ // Table prefix goes into table names (which can't be parameterized), so it must
9
+ // be a safe SQL identifier — this is the one injection vector. Validate hard.
10
+ export function validatePrefix(p) {
11
+ if (!/^[A-Za-z0-9_]+$/.test(p)) throw new Error(`Invalid --prefix "${p}" — only letters, numbers, and underscore are allowed.`);
12
+ return p;
13
+ }
14
+
15
+ // Shape DB rows (posts + flat term rows) into the common item shape.
16
+ export function rowsToItems(posts, terms) {
17
+ const byPost = new Map();
18
+ for (const t of terms || []) {
19
+ const m = byPost.get(t.object_id) || { categories: [], tags: [] };
20
+ (t.taxonomy === "post_tag" ? m.tags : m.categories).push(t.name);
21
+ byPost.set(t.object_id, m);
22
+ }
23
+ return (posts || []).map((p) => {
24
+ const t = byPost.get(p.ID) || { categories: [], tags: [] };
25
+ return {
26
+ type: p.post_type || "post",
27
+ title: p.post_title || p.post_name || "Untitled",
28
+ slug: p.post_name || "",
29
+ status: p.post_status || "publish",
30
+ date: p.post_date ? String(p.post_date).replace("T", " ").replace(/\.\d+$/, "") : "",
31
+ content: p.post_content || "",
32
+ categories: t.categories,
33
+ tags: t.tags,
34
+ };
35
+ });
36
+ }
37
+
38
+ async function defaultConnect(dbUrl) {
39
+ let mysql;
40
+ try {
41
+ mysql = await import("mysql2/promise");
42
+ } catch {
43
+ throw new Error("mysql2 is required for import-wp-db — install it: npm i mysql2");
44
+ }
45
+ const c = await mysql.createConnection(dbUrl);
46
+ return {
47
+ query: async (sql) => {
48
+ const [rows] = await c.query(sql);
49
+ return rows;
50
+ },
51
+ end: () => c.end(),
52
+ };
53
+ }
54
+
55
+ // Read posts + pages (and their terms) from a WordPress database.
56
+ export async function fetchWPFromDB(dbUrl, { prefix = "wp_", drafts = false, connect = defaultConnect } = {}) {
57
+ validatePrefix(prefix);
58
+ const statuses = (drafts ? ["publish", "draft", "pending", "private", "future"] : ["publish"]).map((s) => `'${s}'`).join(","); // fixed allowlist, safe
59
+ const conn = await connect(dbUrl);
60
+ try {
61
+ const posts = await conn.query(
62
+ `SELECT ID, post_title, post_name, post_content, post_status, post_type, post_date FROM \`${prefix}posts\` WHERE post_type IN ('post','page') AND post_status IN (${statuses})`,
63
+ );
64
+ const terms = await conn.query(
65
+ `SELECT tr.object_id AS object_id, tt.taxonomy AS taxonomy, t.name AS name FROM \`${prefix}term_relationships\` tr` +
66
+ ` JOIN \`${prefix}term_taxonomy\` tt ON tr.term_taxonomy_id = tt.term_taxonomy_id` +
67
+ ` JOIN \`${prefix}terms\` t ON tt.term_id = t.term_id WHERE tt.taxonomy IN ('category','post_tag')`,
68
+ );
69
+ return rowsToItems(posts, terms);
70
+ } finally {
71
+ await conn.end?.();
72
+ }
73
+ }
74
+
75
+ export async function runImportFromDB(dbUrl, opts = {}) {
76
+ return transform(await fetchWPFromDB(dbUrl, opts), opts);
77
+ }
@@ -0,0 +1,152 @@
1
+ // import-wxr.js — import a WordPress export (WXR XML) into Volt markdown pages.
2
+ // Zero-dep: WXR is a consistent, machine-generated format, so a focused parser
3
+ // is more honest than pulling a general XML library. Pure functions (parseWXR,
4
+ // toMarkdown, runImport) so they're unit-testable; file I/O lives in the CLI.
5
+
6
+ const unCdata = (s) => s.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, "$1");
7
+
8
+ const decode = (s) =>
9
+ String(s)
10
+ .replace(/&lt;/g, "<")
11
+ .replace(/&gt;/g, ">")
12
+ .replace(/&quot;/g, '"')
13
+ .replace(/&#0?39;|&#x27;|&apos;/g, "'")
14
+ .replace(/&#8217;/g, "’")
15
+ .replace(/&#8216;/g, "‘")
16
+ .replace(/&#8220;/g, "“")
17
+ .replace(/&#8221;/g, "”")
18
+ .replace(/&#8211;/g, "–")
19
+ .replace(/&#8212;/g, "—")
20
+ .replace(/&nbsp;/g, " ")
21
+ .replace(/&amp;/g, "&");
22
+
23
+ const tagOf = (block, name) => {
24
+ const m = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
25
+ return m ? unCdata(m[1]).trim() : "";
26
+ };
27
+
28
+ const catsOf = (block, domain) =>
29
+ [...block.matchAll(new RegExp(`<category domain="${domain}"[^>]*>([\\s\\S]*?)</category>`, "g"))].map((m) => decode(unCdata(m[1]).trim())).filter(Boolean);
30
+
31
+ // Parse a WXR document into raw items.
32
+ export function parseWXR(xml) {
33
+ const items = [];
34
+ for (const m of String(xml).matchAll(/<item>([\s\S]*?)<\/item>/g)) {
35
+ const b = m[1];
36
+ items.push({
37
+ type: tagOf(b, "wp:post_type") || "post",
38
+ title: decode(tagOf(b, "title")),
39
+ slug: tagOf(b, "wp:post_name"),
40
+ status: tagOf(b, "wp:status") || "publish",
41
+ date: tagOf(b, "wp:post_date") || tagOf(b, "pubDate"),
42
+ content: unCdata((b.match(/<content:encoded>([\s\S]*?)<\/content:encoded>/) || [, ""])[1]).trim(),
43
+ categories: catsOf(b, "category"),
44
+ tags: catsOf(b, "post_tag"),
45
+ });
46
+ }
47
+ return items;
48
+ }
49
+
50
+ export const slugify = (s) =>
51
+ String(s)
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9]+/g, "-")
54
+ .replace(/^-+|-+$/g, "")
55
+ .slice(0, 80) || "page";
56
+
57
+ // WP content is HTML (often with Gutenberg block comments). Strip the block
58
+ // comments; the rest is valid HTML that the pages add-on's `marked` passes
59
+ // through. Pages are author-trusted files.
60
+ const cleanBody = (html) =>
61
+ String(html)
62
+ .replace(/<!--\s*\/?wp:[\s\S]*?-->/g, "")
63
+ .replace(/\n{3,}/g, "\n\n")
64
+ .trim();
65
+
66
+ // Render one item to a markdown file body (front-matter + content).
67
+ export function toMarkdown(item) {
68
+ const fm = ["---", `title: ${String(item.title || "Untitled").replace(/[\r\n]+/g, " ").trim()}`];
69
+ if (item.date) fm.push(`date: ${item.date}`);
70
+ if (item.tags && item.tags.length) fm.push(`tags: ${item.tags.join(", ")}`);
71
+ fm.push("---", "");
72
+ return fm.join("\n") + cleanBody(item.content) + "\n";
73
+ }
74
+
75
+ // Map a WP REST API object (?_embed=1) into the same item shape as parseWXR.
76
+ function mapREST(p, type) {
77
+ const flat = ((p._embedded && p._embedded["wp:term"]) || []).flat();
78
+ const byTax = (tax) => flat.filter((t) => t && t.taxonomy === tax).map((t) => decode(t.name)).filter(Boolean);
79
+ return {
80
+ type,
81
+ title: decode((p.title && p.title.rendered) || p.slug || "Untitled"),
82
+ slug: p.slug || "",
83
+ status: p.status || "publish",
84
+ date: String(p.date || "").replace("T", " ").replace(/\.\d+$/, ""),
85
+ content: (p.content && p.content.rendered) || "",
86
+ categories: byTax("category"),
87
+ tags: byTax("post_tag"),
88
+ };
89
+ }
90
+
91
+ // Fetch posts + pages from a live WordPress site over the REST API. Credentials
92
+ // (Application Password) are only needed for non-published content. fetchImpl is
93
+ // injectable for testing.
94
+ export async function fetchWP(baseUrl, { user, appPassword, drafts = false, fetchImpl = fetch } = {}) {
95
+ const base = String(baseUrl).replace(/\/+$/, "");
96
+ const headers = { Accept: "application/json" };
97
+ if (user && appPassword) headers.Authorization = "Basic " + Buffer.from(`${user}:${appPassword}`).toString("base64");
98
+ const statuses = drafts ? "publish,draft,pending,private,future" : "publish";
99
+ const items = [];
100
+ for (const [kind, type] of [["posts", "post"], ["pages", "page"]]) {
101
+ let page = 1;
102
+ let totalPages = 1;
103
+ do {
104
+ const url = `${base}/wp-json/wp/v2/${kind}?per_page=100&page=${page}&status=${statuses}&_embed=1`;
105
+ const res = await fetchImpl(url, { headers });
106
+ if (res.status === 400 && page > 1) break; // WP 400s past the last page
107
+ if (res.status === 401 || res.status === 403) throw new Error(`WP REST ${kind}: ${res.status} — credentials needed/insufficient for status=${statuses}`);
108
+ if (!res.ok) throw new Error(`WP REST ${kind} page ${page}: ${res.status} ${res.statusText || ""}`.trim());
109
+ totalPages = Number(res.headers && res.headers.get && res.headers.get("X-WP-TotalPages")) || 1;
110
+ const arr = await res.json();
111
+ if (!Array.isArray(arr) || arr.length === 0) break;
112
+ for (const p of arr) items.push(mapREST(p, type));
113
+ page++;
114
+ } while (page <= totalPages);
115
+ }
116
+ return items;
117
+ }
118
+
119
+ // Select + transform raw items into importable markdown pages. Returns { imported, stats }.
120
+ export function transform(all, { types = ["page", "post"], drafts = false } = {}) {
121
+ const stats = { total: all.length, byType: {}, draftsSkipped: 0, otherTypeSkipped: 0 };
122
+ for (const i of all) stats.byType[i.type] = (stats.byType[i.type] || 0) + 1;
123
+
124
+ const used = new Set();
125
+ const imported = [];
126
+ for (const i of all) {
127
+ if (!types.includes(i.type)) {
128
+ stats.otherTypeSkipped++;
129
+ continue;
130
+ }
131
+ if (i.status !== "publish" && !drafts) {
132
+ stats.draftsSkipped++;
133
+ continue;
134
+ }
135
+ let slug = i.slug && /^[a-z0-9][a-z0-9-]*$/i.test(i.slug) ? i.slug.toLowerCase() : slugify(i.title || i.slug);
136
+ let s = slug;
137
+ for (let n = 2; used.has(s); n++) s = `${slug}-${n}`;
138
+ used.add(s);
139
+ imported.push({ slug: s, type: i.type, title: i.title, filename: `${s}.md`, markdown: toMarkdown(i) });
140
+ }
141
+ return { imported, stats };
142
+ }
143
+
144
+ // Import from a WXR export string (offline file path).
145
+ export function runImport(xml, opts = {}) {
146
+ return transform(parseWXR(xml), opts);
147
+ }
148
+
149
+ // Import from a live WordPress site over the REST API.
150
+ export async function runImportFromWP(baseUrl, opts = {}) {
151
+ return transform(await fetchWP(baseUrl, opts), opts);
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.27.0",
3
+ "version": "0.29.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,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
+ "lib",
11
12
  "templates",
12
13
  "addons",
13
14
  "CHANGELOG.md"
@@ -11,7 +11,7 @@
11
11
  import http from "node:http";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
- import { spawn } from "node:child_process";
14
+ import { spawn, spawnSync } from "node:child_process";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import express from "express";
17
17
  import { Server as SocketServer } from "socket.io";
@@ -208,6 +208,15 @@ function neededPackages(env) {
208
208
  return want.filter((p) => !deps[p]);
209
209
  }
210
210
 
211
+ // Install a DB driver's package on demand (pinned), so the wizard's "Test
212
+ // connection" works before Apply has installed it.
213
+ function ensureDriverInstalled(driver) {
214
+ const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
215
+ if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
216
+ console.log(`[volt] installing ${pkg} for the connection test…`);
217
+ spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
218
+ }
219
+
211
220
  // --- the disposable setup wizard (localhost only) ---
212
221
  function startSetup() {
213
222
  const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
@@ -244,6 +253,7 @@ function startSetup() {
244
253
  if (env[k]) process.env[k] = env[k];
245
254
  else delete process.env[k];
246
255
  }
256
+ ensureDriverInstalled(process.env.DB_DRIVER); // install the driver first, so the test can connect
247
257
  const store = await (await addonMod("db")).createStore();
248
258
  await store.collection("__voltcheck").all();
249
259
  res.setHeader("Content-Type", "application/json");
@@ -12,7 +12,7 @@ import http from "node:http";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import crypto from "node:crypto";
15
- import { spawn } from "node:child_process";
15
+ import { spawn, spawnSync } from "node:child_process";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
17
  import express from "express";
18
18
  import { Server as SocketServer } from "socket.io";
@@ -234,6 +234,15 @@ function neededPackages(env) {
234
234
  return want.filter((p) => !deps[p]);
235
235
  }
236
236
 
237
+ // Install a DB driver's package on demand (pinned), so the wizard's "Test
238
+ // connection" works before Apply has installed it.
239
+ function ensureDriverInstalled(driver) {
240
+ const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
241
+ if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
242
+ console.log(`[volt] installing ${pkg} for the connection test…`);
243
+ spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
244
+ }
245
+
237
246
  // --- the disposable setup wizard (localhost only) ---
238
247
  function startSetup() {
239
248
  const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
@@ -270,6 +279,7 @@ function startSetup() {
270
279
  if (env[k]) process.env[k] = env[k];
271
280
  else delete process.env[k];
272
281
  }
282
+ ensureDriverInstalled(process.env.DB_DRIVER); // install the driver first, so the test can connect
273
283
  const store = await (await addonMod("db")).createStore();
274
284
  await store.collection("__voltcheck").all();
275
285
  res.setHeader("Content-Type", "application/json");