55 Commits

Author SHA1 Message Date
902c0fd5a2 Merge pull request 'chore: update README with new CLI options for configuration and linting & add NOTICE' (#66) from feat/update-docu into main
Reviewed-on: #66
Reviewed-by: blas <me@blassanto.me>
2026-03-27 14:11:28 +00:00
ef804b74c2 Merge branch 'main' into feat/update-docu
All checks were successful
Lint / lint (pull_request) Successful in 13s
2026-03-27 14:11:12 +00:00
98cd7a5edb Merge pull request 'chore: fix releases to npm' (#70) from fix/dist-result into main
Reviewed-on: #70
Reviewed-by: didavila <diego.davilafreitas@gmail.com>
2026-03-27 14:10:45 +00:00
3ede53ae3b chore: bump to version v1.3.14 2026-03-27 13:54:17 +00:00
aa7c6cf338 chore: add dist to package.json
All checks were successful
Publish / publish (push) Successful in 1m47s
Lint / lint (pull_request) Successful in 13s
2026-03-27 14:53:39 +01:00
0075717a1f chore: bump to version v1.3.13 2026-03-27 13:47:08 +00:00
7c5af2f3ab chore: add dist to package.json
All checks were successful
Publish / publish (push) Successful in 1m44s
2026-03-27 14:45:53 +01:00
e667c7bf04 chore: bump to version v1.3.12 2026-03-27 13:45:13 +00:00
2e9aecdffe chore: bump to version v1.3.11 2026-03-27 13:38:06 +00:00
5304ed7047 chore: bump to version v0.1 2026-03-27 13:30:51 +00:00
56bd6f4e9f chore: bump to version v0.1-test 2026-03-27 13:28:49 +00:00
cbef98a077 chore: add dist to package.json
Some checks failed
Publish / publish (push) Has been cancelled
2026-03-27 14:26:09 +01:00
3eb604c95f chore: update copyright year in NOTICE file to 2026
All checks were successful
Lint / lint (pull_request) Successful in 13s
2026-03-27 12:15:36 +01:00
4293857a7d chore: add NOTICE file with third-party licenses and copyright information
All checks were successful
Lint / lint (pull_request) Successful in 13s
2026-03-27 11:35:23 +01:00
07e4559133 chore: update README with new command options for skipping linting and using config files
All checks were successful
Lint / lint (pull_request) Successful in 13s
2026-03-27 11:29:52 +01:00
bdd0e4873e chore: update README with new CLI options for configuration and linting
All checks were successful
Lint / lint (pull_request) Successful in 39s
2026-03-27 11:26:54 +01:00
ddca01e4e9 chore: bump to version v1.3.10 2026-03-27 08:37:33 +00:00
59ff941fda Merge pull request 'chore: update installation instructions and add mock files to README' (#65) from feat/update-docu into main
Reviewed-on: #65
Reviewed-by: blas <me@blassanto.me>
2026-03-27 08:33:50 +00:00
8881e9494c chore: update installation instructions and add mock files to README
All checks were successful
Lint / lint (pull_request) Successful in 13s
Publish / publish (push) Successful in 2m48s
2026-03-27 09:28:51 +01:00
720748b73d chore: bump to version v1.3.9 2026-03-27 08:03:10 +00:00
7063796e28 Merge pull request 'chore: update installation instructions for npm registry in release body' (#64) from chore/change-registry-to-npm into main
All checks were successful
Publish / publish (push) Successful in 2m21s
Reviewed-on: #64
2026-03-27 08:02:13 +00:00
f349b7b2a3 chore: update installation instructions for npm registry in release body
All checks were successful
Lint / lint (pull_request) Successful in 19s
2026-03-27 08:59:28 +01:00
b59084dec6 chore: bump to version v1.3.8 2026-03-26 20:39:57 +00:00
5c83520f01 Merge pull request 'refactor: streamline npm registry authentication setup in publish workflow' (#63) from chore/update-publish into main
Some checks failed
Publish / publish (push) Failing after 32s
Reviewed-on: #63
2026-03-26 20:38:41 +00:00
cc0439e26e refactor: streamline npm registry authentication setup in publish workflow
All checks were successful
Lint / lint (pull_request) Successful in 14s
2026-03-26 21:32:24 +01:00
b5b3632f5b chore: bump to version v1.3.7 2026-03-26 20:24:26 +00:00
d78bc303fa Merge pull request 'chore: update-publish' (#62) from chore/update-publish into main
Some checks failed
Publish / publish (push) Failing after 5m32s
Reviewed-on: #62
Reviewed-by: blas <me@blassanto.me>
2026-03-26 20:23:35 +00:00
df9283556b Merge branch 'main' into chore/update-publish
All checks were successful
Lint / lint (pull_request) Successful in 14s
2026-03-26 21:21:07 +01:00
909f709659 fix: update environment variable for npm registry publishing 2026-03-26 21:20:50 +01:00
5878331abf chore: bump to version v1.3.6 2026-03-26 20:14:26 +00:00
7e8e6d7058 Merge pull request 'fix: update package name and installation instructions in publish workflow' (#61) from chore/update-publish into main
Some checks failed
Publish / publish (push) Failing after 5m30s
Reviewed-on: #61
Reviewed-by: blas <me@blassanto.me>
2026-03-26 20:12:32 +00:00
469697f636 fix: update package name and installation instructions in publish workflow
All checks were successful
Lint / lint (pull_request) Successful in 23s
2026-03-26 20:51:50 +01:00
2257e2141e Merge pull request 'feat/add-config-file' (#60) from feat/add-config-file into main
Reviewed-on: #60
2026-03-26 18:09:18 +00:00
0f64b51b63 Merge remote-tracking branch 'origin/main'
All checks were successful
Lint / lint (pull_request) Successful in 20s
# Conflicts:
#	main.ts
2026-03-26 19:02:16 +01:00
9c385191e2 feat: add configuration file strategy 2026-03-26 18:52:34 +01:00
d2f9eaa933 chore: bump to version v1.3.5 2026-03-26 16:08:21 +00:00
a600a60678 Merge pull request 'chore: update npm registry configuration in publish workflow' (#59) from chore/change-registry-to-npm into main
Some checks failed
Publish / publish (push) Failing after 5m27s
Reviewed-on: #59
2026-03-26 16:07:47 +00:00
a42063c1d9 chore: update npm registry configuration in publish workflow
All checks were successful
Lint / lint (pull_request) Successful in 14s
2026-03-26 17:05:10 +01:00
7f6feda81d chore: bump to version v1.3.4 2026-03-26 15:55:38 +00:00
12b2dd6b51 Merge pull request 'chore: update npm registry configuration in Bun settings' (#58) from chore/change-registry-to-npm into main
Some checks failed
Publish / publish (push) Has been cancelled
Reviewed-on: #58
2026-03-26 15:55:08 +00:00
84486e816a chore: update npm registry configuration in Bun settings
All checks were successful
Lint / lint (pull_request) Successful in 14s
2026-03-26 16:54:33 +01:00
942cf7f092 chore: bump to version v1.3.3 2026-03-26 15:52:35 +00:00
e0fb12a6c6 Merge pull request 'chore: add npm registry configuration to Bun settings' (#57) from chore/change-registry-to-npm into main
Some checks failed
Publish / publish (push) Failing after 27s
Reviewed-on: #57
2026-03-26 15:51:57 +00:00
2402b40059 chore: add npm registry configuration to Bun settings
All checks were successful
Lint / lint (pull_request) Successful in 14s
2026-03-26 16:48:15 +01:00
04962e32f5 chore: bump to version v1.3.2 2026-03-26 15:36:08 +00:00
144629bed6 chore: bump to version v1.3.2 2026-03-26 15:34:44 +00:00
0c58a63d01 chore: bump to version v1.3.2
Some checks failed
Publish / publish (push) Failing after 5m31s
2026-03-26 15:31:47 +00:00
74ac1c26a1 Merge pull request 'chore: update registry configuration from Gitea to npm' (#56) from chore/change-registry-to-npm into main
Reviewed-on: #56
2026-03-26 15:30:50 +00:00
db70f47bb7 chore: update registry configuration from Gitea to npm
All checks were successful
Lint / lint (pull_request) Successful in 18s
2026-03-26 16:29:34 +01:00
91e608415f chore: bump to version v1.3.1
Some checks failed
Publish / publish (push) Has been cancelled
2026-03-26 12:23:55 +00:00
058abf59c4 Merge pull request 'fix: assert repository call with specific parameters in observable completion' (#55) from fix/spec-without-expect into main
Reviewed-on: #55
Reviewed-by: blas <me@blassanto.me>
2026-03-26 12:22:55 +00:00
1d52da3805 test: assert repository call with specific parameters in observable completion
All checks were successful
Lint / lint (pull_request) Successful in 14s
2026-03-26 13:16:41 +01:00
32cb3d476f Merge pull request 'feat: enhance logging and linting functionality with detailed reports' (#54) from feat/move-logs into main
All checks were successful
Publish / publish (push) Successful in 2m5s
Reviewed-on: #54
Reviewed-by: blas <me@blassanto.me>
2026-03-26 12:06:46 +00:00
79ea7dfc7e feat: enhance logging and linting functionality with detailed reports
All checks were successful
Lint / lint (pull_request) Successful in 16s
2026-03-26 13:03:10 +01:00
b54a94c6d3 chore: bump to version v1.3.0 2026-03-26 10:59:19 +00:00
16 changed files with 519 additions and 113 deletions

View File

@@ -62,21 +62,17 @@ 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
cat >> ~/.bunfig.toml << EOF
[install.scopes]
"@blas" = { registry = "https://git.blassanto.me/api/packages/blas/npm/", token = "${NODE_AUTH_TOKEN}" }
EOF
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
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 }}
BUN_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create Gitea release and upload binaries
run: |
@@ -89,7 +85,7 @@ jobs:
-d "{
\"tag_name\": \"${GITHUB_REF_NAME}\",
\"name\": \"v${VERSION}\",
\"body\": \"## Installation\n\nDownload the binary for your platform or install via the npm registry:\n\n\`\`\`bash\nbun add -g @blas/openapi-clean-arch-generator --registry https://git.blassanto.me/api/packages/blas/npm/\n\`\`\`\",
\"body\": \"## Installation\n\nDownload the binary for your platform or install via the npm registry:\n\n\`\`\`bash\nbun add -g @0kmpo/openapi-clean-arch-generator\n\`\`\`\",
\"draft\": false,
\"prerelease\": false
}" | bun -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).id))")

50
NOTICE Normal file
View File

@@ -0,0 +1,50 @@
OpenAPI Clean Architecture Generator
Copyright (c) 2026 Blas Santomé Ocampo
This product includes software developed by third parties.
All third-party packages listed below are distributed under the MIT License.
---
chalk v4.1.2
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
License: MIT
https://github.com/chalk/chalk
---
commander v11.1.0
Copyright (c) TJ Holowaychuk <tj@vision-media.ca>
License: MIT
https://github.com/tj/commander.js
---
fs-extra v11.3.4
Copyright (c) JP Richardson <jprichardson@gmail.com>
License: MIT
https://github.com/jprichardson/node-fs-extra
---
js-yaml v4.1.1
Copyright (c) Vladimir Zapparov <dervus.grim@gmail.com>
Copyright (c) Aleksey V Zapparov <ixti@member.fsf.org>
Copyright (c) Vitaly Puzrin <vitaly@rcdesign.ru>
Copyright (c) Martin Grenfell <martin.grenfell@gmail.com>
License: MIT
https://github.com/nodeca/js-yaml
---
mustache v4.2.0
Copyright (c) mustache.js Authors (http://github.com/janl/mustache.js)
License: MIT
https://github.com/janl/mustache.js
---
prompts v2.4.2
Copyright (c) Terkel Gjervig
License: MIT
https://github.com/terkelg/prompts

View File

@@ -15,29 +15,29 @@ Download the binary for your platform from the releases page and run it directly
```bash
# macOS (Apple Silicon)
curl -L <release-url>/generate-clean-arch-macos-arm64 -o generate-clean-arch
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-macos-arm64 -o generate-clean-arch
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
# macOS (Intel)
curl -L <release-url>/generate-clean-arch-macos-x64 -o generate-clean-arch
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-macos-x64 -o generate-clean-arch
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
# Linux x64
curl -L <release-url>/generate-clean-arch-linux-x64 -o generate-clean-arch
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-linux-x64 -o generate-clean-arch
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
# Windows (PowerShell)
curl -L <release-url>/generate-clean-arch-windows-x64.exe -o generate-clean-arch.exe
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-windows-x64.exe -o generate-clean-arch.exe
.\generate-clean-arch.exe -i swagger.yaml
```
### Option 1: Install as a global CLI from the registry
### Option 1: Install as a global CLI from npm
```bash
bun add -g @blas/openapi-clean-arch-generator --registry https://git.blassanto.me/api/packages/blas/npm/
bun add -g @0kmpo/openapi-clean-arch-generator
```
Or configure the registry in your `.npmrc` / `bunfig.toml` and then run:
Then run:
```bash
generate-clean-arch -i swagger.yaml
@@ -76,7 +76,10 @@ Options:
-o, --output <dir> Output directory [default: ./src/app]
-t, --templates <dir> Custom templates directory [default: ./templates]
-s, --select-endpoints Interactively select tags and endpoints to generate
-c, --config <file> Use a JSON configuration file (skips interactive prompts)
--init-config [file] Generate a JSON configuration file instead of generating code
--skip-install Skip dependency installation
--skip-lint Skip post-generation linting and formatting
--dry-run Simulate without writing files
-h, --help Show help
```
@@ -96,6 +99,15 @@ generate-clean-arch -i api.yaml -t ./my-templates
# Dry run (no files written)
generate-clean-arch -i swagger.yaml --dry-run
# Skip linting after generation
generate-clean-arch -i swagger.yaml --skip-lint
# Generate a config file to reuse later
generate-clean-arch --init-config generation-config.json
# Run using a config file (no interactive prompts)
generate-clean-arch -c generation-config.json
# Full example with all options
generate-clean-arch -i ./docs/api.yaml -o ./frontend/src/app -t ./custom-templates
```
@@ -109,48 +121,54 @@ src/app/
├── data/ # Data layer
│ ├── dtos/ # Data Transfer Objects
│ │ ├── node/
│ │ │ ── node.dto.ts
│ │ │ ── node.dto.ts
│ │ │ └── node.dto.mock.ts
│ │ ├── order-type/
│ │ │ ── order-type.dto.ts
│ │ │ ── order-type.dto.ts
│ │ │ └── order-type.dto.mock.ts
│ │ └── supply-mode/
│ │ ── supply-mode.dto.ts
│ │ ── supply-mode.dto.ts
│ │ └── supply-mode.dto.mock.ts
│ ├── repositories/ # Repository implementations
│ │ ├── node.repository.impl.ts
│ │ ├── node.repository.impl.mock.ts
│ │ ├── node.repository.impl.spec.ts
│ │ ├── order-type.repository.impl.ts
│ │ ── supply-mode.repository.impl.ts
│ │ ── order-type.repository.impl.mock.ts
│ │ ├── order-type.repository.impl.spec.ts
│ │ └── ...
│ └── mappers/ # DTO → Entity transformers
│ ├── node.mapper.ts
│ ├── node.mapper.spec.ts
│ ├── order-type.mapper.ts
── supply-mode.mapper.ts
── order-type.mapper.spec.ts
│ └── ...
├── domain/ # Domain layer
│ ├── repositories/ # Repository contracts
│ │ ├── node.repository.contract.ts
│ │ ── order-type.repository.contract.ts
│ │ └── supply-mode.repository.contract.ts
│ │ ── ...
│ └── use-cases/ # Use cases
│ ├── node/
│ │ ├── node.use-cases.contract.ts
│ │ ── node.use-cases.impl.ts
├── order-type/
│ │ ── order-type.use-cases.contract.ts
│ └── order-type.use-cases.impl.ts
│ └── supply-mode/
│ ├── supply-mode.use-cases.contract.ts
│ └── supply-mode.use-cases.impl.ts
│ │ ── node.use-cases.impl.ts
│ ├── node.use-cases.mock.ts
│ │ ── node.use-cases.impl.spec.ts
└── ...
├── di/ # Dependency injection
│ ├── repositories/ # Repository providers
│ │ ├── node.repository.provider.ts
│ │ ├── order-type.repository.provider.ts
│ │ └── supply-mode.repository.provider.ts
│ │ ├── node.repository.provider.mock.ts
│ │ └── ...
│ └── use-cases/ # Use case providers
│ ├── node.use-cases.provider.ts
│ ├── order-type.use-cases.provider.ts
│ └── supply-mode.use-cases.provider.ts
│ ├── node.use-cases.provider.mock.ts
│ └── ...
└── entities/ # Domain entities
└── models/
├── node.model.ts
├── order-type.model.ts
── supply-mode.model.ts
├── node.model.mock.ts
── node.model.spec.ts
└── ...
```
## 🔧 Template Customization
@@ -160,14 +178,24 @@ Templates live in `templates/` and use [Mustache](https://mustache.github.io/) s
| Template | Generates |
|---|---|
| `model.mustache` | DTOs |
| `model.mock.mustache` | DTO mocks |
| `dto.mock.mustache` | DTO mocks (alternative) |
| `model-entity.mustache` | Domain entity models |
| `model-entity.spec.mustache` | Entity model specs |
| `mapper.mustache` | DTO → Entity mappers |
| `mapper.spec.mustache` | Mapper specs |
| `api.repository.contract.mustache` | Repository contracts |
| `api.repository.impl.mustache` | Repository implementations |
| `api.repository.impl.mock.mustache` | Repository mocks |
| `api.repository.impl.spec.mustache` | Repository specs |
| `api.use-cases.contract.mustache` | Use case contracts |
| `api.use-cases.impl.mustache` | Use case implementations |
| `api.use-cases.mock.mustache` | Use case mocks |
| `api.use-cases.impl.spec.mustache` | Use case specs |
| `repository.provider.mustache` | Repository DI providers |
| `repository.provider.mock.mustache` | Repository provider mocks |
| `use-cases.provider.mustache` | Use case DI providers |
| `use-cases.provider.mock.mustache` | Use case provider mocks |
### Available Mustache variables
@@ -194,12 +222,23 @@ After each run a `generation-report.json` file is created:
"timestamp": "2025-01-15T10:30:00.000Z",
"tags": 3,
"endpoints": 8,
"tagDetails": [
{ "name": "User", "description": "User operations", "endpoints": 3 },
{ "name": "Product", "description": "Product operations", "endpoints": 2 }
],
"outputDirectory": "./src/app",
"linting": {
"prettier": { "ran": true, "filesFormatted": 42 },
"eslint": { "ran": true, "filesFixed": 42 }
},
"structure": {
"dtos": 15,
"repositories": 9,
"mappers": 3,
"useCases": 6
"mappers": 5,
"useCases": 6,
"providers": 12,
"mocks": 18,
"specs": 14
}
}
```

24
generation-config.json Normal file
View File

@@ -0,0 +1,24 @@
{
"input": "example-swagger.yaml",
"output": "./src/app",
"skipLint": false,
"skipInstall": false,
"tags": {
"User": {
"baseUrl": "apiUrl",
"endpoints": [
"getUsers",
"createUser",
"getUserById",
"deleteUser"
]
},
"Product": {
"baseUrl": "apiUrl",
"endpoints": [
"getProducts"
]
}
},
"templates": "/Users/bsantome/Downloads/openapi-clean-arch-generator/templates"
}

106
main.ts
View File

@@ -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';
@@ -18,7 +26,14 @@ import { generateReport } from './src/generators/report.generator';
import { lintGeneratedFiles } from './src/generators/lint.generator';
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
import type { SelectionFilter } from './src/types';
import {
loadConfig,
generateDefaultConfig,
writeConfig,
deriveSelectionFilter,
deriveTagApiKeyMap
} from './src/utils/config';
import type { SelectionFilter, LintResult } from './src/types';
import type { CliOptions } from './src/types';
import packageJson from './package.json';
@@ -40,6 +55,8 @@ program
.option('--dry-run', 'Simulate without generating files')
.option('--skip-lint', 'Skip post-generation linting and formatting')
.option('-s, --select-endpoints', 'Interactively select which tags and endpoints to generate')
.option('-c, --config <file>', 'Use a JSON configuration file (skips interactive prompts)')
.option('--init-config [file]', 'Generate a JSON configuration file instead of generating code')
.parse(process.argv);
const options = program.opts<CliOptions>();
@@ -52,15 +69,32 @@ async function main(): Promise<void> {
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;
if (generationConfig) {
if (generationConfig.input) options.input = generationConfig.input;
if (generationConfig.output) options.output = generationConfig.output;
if (generationConfig.templates) options.templates = generationConfig.templates;
if (generationConfig.skipInstall !== undefined)
options.skipInstall = generationConfig.skipInstall;
if (generationConfig.skipLint !== undefined) options.skipLint = generationConfig.skipLint;
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');
}
@@ -80,18 +114,51 @@ async function main(): Promise<void> {
}
const analysis = analyzeSwagger(options.input);
const tagSummaries = extractTagsWithOperations(analysis);
// ── INIT CONFIG MODE: generate config file and exit ───────────────────────
if (options.initConfig !== undefined) {
const envFile = findEnvironmentFile(process.cwd());
let apiKeys: ReturnType<typeof parseApiKeys> = [];
if (envFile) {
const envContent = fs.readFileSync(envFile, 'utf8');
apiKeys = parseApiKeys(envContent);
}
const defaultConfig = generateDefaultConfig(analysis, tagSummaries, options, apiKeys);
const outputFile =
typeof options.initConfig === 'string' ? options.initConfig : 'generation-config.json';
writeConfig(defaultConfig, outputFile);
logSuccess(`Configuration file generated: ${outputFile}`);
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;
}
createDirectoryStructure(options.output);
// ── SELECTION: tags and endpoints ─────────────────────────────────────────
const tagSummaries = extractTagsWithOperations(analysis);
let selectionFilter: SelectionFilter = {};
let tagApiKeyMap: Record<string, string>;
if (generationConfig) {
// Config-driven: derive everything from the JSON file
selectionFilter = deriveSelectionFilter(generationConfig);
tagApiKeyMap = deriveTagApiKeyMap(generationConfig);
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) {
selectionFilter = await askSelectionFilter(tagSummaries);
}
@@ -100,7 +167,7 @@ async function main(): Promise<void> {
? Object.keys(selectionFilter)
: tagSummaries.map((t) => t.tag);
// ── ENVIRONMENT API KEY SELECTION ──────────────────────────────────────────
// ── ENVIRONMENT API KEY SELECTION ────────────────────────────────────────
const envFile = findEnvironmentFile(process.cwd());
let apiKeys: ReturnType<typeof parseApiKeys> = [];
@@ -110,16 +177,19 @@ async function main(): Promise<void> {
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 {
logWarning('No environment.ts found. The key will be requested manually per repository.');
}
const tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys);
tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys);
Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
logDetail('config', `API key for "${tag}": environment.${key}.url`);
});
}
// ──────────────────────────────────────────────────────────────────────────
const tempDir = generateCode(options.input, options.templates);
@@ -134,11 +204,13 @@ async function main(): Promise<void> {
);
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');

View File

@@ -1,6 +1,6 @@
{
"name": "@blas/openapi-clean-arch-generator",
"version": "1.2.0",
"name": "@0kmpo/openapi-clean-arch-generator",
"version": "1.3.14",
"description": "Angular Clean Architecture generator from OpenAPI/Swagger",
"main": "dist/main.js",
"bin": {
@@ -39,9 +39,14 @@
}
],
"license": "MIT",
"publishConfig": {
"registry": "https://git.blassanto.me/api/packages/blas/npm/"
},
"files": [
"dist/main.js",
"dist/package.json",
"dist/src/",
"dist/templates/",
"README.md",
"LICENSE"
],
"dependencies": {
"chalk": "^4.1.2",
"commander": "^11.1.0",

View File

@@ -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)}`
);
}
}

View File

@@ -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))`);
}
});

View File

@@ -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;
}

View File

@@ -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<string, unknown>).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,

View File

@@ -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<string, unknown>) || {};
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;

View File

@@ -10,4 +10,27 @@ export interface CliOptions {
dryRun?: boolean;
selectEndpoints?: boolean;
skipLint?: boolean;
config?: string;
initConfig?: string | boolean;
}
/**
* Per-tag configuration inside the generation config file.
*/
export interface TagConfig {
baseUrl: string;
endpoints: string[];
}
/**
* JSON configuration file schema.
* Allows full non-interactive control of the generation process.
*/
export interface GenerationConfig {
input: string;
output: string;
templates?: string;
skipInstall?: boolean;
skipLint?: boolean;
tags: Record<string, TagConfig>;
}

View File

@@ -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;

118
src/utils/config.ts Normal file
View File

@@ -0,0 +1,118 @@
import fs from 'fs-extra';
import { logInfo, logError } from './logger';
import type { GenerationConfig, TagConfig } from '../types';
import type { SwaggerAnalysis } from '../types';
import type { TagSummary } from '../types';
import type { ApiKeyInfo } from './environment-finder';
/**
* Loads and validates a GenerationConfig from a JSON file.
*/
export function loadConfig(filePath: string): GenerationConfig {
if (!fs.existsSync(filePath)) {
logError(`Configuration file not found: ${filePath}`);
process.exit(1);
}
const raw = fs.readFileSync(filePath, 'utf8');
let config: GenerationConfig;
try {
config = JSON.parse(raw) as GenerationConfig;
} catch {
logError(`Invalid JSON in configuration file: ${filePath}`);
process.exit(1);
}
if (!config.tags || typeof config.tags !== 'object') {
logError('Configuration file must contain a "tags" object');
process.exit(1);
}
for (const [tag, tagConfig] of Object.entries(config.tags)) {
if (!tagConfig.baseUrl || typeof tagConfig.baseUrl !== 'string') {
logError(`Tag "${tag}" must have a "baseUrl" string`);
process.exit(1);
}
if (!Array.isArray(tagConfig.endpoints) || tagConfig.endpoints.length === 0) {
logError(`Tag "${tag}" must have a non-empty "endpoints" array`);
process.exit(1);
}
}
return config;
}
/**
* Builds a default GenerationConfig from a swagger analysis, including all tags
* and all endpoints. Useful for --init-config to scaffold a config template.
*/
export function generateDefaultConfig(
analysis: SwaggerAnalysis,
tagSummaries: TagSummary[],
cliOptions: {
input: string;
output: string;
templates?: string;
skipLint?: boolean;
skipInstall?: boolean;
},
apiKeys: ApiKeyInfo[]
): GenerationConfig {
const tags: Record<string, TagConfig> = {};
for (const summary of tagSummaries) {
const matchingKey = apiKeys.find((k) =>
k.key.toLowerCase().includes(summary.tag.toLowerCase())
);
tags[summary.tag] = {
baseUrl: matchingKey?.key || 'apiUrl',
endpoints: summary.operations.map((op) => op.nickname)
};
}
const config: GenerationConfig = {
input: cliOptions.input,
output: cliOptions.output,
skipLint: cliOptions.skipLint ?? false,
skipInstall: cliOptions.skipInstall ?? false,
tags
};
if (cliOptions.templates) {
config.templates = cliOptions.templates;
}
return config;
}
/**
* Writes a GenerationConfig to a JSON file.
*/
export function writeConfig(config: GenerationConfig, filePath: string): void {
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8');
logInfo(`Configuration file written to: ${filePath}`);
}
/**
* Derives the selectionFilter (tag → endpoint nicknames) from a GenerationConfig.
*/
export function deriveSelectionFilter(config: GenerationConfig): Record<string, string[]> {
const filter: Record<string, string[]> = {};
for (const [tag, tagConfig] of Object.entries(config.tags)) {
filter[tag] = [...tagConfig.endpoints];
}
return filter;
}
/**
* Derives the tagApiKeyMap (tag → baseUrl key) from a GenerationConfig.
*/
export function deriveTagApiKeyMap(config: GenerationConfig): Record<string, string> {
const map: Record<string, string> = {};
for (const [tag, tagConfig] of Object.entries(config.tags)) {
map[tag] = tagConfig.baseUrl;
}
return map;
}

View File

@@ -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}`);

View File

@@ -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}}