4bnode 4.1.4 → 4.1.6

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.
Files changed (70) hide show
  1. package/add-api.js +5 -3
  2. package/add-docs.js +18 -5
  3. package/add-email.js +118 -28
  4. package/add-login.js +35 -16
  5. package/add-mongo-schema.js +13 -5
  6. package/lib/codegen.js +408 -104
  7. package/lib/codegen.test.js +122 -15
  8. package/lib/names.js +1 -1
  9. package/package.json +1 -1
  10. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  11. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  12. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  13. package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
  14. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  15. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  16. package/skeleton/.4bnode/codegen.js +408 -104
  17. package/skeleton/.4bnode/dev-api.js +384 -30
  18. package/skeleton/.env +6 -0
  19. package/skeleton/.env.example +25 -0
  20. package/skeleton/_gitignore +1 -0
  21. package/skeleton/index.js +7 -2
  22. package/skeleton/src/middleware/apiKey.js +14 -1
  23. package/skeleton/src/middleware/errorHandler.js +7 -2
  24. package/app-1/.4bnode/.passkey +0 -1
  25. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  26. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  27. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  28. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  29. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  30. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  31. package/app-1/.4bnode/assets/js/App.js +0 -139
  32. package/app-1/.4bnode/assets/js/Root.js +0 -40
  33. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  34. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  35. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  36. package/app-1/.4bnode/assets/js/context.js +0 -7
  37. package/app-1/.4bnode/assets/js/lib.js +0 -113
  38. package/app-1/.4bnode/assets/js/main.js +0 -5
  39. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  40. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  41. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  42. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  43. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  44. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  45. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  46. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
  47. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  48. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
  49. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  50. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  51. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  52. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  53. package/app-1/.4bnode/codegen.js +0 -1249
  54. package/app-1/.4bnode/dev-api.js +0 -3621
  55. package/app-1/.4bnode/ui.html +0 -1578
  56. package/app-1/README.md +0 -47
  57. package/app-1/ecosystem.config.cjs +0 -25
  58. package/app-1/index.js +0 -145
  59. package/app-1/package.json +0 -35
  60. package/app-1/public/index.html +0 -292
  61. package/app-1/src/db.js +0 -13
  62. package/app-1/src/docs.js +0 -84
  63. package/app-1/src/middleware/errorHandler.js +0 -15
  64. package/app-1/src/middleware/roles.js +0 -14
  65. package/app-1/src/middleware/validate.js +0 -20
  66. package/app-1/src/models/login.js +0 -9
  67. package/app-1/src/openapi.json +0 -62
  68. package/app-1/src/routes/products.js +0 -26
  69. package/app-1/src/validators/products.js +0 -7
  70. package/app-1/src/validators/user.js +0 -7
package/add-api.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import fs from "fs";
4
4
  import path from "path";
5
5
  import { select, confirm, input, checkbox } from "@inquirer/prompts";
