nestgen-cli 0.1.0 → 0.2.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.
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { projectPath } from './project-path.js';
4
+ const transactionDirectory = '.nestgen-transaction';
5
+ function contentDiff(file, before, after) {
6
+ const removed = before
7
+ .split(/(?<=\n)/)
8
+ .map((line) => `-${line}`)
9
+ .join('');
10
+ const added = after
11
+ .split(/(?<=\n)/)
12
+ .map((line) => `+${line}`)
13
+ .join('');
14
+ return `--- ${file}\n+++ ${file}\n@@\n${removed}${added}`;
15
+ }
16
+ /** Inspect the exact file writes of a generation without touching the filesystem. */
17
+ export function previewFileChanges(projectRoot, changes) {
18
+ const root = fs.realpathSync(projectRoot);
19
+ const seen = new Set();
20
+ return changes.map((change) => {
21
+ let target;
22
+ try {
23
+ target = projectPath(root, change.path);
24
+ }
25
+ catch (cause) {
26
+ return {
27
+ ...change,
28
+ status: 'conflict',
29
+ reason: cause instanceof Error ? cause.message : String(cause),
30
+ };
31
+ }
32
+ if (seen.has(target))
33
+ return { ...change, status: 'conflict', reason: `Destination dupliquée : ${change.path}` };
34
+ seen.add(target);
35
+ if (path.relative(root, target).split(path.sep)[0].startsWith(transactionDirectory))
36
+ return { ...change, status: 'conflict', reason: `Destination réservée : ${change.path}` };
37
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
38
+ if (change.operation === 'create' && stat)
39
+ return { ...change, status: 'conflict', reason: `Le fichier existe déjà : ${change.path}` };
40
+ if (change.operation === 'replace' && !stat?.isFile())
41
+ return { ...change, status: 'conflict', reason: `Fichier à remplacer introuvable : ${change.path}` };
42
+ const before = stat ? fs.readFileSync(target, 'utf8') : '';
43
+ return {
44
+ ...change,
45
+ status: change.operation,
46
+ diff: contentDiff(change.path, before, change.content),
47
+ };
48
+ });
49
+ }
50
+ /** Apply a fully validated plan; roll back all applied files when a filesystem operation fails. */
51
+ export function applyFileChanges(projectRoot, changes) {
52
+ const root = fs.realpathSync(projectRoot);
53
+ const stage = path.join(root, transactionDirectory);
54
+ const targets = new Set();
55
+ const plan = changes.map((change, index) => {
56
+ const target = projectPath(root, change.path);
57
+ const relative = path.relative(root, target);
58
+ if (relative.split(path.sep)[0].startsWith('.nestgen-transaction'))
59
+ throw new Error(`Destination réservée : ${change.path}`);
60
+ if (targets.has(target))
61
+ throw new Error(`Destination dupliquée : ${change.path}`);
62
+ targets.add(target);
63
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
64
+ if (change.operation === 'create' && stat)
65
+ throw new Error(`Le fichier existe déjà : ${change.path}`);
66
+ if (change.operation === 'replace' && !stat?.isFile())
67
+ throw new Error(`Fichier à remplacer introuvable : ${change.path}`);
68
+ return {
69
+ ...change,
70
+ target,
71
+ original: stat ? fs.readFileSync(target) : undefined,
72
+ mode: stat ? stat.mode & 0o777 : 0o666 & ~process.umask(),
73
+ prepared: path.join(stage, `new-${index}`),
74
+ backup: path.join(stage, `old-${index}`),
75
+ };
76
+ });
77
+ for (const item of plan) {
78
+ let parent = path.dirname(item.target);
79
+ while (parent !== root) {
80
+ if (targets.has(parent))
81
+ throw new Error(`Conflit fichier/répertoire : ${parent}`);
82
+ parent = path.dirname(parent);
83
+ }
84
+ }
85
+ if (fs.existsSync(path.join(root, '.nestgen-transaction.json')))
86
+ throw new Error('Transaction interrompue détectée : .nestgen-transaction.json.');
87
+ // mkdir without recursive is an exclusive lock; never remove a lock owned by another invocation.
88
+ fs.mkdirSync(stage);
89
+ const directories = [];
90
+ const applied = [];
91
+ let retainBackups = false;
92
+ let committed = false;
93
+ try {
94
+ fs.writeFileSync(path.join(stage, 'manifest.json'), JSON.stringify(plan.map(({ path: file, operation, mode }, index) => ({ file, operation, mode, index }))));
95
+ for (const item of plan) {
96
+ fs.writeFileSync(item.prepared, item.content, { flag: 'wx', mode: item.mode });
97
+ fs.chmodSync(item.prepared, item.mode);
98
+ if (item.original !== undefined) {
99
+ fs.writeFileSync(item.backup, item.original, { flag: 'wx', mode: item.mode });
100
+ fs.chmodSync(item.backup, item.mode);
101
+ }
102
+ }
103
+ for (const item of plan) {
104
+ const missing = [];
105
+ let parent = path.dirname(item.target);
106
+ while (!fs.existsSync(parent)) {
107
+ missing.push(parent);
108
+ parent = path.dirname(parent);
109
+ }
110
+ for (const directory of missing.reverse()) {
111
+ fs.mkdirSync(directory);
112
+ directories.push(directory);
113
+ }
114
+ if (item.original !== undefined) {
115
+ if (!fs.readFileSync(item.target).equals(item.original))
116
+ throw new Error(`Le fichier a changé pendant la génération : ${item.path}`);
117
+ }
118
+ else if (fs.existsSync(item.target)) {
119
+ throw new Error(`Le fichier est apparu pendant la génération : ${item.path}`);
120
+ }
121
+ fs.renameSync(item.prepared, item.target);
122
+ applied.push(item);
123
+ }
124
+ committed = true;
125
+ }
126
+ catch (error) {
127
+ const failures = [];
128
+ for (const item of applied.reverse()) {
129
+ try {
130
+ if (item.original !== undefined)
131
+ fs.renameSync(item.backup, item.target);
132
+ else
133
+ fs.unlinkSync(item.target);
134
+ }
135
+ catch (rollbackError) {
136
+ failures.push(rollbackError);
137
+ }
138
+ }
139
+ for (const directory of directories.reverse()) {
140
+ try {
141
+ fs.rmdirSync(directory);
142
+ }
143
+ catch (rollbackError) {
144
+ failures.push(rollbackError);
145
+ }
146
+ }
147
+ if (failures.length) {
148
+ retainBackups = true;
149
+ throw new AggregateError([error, ...failures], `Restauration incomplète ; sauvegardes conservées : ${stage}`);
150
+ }
151
+ throw error;
152
+ }
153
+ finally {
154
+ if (!retainBackups) {
155
+ try {
156
+ fs.rmSync(stage, { recursive: true, force: true });
157
+ }
158
+ catch (cleanupError) {
159
+ if (!committed)
160
+ throw cleanupError;
161
+ // The complete plan is already committed. Never report a failed generation after that point.
162
+ console.warn(`Génération terminée ; nettoyage manuel nécessaire : ${stage}`, cleanupError);
163
+ }
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ import { format, resolveConfig } from 'prettier';
3
+ /** Format newly generated TypeScript in memory before the transaction can write anything. */
4
+ export async function formatGeneratedCode(root, changes) {
5
+ const result = [];
6
+ for (const change of changes) {
7
+ // Existing shared files contain user formatting; their targeted edits must stay targeted.
8
+ if (change.operation !== 'create' || !change.path.endsWith('.ts')) {
9
+ result.push(change);
10
+ continue;
11
+ }
12
+ const filepath = path.join(root, change.path);
13
+ const options = await resolveConfig(filepath, { editorconfig: true, useCache: false });
14
+ result.push({
15
+ ...change,
16
+ content: await format(change.content, {
17
+ singleQuote: true,
18
+ trailingComma: 'all',
19
+ ...options,
20
+ filepath,
21
+ parser: 'typescript',
22
+ }),
23
+ });
24
+ }
25
+ return result;
26
+ }
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { projectPath } from './project-path.js';
6
+ export const generationManifestPath = '.nestgen/generation-manifest.json';
7
+ const manifestVersion = 1;
8
+ function generatorVersion() {
9
+ const packagePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
10
+ const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
11
+ if (typeof manifest.version !== 'string')
12
+ throw new Error('Version du générateur introuvable.');
13
+ return manifest.version;
14
+ }
15
+ function generationKey(definition) {
16
+ return `${definition.kind}:${definition.sourceRoot}/app/${definition.name}`;
17
+ }
18
+ export function resourceGenerationKey(sourceRoot, name) {
19
+ return `resource:${sourceRoot}/app/${name}`;
20
+ }
21
+ function sha256(content) {
22
+ return createHash('sha256').update(content).digest('hex');
23
+ }
24
+ function normalizeManifest(manifest) {
25
+ return {
26
+ version: 1,
27
+ generations: Object.fromEntries(Object.entries(manifest.generations)
28
+ .sort(([left], [right]) => left.localeCompare(right))
29
+ .map(([key, entry]) => [
30
+ key,
31
+ {
32
+ definition: entry.definition,
33
+ files: Object.fromEntries(Object.entries(entry.files).sort(([left], [right]) => left.localeCompare(right))),
34
+ generator: entry.generator,
35
+ },
36
+ ])),
37
+ };
38
+ }
39
+ export function loadGenerationManifest(projectRoot) {
40
+ const file = projectPath(projectRoot, generationManifestPath);
41
+ if (!fs.existsSync(file))
42
+ return undefined;
43
+ let value;
44
+ try {
45
+ value = JSON.parse(fs.readFileSync(file, 'utf8'));
46
+ }
47
+ catch {
48
+ throw new Error('Le manifeste de génération est invalide.');
49
+ }
50
+ if (!value || typeof value !== 'object' || value.version !== manifestVersion)
51
+ throw new Error('Version de manifeste de génération non supportée.');
52
+ const generations = value.generations;
53
+ if (!generations || typeof generations !== 'object' || Array.isArray(generations))
54
+ throw new Error('Le manifeste de génération est invalide.');
55
+ return normalizeManifest(value);
56
+ }
57
+ function resourceDefinition(name, orm, sourceRoot, route, table, profile, fields, indexes, relations, list) {
58
+ return {
59
+ kind: 'resource',
60
+ name,
61
+ orm,
62
+ sourceRoot,
63
+ route,
64
+ table,
65
+ profile,
66
+ // Defaults can contain domain secrets. They remain in the resource definition,
67
+ // but generation metadata must stay safe to commit.
68
+ fields: fields.map(({ defaultValue: _defaultValue, ...field }) => field),
69
+ indexes,
70
+ relations,
71
+ list,
72
+ };
73
+ }
74
+ export function moduleGenerationDefinition(name, orm, sourceRoot) {
75
+ return { kind: 'module', name, orm, sourceRoot };
76
+ }
77
+ export function resourceGenerationDefinition(name, orm, sourceRoot, route, table, profile, fields, indexes, relations, list) {
78
+ return resourceDefinition(name, orm, sourceRoot, route, table, profile, fields, indexes, relations, list);
79
+ }
80
+ /** Add the versioned generation manifest to the same transaction as generated files. */
81
+ export function withGenerationManifest(projectRoot, changes, definition) {
82
+ const existing = loadGenerationManifest(projectRoot) ?? { version: 1, generations: {} };
83
+ const files = Object.fromEntries(changes
84
+ .map((change) => [change.path, sha256(change.content)])
85
+ .sort(([left], [right]) => left.localeCompare(right)));
86
+ const manifest = normalizeManifest({
87
+ ...existing,
88
+ generations: {
89
+ ...existing.generations,
90
+ [generationKey(definition)]: {
91
+ definition,
92
+ files,
93
+ generator: { name: 'nestgen-cli', version: generatorVersion() },
94
+ },
95
+ },
96
+ });
97
+ return [
98
+ ...changes,
99
+ {
100
+ path: generationManifestPath,
101
+ content: `${JSON.stringify(manifest, null, 2)}\n`,
102
+ operation: existing.generations[generationKey(definition)] ||
103
+ fs.existsSync(projectPath(projectRoot, generationManifestPath))
104
+ ? 'replace'
105
+ : 'create',
106
+ },
107
+ ];
108
+ }
109
+ /** Report generated files changed after their recorded successful generation. */
110
+ export function modifiedGeneratedFiles(projectRoot, entry) {
111
+ return Object.entries(entry.files)
112
+ .filter(([file, fingerprint]) => {
113
+ const target = projectPath(projectRoot, file);
114
+ return !fs.existsSync(target) || sha256(fs.readFileSync(target, 'utf8')) !== fingerprint;
115
+ })
116
+ .map(([file]) => file)
117
+ .sort();
118
+ }
@@ -0,0 +1,78 @@
1
+ import { loadGenerationManifest, resourceGenerationKey } from './generation-manifest.js';
2
+ function migrationName(value) {
3
+ const normalized = value.trim().toLowerCase();
4
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized))
5
+ throw new Error('Nom de migration invalide. Utilisez des minuscules, chiffres et tirets.');
6
+ return normalized;
7
+ }
8
+ function indexKey(index) {
9
+ return `${index.unique ? 'unique:' : 'index:'}${index.fields.join('+')}`;
10
+ }
11
+ /** Plan an official ORM migration without generating or applying it. */
12
+ export function planResourceMigration(projectRoot, sourceRoot, resourceName, orm, requestedName, fields, indexes, relations) {
13
+ const manifest = loadGenerationManifest(projectRoot);
14
+ const entry = manifest?.generations[resourceGenerationKey(sourceRoot, resourceName)];
15
+ if (!entry || entry.definition.kind !== 'resource')
16
+ throw new Error(`Aucun manifeste de génération pour la ressource ${resourceName}.`);
17
+ const previous = entry.definition;
18
+ if (previous.kind !== 'resource' || entry.definition.orm !== orm)
19
+ throw new Error(`Le manifeste de la ressource ${resourceName} ne correspond pas à l'ORM ${orm}.`);
20
+ const risks = [];
21
+ const previousFields = new Map(previous.fields.map((field) => [field.name, field]));
22
+ const nextFields = new Map(fields.map((field) => [field.name, field]));
23
+ for (const [name, field] of previousFields) {
24
+ const next = nextFields.get(name);
25
+ if (!next) {
26
+ risks.push({
27
+ id: 'COLUMN_REMOVED',
28
+ severity: 'danger',
29
+ message: `Suppression potentielle de la colonne ${name}.`,
30
+ });
31
+ continue;
32
+ }
33
+ if (next.type !== field.type) {
34
+ risks.push({
35
+ id: 'FIELD_TYPE_CHANGED',
36
+ severity: 'danger',
37
+ message: `Changement de type potentiel pour ${name} (${field.type} vers ${next.type}).`,
38
+ });
39
+ }
40
+ if (field.nullable && !next.nullable) {
41
+ risks.push({
42
+ id: 'NULLABILITY_TIGHTENED',
43
+ severity: 'warning',
44
+ message: `${name} devient obligatoire ; les lignes existantes doivent être complétées.`,
45
+ });
46
+ }
47
+ }
48
+ const previousIndexes = new Set(previous.indexes.filter((index) => index.unique).map(indexKey));
49
+ for (const index of indexes.filter((item) => item.unique)) {
50
+ if (!previousIndexes.has(indexKey(index)))
51
+ risks.push({
52
+ id: 'UNIQUE_CONSTRAINT_ADDED',
53
+ severity: 'warning',
54
+ message: `Contrainte unique ajoutée sur ${index.fields.join(', ')} ; vérifiez les doublons existants.`,
55
+ });
56
+ }
57
+ const previousRelations = new Set(previous.relations.map((relation) => `${relation.type}:${relation.target}:${relation.field ?? ''}`));
58
+ const nextRelations = new Set(relations.map((relation) => `${relation.type}:${relation.target}:${relation.field ?? ''}`));
59
+ for (const relation of previousRelations) {
60
+ if (!nextRelations.has(relation))
61
+ risks.push({
62
+ id: 'RELATION_REMOVED',
63
+ severity: 'danger',
64
+ message: `Suppression potentielle de relation ${relation}.`,
65
+ });
66
+ }
67
+ const name = migrationName(requestedName);
68
+ return {
69
+ version: 1,
70
+ orm,
71
+ migrationName: name,
72
+ command: orm === 'typeorm'
73
+ ? `npx typeorm-ts-node-commonjs migration:generate src/database/migrations/${name} --dataSource src/database/data-source.ts`
74
+ : `npx prisma migrate dev --create-only --name ${name}`,
75
+ risks,
76
+ appliesMigration: false,
77
+ };
78
+ }
@@ -1,11 +1,11 @@
1
- import crypto from 'node:crypto';
2
1
  import fs from 'node:fs';
2
+ import { formatGeneratedCode } from './generated-code.js';
3
3
  import path from 'node:path';
4
4
  import ts from 'typescript';
5
- // The legacy preflight is JavaScript while the migration is incremental.
6
- // @ts-expect-error declaration added when preflight moves into this engine.
7
- import { inspectProject } from '../../nestjs-generator/features/preflight.mjs';
8
- const stateFile = '.nestgen-transaction.json';
5
+ import { applyFileChanges, previewFileChanges } from './file-transaction.js';
6
+ import { inspectProject } from './project-preflight.js';
7
+ import { projectPath } from './project-path.js';
8
+ import { moduleGenerationDefinition, withGenerationManifest } from './generation-manifest.js';
9
9
  function describe(rawName) {
10
10
  const name = rawName.trim().toLowerCase().replace(/_/g, '-');
11
11
  if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name))
