create-volt 0.54.0 → 0.55.1

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,23 @@ 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.55.1] - 2026-07-05
8
+
9
+ ### Security
10
+ - **Bump the scaffold's nodemailer pin `^6.10.1` → `^9.0.3`.** nodemailer <= 9.0.0
11
+ carries several high-severity advisories (email to an unintended domain, SMTP/CRLF
12
+ command injection, addressparser DoS, improper TLS validation, file-read/SSRF).
13
+ Apps that enable the mailer add-on now install the fixed 9.x — same
14
+ createTransport/sendMail/verify API, no code change.
15
+
16
+ ## [0.55.0] - 2026-07-05
17
+
18
+ ### Added
19
+ - **Inline "Test" buttons for SMTP + AI in the config wizard** — the result shows
20
+ right next to each button. `/setup/test-smtp` verifies connection/auth (nodemailer,
21
+ or a TCP reachability fallback); `/setup/test-ai` does a 1-token live call to the
22
+ provider key or the hosted gateway. The DB test result is inline now too.
23
+
7
24
  ## [0.54.0] - 2026-07-04
8
25
 
9
26
  ### Added
@@ -703,6 +720,8 @@ All notable changes to `create-volt` are documented here. The format follows
703
720
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
704
721
  and auto-detects npm / pnpm / yarn / bun for the install step.
705
722
 
723
+ [0.55.1]: https://github.com/MIR-2025/volt/releases/tag/v0.55.1
724
+ [0.55.0]: https://github.com/MIR-2025/volt/releases/tag/v0.55.0
706
725
  [0.54.0]: https://github.com/MIR-2025/volt/releases/tag/v0.54.0
707
726
  [0.53.0]: https://github.com/MIR-2025/volt/releases/tag/v0.53.0
708
727
  [0.52.0]: https://github.com/MIR-2025/volt/releases/tag/v0.52.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.54.0",
3
+ "version": "0.55.1",
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": {
@@ -42,7 +42,7 @@ function configPort() {
42
42
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
43
43
  return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
44
44
  }
45
- const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
45
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^9.0.3", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
46
46
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
47
47
 
48
48
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
@@ -564,6 +564,104 @@ function startSetup() {
564
564
  });
565
565
  return;
566
566
  }
