@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.
package/lib/fragen.js ADDED
@@ -0,0 +1,265 @@
1
+ // lib/fragen.js
2
+ //
3
+ // Interaktive Fragen (node:readline/promises — bewusst ohne Prompt-Library,
4
+ // Suite-Linie „nativ statt Extra-Deps"). Alles, was schon per Flag beantwortet
5
+ // wurde, wird nicht mehr gefragt; mit --yes (oder ohne TTY) gelten die
6
+ // Standardwerte (Profil both, Dependencies installieren, git init, backend/.env).
7
+
8
+ import { createInterface } from "node:readline/promises";
9
+ import { stdin, stdout } from "node:process";
10
+ import { istSlug, istEmail, istPort, schlageOrdnerVor, schlageSlugVor, loeseZiel, RESERVIERTE_SLUGS } from "./validieren.js";
11
+ import { istHexFarbe, normalisiereHex, helleVariante } from "./farben.js";
12
+
13
+ /**
14
+ * Anzeigename-Vorschlag aus dem Projektordner: "pruef-protokolle" → "Pruef Protokolle".
15
+ * Ein `vefk-`-Präfix fällt nur HIER weg (Anzeigename), nicht im Slug.
16
+ */
17
+ export function schlageNamenVor(ziel) {
18
+ const rumpf = ziel.replace(/^vefk-/, "");
19
+ return rumpf
20
+ .split("-")
21
+ .map((teil) => teil.charAt(0).toUpperCase() + teil.slice(1))
22
+ .join(" ");
23
+ }
24
+
25
+ export async function stelleFragen(vorgaben, interaktiv) {
26
+ const rl = interaktiv ? createInterface({ input: stdin, output: stdout }) : null;
27
+
28
+ async function frage(text, standard, pruefen, fehlertext) {
29
+ if (!rl) return standard;
30
+ for (;;) {
31
+ const hinweis = standard !== undefined && standard !== "" ? ` [${standard}]` : "";
32
+ const eingabe = (await rl.question(`${text}${hinweis}: `)).trim();
33
+ const wert = eingabe === "" ? standard : eingabe;
34
+ if (!pruefen || pruefen(wert)) return wert;
35
+ stdout.write(` ✗ ${fehlertext}\n`);
36
+ }
37
+ }
38
+
39
+ async function jaNein(text, standard) {
40
+ if (!rl) return standard;
41
+ const wert = await frage(`${text} (j/n)`, standard ? "j" : "n",
42
+ (w) => /^(j|ja|n|nein)$/i.test(w), "bitte j oder n");
43
+ return /^j/i.test(wert);
44
+ }
45
+
46
+ try {
47
+ // --- Zielordner (Ordnername = Server-Slug) ------------------------------
48
+ // Kein Präfix-Zwang: der Ordner heißt, wie das Tool auf dem Server heißt.
49
+ // Ein vorhandener Ordner (auch `.`) ist als Ziel erlaubt, wenn er leer ist —
50
+ // das prüft bin/ vor dem Klonen.
51
+ let ziel = vorgaben.ziel;
52
+ let zielPfad = vorgaben.zielPfad;
53
+ if (!ziel) {
54
+ const eingabe = await frage(
55
+ "In welchen Ordner soll installiert werden? (. = aktueller Ordner; Ordnername = Server-Slug)",
56
+ vorgaben.name ? schlageOrdnerVor(vorgaben.name) : undefined,
57
+ (w) => w === "." || istSlug(w),
58
+ "nur a-z, 0-9, Bindestrich (nicht am Anfang oder Ende) oder . für den aktuellen Ordner",
59
+ );
60
+ ({ zielPfad, ziel } = loeseZiel(eingabe));
61
+ }
62
+ // Ordnername, der kein Slug ist (z. B. `.` in „Mein Tool"): interaktiv wird
63
+ // der Server-Slug unten ohne Vorschlag erfragt, non-interaktiv braucht es --slug.
64
+ const zielIstSlug = istSlug(ziel);
65
+ if (!zielIstSlug && !vorgaben.slug && !rl) {
66
+ throw new Error(
67
+ `Ordnername „${ziel}" taugt nicht als Server-Slug (nur a-z, 0-9, -) — ` +
68
+ "bitte anderen Ordner wählen oder --slug angeben.",
69
+ );
70
+ }
71
+
72
+ // --- Anzeigename --------------------------------------------------------
73
+ const name = vorgaben.name
74
+ ?? await frage("Anzeigename des Tools (Header, Mail-Betreff)", schlageNamenVor(ziel),
75
+ (w) => typeof w === "string" && w.trim().length > 0, "darf nicht leer sein");
76
+
77
+ // --- Auth-Profil --------------------------------------------------------
78
+ // Standard „both": Mandanten-Bereiche PLUS ein Bereich ohne Mandant.
79
+ let authMode = vorgaben.authMode;
80
+ if (!authMode) {
81
+ const antwort = await frage(
82
+ "Auth-Profil — tenant (Bereiche je Mandant unter /<mandant>) " +
83
+ "generic oder both (beides)",
84
+ "both",
85
+ (w) => ["tenant", "generic", "both"].includes(w),
86
+ "bitte tenant, generic oder both",
87
+ );
88
+ authMode = antwort ?? "both";
89
+ }
90
+ const mitMandanten = authMode === "tenant" || authMode === "both";
91
+ const mitOffen = authMode === "generic" || authMode === "both";
92
+
93
+ // --- Login-Verfahren des offenen Bereichs (generic/both) -----------------
94
+ // magic = Code per E-Mail, Nutzer legen sich selbst an (offen für alle);
95
+ // password = „normaler" Login mit E-Mail + Passwort, Nutzer legt der Admin an.
96
+ let genericLogin = "magic";
97
+ if (mitOffen) {
98
+ genericLogin = vorgaben.genericLogin ?? await frage(
99
+ "Login ohne Mandant — password (E-Mail + Passwort, Admin legt Nutzer an) " +
100
+ "oder magic (Code per E-Mail, Nutzer legt sich selbst an)",
101
+ "magic",
102
+ (w) => ["magic", "password"].includes(w),
103
+ "bitte magic oder password",
104
+ );
105
+ }
106
+ const mitMagic = mitOffen && genericLogin === "magic";
107
+
108
+ // --- Profil-Details -----------------------------------------------------
109
+ let zweiFaktor = false;
110
+ let tenantSlug = ""; // erster Mandant (tenant/both)
111
+ let anmeldung = "konto"; // Anmeldeart des ersten Mandanten: konto | zugang
112
+ // Interner Slug des offenen Bereichs (Nutzer + Daten): bewusst NICHT erfragt —
113
+ // reiner Ordner-/Tabellenname, "app" passt immer; --generic-tenant für Sonderfälle.
114
+ const genericSlug = mitOffen ? (vorgaben.genericSlug ?? "app") : "";
115
+ // Owner-E-Mail: NICHT erfragt — die Adressen aus admin.emails werden im
116
+ // Magic-Code-Bereich ohnehin admin; --owner-email nur für eine weitere Adresse.
117
+ const ownerEmail = vorgaben.ownerEmail ?? "";
118
+
119
+ const istFreierMandant = (w) =>
120
+ istSlug(w) && !["_muster", "beispiel", ...RESERVIERTE_SLUGS].includes(w);
121
+
122
+ if (mitMandanten) {
123
+ tenantSlug = vorgaben.tenantSlug ?? await frage(
124
+ "Slug des ersten Mandanten (leer lassen = nur der beispiel-Mandant)",
125
+ "",
126
+ (w) => w === "" || istFreierMandant(w),
127
+ `nur a-z, 0-9, - (nicht _muster/beispiel/${RESERVIERTE_SLUGS.join("/")})`,
128
+ );
129
+ if (tenantSlug) {
130
+ // Anmeldeart des ersten Mandanten — steht in dessen dashboard-config.json
131
+ // (anmeldung) und ist später in /admin → Einstellungen umschaltbar.
132
+ anmeldung = vorgaben.anmeldung ?? await frage(
133
+ `Anmeldung der Nutzer von „${tenantSlug}“ — konto (E-Mail + Passwort oder E-Mail-Code) ` +
134
+ "oder zugang (nur ein gemeinsames Mandanten-Passwort, ohne E-Mail)",
135
+ "konto",
136
+ (w) => ["konto", "zugang"].includes(w),
137
+ "bitte konto oder zugang",
138
+ );
139
+ }
140
+ }
141
+ if (!["konto", "zugang"].includes(anmeldung)) {
142
+ throw new Error(`--anmeldung muss konto oder zugang sein (war: ${anmeldung}).`);
143
+ }
144
+
145
+ // 2FA gibt es nur nach einem Passwort-Login mit Konto: Mandanten mit Nutzerkonten
146
+ // oder der Passwort-Login des offenen Bereichs. Nicht bei Magic-Code (der Code ist
147
+ // der Faktor) und nicht beim Mandanten-Passwort ohne E-Mail.
148
+ const mitPasswort = (mitMandanten && anmeldung !== "zugang") || (mitOffen && genericLogin === "password");
149
+ if (mitPasswort) {
150
+ zweiFaktor = vorgaben.zweiFaktor ?? await jaNein(
151
+ mitMandanten && anmeldung !== "zugang"
152
+ ? "E-Mail-2FA für Mandanten-Logins mit Passwort als Standard aktivieren?"
153
+ : "E-Mail-2FA nach dem Passwort aktivieren?",
154
+ false,
155
+ );
156
+ }
157
+
158
+ // --- Werte prüfen, egal ob erfragt oder per Flag geliefert ---------------
159
+ // (die Prompt-Validierung greift nur bei interaktiver Eingabe)
160
+ if (tenantSlug && !istFreierMandant(tenantSlug)) {
161
+ throw new Error(
162
+ `Mandanten-Slug „${tenantSlug}" ist ungültig oder reserviert ` +
163
+ `(_muster, beispiel, ${RESERVIERTE_SLUGS.join(", ")}).`,
164
+ );
165
+ }
166
+ if (mitOffen && (!istSlug(genericSlug) || RESERVIERTE_SLUGS.includes(genericSlug) || genericSlug === tenantSlug)) {
167
+ throw new Error(
168
+ `Interner Slug „${genericSlug}" ist ungültig, reserviert oder kollidiert mit dem Mandanten-Slug.`,
169
+ );
170
+ }
171
+ if (ownerEmail && !istEmail(ownerEmail)) {
172
+ throw new Error(`Owner-E-Mail „${ownerEmail}" ist keine gültige E-Mail-Adresse.`);
173
+ }
174
+
175
+ // --- Admin-Bereich (/admin) + Datenspeicher ------------------------------
176
+ // Admin-Adressen: Betreiber, nicht Kunden — stehen versioniert in app.config.js.
177
+ // Leer lassen = die Standard-Adressen aus dem Template bleiben (undefined).
178
+ let adminEmails = vorgaben.adminEmails;
179
+ if (adminEmails === undefined) {
180
+ const eingabe = await frage(
181
+ "Admin-E-Mails für /admin (Komma-getrennt) [dr.dirk.peters@gmail.com, eva@iddp-vefk.de]",
182
+ "",
183
+ (w) => w === "" || w.split(",").map((x) => x.trim()).filter(Boolean).every(istEmail),
184
+ "eine oder mehrere gültige E-Mail-Adressen, Komma-getrennt",
185
+ );
186
+ adminEmails = eingabe ? eingabe.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
187
+ }
188
+ if (adminEmails !== undefined) {
189
+ const kaputt = adminEmails.filter((e) => !istEmail(e));
190
+ if (!adminEmails.length || kaputt.length) {
191
+ throw new Error(`--admin-email: keine gültige(n) Adresse(n) (${kaputt.join(", ") || "leer"}).`);
192
+ }
193
+ }
194
+ // Datenspeicher: SQLite ist Standard für neue Tools; JSON nur per --daten json.
195
+ const daten = vorgaben.daten ?? "sqlite";
196
+ if (!["sqlite", "json"].includes(daten)) {
197
+ throw new Error(`--daten muss sqlite oder json sein (war: ${daten}).`);
198
+ }
199
+
200
+ // --- Marke --------------------------------------------------------------
201
+ const markeRoh = vorgaben.marke ?? await frage(
202
+ "Markenfarbe (Hex; Standard = IDDP-Violett)",
203
+ "#6667ab",
204
+ istHexFarbe,
205
+ "bitte als #rrggbb angeben",
206
+ );
207
+ if (!istHexFarbe(markeRoh) || (vorgaben.markeHell && !istHexFarbe(vorgaben.markeHell))) {
208
+ throw new Error(`Markenfarbe bitte als #rrggbb angeben (war: ${markeRoh} / ${vorgaben.markeHell ?? "—"}).`);
209
+ }
210
+ const marke = normalisiereHex(markeRoh);
211
+ const markeHell = vorgaben.markeHell
212
+ ? normalisiereHex(vorgaben.markeHell)
213
+ : helleVariante(marke);
214
+
215
+ // --- Server: Slug + Port-Paar -------------------------------------------
216
+ // Slug → /opt/<slug>, PM2 <slug>/<slug>-test, <slug>.<domain>. Port: prod,
217
+ // test = prod+1. Ohne Port bleiben die @@PORT_*@@-Token stehen (Warnung).
218
+ const slug = vorgaben.slug ?? await frage(
219
+ "Server-Slug (Ordner /opt/<slug>, PM2-Name, Domain)",
220
+ zielIstSlug ? schlageSlugVor(ziel) : undefined,
221
+ istSlug,
222
+ "nur a-z, 0-9, Bindestrich",
223
+ );
224
+ if (!istSlug(slug)) {
225
+ throw new Error(`Server-Slug „${slug}" ist ungültig (nur a-z, 0-9, Bindestrich).`);
226
+ }
227
+ const portRoh = vorgaben.port ?? await frage(
228
+ "Prod-Port aus dem Port-Register (test = +1; freies Paar wählen UND dort eintragen)",
229
+ undefined,
230
+ istPort,
231
+ "vierstellige Portnummer, z. B. 3090",
232
+ );
233
+ if (portRoh !== undefined && !istPort(portRoh)) {
234
+ throw new Error(`--port muss eine Portnummer zwischen 1024 und 65534 sein (war: ${portRoh}).`);
235
+ }
236
+ const port = portRoh === undefined ? undefined : Number(portRoh);
237
+ // Lokale Dev-Ports: nicht erfragt, nur per --dev-port übersteuerbar.
238
+ if (vorgaben.devPort !== undefined && !istPort(vorgaben.devPort)) {
239
+ throw new Error(`--dev-port muss eine Portnummer zwischen 1024 und 65534 sein (war: ${vorgaben.devPort}).`);
240
+ }
241
+ const devPort = vorgaben.devPort === undefined ? undefined : Number(vorgaben.devPort);
242
+
243
+ // --- Mail / SMTP --------------------------------------------------------
244
+ // Magic-Code braucht SMTP zwingend, Passwort-Logins nur mit 2FA.
245
+ const mailStandard = mitMagic || zweiFaktor;
246
+ const mail = vorgaben.mail ?? (mailStandard
247
+ ? mailStandard // zwingend/sinnvoll → nicht extra fragen
248
+ : await jaNein("Mail-Versand (SMTP) direkt vorkonfigurieren?", false));
249
+
250
+ // --- Abschluss-Aktionen -------------------------------------------------
251
+ const gitInit = vorgaben.gitInit ?? true;
252
+ const localEnv = vorgaben.localEnv ?? true; // backend/.env anlegen (--no-local-env)
253
+ const install = vorgaben.install ?? await jaNein(
254
+ "npm-Dependencies direkt installieren? (zieht die @iddp-Pakete von GitHub; --no-install überspringt)",
255
+ true,
256
+ );
257
+
258
+ return {
259
+ ziel, zielPfad, name, authMode, genericLogin, zweiFaktor, mail, marke, markeHell,
260
+ tenantSlug, anmeldung, genericSlug, ownerEmail, adminEmails, daten, slug, port, devPort, gitInit, install, localEnv,
261
+ };
262
+ } finally {
263
+ rl?.close();
264
+ }
265
+ }
package/lib/git.js ADDED
@@ -0,0 +1,33 @@
1
+ // lib/git.js
2
+ //
3
+ // Frisches Repo im neuen Tool: git init + Initial-Commit.
4
+ // Schlägt der Commit fehl (z. B. keine user.name/user.email konfiguriert),
5
+ // bricht der Scaffolder nicht ab — das Projekt ist trotzdem fertig, nur
6
+ // der Commit fehlt; der Hinweis dazu kommt in der Abschluss-Ausgabe.
7
+
8
+ import { fuehreAus } from "./prozesse.js";
9
+
10
+ export async function initialisiereRepo(dir) {
11
+ const optionen = { cwd: dir, stdio: ["ignore", "ignore", "inherit"] };
12
+ try {
13
+ await fuehreAus("git", ["init", "-b", "main"], optionen);
14
+ } catch {
15
+ await fuehreAus("git", ["init"], optionen); // ältere git-Version ohne -b
16
+ }
17
+ await fuehreAus("git", ["add", "-A"], optionen);
18
+ try {
19
+ await fuehreAus(
20
+ "git",
21
+ ["commit", "-m", "Init aus vefk-app-template (create-vefk-app)"],
22
+ optionen,
23
+ );
24
+ return { commit: true };
25
+ } catch {
26
+ return {
27
+ commit: false,
28
+ hinweis:
29
+ "git init ok, aber der Initial-Commit schlug fehl (git user.name/user.email gesetzt?). " +
30
+ "Bitte manuell committen: git add -A && git commit -m \"Init aus vefk-app-template\"",
31
+ };
32
+ }
33
+ }
package/lib/klonen.js ADDED
@@ -0,0 +1,80 @@
1
+ // lib/klonen.js
2
+ //
3
+ // Template holen: `git clone --depth 1` + `.git` entfernen.
4
+ //
5
+ // Bewusst git statt degit: degit lädt GitHub-Tarballs über die API und
6
+ // scheitert am privaten Template-Repo, solange kein degit-eigener Token
7
+ // konfiguriert ist. `git clone` nutzt die Git-Credentials, die auf dem
8
+ // Rechner ohnehin eingerichtet sind (gleiche Voraussetzung wie das
9
+ // `npm install` der git-Dependencies) — und funktioniert zusätzlich mit
10
+ // lokalen Pfaden (`--template ../vefk-app-template`), was Tests und
11
+ // Offline-Arbeit erlaubt.
12
+
13
+ import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises";
14
+ import { resolve, join } from "node:path";
15
+ import { fuehreAus } from "./prozesse.js";
16
+
17
+ export const STANDARD_TEMPLATE = "https://github.com/esgoet/vefk-app-template.git";
18
+
19
+ /** Dateien, die ein Ordner haben darf und trotzdem als „leer" gilt. */
20
+ const IGNORIERT_BEI_LEER = new Set([".DS_Store"]);
21
+
22
+ /** Lokalen Pfad zu absolutem Pfad auflösen; URLs unangetastet lassen. */
23
+ export async function loeseTemplateQuelle(quelle) {
24
+ if (/^[a-z+]+:\/\//i.test(quelle) || quelle.startsWith("git@")) return quelle;
25
+ const pfad = resolve(quelle);
26
+ await stat(pfad); // wirft, wenn der lokale Pfad nicht existiert
27
+ return pfad;
28
+ }
29
+
30
+ /**
31
+ * Zielordner prüfen (wie `npm create vite@latest .`): darf fehlen oder leer
32
+ * sein — auch `.` oder ein schon angelegter Ordner. Ist er nicht leer, wird
33
+ * abgebrochen, bevor irgendetwas geschrieben wird.
34
+ *
35
+ * @returns {Promise<"neu"|"leer">}
36
+ */
37
+ export async function pruefeZielordner(zielDir) {
38
+ const info = await stat(zielDir).catch(() => null);
39
+ if (!info) return "neu";
40
+ if (!info.isDirectory()) {
41
+ throw new Error(`Ziel ${zielDir} existiert, ist aber kein Ordner.`);
42
+ }
43
+ const eintraege = (await readdir(zielDir)).filter((e) => !IGNORIERT_BEI_LEER.has(e));
44
+ if (eintraege.length > 0) {
45
+ throw new Error(
46
+ `Zielordner ${zielDir} ist nicht leer (${eintraege.slice(0, 3).join(", ")}${
47
+ eintraege.length > 3 ? ", …" : ""}) — es wird nichts überschrieben. ` +
48
+ "Bitte leeren Ordner oder anderen Namen wählen.",
49
+ );
50
+ }
51
+ return "leer";
52
+ }
53
+
54
+ /**
55
+ * Klont das Template (ohne Historie) nach zielDir und entfernt `.git`,
56
+ * damit das neue Tool mit einer frischen Historie startet.
57
+ *
58
+ * Geklont wird in einen temporären Unterordner des Ziels, dessen Inhalt dann
59
+ * eine Ebene hochgezogen wird: so funktioniert es auch für `.` und für
60
+ * vorhandene Ordner (mit `.DS_Store`), an denen `git clone` sonst scheitert —
61
+ * und die Verschiebung bleibt im selben Dateisystem (`rename`).
62
+ */
63
+ export async function kloneTemplate(quelle, zielDir) {
64
+ // Bei lokalen Pfaden kein --depth (git ignoriert es dort mit Warnung);
65
+ // die Historie fliegt gleich darauf ohnehin mit `.git` raus.
66
+ const flach = /^[a-z+]+:\/\//i.test(quelle) || quelle.startsWith("git@")
67
+ ? ["--depth", "1", "--single-branch"]
68
+ : [];
69
+ await mkdir(zielDir, { recursive: true });
70
+ const tmp = await mkdtemp(join(zielDir, ".create-vefk-app-"));
71
+ try {
72
+ await fuehreAus("git", ["clone", ...flach, quelle, tmp]);
73
+ await rm(join(tmp, ".git"), { recursive: true, force: true });
74
+ for (const eintrag of await readdir(tmp)) {
75
+ await rename(join(tmp, eintrag), join(zielDir, eintrag));
76
+ }
77
+ } finally {
78
+ await rm(tmp, { recursive: true, force: true });
79
+ }
80
+ }
@@ -0,0 +1,34 @@
1
+ // lib/lokaleEnv.js
2
+ //
3
+ // Lokale backend/.env direkt mit anlegen, damit `npm run dev:backend` sofort
4
+ // läuft: Kopie von .env.example, NODE_ENV=development + VEFK_DEV_LOGIN=true
5
+ // einkommentiert, SESSION_SECRET durch einen Zufallswert ersetzt. Der Wert
6
+ // wird nirgends ausgegeben. .env steht im .gitignore des Templates.
7
+
8
+ import { randomBytes } from "node:crypto";
9
+ import { readFile, writeFile, access } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+
12
+ /**
13
+ * @returns {Promise<"angelegt"|"vorhanden"|"keine-vorlage">}
14
+ */
15
+ export async function legeLokaleEnvAn(dir) {
16
+ const ziel = join(dir, "backend", ".env");
17
+ if (await access(ziel).then(() => true, () => false)) return "vorhanden"; // nie überschreiben
18
+
19
+ let vorlage;
20
+ try {
21
+ vorlage = await readFile(join(dir, ".env.example"), "utf8");
22
+ } catch {
23
+ return "keine-vorlage";
24
+ }
25
+
26
+ const secret = randomBytes(48).toString("base64url");
27
+ const inhalt = vorlage
28
+ .replace(/^SESSION_SECRET=.*$/m, `SESSION_SECRET=${secret}`)
29
+ .replace(/^# (NODE_ENV=development)/m, "$1")
30
+ .replace(/^# (VEFK_DEV_LOGIN=true)/m, "$1");
31
+
32
+ await writeFile(ziel, inhalt, { mode: 0o600 });
33
+ return "angelegt";
34
+ }
@@ -0,0 +1,21 @@
1
+ // lib/prozesse.js
2
+ //
3
+ // Dünner Wrapper um child_process für git/npm-Aufrufe.
4
+ // stdio wird durchgereicht, damit z. B. der Git-Credential-Prompt beim
5
+ // Klonen des privaten Template-Repos sichtbar bleibt.
6
+
7
+ import { spawn } from "node:child_process";
8
+
9
+ export function fuehreAus(befehl, argumente, optionen = {}) {
10
+ return new Promise((aufloesen, ablehnen) => {
11
+ const kind = spawn(befehl, argumente, {
12
+ stdio: "inherit",
13
+ ...optionen,
14
+ });
15
+ kind.on("error", ablehnen);
16
+ kind.on("close", (code) => {
17
+ if (code === 0) aufloesen();
18
+ else ablehnen(new Error(`${befehl} ${argumente.join(" ")} → Exit-Code ${code}`));
19
+ });
20
+ });
21
+ }
@@ -0,0 +1,85 @@
1
+ // lib/validieren.js
2
+ //
3
+ // Eingabe-Validierung des Scaffolders. Die Slug-Regel ist dieselbe wie im
4
+ // vefk-app-kit (Tenant-Slug-Validierung, Path-Traversal-Schutz — docs/03, docs/05):
5
+ // nur Kleinbuchstaben, Ziffern, Bindestriche.
6
+ //
7
+ // Ordnername = Server-Slug (Konvention: /opt/<slug>, PM2 <slug>, <slug>.<domain>).
8
+ // Ein `vefk-`-Präfix wird NICHT erzwungen — kaum ein Tool heißt so (gbu, beirat,
9
+ // seminar, vefkev-user …); `--slug` übersteuert bei Bedarf.
10
+
11
+ import { basename } from "node:path";
12
+ import process from "node:process";
13
+
14
+ /** Tenant-/Ordner-/Server-Slug: ^[a-z0-9-]+$, beginnt und endet alphanumerisch. */
15
+ export function istSlug(wert) {
16
+ return typeof wert === "string" && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(wert);
17
+ }
18
+
19
+ /**
20
+ * Von den App-Routen belegte Namen — als Tenant-Slug verboten, sonst
21
+ * verschatten sie im both-Modus die offenen Routen (/api/data, /login,
22
+ * /uebersicht). → Template docs/03 (both-Abschnitt).
23
+ */
24
+ // Deckungsgleich mit RESERVIERTE_SLUGS in @iddp/vefk-app-kit/server (Admin-Bereich prüft dasselbe).
25
+ export const RESERVIERTE_SLUGS = ["data", "login", "uebersicht", "api", "admin", "impressum", "datenschutz"];
26
+
27
+ /**
28
+ * Server-Slug-Vorschlag aus dem Projektordner: der Ordnername selbst.
29
+ * Daraus werden /opt/<slug>, PM2-Name und Domains.
30
+ */
31
+ export function schlageSlugVor(ziel) {
32
+ return String(ziel);
33
+ }
34
+
35
+ /**
36
+ * Prod-Port des Tools; test ist prod+1. Vierstellig wie das Port-Register
37
+ * (30x0/30x1). Es gibt bewusst KEINEN Standardwert — ein Beispiel-Port
38
+ * kollidiert früher oder später mit einer echten App.
39
+ */
40
+ export function istPort(wert) {
41
+ const n = Number(wert);
42
+ return Number.isInteger(n) && n >= 1024 && n <= 65534 && String(wert) === String(n);
43
+ }
44
+
45
+ /**
46
+ * Lokale Dev-Ports aus dem Prod-Port: Backend lokal = Prod-Port (oder
47
+ * --dev-port), Vite = "51" + die letzten zwei Stellen davon. So laufen
48
+ * mehrere Tools parallel, ohne sich die Standard-Ports 3001/5173 zu teilen.
49
+ * Ohne Port bleiben beide undefined — das Template behält dann 3001/5173.
50
+ */
51
+ export function leiteDevPortsAb(port, devPort) {
52
+ const basis = devPort ?? port;
53
+ if (basis === undefined) return { devPort: undefined, vitePort: undefined };
54
+ const n = Number(basis);
55
+ return { devPort: n, vitePort: Number(`51${String(n).padStart(2, "0").slice(-2)}`) };
56
+ }
57
+
58
+ /** Grobe E-Mail-Prüfung — echte Prüfung macht der Mail-Versand selbst. */
59
+ export function istEmail(wert) {
60
+ return typeof wert === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert);
61
+ }
62
+
63
+ /** Anzeigename → Vorschlag für den Projektordner (= Slug). */
64
+ export function schlageOrdnerVor(name) {
65
+ return String(name)
66
+ .toLowerCase()
67
+ .replace(/ä/g, "ae")
68
+ .replace(/ö/g, "oe")
69
+ .replace(/ü/g, "ue")
70
+ .replace(/ß/g, "ss")
71
+ .replace(/[^a-z0-9]+/g, "-")
72
+ .replace(/^-+|-+$/g, "");
73
+ }
74
+
75
+ /**
76
+ * Eingabe der Ordner-Frage auflösen: `.` = aktueller Ordner (Slug = dessen
77
+ * Name), sonst ein Ordnername relativ zum Arbeitsverzeichnis.
78
+ * @returns {{ zielPfad: string, ziel: string }}
79
+ */
80
+ export function loeseZiel(eingabe, cwd = process.cwd()) {
81
+ const wert = String(eingabe).trim();
82
+ if (wert === ".") return { zielPfad: ".", ziel: basename(cwd) };
83
+ return { zielPfad: wert, ziel: wert };
84
+ }
85
+
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@iddp/create-vefk-app",
3
+ "version": "0.7.0",
4
+ "description": "Scaffolder der VEFK-Tools-Suite — erzeugt ein neues Tool aus vefk-app-template und ersetzt die Platzhalter (Toolname, Marke, Auth-Profil, 2FA/Mail).",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-vefk-app": "bin/create-vefk-app.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "lib",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20.11.0"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test test/*.test.js"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/esgoet/create-vefk-app.git"
26
+ },
27
+ "keywords": [
28
+ "create",
29
+ "scaffolder",
30
+ "vefk"
31
+ ],
32
+ "author": "Eva Goetzke",
33
+ "license": "MIT"
34
+ }