@@ -35,7 +35,15 @@ function updateAppModule(source, moduleClass, moduleImport, moduleReference = mo
35
35
  !ts.isCallExpression(decorator.expression) ||
36
36
  !ts.isObjectLiteralExpression(decorator.expression.arguments[0]))
37
37
  throw new Error('AppModule doit utiliser @Module({ ... }).');
38
- const imports = decorator.expression.arguments[0].properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'imports');
38
+ const metadata = decorator.expression.arguments[0];
39
+ if (metadata.properties.some((property) => ts.isSpreadAssignment(property) || (property.name && ts.isComputedPropertyName(property.name))))
40
+ throw new Error('AppModule : les métadonnées avec spread ou clés calculées ne sont pas supportées.');
41
+ const importProperties = metadata.properties.filter((property) => property.name &&
42
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
43
+ property.name.text === 'imports');
44
+ if (importProperties.length > 1)
45
+ throw new Error('AppModule : propriété imports dupliquée.');
46
+ const imports = importProperties[0] && ts.isPropertyAssignment(importProperties[0]) ? importProperties[0] : undefined;
39
47
  if (!imports || !ts.isArrayLiteralExpression(imports.initializer))
40
48
  throw new Error('AppModule doit définir imports: [] pour enregistrer un module.');
41
49
  if (imports.initializer.elements.some((element) => element.getText(file) === moduleReference))