567
+ // verify SMTP creds (form values merged over the saved .env) — auth check via
568
+ // nodemailer if available, else a TCP reachability check.
569
+ if (req.method === "POST" && p === "/setup/test-smtp") {
570
+ let body = "";
571
+ req.on("data", (c) => (body += c));
572
+ req.on("end", async () => {
573
+ res.setHeader("Content-Type", "application/json");
574
+ const done = (o) => res.end(JSON.stringify(o));
575
+ try {
576
+ const { env = {} } = JSON.parse(body || "{}");
577
+ const cfg = { ...readEnvFile(), ...env };
578
+ const url = cfg.SMTP_URL;
579
+ const host = cfg.SMTP_HOST;
580
+ if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
581
+ let nodemailer;
582
+ try {
583
+ nodemailer = (await import("nodemailer")).default;
584
+ } catch {
585
+ /* not installed */
586
+ }
587
+ if (nodemailer) {
588
+ const transport = url
589
+ ? nodemailer.createTransport(url)
590
+ : nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
591
+ await transport.verify();
592
+ return done({ ok: true, detail: "connection + auth OK" });
593
+ }
594
+ const net = await import("node:net");
595
+ let h = host;
596
+ let prt = Number(cfg.SMTP_PORT) || 587;
597
+ if (url) {
598
+ const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
599
+ h = u.hostname;
600
+ prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
601
+ }
602
+ await new Promise((resolve, reject) => {
603
+ const s = net.connect(prt, h, () => {
604
+ s.end();
605
+ resolve();
606
+ });
607
+ s.setTimeout(5000, () => {
608
+ s.destroy();
609
+ reject(new Error("timeout"));
610
+ });
611
+ s.on("error", reject);
612
+ });
613
+ done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
614
+ } catch (e) {
615
+ done({ ok: false, error: e.message });
616
+ }
617
+ });
618
+ return;
619
+ }
620
+ // verify the AI provider key (or gateway token) with a 1-token live call.
621
+ if (req.method === "POST" && p === "/setup/test-ai") {
622
+ let body = "";
623
+ req.on("data", (c) => (body += c));
624
+ req.on("end", async () => {
625
+ res.setHeader("Content-Type", "application/json");
626
+ const done = (o) => res.end(JSON.stringify(o));
627
+ try {
628
+ const { env = {} } = JSON.parse(body || "{}");
629
+ const cfg = { ...readEnvFile(), ...env };
630
+ const provider = cfg.AI_PROVIDER || "anthropic";
631
+ const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
632
+ let url, headers, payload, label;
633
+ if (key) {
634
+ label = provider + " key";
635
+ if (provider === "anthropic") {
636
+ url = "https://api.anthropic.com/v1/messages";
637
+ headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
638
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
639
+ } else if (provider === "openai") {
640
+ url = "https://api.openai.com/v1/chat/completions";
641
+ headers = { authorization: "Bearer " + key, "content-type": "application/json" };
642
+ payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
643
+ } else {
644
+ url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
645
+ headers = { "content-type": "application/json" };
646
+ payload = { contents: [{ parts: [{ text: "hi" }] }] };
647
+ }
648
+ } else if (cfg.VOLT_AI_TOKEN) {
649
+ label = "hosted gateway";
650
+ url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
651
+ headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
652
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
653
+ } else {
654
+ return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
655
+ }
656
+ const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
657
+ if (r.ok) return done({ ok: true, detail: `${label} works` });
658
+ done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
659
+ } catch (e) {
660
+ done({ ok: false, error: e.message });
661
+ }
662
+ });
663
+ return;
664
+ }
567
665
  if (req.method === "POST" && p === "/setup/apply") {
568
666
  let body = "";
569
667
  req.on("data", (c) => (body += c));
@@ -39,6 +39,12 @@ const state = signal({
39
39
  const set = (patch) => state({ ...state(), ...patch });
40
40
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
41
41
  const status = signal("");
42
+ // per-test inline results (shown right next to each Test button)
43
+ const dbTest = signal("");
44
+ const smtpTest = signal("");
45
+ const aiTest = signal("");
46
+ const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
+ const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
42
48
 
43
49
  // selected add-ons, dependencies expanded, in display order
44
50
  function effective(s) {
@@ -149,12 +155,30 @@ async function testDb() {
149
155
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
150
156
  e.DATABASE_URL = s.dbUrl;
151
157
  }
152
- status("Testing connection…");
158
+ dbTest("Testing…");
153
159
  try {
154
160
  const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
155
- status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
161
+ dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
156
162
  } catch {
157
- status("Network error testing connection.");
163
+ dbTest(" network error");
164
+ }
165
+ }
166
+ async function testSmtp() {
167
+ smtpTest("Testing…");
168
+ try {
169
+ const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
170
+ smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
171
+ } catch {
172
+ smtpTest("✗ network error");
173
+ }
174
+ }
175
+ async function testAi() {
176
+ aiTest("Testing…");
177
+ try {
178
+ const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
179
+ aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
180
+ } catch {
181
+ aiTest("✗ network error");
158
182
  }
159
183
  }
160
184
 
@@ -221,7 +245,7 @@ const dbSettings = () =>
221
245
  : dbDriver() === "mysql" || dbDriver() === "postgres"
222
246
  ? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
223
247
  : null}
224
- ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
248
+ ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
225
249
 
226
250
  const mediaSettings = () =>
227
251
  html`<div class="mb-2">
@@ -270,6 +294,7 @@ const aiSettings = () =>
270
294
  ${field("API key", "aiKey", "sk-…")}
271
295
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
272
296
  ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
273
298
  </div>
274
299
  </details>`;
275
300
 
@@ -417,7 +442,7 @@ const configView = () =>
417
442
  ${field("SITE_NAME", "siteName", "My Site")}
418
443
  ${() => (hasContent() ? themePicker() : null)}
419
444
  ${() => (hasDb() ? dbSettings() : null)}
420
- ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
445
+ ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
421
446
  ${() => (hasMedia() ? mediaSettings() : null)}
422
447
  ${aiSettings()}
423
448
  ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
@@ -42,7 +42,7 @@ function configPort() {
42
42
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
43
43
  return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
44
44
  }
45
- const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
45
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^9.0.3", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
46
46
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
47
47
 
48
48
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
@@ -564,6 +564,104 @@ function startSetup() {
564
564
  });
565
565
  return;
566
566
  }
