26 Commits

Author SHA1 Message Date
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
7 changed files with 265 additions and 41 deletions

View File

@@ -62,21 +62,21 @@ jobs:
- name: Build binaries - name: Build binaries
run: bun run binaries run: bun run binaries
- name: Configure Gitea registry auth - name: Configure npm registry auth
run: | run: |
echo "@blas:registry=https://git.blassanto.me/api/packages/blas/npm/" >> ~/.npmrc echo "registry=https://registry.npmjs.org" >> ~/.npmrc
echo "//git.blassanto.me/api/packages/blas/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
cat >> ~/.bunfig.toml << EOF cat >> ~/.bunfig.toml << EOF
[install.scopes] [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 EOF
env: 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 run: bun publish --access public
env: env:
NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} BUN_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create Gitea release and upload binaries - name: Create Gitea release and upload binaries
run: | run: |
@@ -89,7 +89,7 @@ jobs:
-d "{ -d "{
\"tag_name\": \"${GITHUB_REF_NAME}\", \"tag_name\": \"${GITHUB_REF_NAME}\",
\"name\": \"v${VERSION}\", \"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, \"draft\": false,
\"prerelease\": false \"prerelease\": false
}" | bun -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).id))") }" | bun -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).id))")

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

113
main.ts
View File

@@ -26,6 +26,13 @@ import { generateReport } from './src/generators/report.generator';
import { lintGeneratedFiles } from './src/generators/lint.generator'; import { lintGeneratedFiles } from './src/generators/lint.generator';
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder'; import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt'; import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
import {
loadConfig,
generateDefaultConfig,
writeConfig,
deriveSelectionFilter,
deriveTagApiKeyMap
} from './src/utils/config';
import type { SelectionFilter, LintResult } from './src/types'; import type { SelectionFilter, LintResult } from './src/types';
import type { CliOptions } from './src/types'; import type { CliOptions } from './src/types';
import packageJson from './package.json'; import packageJson from './package.json';
@@ -48,6 +55,8 @@ program
.option('--dry-run', 'Simulate without generating files') .option('--dry-run', 'Simulate without generating files')
.option('--skip-lint', 'Skip post-generation linting and formatting') .option('--skip-lint', 'Skip post-generation linting and formatting')
.option('-s, --select-endpoints', 'Interactively select which tags and endpoints to generate') .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); .parse(process.argv);
const options = program.opts<CliOptions>(); const options = program.opts<CliOptions>();
@@ -62,6 +71,21 @@ async function main(): Promise<void> {
const logPath = path.join(process.cwd(), 'generation.log'); const logPath = path.join(process.cwd(), 'generation.log');
initGenerationLog(logPath); 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', `Input: ${options.input}`);
logDetail('config', `Output: ${options.output}`); logDetail('config', `Output: ${options.output}`);
logDetail('config', `Templates: ${options.templates}`); logDetail('config', `Templates: ${options.templates}`);
@@ -90,6 +114,29 @@ async function main(): Promise<void> {
} }
const analysis = analyzeSwagger(options.input); 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) { if (options.dryRun) {
logWarning('Finishing in DRY RUN mode'); logWarning('Finishing in DRY RUN mode');
@@ -99,38 +146,50 @@ async function main(): Promise<void> {
createDirectoryStructure(options.output); createDirectoryStructure(options.output);
// ── SELECTION: tags and endpoints ───────────────────────────────────────── // ── SELECTION: tags and endpoints ─────────────────────────────────────────
const tagSummaries = extractTagsWithOperations(analysis);
let selectionFilter: SelectionFilter = {}; let selectionFilter: SelectionFilter = {};
let tagApiKeyMap: Record<string, string>;
if (options.selectEndpoints) { if (generationConfig) {
selectionFilter = await askSelectionFilter(tagSummaries); // Config-driven: derive everything from the JSON file
} selectionFilter = deriveSelectionFilter(generationConfig);
tagApiKeyMap = deriveTagApiKeyMap(generationConfig);
const selectedTags = options.selectEndpoints logDetail('config', `Tags from config: ${Object.keys(generationConfig.tags).join(', ')}`);
? Object.keys(selectionFilter) Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
: tagSummaries.map((t) => t.tag); logDetail('config', `API key for "${tag}": environment.${key}.url`);
});
// ── ENVIRONMENT API KEY SELECTION ──────────────────────────────────────────
const envFile = findEnvironmentFile(process.cwd());
let apiKeys: ReturnType<typeof parseApiKeys> = [];
if (envFile) {
const envContent = fs.readFileSync(envFile, 'utf8');
apiKeys = parseApiKeys(envContent);
logSuccess(
`environment.ts found: ${colors.cyan}${path.relative(process.cwd(), envFile)}${colors.reset}`
);
if (apiKeys.length === 0) {
logWarning('No keys containing "api" found in environment.ts. Will be requested manually.');
}
} else { } else {
logWarning('No environment.ts found. The key will be requested manually per repository.'); // Interactive mode (original behaviour)
if (options.selectEndpoints) {
selectionFilter = await askSelectionFilter(tagSummaries);
}
const selectedTags = options.selectEndpoints
? Object.keys(selectionFilter)
: tagSummaries.map((t) => t.tag);
// ── ENVIRONMENT API KEY SELECTION ────────────────────────────────────────
const envFile = findEnvironmentFile(process.cwd());
let apiKeys: ReturnType<typeof parseApiKeys> = [];
if (envFile) {
const envContent = fs.readFileSync(envFile, 'utf8');
apiKeys = parseApiKeys(envContent);
logSuccess(
`environment.ts found: ${colors.cyan}${path.relative(process.cwd(), envFile)}${colors.reset}`
);
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.');
}
tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys);
Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
logDetail('config', `API key for "${tag}": environment.${key}.url`);
});
} }
const 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); const tempDir = generateCode(options.input, options.templates);

View File

@@ -1,6 +1,6 @@
{ {
"name": "@blas/openapi-clean-arch-generator", "name": "@0kmpo/openapi-clean-arch-generator",
"version": "1.3.0", "version": "1.3.6",
"description": "Angular Clean Architecture generator from OpenAPI/Swagger", "description": "Angular Clean Architecture generator from OpenAPI/Swagger",
"main": "dist/main.js", "main": "dist/main.js",
"bin": { "bin": {
@@ -39,9 +39,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"publishConfig": {
"registry": "https://git.blassanto.me/api/packages/blas/npm/"
},
"dependencies": { "dependencies": {
"chalk": "^4.1.2", "chalk": "^4.1.2",
"commander": "^11.1.0", "commander": "^11.1.0",

View File

@@ -10,4 +10,27 @@ export interface CliOptions {
dryRun?: boolean; dryRun?: boolean;
selectEndpoints?: boolean; selectEndpoints?: boolean;
skipLint?: 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>;
} }

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

@@ -76,7 +76,10 @@ describe('{{classname}}UseCasesImpl', () => {
mockRepository.{{nickname}}.and.returnValue(of(undefined)); mockRepository.{{nickname}}.and.returnValue(of(undefined));
useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({ useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({
complete: () => done() complete: () => {
expect(mockRepository.{{nickname}}).toHaveBeenCalledOnceWith({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}});
done();
}
}); });
{{/returnBaseType}} {{/returnBaseType}}
{{/isListContainer}} {{/isListContainer}}