@iddp/create-vefk-app 0.7.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.
@@ -0,0 +1,404 @@
1
+ // lib/ersetzen.js
2
+ //
3
+ // Herzstück des Scaffolders: nimmt das frisch geklonte Template und ersetzt
4
+ // die Platzhalter (Toolname, Marke, Auth-Profil, 2FA/Mail, Tenant-Slug) sowie
5
+ // die @@TOKEN@@ der Infrastruktur-Dateien (Server-Slug, Port-Paar, Datum).
6
+ //
7
+ // Jede gezielte Ersetzung prüft, ob ihr Muster überhaupt getroffen hat.
8
+ // Trifft eines nicht (weil sich das Template weiterentwickelt hat), bricht
9
+ // nichts ab — die Stelle landet als Warnung im Ergebnis und wird am Ende
10
+ // zum manuellen Nacharbeiten angezeigt. So driftet der Scaffolder nie
11
+ // stillschweigend am Template vorbei.
12
+
13
+ import { readFile, writeFile, cp, access, readdir, rm } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+
16
+ /** Ein Muster genau einmal ersetzen; Fehltreffer → Warnung. */
17
+ function ersetzeGenau(inhalt, muster, ersatz, beschreibung, warnungen) {
18
+ if (!muster.test(inhalt)) {
19
+ warnungen.push(beschreibung);
20
+ return inhalt;
21
+ }
22
+ return inhalt.replace(muster, ersatz);
23
+ }
24
+
25
+ async function bearbeiteDatei(dir, relPfad, warnungen, fn) {
26
+ const pfad = join(dir, relPfad);
27
+ let inhalt;
28
+ try {
29
+ inhalt = await readFile(pfad, "utf8");
30
+ } catch {
31
+ warnungen.push(`${relPfad}: Datei nicht gefunden`);
32
+ return;
33
+ }
34
+ const neu = await fn(inhalt);
35
+ if (neu !== inhalt) await writeFile(pfad, neu);
36
+ }
37
+
38
+ /** package.json / package-lock.json: Namen umbenennen (Lock muss mitziehen, sonst scheitert `npm ci`). */
39
+ async function benenneNpmPaket(dir, relDir, neuerName, warnungen) {
40
+ await bearbeiteDatei(dir, join(relDir, "package.json"), warnungen, (inhalt) => {
41
+ const json = JSON.parse(inhalt);
42
+ json.name = neuerName;
43
+ return `${JSON.stringify(json, null, 2)}\n`;
44
+ });
45
+ const lockPfad = join(dir, relDir, "package-lock.json");
46
+ try {
47
+ await access(lockPfad);
48
+ } catch {
49
+ return; // kein Lockfile (z. B. Wurzel) — nichts zu tun
50
+ }
51
+ await bearbeiteDatei(dir, join(relDir, "package-lock.json"), warnungen, (inhalt) => {
52
+ const json = JSON.parse(inhalt);
53
+ json.name = neuerName;
54
+ if (json.packages?.[""]) json.packages[""].name = neuerName;
55
+ return `${JSON.stringify(json, null, 2)}\n`;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Dateien mit @@TOKEN@@-Platzhaltern der Zwei-Server-Infrastruktur. Die Liste
61
+ * spiegelt das Template; ein Rest-Scan über Wurzel + docs/ fängt neue Dateien.
62
+ */
63
+ export const TOKEN_DATEIEN = [
64
+ "deploy.sh",
65
+ "ecosystem.config.cjs",
66
+ "CLAUDE-DEPLOYMENT.md",
67
+ "docs/01-Infrastruktur.md",
68
+ "docs/07-Mail-Versand.md",
69
+ ];
70
+
71
+ const TOKEN_MUSTER = /@@[A-Z_]+@@/g;
72
+
73
+ function heuteDeutsch() {
74
+ const d = new Date();
75
+ const zwei = (n) => String(n).padStart(2, "0");
76
+ return `${zwei(d.getDate())}.${zwei(d.getMonth() + 1)}.${d.getFullYear()}`;
77
+ }
78
+
79
+ /** @@TOKEN@@ in einer Datei ersetzen; unbekannte Token bleiben stehen. */
80
+ function ersetzeTokens(inhalt, werte) {
81
+ return inhalt.replace(TOKEN_MUSTER, (token) => werte[token] ?? token);
82
+ }
83
+
84
+ /** Verbliebene @@TOKEN@@ in Wurzel-Dateien und docs/ melden (README wird ohnehin ersetzt). */
85
+ async function findeTokenReste(dir, warnungen) {
86
+ const kandidaten = [];
87
+ for (const rel of [".", "docs"]) {
88
+ let eintraege;
89
+ try {
90
+ eintraege = await readdir(join(dir, rel), { withFileTypes: true });
91
+ } catch {
92
+ continue;
93
+ }
94
+ for (const e of eintraege) {
95
+ if (e.isFile() && e.name !== "README.md") kandidaten.push(join(rel, e.name));
96
+ }
97
+ }
98
+ for (const rel of kandidaten) {
99
+ const inhalt = await readFile(join(dir, rel), "utf8").catch(() => "");
100
+ const reste = [...new Set(inhalt.match(TOKEN_MUSTER) ?? [])];
101
+ if (reste.length > 0) warnungen.push(`${rel}: Platzhalter ${reste.join(", ")} nicht ersetzt`);
102
+ }
103
+ }
104
+
105
+ function mandantTitel(slug) {
106
+ return slug
107
+ .split("-")
108
+ .map((teil) => teil.charAt(0).toUpperCase() + teil.slice(1))
109
+ .join(" ");
110
+ }
111
+
112
+ /**
113
+ * Wendet die Antworten auf das geklonte Template an.
114
+ *
115
+ * @param {string} dir Wurzel des frisch geklonten Projekts
116
+ * @param {object} antworten { ziel, name, authMode, genericLogin, zweiFaktor, anmeldung, adminEmails, daten,
117
+ * mail, marke, markeHell, tenantSlug, genericSlug,
118
+ * ownerEmail, slug, port, devPort, vitePort }
119
+ * genericLogin = "magic" | "password" (Login im
120
+ * offenen Bereich; fehlt er → magic),
121
+ * tenantSlug = erster Mandant (tenant/both),
122
+ * genericSlug = interner offener Bereich (generic/both),
123
+ * slug = Server-Slug (/opt/<slug>, PM2, Domain),
124
+ * port = Prod-Port (test = port+1) oder undefined,
125
+ * devPort/vitePort = lokale Dev-Ports (undefined →
126
+ * Template-Standard 3001/5173 bleibt)
127
+ * @returns {Promise<string[]>} Liste der Warnungen (leer = alles ersetzt)
128
+ */
129
+ export async function wendeAntwortenAn(dir, antworten) {
130
+ const warnungen = [];
131
+ const {
132
+ name, authMode, zweiFaktor, mail, adminEmails, daten,
133
+ marke, markeHell, tenantSlug, anmeldung, genericSlug, ownerEmail, slug, port, devPort, vitePort,
134
+ } = antworten;
135
+ const genericLogin = antworten.genericLogin === "password" ? "password" : "magic";
136
+ const mitMandanten = authMode === "tenant" || authMode === "both";
137
+ const mitOffen = authMode === "generic" || authMode === "both";
138
+
139
+ // --- app.config.js: zentrale Tool-Konfiguration --------------------------
140
+ await bearbeiteDatei(dir, "app.config.js", warnungen, (inhalt) => {
141
+ inhalt = ersetzeGenau(inhalt, /name:\s*"[^"]*"/, `name: ${JSON.stringify(name)}`,
142
+ "app.config.js: name", warnungen);
143
+ inhalt = ersetzeGenau(inhalt, /authMode:\s*"[^"]*"/, `authMode: "${authMode}"`,
144
+ "app.config.js: authMode", warnungen);
145
+ inhalt = ersetzeGenau(inhalt, /zweiFaktorDefault:\s*(?:true|false)/,
146
+ `zweiFaktorDefault: ${mitMandanten && zweiFaktor}`,
147
+ "app.config.js: zweiFaktorDefault", warnungen);
148
+ if (mitOffen) {
149
+ inhalt = ersetzeGenau(inhalt, /tenant:\s*"app"/, `tenant: "${genericSlug || "app"}"`,
150
+ "app.config.js: generic.tenant", warnungen);
151
+ inhalt = ersetzeGenau(inhalt, /login:\s*"(?:magic|password)"/, `login: "${genericLogin}"`,
152
+ "app.config.js: generic.login", warnungen);
153
+ // generic.zweiFaktor (nicht zweiFaktorDefault — das ist der tenant-Block)
154
+ inhalt = ersetzeGenau(inhalt, /(\n\s*)zweiFaktor:\s*(?:true|false)/,
155
+ `$1zweiFaktor: ${genericLogin === "password" && Boolean(zweiFaktor)}`,
156
+ "app.config.js: generic.zweiFaktor", warnungen);
157
+ if (ownerEmail && genericLogin === "magic") {
158
+ inhalt = ersetzeGenau(inhalt, /ownerEmail:\s*""/, `ownerEmail: ${JSON.stringify(ownerEmail)}`,
159
+ "app.config.js: generic.ownerEmail", warnungen);
160
+ }
161
+ }
162
+ // Admin-Bereich: Betreiber-Adressen; Datenspeicher: sqlite (Standard) | json
163
+ if (adminEmails?.length) {
164
+ inhalt = ersetzeGenau(inhalt, /emails:\s*\[[^\]]*\]/, `emails: ${JSON.stringify(adminEmails)}`,
165
+ "app.config.js: admin.emails", warnungen);
166
+ }
167
+ if (daten) {
168
+ inhalt = ersetzeGenau(inhalt, /adapter:\s*"(?:sqlite|json)"/, `adapter: "${daten}"`,
169
+ "app.config.js: daten.adapter", warnungen);
170
+ }
171
+ inhalt = ersetzeGenau(inhalt, /primaer:\s*"#[0-9a-fA-F]{3,8}"/, `primaer: "${marke}"`,
172
+ "app.config.js: marke.primaer", warnungen);
173
+ inhalt = ersetzeGenau(inhalt, /primaerHell:\s*"#[0-9a-fA-F]{3,8}"/, `primaerHell: "${markeHell}"`,
174
+ "app.config.js: marke.primaerHell", warnungen);
175
+ return inhalt;
176
+ });
177
+
178
+ // --- Paketnamen (npm ci prüft Lockfile-Namen mit!) ------------------------
179
+ // Technische Namen kommen überall aus dem Slug, nicht aus dem Ordnernamen —
180
+ // der Ordner darf lokal anders heißen (oder `.` sein).
181
+ await benenneNpmPaket(dir, ".", slug, warnungen);
182
+ await benenneNpmPaket(dir, "backend", `${slug}-backend`, warnungen);
183
+ await benenneNpmPaket(dir, "frontend", `${slug}-frontend`, warnungen);
184
+
185
+ // --- Frontend: Titel + Markenfarben ---------------------------------------
186
+ await bearbeiteDatei(dir, "frontend/index.html", warnungen, (inhalt) =>
187
+ ersetzeGenau(inhalt, /<title>[^<]*<\/title>/, `<title>${name}</title>`,
188
+ "frontend/index.html: <title>", warnungen));
189
+
190
+ await bearbeiteDatei(dir, "frontend/src/index.css", warnungen, (inhalt) => {
191
+ inhalt = ersetzeGenau(inhalt, /--iddp:\s*#[0-9a-fA-F]{3,8};/, `--iddp: ${marke};`,
192
+ "frontend/src/index.css: --iddp", warnungen);
193
+ inhalt = ersetzeGenau(inhalt, /--iddp-light:\s*#[0-9a-fA-F]{3,8};/, `--iddp-light: ${markeHell};`,
194
+ "frontend/src/index.css: --iddp-light", warnungen);
195
+ return inhalt;
196
+ });
197
+
198
+ // --- .env.example + ecosystem-Beispiel: Anzeigename überall ---------------
199
+ await bearbeiteDatei(dir, ".env.example", warnungen, (inhalt) => {
200
+ if (!inhalt.includes("VEFK Tool")) warnungen.push(".env.example: „VEFK Tool“ nicht gefunden");
201
+ inhalt = inhalt.replaceAll("VEFK Tool", name);
202
+ if (mail) {
203
+ // SMTP-Block einkommentieren, damit die Werte direkt ausgefüllt werden.
204
+ inhalt = inhalt.replace(/^# (SMTP_[A-Z_]+=)/gm, "$1");
205
+ }
206
+ if (devPort !== undefined) {
207
+ inhalt = ersetzeGenau(inhalt, /^PORT=\d+$/m, `PORT=${devPort}`, ".env.example: PORT", warnungen);
208
+ }
209
+ return inhalt;
210
+ });
211
+
212
+ // --- Lokale Dev-Ports (Backend = Prod-Port, Vite = 51xx) ------------------
213
+ // Gezielte Ersetzungen statt @@TOKEN@@: das Template bleibt ohne Scaffolder
214
+ // lauffähig (3001/5173), und ein Fehltreffer wird hier gemeldet — der
215
+ // Token-Rest-Scan sieht frontend/ nicht.
216
+ if (devPort !== undefined) {
217
+ await bearbeiteDatei(dir, "frontend/vite.config.js", warnungen, (inhalt) => {
218
+ inhalt = ersetzeGenau(inhalt, /port:\s*\d+/, `port: ${vitePort}`,
219
+ "frontend/vite.config.js: server.port", warnungen);
220
+ inhalt = ersetzeGenau(inhalt, /http:\/\/localhost:\d+/, `http://localhost:${devPort}`,
221
+ "frontend/vite.config.js: proxy → Backend", warnungen);
222
+ return inhalt;
223
+ });
224
+ await bearbeiteDatei(dir, "docs/08-2FA-Login.md", warnungen, (inhalt) => {
225
+ if (!inhalt.includes("localhost:3001")) warnungen.push("docs/08-2FA-Login.md: localhost:3001 (curl-Beispiele) nicht gefunden");
226
+ return inhalt.replaceAll("localhost:3001", `localhost:${devPort}`);
227
+ });
228
+ await bearbeiteDatei(dir, "CLAUDE-DEPLOYMENT.md", warnungen, (inhalt) => {
229
+ inhalt = ersetzeGenau(inhalt, /127\.0\.0\.1:3001/, `127.0.0.1:${devPort}`,
230
+ "CLAUDE-DEPLOYMENT.md: Dev-Backend-Port", warnungen);
231
+ inhalt = ersetzeGenau(inhalt, /Vite auf :5173/, `Vite auf :${vitePort}`,
232
+ "CLAUDE-DEPLOYMENT.md: Dev-Vite-Port", warnungen);
233
+ inhalt = ersetzeGenau(inhalt, /Proxy → :3001/, `Proxy → :${devPort}`,
234
+ "CLAUDE-DEPLOYMENT.md: Dev-Proxy-Port", warnungen);
235
+ return inhalt;
236
+ });
237
+ }
238
+
239
+ // --- Infrastruktur: deploy.sh, ecosystem.config.cjs, CLAUDE-DEPLOYMENT.md, docs
240
+ // Ohne Port bleiben @@PORT_*@@ stehen — deploy.sh bricht dann bewusst ab,
241
+ // ecosystem.config.cjs lädt nicht. Beides landet als Warnung.
242
+ const werte = {
243
+ "@@APP@@": slug,
244
+ "@@APPNAME@@": name,
245
+ "@@DATUM@@": heuteDeutsch(),
246
+ };
247
+ if (port !== undefined) {
248
+ werte["@@PORT_PROD@@"] = String(port);
249
+ werte["@@PORT_TEST@@"] = String(port + 1);
250
+ } else {
251
+ warnungen.push("Port-Paar nicht gesetzt (--port): @@PORT_PROD@@/@@PORT_TEST@@ in deploy.sh, ecosystem.config.cjs und Docs von Hand eintragen");
252
+ }
253
+ for (const rel of TOKEN_DATEIEN) {
254
+ await bearbeiteDatei(dir, rel, warnungen, (inhalt) => ersetzeTokens(inhalt, werte));
255
+ }
256
+ if (port !== undefined) await findeTokenReste(dir, warnungen);
257
+
258
+ // --- Erster Mandant (tenant/both, optional) -------------------------------
259
+ if (mitMandanten && tenantSlug) {
260
+ const quelle = join(dir, "backend/data/tenants/_muster");
261
+ const zielDir = join(dir, "backend/data/tenants", tenantSlug);
262
+ try {
263
+ await cp(quelle, zielDir, { recursive: true, errorOnExist: true, force: false });
264
+ await bearbeiteDatei(dir, join("backend/data/tenants", tenantSlug, "dashboard-config.json"),
265
+ warnungen, (inhalt) => {
266
+ const json = JSON.parse(inhalt);
267
+ json.titel = mandantTitel(tenantSlug);
268
+ // Explizit setzen: der Dateiwert übersteuert zweiFaktorDefault (docs/08).
269
+ json.zweiFaktor = zweiFaktor;
270
+ // Anmeldeart: konto (Default) | zugang — später in /admin umschaltbar (docs/08).
271
+ json.anmeldung = anmeldung === "zugang" ? "zugang" : "konto";
272
+ return `${JSON.stringify(json, null, 2)}\n`;
273
+ });
274
+ } catch (fehler) {
275
+ warnungen.push(`Mandant „${tenantSlug}“ anlegen: ${fehler.message}`);
276
+ }
277
+ }
278
+
279
+ // --- beispiel-Mandant entfernen -------------------------------------------
280
+ // Sobald ein echter erster Mandant existiert (oder das Tool gar keine
281
+ // Mandanten hat: generic), ist der Demo-Mandant nur noch im Weg. Er
282
+ // verschwindet samt seiner Whitelist in .gitignore. Der erste Mandant erbt
283
+ // die Whitelist NICHT: er ist ein echter Kunde, seine Nutzdaten (Mitarbeiter,
284
+ // Dokumente, Änderungshistorie) gehören nie ins Git — es bleibt bei
285
+ // `tenants/* + !_muster`; auf dem Server entsteht der Ordner aus _muster.
286
+ // Nur der Wurzel-Redirect in App.jsx zeigt auf ihn. Ohne ersten Mandanten
287
+ // (tenant/both ohne --tenant) bleibt beispiel als Demo stehen.
288
+ const beispielWeg = (mitMandanten && Boolean(tenantSlug)) || authMode === "generic";
289
+ if (beispielWeg) {
290
+ await rm(join(dir, "backend/data/tenants/beispiel"), { recursive: true, force: true });
291
+ await bearbeiteDatei(dir, ".gitignore", warnungen, (inhalt) => {
292
+ inhalt = ersetzeGenau(inhalt,
293
+ /^# === Tenant-Daten — nur _muster und beispiel im Repo ===$/m,
294
+ "# === Tenant-Daten — nur _muster im Repo ===",
295
+ ".gitignore: Kommentar „Tenant-Daten“", warnungen);
296
+ inhalt = ersetzeGenau(inhalt,
297
+ /^!backend\/data\/tenants\/beispiel\/\n/m, "",
298
+ ".gitignore: Whitelist des beispiel-Mandanten", warnungen);
299
+ inhalt = ersetzeGenau(inhalt,
300
+ /^backend\/data\/tenants\/beispiel\/users\.json\n/m, "",
301
+ ".gitignore: users.json des beispiel-Mandanten", warnungen);
302
+ return inhalt;
303
+ });
304
+ if (mitMandanten && tenantSlug) {
305
+ await bearbeiteDatei(dir, "frontend/src/App.jsx", warnungen, (inhalt) => ersetzeGenau(inhalt,
306
+ /to="\/beispiel"/, `to="/${tenantSlug}"`,
307
+ "frontend/src/App.jsx: Wurzel-Redirect /beispiel → erster Mandant", warnungen));
308
+ }
309
+ }
310
+
311
+ // --- README des neuen Tools (ersetzt das Template-README) -----------------
312
+ await writeFile(join(dir, "README.md"), erzeugeReadme(antworten));
313
+
314
+ return warnungen;
315
+ }
316
+
317
+ function erzeugeReadme({ name, authMode, genericLogin, zweiFaktor, mail, tenantSlug, anmeldung, genericSlug, adminEmails, daten, slug, port, devPort, vitePort }) {
318
+ const passwortOffen = genericLogin === "password";
319
+ const offenText = passwortOffen
320
+ ? `Login mit E-Mail + Passwort${zweiFaktor ? " + E-Mail-2FA" : ""} (\`generic.login: "password"\`), Nutzer legt der Admin an`
321
+ : "passwortloser Magic-Code-Login per E-Mail (`generic.login: \"magic\"`)";
322
+ const authZeile = {
323
+ tenant: `Mandanten-Tool (\`authMode: "tenant"\`) — Login mit E-Mail + Passwort${zweiFaktor ? " + E-Mail-2FA" : ""}, Tenant-Guard aktiv.`,
324
+ generic: `Mandantenloses Tool (\`authMode: "generic"\`) — ${offenText}.`,
325
+ both: `Kombi-Tool (\`authMode: "both"\`) — Mandanten-Bereiche unter \`/:tenant\` (Passwort${zweiFaktor ? " + 2FA" : ""}) plus offener Bereich unter \`/uebersicht\` (${offenText}; interner Slug: \`${genericSlug || "app"}\`).`,
326
+ }[authMode];
327
+ const mitMandanten = authMode === "tenant" || authMode === "both";
328
+ const anmeldungText = anmeldung === "zugang"
329
+ ? "Anmeldung **nur mit dem gemeinsamen Mandanten-Passwort** (ohne E-Mail) — vor dem ersten Login `npm run zugang` bzw. in `/admin` setzen"
330
+ : "Anmeldung mit **Nutzerkonten** (E-Mail + Passwort oder E-Mail-Code; ein Mandanten-Zugang ist zusätzlich möglich)";
331
+ const mandantZeile = mitMandanten && tenantSlug
332
+ ? `\nErster Mandant: \`backend/data/tenants/${tenantSlug}/\` (aus \`_muster\` erzeugt; der Demo-Mandant \`beispiel\` wurde entfernt, \`/\` leitet nach \`/${tenantSlug}\`). ${anmeldungText}; umschaltbar in \`/admin\` → Einstellungen. Der Ordner ist **nicht im Git** (echter Mandant, \`docs/05\`) — auf dem Server ebenfalls aus \`_muster\` anlegen.\n`
333
+ : mitMandanten
334
+ ? "\nNoch kein eigener Mandant — der Demo-Mandant `beispiel` steht als Vorlage (Slug bei Bedarf umbenennen, `.gitignore` + `App.jsx` mitziehen).\n"
335
+ : "";
336
+ const zugangZeile = mitMandanten
337
+ ? "npm run zugang # optional: Mandanten-Zugang (nur Passwort, kein Konto)\n"
338
+ : "";
339
+ const mailHinweis = mail
340
+ ? "SMTP-Zugangsdaten in `backend/.env` eintragen (Regeln: `docs/07`)."
341
+ : "SMTP ist noch nicht konfiguriert — für 2FA/Magic-Code später `docs/07` folgen.";
342
+ const datenZeile = daten === "json"
343
+ ? "Datenspeicher: JSON-Dateien je Mandant (`daten.adapter: \"json\"`); Wechsel auf SQLite später per `npm run migrate:sqlite`."
344
+ : "Datenspeicher: SQLite (`backend/data/app.sqlite`, nie im Git); vorhandene JSON-Mandanten werden beim ersten Start übernommen.";
345
+ const adminZeile = `Admin-Bereich: \`/admin\` — Mandanten, Nutzer, Zugänge, Admins, Protokoll. Anmeldung mit Google-Konto ` +
346
+ `(Suite-OAuth-Client aus \`/etc/vefk-suite/env\` bzw. \`~/.config/vefk-suite/env\`, Redirect-URI \`/api/admin/auth/google/callback\` zeigt die Login-Seite) ` +
347
+ `oder per E-Mail-Code (SMTP). Zugelassen: ${adminEmails?.length ? adminEmails.map((e) => `\`${e}\``).join(", ") : "die Adressen in `app.config.js → admin.emails`"} (\`docs/09\`).`;
348
+ const portProd = port ?? "<prod-port>";
349
+ const portTest = port ? port + 1 : "<test-port>";
350
+ const lokalBackend = devPort ?? 3001;
351
+ const lokalVite = vitePort ?? 5173;
352
+ return `# ${slug}
353
+
354
+ **${name}** — ein Tool der VEFK-Tools-Suite, erzeugt mit \`create-vefk-app\`
355
+ aus \`vefk-app-template\`.
356
+
357
+ ${authZeile}
358
+ ${mandantZeile}
359
+ ${datenZeile}
360
+
361
+ ${adminZeile}
362
+
363
+ ## Loslegen
364
+
365
+ \`\`\`bash
366
+ # backend/.env hat create-vefk-app angelegt (Dev-Flags + zufälliges SESSION_SECRET);
367
+ # fehlt sie: cp .env.example backend/.env und SESSION_SECRET setzen.
368
+ npm run install:all # zieht @iddp/vefk-ui von GitHub, @iddp/vefk-app-kit von npm
369
+ npm run create-user # ersten Nutzer anlegen (ohne Passwort = Login per E-Mail-Code)
370
+ ${zugangZeile}npm run dev # Backend :${lokalBackend} + Vite :${lokalVite} zusammen (Ctrl-C beendet beide)
371
+ \`\`\`
372
+
373
+ Einzeln: \`npm run dev:backend\` / \`npm run dev:frontend\` (Vite mit strictPort, Proxy → :${lokalBackend}).
374
+
375
+ ${mailHinweis}
376
+
377
+ ## Deployen (zwei Server, kein Dev-Server)
378
+
379
+ | | prod | test |
380
+ |---|---|---|
381
+ | Server / Ordner | \`prod-server\` · \`/opt/${slug}\` | \`test-server\` · \`/opt/${slug}-test\` |
382
+ | PM2 / Port (\`127.0.0.1\`) | \`${slug}\` · ${portProd} | \`${slug}-test\` · ${portTest} |
383
+
384
+ \`\`\`bash
385
+ ./deploy.sh test # auf Zuruf
386
+ ./deploy.sh prod # nur mit ausdrücklicher Erlaubnis des Betreibers
387
+ \`\`\`
388
+
389
+ Details, Erstinbetriebnahme und Regeln: \`CLAUDE-DEPLOYMENT.md\`. Secrets liegen
390
+ als \`backend/.env\` auf dem Server; \`ecosystem.config.cjs\` ist secret-frei im Repo.
391
+
392
+ ## Wo steht was
393
+
394
+ | Pfad | Inhalt |
395
+ |---|---|
396
+ | \`app.config.js\` | Zentrale Tool-Konfiguration (Name, Auth-Profil, Marke) |
397
+ | \`AGENTS.md\` / \`CLAUDE.md\` | Betriebsanleitung für Coding-Agenten |
398
+ | \`CLAUDE-DEPLOYMENT.md\` | Steckbrief auf beiden Servern, Deploy-Regeln |
399
+ | \`docs/00\`–\`09\` | Blueprint der Suite (Architektur, Sicherheit, Mail, 2FA, Admin-Bereich) |
400
+ | \`docs/06\` | Start-Checkliste für die ersten eigenen Seiten |
401
+
402
+ Tests: \`npm test\` (Sicherheits-Smoke-Tests — müssen grün bleiben).
403
+ `;
404
+ }
package/lib/farben.js ADDED
@@ -0,0 +1,66 @@
1
+ // lib/farben.js
2
+ //
3
+ // Markenfarbe: Validierung + Ableitung der hellen Variante.
4
+ // Das Template kennt zwei Markenwerte (--iddp und --iddp-light, docs/04).
5
+ // Wer nur EINE Farbe angibt, bekommt die helle Variante automatisch
6
+ // abgeleitet (gleicher Farbton, mehr Sättigung, mehr Helligkeit) —
7
+ // beim IDDP-Violett #6667ab landet das fast exakt auf dem bisherigen
8
+ // Handwert #8a8bd4.
9
+
10
+ /** #abc oder #aabbcc (Groß-/Kleinschreibung egal)? */
11
+ export function istHexFarbe(wert) {
12
+ return typeof wert === "string" && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(wert);
13
+ }
14
+
15
+ /** #abc → #aabbcc, alles kleingeschrieben. */
16
+ export function normalisiereHex(wert) {
17
+ let hex = wert.toLowerCase();
18
+ if (hex.length === 4) {
19
+ hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
20
+ }
21
+ return hex;
22
+ }
23
+
24
+ function hexZuHsl(hex) {
25
+ const n = normalisiereHex(hex);
26
+ const r = parseInt(n.slice(1, 3), 16) / 255;
27
+ const g = parseInt(n.slice(3, 5), 16) / 255;
28
+ const b = parseInt(n.slice(5, 7), 16) / 255;
29
+ const max = Math.max(r, g, b);
30
+ const min = Math.min(r, g, b);
31
+ const l = (max + min) / 2;
32
+ const d = max - min;
33
+ if (d === 0) return { h: 0, s: 0, l };
34
+ const s = d / (1 - Math.abs(2 * l - 1));
35
+ let h;
36
+ if (max === r) h = 60 * (((g - b) / d) % 6);
37
+ else if (max === g) h = 60 * ((b - r) / d + 2);
38
+ else h = 60 * ((r - g) / d + 4);
39
+ if (h < 0) h += 360;
40
+ return { h, s, l };
41
+ }
42
+
43
+ function hslZuHex({ h, s, l }) {
44
+ const c = (1 - Math.abs(2 * l - 1)) * s;
45
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
46
+ const m = l - c / 2;
47
+ let r = 0, g = 0, b = 0;
48
+ if (h < 60) [r, g, b] = [c, x, 0];
49
+ else if (h < 120) [r, g, b] = [x, c, 0];
50
+ else if (h < 180) [r, g, b] = [0, c, x];
51
+ else if (h < 240) [r, g, b] = [0, x, c];
52
+ else if (h < 300) [r, g, b] = [x, 0, c];
53
+ else [r, g, b] = [c, 0, x];
54
+ const kanal = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
55
+ return `#${kanal(r)}${kanal(g)}${kanal(b)}`;
56
+ }
57
+
58
+ /** Helle Markenvariante aus der Primärfarbe ableiten. */
59
+ export function helleVariante(hex) {
60
+ const { h, s, l } = hexZuHsl(hex);
61
+ return hslZuHex({
62
+ h,
63
+ s: Math.min(1, s * 1.6),
64
+ l: Math.min(0.92, l + 0.15),
65
+ });
66
+ }