567
+ // verify SMTP creds (form values merged over the saved .env) — auth check via
568
+ // nodemailer if available, else a TCP reachability check.
569
+ if (req.method === "POST" && p === "/setup/test-smtp") {
570
+ let body = "";
571
+ req.on("data", (c) => (body += c));
572
+ req.on("end", async () => {
573
+ res.setHeader("Content-Type", "application/json");
574
+ const done = (o) => res.end(JSON.stringify(o));
575
+ try {
576
+ const { env = {} } = JSON.parse(body || "{}");
577
+ const cfg = { ...readEnvFile(), ...env };
578
+ const url = cfg.SMTP_URL;
579
+ const host = cfg.SMTP_HOST;
580
+ if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
581
+ let nodemailer;
582
+ try {
583
+ nodemailer = (await import("nodemailer")).default;
584
+ } catch {
585
+ /* not installed */
586
+ }
587
+ if (nodemailer) {
588
+ const transport = url
589
+ ? nodemailer.createTransport(url)
590
+ : nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
591
+ await transport.verify();
592
+ return done({ ok: true, detail: "connection + auth OK" });
593
+ }
594
+ const net = await import("node:net");
595
+ let h = host;
596
+ let prt = Number(cfg.SMTP_PORT) || 587;
597
+ if (url) {
598
+ const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
599
+ h = u.hostname;
600
+ prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
601
+ }
602
+ await new Promise((resolve, reject) => {
603
+ const s = net.connect(prt, h, () => {
604
+ s.end();
605
+ resolve();
606
+ });
607
+ s.setTimeout(5000, () => {
608
+ s.destroy();
609
+ reject(new Error("timeout"));
610
+ });
611
+ s.on("error", reject);
612
+ });
613
+ done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
614
+ } catch (e) {
615
+ done({ ok: false, error: e.message });
616
+ }
617
+ });
618
+ return;
619
+ }
620
+ // verify the AI provider key (or gateway token) with a 1-token live call.
621
+ if (req.method === "POST" && p === "/setup/test-ai") {
622
+ let body = "";
623
+ req.on("data", (c) => (body += c));
624
+ req.on("end", async () => {
625
+ res.setHeader("Content-Type", "application/json");
626
+ const done = (o) => res.end(JSON.stringify(o));
627
+ try {
628
+ const { env = {} } = JSON.parse(body || "{}");
629
+ const cfg = { ...readEnvFile(), ...env };
630
+ const provider = cfg.AI_PROVIDER || "anthropic";
631
+ const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
632
+ let url, headers, payload, label;
633
+ if (key) {
634
+ label = provider + " key";
635
+ if (provider === "anthropic") {
636
+ url = "https://api.anthropic.com/v1/messages";
637
+ headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
638
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
639
+ } else if (provider === "openai") {
640
+ url = "https://api.openai.com/v1/chat/completions";
641
+ headers = { authorization: "Bearer " + key, "content-type": "application/json" };
642
+ payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
643
+ } else {
644
+ url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
645
+ headers = { "content-type": "application/json" };
646
+ payload = { contents: [{ parts: [{ text: "hi" }] }] };
647
+ }
648
+ } else if (cfg.VOLT_AI_TOKEN) {
649
+ label = "hosted gateway";
650
+ url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
651
+ headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
652
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
653
+ } else {
654
+ return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
655
+ }
656
+ const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
657
+ if (r.ok) return done({ ok: true, detail: `${label} works` });
658
+ done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
659
+ } catch (e) {
660
+ done({ ok: false, error: e.message });
661
+ }
662
+ });
663
+ return;
664
+ }
567
665
  if (req.method === "POST" && p === "/setup/apply") {
568
666
  let body = "";
569
667
  req.on("data", (c) => (body += c));
@@ -39,6 +39,12 @@ const state = signal({
39
39
  const set = (patch) => state({ ...state(), ...patch });
40
40
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
41
41
  const status = signal("");
42
+ // per-test inline results (shown right next to each Test button)
43
+ const dbTest = signal("");
44
+ const smtpTest = signal("");
45
+ const aiTest = signal("");
46
+ const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
+ const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
42
48
 
43
49
  // selected add-ons, dependencies expanded, in display order
44
50
  function effective(s) {
@@ -149,12 +155,30 @@ async function testDb() {
149
155
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
150
156
  e.DATABASE_URL = s.dbUrl;
151
157
  }
152
- status("Testing connection…");
158
+ dbTest("Testing…");
153
159
  try {
154
160
  const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
155
- status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
161
+ dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
156
162
  } catch {
157
- status("Network error testing connection.");
163
+ dbTest(" network error");
164
+ }
165
+ }
166
+ async function testSmtp() {
167
+ smtpTest("Testing…");
168
+ try {
169
+ const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
170
+ smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
171
+ } catch {
172
+ smtpTest("✗ network error");
173
+ }
174
+ }
175
+ async function testAi() {
176
+ aiTest("Testing…");
177
+ try {
178
+ const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
179
+ aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
180
+ } catch {
181
+ aiTest("✗ network error");
158
182
  }
159
183
  }
160
184
 
@@ -221,7 +245,7 @@ const dbSettings = () =>
221
245
  : dbDriver() === "mysql" || dbDriver() === "postgres"
222
246
  ? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
223
247
  : null}
224
- ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
248
+ ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
225
249
 
226
250
  const mediaSettings = () =>
227
251
  html`<div class="mb-2">
@@ -270,6 +294,7 @@ const aiSettings = () =>
270
294
  ${field("API key", "aiKey", "sk-…")}
271
295
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
272
296
  ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
273
298
  </div>
274
299
  </details>`;
275
300
 
@@ -417,7 +442,7 @@ const configView = () =>
417
442
  ${field("SITE_NAME", "siteName", "My Site")}
418
443
  ${() => (hasContent() ? themePicker() : null)}
419
444
  ${() => (hasDb() ? dbSettings() : null)}
420
- ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
445
+ ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
421
446
  ${() => (hasMedia() ? mediaSettings() : null)}
422
447
  ${aiSettings()}
423
448
  ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
@@ -42,7 +42,7 @@ function configPort() {
42
42
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
43
43
  return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
44
44
  }
45
- const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
45
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^9.0.3", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
46
46
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
47
47
 
48
48
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
@@ -564,6 +564,104 @@ function startSetup() {
564
564
  });
565
565
  return;
566
566
  }
