diff --git a/generation-config.json b/generation-config.json new file mode 100644 index 0000000..50c57b6 --- /dev/null +++ b/generation-config.json @@ -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" +} diff --git a/main.ts b/main.ts index 958be6b..aa59d93 100755 --- a/main.ts +++ b/main.ts @@ -26,6 +26,13 @@ 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 { + 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'; @@ -48,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 ', '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(); @@ -62,6 +71,21 @@ async function main(): Promise { 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}`); @@ -90,6 +114,29 @@ async function main(): Promise { } 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 = []; + 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) { logWarning('Finishing in DRY RUN mode'); @@ -99,38 +146,50 @@ async function main(): Promise { createDirectoryStructure(options.output); // ── SELECTION: tags and endpoints ───────────────────────────────────────── - const tagSummaries = extractTagsWithOperations(analysis); let selectionFilter: SelectionFilter = {}; + let tagApiKeyMap: Record; - 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 = []; - - 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.'); - } + 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 { - 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 = []; + + 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); diff --git a/src/types/cli.types.ts b/src/types/cli.types.ts index bc2605a..8760754 100644 --- a/src/types/cli.types.ts +++ b/src/types/cli.types.ts @@ -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; } diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..551a82d --- /dev/null +++ b/src/utils/config.ts @@ -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 = {}; + + 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 { + const filter: Record = {}; + 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 { + const map: Record = {}; + for (const [tag, tagConfig] of Object.entries(config.tags)) { + map[tag] = tagConfig.baseUrl; + } + return map; +}