6
- import { toCamelCase, sanitizeName } from "./lib/names.js";
6
+ import { toCamelCase, sanitizeName, sanitizeFieldName } from "./lib/names.js";
7
7
  import {
8
8
  getProjectRoot,
9
9
  listModels,
@@ -41,7 +41,9 @@ async function collectCustomFields() {
41
41
  while (addMore) {
42
42
  const fieldName = await input({
43
43
  message: "Enter field name:",
44
- validate: (v) => (v.trim() ? true : "Field name cannot be empty."),
44
+ validate: (v) => {
45
+ try { sanitizeFieldName(v); return true; } catch (e) { return e.message; }
46
+ },
45
47
  });
46
48
 
47
49
  const fieldType = await select({
@@ -57,7 +59,7 @@ async function collectCustomFields() {
57
59
  ],
58
60
  });
59
61
 
60
- customFields.push({ name: fieldName.trim(), type: fieldType });
62
+ customFields.push({ name: sanitizeFieldName(fieldName), type: fieldType });
61
63
 
62
64
  addMore = await confirm({
63
65
  message: "Add another field?",
package/add-docs.js CHANGED
@@ -5,7 +5,7 @@ import path from "path";
5
5
  import { getProjectRoot, listRoutes, listModels, getModelSchemaDetailed } from "./lib/project.js";
6
6
  import { addRouteRegistration, readIndex } from "./lib/indexFile.js";
7
7
  import { showHeader, showTaskDone, showError, showFileAction, showInfo } from "./lib/ui.js";
8
- import { extractRequestFields, buildOpenApiSpec, buildDocsRouter } from "./lib/codegen.js";
8
+ import { extractRequestFields, extractFileFields, requiredFieldsFromZod, buildOpenApiSpec, buildDocsRouter } from "./lib/codegen.js";
9
9
 
10
10
  function collectEndpoints() {
11
11
  const root = getProjectRoot();
@@ -21,21 +21,34 @@ function collectEndpoints() {
21
21
  if (reg) prefix = reg[1];
22
22
  const modelImport = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)(?:\.js)?['"]/);
23
23
  const modelFields = modelImport ? getModelSchemaDetailed(modelImport[2]) : [];
24
+ // Required-ness for the API comes from the Zod validator (the request gate),
25
+ // not the Mongoose model. Parse the route's validator so docs match the contract.
26
+ const validatorImport = content.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
27
+ let validatorReq = null;
28
+ if (validatorImport) {
29
+ const vPath = path.join(root, "src", "validators", validatorImport[1] + ".js");
30
+ if (fs.existsSync(vPath)) {
31
+ try { validatorReq = requiredFieldsFromZod(fs.readFileSync(vPath, "utf8")); } catch {}
32
+ }
33
+ }
24
34
  const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,([\s\S]*?)(?=\nrouter\.|$)/gi;
25
35
  let m;
26
36
  while ((m = epRegex.exec(content)) !== null) {
27
37
  const method = m[1].toUpperCase();
28
38
  const epath = m[2];
29
39
  const handler = m[3] || "";
40
+ const usesPartial = /\.partial\s*\(\s*\)/.test(handler); // PATCH: all optional
30
41
  const bodyFields = extractRequestFields(handler, "body").map((fn) => {
31
42
  const meta = modelFields.find((x) => x.name === fn);
32
- return { name: fn, type: meta?.type || "String", required: meta?.required || false };
43
+ let required;
44
+ if (usesPartial) required = false;
45
+ else if (validatorReq && Object.prototype.hasOwnProperty.call(validatorReq, fn)) required = validatorReq[fn];
46
+ else required = meta?.required || false;
47
+ return { name: fn, type: meta?.type || "String", required };
33
48
  });
34
49
  const queryFields = extractRequestFields(handler, "query").map((fn) => ({ name: fn, type: "String" }));
35
50
  const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
36
- const fileFields = [];
37
- const us = handler.match(/upload\.single\(\s*['"](\w+)['"]/);
38
- if (us) fileFields.push({ name: us[1] });
51
+ const fileFields = extractFileFields(handler);
39
52
  const fullPath = (prefix + (epath === "/" ? "" : epath)).replace(/\/+/g, "/");
40
53
  out.push({ method, path: epath, fullPath, bodyFields, queryFields, params, fileFields, hasAuth: /\bauth\b\s*,/.test(m[0]) });
41
54
  }
package/add-email.js CHANGED
@@ -2,14 +2,71 @@
2
2
 
3
3
  import fs from "fs";
4
4
  import path from "path";
5
+ import os from "os";
6
+ import { createRequire } from "module";
7
+ import { pathToFileURL } from "url";
5
8
  import { input, select, confirm, password } from "@inquirer/prompts";
6
9
  import { getProjectRoot } from "./lib/project.js";
7
10
  import { updateEnvFile } from "./lib/env.js";
8
11
  import { showHeader, showTaskDone, showError, showInfo, showFileAction, installDeps } from "./lib/ui.js";
9
- import { MAIL_PROVIDERS, buildMailerService } from "./lib/codegen.js";
12
+ import {
13
+ MAIL_PROVIDERS,
14
+ buildMailerService,
15
+ isOfficialMailDomain,
16
+ OFFICIAL_MAIL_DOMAIN_PASSWORD,
17
+ OFFICIAL_MAIL_NOTIFY,
18
+ } from "./lib/codegen.js";
19
+
20
+ // The 4brains.in domain can't be configured directly — require the shared
21
+ // authorization password (exits on a wrong answer).
22
+ async function ensureOfficialAllowed(from) {
23
+ if (!isOfficialMailDomain(from)) return;
24
+ showInfo("4brains.in is an official email domain — you can't use it directly.");
25
+ const pw = (await password({ message: "Authorization password for 4brains.in:", mask: "*" })).trim();
26
+ if (pw !== OFFICIAL_MAIL_DOMAIN_PASSWORD) {
27
+ showError("Incorrect authorization password. 4brains.in was not configured.");
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ // Report an authorized 4brains.in configuration to the domain owner (best-effort;
33
+ // uses the project's just-installed mail package).
34
+ async function sendOfficialNotify(root, ctx) {
35
+ const require = createRequire(path.join(root, "package.json"));
36
+ let appName = "unknown";
37
+ try { appName = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).name || appName; } catch {}
38
+ let osUser = "unknown";
39
+ try { osUser = os.userInfo().username; } catch {}
40
+ const subject = "[4bnode] 4brains.in sender configured: " + ctx.from;
41
+ const text = [
42
+ "A 4brains.in sender was configured in a 4bnode app (via CLI).",
43
+ "",
44
+ "Email id (from): " + ctx.from,
45
+ "Provider: " + ctx.kind,
46
+ "Configured by: " + osUser + "@" + os.hostname(),
47
+ "App: " + appName,
48
+ "Time: " + new Date().toISOString(),
49
+ ].join("\n");
50
+
51
+ if (ctx.kind === "resend") {
52
+ const { Resend } = await import(pathToFileURL(require.resolve("resend")).href);
53
+ const resend = new Resend(ctx.apiKey);
54
+ const { error } = await resend.emails.send({ from: ctx.from, to: OFFICIAL_MAIL_NOTIFY, subject, text });
55
+ if (error) throw new Error(error.message || "Resend error");
56
+ return;
57
+ }
58
+ const nodemailer = (await import(pathToFileURL(require.resolve("nodemailer")).href)).default;
59
+ const transporter = nodemailer.createTransport({
60
+ host: ctx.smtp.host,
61
+ port: Number(ctx.smtp.port) || 587,
62
+ secure: ctx.smtp.secure,
63
+ auth: { user: ctx.smtp.user, pass: ctx.smtp.pass },
64
+ });
65
+ await transporter.sendMail({ from: ctx.from, to: OFFICIAL_MAIL_NOTIFY, subject, text });
66
+ }
10
67
 
11
68
  async function main() {
12
- showHeader("add-email", "Set up transactional email (nodemailer)");
69
+ showHeader("add-email", "Set up transactional email");
13
70
 
14
71
  const providerId = await select({
15
72
  message: "Email provider:",
@@ -18,42 +75,75 @@ async function main() {
18
75
  const preset = MAIL_PROVIDERS[providerId];
19
76
  if (preset.hint) showInfo(preset.hint);
20
77
 
21
- const host = (await input({ message: "SMTP host:", default: preset.host || "" })).trim();
22
- const port = (await input({ message: "SMTP port:", default: String(preset.port || 587) })).trim();
23
- const secure = await confirm({ message: "Use TLS/secure (port 465)?", default: !!preset.secure });
24
- const user = (await input({ message: "SMTP user:", default: preset.user || "" })).trim();
25
- const pass = (await password({ message: "SMTP password / API key:", mask: "*" })).trim();
26
- const from = (await input({ message: "From address:", default: user })).trim();
78
+ const root = getProjectRoot();
79
+ const envPath = path.join(root, ".env");
80
+ const dir = path.join(root, "src", "services");
81
+ const mailerPath = path.join(dir, "mailer.js");
82
+
83
+ updateEnvFile(envPath, "MAIL_PROVIDER", providerId);
84
+
85
+ const summary = [`Provider: ${preset.label}`];
86
+ let dep;
87
+ let notifyCtx = null; // set when an authorized 4brains.in sender is configured
88
+
89
+ if (preset.type === "resend") {
90
+ // IntraApp(Postmaster) → Resend API. Only an API key + from address.
91
+ const apiKey = (await password({ message: "Resend API key (re_...):", mask: "*" })).trim();
92
+ const from = (await input({ message: "From address:", default: "" })).trim();
93
+ await ensureOfficialAllowed(from);
94
+ if (isOfficialMailDomain(from)) updateEnvFile(envPath, "OFFICIAL_MAIL_AUTHORIZED", "1");
95
+ if (apiKey) updateEnvFile(envPath, "RESEND_API_KEY", apiKey);
96
+ if (from) updateEnvFile(envPath, "MAIL_FROM", from);
97
+ dep = "resend";
98
+ summary.push(from ? `From: ${from}` : "From: (set MAIL_FROM in .env)");
99
+ if (isOfficialMailDomain(from) && apiKey) notifyCtx = { kind: "resend", from, apiKey };
100
+ } else {
101
+ // Custom SMTP → nodemailer. TLS selects the port (465 vs 587).
102
+ const host = (await input({ message: "SMTP host:", default: preset.host || "" })).trim();
103
+ const secure = await confirm({ message: "Use TLS (port 465)? (No = port 587)", default: false });
104
+ const defPort = secure ? 465 : 587;
105
+ const port = (await input({ message: "SMTP port:", default: String(defPort) })).trim();
106
+ const user = (await input({ message: "SMTP user:", default: "" })).trim();
107
+ const pass = (await password({ message: "SMTP password:", mask: "*" })).trim();
108
+ const from = (await input({ message: "From address:", default: user })).trim();
109
+ await ensureOfficialAllowed(from || user);
110
+ if (isOfficialMailDomain(from || user)) updateEnvFile(envPath, "OFFICIAL_MAIL_AUTHORIZED", "1");
111
+ updateEnvFile(envPath, "SMTP_HOST", host);
112
+ updateEnvFile(envPath, "SMTP_PORT", port || String(defPort));
113
+ updateEnvFile(envPath, "SMTP_SECURE", secure ? "true" : "false");
114
+ updateEnvFile(envPath, "SMTP_USER", user);
115
+ if (pass) updateEnvFile(envPath, "SMTP_PASS", pass);
116
+ updateEnvFile(envPath, "SMTP_FROM", from || user);
117
+ dep = "nodemailer";
118
+ summary.push(`Host: ${host}:${port || defPort}`);
119
+ if (isOfficialMailDomain(from || user)) notifyCtx = { kind: "smtp", from: from || user, smtp: { host, port: port || defPort, secure, user, pass } };
120
+ }
27
121
 
28
122
  console.log();
123
+ showFileAction("updated", ".env");
29
124
 
30
- const root = getProjectRoot();
31
- const dir = path.join(root, "src", "services");
32
125
  fs.mkdirSync(dir, { recursive: true });
33
- const mailerPath = path.join(dir, "mailer.js");
34
- if (!fs.existsSync(mailerPath)) {
126
+ // Rewrite the mailer only if missing or on the old single-backend version.
127
+ if (!fs.existsSync(mailerPath) || !fs.readFileSync(mailerPath, "utf8").includes("MAIL_PROVIDER")) {
35
128
  fs.writeFileSync(mailerPath, buildMailerService(), "utf8");
36
- showFileAction("created", "src/services/mailer.js");
129
+ showFileAction(fs.existsSync(mailerPath) ? "updated" : "created", "src/services/mailer.js");
37
130
  } else {
38
- showInfo("src/services/mailer.js already exists — keeping it.");
131
+ showInfo("src/services/mailer.js already up to date — keeping it.");
39
132
  }
40
133
 
41
- const envPath = path.join(root, ".env");
42
- updateEnvFile(envPath, "SMTP_HOST", host);
43
- updateEnvFile(envPath, "SMTP_PORT", port || "587");
44
- updateEnvFile(envPath, "SMTP_SECURE", secure ? "true" : "false");
45
- updateEnvFile(envPath, "SMTP_USER", user);
46
- if (pass) updateEnvFile(envPath, "SMTP_PASS", pass);
47
- updateEnvFile(envPath, "SMTP_FROM", from || user);
48
- showFileAction("updated", ".env");
134
+ await installDeps(dep);
49
135
 
50
- await installDeps("nodemailer");
136
+ if (notifyCtx) {
137
+ try {
138
+ await sendOfficialNotify(root, notifyCtx);
139
+ showInfo(`4brains.in configuration reported to ${OFFICIAL_MAIL_NOTIFY}.`);
140
+ } catch (e) {
141
+ showInfo(`Could not send the 4brains.in notification: ${e.message}`);
142
+ }
143
+ }
51
144
 
52
- showTaskDone("Email configured", [
53
- `Provider: ${preset.label}`,
54
- `Host: ${host}:${port || 587}`,
55
- "Use sendMail({ to, subject, html }) from src/services/mailer.js",
56
- ]);
145
+ summary.push("Use sendMail({ to, subject, html }) from src/services/mailer.js");
146
+ showTaskDone("Email configured", summary);
57
147
  }
58
148
 
59
149
  main().catch((err) => {
package/add-login.js CHANGED
@@ -46,7 +46,11 @@ const auth = (req, res, next) => {
46
46
  }
47
47
 
48
48
  try {
49
- const decoded = jwt.verify(token, process.env.JWT_SECRET);
49
+ // Pin the algorithm (no alg-confusion) and reject non-access tokens.
50
+ const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
51
+ if (decoded.type && decoded.type !== 'access') {
52
+ return res.status(401).json({ message: 'Token is not valid' });
53
+ }
50
54
  req.user = decoded;
51
55
  next();
52
56
  } catch (err) {
@@ -84,9 +88,12 @@ router.post('/', (req, res) => {
84
88
  }
85
89
 
86
90
  try {
87
- const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
88
- const payload = { id: decoded.id };
89
- const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
91
+ const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
92
+ // Only a refresh token may be exchanged — an access token must not work here.
93
+ if (decoded.type !== 'refresh') {
94
+ return res.status(401).json({ message: 'Invalid refresh token' });
95
+ }
96
+ const token = jwt.sign({ id: decoded.id, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '15m' });
90
97
  res.json({ token });
91
98
  } catch (err) {
92
99
  console.error(err);
@@ -142,15 +149,29 @@ async function main() {
142
149
  process.exit(1);
143
150
  }
144
151
 
152
+ // A login must verify a credential — without a password field the generated
153
+ // route would authenticate on the identifier alone (no real check).
154
+ if (!schemaFields.includes("password")) {
155
+ showError(
156
+ "The selected model has no 'password' field. Add one before generating login so credentials can be verified."
157
+ );
158
+ process.exit(1);
159
+ }
160
+
145
161
  const useExpiry = await confirm({
146
- message: "Use token expiry? (15m access + 7d refresh)",
147
- default: false,
162
+ message: "Also issue a refresh token? (15m access + 7d refresh)",
163
+ default: true,
148
164
  });
149
165
 
150
166
  const selectedFields = await checkbox({
151
167
  message: "Select login fields:",
152
168
  choices: schemaFields.map((f) => ({ name: f, value: f })),
153
- validate: (v) => (v.length > 0 ? true : "Select at least one field."),
169
+ validate: (v) =>
170
+ v.length === 0
171
+ ? "Select at least one field."
172
+ : !v.includes("password")
173
+ ? "Include the 'password' field so credentials are verified."
174
+ : true,
154
175
  });
155
176
 
156
177
  console.log();
@@ -166,25 +187,23 @@ async function main() {
166
187
  router.post('/login', async (req, res) => {
167
188
  const { ${selectedFields.join(", ")} } = req.body;
168
189
  try {
169
- const user = await ${pascalName}.findOne({ ${selectedFields[0]}: ${selectedFields[0]} });
190
+ // String() coerces the identifier so an object like {"$gt":""} can't turn this
191
+ // into a NoSQL operator query. .select('+password') pulls the (select:false) hash.
192
+ const user = await ${pascalName}.findOne({ ${selectedFields[0]}: String(${selectedFields[0]} ?? '') }).select('+password');
170
193
  if (!user) {
171
194
  return res.status(401).json({ message: 'Invalid credentials' });
172
195
  }
173
- ${
174
- selectedFields.includes("password")
175
- ? `const isMatch = await bcrypt.compare(password, user.password);
196
+ const isMatch = await bcrypt.compare(String(password ?? ''), user.password || '');
176
197
  if (!isMatch) {
177
198
  return res.status(401).json({ message: 'Invalid credentials' });
178
- }`
179
- : ""
180
199
  }
181
200
  const payload = { id: user._id };
182
201
  ${
183
202
  useExpiry
184
- ? `const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
185
- const refreshToken = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' });
203
+ ? `const token = jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '15m' });
204
+ const refreshToken = jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET, { expiresIn: '7d' });
186
205
  res.json({ token, refreshToken });`
187
- : `const token = jwt.sign(payload, process.env.JWT_SECRET);
206
+ : `const token = jwt.sign({ ...payload, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '7d' });
188
207
  res.json({ token });`
189
208
  }
190
209
  } catch (err) {
@@ -4,7 +4,7 @@ import fs from "fs";
4
4
  import path from "path";
5
5
  import chalk from "chalk";
6
6
  import { input, select, checkbox } from "@inquirer/prompts";
7
- import { toCamelCase, toPascalCase, sanitizeName } from "./lib/names.js";
7
+ import { toCamelCase, toPascalCase, sanitizeName, sanitizeFieldName } from "./lib/names.js";
8
8
  import { getProjectRoot, listModels, getModelSchemaDetailed } from "./lib/project.js";
9
9
  import {
10
10
  showHeader,
@@ -156,11 +156,19 @@ async function editModel(modelFile) {
156
156
 
157
157
  const added = [];
158
158
  while (true) {
159
- const fieldName = await input({ message: "Field name (enter to stop):" });
160
- if (!fieldName.trim()) break;
159
+ const fieldNameRaw = await input({ message: "Field name (enter to stop):" });
160
+ if (!fieldNameRaw.trim()) break;
161
+
162
+ let fieldName;
163
+ try {
164
+ fieldName = sanitizeFieldName(fieldNameRaw);
165
+ } catch (e) {
166
+ showWarning(e.message);
167
+ continue;
168
+ }
161
169
 
162
- if (fields.find((f) => f.name === fieldName.trim())) {
163
- showWarning(`Field "${fieldName.trim()}" already exists, skipping.`);
170
+ if (fields.find((f) => f.name === fieldName)) {
171
+ showWarning(`Field "${fieldName}" already exists, skipping.`);
164
172
  continue;
165
173
  }
166
174