create-volt 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +24 -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/config/config-app.js +168 -0
- package/config/index.html +29 -0
- package/index.js +192 -5
- package/package.json +3 -1
- package/templates/default/README.md +7 -1
- package/templates/default/server.js +189 -40
- package/templates/default/setup/index.html +27 -0
- package/templates/default/setup/setup.js +121 -0
- package/templates/guestbook/server.js +12 -0
|
@@ -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";
|
|
@@ -35,12 +38,15 @@ ${bold("Usage")}
|
|
|
35
38
|
npm create volt@latest <project-directory> [options]
|
|
36
39
|
npx create-volt <project-directory> [options]
|
|
37
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
|
|
38
42
|
|
|
39
43
|
${bold("Options")}
|
|
40
44
|
--template <name> Starter template: default | guestbook (default: default)
|
|
41
45
|
--port <number> Dev port for the app (default: derived from today's date)
|
|
42
46
|
--skip-install Don't run the package manager install step
|
|
43
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
|
|
44
50
|
--dry-run Show what would be created without writing anything
|
|
45
51
|
--force Scaffold into an existing non-empty directory
|
|
46
52
|
-h, --help Show this help
|
|
@@ -57,12 +63,15 @@ const flags = new Set();
|
|
|
57
63
|
const positionals = [];
|
|
58
64
|
let portArg = null;
|
|
59
65
|
let templateArg = null;
|
|
66
|
+
let hostArg = null;
|
|
60
67
|
for (let i = 0; i < argv.length; i++) {
|
|
61
68
|
const a = argv[i];
|
|
62
69
|
if (a === "--port") portArg = argv[++i];
|
|
63
70
|
else if (a.startsWith("--port=")) portArg = a.slice("--port=".length);
|
|
64
71
|
else if (a === "--template") templateArg = argv[++i];
|
|
65
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);
|
|
66
75
|
else if (a.startsWith("-")) flags.add(a);
|
|
67
76
|
else positionals.push(a);
|
|
68
77
|
}
|
|
@@ -81,6 +90,11 @@ const force = flags.has("--force");
|
|
|
81
90
|
const dryRun = flags.has("--dry-run");
|
|
82
91
|
const noGit = flags.has("--no-git");
|
|
83
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
|
+
|
|
84
98
|
// --- `update` subcommand: refresh public/volt.js in the current app to the
|
|
85
99
|
// version bundled with this create-volt (so `npx create-volt@latest update`
|
|
86
100
|
// pulls the latest library). Only touches the library file — never the user's
|
|
@@ -107,6 +121,165 @@ if (positionals[0] === "update") {
|
|
|
107
121
|
process.exit(0);
|
|
108
122
|
}
|
|
109
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
|
+
|
|
110
283
|
// Resolve the dev port: --port wins, else derive it from today's date as
|
|
111
284
|
// two-digit-year + month (no leading zero) + two-digit-day (house convention),
|
|
112
285
|
// so apps scaffolded on different days don't collide on the same port.
|
|
@@ -214,10 +387,10 @@ fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
|
|
|
214
387
|
|
|
215
388
|
// --- stamp the chosen dev port into server.js + README ---
|
|
216
389
|
const serverPath = path.join(targetDir, "server.js");
|
|
217
|
-
fs.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
);
|
|
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);
|
|
221
394
|
const appReadme = path.join(targetDir, "README.md");
|
|
222
395
|
if (fs.existsSync(appReadme)) {
|
|
223
396
|
fs.writeFileSync(appReadme, fs.readFileSync(appReadme, "utf8").replace(/localhost:\d+/g, `localhost:${port}`));
|
|
@@ -287,4 +460,18 @@ console.log(`\n${green("✔")} ${bold("Done!")} Next steps:\n`);
|
|
|
287
460
|
console.log(` ${cyan("cd")} ${projectName}`);
|
|
288
461
|
if (!installed) console.log(` ${cyan(installCmd)}`);
|
|
289
462
|
console.log(` ${cyan(runCmd)}`);
|
|
290
|
-
console.log(`\
|
|
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
|
+
"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": {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
11
|
"templates",
|
|
12
|
+
"addons",
|
|
13
|
+
"config",
|
|
12
14
|
"CHANGELOG.md"
|
|
13
15
|
],
|
|
14
16
|
"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
|
|