Repository: Redocly/redocly-cli
Pull request #1717 (2024-09-06 13:29:43)

fix: bundling multiple files specified as CLI arguments
## What/Why/How?

Fixed a bug where bundling multiple API description files specified as CLI arguments, along with the `--output` option, stored the result in a single file instead of a folder.


## Reference

Fixes https://github.com/Redocly/redocly-cli/issues/1715

The original bug was introduced in https://github.com/Redocly/redocly-cli/pull/1708

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: e6cd73a9da0def7d19d4423d35a25aad8fafba81

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name                  | Type  |
| --------------------- | ----- |
| @redocly/cli          | Patch |
| @redocly/openapi-core | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Redocly/redocly-cli/new/fix/bundling-multiple-apis?filename=.changeset/quick-needles-scream.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A---%0A%0Afix%3A%20bundling%20multiple%20files%20specified%20as%20CLI%20arguments%0A)


github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 995.6 ± 10.3 | 975.3 | 1008.5 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 995.8 ± 10.6 | 981.3 | 1012.2 | 1.00 ± 0.01 |

<!-- thollander/actions-comment-pull-request "latest-vs-next-comparison" -->
github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 78.34%     |    4901/6256    |
|                                                                           🟡                                                                           | Branches   | 67.05%     |    2027/3023    |
|                                                                           🟡                                                                           | Functions  | 72.62%     |     809/1114    |
|                                                                           🟡                                                                           | Lines      | 78.64%     |    4623/5879    |


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                       | Statements | Branches                                                       | Functions | Lines  |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------- | :--------- | :------------------------------------------------------------- | :-------- | :----- |
|                                                                           🟡                                                                           | <div title="cli/src/utils/miscellaneous.ts">`...` / miscellaneous.ts</div> | 68.29%     | <div title="Base coverage is: 58.18%">59.83% (+1.65% 🔼)</div> | 71.7%     | 68.05% |

</details>

## Test suite run success
794 tests passing in 118 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from aaabc2711305b75655053b48c134e040d28bea81</p>


Patch:
diff --git a/.changeset/late-readers-design.md b/.changeset/late-readers-design.md
new file mode 100644
index 00000000..14722f51
--- /dev/null
+++ b/.changeset/late-readers-design.md
@@ -0,0 +1,5 @@
+---
+"@redocly/cli": patch
+---
+
+Fixed a bug where bundling multiple API description files specified as CLI arguments, along with the `--output` option, stored the result in a single file instead of a folder.
diff --git a/docs/commands/bundle.md b/docs/commands/bundle.md
index 881d3431..062adc7f 100644
--- a/docs/commands/bundle.md
+++ b/docs/commands/bundle.md
@@ -22,23 +22,23 @@ redocly bundle --version
 
 ## Options
 