@@ -59,84 +67,91 @@ function updatePrismaSchema(source, resource) {
59
67
  throw new Error(`Le modèle Prisma ${resource.pascal} existe déjà.`);
60
68
  return `${source.trimEnd()}\n\nmodel ${resource.pascal} {\n id String @id @default(uuid())\n name String\n email String @unique\n\n @@map("${resource.table}")\n}\n`;
61
69
  }
62
- function ensurePrismaRuntime(projectRoot) {
63
- const prismaDirectory = path.join(projectRoot, 'src', 'prisma');
70
+ function prismaRuntimeChanges(projectRoot, sourceRoot) {
71
+ const prismaDirectory = path.join(projectRoot, sourceRoot, 'prisma');
64
72
  const servicePath = path.join(prismaDirectory, 'prisma.service.ts');
65
73
  const modulePath = path.join(prismaDirectory, 'prisma.module.ts');
74
+ if (fs.existsSync(servicePath) && fs.existsSync(modulePath))
75
+ return [];
66
76
  if (fs.existsSync(servicePath) || fs.existsSync(modulePath))
67
- return;
68
- fs.mkdirSync(prismaDirectory, { recursive: true });
69
- fs.writeFileSync(servicePath, "import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';\nimport { PrismaClient } from '@prisma/client';\n\n@Injectable()\nexport class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {\n async onModuleInit(): Promise<void> { await this.$connect(); }\n async onModuleDestroy(): Promise<void> { await this.$disconnect(); }\n}\n");
70
- fs.writeFileSync(modulePath, "import { Global, Module } from '@nestjs/common';\nimport { PrismaService } from './prisma.service';\n\n@Global()\n@Module({ providers: [PrismaService], exports: [PrismaService] })\nexport class PrismaModule {}\n");
77
+ throw new Error('Runtime Prisma incomplet.');
78
+ return [
79
+ {
80
+ path: `${sourceRoot}/prisma/prisma.service.ts`,
81
+ operation: 'create',
82
+ content: "import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';\nimport { PrismaClient } from '@prisma/client';\n\n@Injectable()\nexport class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {\n async onModuleInit(): Promise<void> { await this.$connect(); }\n async onModuleDestroy(): Promise<void> { await this.$disconnect(); }\n}\n",
83
+ },
84
+ {
85
+ path: `${sourceRoot}/prisma/prisma.module.ts`,
86
+ operation: 'create',
87
+ content: "import { Global, Module } from '@nestjs/common';\nimport { PrismaService } from './prisma.service.js';\n\n@Global()\n@Module({ providers: [PrismaService], exports: [PrismaService] })\nexport class PrismaModule {}\n",
88
+ },
89
+ ];
71
90
  }
72
91
  function files(resource, orm) {
73
92
  const token = `${resource.pascal}RepositoryToken`;
74
93
  const result = new Map();
75
94
  result.set(`core/domain/entities/${resource.name}.entity.ts`, `export class ${resource.pascal} {\n constructor(public readonly id: string | undefined, public name: string, public email: string) {}\n}\n`);
76
- result.set(`core/domain/ports/${resource.name}.repository.ts`, `import { ${resource.pascal} } from '../entities/${resource.name}.entity';\n\nexport const ${token} = Symbol('${resource.pascal}RepositoryPort');\nexport interface ${resource.pascal}RepositoryPort { save(value: ${resource.pascal}): Promise<${resource.pascal}>; findById(id: string): Promise<${resource.pascal} | null>; }\n`);
95
+ result.set(`core/domain/ports/${resource.name}.repository.ts`, `import { ${resource.pascal} } from '../entities/${resource.name}.entity.js';\n\nexport const ${token} = Symbol('${resource.pascal}RepositoryPort');\nexport interface ${resource.pascal}RepositoryPort { save(value: ${resource.pascal}): Promise<${resource.pascal}>; findById(id: string): Promise<${resource.pascal} | null>; }\n`);
77
96
  result.set(`core/application/commands/create-${resource.name}.command.ts`, `export class Create${resource.pascal}Command { constructor(public readonly name: string, public readonly email: string) {} }\n`);
78
- result.set(`core/application/commands/create-${resource.name}.handler.ts`, `import { Inject } from '@nestjs/common';\nimport { CommandHandler, ICommandHandler } from '@nestjs/cqrs';\nimport { Create${resource.pascal}Command } from './create-${resource.name}.command';\nimport { ${resource.pascal} } from '../../domain/entities/${resource.name}.entity';\nimport { ${resource.pascal}RepositoryPort, ${token} } from '../../domain/ports/${resource.name}.repository';\n@CommandHandler(Create${resource.pascal}Command)\nexport class Create${resource.pascal}Handler implements ICommandHandler<Create${resource.pascal}Command> { constructor(@Inject(${token}) private readonly repository: ${resource.pascal}RepositoryPort) {} async execute(command: Create${resource.pascal}Command): Promise<string> { return (await this.repository.save(new ${resource.pascal}(undefined, command.name, command.email))).id!; } }\n`);
97
+ result.set(`core/application/commands/create-${resource.name}.handler.ts`, `import { Inject } from '@nestjs/common';\nimport { CommandHandler, ICommandHandler } from '@nestjs/cqrs';\nimport { Create${resource.pascal}Command } from './create-${resource.name}.command.js';\nimport { ${resource.pascal} } from '../../domain/entities/${resource.name}.entity.js';\nimport { ${resource.pascal}RepositoryPort, ${token} } from '../../domain/ports/${resource.name}.repository.js';\n@CommandHandler(Create${resource.pascal}Command)\nexport class Create${resource.pascal}Handler implements ICommandHandler<Create${resource.pascal}Command> { constructor(@Inject(${token}) private readonly repository: ${resource.pascal}RepositoryPort) {} async execute(command: Create${resource.pascal}Command): Promise<string> { return (await this.repository.save(new ${resource.pascal}(undefined, command.name, command.email))).id!; } }\n`);
79
98
  result.set(`interfaces/dtos/create-${resource.name}.dto.ts`, `import { IsEmail, IsString } from 'class-validator';\nexport class Create${resource.pascal}Dto { @IsString() name!: string; @IsEmail() email!: string; }\n`);
80
- result.set(`interfaces/controllers/${resource.name}.controller.ts`, `import { Body, Controller, Post } from '@nestjs/common';\nimport { CommandBus } from '@nestjs/cqrs';\nimport { Create${resource.pascal}Command } from '../../core/application/commands/create-${resource.name}.command';\nimport { Create${resource.pascal}Dto } from '../dtos/create-${resource.name}.dto';\n@Controller('${resource.route}') export class ${resource.pascal}Controller { constructor(private readonly commandBus: CommandBus) {} @Post() async create(@Body() dto: Create${resource.pascal}Dto) { return { id: await this.commandBus.execute(new Create${resource.pascal}Command(dto.name, dto.email)) }; } }\n`);
99
+ result.set(`interfaces/controllers/${resource.name}.controller.ts`, `import { Body, Controller, Post } from '@nestjs/common';\nimport { CommandBus } from '@nestjs/cqrs';\nimport { Create${resource.pascal}Command } from '../../core/application/commands/create-${resource.name}.command.js';\nimport { Create${resource.pascal}Dto } from '../dtos/create-${resource.name}.dto.js';\n@Controller('${resource.route}') export class ${resource.pascal}Controller { constructor(private readonly commandBus: CommandBus) {} @Post() async create(@Body() dto: Create${resource.pascal}Dto) { return { id: await this.commandBus.execute(new Create${resource.pascal}Command(dto.name, dto.email)) }; } }\n`);
81
100
  if (orm === 'typeorm') {
82
101
  result.set(`infrastructure/persistences/repositories/${resource.name}.orm.ts`, `import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';\n@Entity('${resource.table}') export class ${resource.pascal}Entity { @PrimaryGeneratedColumn('uuid') id!: string; @Column() name!: string; @Column({ unique: true }) email!: string; }\n`);
83
- result.set(`infrastructure/persistences/repositories/${resource.name}.typeorm.repository.ts`, `import { Injectable } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { Repository } from 'typeorm';\nimport { ${resource.pascal} } from '../../../core/domain/entities/${resource.name}.entity';\nimport { ${resource.pascal}RepositoryPort } from '../../../core/domain/ports/${resource.name}.repository';\nimport { ${resource.pascal}Entity } from './${resource.name}.orm';\n@Injectable() export class ${resource.pascal}TypeOrmRepository implements ${resource.pascal}RepositoryPort { constructor(@InjectRepository(${resource.pascal}Entity) private readonly repository: Repository<${resource.pascal}Entity>) {} async save(value: ${resource.pascal}) { const saved = await this.repository.save(this.repository.create({ name: value.name, email: value.email })); return new ${resource.pascal}(saved.id, saved.name, saved.email); } async findById(id: string) { const found = await this.repository.findOneBy({ id }); return found ? new ${resource.pascal}(found.id, found.name, found.email) : null; } }\n`);
84
- result.set(`${resource.name}.module.ts`, `import { Module } from '@nestjs/common';\nimport { CqrsModule } from '@nestjs/cqrs';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { Create${resource.pascal}Handler } from './core/application/commands/create-${resource.name}.handler';\nimport { ${token} } from './core/domain/ports/${resource.name}.repository';\nimport { ${resource.pascal}Entity } from './infrastructure/persistences/repositories/${resource.name}.orm';\nimport { ${resource.pascal}TypeOrmRepository } from './infrastructure/persistences/repositories/${resource.name}.typeorm.repository';\nimport { ${resource.pascal}Controller } from './interfaces/controllers/${resource.name}.controller';\n@Module({ imports: [CqrsModule, TypeOrmModule.forFeature([${resource.pascal}Entity])], controllers: [${resource.pascal}Controller], providers: [Create${resource.pascal}Handler, { provide: ${token}, useClass: ${resource.pascal}TypeOrmRepository }] }) export class ${resource.pascal}Module {}\n`);
102
+ result.set(`infrastructure/persistences/repositories/${resource.name}.typeorm.repository.ts`, `import { Injectable } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { Repository } from 'typeorm';\nimport { ${resource.pascal} } from '../../../core/domain/entities/${resource.name}.entity.js';\nimport { ${resource.pascal}RepositoryPort } from '../../../core/domain/ports/${resource.name}.repository.js';\nimport { ${resource.pascal}Entity } from './${resource.name}.orm.js';\n@Injectable() export class ${resource.pascal}TypeOrmRepository implements ${resource.pascal}RepositoryPort { constructor(@InjectRepository(${resource.pascal}Entity) private readonly repository: Repository<${resource.pascal}Entity>) {} async save(value: ${resource.pascal}) { const saved = await this.repository.save(this.repository.create({ name: value.name, email: value.email })); return new ${resource.pascal}(saved.id, saved.name, saved.email); } async findById(id: string) { const found = await this.repository.findOneBy({ id }); return found ? new ${resource.pascal}(found.id, found.name, found.email) : null; } }\n`);
103
+ result.set(`${resource.name}.module.ts`, `import { Module } from '@nestjs/common';\nimport { CqrsModule } from '@nestjs/cqrs';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { Create${resource.pascal}Handler } from './core/application/commands/create-${resource.name}.handler.js';\nimport { ${token} } from './core/domain/ports/${resource.name}.repository.js';\nimport { ${resource.pascal}Entity } from './infrastructure/persistences/repositories/${resource.name}.orm.js';\nimport { ${resource.pascal}TypeOrmRepository } from './infrastructure/persistences/repositories/${resource.name}.typeorm.repository.js';\nimport { ${resource.pascal}Controller } from './interfaces/controllers/${resource.name}.controller.js';\n@Module({ imports: [CqrsModule, TypeOrmModule.forFeature([${resource.pascal}Entity])], controllers: [${resource.pascal}Controller], providers: [Create${resource.pascal}Handler, { provide: ${token}, useClass: ${resource.pascal}TypeOrmRepository }] }) export class ${resource.pascal}Module {}\n`);
85
104
  }
86
105
  else {
87
- result.set(`infrastructure/persistences/repositories/${resource.name}.prisma.repository.ts`, `import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '../../../../../prisma/prisma.service';\nimport { ${resource.pascal} } from '../../../core/domain/entities/${resource.name}.entity';\nimport { ${resource.pascal}RepositoryPort } from '../../../core/domain/ports/${resource.name}.repository';\n@Injectable() export class ${resource.pascal}PrismaRepository implements ${resource.pascal}RepositoryPort { constructor(private readonly prisma: PrismaService) {} async save(value: ${resource.pascal}) { const saved = await this.prisma.${resource.camel}.create({ data: { name: value.name, email: value.email } }); return new ${resource.pascal}(saved.id, saved.name, saved.email); } async findById(id: string) { const found = await this.prisma.${resource.camel}.findUnique({ where: { id } }); return found ? new ${resource.pascal}(found.id, found.name, found.email) : null; } }\n`);
88
- result.set(`${resource.name}.module.ts`, `import { Module } from '@nestjs/common';\nimport { CqrsModule } from '@nestjs/cqrs';\nimport { PrismaModule } from '../../prisma/prisma.module';\nimport { Create${resource.pascal}Handler } from './core/application/commands/create-${resource.name}.handler';\nimport { ${token} } from './core/domain/ports/${resource.name}.repository';\nimport { ${resource.pascal}PrismaRepository } from './infrastructure/persistences/repositories/${resource.name}.prisma.repository';\nimport { ${resource.pascal}Controller } from './interfaces/controllers/${resource.name}.controller';\n@Module({ imports: [CqrsModule, PrismaModule], controllers: [${resource.pascal}Controller], providers: [Create${resource.pascal}Handler, { provide: ${token}, useClass: ${resource.pascal}PrismaRepository }] }) export class ${resource.pascal}Module {}\n`);
106
+ result.set(`infrastructure/persistences/repositories/${resource.name}.prisma.repository.ts`, `import { Injectable } from '@nestjs/common';\nimport { PrismaService } from '../../../../../prisma/prisma.service.js';\nimport { ${resource.pascal} } from '../../../core/domain/entities/${resource.name}.entity.js';\nimport { ${resource.pascal}RepositoryPort } from '../../../core/domain/ports/${resource.name}.repository.js';\n@Injectable() export class ${resource.pascal}PrismaRepository implements ${resource.pascal}RepositoryPort { constructor(private readonly prisma: PrismaService) {} async save(value: ${resource.pascal}) { const saved = await this.prisma.${resource.camel}.create({ data: { name: value.name, email: value.email } }); return new ${resource.pascal}(saved.id, saved.name, saved.email); } async findById(id: string) { const found = await this.prisma.${resource.camel}.findUnique({ where: { id } }); return found ? new ${resource.pascal}(found.id, found.name, found.email) : null; } }\n`);
107
+ result.set(`${resource.name}.module.ts`, `import { Module } from '@nestjs/common';\nimport { CqrsModule } from '@nestjs/cqrs';\nimport { PrismaModule } from '../../prisma/prisma.module.js';\nimport { Create${resource.pascal}Handler } from './core/application/commands/create-${resource.name}.handler.js';\nimport { ${token} } from './core/domain/ports/${resource.name}.repository.js';\nimport { ${resource.pascal}PrismaRepository } from './infrastructure/persistences/repositories/${resource.name}.prisma.repository.js';\nimport { ${resource.pascal}Controller } from './interfaces/controllers/${resource.name}.controller.js';\n@Module({ imports: [CqrsModule, PrismaModule], controllers: [${resource.pascal}Controller], providers: [Create${resource.pascal}Handler, { provide: ${token}, useClass: ${resource.pascal}PrismaRepository }] }) export class ${resource.pascal}Module {}\n`);
89
108
  }
90
109
  return result;
91
110
  }
92
- export function generateModule(projectRoot, rawName, orm) {
111
+ export async function planModuleGeneration(projectRoot, rawName, orm, application) {
93
112
  if (orm !== 'typeorm' && orm !== 'prisma')
94
113
  throw new Error(`ORM non supporté : ${orm}.`);
95
- inspectProject(projectRoot, orm);
96
114
  const resource = describe(rawName);
97
- const pending = path.join(projectRoot, stateFile);
98
- const moduleDirectory = path.join(projectRoot, 'src/app', resource.name);
99
- const stage = path.join(projectRoot, `.nestgen-stage-${crypto.randomUUID()}`);
100
- const appModule = path.join(projectRoot, 'src/app.module.ts');
101
- const prismaSchema = path.join(projectRoot, 'prisma/schema.prisma');
102
- if (fs.existsSync(pending))
103
- throw new Error(`Transaction interrompue détectée : ${stateFile}.`);
104
- if (fs.existsSync(moduleDirectory))
105
- throw new Error(`Le module ${resource.name} existe déjà.`);
106
- const originalAppModule = fs.readFileSync(appModule, 'utf8');
107
- const originalSchema = orm === 'prisma' ? fs.readFileSync(prismaSchema, 'utf8') : undefined;
108
- const state = { status: 'prepared', stage, moduleDirectory, appModule };
109
- try {
110
- for (const [relative, content] of files(resource, orm)) {
111
- const target = path.join(stage, relative);
112
- fs.mkdirSync(path.dirname(target), { recursive: true });
113
- fs.writeFileSync(target, content);
114
- }
115
- const updated = updateAppModule(originalAppModule, `${resource.pascal}Module`, `./app/${resource.name}/${resource.name}.module`);
116
- fs.writeFileSync(pending, JSON.stringify(state, null, 2));
117
- fs.mkdirSync(path.dirname(moduleDirectory), { recursive: true });
118
- fs.renameSync(stage, moduleDirectory);
119
- state.status = 'module-applied';
120
- fs.writeFileSync(pending, JSON.stringify(state, null, 2));
121
- const temporary = `${appModule}.nestgen-${process.pid}`;
122
- fs.writeFileSync(temporary, updated);
123
- fs.renameSync(temporary, appModule);
124
- if (originalSchema) {
125
- fs.writeFileSync(prismaSchema, updatePrismaSchema(originalSchema, resource));
126
- ensurePrismaRuntime(projectRoot);
127
- }
128
- fs.rmSync(pending);
129
- }
130
- catch (error) {
131
- if (state.status === 'module-applied')
132
- fs.rmSync(moduleDirectory, { recursive: true, force: true });
133
- if (state.status === 'module-applied')
134
- fs.writeFileSync(appModule, originalAppModule);
135
- if (originalSchema)
136
- fs.writeFileSync(prismaSchema, originalSchema);
137
- throw error;
138
- }
139
- finally {
140
- fs.rmSync(stage, { recursive: true, force: true });
115
+ const project = inspectProject(projectRoot, orm, { application });
116
+ projectRoot = project.root;
117
+ const featureDirectory = projectPath(projectRoot, `${project.sourceRoot}/app/${resource.name}`);
118
+ const appModule = fs.readFileSync(project.appModulePath, 'utf8');
119
+ const changes = [...files(resource, orm)].map(([relative, content]) => ({
120
+ path: `${project.sourceRoot}/app/${resource.name}/${relative}`,
121
+ content,
122
+ operation: 'create',
123
+ }));
124
+ changes.push({
125
+ path: `${project.sourceRoot}/app.module.ts`,
126
+ operation: 'replace',
127
+ content: updateAppModule(appModule, `${resource.pascal}Module`, `./app/${resource.name}/${resource.name}.module.js`),
128
+ });
129
+ if (orm === 'prisma') {
130
+ const schema = fs.readFileSync(path.join(projectRoot, 'prisma/schema.prisma'), 'utf8');
131
+ changes.push({
132
+ path: 'prisma/schema.prisma',
133
+ operation: 'replace',
134
+ content: updatePrismaSchema(schema, resource),
135
+ });
136
+ changes.push(...prismaRuntimeChanges(projectRoot, project.sourceRoot));
141
137
  }
138
+ const formattedChanges = await formatGeneratedCode(projectRoot, changes);
139
+ const changesWithManifest = withGenerationManifest(projectRoot, formattedChanges, moduleGenerationDefinition(resource.name, orm, project.sourceRoot));
140
+ return {
141
+ root: projectRoot,
142
+ changes: changesWithManifest,
143
+ preview: previewFileChanges(projectRoot, changesWithManifest),
144
+ featureConflict: fs.existsSync(featureDirectory)
145
+ ? `Le module ou la ressource ${resource.name} existe déjà.`
146
+ : undefined,
147
+ };
148
+ }
149
+ export async function generateModule(projectRoot, rawName, orm, application) {
150
+ const plan = await planModuleGeneration(projectRoot, rawName, orm, application);
151
+ if (plan.featureConflict)
152
+ throw new Error(plan.featureConflict);
153
+ const conflict = plan.preview.find((change) => change.status === 'conflict');
154
+ if (conflict)
155
+ throw new Error(conflict.reason);
156
+ applyFileChanges(plan.root, plan.changes);
142
157
  }
@@ -20,8 +20,11 @@ export function loadConfig(projectRoot) {
20
20
  throw new Error('Version de configuration ou de templates non supportée.');
21
21
  if (config.orm !== 'typeorm' && config.orm !== 'prisma')
22
22
  throw new Error('ORM de configuration invalide.');
23
- if (config.sourceRoot !== 'src')
24
- throw new Error('Seul sourceRoot "src" est supporté.');
23
+ if (typeof config.sourceRoot !== 'string' ||
24
+ !config.sourceRoot ||
25
+ path.isAbsolute(config.sourceRoot) ||
26
+ config.sourceRoot.split(/[\\/]/).includes('..'))
27
+ throw new Error('sourceRoot de configuration invalide.');
25
28
  const packageManager = config.packageManager;
26
29
  if (!packageManager || !['npm', 'pnpm', 'yarn'].includes(packageManager))
27
30
  throw new Error('Package manager invalide.');
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Resolve a path inside a canonical project root, without following child symlinks. */
4
+ export function projectPath(root, relativePath) {
5
+ const target = path.resolve(root, relativePath);
6
+ const relative = path.relative(root, target);
7
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
8
+ throw new Error(`Destination hors projet : ${relativePath}`);
9
+ let current = root;
10
+ for (const part of relative.split(path.sep)) {
11
+ current = path.join(current, part);
12
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
13
+ if (stat?.isSymbolicLink())
14
+ throw new Error(`Lien symbolique non supporté : ${current}`);
15
+ if (stat && current !== target && !stat.isDirectory())
16
+ throw new Error(`Répertoire attendu : ${current}`);
17
+ }
18
+ return target;
19
+ }
20
+ export function availableFeatureDirectory(root, name, sourceRoot = 'src') {
21
+ const directory = projectPath(root, `${sourceRoot}/app/${name}`);
22
+ if (fs.lstatSync(directory, { throwIfNoEntry: false }))
23
+ throw new Error(`Le module ou la ressource ${name} existe déjà.`);
24
+ return directory;
25
+ }