@dawudesign/node-hexa-cli 0.1.0 → 0.1.2
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/dist/index.js +1055 -12
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -73,19 +73,1062 @@ function checkLicense() {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
//
|
|
76
|
+
// ../../packages/parser/dist/index.js
|
|
77
|
+
import { Project } from "ts-morph";
|
|
78
|
+
import fs from "fs";
|
|
79
|
+
import path from "path";
|
|
80
|
+
async function parseProject(rootPath) {
|
|
81
|
+
const tsConfigFilePath = path.resolve(rootPath, "tsconfig.json");
|
|
82
|
+
if (!fs.existsSync(tsConfigFilePath)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`No tsconfig.json found at "${tsConfigFilePath}".
|
|
85
|
+
Make sure you are pointing to a valid TypeScript project root.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const project = new Project({ tsConfigFilePath });
|
|
89
|
+
const files = project.getSourceFiles().map((file) => {
|
|
90
|
+
const imports = file.getImportDeclarations().map((i) => i.getModuleSpecifierValue());
|
|
91
|
+
const classes = file.getClasses().map((cls) => ({
|
|
92
|
+
name: cls.getName() || "AnonymousClass",
|
|
93
|
+
decorators: cls.getDecorators().map((d) => d.getName())
|
|
94
|
+
}));
|
|
95
|
+
const interfaces = file.getInterfaces().map((i) => i.getName());
|
|
96
|
+
return {
|
|
97
|
+
path: file.getFilePath(),
|
|
98
|
+
imports,
|
|
99
|
+
classes,
|
|
100
|
+
interfaces
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
return { files };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ../../packages/core/src/config.ts
|
|
107
|
+
import path2 from "path";
|
|
108
|
+
import fs2 from "fs";
|
|
109
|
+
var defaultConfig = {
|
|
110
|
+
architecture: "hexagonal-ddd",
|
|
111
|
+
strict: true,
|
|
112
|
+
contextsDir: "src/contexts",
|
|
113
|
+
layers: {
|
|
114
|
+
domain: ["domain"],
|
|
115
|
+
application: ["application"],
|
|
116
|
+
infrastructure: ["infrastructure"],
|
|
117
|
+
adapterIn: ["controller", "http", "rest"],
|
|
118
|
+
adapterOut: ["repository", "persistence"]
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
function loadConfig(projectPath) {
|
|
122
|
+
const configPath = path2.join(projectPath, "node-hexa.config.json");
|
|
123
|
+
if (!fs2.existsSync(configPath)) {
|
|
124
|
+
return defaultConfig;
|
|
125
|
+
}
|
|
126
|
+
let userConfig;
|
|
127
|
+
try {
|
|
128
|
+
userConfig = JSON.parse(fs2.readFileSync(configPath, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(`Failed to parse node-hexa.config.json at "${configPath}". Make sure it is valid JSON.`);
|
|
131
|
+
}
|
|
132
|
+
const userLayers = userConfig.layers && typeof userConfig.layers === "object" ? userConfig.layers : {};
|
|
133
|
+
return {
|
|
134
|
+
architecture: "hexagonal-ddd",
|
|
135
|
+
strict: typeof userConfig.strict === "boolean" ? userConfig.strict : defaultConfig.strict,
|
|
136
|
+
contextsDir: typeof userConfig.contextsDir === "string" && userConfig.contextsDir.length > 0 ? userConfig.contextsDir : defaultConfig.contextsDir,
|
|
137
|
+
layers: {
|
|
138
|
+
...defaultConfig.layers,
|
|
139
|
+
...userLayers
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ../../packages/rules/dist/index.js
|
|
145
|
+
var SEVERITY_PENALTY = {
|
|
146
|
+
critical: 25,
|
|
147
|
+
high: 15,
|
|
148
|
+
medium: 10
|
|
149
|
+
};
|
|
150
|
+
function findNodeByImport(importPath, nodes) {
|
|
151
|
+
const normalizedImport = importPath.replaceAll("\\", "/").toLowerCase();
|
|
152
|
+
return nodes.find((n) => {
|
|
153
|
+
const filename = n.filePath.replaceAll("\\", "/").split("/").pop()?.replace(/\.ts$/, "").toLowerCase() ?? "";
|
|
154
|
+
return filename.length > 0 && normalizedImport.endsWith(filename);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function isOuterLayer(layer) {
|
|
158
|
+
return layer === "infrastructure" || layer === "adapter-in" || layer === "adapter-out";
|
|
159
|
+
}
|
|
160
|
+
function checkLayerViolation(node, target, violations) {
|
|
161
|
+
if (node.layer === "domain" && isOuterLayer(target.layer)) {
|
|
162
|
+
violations.push({
|
|
163
|
+
message: "Domain must not depend on infrastructure",
|
|
164
|
+
node: node.name,
|
|
165
|
+
filePath: node.filePath,
|
|
166
|
+
severity: "critical"
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (node.layer === "domain" && target.layer === "application") {
|
|
171
|
+
violations.push({
|
|
172
|
+
message: "Domain must not depend on application",
|
|
173
|
+
node: node.name,
|
|
174
|
+
filePath: node.filePath,
|
|
175
|
+
severity: "critical"
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (node.layer === "application" && isOuterLayer(target.layer)) {
|
|
180
|
+
violations.push({
|
|
181
|
+
message: "Application must not depend on infrastructure",
|
|
182
|
+
node: node.name,
|
|
183
|
+
filePath: node.filePath,
|
|
184
|
+
severity: "high"
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function checkFrameworkViolations(node, violations) {
|
|
189
|
+
if (node.layer !== "domain") return;
|
|
190
|
+
const forbidden = ["@nestjs", "express", "prisma", "mongoose", "typeorm"];
|
|
191
|
+
for (const imp of node.imports) {
|
|
192
|
+
if (forbidden.some((f) => imp.includes(f))) {
|
|
193
|
+
violations.push({
|
|
194
|
+
message: "Domain must not depend on frameworks",
|
|
195
|
+
node: node.name,
|
|
196
|
+
filePath: node.filePath,
|
|
197
|
+
severity: "critical"
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function runRules(model, strict = true) {
|
|
204
|
+
const violations = [];
|
|
205
|
+
for (const node of model.nodes) {
|
|
206
|
+
for (const imp of node.imports) {
|
|
207
|
+
const target = findNodeByImport(imp, model.nodes);
|
|
208
|
+
if (target) {
|
|
209
|
+
checkLayerViolation(node, target, violations);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
checkFrameworkViolations(node, violations);
|
|
213
|
+
}
|
|
214
|
+
return strict ? violations : violations.filter((v) => v.severity !== "medium");
|
|
215
|
+
}
|
|
216
|
+
function computeScore(violations) {
|
|
217
|
+
const penalty = violations.reduce(
|
|
218
|
+
(sum, v) => sum + SEVERITY_PENALTY[v.severity],
|
|
219
|
+
0
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
score: Math.max(100 - penalty, 0),
|
|
223
|
+
max: 100
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ../../packages/core/src/index.ts
|
|
228
|
+
import path11 from "path";
|
|
229
|
+
|
|
230
|
+
// ../../packages/core/src/mermaid.ts
|
|
231
|
+
function generateSubgraph(label, nodes) {
|
|
232
|
+
if (!nodes.length) return [];
|
|
233
|
+
return [`
|
|
234
|
+
subgraph ${label}`, ...nodes.map((n) => ` ${n.name}`), "end"];
|
|
235
|
+
}
|
|
236
|
+
function importsTarget(imp, target) {
|
|
237
|
+
const fileBaseName = target.filePath.split("/").pop()?.replace(".ts", "") ?? "";
|
|
238
|
+
if (!fileBaseName) return false;
|
|
239
|
+
const normalizedImp = imp.replace(/\.ts$/, "");
|
|
240
|
+
return normalizedImp === fileBaseName || normalizedImp.endsWith(`/${fileBaseName}`);
|
|
241
|
+
}
|
|
242
|
+
function generateEdges(nodes) {
|
|
243
|
+
const seen = /* @__PURE__ */ new Set();
|
|
244
|
+
const lines = [];
|
|
245
|
+
for (const node of nodes) {
|
|
246
|
+
for (const imp of node.imports) {
|
|
247
|
+
for (const target of nodes) {
|
|
248
|
+
if (target === node) continue;
|
|
249
|
+
const edgeKey = `${node.name}-->${target.name}`;
|
|
250
|
+
if (!seen.has(edgeKey) && importsTarget(imp, target)) {
|
|
251
|
+
seen.add(edgeKey);
|
|
252
|
+
lines.push(`${node.name} --> ${target.name}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
function generateMermaidGraph(model) {
|
|
260
|
+
const { nodes } = model;
|
|
261
|
+
const byLayer = (layer) => nodes.filter((n) => n.layer === layer);
|
|
262
|
+
const subgraphs = [
|
|
263
|
+
...generateSubgraph("Domain", byLayer("domain")),
|
|
264
|
+
...generateSubgraph("Application", byLayer("application")),
|
|
265
|
+
...generateSubgraph('AdapterIn["Adapter In (HTTP)"]', byLayer("adapter-in")),
|
|
266
|
+
...generateSubgraph('AdapterOut["Adapter Out (Persistence)"]', byLayer("adapter-out")),
|
|
267
|
+
...generateSubgraph("Infrastructure", byLayer("infrastructure"))
|
|
268
|
+
];
|
|
269
|
+
return ["flowchart LR", ...subgraphs, ...generateEdges(nodes)].join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ../../packages/core/src/docs.ts
|
|
273
|
+
import fs3 from "fs";
|
|
274
|
+
import path3 from "path";
|
|
275
|
+
function generateDocs(result, projectPath) {
|
|
276
|
+
const graph = generateMermaidGraph(result.model);
|
|
277
|
+
const domain = result.model.nodes.filter((n) => n.layer === "domain");
|
|
278
|
+
const application = result.model.nodes.filter((n) => n.layer === "application");
|
|
279
|
+
const infrastructure = result.model.nodes.filter((n) => n.layer === "infrastructure");
|
|
280
|
+
let doc = "# Architecture Documentation\n\n";
|
|
281
|
+
doc += "## Architecture Diagram\n\n";
|
|
282
|
+
doc += "```mermaid\n";
|
|
283
|
+
doc += graph + "\n";
|
|
284
|
+
doc += "```\n\n";
|
|
285
|
+
doc += "## Domain\n";
|
|
286
|
+
domain.forEach((n) => {
|
|
287
|
+
doc += `- **${n.name}** (${n.kind}) \u2014 \`${n.filePath}\`
|
|
288
|
+
`;
|
|
289
|
+
});
|
|
290
|
+
doc += "\n## Application\n";
|
|
291
|
+
application.forEach((n) => {
|
|
292
|
+
doc += `- **${n.name}** (${n.kind}) \u2014 \`${n.filePath}\`
|
|
293
|
+
`;
|
|
294
|
+
});
|
|
295
|
+
doc += "\n## Infrastructure\n";
|
|
296
|
+
infrastructure.forEach((n) => {
|
|
297
|
+
doc += `- **${n.name}** (${n.kind}) \u2014 \`${n.filePath}\`
|
|
298
|
+
`;
|
|
299
|
+
});
|
|
300
|
+
doc += "\n## Violations\n";
|
|
301
|
+
if (result.violations.length === 0) {
|
|
302
|
+
doc += "- \u2713 None\n";
|
|
303
|
+
} else {
|
|
304
|
+
result.violations.forEach((v) => {
|
|
305
|
+
doc += `- \u2717 **${v.message}** \u2014 \`${v.node}\` (${v.filePath})
|
|
306
|
+
`;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
doc += `
|
|
310
|
+
## Architecture Score
|
|
311
|
+
|
|
312
|
+
`;
|
|
313
|
+
doc += `**${result.score.score} / ${result.score.max}**
|
|
314
|
+
`;
|
|
315
|
+
if (projectPath) {
|
|
316
|
+
const outputPath = path3.join(projectPath, "architecture.md");
|
|
317
|
+
fs3.writeFileSync(outputPath, doc);
|
|
318
|
+
console.log(`Architecture documentation generated: ${outputPath}`);
|
|
319
|
+
}
|
|
320
|
+
return doc;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ../../packages/core/src/graph.ts
|
|
324
|
+
import fs4 from "fs";
|
|
325
|
+
import { execSync } from "child_process";
|
|
326
|
+
import nodePath from "path";
|
|
327
|
+
function getCandidateChromePaths() {
|
|
328
|
+
const candidates = [];
|
|
329
|
+
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
|
|
330
|
+
candidates.push(process.env.PUPPETEER_EXECUTABLE_PATH);
|
|
331
|
+
}
|
|
332
|
+
candidates.push(
|
|
333
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
334
|
+
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
|
|
335
|
+
);
|
|
336
|
+
const cwdChromeDir = nodePath.join(process.cwd(), "chrome");
|
|
337
|
+
if (fs4.existsSync(cwdChromeDir)) {
|
|
338
|
+
const entries = fs4.readdirSync(cwdChromeDir, { withFileTypes: true });
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
if (!entry.isDirectory()) continue;
|
|
341
|
+
const localCandidate = nodePath.join(
|
|
342
|
+
cwdChromeDir,
|
|
343
|
+
entry.name,
|
|
344
|
+
"chrome-mac-arm64",
|
|
345
|
+
"Google Chrome for Testing.app",
|
|
346
|
+
"Contents",
|
|
347
|
+
"MacOS",
|
|
348
|
+
"Google Chrome for Testing"
|
|
349
|
+
);
|
|
350
|
+
candidates.push(localCandidate);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return candidates;
|
|
354
|
+
}
|
|
355
|
+
function resolveChromeExecutablePath() {
|
|
356
|
+
const candidates = getCandidateChromePaths();
|
|
357
|
+
for (const candidate of candidates) {
|
|
358
|
+
if (candidate && fs4.existsSync(candidate)) {
|
|
359
|
+
return candidate;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return void 0;
|
|
363
|
+
}
|
|
364
|
+
function generateGraphFile(result) {
|
|
365
|
+
const graph = generateMermaidGraph(result.model);
|
|
366
|
+
const mermaidFile = "architecture.mmd";
|
|
367
|
+
const svgFile = "architecture.svg";
|
|
368
|
+
fs4.writeFileSync(mermaidFile, graph);
|
|
369
|
+
const chromeExecutablePath = resolveChromeExecutablePath();
|
|
370
|
+
execSync(
|
|
371
|
+
["npx", "mmdc", "-i", mermaidFile, "-o", svgFile].join(" "),
|
|
372
|
+
{
|
|
373
|
+
env: {
|
|
374
|
+
...process.env,
|
|
375
|
+
...chromeExecutablePath ? { PUPPETEER_EXECUTABLE_PATH: chromeExecutablePath } : {}
|
|
376
|
+
},
|
|
377
|
+
stdio: "inherit"
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
return svgFile;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ../../packages/core/src/context.ts
|
|
384
|
+
import path4 from "path";
|
|
385
|
+
function detectContexts(nodes) {
|
|
386
|
+
const contexts = {};
|
|
387
|
+
for (const node of nodes) {
|
|
388
|
+
const parts = path4.normalize(node.filePath).split(path4.sep);
|
|
389
|
+
const contextIndex = parts.findIndex(
|
|
390
|
+
(p) => ["domain", "application", "infrastructure"].includes(p)
|
|
391
|
+
);
|
|
392
|
+
if (contextIndex <= 0) continue;
|
|
393
|
+
const contextName = parts[contextIndex - 1];
|
|
394
|
+
if (!contexts[contextName]) {
|
|
395
|
+
contexts[contextName] = [];
|
|
396
|
+
}
|
|
397
|
+
contexts[contextName].push(node);
|
|
398
|
+
}
|
|
399
|
+
return contexts;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ../../packages/core/src/init.ts
|
|
403
|
+
import { execSync as execSync2 } from "child_process";
|
|
404
|
+
import fs5 from "fs";
|
|
405
|
+
import path5 from "path";
|
|
406
|
+
function detectPackageManager() {
|
|
407
|
+
try {
|
|
408
|
+
execSync2("pnpm --version", { stdio: "ignore" });
|
|
409
|
+
return "pnpm";
|
|
410
|
+
} catch {
|
|
411
|
+
return "npm";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function generateProject(name) {
|
|
415
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`Invalid project name "${name}". Use lowercase letters, digits, and hyphens only (e.g. my-app).`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const dest = path5.join(process.cwd(), name);
|
|
421
|
+
if (fs5.existsSync(dest)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Directory "${name}" already exists. Remove it first or choose a different name.`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
const pm = detectPackageManager();
|
|
427
|
+
console.log("Creating NestJS project...");
|
|
428
|
+
try {
|
|
429
|
+
execSync2(
|
|
430
|
+
`npx @nestjs/cli@latest new ${name} --package-manager ${pm} --skip-git`,
|
|
431
|
+
{ stdio: "inherit" }
|
|
432
|
+
);
|
|
433
|
+
} catch {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`Failed to scaffold the NestJS project "${name}".
|
|
436
|
+
Make sure you have a working internet connection and npm access.
|
|
437
|
+
Try manually: npx @nestjs/cli@latest new ${name}`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const base = path5.join(process.cwd(), name);
|
|
441
|
+
console.log("Applying Hexagonal DDD structure...");
|
|
442
|
+
const src = path5.join(base, "src");
|
|
443
|
+
fs5.rmSync(src, { recursive: true, force: true });
|
|
444
|
+
const dirs = [
|
|
445
|
+
"src/contexts/iam/domain/ports",
|
|
446
|
+
"src/contexts/iam/application/use-cases",
|
|
447
|
+
"src/contexts/iam/infrastructure/http",
|
|
448
|
+
"src/contexts/iam/infrastructure/persistence",
|
|
449
|
+
"src/shared"
|
|
450
|
+
];
|
|
451
|
+
dirs.forEach((dir) => {
|
|
452
|
+
fs5.mkdirSync(path5.join(base, dir), { recursive: true });
|
|
453
|
+
});
|
|
454
|
+
fs5.writeFileSync(
|
|
455
|
+
path5.join(base, "src/main.ts"),
|
|
456
|
+
`import { NestFactory } from '@nestjs/core';
|
|
457
|
+
import { AppModule } from './app.module';
|
|
458
|
+
|
|
459
|
+
async function bootstrap() {
|
|
460
|
+
const app = await NestFactory.create(AppModule);
|
|
461
|
+
await app.listen(3000);
|
|
462
|
+
}
|
|
463
|
+
bootstrap();
|
|
464
|
+
`
|
|
465
|
+
);
|
|
466
|
+
fs5.writeFileSync(
|
|
467
|
+
path5.join(base, "src/app.module.ts"),
|
|
468
|
+
`import { Module } from '@nestjs/common';
|
|
469
|
+
import { IamModule } from './contexts/iam/iam.module';
|
|
470
|
+
|
|
471
|
+
@Module({
|
|
472
|
+
imports: [IamModule],
|
|
473
|
+
})
|
|
474
|
+
export class AppModule {}
|
|
475
|
+
`
|
|
476
|
+
);
|
|
477
|
+
fs5.writeFileSync(
|
|
478
|
+
path5.join(base, "src/contexts/iam/domain/entities/user.entity.ts"),
|
|
479
|
+
`export class User {
|
|
480
|
+
constructor(
|
|
481
|
+
public readonly id: string,
|
|
482
|
+
public readonly email: string,
|
|
483
|
+
public readonly name: string,
|
|
484
|
+
) {}
|
|
485
|
+
}
|
|
486
|
+
`
|
|
487
|
+
);
|
|
488
|
+
fs5.writeFileSync(
|
|
489
|
+
path5.join(base, "src/contexts/iam/domain/ports/user.repository.port.ts"),
|
|
490
|
+
`import { User } from '../entities/user.entity';
|
|
491
|
+
|
|
492
|
+
export const USER_REPOSITORY_PORT = Symbol('UserRepositoryPort');
|
|
493
|
+
|
|
494
|
+
export interface UserRepositoryPort {
|
|
495
|
+
save(user: User): Promise<void>;
|
|
496
|
+
findById(id: string): Promise<User | null>;
|
|
497
|
+
}
|
|
498
|
+
`
|
|
499
|
+
);
|
|
500
|
+
fs5.writeFileSync(
|
|
501
|
+
path5.join(base, "src/contexts/iam/application/use-cases/create-user.usecase.ts"),
|
|
502
|
+
`import { Inject, Injectable } from '@nestjs/common';
|
|
503
|
+
import { User } from '../../domain/entities/user.entity';
|
|
504
|
+
import {
|
|
505
|
+
USER_REPOSITORY_PORT,
|
|
506
|
+
UserRepositoryPort,
|
|
507
|
+
} from '../../domain/ports/user.repository.port';
|
|
508
|
+
import { randomUUID } from 'node:crypto';
|
|
509
|
+
|
|
510
|
+
export interface CreateUserDto {
|
|
511
|
+
email: string;
|
|
512
|
+
name: string;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
@Injectable()
|
|
516
|
+
export class CreateUserUseCase {
|
|
517
|
+
constructor(
|
|
518
|
+
@Inject(USER_REPOSITORY_PORT)
|
|
519
|
+
private readonly userRepository: UserRepositoryPort,
|
|
520
|
+
) {}
|
|
521
|
+
|
|
522
|
+
async execute(dto: CreateUserDto): Promise<User> {
|
|
523
|
+
const user = new User(randomUUID(), dto.email, dto.name);
|
|
524
|
+
await this.userRepository.save(user);
|
|
525
|
+
return user;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
`
|
|
529
|
+
);
|
|
530
|
+
fs5.writeFileSync(
|
|
531
|
+
path5.join(
|
|
532
|
+
base,
|
|
533
|
+
"src/contexts/iam/infrastructure/persistence/in-memory-user.repository.ts"
|
|
534
|
+
),
|
|
535
|
+
`import { Injectable } from '@nestjs/common';
|
|
536
|
+
import { User } from '../../domain/entities/user.entity';
|
|
537
|
+
import { UserRepositoryPort } from '../../domain/ports/user.repository.port';
|
|
538
|
+
|
|
539
|
+
@Injectable()
|
|
540
|
+
export class InMemoryUserRepository implements UserRepositoryPort {
|
|
541
|
+
private readonly store = new Map<string, User>();
|
|
542
|
+
|
|
543
|
+
async save(user: User): Promise<void> {
|
|
544
|
+
this.store.set(user.id, user);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async findById(id: string): Promise<User | null> {
|
|
548
|
+
return this.store.get(id) ?? null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
`
|
|
552
|
+
);
|
|
553
|
+
fs5.writeFileSync(
|
|
554
|
+
path5.join(base, "src/contexts/iam/infrastructure/http/user.controller.ts"),
|
|
555
|
+
`import { Body, Controller, Post } from '@nestjs/common';
|
|
556
|
+
import { CreateUserUseCase, CreateUserDto } from '../../application/use-cases/create-user.usecase';
|
|
557
|
+
|
|
558
|
+
@Controller('users')
|
|
559
|
+
export class UserController {
|
|
560
|
+
constructor(private readonly createUser: CreateUserUseCase) {}
|
|
561
|
+
|
|
562
|
+
@Post()
|
|
563
|
+
async create(@Body() dto: CreateUserDto) {
|
|
564
|
+
return this.createUser.execute(dto);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
`
|
|
568
|
+
);
|
|
569
|
+
fs5.writeFileSync(
|
|
570
|
+
path5.join(base, "src/contexts/iam/iam.module.ts"),
|
|
571
|
+
`import { Module } from '@nestjs/common';
|
|
572
|
+
import { USER_REPOSITORY_PORT } from './domain/ports/user.repository.port';
|
|
573
|
+
import { InMemoryUserRepository } from './infrastructure/persistence/in-memory-user.repository';
|
|
574
|
+
import { CreateUserUseCase } from './application/use-cases/create-user.usecase';
|
|
575
|
+
import { UserController } from './infrastructure/http/user.controller';
|
|
576
|
+
|
|
577
|
+
@Module({
|
|
578
|
+
controllers: [UserController],
|
|
579
|
+
providers: [
|
|
580
|
+
{
|
|
581
|
+
provide: USER_REPOSITORY_PORT,
|
|
582
|
+
useClass: InMemoryUserRepository,
|
|
583
|
+
},
|
|
584
|
+
CreateUserUseCase,
|
|
585
|
+
],
|
|
586
|
+
})
|
|
587
|
+
export class IamModule {}
|
|
588
|
+
`
|
|
589
|
+
);
|
|
590
|
+
fs5.writeFileSync(path5.join(base, "src/shared/.gitkeep"), "");
|
|
591
|
+
fs5.writeFileSync(
|
|
592
|
+
path5.join(base, "node-hexa.config.json"),
|
|
593
|
+
JSON.stringify(
|
|
594
|
+
{
|
|
595
|
+
architecture: "hexagonal-ddd",
|
|
596
|
+
strict: true,
|
|
597
|
+
contextsDir: "src/contexts"
|
|
598
|
+
},
|
|
599
|
+
null,
|
|
600
|
+
2
|
|
601
|
+
)
|
|
602
|
+
);
|
|
603
|
+
console.log(`
|
|
604
|
+
\u2713 Hexagonal DDD NestJS project ready at ./${name}`);
|
|
605
|
+
console.log(` cd ${name} && pnpm start:dev`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ../../packages/core/src/generate-context.ts
|
|
609
|
+
import fs7 from "fs";
|
|
610
|
+
import path7 from "path";
|
|
611
|
+
|
|
612
|
+
// ../../packages/core/src/guards.ts
|
|
613
|
+
import fs6 from "fs";
|
|
614
|
+
import path6 from "path";
|
|
615
|
+
var NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
616
|
+
function assertKebabCase(value, label) {
|
|
617
|
+
if (!NAME_RE.test(value)) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`Invalid ${label} "${value}". Use lowercase letters, digits, and hyphens (e.g. my-context).`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function assertInsideProject() {
|
|
624
|
+
const pkgPath = path6.join(process.cwd(), "package.json");
|
|
625
|
+
if (!fs6.existsSync(pkgPath)) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
"No package.json found. Run this command from the root of your NestJS project."
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
let pkg;
|
|
631
|
+
try {
|
|
632
|
+
pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf8"));
|
|
633
|
+
} catch {
|
|
634
|
+
throw new Error(
|
|
635
|
+
"Could not parse package.json. Make sure it contains valid JSON."
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
639
|
+
if (!allDeps["@nestjs/core"]) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
"@nestjs/core not found in dependencies. node-hexa only works with NestJS projects."
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ../../packages/core/src/generate-context.ts
|
|
647
|
+
function generateContext(name) {
|
|
648
|
+
assertKebabCase(name, "context name");
|
|
649
|
+
assertInsideProject();
|
|
650
|
+
const base = path7.join(process.cwd(), "src", "contexts", name);
|
|
651
|
+
if (fs7.existsSync(base)) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`Context '${name}' already exists at src/contexts/${name}.`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const dirs = [
|
|
657
|
+
"domain/entities",
|
|
658
|
+
"domain/value-objects",
|
|
659
|
+
"domain/ports",
|
|
660
|
+
"application/use-cases",
|
|
661
|
+
"infrastructure/http",
|
|
662
|
+
"infrastructure/persistence"
|
|
663
|
+
];
|
|
664
|
+
dirs.forEach((dir) => {
|
|
665
|
+
fs7.mkdirSync(path7.join(base, dir), { recursive: true });
|
|
666
|
+
});
|
|
667
|
+
const pascal = capitalize(name);
|
|
668
|
+
const token = `${name.toUpperCase().replaceAll("-", "_")}_REPOSITORY_PORT`;
|
|
669
|
+
fs7.writeFileSync(
|
|
670
|
+
path7.join(base, "domain/entities", `${name}.entity.ts`),
|
|
671
|
+
`export class ${pascal} {
|
|
672
|
+
constructor(public readonly id: string) {}
|
|
673
|
+
}
|
|
674
|
+
`
|
|
675
|
+
);
|
|
676
|
+
fs7.writeFileSync(
|
|
677
|
+
path7.join(base, "domain/ports", `${name}.repository.port.ts`),
|
|
678
|
+
`import { ${pascal} } from '../entities/${name}.entity';
|
|
679
|
+
|
|
680
|
+
export const ${token} = Symbol('${pascal}RepositoryPort');
|
|
681
|
+
|
|
682
|
+
export interface ${pascal}RepositoryPort {
|
|
683
|
+
save(entity: ${pascal}): Promise<void>;
|
|
684
|
+
findById(id: string): Promise<${pascal} | null>;
|
|
685
|
+
}
|
|
686
|
+
`
|
|
687
|
+
);
|
|
688
|
+
fs7.writeFileSync(
|
|
689
|
+
path7.join(base, "application/use-cases", `create-${name}.usecase.ts`),
|
|
690
|
+
`import { Inject, Injectable } from '@nestjs/common';
|
|
691
|
+
import { ${pascal} } from '../../domain/entities/${name}.entity';
|
|
692
|
+
import {
|
|
693
|
+
${token},
|
|
694
|
+
${pascal}RepositoryPort,
|
|
695
|
+
} from '../../domain/ports/${name}.repository.port';
|
|
696
|
+
import { randomUUID } from 'node:crypto';
|
|
697
|
+
|
|
698
|
+
@Injectable()
|
|
699
|
+
export class Create${pascal}UseCase {
|
|
700
|
+
constructor(
|
|
701
|
+
@Inject(${token})
|
|
702
|
+
private readonly repository: ${pascal}RepositoryPort,
|
|
703
|
+
) {}
|
|
704
|
+
|
|
705
|
+
async execute(id?: string): Promise<${pascal}> {
|
|
706
|
+
const entity = new ${pascal}(id ?? randomUUID());
|
|
707
|
+
await this.repository.save(entity);
|
|
708
|
+
return entity;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
`
|
|
712
|
+
);
|
|
713
|
+
fs7.writeFileSync(
|
|
714
|
+
path7.join(base, "infrastructure/http", `${name}.controller.ts`),
|
|
715
|
+
`import { Controller, Post } from '@nestjs/common';
|
|
716
|
+
import { Create${pascal}UseCase } from '../../application/use-cases/create-${name}.usecase';
|
|
717
|
+
|
|
718
|
+
@Controller('${name}')
|
|
719
|
+
export class ${pascal}Controller {
|
|
720
|
+
constructor(private readonly create${pascal}: Create${pascal}UseCase) {}
|
|
721
|
+
|
|
722
|
+
@Post()
|
|
723
|
+
async create() {
|
|
724
|
+
return this.create${pascal}.execute();
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
`
|
|
728
|
+
);
|
|
729
|
+
fs7.writeFileSync(
|
|
730
|
+
path7.join(
|
|
731
|
+
base,
|
|
732
|
+
"infrastructure/persistence",
|
|
733
|
+
`in-memory-${name}.repository.ts`
|
|
734
|
+
),
|
|
735
|
+
`import { Injectable } from '@nestjs/common';
|
|
736
|
+
import { ${pascal} } from '../../domain/entities/${name}.entity';
|
|
737
|
+
import { ${pascal}RepositoryPort } from '../../domain/ports/${name}.repository.port';
|
|
738
|
+
|
|
739
|
+
@Injectable()
|
|
740
|
+
export class InMemory${pascal}Repository implements ${pascal}RepositoryPort {
|
|
741
|
+
private readonly store = new Map<string, ${pascal}>();
|
|
742
|
+
|
|
743
|
+
async save(entity: ${pascal}): Promise<void> {
|
|
744
|
+
this.store.set(entity.id, entity);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async findById(id: string): Promise<${pascal} | null> {
|
|
748
|
+
return this.store.get(id) ?? null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
`
|
|
752
|
+
);
|
|
753
|
+
fs7.writeFileSync(
|
|
754
|
+
path7.join(base, `${name}.module.ts`),
|
|
755
|
+
`import { Module } from '@nestjs/common';
|
|
756
|
+
import { ${token} } from './domain/ports/${name}.repository.port';
|
|
757
|
+
import { InMemory${pascal}Repository } from './infrastructure/persistence/in-memory-${name}.repository';
|
|
758
|
+
import { Create${pascal}UseCase } from './application/use-cases/create-${name}.usecase';
|
|
759
|
+
import { ${pascal}Controller } from './infrastructure/http/${name}.controller';
|
|
760
|
+
|
|
761
|
+
@Module({
|
|
762
|
+
controllers: [${pascal}Controller],
|
|
763
|
+
providers: [
|
|
764
|
+
{ provide: ${token}, useClass: InMemory${pascal}Repository },
|
|
765
|
+
Create${pascal}UseCase,
|
|
766
|
+
],
|
|
767
|
+
})
|
|
768
|
+
export class ${pascal}Module {}
|
|
769
|
+
`
|
|
770
|
+
);
|
|
771
|
+
console.log(`\u2713 Context '${name}' generated at src/contexts/${name}/`);
|
|
772
|
+
console.log(` \u2192 Import ${pascal}Module in your AppModule to activate it.`);
|
|
773
|
+
}
|
|
774
|
+
function capitalize(str) {
|
|
775
|
+
return str.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ../../packages/core/src/generate-usecase.ts
|
|
779
|
+
import fs8 from "fs";
|
|
780
|
+
import path8 from "path";
|
|
781
|
+
function findContextPort(base, context) {
|
|
782
|
+
const portsDir = path8.join(base, "domain/ports");
|
|
783
|
+
if (!fs8.existsSync(portsDir)) return null;
|
|
784
|
+
const portFile = fs8.readdirSync(portsDir).find((f) => f.endsWith(".repository.port.ts"));
|
|
785
|
+
if (!portFile) return null;
|
|
786
|
+
const baseName = portFile.replace(".repository.port.ts", "");
|
|
787
|
+
const pascal = capitalize2(baseName);
|
|
788
|
+
const token = `${baseName.toUpperCase().replaceAll("-", "_")}_REPOSITORY_PORT`;
|
|
789
|
+
return {
|
|
790
|
+
token,
|
|
791
|
+
interfaceName: `${pascal}RepositoryPort`,
|
|
792
|
+
importPath: `../../domain/ports/${baseName}.repository.port`,
|
|
793
|
+
paramName: `${context.replaceAll("-", "")}Repository`
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
function generateUseCase(name, context) {
|
|
797
|
+
assertKebabCase(name, "use case name");
|
|
798
|
+
assertKebabCase(context, "context name");
|
|
799
|
+
assertInsideProject();
|
|
800
|
+
const base = path8.join(process.cwd(), "src", "contexts", context);
|
|
801
|
+
if (!fs8.existsSync(base)) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`Context '${context}' does not exist at src/contexts/${context}. Run 'node-hexa generate context ${context}' first.`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
const className = capitalize2(name) + "UseCase";
|
|
807
|
+
const usecasePath = path8.join(
|
|
808
|
+
base,
|
|
809
|
+
"application/use-cases",
|
|
810
|
+
`${name}.usecase.ts`
|
|
811
|
+
);
|
|
812
|
+
const dtoPath = path8.join(base, "application/use-cases", `${name}.dto.ts`);
|
|
813
|
+
const testPath = path8.join(
|
|
814
|
+
base,
|
|
815
|
+
"application/use-cases",
|
|
816
|
+
`${name}.usecase.spec.ts`
|
|
817
|
+
);
|
|
818
|
+
fs8.mkdirSync(path8.dirname(usecasePath), { recursive: true });
|
|
819
|
+
const port = findContextPort(base, context);
|
|
820
|
+
const usecaseContent = port ? `import { Inject, Injectable } from '@nestjs/common';
|
|
821
|
+
import { ${port.token}, ${port.interfaceName} } from '${port.importPath}';
|
|
822
|
+
import { ${className}Dto } from './${name}.dto';
|
|
823
|
+
|
|
824
|
+
@Injectable()
|
|
825
|
+
export class ${className} {
|
|
826
|
+
constructor(
|
|
827
|
+
@Inject(${port.token})
|
|
828
|
+
private readonly ${port.paramName}: ${port.interfaceName},
|
|
829
|
+
) {}
|
|
830
|
+
|
|
831
|
+
async execute(dto: ${className}Dto): Promise<void> {
|
|
832
|
+
// TODO: implement use case logic
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
` : `import { Injectable } from '@nestjs/common';
|
|
836
|
+
import { ${className}Dto } from './${name}.dto';
|
|
837
|
+
|
|
838
|
+
@Injectable()
|
|
839
|
+
export class ${className} {
|
|
840
|
+
async execute(dto: ${className}Dto): Promise<void> {
|
|
841
|
+
// TODO: inject a repository port via constructor and implement logic
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
`;
|
|
845
|
+
fs8.writeFileSync(usecasePath, usecaseContent);
|
|
846
|
+
fs8.writeFileSync(
|
|
847
|
+
dtoPath,
|
|
848
|
+
`export interface ${className}Dto {
|
|
849
|
+
id: string;
|
|
850
|
+
}
|
|
851
|
+
`
|
|
852
|
+
);
|
|
853
|
+
fs8.writeFileSync(
|
|
854
|
+
testPath,
|
|
855
|
+
`import { describe, it, expect } from 'vitest';
|
|
856
|
+
import { ${className} } from './${name}.usecase';
|
|
857
|
+
|
|
858
|
+
describe('${className}', () => {
|
|
859
|
+
it('should execute without errors', async () => {
|
|
860
|
+
const usecase = new ${className}(${port ? `{} as any` : ""});
|
|
861
|
+
await expect(usecase.execute({ id: '1' })).resolves.toBeUndefined();
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
function capitalize2(str) {
|
|
868
|
+
return str.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ../../packages/core/src/generate-aggregate.ts
|
|
872
|
+
import fs9 from "fs";
|
|
873
|
+
import path9 from "path";
|
|
874
|
+
function generateAggregate(name, context) {
|
|
875
|
+
assertKebabCase(name, "aggregate name");
|
|
876
|
+
assertKebabCase(context, "context name");
|
|
877
|
+
assertInsideProject();
|
|
878
|
+
const base = path9.join(process.cwd(), "src", "contexts", context);
|
|
879
|
+
if (!fs9.existsSync(base)) {
|
|
880
|
+
throw new Error(
|
|
881
|
+
`Context '${context}' does not exist at src/contexts/${context}. Run 'node-hexa generate context ${context}' first.`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
const pascal = capitalize3(name);
|
|
885
|
+
const token = `${name.toUpperCase().replaceAll("-", "_")}_REPOSITORY_PORT`;
|
|
886
|
+
const dirs = [
|
|
887
|
+
"domain/entities",
|
|
888
|
+
"domain/value-objects",
|
|
889
|
+
"domain/ports",
|
|
890
|
+
"application/use-cases",
|
|
891
|
+
"infrastructure/persistence",
|
|
892
|
+
"infrastructure/http"
|
|
893
|
+
];
|
|
894
|
+
dirs.forEach((dir) => {
|
|
895
|
+
fs9.mkdirSync(path9.join(base, dir), { recursive: true });
|
|
896
|
+
});
|
|
897
|
+
fs9.writeFileSync(
|
|
898
|
+
path9.join(base, "domain/entities", `${name}.entity.ts`),
|
|
899
|
+
`export class ${pascal} {
|
|
900
|
+
constructor(public readonly id: string) {}
|
|
901
|
+
}
|
|
902
|
+
`
|
|
903
|
+
);
|
|
904
|
+
fs9.writeFileSync(
|
|
905
|
+
path9.join(base, "domain/value-objects", `${name}-id.vo.ts`),
|
|
906
|
+
`export class ${pascal}Id {
|
|
907
|
+
constructor(public readonly value: string) {}
|
|
908
|
+
}
|
|
909
|
+
`
|
|
910
|
+
);
|
|
911
|
+
fs9.writeFileSync(
|
|
912
|
+
path9.join(base, "domain/ports", `${name}.repository.port.ts`),
|
|
913
|
+
`import { ${pascal} } from '../entities/${name}.entity';
|
|
914
|
+
|
|
915
|
+
export const ${token} = Symbol('${pascal}RepositoryPort');
|
|
916
|
+
|
|
917
|
+
export interface ${pascal}RepositoryPort {
|
|
918
|
+
save(entity: ${pascal}): Promise<void>;
|
|
919
|
+
findById(id: string): Promise<${pascal} | null>;
|
|
920
|
+
}
|
|
921
|
+
`
|
|
922
|
+
);
|
|
923
|
+
fs9.writeFileSync(
|
|
924
|
+
path9.join(base, "application/use-cases", `create-${name}.dto.ts`),
|
|
925
|
+
`export type Create${pascal}Dto = {
|
|
926
|
+
id: string;
|
|
927
|
+
};
|
|
928
|
+
`
|
|
929
|
+
);
|
|
930
|
+
fs9.writeFileSync(
|
|
931
|
+
path9.join(base, "application/use-cases", `create-${name}.usecase.ts`),
|
|
932
|
+
`import { Inject, Injectable } from '@nestjs/common';
|
|
933
|
+
import { ${pascal} } from '../../domain/entities/${name}.entity';
|
|
77
934
|
import {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
935
|
+
${token},
|
|
936
|
+
${pascal}RepositoryPort,
|
|
937
|
+
} from '../../domain/ports/${name}.repository.port';
|
|
938
|
+
import { Create${pascal}Dto } from './create-${name}.dto';
|
|
939
|
+
|
|
940
|
+
@Injectable()
|
|
941
|
+
export class Create${pascal}UseCase {
|
|
942
|
+
constructor(
|
|
943
|
+
@Inject(${token})
|
|
944
|
+
private readonly repository: ${pascal}RepositoryPort,
|
|
945
|
+
) {}
|
|
946
|
+
|
|
947
|
+
async execute(dto: Create${pascal}Dto): Promise<${pascal}> {
|
|
948
|
+
const entity = new ${pascal}(dto.id);
|
|
949
|
+
await this.repository.save(entity);
|
|
950
|
+
return entity;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
`
|
|
954
|
+
);
|
|
955
|
+
fs9.writeFileSync(
|
|
956
|
+
path9.join(base, "application/use-cases", `create-${name}.usecase.spec.ts`),
|
|
957
|
+
`import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
958
|
+
import { Create${pascal}UseCase } from './create-${name}.usecase';
|
|
959
|
+
|
|
960
|
+
const mockRepository = {
|
|
961
|
+
save: vi.fn(),
|
|
962
|
+
findById: vi.fn(),
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
describe('Create${pascal}UseCase', () => {
|
|
966
|
+
let useCase: Create${pascal}UseCase;
|
|
967
|
+
|
|
968
|
+
beforeEach(() => {
|
|
969
|
+
useCase = new Create${pascal}UseCase(mockRepository as any);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it('should create and persist a ${pascal}', async () => {
|
|
973
|
+
const result = await useCase.execute({ id: '123' });
|
|
974
|
+
expect(result.id).toBe('123');
|
|
975
|
+
expect(mockRepository.save).toHaveBeenCalledWith(result);
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
`
|
|
979
|
+
);
|
|
980
|
+
fs9.writeFileSync(
|
|
981
|
+
path9.join(base, "infrastructure/persistence", `in-memory-${name}.repository.ts`),
|
|
982
|
+
`import { Injectable } from '@nestjs/common';
|
|
983
|
+
import { ${pascal} } from '../../domain/entities/${name}.entity';
|
|
984
|
+
import { ${pascal}RepositoryPort } from '../../domain/ports/${name}.repository.port';
|
|
985
|
+
|
|
986
|
+
@Injectable()
|
|
987
|
+
export class InMemory${pascal}Repository implements ${pascal}RepositoryPort {
|
|
988
|
+
private readonly store = new Map<string, ${pascal}>();
|
|
989
|
+
|
|
990
|
+
async save(entity: ${pascal}): Promise<void> {
|
|
991
|
+
this.store.set(entity.id, entity);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async findById(id: string): Promise<${pascal} | null> {
|
|
995
|
+
return this.store.get(id) ?? null;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
`
|
|
999
|
+
);
|
|
1000
|
+
fs9.writeFileSync(
|
|
1001
|
+
path9.join(base, "infrastructure/http", `${name}.controller.ts`),
|
|
1002
|
+
`import { Body, Controller, Post } from '@nestjs/common';
|
|
1003
|
+
import { Create${pascal}UseCase } from '../../application/use-cases/create-${name}.usecase';
|
|
1004
|
+
import { Create${pascal}Dto } from '../../application/use-cases/create-${name}.dto';
|
|
1005
|
+
|
|
1006
|
+
@Controller('${name}')
|
|
1007
|
+
export class ${pascal}Controller {
|
|
1008
|
+
constructor(private readonly create${pascal}: Create${pascal}UseCase) {}
|
|
1009
|
+
|
|
1010
|
+
@Post()
|
|
1011
|
+
async create(@Body() dto: Create${pascal}Dto) {
|
|
1012
|
+
return this.create${pascal}.execute(dto);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
`
|
|
1016
|
+
);
|
|
1017
|
+
fs9.writeFileSync(
|
|
1018
|
+
path9.join(base, `${name}.module.ts`),
|
|
1019
|
+
`import { Module } from '@nestjs/common';
|
|
1020
|
+
import { ${token} } from './domain/ports/${name}.repository.port';
|
|
1021
|
+
import { InMemory${pascal}Repository } from './infrastructure/persistence/in-memory-${name}.repository';
|
|
1022
|
+
import { Create${pascal}UseCase } from './application/use-cases/create-${name}.usecase';
|
|
1023
|
+
import { ${pascal}Controller } from './infrastructure/http/${name}.controller';
|
|
1024
|
+
|
|
1025
|
+
@Module({
|
|
1026
|
+
controllers: [${pascal}Controller],
|
|
1027
|
+
providers: [
|
|
1028
|
+
{ provide: ${token}, useClass: InMemory${pascal}Repository },
|
|
1029
|
+
Create${pascal}UseCase,
|
|
1030
|
+
],
|
|
1031
|
+
})
|
|
1032
|
+
export class ${pascal}Module {}
|
|
1033
|
+
`
|
|
1034
|
+
);
|
|
1035
|
+
console.log(`\u2713 Aggregate '${name}' generated in context '${context}' at src/contexts/${context}/`);
|
|
1036
|
+
console.log(` \u2192 Import ${pascal}Module in your ${capitalize3(context)}Module or AppModule.`);
|
|
1037
|
+
}
|
|
1038
|
+
function capitalize3(str) {
|
|
1039
|
+
return str.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ../../packages/core/src/list.ts
|
|
1043
|
+
import fs10 from "fs";
|
|
1044
|
+
import path10 from "path";
|
|
1045
|
+
function listFiles(dir) {
|
|
1046
|
+
if (!fs10.existsSync(dir)) return [];
|
|
1047
|
+
return fs10.readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".spec.ts")).map((f) => f.replace(".ts", ""));
|
|
1048
|
+
}
|
|
1049
|
+
function listContexts(projectPath) {
|
|
1050
|
+
const config = loadConfig(projectPath);
|
|
1051
|
+
const contextsDir = path10.join(projectPath, config.contextsDir);
|
|
1052
|
+
if (!fs10.existsSync(contextsDir)) return [];
|
|
1053
|
+
return fs10.readdirSync(contextsDir).filter(
|
|
1054
|
+
(entry) => fs10.statSync(path10.join(contextsDir, entry)).isDirectory()
|
|
1055
|
+
).map((ctxName) => {
|
|
1056
|
+
const ctxPath = path10.join(contextsDir, ctxName);
|
|
1057
|
+
return {
|
|
1058
|
+
name: ctxName,
|
|
1059
|
+
entities: listFiles(path10.join(ctxPath, "domain/entities")),
|
|
1060
|
+
valueObjects: listFiles(path10.join(ctxPath, "domain/value-objects")),
|
|
1061
|
+
ports: listFiles(path10.join(ctxPath, "domain/ports")),
|
|
1062
|
+
useCases: listFiles(path10.join(ctxPath, "application/use-cases"))
|
|
1063
|
+
};
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ../../packages/core/src/index.ts
|
|
1068
|
+
function detectLayer(filePath, config) {
|
|
1069
|
+
const normalized = filePath.toLowerCase().replaceAll("\\", "/");
|
|
1070
|
+
const dirPath = normalized.split("/").slice(0, -1).join("/");
|
|
1071
|
+
if (config.layers.domain.some((l) => dirPath.includes(l)))
|
|
1072
|
+
return "domain";
|
|
1073
|
+
if (config.layers.application.some((l) => dirPath.includes(l)))
|
|
1074
|
+
return "application";
|
|
1075
|
+
if (config.layers.adapterIn?.some((l) => dirPath.includes(l)))
|
|
1076
|
+
return "adapter-in";
|
|
1077
|
+
if (config.layers.adapterOut?.some((l) => dirPath.includes(l)))
|
|
1078
|
+
return "adapter-out";
|
|
1079
|
+
if (config.layers.infrastructure.some((l) => dirPath.includes(l)))
|
|
1080
|
+
return "infrastructure";
|
|
1081
|
+
return "unknown";
|
|
1082
|
+
}
|
|
1083
|
+
function detectKind(name, decorators) {
|
|
1084
|
+
if (decorators.includes("Controller")) return "controller";
|
|
1085
|
+
if (decorators.includes("Injectable")) return "service";
|
|
1086
|
+
if (decorators.includes("Module")) return "module";
|
|
1087
|
+
if (name.endsWith("Controller")) return "controller";
|
|
1088
|
+
if (name.endsWith("UseCase")) return "use-case";
|
|
1089
|
+
if (name.endsWith("Repository")) return "repository";
|
|
1090
|
+
if (name.endsWith("Entity")) return "entity";
|
|
1091
|
+
if (name.endsWith("Service")) return "service";
|
|
1092
|
+
if (name.endsWith("Port")) return "port";
|
|
1093
|
+
if (name.endsWith("Adapter")) return "adapter";
|
|
1094
|
+
if (name.endsWith("Vo") || name.endsWith("ValueObject") || name.endsWith("Id"))
|
|
1095
|
+
return "value-object";
|
|
1096
|
+
return "unknown";
|
|
1097
|
+
}
|
|
1098
|
+
async function analyzeProject(projectPath) {
|
|
1099
|
+
const parsed = await parseProject(projectPath);
|
|
1100
|
+
const config = loadConfig(projectPath);
|
|
1101
|
+
const contextsAbsDir = path11.resolve(projectPath, config.contextsDir);
|
|
1102
|
+
const nodes = parsed.files.filter((file) => {
|
|
1103
|
+
const normalizedPath = path11.normalize(file.path);
|
|
1104
|
+
return normalizedPath.startsWith(contextsAbsDir + path11.sep) || normalizedPath === contextsAbsDir;
|
|
1105
|
+
}).flatMap((file) => [
|
|
1106
|
+
...file.classes.map((cls) => ({
|
|
1107
|
+
name: cls.name,
|
|
1108
|
+
filePath: file.path,
|
|
1109
|
+
layer: detectLayer(file.path, config),
|
|
1110
|
+
kind: detectKind(cls.name, cls.decorators),
|
|
1111
|
+
imports: file.imports
|
|
1112
|
+
})),
|
|
1113
|
+
...file.interfaces.map((itf) => ({
|
|
1114
|
+
name: itf,
|
|
1115
|
+
filePath: file.path,
|
|
1116
|
+
layer: detectLayer(file.path, config),
|
|
1117
|
+
kind: "port",
|
|
1118
|
+
imports: file.imports
|
|
1119
|
+
}))
|
|
1120
|
+
]);
|
|
1121
|
+
const model = { nodes };
|
|
1122
|
+
const violations = runRules(model, config.strict);
|
|
1123
|
+
const score = computeScore(violations);
|
|
1124
|
+
return {
|
|
1125
|
+
model,
|
|
1126
|
+
violations,
|
|
1127
|
+
score
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// src/index.ts
|
|
89
1132
|
function severityBadge(severity) {
|
|
90
1133
|
if (severity === "critical") return "CRITICAL";
|
|
91
1134
|
if (severity === "high") return "HIGH";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dawudesign/node-hexa-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "CLI to scaffold and analyze NestJS Hexagonal DDD projects",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nestjs",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"dist"
|
|
20
20
|
],
|
|
21
21
|
"bin": {
|
|
22
|
-
"node-hexa": "
|
|
22
|
+
"node-hexa": "dist/index.js"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|
|
@@ -32,14 +32,15 @@
|
|
|
32
32
|
"start": "node dist/index.js"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"commander": "^14.0.3"
|
|
35
|
+
"commander": "^14.0.3",
|
|
36
|
+
"ts-morph": "^27.0.2"
|
|
38
37
|
},
|
|
39
38
|
"devDependencies": {
|
|
39
|
+
"@node-hexa/core": "workspace:^",
|
|
40
|
+
"@node-hexa/parser": "workspace:^",
|
|
40
41
|
"@types/node": "^25.3.5",
|
|
41
42
|
"tsup": "^8.0.0",
|
|
42
43
|
"tsx": "^4.0.0",
|
|
43
44
|
"typescript": "^5.3.0"
|
|
44
45
|
}
|
|
45
|
-
}
|
|
46
|
+
}
|