-| Option                     | Type     | Description                                                                                                                                                                                                                                                                                                                                                                  |
-| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| apis                       | [string] | List of API description root filenames or names assigned in the `apis` section of your Redocly configuration file. Default values are all names defined in the `apis` section within your configuration file.                                                                                                                                                                |
-| --config                   | string   | Specify path to the [configuration file](#use-alternative-configuration-file).                                                                                                                                                                                                                                                                                               |
-| --dereferenced, -d         | boolean  | Generate fully dereferenced bundle.                                                                                                                                                                                                                                                                                                                                          |
-| --ext                      | string   | Specify bundled file extension. Possible values are `json`, `yaml`, or `yml`. Default value is `yaml`.                                                                                                                                                                                                                                                                       |
-| --extends                  | [string] | Can be used in combination with `--lint` to [extend a specific configuration](./lint.md#extend-configuration). Default values are taken from the Redocly configuration file.                                                                                                                                                                                                 |
-| --force, -f                | boolean  | Generate bundle output even when errors occur.                                                                                                                                                                                                                                                                                                                               |
-| --help                     | boolean  | Show help.                                                                                                                                                                                                                                                                                                                                                                   |
-| --keep-url-references, -k  | boolean  | Keep absolute url references.                                                                                                                                                                                                                                                                                                                                                |
-| --lint-config              | string   | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`.                                                                                                                                                                                                                                           |
-| --metafile                 | string   | Path for the bundle metadata file.                                                                                                                                                                                                                                                                                                                                           |
-| --output, -o               | string   | Name or folder for the bundle file. If you don't specify the file extension, `.yaml` is used by default. If the specified folder doesn't exist, it's created automatically. **If the file specified as the bundler's output already exists, it's overwritten.** Use this option when bundling a single API only; otherwise use the `output` option in per-API configuration. |
-| --remove-unused-components | boolean  | Remove unused components from the `bundle` output.                                                                                                                                                                                                                                                                                                                           |
-| --skip-decorator           | [string] | Ignore certain decorators. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator).                                                                                                                                                                                                                                                    |
-| --skip-preprocessor        | [string] | Ignore certain preprocessors. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator).                                                                                                                                                                                                                                                 |
-| --version                  | boolean  | Show version number.                                                                                                                                                                                                                                                                                                                                                         |
+| Option                     | Type     | Description                                                                                                                                                                                                              |
+| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| apis                       | [string] | List of API description root filenames or names assigned in the `apis` section of your Redocly configuration file. Default values are names defined in the `apis` section of your configuration file.                    |
+| --config                   | string   | Specify the path to the [configuration file](#use-alternative-configuration-file).                                                                                                                                       |
+| --dereferenced, -d         | boolean  | Generate fully dereferenced bundle.                                                                                                                                                                                      |
+| --ext                      | string   | Specify the bundled file's extension. The possible values are `json`, `yaml`, or `yml`. Default value is `yaml`.                                                                                                         |
+| --extends                  | [string] | Can be used in combination with `--lint` to [extend a specific configuration](./lint.md#extend-configuration). The default values are taken from the Redocly configuration file.                                         |
+| --force, -f                | boolean  | Generate a bundle output even when errors occur.                                                                                                                                                                         |
+| --help                     | boolean  | Show help.                                                                                                                                                                                                               |
+| --keep-url-references, -k  | boolean  | Preserve absolute URL references.                                                                                                                                                                                        |
+| --lint-config              | string   | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`.                                                                                       |
+| --metafile                 | string   | Path for the bundle metadata file.                                                                                                                                                                                       |
+| --output, -o               | string   | Name or folder for the bundle file. If you don't specify the file extension, `.yaml` is used by default. If the specified folder doesn't exist, it's created automatically. **Overwrites existing bundler output file.** |
+| --remove-unused-components | boolean  | Remove unused components from the `bundle` output.                                                                                                                                                                       |
+| --skip-decorator           | [string] | Ignore certain decorators. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator).                                                                                                |
+| --skip-preprocessor        | [string] | Ignore certain preprocessors. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator).                                                                                             |
+| --version                  | boolean  | Show version number.                                                                                                                                                                                                     |
 
 ## Examples
 
diff --git a/packages/cli/src/commands/bundle.ts b/packages/cli/src/commands/bundle.ts
index ab4e0fdd..f11b7da2 100644
--- a/packages/cli/src/commands/bundle.ts
+++ b/packages/cli/src/commands/bundle.ts
@@ -70,7 +70,13 @@ export async function handleBundle({
       });
 
       const fileTotals = getTotals(problems);
-      const { outputFile, ext } = getOutputFileName(path, output || argv.output, argv.ext);
+      const { outputFile, ext } = getOutputFileName({
+        entrypoint: path,
+        output,
+        argvOutput: argv.output,
+        ext: argv.ext,
+        entries: argv?.apis?.length || 0,
+      });
 
       if (fileTotals.errors === 0 || argv.force) {
         if (!outputFile) {
diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts
index 20b398f7..0acdbbe3 100644
--- a/packages/cli/src/utils/miscellaneous.ts
+++ b/packages/cli/src/utils/miscellaneous.ts
@@ -363,20 +363,40 @@ export function printConfigLintTotals(totals: Totals, command?: string | number)
   }
 }
 
