create-volt 0.8.0 → 0.10.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 +40 -0
- package/README.md +13 -11
- package/addons/auth/files/lib/auth.js +1 -1
- package/addons/auth/files/public/auth-ui.js +62 -0
- package/addons/realtime/files/public/chat-ui.js +116 -0
- package/index.js +18 -161
- package/package.json +1 -2
- package/templates/default/public/app.js +24 -1
- package/templates/default/server.js +129 -33
- package/templates/default/setup/setup.js +84 -45
- package/config/config-app.js +0 -168
- package/config/index.html +0 -29
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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.10.0] - 2026-06-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Frontend UI for the user-facing add-ons, auto-mounted when enabled:
|
|
11
|
+
- **auth** → a magic-link sign-in panel (email → link → signed-in state)
|
|
12
|
+
- **realtime** → a live chat panel (rooms, presence, typing, messages)
|
|
13
|
+
Each add-on serves its own `/<name>-ui.js` (only when enabled); `public/app.js`
|
|
14
|
+
mounts them alongside the demo. The server exposes `GET /__volt/addons`.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
- Security headers on every response: `X-Content-Type-Options: nosniff`,
|
|
18
|
+
`X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: same-origin`, `X-Powered-By` off.
|
|
19
|
+
- Hardened forms: typed / length-capped / autocompleted inputs; all user content
|
|
20
|
+
renders through Volt holes (text nodes — HTML-escaped, no innerHTML); server
|
|
21
|
+
validation + caps (email ≤ 320, chat ≤ 500); `.env` values stripped of newlines;
|
|
22
|
+
session cookies `HttpOnly` + `SameSite=Lax`.
|
|
23
|
+
|
|
24
|
+
## [0.9.0] - 2026-06-28
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- The setup wizard is now the single place to configure an app, and it shows
|
|
28
|
+
**all** add-ons: tick db/auth/realtime/mailer + fill their settings. Enabling
|
|
29
|
+
is pure config — **Apply writes `.env`** (a `VOLT_ADDONS` list + settings) and
|
|
30
|
+
**adds any needed packages to `package.json` + runs `npm install`**, then
|
|
31
|
+
starts the app, which **auto-wires** whatever `.env` enables (auth routes,
|
|
32
|
+
realtime sockets, db). Add-on code ships bundled under `.volt/addons`; nothing
|
|
33
|
+
is copied into your `lib/`.
|
|
34
|
+
- `create-volt config` now just opens that in-app wizard (`server.js --edit`) —
|
|
35
|
+
one implementation, localhost-only (shell/SSH access is the auth).
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
- The standalone create-volt config page and its `--host`/key flags (superseded
|
|
39
|
+
by the in-app wizard, which is localhost-only + SSH-tunnel for remote).
|
|
40
|
+
|
|
41
|
+
### Note
|
|
42
|
+
- Backend of an enabled add-on is wired automatically; the frontend UI (login
|
|
43
|
+
form, chat) is yours to build — or start from `--template guestbook`.
|
|
44
|
+
|
|
7
45
|
## [0.8.0] - 2026-06-28
|
|
8
46
|
|
|
9
47
|
### Added
|
|
@@ -108,6 +146,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
108
146
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
109
147
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
110
148
|
|
|
149
|
+
[0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
|
|
150
|
+
[0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
|
|
111
151
|
[0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
|
|
112
152
|
[0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
|
|
113
153
|
[0.6.0]: https://github.com/MIR-2025/volt/releases/tag/v0.6.0
|
package/README.md
CHANGED
|
@@ -42,27 +42,29 @@ Edit `public/app.js` and save — the page hot-reloads itself.
|
|
|
42
42
|
|
|
43
43
|
## Add-on integrations
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`.env`. Run from the app directory:
|
|
45
|
+
Apps ship with the add-ons **bundled** (under `.volt/addons`) but off. The
|
|
46
|
+
**setup wizard** turns them on — it opens on first run, or anytime with:
|
|
48
47
|
|
|
49
48
|
```bash
|
|
50
|
-
npx create-volt
|
|
49
|
+
npm run dev -- --edit # or: npx create-volt config
|
|
51
50
|
```
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
Tick the features you want, fill in their settings, and **Apply**. Enabling is
|
|
53
|
+
pure config: it writes `.env` (a `VOLT_ADDONS` list + settings) and adds any
|
|
54
|
+
needed packages to `package.json` + runs `npm install`. The app then auto-wires
|
|
55
|
+
whatever's enabled. Available add-ons:
|
|
55
56
|
|
|
56
57
|
| Add-on | What it gives you |
|
|
57
58
|
| ---------- | ------------------------------------------------------------------- |
|
|
58
59
|
| `db` | document store: memory / MongoDB / MySQL / Postgres, one interface |
|
|
59
60
|
| `mailer` | console (dev) / SMTP (prod) email |
|
|
60
|
-
| `auth` | magic-link login + sessions (
|
|
61
|
-
| `realtime` | Socket.io chat: rooms, presence, typing (
|
|
61
|
+
| `auth` | magic-link login + sessions (pulls in db + mailer) |
|
|
62
|
+
| `realtime` | Socket.io chat: rooms, presence, typing (pulls in db) |
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
The wizard is localhost-only (shell/SSH access is the auth; it prints an
|
|
65
|
+
SSH-tunnel hint on a remote box). Enabling an add-on wires its **backend**
|
|
66
|
+
automatically — the **frontend** UI (login form, chat) is yours to build, or
|
|
67
|
+
start from `--template guestbook`, which has it wired end-to-end.
|
|
66
68
|
|
|
67
69
|
## Updating Volt
|
|
68
70
|
|
|
@@ -72,7 +72,7 @@ export function authRouter({ store, mailer }) {
|
|
|
72
72
|
|
|
73
73
|
router.post("/api/login", async (req, res) => {
|
|
74
74
|
const email = normalize(req.body?.email);
|
|
75
|
-
if (!validEmail(email)) return res.status(400).json({ ok: false, error: "Enter a valid email." });
|
|
75
|
+
if (!validEmail(email) || email.length > 320) return res.status(400).json({ ok: false, error: "Enter a valid email." });
|
|
76
76
|
const tok = token();
|
|
77
77
|
await tokens.put(tok, { email, ua: req.headers["user-agent"] || "", expiresAt: Date.now() + TOKEN_TTL, used: false });
|
|
78
78
|
const link = `${req.protocol}://${req.get("host")}/verify?token=${tok}`;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// auth-ui.js — magic-link sign-in panel (frontend for the auth add-on).
|
|
2
|
+
// Served at /auth-ui.js when auth is enabled; mounted by public/app.js.
|
|
3
|
+
// All dynamic text is rendered through Volt holes, which create text nodes
|
|
4
|
+
// (HTML-escaped) — so emails/notices can't inject markup.
|
|
5
|
+
import { signal, html } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
const api = async (url, body) => {
|
|
8
|
+
const res = await fetch(url, {
|
|
9
|
+
method: body ? "POST" : "GET",
|
|
10
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
11
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
12
|
+
});
|
|
13
|
+
return res.json().catch(() => ({}));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// local part only, for a friendlier "signed in as" (still escaped on render)
|
|
17
|
+
const shortName = (email) => String(email || "").split("@")[0];
|
|
18
|
+
|
|
19
|
+
export function authPanel() {
|
|
20
|
+
const me = signal(null);
|
|
21
|
+
const email = signal("");
|
|
22
|
+
const notice = signal("");
|
|
23
|
+
const busy = signal(false);
|
|
24
|
+
|
|
25
|
+
api("/api/me").then((r) => me(r.email || null));
|
|
26
|
+
|
|
27
|
+
async function sendLink(e) {
|
|
28
|
+
e?.preventDefault?.();
|
|
29
|
+
const addr = email().trim();
|
|
30
|
+
if (!addr || busy()) return;
|
|
31
|
+
busy(true);
|
|
32
|
+
notice("Sending…");
|
|
33
|
+
const r = await api("/api/login", { email: addr });
|
|
34
|
+
busy(false);
|
|
35
|
+
notice(r.ok ? (r.dev ? "Magic link printed to the server console — open it to sign in." : `Magic link sent to ${addr}.`) : r.error || "Could not send link.");
|
|
36
|
+
}
|
|
37
|
+
async function logout() {
|
|
38
|
+
await api("/api/logout", {});
|
|
39
|
+
me(null);
|
|
40
|
+
notice("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const signedOut = () =>
|
|
44
|
+
html`<form class="d-flex gap-2" onsubmit=${sendLink} autocomplete="on">
|
|
45
|
+
<input class="form-control" type="email" name="email" placeholder="you@example.com"
|
|
46
|
+
maxlength="320" autocomplete="email" inputmode="email" required
|
|
47
|
+
value=${email} oninput=${(e) => email(e.target.value)} />
|
|
48
|
+
<button class="btn btn-primary" type="submit" disabled=${() => busy()}>Send magic link</button>
|
|
49
|
+
</form>`;
|
|
50
|
+
|
|
51
|
+
const signedIn = () =>
|
|
52
|
+
html`<div class="d-flex justify-content-between align-items-center">
|
|
53
|
+
<span class="text-muted small">Signed in as <span class="accent fw-semibold">${() => shortName(me())}</span></span>
|
|
54
|
+
<button class="btn btn-sm btn-outline-secondary" onclick=${logout}>Sign out</button>
|
|
55
|
+
</div>`;
|
|
56
|
+
|
|
57
|
+
return html`<div class="card-x p-4 mb-4">
|
|
58
|
+
<h2 class="h6 mb-3">Account <span class="text-muted small">— magic-link auth</span></h2>
|
|
59
|
+
${() => (me() ? signedIn() : signedOut())}
|
|
60
|
+
${() => (notice() ? html`<p class="small text-muted mb-0 mt-2">${notice}</p>` : null)}
|
|
61
|
+
</div>`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// chat-ui.js — live chat panel (frontend for the realtime add-on). Served at
|
|
2
|
+
// /chat-ui.js when realtime is enabled; mounted by public/app.js.
|
|
3
|
+
// Identity comes from the auth session (if auth is on), else "guest-…".
|
|
4
|
+
// All message text and names render through Volt holes → text nodes (escaped),
|
|
5
|
+
// so nothing a user types can inject markup.
|
|
6
|
+
import { signal, computed, html } from "/volt.js";
|
|
7
|
+
import { createChat } from "/chat-client.js";
|
|
8
|
+
|
|
9
|
+
const ROOMS = ["general", "random"];
|
|
10
|
+
const MAX = 500; // keep in sync with the server cap
|
|
11
|
+
|
|
12
|
+
const fmtTime = (ts) => {
|
|
13
|
+
try {
|
|
14
|
+
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
15
|
+
} catch {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function chatPanel() {
|
|
21
|
+
const room = signal(ROOMS[0]);
|
|
22
|
+
const messages = signal([]);
|
|
23
|
+
const online = signal([]);
|
|
24
|
+
const typers = signal([]);
|
|
25
|
+
const draft = signal("");
|
|
26
|
+
|
|
27
|
+
const timers = new Map();
|
|
28
|
+
const sawTyping = (name) => {
|
|
29
|
+
clearTimeout(timers.get(name));
|
|
30
|
+
if (!typers().includes(name)) typers([...typers(), name]);
|
|
31
|
+
timers.set(
|
|
32
|
+
name,
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
typers(typers().filter((n) => n !== name));
|
|
35
|
+
timers.delete(name);
|
|
36
|
+
}, 2500),
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let chat;
|
|
41
|
+
try {
|
|
42
|
+
chat = createChat({
|
|
43
|
+
onHistory: ({ room: r, messages: m }) => {
|
|
44
|
+
if (r === room()) messages(m);
|
|
45
|
+
},
|
|
46
|
+
onMessage: (m) => {
|
|
47
|
+
if (m.room === room()) messages([...messages(), m]);
|
|
48
|
+
},
|
|
49
|
+
onPresence: ({ room: r, users }) => {
|
|
50
|
+
if (r === room()) online(users);
|
|
51
|
+
},
|
|
52
|
+
onTyping: ({ name }) => sawTyping(name),
|
|
53
|
+
});
|
|
54
|
+
chat.join(room());
|
|
55
|
+
} catch {
|
|
56
|
+
/* socket.io client missing — panel still renders, just inert */
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const switchRoom = (r) => {
|
|
60
|
+
if (r === room() || !chat) return;
|
|
61
|
+
room(r);
|
|
62
|
+
messages([]);
|
|
63
|
+
online([]);
|
|
64
|
+
typers([]);
|
|
65
|
+
chat.join(r);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let lastTyped = 0;
|
|
69
|
+
const onType = (e) => {
|
|
70
|
+
draft(e.target.value);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (chat && now - lastTyped > 800) {
|
|
73
|
+
chat.typing(room());
|
|
74
|
+
lastTyped = now;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const send = () => {
|
|
78
|
+
const body = draft().trim();
|
|
79
|
+
if (!body || !chat) return;
|
|
80
|
+
chat.send(room(), body.slice(0, MAX));
|
|
81
|
+
draft("");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const typingLine = computed(() => {
|
|
85
|
+
const t = typers().filter((n) => n);
|
|
86
|
+
if (!t.length) return "";
|
|
87
|
+
return t.length === 1 ? `${t[0]} is typing…` : `${t.length} people are typing…`;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const roomTab = (r) =>
|
|
91
|
+
html`<button class=${() => "btn btn-sm " + (room() === r ? "btn-primary" : "btn-outline-secondary")} onclick=${() => switchRoom(r)}>#${r}</button>`;
|
|
92
|
+
|
|
93
|
+
const messageRow = (m) =>
|
|
94
|
+
html`<div class="py-1 small">
|
|
95
|
+
<span class="accent fw-semibold">${m.name}</span>
|
|
96
|
+
<span class="text-muted ms-1">${fmtTime(m.createdAt)}</span>
|
|
97
|
+
<div>${m.body}</div>
|
|
98
|
+
</div>`;
|
|
99
|
+
|
|
100
|
+
return html`<div class="card-x p-4 mb-4">
|
|
101
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
102
|
+
<h2 class="h6 mb-0">Chat <span class="text-muted small">— realtime</span></h2>
|
|
103
|
+
<div class="d-flex gap-1">${ROOMS.map(roomTab)}</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="small text-muted mb-2">Online: ${() => online().join(", ") || "—"}</div>
|
|
106
|
+
<div style="max-height:220px;overflow:auto">
|
|
107
|
+
${() => (messages().length ? messages().map(messageRow) : html`<span class="text-muted small">No messages yet.</span>`)}
|
|
108
|
+
</div>
|
|
109
|
+
<div style="height:1.2em">${() => (typingLine() ? html`<span class="text-muted small fst-italic">${typingLine}</span>` : null)}</div>
|
|
110
|
+
<div class="input-group mt-2">
|
|
111
|
+
<input class="form-control" placeholder=${() => "Message #" + room() + "…"} maxlength=${String(MAX)}
|
|
112
|
+
value=${draft} oninput=${onType} onkeydown=${(e) => e.key === "Enter" && send()} />
|
|
113
|
+
<button class="btn btn-primary" onclick=${send}>Send</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>`;
|
|
116
|
+
}
|
package/index.js
CHANGED
|
@@ -10,9 +10,6 @@
|
|
|
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";
|
|
16
13
|
import { spawnSync } from "node:child_process";
|
|
17
14
|
import { fileURLToPath } from "node:url";
|
|
18
15
|
import { createRequire } from "node:module";
|
|
@@ -38,7 +35,7 @@ ${bold("Usage")}
|
|
|
38
35
|
npm create volt@latest <project-directory> [options]
|
|
39
36
|
npx create-volt <project-directory> [options]
|
|
40
37
|
npx create-volt@latest update # refresh public/volt.js in an existing app
|
|
41
|
-
npx create-volt@latest config #
|
|
38
|
+
npx create-volt@latest config # open the app's setup wizard (edit add-ons + settings)
|
|
42
39
|
|
|
43
40
|
${bold("Options")}
|
|
44
41
|
--template <name> Starter template: default | guestbook (default: default)
|
|
@@ -63,15 +60,12 @@ const flags = new Set();
|
|
|
63
60
|
const positionals = [];
|
|
64
61
|
let portArg = null;
|
|
65
62
|
let templateArg = null;
|
|
66
|
-
let hostArg = null;
|
|
67
63
|
for (let i = 0; i < argv.length; i++) {
|
|
68
64
|
const a = argv[i];
|
|
69
65
|
if (a === "--port") portArg = argv[++i];
|
|
70
66
|
else if (a.startsWith("--port=")) portArg = a.slice("--port=".length);
|
|
71
67
|
else if (a === "--template") templateArg = argv[++i];
|
|
72
68
|
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);
|
|
75
69
|
else if (a.startsWith("-")) flags.add(a);
|
|
76
70
|
else positionals.push(a);
|
|
77
71
|
}
|
|
@@ -121,163 +115,20 @@ if (positionals[0] === "update") {
|
|
|
121
115
|
process.exit(0);
|
|
122
116
|
}
|
|
123
117
|
|
|
124
|
-
// --- `config` subcommand:
|
|
125
|
-
//
|
|
118
|
+
// --- `config` subcommand: open the app's setup wizard to edit add-ons/settings.
|
|
119
|
+
// One implementation — delegates to the in-app wizard via `server.js --edit`. ---
|
|
126
120
|
if (positionals[0] === "config") {
|
|
127
121
|
const cwd = process.cwd();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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`);
|
|
122
|
+
if (!fs.existsSync(path.join(cwd, "server.js"))) {
|
|
123
|
+
die(`No ${cyan("server.js")} here — run ${cyan("create-volt config")} from inside a Volt app.`);
|
|
124
|
+
}
|
|
125
|
+
if (portArg) process.env.PORT = String(Number(portArg) || "");
|
|
126
|
+
const res = spawnSync(process.execPath, ["server.js", "--edit"], {
|
|
127
|
+
cwd,
|
|
128
|
+
stdio: "inherit",
|
|
129
|
+
env: flags.has("--no-open") ? { ...process.env, VOLT_NO_OPEN: "1" } : process.env,
|
|
279
130
|
});
|
|
280
|
-
|
|
131
|
+
process.exit(res.status ?? 0);
|
|
281
132
|
}
|
|
282
133
|
|
|
283
134
|
// Resolve the dev port: --port wins, else derive it from today's date as
|
|
@@ -379,6 +230,12 @@ if (fs.existsSync(shippedGitignore)) {
|
|
|
379
230
|
fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
|
|
380
231
|
}
|
|
381
232
|
|
|
233
|
+
// Bundle the add-on sources so the app's setup wizard can enable them later
|
|
234
|
+
// (only for templates that ship the wizard, i.e. have a setup/ dir).
|
|
235
|
+
if (fs.existsSync(path.join(targetDir, "setup"))) {
|
|
236
|
+
fs.cpSync(path.join(__dirname, "addons"), path.join(targetDir, ".volt", "addons"), { recursive: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
382
239
|
// --- stamp the project name into package.json ---
|
|
383
240
|
const appPkgPath = path.join(targetDir, "package.json");
|
|
384
241
|
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-volt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"index.js",
|
|
11
11
|
"templates",
|
|
12
12
|
"addons",
|
|
13
|
-
"config",
|
|
14
13
|
"CHANGELOG.md"
|
|
15
14
|
],
|
|
16
15
|
"engines": {
|
|
@@ -63,4 +63,27 @@ function Todos() {
|
|
|
63
63
|
</div>`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
// Mount the demo, plus the UI for any enabled add-ons (auth, realtime, …).
|
|
67
|
+
// Add-ons serve their own /…-ui.js when turned on in the setup wizard.
|
|
68
|
+
const nodes = [Counter(), Todos()];
|
|
69
|
+
let enabled = [];
|
|
70
|
+
try {
|
|
71
|
+
enabled = await (await fetch("/__volt/addons")).json();
|
|
72
|
+
} catch {
|
|
73
|
+
/* older app without the endpoint — just the demo */
|
|
74
|
+
}
|
|
75
|
+
if (enabled.includes("auth")) {
|
|
76
|
+
try {
|
|
77
|
+
nodes.unshift((await import("/auth-ui.js")).authPanel());
|
|
78
|
+
} catch {
|
|
79
|
+
/* auth UI unavailable */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (enabled.includes("realtime")) {
|
|
83
|
+
try {
|
|
84
|
+
nodes.push((await import("/chat-ui.js")).chatPanel());
|
|
85
|
+
} catch {
|
|
86
|
+
/* realtime UI unavailable */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
mount("#app", ...nodes);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// server.js — dev server with a built-in first-run setup wizard.
|
|
2
2
|
//
|
|
3
3
|
// First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
|
|
4
|
-
// config page
|
|
5
|
-
//
|
|
4
|
+
// config page: tick add-ons, fill settings, Apply. Apply writes .env (a
|
|
5
|
+
// VOLT_ADDONS list + settings) and adds any needed packages to package.json,
|
|
6
|
+
// runs npm install, then starts the app — which wires whatever .env enables.
|
|
7
|
+
// Add-on code is bundled under .volt/addons; nothing is copied into your code.
|
|
6
8
|
//
|
|
7
9
|
// No build step, no env-file flag: .env is auto-loaded below.
|
|
8
10
|
|
|
@@ -16,7 +18,11 @@ import { Server as SocketServer } from "socket.io";
|
|
|
16
18
|
|
|
17
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
const ENV_PATH = path.join(__dirname, ".env");
|
|
21
|
+
const PKG_PATH = path.join(__dirname, "package.json");
|
|
22
|
+
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
19
23
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
24
|
+
const PKG_VERSIONS = { mongodb: "^6.8.0", mysql2: "^3.11.0", pg: "^8.12.0", nodemailer: "^6.9.0" };
|
|
25
|
+
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js" };
|
|
20
26
|
|
|
21
27
|
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
22
28
|
function readEnvFile() {
|
|
@@ -32,16 +38,34 @@ function loadEnv() {
|
|
|
32
38
|
for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
// Add-ons available to enable (bundled under .volt/addons by create-volt).
|
|
42
|
+
function availableAddons() {
|
|
43
|
+
if (!fs.existsSync(ADDONS_DIR)) return [];
|
|
44
|
+
return fs
|
|
45
|
+
.readdirSync(ADDONS_DIR, { withFileTypes: true })
|
|
46
|
+
.filter((e) => e.isDirectory())
|
|
47
|
+
.map((e) => {
|
|
48
|
+
const m = JSON.parse(fs.readFileSync(path.join(ADDONS_DIR, e.name, "meta.json"), "utf8"));
|
|
49
|
+
return { name: e.name, description: m.description, dependsOn: m.dependsOn || [] };
|
|
50
|
+
});
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
54
|
+
function enabledFrom(env) {
|
|
55
|
+
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
56
|
+
const out = new Set();
|
|
57
|
+
const visit = (n) => {
|
|
58
|
+
if (!metas[n] || out.has(n)) return;
|
|
59
|
+
out.add(n);
|
|
60
|
+
for (const d of metas[n].dependsOn) visit(d);
|
|
61
|
+
};
|
|
62
|
+
for (const n of String(env.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean)) visit(n);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
|
|
67
|
+
const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
|
|
68
|
+
|
|
45
69
|
function openBrowser(url) {
|
|
46
70
|
if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
|
|
47
71
|
const plat = process.platform;
|
|
@@ -50,7 +74,7 @@ function openBrowser(url) {
|
|
|
50
74
|
const args = plat === "win32" ? ["/c", "start", "", url] : [url];
|
|
51
75
|
try {
|
|
52
76
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
53
|
-
child.on("error", () => {}); // launcher missing
|
|
77
|
+
child.on("error", () => {}); // launcher missing — emits async, don't crash
|
|
54
78
|
child.unref();
|
|
55
79
|
return true;
|
|
56
80
|
} catch {
|
|
@@ -58,17 +82,39 @@ function openBrowser(url) {
|
|
|
58
82
|
}
|
|
59
83
|
}
|
|
60
84
|
|
|
61
|
-
// --- the actual app ---
|
|
62
|
-
function startApp() {
|
|
85
|
+
// --- the actual app: wires whichever add-ons .env enables ---
|
|
86
|
+
async function startApp() {
|
|
63
87
|
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
88
|
+
const enabled = enabledFrom(process.env);
|
|
64
89
|
const app = express();
|
|
90
|
+
app.disable("x-powered-by");
|
|
91
|
+
app.use((_req, res, next) => {
|
|
92
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
93
|
+
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
94
|
+
res.setHeader("Referrer-Policy", "same-origin");
|
|
95
|
+
next();
|
|
96
|
+
});
|
|
65
97
|
app.use(express.static(path.join(__dirname, "public")));
|
|
98
|
+
|
|
99
|
+
let store = null;
|
|
100
|
+
let mailer = null;
|
|
101
|
+
if (enabled.has("db")) store = await (await addonMod("db")).createStore();
|
|
102
|
+
if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
|
|
103
|
+
if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
|
|
104
|
+
|
|
105
|
+
// expose which add-ons are on, and serve each enabled add-on's frontend assets
|
|
106
|
+
app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
|
|
107
|
+
for (const n of enabled) {
|
|
108
|
+
const pub = path.join(ADDONS_DIR, n, "files", "public");
|
|
109
|
+
if (fs.existsSync(pub)) app.use(express.static(pub));
|
|
110
|
+
}
|
|
111
|
+
|
|
66
112
|
app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
|
|
67
113
|
|
|
68
114
|
const server = http.createServer(app);
|
|
69
115
|
const io = new SocketServer(server);
|
|
116
|
+
if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
|
|
70
117
|
|
|
71
|
-
// hot reload: watch views/ + public/, debounce, broadcast a reload
|
|
72
118
|
let timer = null;
|
|
73
119
|
const onChange = (file) => {
|
|
74
120
|
clearTimeout(timer);
|
|
@@ -82,7 +128,7 @@ function startApp() {
|
|
|
82
128
|
fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
|
|
83
129
|
return;
|
|
84
130
|
} catch {
|
|
85
|
-
/*
|
|
131
|
+
/* per-dir fallback */
|
|
86
132
|
}
|
|
87
133
|
const w = (d) => {
|
|
88
134
|
try {
|
|
@@ -96,12 +142,26 @@ function startApp() {
|
|
|
96
142
|
};
|
|
97
143
|
for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
|
|
98
144
|
|
|
99
|
-
|
|
145
|
+
const on = [...enabled];
|
|
146
|
+
server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Packages an .env's selections need, beyond what package.json already has.
|
|
150
|
+
function neededPackages(env) {
|
|
151
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
|
|
152
|
+
const deps = pkg.dependencies || {};
|
|
153
|
+
const want = [];
|
|
154
|
+
const driver = (env.match(/^\s*DB_DRIVER\s*=\s*(\w+)/m) || [])[1];
|
|
155
|
+
if (driver === "mongodb") want.push("mongodb");
|
|
156
|
+
if (driver === "mysql") want.push("mysql2");
|
|
157
|
+
if (driver === "postgres") want.push("pg");
|
|
158
|
+
if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
|
|
159
|
+
return want.filter((p) => !deps[p]);
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
// --- the disposable setup wizard (localhost only) ---
|
|
103
163
|
function startSetup() {
|
|
104
|
-
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
164
|
+
const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
|
|
105
165
|
const assets = {
|
|
106
166
|
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
107
167
|
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
@@ -121,7 +181,7 @@ function startSetup() {
|
|
|
121
181
|
}
|
|
122
182
|
if (req.method === "GET" && p === "/setup/state") {
|
|
123
183
|
res.setHeader("Content-Type", "application/json");
|
|
124
|
-
return res.end(JSON.stringify({
|
|
184
|
+
return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
|
|
125
185
|
}
|
|
126
186
|
if (req.method === "POST" && p === "/setup/test-db") {
|
|
127
187
|
let body = "";
|
|
@@ -135,9 +195,8 @@ function startSetup() {
|
|
|
135
195
|
if (env[k]) process.env[k] = env[k];
|
|
136
196
|
else delete process.env[k];
|
|
137
197
|
}
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
await store.collection("__voltcheck").all(); // actually touch the connection
|
|
198
|
+
const store = await (await addonMod("db")).createStore();
|
|
199
|
+
await store.collection("__voltcheck").all();
|
|
141
200
|
res.setHeader("Content-Type", "application/json");
|
|
142
201
|
res.end(JSON.stringify({ ok: true, driver: store.name }));
|
|
143
202
|
} catch (e) {
|
|
@@ -159,20 +218,54 @@ function startSetup() {
|
|
|
159
218
|
try {
|
|
160
219
|
const { env } = JSON.parse(body);
|
|
161
220
|
if (typeof env !== "string") throw new Error("missing env");
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
221
|
+
|
|
222
|
+
// 1) write .env, preserving any custom keys the form doesn't manage
|
|
223
|
+
let finalEnv = env;
|
|
224
|
+
if (fs.existsSync(ENV_PATH)) {
|
|
225
|
+
const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
|
|
226
|
+
const extra = readEnvFileLines().filter((line) => {
|
|
227
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
|
|
228
|
+
return m && !managed.has(m[1]);
|
|
229
|
+
});
|
|
230
|
+
if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
|
|
231
|
+
}
|
|
232
|
+
fs.writeFileSync(ENV_PATH, finalEnv);
|
|
233
|
+
|
|
234
|
+
// 2) declare any needed packages in package.json
|
|
235
|
+
const added = neededPackages(env);
|
|
236
|
+
if (added.length) {
|
|
237
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
|
|
238
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
239
|
+
for (const name of added) pkg.dependencies[name] = PKG_VERSIONS[name] || "latest";
|
|
240
|
+
fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
165
243
|
const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
|
|
166
244
|
const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
|
|
167
245
|
res.setHeader("Content-Type", "application/json");
|
|
168
|
-
res.end(JSON.stringify({ ok: true, port: newPort }));
|
|
169
|
-
|
|
246
|
+
res.end(JSON.stringify({ ok: true, port: newPort, installing: added }));
|
|
247
|
+
|
|
248
|
+
// 3) install (if needed), then hand off to the app
|
|
170
249
|
res.on("finish", () => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
250
|
+
const handoff = () => {
|
|
251
|
+
server.close(() => {
|
|
252
|
+
loadEnv();
|
|
253
|
+
startApp();
|
|
254
|
+
});
|
|
255
|
+
server.closeIdleConnections?.();
|
|
256
|
+
};
|
|
257
|
+
if (added.length) {
|
|
258
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
259
|
+
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
260
|
+
npm.on("error", () => handoff());
|
|
261
|
+
npm.on("close", () => {
|
|
262
|
+
console.log("[volt] saved .env — starting the app…");
|
|
263
|
+
handoff();
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
console.log("[volt] saved .env — starting the app…");
|
|
267
|
+
handoff();
|
|
268
|
+
}
|
|
176
269
|
});
|
|
177
270
|
} catch (e) {
|
|
178
271
|
res.statusCode = 400;
|
|
@@ -185,12 +278,11 @@ function startSetup() {
|
|
|
185
278
|
res.end("not found");
|
|
186
279
|
});
|
|
187
280
|
|
|
188
|
-
// localhost-only: an unauthenticated write endpoint shouldn't be on the network
|
|
189
281
|
server.listen(PORT, "127.0.0.1", () => {
|
|
190
282
|
const url = `http://localhost:${PORT}`;
|
|
191
283
|
console.log(`\n⚡ Volt setup → ${url}`);
|
|
192
284
|
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
193
|
-
const ssh = process.env.SSH_CONNECTION;
|
|
285
|
+
const ssh = process.env.SSH_CONNECTION;
|
|
194
286
|
if (ssh) {
|
|
195
287
|
const host = ssh.split(" ")[2];
|
|
196
288
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
@@ -203,6 +295,10 @@ function startSetup() {
|
|
|
203
295
|
});
|
|
204
296
|
}
|
|
205
297
|
|
|
298
|
+
function readEnvFileLines() {
|
|
299
|
+
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
300
|
+
}
|
|
301
|
+
|
|
206
302
|
// --- gate: setup on first run / --edit, otherwise the app ---
|
|
207
303
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
208
304
|
if (editMode || !fs.existsSync(ENV_PATH)) {
|
|
@@ -1,89 +1,121 @@
|
|
|
1
|
-
// setup.js —
|
|
2
|
-
//
|
|
1
|
+
// setup.js — first-run / --edit wizard, built with Volt. Tick add-ons + fill
|
|
2
|
+
// settings → writes .env (a VOLT_ADDONS list + settings), adds any needed
|
|
3
|
+
// packages, installs, and starts the app. Add-on code is bundled; enabling is
|
|
4
|
+
// just config.
|
|
3
5
|
import { signal, computed, html, mount } from "/volt.js";
|
|
4
6
|
|
|
5
|
-
const {
|
|
7
|
+
const { available, current, defaultPort } = await (await fetch("/setup/state")).json();
|
|
8
|
+
const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
|
|
9
|
+
const order = available.map((a) => a.name);
|
|
10
|
+
const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
6
11
|
|
|
7
12
|
const state = signal({
|
|
8
|
-
|
|
13
|
+
addons: Object.fromEntries(available.map((a) => [a.name, enabledNow.has(a.name)])),
|
|
9
14
|
dbDriver: current.DB_DRIVER || "memory",
|
|
10
15
|
mongoUri: current.MONGODB_URI || "",
|
|
11
16
|
mongoDb: current.MONGODB_DATABASE || "",
|
|
12
17
|
dbUrl: current.DATABASE_URL || "",
|
|
13
18
|
smtpUrl: current.SMTP_URL || "",
|
|
14
19
|
mailFrom: current.MAIL_FROM || "",
|
|
20
|
+
port: current.PORT || String(defaultPort),
|
|
15
21
|
});
|
|
16
22
|
const set = (patch) => state({ ...state(), ...patch });
|
|
23
|
+
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
17
24
|
const status = signal("");
|
|
18
25
|
|
|
26
|
+
// selected add-ons, dependencies expanded, in display order
|
|
27
|
+
function effective(s) {
|
|
28
|
+
const want = new Set();
|
|
29
|
+
const visit = (n) => {
|
|
30
|
+
if (want.has(n)) return;
|
|
31
|
+
want.add(n);
|
|
32
|
+
(depsOf[n] || []).forEach(visit);
|
|
33
|
+
};
|
|
34
|
+
for (const n of order) if (s.addons[n]) visit(n);
|
|
35
|
+
return order.filter((n) => want.has(n));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
|
|
19
39
|
function genEnv(s) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
40
|
+
const eff = effective(s);
|
|
41
|
+
const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
|
|
42
|
+
if (eff.includes("db")) {
|
|
43
|
+
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
23
44
|
if (s.dbDriver === "mongodb") {
|
|
24
|
-
out.push(`MONGODB_URI=${s.mongoUri}`);
|
|
25
|
-
if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
|
|
45
|
+
out.push(`MONGODB_URI=${clean(s.mongoUri)}`);
|
|
46
|
+
if (s.mongoDb) out.push(`MONGODB_DATABASE=${clean(s.mongoDb)}`);
|
|
26
47
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
27
|
-
out.push(`DATABASE_URL=${s.dbUrl}`);
|
|
48
|
+
out.push(`DATABASE_URL=${clean(s.dbUrl)}`);
|
|
28
49
|
}
|
|
29
50
|
}
|
|
30
|
-
if (
|
|
31
|
-
if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
|
|
51
|
+
if (eff.includes("mailer")) {
|
|
52
|
+
if (s.smtpUrl) out.push(`SMTP_URL=${clean(s.smtpUrl)}`);
|
|
32
53
|
else out.push("# SMTP_URL= # unset → emails print to the console");
|
|
33
|
-
if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
|
|
54
|
+
if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
|
|
34
55
|
}
|
|
35
56
|
return out.join("\n") + "\n";
|
|
36
57
|
}
|
|
37
58
|
const env = computed(() => genEnv(state()));
|
|
38
|
-
|
|
39
|
-
async function apply() {
|
|
40
|
-
status("Saving & starting…");
|
|
41
|
-
try {
|
|
42
|
-
const r = await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: env() }) });
|
|
43
|
-
const d = await r.json();
|
|
44
|
-
if (!d.ok) return status("Error: " + d.error);
|
|
45
|
-
status("Starting the app…");
|
|
46
|
-
const target = `http://localhost:${d.port}/`;
|
|
47
|
-
const go = async (n) => {
|
|
48
|
-
try {
|
|
49
|
-
await fetch(target, { mode: "no-cors" }); // app is listening
|
|
50
|
-
location.href = target;
|
|
51
|
-
} catch {
|
|
52
|
-
if (n > 0) setTimeout(() => go(n - 1), 400);
|
|
53
|
-
else location.href = target;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
setTimeout(() => go(20), 500);
|
|
57
|
-
} catch {
|
|
58
|
-
status("Network error — try again.");
|
|
59
|
-
}
|
|
60
|
-
}
|
|
59
|
+
const eff = computed(() => effective(state()));
|
|
61
60
|
|
|
62
61
|
async function testDb() {
|
|
63
62
|
const s = state();
|
|
64
|
-
const
|
|
63
|
+
const e = { DB_DRIVER: s.dbDriver };
|
|
65
64
|
if (s.dbDriver === "mongodb") {
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
e.MONGODB_URI = s.mongoUri;
|
|
66
|
+
e.MONGODB_DATABASE = s.mongoDb;
|
|
68
67
|
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
69
|
-
|
|
68
|
+
e.DATABASE_URL = s.dbUrl;
|
|
70
69
|
}
|
|
71
70
|
status("Testing connection…");
|
|
72
71
|
try {
|
|
73
|
-
const r = await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env }) });
|
|
74
|
-
|
|
75
|
-
status(d.ok ? `✓ Connected (${d.driver}).` : `✗ ${d.error}`);
|
|
72
|
+
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
73
|
+
status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
|
|
76
74
|
} catch {
|
|
77
75
|
status("Network error testing connection.");
|
|
78
76
|
}
|
|
79
77
|
}
|
|
80
78
|
|
|
79
|
+
async function apply() {
|
|
80
|
+
status("Saving…");
|
|
81
|
+
let d;
|
|
82
|
+
try {
|
|
83
|
+
d = await (await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ addons: eff(), env: env() }) })).json();
|
|
84
|
+
} catch {
|
|
85
|
+
return status("Network error.");
|
|
86
|
+
}
|
|
87
|
+
if (!d.ok) return status("Error: " + d.error);
|
|
88
|
+
status(d.installing?.length ? `Installing ${d.installing.join(", ")}, then starting…` : "Starting the app…");
|
|
89
|
+
const target = `http://localhost:${d.port}/`;
|
|
90
|
+
const tries = d.installing?.length ? 90 : 20; // npm install can take a while
|
|
91
|
+
const go = async (n) => {
|
|
92
|
+
try {
|
|
93
|
+
await fetch(target, { mode: "no-cors" });
|
|
94
|
+
location.href = target;
|
|
95
|
+
} catch {
|
|
96
|
+
if (n > 0) setTimeout(() => go(n - 1), 500);
|
|
97
|
+
else location.href = target;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
setTimeout(() => go(tries), 600);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- views ---
|
|
81
104
|
const field = (label, key, placeholder = "") =>
|
|
82
105
|
html`<div class="mb-2">
|
|
83
106
|
<label class="form-label small mb-1">${label}</label>
|
|
84
107
|
<input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
|
|
85
108
|
</div>`;
|
|
86
109
|
|
|
110
|
+
const addonRow = (a) =>
|
|
111
|
+
html`<div class="form-check mb-2">
|
|
112
|
+
<input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
|
|
113
|
+
<label class="form-check-label" for=${"x-" + a.name}>
|
|
114
|
+
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
|
|
115
|
+
<div class="small text-muted">${a.description}</div>
|
|
116
|
+
</label>
|
|
117
|
+
</div>`;
|
|
118
|
+
|
|
87
119
|
const dbSettings = () =>
|
|
88
120
|
html`<div class="mb-2">
|
|
89
121
|
<label class="form-label small mb-1">Database (DB_DRIVER)</label>
|
|
@@ -104,11 +136,18 @@ const dbSettings = () =>
|
|
|
104
136
|
|
|
105
137
|
mount(
|
|
106
138
|
"#app",
|
|
139
|
+
available.length
|
|
140
|
+
? html`<div class="card-x p-4 mb-3">
|
|
141
|
+
<h2 class="h6 mb-3">Features</h2>
|
|
142
|
+
${available.map(addonRow)}
|
|
143
|
+
<p class="small text-muted mb-0">Enabling a feature wires its backend automatically. Frontend UI (login form, chat) is yours to build — or start from <code>--template guestbook</code>.</p>
|
|
144
|
+
</div>`
|
|
145
|
+
: null,
|
|
107
146
|
html`<div class="card-x p-4 mb-3">
|
|
108
147
|
<h2 class="h6 mb-3">Settings</h2>
|
|
109
148
|
${field("PORT", "port", String(defaultPort))}
|
|
110
|
-
${() => (
|
|
111
|
-
${() => (
|
|
149
|
+
${() => (eff().includes("db") ? dbSettings() : null)}
|
|
150
|
+
${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
112
151
|
</div>`,
|
|
113
152
|
html`<div class="card-x p-4 mb-3">
|
|
114
153
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
package/config/config-app.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
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
|
-
);
|
package/config/index.html
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
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>
|