567
+ // verify SMTP creds (form values merged over the saved .env) — auth check via
568
+ // nodemailer if available, else a TCP reachability check.
569
+ if (req.method === "POST" && p === "/setup/test-smtp") {
570
+ let body = "";
571
+ req.on("data", (c) => (body += c));
572
+ req.on("end", async () => {
573
+ res.setHeader("Content-Type", "application/json");
574
+ const done = (o) => res.end(JSON.stringify(o));
575
+ try {
576
+ const { env = {} } = JSON.parse(body || "{}");
577
+ const cfg = { ...readEnvFile(), ...env };
578
+ const url = cfg.SMTP_URL;
579
+ const host = cfg.SMTP_HOST;
580
+ if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
581
+ let nodemailer;
582
+ try {
583
+ nodemailer = (await import("nodemailer")).default;
584
+ } catch {
585
+ /* not installed */
586
+ }
587
+ if (nodemailer) {
588
+ const transport = url
589
+ ? nodemailer.createTransport(url)
590
+ : nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
591
+ await transport.verify();
592
+ return done({ ok: true, detail: "connection + auth OK" });
593
+ }
594
+ const net = await import("node:net");
595
+ let h = host;
596
+ let prt = Number(cfg.SMTP_PORT) || 587;
597
+ if (url) {
598
+ const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
599
+ h = u.hostname;
600
+ prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
601
+ }
602
+ await new Promise((resolve, reject) => {
603
+ const s = net.connect(prt, h, () => {
604
+ s.end();
605
+ resolve();
606
+ });
607
+ s.setTimeout(5000, () => {
608
+ s.destroy();
609
+ reject(new Error("timeout"));
610
+ });
611
+ s.on("error", reject);
612
+ });
613
+ done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
614
+ } catch (e) {
615
+ done({ ok: false, error: e.message });
616
+ }
617
+ });
618
+ return;
619
+ }
620
+ // verify the AI provider key (or gateway token) with a 1-token live call.
621
+ if (req.method === "POST" && p === "/setup/test-ai") {
622
+ let body = "";
623
+ req.on("data", (c) => (body += c));
624
+ req.on("end", async () => {
625
+ res.setHeader("Content-Type", "application/json");
626
+ const done = (o) => res.end(JSON.stringify(o));
627
+ try {
628
+ const { env = {} } = JSON.parse(body || "{}");
629
+ const cfg = { ...readEnvFile(), ...env };
630
+ const provider = cfg.AI_PROVIDER || "anthropic";
631
+ const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
632
+ let url, headers, payload, label;
633
+ if (key) {
634
+ label = provider + " key";
635
+ if (provider === "anthropic") {
636
+ url = "https://api.anthropic.com/v1/messages";
637
+ headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
638
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
639
+ } else if (provider === "openai") {
640
+ url = "https://api.openai.com/v1/chat/completions";
641
+ headers = { authorization: "Bearer " + key, "content-type": "application/json" };
642
+ payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
643
+ } else {
644
+ url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
645
+ headers = { "content-type": "application/json" };
646
+ payload = { contents: [{ parts: [{ text: "hi" }] }] };
647
+ }
648
+ } else if (cfg.VOLT_AI_TOKEN) {
649
+ label = "hosted gateway";
650
+ url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
651
+ headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
652
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
653
+ } else {
654
+ return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
655
+ }
656
+ const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
657
+ if (r.ok) return done({ ok: true, detail: `${label} works` });
658
+ done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
659
+ } catch (e) {
660
+ done({ ok: false, error: e.message });
661
+ }
662
+ });
663
+ return;
664
+ }
567
665
  if (req.method === "POST" && p === "/setup/apply") {
568
666
  let body = "";
569
667
  req.on("data", (c) => (body += c));
@@ -39,6 +39,12 @@ const state = signal({
39
39
  const set = (patch) => state({ ...state(), ...patch });
40
40
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
41
41
  const status = signal("");
42
+ // per-test inline results (shown right next to each Test button)
43
+ const dbTest = signal("");
44
+ const smtpTest = signal("");
45
+ const aiTest = signal("");
46
+ const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
+ const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
42
48
 
43
49
  // selected add-ons, dependencies expanded, in display order
44
50
  function effective(s) {
@@ -149,12 +155,30 @@ async function testDb() {
149
155
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
150
156
  e.DATABASE_URL = s.dbUrl;
151
157
  }
152
- status("Testing connection…");
158
+ dbTest("Testing…");
153
159
  try {
154
160
  const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
155
- status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
161
+ dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
156
162
  } catch {
157
- status("Network error testing connection.");
163
+ dbTest(" network error");
164
+ }
165
+ }
166
+ async function testSmtp() {
167
+ smtpTest("Testing…");
168
+ try {
169
+ const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
170
+ smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
171
+ } catch {
172
+ smtpTest("✗ network error");
173
+ }
174
+ }
175
+ async function testAi() {
176
+ aiTest("Testing…");
177
+ try {
178
+ const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
179
+ aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
180
+ } catch {
181
+ aiTest("✗ network error");
158
182
  }
159
183
  }
160
184
 
@@ -221,7 +245,7 @@ const dbSettings = () =>
221
245
  : dbDriver() === "mysql" || dbDriver() === "postgres"
222
246
  ? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
223
247
  : null}
224
- ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
248
+ ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
225
249
 
226
250
  const mediaSettings = () =>
227
251
  html`<div class="mb-2">
@@ -270,6 +294,7 @@ const aiSettings = () =>
270
294
  ${field("API key", "aiKey", "sk-…")}
271
295
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
272
296
  ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
273
298
  </div>
274
299
  </details>`;
275
300
 
@@ -417,7 +442,7 @@ const configView = () =>
417
442
  ${field("SITE_NAME", "siteName", "My Site")}
418
443
  ${() => (hasContent() ? themePicker() : null)}
419
444
  ${() => (hasDb() ? dbSettings() : null)}
420
- ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
445
+ ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
421
446
  ${() => (hasMedia() ? mediaSettings() : null)}
422
447
  ${aiSettings()}
423
448
  ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
@@ -43,7 +43,7 @@ function configPort() {
43
43
  const env = readEnvFile(); // --edit runs before loadEnv(), so read the file too
44
44
  return cliPort() || Number(process.env.CONFIG_PORT) || Number(env.CONFIG_PORT) || CONFIG_DEFAULT_PORT;
45
45
  }
46
- const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
46
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^9.0.3", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
47
47
  const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", posts: "posts.js", media: "media.js" };
48
48
 
49
49
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
@@ -590,6 +590,104 @@ function startSetup() {
590
590
  });
591
591
  return;
592
592
  }