-export function getOutputFileName(entrypoint: string, output?: string, ext?: BundleOutputFormat) {
-  let outputFile = output;
+export function getOutputFileName({
+  entrypoint,
+  output,
+  argvOutput,
+  ext,
+  entries,
+}: {
+  entrypoint: string;
+  output?: string;
+  argvOutput?: string;
+  ext?: BundleOutputFormat;
+  entries: number;
+}) {
+  let outputFile = output || argvOutput;
   if (!outputFile) {
     return { ext: ext || 'yaml' };
   }
 
-  if (outputFile) {
-    ext = ext || (extname(outputFile).substring(1) as BundleOutputFormat);
-  }
-  ext = ext || (extname(entrypoint).substring(1) as BundleOutputFormat);
-  if (!outputExtensions.includes(ext)) {
-    throw new Error(`Invalid file extension: ${ext}.`);
+  if (entries > 1 && argvOutput) {
+    ext = ext || (extname(entrypoint).substring(1) as BundleOutputFormat);
+    if (!outputExtensions.includes(ext)) {
+      throw new Error(`Invalid file extension: ${ext}.`);
+    }
+    outputFile = join(argvOutput, basename(entrypoint, extname(entrypoint))) + '.' + ext;
+  } else {
+    ext =
+      ext ||
+      (extname(outputFile).substring(1) as BundleOutputFormat) ||
+      (extname(entrypoint).substring(1) as BundleOutputFormat);
+    if (!outputExtensions.includes(ext)) {
+      throw new Error(`Invalid file extension: ${ext}.`);
+    }
+    outputFile = join(dirname(outputFile), basename(outputFile, extname(outputFile))) + '.' + ext;
   }
-  outputFile = join(dirname(outputFile), basename(outputFile, extname(outputFile))) + '.' + ext;
   return { outputFile, ext };
 }
 


Test patch:
diff --git a/packages/cli/src/__tests__/commands/bundle.test.ts b/packages/cli/src/__tests__/commands/bundle.test.ts
index c13f60cd..640e1bde 100644
--- a/packages/cli/src/__tests__/commands/bundle.test.ts
+++ b/packages/cli/src/__tests__/commands/bundle.test.ts
@@ -127,88 +127,88 @@ describe('bundle', () => {
     expect(handleError).toHaveBeenCalledTimes(0);
   });
 
-  it('should store bundled API descriptions in the output files described in the apis section of config IF no positional apis provided AND output is specified for both apis', async () => {
-    const apis = {
-      foo: {
-        root: 'foo.yaml',
-        output: 'output/foo.yaml',
-      },
-      bar: {
-        root: 'bar.yaml',
-        output: 'output/bar.json',
-      },
-    };
-    const config = {
-      apis,
-      styleguide: {
-        skipPreprocessors: jest.fn(),
-        skipDecorators: jest.fn(),
-      },
-    } as unknown as Config;
-    // @ts-ignore
-    getFallbackApisOrExit = jest
-      .fn()
-      .mockResolvedValueOnce(
-        Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
-      );
-    (getTotals as jest.Mock).mockReturnValue({
-      errors: 0,
-      warnings: 0,
-      ignored: 0,
-    });
+  describe('per api output', () => {
+    it('should store bundled API descriptions in the output files described in the apis section of config IF no positional apis provided AND output is specified for both apis', async () => {
+      const apis = {
+        foo: {
+          root: 'foo.yaml',
+          output: 'output/foo.yaml',
+        },
+        bar: {
+          root: 'bar.yaml',
+          output: 'output/bar.json',
+        },
+      };
+      const config = {
+        apis,
+        styleguide: {
+          skipPreprocessors: jest.fn(),
+          skipDecorators: jest.fn(),
+        },
+      } as unknown as Config;
+      // @ts-ignore
+      getFallbackApisOrExit = jest
+        .fn()
+        .mockResolvedValueOnce(
+          Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
+        );
+      (getTotals as jest.Mock).mockReturnValue({
+        errors: 0,
+        warnings: 0,
+        ignored: 0,
+      });
+
+      await handleBundle({
+        argv: { apis: [] }, // positional
+        version: 'test',
+        config,
+      });
 
-    await handleBundle({
-      argv: { apis: [] }, // positional
-      version: 'test',
-      config,
+      expect(saveBundle).toBeCalledTimes(2);
+      expect(saveBundle).toHaveBeenNthCalledWith(1, 'output/foo.yaml', expect.any(String));
+      expect(saveBundle).toHaveBeenNthCalledWith(2, 'output/bar.json', expect.any(String));
     });
 
-    expect(saveBundle).toBeCalledTimes(2);
-    expect(saveBundle).toHaveBeenNthCalledWith(1, 'output/foo.yaml', expect.any(String));
-    expect(saveBundle).toHaveBeenNthCalledWith(2, 'output/bar.json', expect.any(String));
-  });
+    it('should store bundled API descriptions in the output files described in the apis section of config AND print the bundled api without the output specified to the terminal IF no positional apis provided AND output is specified for one api', async () => {
+      const apis = {
+        foo: {
+          root: 'foo.yaml',
+          output: 'output/foo.yaml',
+        },
+        bar: {
+          root: 'bar.yaml',
+        },
+      };
+      const config = {
+        apis,
+        styleguide: {
+          skipPreprocessors: jest.fn(),
+          skipDecorators: jest.fn(),
+        },
+      } as unknown as Config;
+      // @ts-ignore
+      getFallbackApisOrExit = jest
+        .fn()
+        .mockResolvedValueOnce(
+          Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
+        );
+      (getTotals as jest.Mock).mockReturnValue({
+        errors: 0,
+        warnings: 0,
+        ignored: 0,
+      });
 
-  it('should store bundled API descriptions in the output files described in the apis section of config AND print the bundled api without the output specified to the terminal IF no positional apis provided AND output is specified for one api', async () => {
-    const apis = {
-      foo: {
-        root: 'foo.yaml',
-        output: 'output/foo.yaml',
-      },
-      bar: {
-        root: 'bar.yaml',
-      },
-    };
-    const config = {
-      apis,
-      styleguide: {
-        skipPreprocessors: jest.fn(),
-        skipDecorators: jest.fn(),
-      },
-    } as unknown as Config;
-    // @ts-ignore
-    getFallbackApisOrExit = jest
-      .fn()
-      .mockResolvedValueOnce(
-        Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
-      );
-    (getTotals as jest.Mock).mockReturnValue({
-      errors: 0,
-      warnings: 0,
-      ignored: 0,
-    });
+      await handleBundle({
+        argv: { apis: [] }, // positional
+        version: 'test',
+        config,
+      });
 
-    await handleBundle({
-      argv: { apis: [] }, // positional
-      version: 'test',
-      config,
+      expect(saveBundle).toBeCalledTimes(1);
+      expect(saveBundle).toHaveBeenCalledWith('output/foo.yaml', expect.any(String));
+      expect(process.stdout.write).toHaveBeenCalledTimes(1);
     });
 
-    expect(saveBundle).toBeCalledTimes(1);
-    expect(saveBundle).toHaveBeenCalledWith('output/foo.yaml', expect.any(String));
-    expect(process.stdout.write).toHaveBeenCalledTimes(1);
-  });
-
-  describe('per api output', () => {
     it('should NOT store bundled API descriptions in the output files described in the apis section of config IF no there is a positional api provided', async () => {
       const apis = {
         foo: {
@@ -240,5 +240,46 @@ describe('bundle', () => {
       expect(saveBundle).toBeCalledTimes(0);
       expect(process.stdout.write).toHaveBeenCalledTimes(1);
     });
+
+    it('should store bundled API descriptions in the directory specified in argv IF multiple positional apis provided AND --output specified', async () => {
+      const apis = {
+        foo: {
+          root: 'foo.yaml',
+          output: 'output/foo.yaml',
+        },
+        bar: {
+          root: 'bar.yaml',
+          output: 'output/bar.json',
+        },
+      };
+      const config = {
+        apis,
+        styleguide: {
+          skipPreprocessors: jest.fn(),
+          skipDecorators: jest.fn(),
+        },
+      } as unknown as Config;
+      // @ts-ignore
+      getFallbackApisOrExit = jest
+        .fn()
+        .mockResolvedValueOnce(
+          Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
+        );
+      (getTotals as jest.Mock).mockReturnValue({
+        errors: 0,
+        warnings: 0,
+        ignored: 0,
+      });
+
+      await handleBundle({
+        argv: { apis: ['foo.yaml', 'bar.yaml'], output: 'dist' }, // cli options
+        version: 'test',
+        config,
+      });
+
+      expect(saveBundle).toBeCalledTimes(2);
+      expect(saveBundle).toHaveBeenNthCalledWith(1, 'dist/foo.yaml', expect.any(String));
+      expect(saveBundle).toHaveBeenNthCalledWith(2, 'dist/bar.yaml', expect.any(String));
+    });
   });
 });