4bnode 4.0.9 → 4.1.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/README.md +25 -206
- package/add-crud.js +6 -106
- package/add-docs.js +91 -0
- package/add-email.js +63 -0
- package/add-security.js +143 -0
- package/index.js +76 -8
- package/lib/codegen.js +1223 -0
- package/lib/codegen.test.js +480 -0
- package/lib/indexFile.js +10 -12
- package/lib/names.js +4 -21
- package/lib/routeFile.js +9 -27
- package/lib/sync-skeleton.js +47 -0
- package/lib/ui.js +15 -2
- package/package.json +4 -2
- package/scripts/audit-dev-surface.mjs +153 -0
- package/scripts/reveal-passkey.mjs +35 -0
- package/skeleton/.4bnode/assets/js/App.js +31 -9
- package/skeleton/.4bnode/assets/js/Root.js +1 -5
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
- package/skeleton/.4bnode/assets/js/lib.js +18 -5
- package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
- package/skeleton/.4bnode/codegen.js +1223 -0
- package/skeleton/.4bnode/dev-api.js +634 -728
- package/skeleton/.4bnode/ui.html +62 -40
- package/skeleton/_gitignore +7 -0
- package/skeleton/index.js +41 -3
- package/skeleton/package.json +4 -1
- package/skeleton/src/middleware/apiKey.js +36 -0
- package/skeleton/src/middleware/errorHandler.js +15 -0
- package/skeleton/src/middleware/roles.js +14 -0
- package/skeleton/src/middleware/validate.js +20 -0
- package/skeleton/.4bnode/assets/images/mysql.png +0 -0
- package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
- package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lightweight assertion tests for the shared codegen layer. No test framework —
|
|
3
|
+
// run with `npm test`. Exits non-zero on first failure.
|
|
4
|
+
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import assert from "assert";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
toCamelCase,
|
|
13
|
+
toPascalCase,
|
|
14
|
+
sanitizeName,
|
|
15
|
+
addImportToContent,
|
|
16
|
+
insertCodeIntoContent,
|
|
17
|
+
buildInsertCode,
|
|
18
|
+
buildReadCode,
|
|
19
|
+
buildUpdateCode,
|
|
20
|
+
buildDeleteCode,
|
|
21
|
+
generateMongooseModel,
|
|
22
|
+
buildErrorHandlerFile,
|
|
23
|
+
buildRolesGuardFile,
|
|
24
|
+
buildApiKeyFile,
|
|
25
|
+
buildValidateFile,
|
|
26
|
+
buildZodSchemaFile,
|
|
27
|
+
wireValidateIntoRouteContent,
|
|
28
|
+
securityWiring,
|
|
29
|
+
addImportAfterLastImport,
|
|
30
|
+
insertBeforeListenContent,
|
|
31
|
+
insertBeforeRoutesContent,
|
|
32
|
+
ERROR_HANDLER_MARKER,
|
|
33
|
+
normalizeAiPlan,
|
|
34
|
+
buildCrudRouteFile,
|
|
35
|
+
AI_PROVIDERS,
|
|
36
|
+
DEFAULT_AI_PROVIDER,
|
|
37
|
+
addDepsToPackageJson,
|
|
38
|
+
extractRequestFields,
|
|
39
|
+
buildOpenApiSpec,
|
|
40
|
+
buildDocsHtml,
|
|
41
|
+
buildDocsRouter,
|
|
42
|
+
MAIL_PROVIDERS,
|
|
43
|
+
buildMailerService,
|
|
44
|
+
} from "./codegen.js";
|
|
45
|
+
|
|
46
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const root = path.join(__dirname, "..");
|
|
48
|
+
|
|
49
|
+
let passed = 0;
|
|
50
|
+
function test(name, fn) {
|
|
51
|
+
fn();
|
|
52
|
+
passed++;
|
|
53
|
+
console.log(` ✔ ${name}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Name helpers ────────────────────────────────────────────────────────────
|
|
57
|
+
test("toCamelCase / toPascalCase", () => {
|
|
58
|
+
assert.equal(toCamelCase("user-profile"), "userProfile");
|
|
59
|
+
assert.equal(toPascalCase("user-profile"), "UserProfile");
|
|
60
|
+
assert.equal(toPascalCase("user"), "User");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("sanitizeName accepts valid, rejects invalid", () => {
|
|
64
|
+
assert.equal(sanitizeName(" users "), "users");
|
|
65
|
+
assert.throws(() => sanitizeName("1bad"));
|
|
66
|
+
assert.throws(() => sanitizeName(""));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── String route helpers ────────────────────────────────────────────────────
|
|
70
|
+
test("addImportToContent is idempotent", () => {
|
|
71
|
+
const base = "import express from 'express';\nexport default router;\n";
|
|
72
|
+
const imp = "import User from '../models/user.js';";
|
|
73
|
+
const once = addImportToContent(base, imp);
|
|
74
|
+
assert.ok(once.startsWith(imp));
|
|
75
|
+
assert.equal(addImportToContent(once, imp), once, "should not duplicate");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("insertCodeIntoContent inserts before export default router", () => {
|
|
79
|
+
const base = "const router = express.Router();\nexport default router;\n";
|
|
80
|
+
const out = insertCodeIntoContent(base, "router.get('/', (req,res)=>res.end());");
|
|
81
|
+
assert.ok(out.indexOf("router.get") < out.indexOf("export default router;"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── CRUD builders ───────────────────────────────────────────────────────────
|
|
85
|
+
test("buildInsertCode wires fields + bcrypt for password", () => {
|
|
86
|
+
const code = buildInsertCode("User", ["email", "password"], "req.body");
|
|
87
|
+
assert.ok(code.includes("email: req.body.email"));
|
|
88
|
+
assert.ok(code.includes("bcrypt.hash(newData.password"));
|
|
89
|
+
assert.ok(code.includes("new User(newData)"));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("buildInsertCode omits bcrypt when no password field", () => {
|
|
93
|
+
const code = buildInsertCode("Item", ["name"], "req.body");
|
|
94
|
+
assert.ok(!code.includes("bcrypt"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("buildRead/Update/Delete reference the model", () => {
|
|
98
|
+
assert.ok(buildReadCode("User").includes("User.find()"));
|
|
99
|
+
assert.ok(buildUpdateCode("User", ["name"], "req.body").includes("User.findByIdAndUpdate"));
|
|
100
|
+
assert.ok(buildDeleteCode("User").includes("User.findByIdAndDelete"));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Mongoose model generation ───────────────────────────────────────────────
|
|
104
|
+
test("generateMongooseModel emits types, refs, indexes", () => {
|
|
105
|
+
const out = generateMongooseModel(
|
|
106
|
+
"Post",
|
|
107
|
+
[
|
|
108
|
+
{ name: "title", type: "String", required: true },
|
|
109
|
+
{ name: "author", type: "ObjectId", ref: "User" },
|
|
110
|
+
],
|
|
111
|
+
[{ fields: [{ field: "title", direction: "text" }], unique: false }]
|
|
112
|
+
);
|
|
113
|
+
assert.ok(out.includes("title: { type: String, required: true }"));
|
|
114
|
+
assert.ok(out.includes("author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }"));
|
|
115
|
+
assert.ok(out.includes("PostSchema.index({ title: 'text' });"));
|
|
116
|
+
assert.ok(out.includes("mongoose.model('Post', PostSchema)"));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── End-to-end file wrapper (lib/routeFile.js) ──────────────────────────────
|
|
120
|
+
test("routeFile.js inserts CRUD into a real file", async () => {
|
|
121
|
+
const { addImportToRoute, insertCodeIntoRoute } = await import("./routeFile.js");
|
|
122
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "4bnode-test-"));
|
|
123
|
+
const file = path.join(tmp, "users.js");
|
|
124
|
+
fs.writeFileSync(
|
|
125
|
+
file,
|
|
126
|
+
"import express from 'express';\nconst router = express.Router();\n\nexport default router;\n"
|
|
127
|
+
);
|
|
128
|
+
addImportToRoute(file, "import User from '../models/user.js';");
|
|
129
|
+
insertCodeIntoRoute(file, buildReadCode("User"));
|
|
130
|
+
const result = fs.readFileSync(file, "utf8");
|
|
131
|
+
assert.ok(result.includes("import User from '../models/user.js';"));
|
|
132
|
+
assert.ok(result.indexOf("User.find()") < result.indexOf("export default router;"));
|
|
133
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── Phase 1: Security & Middleware ──────────────────────────────────────────
|
|
137
|
+
test("securityWiring composes imports + insertion blocks", () => {
|
|
138
|
+
const w = securityWiring(["helmet", "rateLimit", "errorHandler"]);
|
|
139
|
+
assert.ok(w.imports.includes("import helmet from 'helmet';"));
|
|
140
|
+
assert.ok(w.imports.includes("import rateLimit from 'express-rate-limit';"));
|
|
141
|
+
assert.ok(w.imports.some((i) => i.includes("errorHandler.js")));
|
|
142
|
+
assert.ok(w.beforeRoutes.includes("app.use(helmet())"));
|
|
143
|
+
assert.ok(w.beforeRoutes.includes("rateLimit({"));
|
|
144
|
+
assert.ok(w.beforeListen.includes("app.use(notFound)"));
|
|
145
|
+
assert.ok(w.beforeListen.includes("app.use(errorHandler)"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("securityWiring only includes selected features", () => {
|
|
149
|
+
const w = securityWiring(["helmet"]);
|
|
150
|
+
assert.equal(w.imports.length, 1);
|
|
151
|
+
assert.equal(w.beforeListen, "");
|
|
152
|
+
assert.ok(!w.beforeRoutes.includes("rateLimit"));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("middleware file builders produce valid-looking modules", () => {
|
|
156
|
+
assert.ok(buildErrorHandlerFile().includes("export function errorHandler"));
|
|
157
|
+
assert.ok(buildRolesGuardFile().includes("export default roles"));
|
|
158
|
+
assert.ok(buildApiKeyFile().includes("req.header('x-api-key')"));
|
|
159
|
+
assert.ok(buildValidateFile().includes("schema.safeParse"));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("buildZodSchemaFile maps mongoose types and optionality", () => {
|
|
163
|
+
const out = buildZodSchemaFile("user", [
|
|
164
|
+
{ name: "email", type: "String", required: true },
|
|
165
|
+
{ name: "age", type: "Number" },
|
|
166
|
+
{ name: "owner", type: "ObjectId", required: true },
|
|
167
|
+
{ name: "active", type: "Boolean" },
|
|
168
|
+
]);
|
|
169
|
+
assert.ok(out.includes("import { z } from 'zod';"));
|
|
170
|
+
assert.ok(out.includes("email: z.string(),"));
|
|
171
|
+
assert.ok(out.includes("age: z.coerce.number().optional(),"));
|
|
172
|
+
assert.ok(out.includes("owner: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid id'),"));
|
|
173
|
+
assert.ok(out.includes("active: z.coerce.boolean().optional(),"));
|
|
174
|
+
assert.ok(out.includes("export const userSchema"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("wireValidateIntoRouteContent guards write endpoints only, idempotently", () => {
|
|
178
|
+
const route = [
|
|
179
|
+
"import express from 'express';",
|
|
180
|
+
"import User from '../models/user.js';",
|
|
181
|
+
"const router = express.Router();",
|
|
182
|
+
"router.get('/', async (req, res) => { res.json([]); });",
|
|
183
|
+
"router.post('/', async (req, res) => { res.json({}); });",
|
|
184
|
+
"router.put('/:id', upload.single('avatar'), async (req, res) => { res.json({}); });",
|
|
185
|
+
"router.patch('/:id', async (req, res) => { res.json({}); });",
|
|
186
|
+
"router.delete('/:id', async (req, res) => { res.json({}); });",
|
|
187
|
+
"export default router;",
|
|
188
|
+
].join("\n");
|
|
189
|
+
const out = wireValidateIntoRouteContent(route, "user");
|
|
190
|
+
assert.ok(out.includes("import validate from '../middleware/validate.js';"));
|
|
191
|
+
assert.ok(out.includes("import { userSchema } from '../validators/user.js';"));
|
|
192
|
+
assert.ok(out.includes("router.post('/', validate(userSchema), async (req, res)"), "POST wired (full schema)");
|
|
193
|
+
assert.ok(out.includes("router.put('/:id', upload.single('avatar'), validate(userSchema), async (req, res)"), "PUT wired after multer (full schema)");
|
|
194
|
+
assert.ok(out.includes("router.patch('/:id', validate(userSchema.partial()), async (req, res)"), "PATCH wired with .partial()");
|
|
195
|
+
assert.ok(out.includes("router.get('/', async (req, res)"), "GET untouched");
|
|
196
|
+
assert.ok(out.includes("router.delete('/:id', async (req, res)"), "DELETE untouched");
|
|
197
|
+
assert.equal(wireValidateIntoRouteContent(out, "user"), out, "idempotent");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── Pure index.js transforms (shared by CLI + dashboard) ────────────────────
|
|
201
|
+
test("addImportAfterLastImport inserts after last import, idempotently", () => {
|
|
202
|
+
const base = "import a from 'a';\nimport b from 'b';\n\nconst x = 1;\n";
|
|
203
|
+
const out = addImportAfterLastImport(base, "import c from 'c';");
|
|
204
|
+
assert.ok(/import b from 'b';\nimport c from 'c';/.test(out));
|
|
205
|
+
assert.equal(addImportAfterLastImport(out, "import c from 'c';"), out, "idempotent");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("insertBeforeListenContent anchors on the start CALL, not the function def", () => {
|
|
209
|
+
const skeleton = `async function startServer(tryPort) {\n const listener = app.listen(tryPort);\n}\n\nstartServer(Number(port));\n`;
|
|
210
|
+
const out = insertBeforeListenContent(skeleton, "app.use(x);");
|
|
211
|
+
// function definition must be intact
|
|
212
|
+
assert.ok(out.includes("async function startServer(tryPort) {"));
|
|
213
|
+
// inserted code must be right before the bottom call, not inside the function
|
|
214
|
+
assert.ok(out.indexOf("app.use(x);") > out.indexOf("const listener"));
|
|
215
|
+
assert.ok(out.indexOf("app.use(x);") < out.lastIndexOf("startServer(Number(port));"));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("insertBeforeListenContent keeps code above the error-handler marker", () => {
|
|
219
|
+
const withHandler = `app.use('/api/a', a);\n\n${ERROR_HANDLER_MARKER}\napp.use(notFound);\n\nstartServer(Number(port));\n`;
|
|
220
|
+
const out = insertBeforeListenContent(withHandler, "app.use('/api/b', b);");
|
|
221
|
+
assert.ok(out.indexOf("app.use('/api/b', b);") < out.indexOf(ERROR_HANDLER_MARKER));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("insertBeforeRoutesContent inserts at the Start Server marker", () => {
|
|
225
|
+
const skeleton = `app.use(cors());\n\n// ── Start Server ──\nasync function startServer() {}\n`;
|
|
226
|
+
const out = insertBeforeRoutesContent(skeleton, "app.use(helmet());");
|
|
227
|
+
assert.ok(out.indexOf("app.use(helmet());") < out.indexOf("// ── Start Server ──"));
|
|
228
|
+
assert.ok(out.indexOf("app.use(helmet());") > out.indexOf("app.use(cors());"));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── Phase 2: AI Builder ─────────────────────────────────────────────────────
|
|
232
|
+
test("normalizeAiPlan keeps valid models/routes and coerces names", () => {
|
|
233
|
+
const out = normalizeAiPlan({
|
|
234
|
+
summary: "blog",
|
|
235
|
+
models: [
|
|
236
|
+
{
|
|
237
|
+
name: "blog-post",
|
|
238
|
+
fields: [
|
|
239
|
+
{ name: "title", type: "String", required: true },
|
|
240
|
+
{ name: "author", type: "ObjectId", ref: "user" },
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
routes: [{ name: "posts", model: "blog-post", operations: ["create", "read"] }],
|
|
245
|
+
});
|
|
246
|
+
assert.equal(out.models[0].name, "blogPost");
|
|
247
|
+
assert.equal(out.models[0].fields[1].ref, "User");
|
|
248
|
+
assert.equal(out.routes[0].model, "blogPost");
|
|
249
|
+
assert.deepEqual(out.routes[0].operations, ["create", "read"]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("normalizeAiPlan resolves route.model casing to the model file name (Linux-safe imports)", () => {
|
|
253
|
+
// LLM defines the model one way but references it with different capitalization
|
|
254
|
+
// in the route. The route's model must resolve to the EXACT model name so the
|
|
255
|
+
// generated import path matches the file on case-sensitive filesystems.
|
|
256
|
+
const out = normalizeAiPlan({
|
|
257
|
+
models: [{ name: "passcodeModel", fields: [{ name: "code", type: "String" }] }],
|
|
258
|
+
routes: [{ name: "passcodes", model: "PasscodeModel", operations: ["read"] }],
|
|
259
|
+
});
|
|
260
|
+
assert.equal(out.models[0].name, "passcodeModel");
|
|
261
|
+
assert.equal(out.routes[0].model, "passcodeModel"); // not "PasscodeModel"
|
|
262
|
+
// The import path in the generated route file must match the model file name.
|
|
263
|
+
const code = buildCrudRouteFile(out.routes[0].name, out.routes[0].model, ["code"], out.routes[0].operations);
|
|
264
|
+
assert.ok(code.includes("from '../models/passcodeModel.js'"));
|
|
265
|
+
assert.ok(!code.includes("PasscodeModel.js"));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("normalizeAiPlan drops junk, dedupes, defaults operations", () => {
|
|
269
|
+
const out = normalizeAiPlan({
|
|
270
|
+
models: [
|
|
271
|
+
{ name: "1bad", fields: [{ name: "x", type: "String" }] }, // invalid name → dropped
|
|
272
|
+
{ name: "item", fields: [] }, // no fields → dropped
|
|
273
|
+
{ name: "good", fields: [{ name: "a", type: "Nope" }, { name: "a", type: "String" }] }, // bad type→String, dup→1
|
|
274
|
+
{ name: "good", fields: [{ name: "z", type: "String" }] }, // duplicate model → dropped
|
|
275
|
+
],
|
|
276
|
+
routes: [
|
|
277
|
+
{ name: "things", model: "good", operations: ["frobnicate"] }, // bad ops → default all
|
|
278
|
+
{ name: "things", model: "good", operations: ["read"] }, // dup route → dropped
|
|
279
|
+
{ name: "x", operations: ["read"] }, // no model → dropped
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
assert.equal(out.models.length, 1);
|
|
283
|
+
assert.equal(out.models[0].name, "good");
|
|
284
|
+
assert.equal(out.models[0].fields.length, 1);
|
|
285
|
+
assert.equal(out.models[0].fields[0].type, "String");
|
|
286
|
+
assert.equal(out.routes.length, 1);
|
|
287
|
+
assert.deepEqual(out.routes[0].operations, ["create", "read", "update", "delete"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("buildCrudRouteFile produces a valid route module", () => {
|
|
291
|
+
const code = buildCrudRouteFile("posts", "post", ["title", "body"], ["create", "read"]);
|
|
292
|
+
assert.ok(code.startsWith("import express from 'express';"));
|
|
293
|
+
assert.ok(code.includes("import Post from '../models/post.js';"));
|
|
294
|
+
assert.ok(code.includes("const router = express.Router();"));
|
|
295
|
+
assert.ok(code.includes("title: req.body.title"));
|
|
296
|
+
assert.ok(code.includes("Post.find()"));
|
|
297
|
+
assert.ok(!code.includes("findByIdAndDelete"), "delete not requested");
|
|
298
|
+
assert.ok(code.trimEnd().endsWith("export default router;"));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("buildCrudRouteFile adds bcrypt when a password field is written", () => {
|
|
302
|
+
const code = buildCrudRouteFile("users", "user", ["email", "password"], ["create"]);
|
|
303
|
+
assert.ok(code.includes("import bcrypt from 'bcrypt';"));
|
|
304
|
+
assert.ok(code.includes("bcrypt.hash(newData.password"));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("AI_PROVIDERS defines openai + anthropic with required fields", () => {
|
|
308
|
+
for (const id of ["openai", "anthropic"]) {
|
|
309
|
+
const p = AI_PROVIDERS[id];
|
|
310
|
+
assert.ok(p, `${id} present`);
|
|
311
|
+
assert.equal(p.id, id);
|
|
312
|
+
assert.ok(p.keyEnv && p.defaultModel && p.label && p.keyPlaceholder, `${id} fields`);
|
|
313
|
+
}
|
|
314
|
+
assert.ok(AI_PROVIDERS[DEFAULT_AI_PROVIDER], "default provider is valid");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── Integration: index.js wiring keeps error handler last + def intact ──────
|
|
318
|
+
test("security wiring + later route keep correct order in index.js", async () => {
|
|
319
|
+
const { addImport, insertBeforeRoutes, insertBeforeListen, addRouteRegistration } =
|
|
320
|
+
await import("./indexFile.js");
|
|
321
|
+
|
|
322
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "4bnode-idx-"));
|
|
323
|
+
fs.copyFileSync(path.join(root, "skeleton", "index.js"), path.join(tmp, "index.js"));
|
|
324
|
+
fs.writeFileSync(path.join(tmp, ".env"), "PORT=3000\n");
|
|
325
|
+
|
|
326
|
+
const cwd = process.cwd();
|
|
327
|
+
process.chdir(tmp);
|
|
328
|
+
try {
|
|
329
|
+
const w = securityWiring(["helmet", "errorHandler"]);
|
|
330
|
+
w.imports.forEach(addImport);
|
|
331
|
+
insertBeforeRoutes(w.beforeRoutes);
|
|
332
|
+
insertBeforeListen(w.beforeListen);
|
|
333
|
+
// Register a route AFTER security — must land above the error handler.
|
|
334
|
+
addRouteRegistration(
|
|
335
|
+
"import orders from './src/routes/orders.js';",
|
|
336
|
+
"app.use('/api/orders', orders);"
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const idx = fs.readFileSync(path.join(tmp, "index.js"), "utf8");
|
|
340
|
+
const routePos = idx.indexOf("app.use('/api/orders', orders);");
|
|
341
|
+
const notFoundPos = idx.indexOf("app.use(notFound)");
|
|
342
|
+
const startCallPos = idx.search(/^startServer\(Number/m);
|
|
343
|
+
|
|
344
|
+
assert.ok(routePos > -1 && notFoundPos > -1, "route + error handler present");
|
|
345
|
+
assert.ok(routePos < notFoundPos, "route must precede error handler");
|
|
346
|
+
assert.ok(notFoundPos < startCallPos, "error handler must precede start call");
|
|
347
|
+
// The startServer function definition must not have been corrupted.
|
|
348
|
+
assert.ok(
|
|
349
|
+
idx.includes("async function startServer(tryPort) {"),
|
|
350
|
+
"startServer function definition intact"
|
|
351
|
+
);
|
|
352
|
+
} finally {
|
|
353
|
+
process.chdir(cwd);
|
|
354
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ── Dependency bookkeeping ───────────────────────────────────────────────────
|
|
359
|
+
test("addDepsToPackageJson adds missing deps with known versions, idempotently", () => {
|
|
360
|
+
const start = JSON.stringify({ name: "x", dependencies: { express: "^5.2.1" } }, null, 2);
|
|
361
|
+
const r = addDepsToPackageJson(start, ["helmet", "express", "zod"]);
|
|
362
|
+
assert.ok(r.changed);
|
|
363
|
+
const p = JSON.parse(r.content);
|
|
364
|
+
assert.equal(p.dependencies.helmet, "^8.0.0");
|
|
365
|
+
assert.equal(p.dependencies.zod, "^3.23.0");
|
|
366
|
+
assert.equal(p.dependencies.express, "^5.2.1", "existing dep left untouched");
|
|
367
|
+
assert.equal(addDepsToPackageJson(r.content, ["helmet"]).changed, false, "idempotent");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("addDepsToPackageJson supports devDependencies + unknown→latest", () => {
|
|
371
|
+
const out = addDepsToPackageJson(JSON.stringify({ name: "x" }), ["mongoose"], { dev: true });
|
|
372
|
+
assert.equal(JSON.parse(out.content).devDependencies.mongoose, "^8.8.0");
|
|
373
|
+
const unknown = addDepsToPackageJson(JSON.stringify({ name: "x" }), ["some-random-pkg"]);
|
|
374
|
+
assert.equal(JSON.parse(unknown.content).dependencies["some-random-pkg"], "latest");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ── API Tester field detection ───────────────────────────────────────────────
|
|
378
|
+
test("extractRequestFields detects destructuring AND req.body.x access", () => {
|
|
379
|
+
assert.deepEqual(
|
|
380
|
+
extractRequestFields("const { email, name } = req.body;"),
|
|
381
|
+
["email", "name"]
|
|
382
|
+
);
|
|
383
|
+
// generated CRUD style — direct member access, no destructuring
|
|
384
|
+
const insert = buildInsertCode("User", ["email", "password"], "req.body");
|
|
385
|
+
assert.deepEqual(extractRequestFields(insert).sort(), ["email", "password"].sort());
|
|
386
|
+
// bracket access + dedupe
|
|
387
|
+
assert.deepEqual(
|
|
388
|
+
extractRequestFields("const x = req.body['title']; log(req.body.title); req.body.body;"),
|
|
389
|
+
["title", "body"]
|
|
390
|
+
);
|
|
391
|
+
// query source
|
|
392
|
+
assert.deepEqual(extractRequestFields("const { page } = req.query;", "query"), ["page"]);
|
|
393
|
+
// nothing
|
|
394
|
+
assert.deepEqual(extractRequestFields("res.json({ ok: true });"), []);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("extractRequestFields works on a CRUD route (req.body.x)", () => {
|
|
398
|
+
const route = buildCrudRouteFile("posts", "Post", ["title", "views"], ["create"]);
|
|
399
|
+
assert.deepEqual(extractRequestFields(route).sort(), ["title", "views"].sort());
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ── API Docs (OpenAPI / Swagger) ─────────────────────────────────────────────
|
|
403
|
+
test("buildOpenApiSpec builds paths, params, requestBody, schemas, security", () => {
|
|
404
|
+
const spec = buildOpenApiSpec({
|
|
405
|
+
title: "MyApp",
|
|
406
|
+
endpoints: [
|
|
407
|
+
{ method: "GET", fullPath: "/api/users/:id", params: ["id"], queryFields: [], bodyFields: [] },
|
|
408
|
+
{ method: "POST", fullPath: "/api/users", params: [], queryFields: [], bodyFields: [{ name: "email", type: "String", required: true }, { name: "age", type: "Number" }], hasAuth: true },
|
|
409
|
+
],
|
|
410
|
+
models: [{ name: "user", fields: [{ name: "email", type: "String" }, { name: "age", type: "Number" }] }],
|
|
411
|
+
});
|
|
412
|
+
assert.equal(spec.openapi, "3.0.0");
|
|
413
|
+
assert.equal(spec.info.title, "MyApp");
|
|
414
|
+
// :id converted to {id}
|
|
415
|
+
assert.ok(spec.paths["/api/users/{id}"].get);
|
|
416
|
+
assert.deepEqual(spec.paths["/api/users/{id}"].get.parameters[0], { name: "id", in: "path", required: true, schema: { type: "string" } });
|
|
417
|
+
const post = spec.paths["/api/users"].post;
|
|
418
|
+
const schema = post.requestBody.content["application/json"].schema;
|
|
419
|
+
assert.deepEqual(schema.properties.email, { type: "string" });
|
|
420
|
+
assert.deepEqual(schema.properties.age, { type: "integer" });
|
|
421
|
+
assert.deepEqual(schema.required, ["email"]);
|
|
422
|
+
assert.deepEqual(post.security, [{ bearerAuth: [] }]);
|
|
423
|
+
assert.ok(spec.components.schemas.User);
|
|
424
|
+
assert.ok(spec.components.securitySchemes.bearerAuth);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("buildOpenApiSpec uses multipart when file fields present", () => {
|
|
428
|
+
const spec = buildOpenApiSpec({ endpoints: [{ method: "POST", fullPath: "/api/upload", bodyFields: [], fileFields: [{ name: "avatar" }] }] });
|
|
429
|
+
const schema = spec.paths["/api/upload"].post.requestBody.content["multipart/form-data"].schema;
|
|
430
|
+
assert.deepEqual(schema.properties.avatar, { type: "string", format: "binary" });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, no Swagger)", () => {
|
|
434
|
+
const html = buildDocsHtml("/docs/openapi.json");
|
|
435
|
+
assert.ok(html.includes("API Documentation"));
|
|
436
|
+
assert.ok(html.includes("SPEC_URL='/docs/openapi.json'"));
|
|
437
|
+
// Fully self-contained: no external URLs and no third-party doc branding.
|
|
438
|
+
assert.ok(!/https?:\/\//.test(html), "no external URLs in the docs page");
|
|
439
|
+
assert.ok(!/swagger/i.test(html), "no Swagger branding");
|
|
440
|
+
assert.ok(!/openapi/i.test(html.replace(/\/docs\/openapi\.json/g, "")), "no visible OpenAPI text");
|
|
441
|
+
const router = buildDocsRouter();
|
|
442
|
+
assert.ok(router.includes("openapi.json"));
|
|
443
|
+
assert.ok(router.includes("res.type('html')"));
|
|
444
|
+
assert.ok(router.trimEnd().endsWith("export default router;"));
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ── Email (nodemailer) ───────────────────────────────────────────────────────
|
|
448
|
+
test("MAIL_PROVIDERS includes Brevo + common providers with host/port", () => {
|
|
449
|
+
assert.equal(MAIL_PROVIDERS.brevo.host, "smtp-relay.brevo.com");
|
|
450
|
+
assert.equal(MAIL_PROVIDERS.brevo.port, 587);
|
|
451
|
+
for (const id of ["brevo", "sendgrid", "mailgun", "gmail", "mailtrap", "smtp"]) {
|
|
452
|
+
assert.ok(MAIL_PROVIDERS[id] && MAIL_PROVIDERS[id].label, id);
|
|
453
|
+
}
|
|
454
|
+
assert.equal(MAIL_PROVIDERS.gmail.secure, true);
|
|
455
|
+
assert.equal(MAIL_PROVIDERS.sendgrid.user, "apikey");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("buildMailerService produces a valid nodemailer module", () => {
|
|
459
|
+
const s = buildMailerService();
|
|
460
|
+
assert.ok(s.includes("import nodemailer from 'nodemailer';"));
|
|
461
|
+
assert.ok(s.includes("nodemailer.createTransport"));
|
|
462
|
+
assert.ok(s.includes("process.env.SMTP_HOST"));
|
|
463
|
+
assert.ok(s.includes("export async function sendMail"));
|
|
464
|
+
assert.ok(s.includes("export function emailTemplate"));
|
|
465
|
+
assert.ok(s.includes("export function verifyMailer"));
|
|
466
|
+
assert.ok(s.trimEnd().endsWith("};"));
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ── Skeleton drift guard ────────────────────────────────────────────────────
|
|
470
|
+
test("skeleton codegen.js is in sync with lib/codegen.js", () => {
|
|
471
|
+
const src = fs.readFileSync(path.join(root, "lib", "codegen.js"), "utf8");
|
|
472
|
+
const copy = fs.readFileSync(path.join(root, "skeleton", ".4bnode", "codegen.js"), "utf8");
|
|
473
|
+
assert.equal(
|
|
474
|
+
src,
|
|
475
|
+
copy,
|
|
476
|
+
"skeleton/.4bnode/codegen.js is stale — run `npm run sync`"
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
console.log(`\n${passed} passed`);
|
package/lib/indexFile.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { getProjectRoot } from "./project.js";
|
|
4
|
+
import { ERROR_HANDLER_MARKER, insertBeforeListenContent } from "./codegen.js";
|
|
4
5
|
|
|
5
6
|
function getIndexPath() {
|
|
6
7
|
return path.join(getProjectRoot(), "index.js");
|
|
@@ -30,7 +31,14 @@ export function addRouteRegistration(importLine, useLine) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
if (!content.includes(useLine.trim())) {
|
|
33
|
-
|
|
34
|
+
// The central error handler (added by `add security`) must stay last. If it's
|
|
35
|
+
// present, register new routes ABOVE it so notFound doesn't swallow them.
|
|
36
|
+
if (content.includes(ERROR_HANDLER_MARKER)) {
|
|
37
|
+
content = content.replace(
|
|
38
|
+
ERROR_HANDLER_MARKER,
|
|
39
|
+
useLine.trim() + "\n\n" + ERROR_HANDLER_MARKER
|
|
40
|
+
);
|
|
41
|
+
} else if (content.includes("app.listen(port")) {
|
|
34
42
|
content = content.replace(
|
|
35
43
|
"app.listen(port",
|
|
36
44
|
useLine.trim() + "\n" + "app.listen(port"
|
|
@@ -52,17 +60,7 @@ export function addRouteRegistration(importLine, useLine) {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
export function insertBeforeListen(codeBlock) {
|
|
55
|
-
|
|
56
|
-
const listenMatch = content.match(/(app|server)\.listen\(port/) || content.match(/^startServer\(/m);
|
|
57
|
-
if (listenMatch) {
|
|
58
|
-
content = content.replace(
|
|
59
|
-
listenMatch[0],
|
|
60
|
-
codeBlock.trim() + "\n\n" + listenMatch[0]
|
|
61
|
-
);
|
|
62
|
-
} else {
|
|
63
|
-
content += "\n" + codeBlock.trim() + "\n";
|
|
64
|
-
}
|
|
65
|
-
writeIndex(content);
|
|
63
|
+
writeIndex(insertBeforeListenContent(readIndex(), codeBlock));
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
export function replaceAppListen(newListenBlock) {
|
package/lib/names.js
CHANGED
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export function toPascalCase(str) {
|
|
6
|
-
const camel = toCamelCase(str);
|
|
7
|
-
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function sanitizeName(input) {
|
|
11
|
-
if (!input || typeof input !== "string") {
|
|
12
|
-
throw new Error("Name is required.");
|
|
13
|
-
}
|
|
14
|
-
const trimmed = input.trim();
|
|
15
|
-
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
|
|
16
|
-
throw new Error(
|
|
17
|
-
`Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
return trimmed;
|
|
21
|
-
}
|
|
1
|
+
// Name helpers live in lib/codegen.js (the pure, project-syncable module) so the
|
|
2
|
+
// CLI and the in-project dashboard share one implementation. Re-exported here to
|
|
3
|
+
// keep the existing `./lib/names.js` import path stable across the codebase.
|
|
4
|
+
export { toCamelCase, toPascalCase, sanitizeName } from "./codegen.js";
|
package/lib/routeFile.js
CHANGED
|
@@ -1,45 +1,27 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { getProjectRoot } from "./project.js";
|
|
4
|
+
import { addImportToContent, insertCodeIntoContent } from "./codegen.js";
|
|
4
5
|
|
|
5
6
|
export function getRouteFilePath(routeFile) {
|
|
6
7
|
return path.join(getProjectRoot(), "src", "routes", routeFile);
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function addImportToRoute(routeFilePath, importStatement) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const original = fs.readFileSync(routeFilePath, "utf8");
|
|
12
|
+
const content = addImportToContent(original, importStatement);
|
|
13
|
+
// Only write when the import was actually new (avoids spurious nodemon restarts).
|
|
14
|
+
if (content !== original) {
|
|
13
15
|
fs.writeFileSync(routeFilePath, content, "utf8");
|
|
14
16
|
}
|
|
15
17
|
return content;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export function insertCodeIntoRoute(routeFilePath, codeBlock) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (exportIndex !== -1) {
|
|
24
|
-
content =
|
|
25
|
-
content.slice(0, exportIndex) +
|
|
26
|
-
codeBlock.trim() +
|
|
27
|
-
"\n\n" +
|
|
28
|
-
content.slice(exportIndex);
|
|
29
|
-
} else {
|
|
30
|
-
// Fallback to inserting before last "});"
|
|
31
|
-
const lastClose = content.lastIndexOf("});");
|
|
32
|
-
if (lastClose !== -1) {
|
|
33
|
-
content =
|
|
34
|
-
content.slice(0, lastClose) +
|
|
35
|
-
codeBlock.trim() +
|
|
36
|
-
"\n" +
|
|
37
|
-
content.slice(lastClose);
|
|
38
|
-
} else {
|
|
39
|
-
content += "\n" + codeBlock.trim() + "\n";
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
21
|
+
const content = insertCodeIntoContent(
|
|
22
|
+
fs.readFileSync(routeFilePath, "utf8"),
|
|
23
|
+
codeBlock
|
|
24
|
+
);
|
|
43
25
|
fs.writeFileSync(routeFilePath, content, "utf8");
|
|
44
26
|
}
|
|
45
27
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
// Sync shared, project-bound modules into the skeleton.
|
|
4
|
+
//
|
|
5
|
+
// Some modules (currently lib/codegen.js) must exist BOTH in the 4bnode package
|
|
6
|
+
// (for the CLI) AND inside every generated project's .4bnode/ folder (for the
|
|
7
|
+
// in-project dashboard, dev-api.js). The package copy is the source of truth;
|
|
8
|
+
// this script copies it verbatim into skeleton/.4bnode/ so the two never drift.
|
|
9
|
+
//
|
|
10
|
+
// Run automatically before publish (prepublishOnly) and manually via `npm run sync`.
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const root = path.join(__dirname, "..");
|
|
19
|
+
|
|
20
|
+
// Each entry: a package file copied verbatim into the skeleton.
|
|
21
|
+
const SYNC_TARGETS = [
|
|
22
|
+
{
|
|
23
|
+
src: path.join(root, "lib", "codegen.js"),
|
|
24
|
+
dest: path.join(root, "skeleton", ".4bnode", "codegen.js"),
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
let changed = 0;
|
|
29
|
+
for (const { src, dest } of SYNC_TARGETS) {
|
|
30
|
+
if (!fs.existsSync(src)) {
|
|
31
|
+
console.error(`✖ Source missing: ${path.relative(root, src)}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const content = fs.readFileSync(src, "utf8");
|
|
36
|
+
const prev = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : null;
|
|
37
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
38
|
+
if (prev !== content) {
|
|
39
|
+
fs.writeFileSync(dest, content, "utf8");
|
|
40
|
+
changed++;
|
|
41
|
+
console.log(`~ synced ${path.relative(root, src)} → ${path.relative(root, dest)}`);
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`= up to date ${path.relative(root, dest)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(changed === 0 ? "✔ Skeleton already in sync" : `✔ Synced ${changed} file(s)`);
|