diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index ca02c4c..ba208e3 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -62,21 +62,21 @@ jobs: - name: Build binaries run: bun run binaries - - name: Configure Gitea registry auth + - name: Configure npm registry auth run: | - echo "@blas:registry=https://git.blassanto.me/api/packages/blas/npm/" >> ~/.npmrc - echo "//git.blassanto.me/api/packages/blas/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc + echo "registry=https://registry.npmjs.org" >> ~/.npmrc + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc cat >> ~/.bunfig.toml << EOF [install.scopes] - "@blas" = { registry = "https://git.blassanto.me/api/packages/blas/npm/", token = "${NODE_AUTH_TOKEN}" } + "@0kmpo" = { registry = "https://registry.npmjs.org", token = "${NODE_AUTH_TOKEN}" } EOF env: - NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish to Gitea registry + - name: Publish to npm registry run: bun publish --access public env: - NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create Gitea release and upload binaries run: | diff --git a/main.ts b/main.ts index 9362052..aa59d93 100755 --- a/main.ts +++ b/main.ts @@ -5,7 +5,15 @@ import mustache from 'mustache'; import path from 'path'; import { program } from 'commander'; -import { log, logSuccess, logInfo, logWarning, logError, colors } from './src/utils/logger'; +import { + log, + logSuccess, + logWarning, + logError, + logDetail, + initGenerationLog, + colors +} from './src/utils/logger'; import { checkOpenApiGenerator, installOpenApiGenerator } from './src/utils/openapi-generator'; import { createDirectoryStructure, cleanup } from './src/utils/filesystem'; import { analyzeSwagger } from './src/swagger/analyzer'; @@ -25,7 +33,7 @@ import { deriveSelectionFilter, deriveTagApiKeyMap } from './src/utils/config'; -import type { SelectionFilter } from './src/types'; +import type { SelectionFilter, LintResult } from './src/types'; import type { CliOptions } from './src/types'; import packageJson from './package.json'; @@ -61,6 +69,9 @@ async function main(): Promise { log(' Angular + Clean Architecture Code Generator', 'cyan'); console.log('='.repeat(60) + '\n'); + const logPath = path.join(process.cwd(), 'generation.log'); + initGenerationLog(logPath); + // ── CONFIG FILE: override CLI defaults with config values ───────────────── const configFile = options.config; const generationConfig = configFile ? loadConfig(configFile) : undefined; @@ -72,18 +83,18 @@ async function main(): Promise { if (generationConfig.skipInstall !== undefined) options.skipInstall = generationConfig.skipInstall; if (generationConfig.skipLint !== undefined) options.skipLint = generationConfig.skipLint; - logInfo(`Using configuration file: ${configFile}`); + logDetail('config', `Using configuration file: ${configFile}`); } + logDetail('config', `Input: ${options.input}`); + logDetail('config', `Output: ${options.output}`); + logDetail('config', `Templates: ${options.templates}`); + if (!fs.existsSync(options.input)) { logError(`File not found: ${options.input}`); process.exit(1); } - logInfo(`Input file: ${options.input}`); - logInfo(`Output directory: ${options.output}`); - logInfo(`Templates: ${options.templates}`); - if (options.dryRun) { logWarning('DRY RUN mode — no files will be generated'); } @@ -120,12 +131,15 @@ async function main(): Promise { writeConfig(defaultConfig, outputFile); logSuccess(`Configuration file generated: ${outputFile}`); - logInfo('Edit the file to customise tags, endpoints and baseUrls, then run with --config'); + logDetail( + 'config', + 'Edit the file to customise tags, endpoints and baseUrls, then run with --config' + ); return; } if (options.dryRun) { - logInfo('Finishing in DRY RUN mode'); + logWarning('Finishing in DRY RUN mode'); return; } @@ -139,7 +153,10 @@ async function main(): Promise { // Config-driven: derive everything from the JSON file selectionFilter = deriveSelectionFilter(generationConfig); tagApiKeyMap = deriveTagApiKeyMap(generationConfig); - logInfo(`Tags from config: ${Object.keys(generationConfig.tags).join(', ')}`); + logDetail('config', `Tags from config: ${Object.keys(generationConfig.tags).join(', ')}`); + Object.entries(tagApiKeyMap).forEach(([tag, key]) => { + logDetail('config', `API key for "${tag}": environment.${key}.url`); + }); } else { // Interactive mode (original behaviour) if (options.selectEndpoints) { @@ -160,9 +177,7 @@ async function main(): Promise { logSuccess( `environment.ts found: ${colors.cyan}${path.relative(process.cwd(), envFile)}${colors.reset}` ); - if (apiKeys.length > 0) { - logInfo(`Detected API keys: ${apiKeys.map((k) => k.key).join(', ')}`); - } else { + if (apiKeys.length === 0) { logWarning('No keys containing "api" found in environment.ts. Will be requested manually.'); } } else { @@ -170,6 +185,9 @@ async function main(): Promise { } tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys); + Object.entries(tagApiKeyMap).forEach(([tag, key]) => { + logDetail('config', `API key for "${tag}": environment.${key}.url`); + }); } // ────────────────────────────────────────────────────────────────────────── @@ -186,11 +204,13 @@ async function main(): Promise { ); cleanup(tempDir); - if (!options.skipLint) { - lintGeneratedFiles(options.output); - } + const noLintResult: LintResult = { + prettier: { ran: false, filesFormatted: 0 }, + eslint: { ran: false, filesFixed: 0 } + }; + const lintResult = options.skipLint ? noLintResult : lintGeneratedFiles(options.output); - const report = generateReport(options.output, analysis); + const report = generateReport(options.output, analysis, lintResult); console.log('\n' + '='.repeat(60)); log(' ✨ Generation completed successfully', 'green'); diff --git a/package.json b/package.json index d0d12f9..2b2b64e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blas/openapi-clean-arch-generator", - "version": "1.3.0", + "version": "1.3.5", "description": "Angular Clean Architecture generator from OpenAPI/Swagger", "main": "dist/main.js", "bin": { diff --git a/src/generators/clean-arch.generator.ts b/src/generators/clean-arch.generator.ts index 78b8dc9..747dd2d 100644 --- a/src/generators/clean-arch.generator.ts +++ b/src/generators/clean-arch.generator.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra'; import path from 'path'; import mustache from 'mustache'; -import { logStep, logSuccess, logInfo } from '../utils/logger'; +import { logStep, logSuccess, logDetail } from '../utils/logger'; import { mapSwaggerTypeToTs } from '../utils/type-mapper'; import { toCamelCase } from '../utils/name-formatter'; import { resolveMockValue } from '../utils/mock-value-resolver'; @@ -157,7 +157,7 @@ export function generateCleanArchitecture( const destPath = path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.ts`); fs.writeFileSync(destPath, output); generatedCount.models++; - logInfo(` ${toCamelCase(baseName)}.model.ts → ${path.relative(process.cwd(), destPath)}`); + logDetail('generate', `model-entity → ${path.relative(process.cwd(), destPath)}`); } // Mapper @@ -514,6 +514,10 @@ function renderTemplate( const output = mustache.render(template, viewData); fs.writeFileSync(destPath, output); counter[key]++; + logDetail( + 'generate', + `${templateName.replace('.mustache', '')} → ${path.relative(process.cwd(), destPath)}` + ); } } diff --git a/src/generators/dto.generator.ts b/src/generators/dto.generator.ts index fbe0629..c82819b 100644 --- a/src/generators/dto.generator.ts +++ b/src/generators/dto.generator.ts @@ -1,7 +1,7 @@ import { execSync } from 'child_process'; import fs from 'fs-extra'; import path from 'path'; -import { logStep, logSuccess, logError, logInfo } from '../utils/logger'; +import { logStep, logSuccess, logError, logDetail } from '../utils/logger'; /** Invokes `openapi-generator-cli` to generate DTOs into a temporary directory. */ export function generateCode(swaggerFile: string, templatesDir: string): string { @@ -22,7 +22,7 @@ export function generateCode(swaggerFile: string, templatesDir: string): string -o "${tempDir}" \ --additional-properties=ngVersion=17.0.0,modelFileSuffix=.dto,modelNameSuffix=Dto`; - execSync(command, { stdio: 'inherit' }); + execSync(command, { stdio: 'pipe' }); logSuccess('Code generated successfully'); return tempDir; @@ -51,10 +51,9 @@ export function organizeFiles(tempDir: string, outputDir: string): void { files.forEach((file) => { const sourcePath = path.join(sourceDir, file); const destPath = path.join(destDir, file); - fs.copySync(sourcePath, destPath); filesMoved++; - logInfo(` ${file} → ${path.relative(process.cwd(), destPath)}`); + logDetail('dto', `${file} → ${path.relative(process.cwd(), destPath)}`); }); } @@ -119,7 +118,7 @@ export function addDtoImports(outputDir: string): void { if (content !== originalContent) { fs.writeFileSync(filePath, content); filesProcessed++; - logInfo(` Procesado ${file}`); + logDetail('dto', `Post-processed ${file} (added ${imports.length} import(s))`); } }); diff --git a/src/generators/lint.generator.ts b/src/generators/lint.generator.ts index 2ac81f1..5b31c8b 100644 --- a/src/generators/lint.generator.ts +++ b/src/generators/lint.generator.ts @@ -1,7 +1,8 @@ import fs from 'fs-extra'; import path from 'path'; import { spawnSync } from 'child_process'; -import { logStep, logSuccess, logWarning, logInfo } from '../utils/logger'; +import { logStep, logSuccess, logWarning, logDetail } from '../utils/logger'; +import type { LintResult } from '../types'; /** * Walks up the directory tree from `startDir` to find the nearest @@ -36,17 +37,19 @@ function collectTsFiles(dir: string): string[] { } /** - * Runs a command synchronously and returns whether it succeeded. - * Prints stdout/stderr to the console only on failure. + * Runs a command synchronously. Only prints to console on fatal failure (exit >= 2). + * Exit code 1 from ESLint means "warnings remain after --fix" — not a fatal error. + * Returns captured output for logging to file. */ -function run(cmd: string, args: string[], cwd: string): boolean { +function run(cmd: string, args: string[], cwd: string): { success: boolean; output: string } { const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: true }); - if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + const fatalError = result.status === null || result.status >= 2; + if (fatalError) { if (result.stderr) process.stderr.write(result.stderr); if (result.stdout) process.stdout.write(result.stdout); - return false; } - return true; + return { success: !fatalError, output }; } /** @@ -56,28 +59,39 @@ function run(cmd: string, args: string[], cwd: string): boolean { * * - Prettier: always attempted; logs a warning if not found. * - ESLint: optional; silently skipped if no config is found in the project root. + * + * Returns a `LintResult` with the outcome of each tool for inclusion in the report. */ -export function lintGeneratedFiles(outputDir: string): void { +export function lintGeneratedFiles(outputDir: string): LintResult { logStep('Linting generated files...'); + const result: LintResult = { + prettier: { ran: false, filesFormatted: 0 }, + eslint: { ran: false, filesFixed: 0 } + }; + const projectRoot = findProjectRoot(outputDir); if (!projectRoot) { logWarning('Could not locate a project root (package.json). Skipping lint.'); - return; + return result; } - logInfo(` Project root: ${projectRoot}`); const files = collectTsFiles(outputDir); if (files.length === 0) { logWarning('No TypeScript files found in output directory. Skipping lint.'); - return; + return result; } + logDetail('lint', `Project root: ${projectRoot}`); + logDetail('lint', `Files to process: ${files.length}`); + const relativePaths = files.map((f) => path.relative(projectRoot, f)); // --- Prettier --- - const prettierOk = run('npx', ['prettier', '--write', ...relativePaths], projectRoot); - if (prettierOk) { + const prettier = run('npx', ['prettier', '--write', ...relativePaths], projectRoot); + if (prettier.output) logDetail('prettier', prettier.output); + if (prettier.success) { + result.prettier = { ran: true, filesFormatted: files.length }; logSuccess(`Prettier formatted ${files.length} files`); } else { logWarning('Prettier not available or encountered errors. Skipping formatting.'); @@ -94,13 +108,17 @@ export function lintGeneratedFiles(outputDir: string): void { if (!hasEslintConfig) { logWarning('No ESLint config found in project root. Skipping ESLint fix.'); - return; + return result; } - const eslintOk = run('npx', ['eslint', '--fix', ...relativePaths], projectRoot); - if (eslintOk) { + const eslint = run('npx', ['eslint', '--fix', ...relativePaths], projectRoot); + if (eslint.output) logDetail('eslint', eslint.output); + if (eslint.success) { + result.eslint = { ran: true, filesFixed: files.length }; logSuccess(`ESLint fixed ${files.length} files`); } else { logWarning('ESLint reported errors that could not be auto-fixed. Review the output above.'); } + + return result; } diff --git a/src/generators/report.generator.ts b/src/generators/report.generator.ts index c127b25..9d3f086 100644 --- a/src/generators/report.generator.ts +++ b/src/generators/report.generator.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra'; import path from 'path'; import { logStep, logSuccess } from '../utils/logger'; -import type { SwaggerAnalysis, GenerationReport } from '../types'; +import type { SwaggerAnalysis, GenerationReport, LintResult } from '../types'; /** Counts files ending with `.mock.ts` in a directory (returns 0 if directory does not exist). */ function countMockFiles(dir: string): number { @@ -22,14 +22,32 @@ function countSpecFiles(dir: string): number { } /** Generates and persists the `generation-report.json` file with process statistics. */ -export function generateReport(outputDir: string, analysis: SwaggerAnalysis): GenerationReport { +export function generateReport( + outputDir: string, + analysis: SwaggerAnalysis, + lintResult: LintResult +): GenerationReport { logStep('Generating report...'); + const tags = Array.isArray(analysis.tags) ? analysis.tags : []; + const tagDetails = tags.map((tag: unknown) => { + const t = tag as { name: string; description?: string }; + const endpointCount = Object.values(analysis.paths).filter((pathObj) => + Object.values(pathObj as Record).some((op) => { + const operation = op as { tags?: string[] }; + return operation.tags?.includes(t.name); + }) + ).length; + return { name: t.name, description: t.description || '', endpoints: endpointCount }; + }); + const report: GenerationReport = { timestamp: new Date().toISOString(), tags: analysis.tags.length, endpoints: Object.keys(analysis.paths).length, + tagDetails, outputDirectory: outputDir, + linting: lintResult, structure: { dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length, repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length, diff --git a/src/swagger/analyzer.ts b/src/swagger/analyzer.ts index fc09e31..567694d 100644 --- a/src/swagger/analyzer.ts +++ b/src/swagger/analyzer.ts @@ -1,6 +1,6 @@ import fs from 'fs-extra'; import yaml from 'js-yaml'; -import { logStep, logInfo, logError } from '../utils/logger'; +import { logStep, logSuccess, logError, logDetail } from '../utils/logger'; import type { SwaggerAnalysis } from '../types'; /** Parses an OpenAPI/Swagger file and extracts tags, paths and the full document. */ @@ -14,14 +14,15 @@ export function analyzeSwagger(swaggerFile: string): SwaggerAnalysis { const tags = Array.isArray(swagger.tags) ? swagger.tags : []; const paths = (swagger.paths as Record) || {}; - logInfo(`Found ${tags.length} tags in the API`); - logInfo(`Found ${Object.keys(paths).length} endpoints`); - + logDetail('analyze', `Input: ${swaggerFile}`); + logDetail('analyze', `Found ${tags.length} tags, ${Object.keys(paths).length} endpoints`); tags.forEach((tag: unknown) => { const t = tag as { name: string; description?: string }; - logInfo(` - ${t.name}: ${t.description || 'No description'}`); + logDetail('analyze', ` - ${t.name}: ${t.description || 'No description'}`); }); + logSuccess(`${tags.length} tags, ${Object.keys(paths).length} endpoints found`); + return { tags, paths, swagger }; } catch (error: unknown) { const err = error as Error; diff --git a/src/types/generation.types.ts b/src/types/generation.types.ts index 22a3c87..ee02c5a 100644 --- a/src/types/generation.types.ts +++ b/src/types/generation.types.ts @@ -11,6 +11,23 @@ export interface GeneratedCount { specs: number; } +/** + * Result returned by the lint/format step. + */ +export interface LintResult { + prettier: { ran: boolean; filesFormatted: number }; + eslint: { ran: boolean; filesFixed: number }; +} + +/** + * Per-tag summary included in the generation report. + */ +export interface TagDetail { + name: string; + description: string; + endpoints: number; +} + /** * Final generation report persisted as `generation-report.json`. */ @@ -18,7 +35,9 @@ export interface GenerationReport { timestamp: string; tags: number; endpoints: number; + tagDetails: TagDetail[]; outputDirectory: string; + linting: LintResult; structure: { dtos: number; repositories: number; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 79ae5cd..e4c34d3 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,3 +1,5 @@ +import fs from 'fs-extra'; + const colors = { reset: '\x1b[0m', bright: '\x1b[1m', @@ -10,6 +12,21 @@ const colors = { type Color = keyof typeof colors; +let _logFilePath: string | null = null; + +/** Initialises the generation log file, overwriting any previous run. */ +export function initGenerationLog(filePath: string): void { + _logFilePath = filePath; + fs.writeFileSync(filePath, `Generation log — ${new Date().toISOString()}\n${'='.repeat(60)}\n`); +} + +/** Writes a detailed entry to the generation log file (not to console). */ +export function logDetail(category: string, message: string): void { + if (!_logFilePath) return; + const line = `[${new Date().toISOString()}] [${category.toUpperCase().padEnd(8)}] ${message}\n`; + fs.appendFileSync(_logFilePath, line); +} + /** Prints a console message with the given ANSI colour. */ export function log(message: string, color: Color = 'reset'): void { console.log(`${colors[color]}${message}${colors.reset}`); diff --git a/templates/api.use-cases.impl.spec.mustache b/templates/api.use-cases.impl.spec.mustache index 2d80747..14fb9b4 100644 --- a/templates/api.use-cases.impl.spec.mustache +++ b/templates/api.use-cases.impl.spec.mustache @@ -76,7 +76,10 @@ describe('{{classname}}UseCasesImpl', () => { mockRepository.{{nickname}}.and.returnValue(of(undefined)); useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({ - complete: () => done() + complete: () => { + expect(mockRepository.{{nickname}}).toHaveBeenCalledOnceWith({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}); + done(); + } }); {{/returnBaseType}} {{/isListContainer}}