Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c83520f01 | |||
| cc0439e26e | |||
| b5b3632f5b | |||
| d78bc303fa | |||
| df9283556b | |||
| 909f709659 | |||
| 5878331abf | |||
| 7e8e6d7058 | |||
| 469697f636 | |||
| 2257e2141e | |||
| 0f64b51b63 | |||
| 9c385191e2 | |||
| d2f9eaa933 |
@@ -62,19 +62,14 @@ jobs:
|
|||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
run: bun run binaries
|
run: bun run binaries
|
||||||
|
|
||||||
- name: Configure npm registry auth
|
- name: Setup Node
|
||||||
run: |
|
uses: actions/setup-node@v4
|
||||||
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
with:
|
||||||
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
|
node-version: '20'
|
||||||
cat >> ~/.bunfig.toml << EOF
|
registry-url: 'https://registry.npmjs.org'
|
||||||
[install.scopes]
|
|
||||||
"@0kmpo" = { registry = "https://registry.npmjs.org", token = "${NODE_AUTH_TOKEN}" }
|
|
||||||
EOF
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Publish to npm registry
|
- name: Publish to npm registry
|
||||||
run: bun publish --access public
|
run: npm publish --access public
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
@@ -89,7 +84,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
24
generation-config.json
Normal 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
113
main.ts
@@ -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);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@blas/openapi-clean-arch-generator",
|
"name": "@0kmpo/openapi-clean-arch-generator",
|
||||||
"version": "1.3.4",
|
"version": "1.3.7",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
118
src/utils/config.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user