593
+ // verify SMTP creds (form values merged over the saved .env) — auth check via
594
+ // nodemailer if available, else a TCP reachability check.
595
+ if (req.method === "POST" && p === "/setup/test-smtp") {
596
+ let body = "";
597
+ req.on("data", (c) => (body += c));
598
+ req.on("end", async () => {
599
+ res.setHeader("Content-Type", "application/json");
600
+ const done = (o) => res.end(JSON.stringify(o));
601
+ try {
602
+ const { env = {} } = JSON.parse(body || "{}");
603
+ const cfg = { ...readEnvFile(), ...env };
604
+ const url = cfg.SMTP_URL;
605
+ const host = cfg.SMTP_HOST;
606
+ if (!url && !host) return done({ ok: false, error: "no SMTP config (set SMTP_URL or SMTP_HOST)" });
607
+ let nodemailer;
608
+ try {
609
+ nodemailer = (await import("nodemailer")).default;
610
+ } catch {
611
+ /* not installed */
612
+ }
613
+ if (nodemailer) {
614
+ const transport = url
615
+ ? nodemailer.createTransport(url)
616
+ : nodemailer.createTransport({ host, port: Number(cfg.SMTP_PORT) || 587, secure: /^(1|true|yes|on)$/i.test(cfg.SMTP_SECURE || "") || Number(cfg.SMTP_PORT) === 465, auth: cfg.SMTP_USER ? { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS } : undefined });
617
+ await transport.verify();
618
+ return done({ ok: true, detail: "connection + auth OK" });
619
+ }
620
+ const net = await import("node:net");
621
+ let h = host;
622
+ let prt = Number(cfg.SMTP_PORT) || 587;
623
+ if (url) {
624
+ const u = new URL(url.replace(/^smtps?:\/\//, "http://"));
625
+ h = u.hostname;
626
+ prt = Number(u.port) || (url.startsWith("smtps") ? 465 : 587);
627
+ }
628
+ await new Promise((resolve, reject) => {
629
+ const s = net.connect(prt, h, () => {
630
+ s.end();
631
+ resolve();
632
+ });
633
+ s.setTimeout(5000, () => {
634
+ s.destroy();
635
+ reject(new Error("timeout"));
636
+ });
637
+ s.on("error", reject);
638
+ });
639
+ done({ ok: true, detail: `${h}:${prt} reachable — enable the mailer add-on for a full auth test` });
640
+ } catch (e) {
641
+ done({ ok: false, error: e.message });
642
+ }
643
+ });
644
+ return;
645
+ }
646
+ // verify the AI provider key (or gateway token) with a 1-token live call.
647
+ if (req.method === "POST" && p === "/setup/test-ai") {
648
+ let body = "";
649
+ req.on("data", (c) => (body += c));
650
+ req.on("end", async () => {
651
+ res.setHeader("Content-Type", "application/json");
652
+ const done = (o) => res.end(JSON.stringify(o));
653
+ try {
654
+ const { env = {} } = JSON.parse(body || "{}");
655
+ const cfg = { ...readEnvFile(), ...env };
656
+ const provider = cfg.AI_PROVIDER || "anthropic";
657
+ const key = { anthropic: cfg.ANTHROPIC_API_KEY, openai: cfg.OPENAI_API_KEY, gemini: cfg.GEMINI_API_KEY }[provider];
658
+ let url, headers, payload, label;
659
+ if (key) {
660
+ label = provider + " key";
661
+ if (provider === "anthropic") {
662
+ url = "https://api.anthropic.com/v1/messages";
663
+ headers = { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
664
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
665
+ } else if (provider === "openai") {
666
+ url = "https://api.openai.com/v1/chat/completions";
667
+ headers = { authorization: "Bearer " + key, "content-type": "application/json" };
668
+ payload = { model: cfg.AI_MODEL || "gpt-4o-mini", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
669
+ } else {
670
+ url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`;
671
+ headers = { "content-type": "application/json" };
672
+ payload = { contents: [{ parts: [{ text: "hi" }] }] };
673
+ }
674
+ } else if (cfg.VOLT_AI_TOKEN) {
675
+ label = "hosted gateway";
676
+ url = cfg.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
677
+ headers = { authorization: "Bearer " + cfg.VOLT_AI_TOKEN, "content-type": "application/json" };
678
+ payload = { model: cfg.AI_MODEL || "claude-haiku-4-5", max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
679
+ } else {
680
+ return done({ ok: false, error: "no AI key or VOLT_AI_TOKEN set" });
681
+ }
682
+ const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
683
+ if (r.ok) return done({ ok: true, detail: `${label} works` });
684
+ done({ ok: false, error: `${r.status}: ${(await r.text()).slice(0, 120)}` });
685
+ } catch (e) {
686
+ done({ ok: false, error: e.message });
687
+ }
688
+ });
689
+ return;
690
+ }
593
691
  if (req.method === "POST" && p === "/setup/apply") {
594
692
  let body = "";
595
693
  req.on("data", (c) => (body += c));
@@ -39,6 +39,12 @@ const state = signal({
39
39
  const set = (patch) => state({ ...state(), ...patch });
40
40
  const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
41
41
  const status = signal("");
42
+ // per-test inline results (shown right next to each Test button)
43
+ const dbTest = signal("");
44
+ const smtpTest = signal("");
45
+ const aiTest = signal("");
46
+ const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
+ const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
42
48
 
43
49
  // selected add-ons, dependencies expanded, in display order
44
50
  function effective(s) {
@@ -149,12 +155,30 @@ async function testDb() {
149
155
  } else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
150
156
  e.DATABASE_URL = s.dbUrl;
151
157
  }
152
- status("Testing connection…");
158
+ dbTest("Testing…");
153
159
  try {
154
160
  const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
155
- status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
161
+ dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
156
162
  } catch {
157
- status("Network error testing connection.");
163
+ dbTest(" network error");
164
+ }
165
+ }
166
+ async function testSmtp() {
167
+ smtpTest("Testing…");
168
+ try {
169
+ const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
170
+ smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
171
+ } catch {
172
+ smtpTest("✗ network error");
173
+ }
174
+ }
175
+ async function testAi() {
176
+ aiTest("Testing…");
177
+ try {
178
+ const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
179
+ aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
180
+ } catch {
181
+ aiTest("✗ network error");
158
182
  }
159
183
  }
160
184
 
@@ -221,7 +245,7 @@ const dbSettings = () =>
221
245
  : dbDriver() === "mysql" || dbDriver() === "postgres"
222
246
  ? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
223
247
  : null}
224
- ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
248
+ ${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
225
249
 
226
250
  const mediaSettings = () =>
227
251
  html`<div class="mb-2">
@@ -270,6 +294,7 @@ const aiSettings = () =>
270
294
  ${field("API key", "aiKey", "sk-…")}
271
295
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
272
296
  ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
273
298
  </div>
274
299
  </details>`;
275
300
 
@@ -417,7 +442,7 @@ const configView = () =>
417
442
  ${field("SITE_NAME", "siteName", "My Site")}
418
443
  ${() => (hasContent() ? themePicker() : null)}
419
444
  ${() => (hasDb() ? dbSettings() : null)}
420
- ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
445
+ ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
421
446
  ${() => (hasMedia() ? mediaSettings() : null)}
422
447
  ${aiSettings()}
423
448
  ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}