Repository: Redocly/redocly-cli
Pull request #1749 (2024-10-03 12:00:54)

fix: resolve plugins from npm packages correctly
## What/Why/How?

When the CLI is installed globally and tries to resolve a plugin from NPM package, it can't find it because by default the `require.resolve` tries to look for the package from the CLI installation location. This would cause errors like below even though the plugin package is installed in the project's `node_modules` directory:

```
Something went wrong when processing :

  - Failed to load plugin "@redocly/portal-plugin-async-api/plugin.js": Cannot find module '@redocly/portal-plugin-async-api/plugin.js'
```

Added the config directory as an additional path to look for NPM packages when resolving plugins.

## Reference

Reported in internal conversation

## Testing

## Screenshots (optional)

## Check yourself

- [ ] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [ ] 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: c7dfe7b7ee7a456057ed2b95004791bae8c985be

**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/openapi-core | Patch |
| @redocly/cli          | 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/npm-plugins-resolving?filename=.changeset/silly-geckos-agree.md&value=---%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afix%3A%20resolve%20plugins%20from%20npm%20packages%20correctly%0A)


github-actions[bot]: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 972.7 ± 11.4 | 947.3 | 991.6 | 1.00 ± 0.02 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 968.5 ± 17.7 | 942.6 | 994.8 | 1.00 |

<!-- 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.62%     |    4986/6342    |
|                                                                           🟡                                                                           | Branches   | 67.3%      |    2058/3058    |
|                                                                           🟡                                                                           | Functions  | 72.94%     |     822/1127    |
|                                                                           🟡                                                                           | Lines      | 78.91%     |    4704/5961    |


## Test suite run success
809 tests passing in 121 suites.

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


Patch:
diff --git a/.changeset/strange-starfishes-press.md b/.changeset/strange-starfishes-press.md
new file mode 100644
index 00000000..57db08db
--- /dev/null
+++ b/.changeset/strange-starfishes-press.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": patch
+"@redocly/cli": patch
+---
+
+Fixed an issue where the plugins in external NPM packages could not be resolved if the CLI package was installed globally.
diff --git a/.github/workflows/smoke-plugins.yaml b/.github/workflows/smoke-plugins.yaml
new file mode 100644
index 00000000..2c44553b
--- /dev/null
+++ b/.github/workflows/smoke-plugins.yaml
@@ -0,0 +1,92 @@
+name: Plugins smoke tests
+
+on:
+  pull_request:
+    types: [opened, synchronize, reopened]
+
+env:
+  CI: true
+  REDOCLY_TELEMETRY: off
+
+jobs:
+  prepare-smoke-plugins:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 18
+          cache: 'npm'
+      - name: Install dependencies
+        run: npm ci
+      - name: Prepare Smoke
+        run: bash ./scripts/prepare-smoke-plugins.sh
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke-plugins/
+          key: cache-plugins-${{ github.run_id }}-${{ github.run_attempt }}
+
+  run-smoke-plugins--npm--node-22:
+    needs: prepare-smoke-plugins
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke-plugins/
+          key: cache-plugins-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 22
+      - run: bash ./__tests__/smoke-plugins/run-smoke.sh
+
+  run-smoke-plugins--npm--node-20:
+    needs: prepare-smoke-plugins
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke-plugins/
+          key: cache-plugins-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 20
+      - run: bash ./__tests__/smoke-plugins/run-smoke.sh
+
+  run-smoke-plugins--npm--node-18:
+    needs: prepare-smoke-plugins
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke-plugins/
+          key: cache-plugins-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 18
+      - run: bash ./__tests__/smoke-plugins/run-smoke.sh
+
+  run-smoke-plugins--npm--node-16:
+    needs: prepare-smoke-plugins
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke-plugins/
+          key: cache-plugins-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 16
+      - run: bash ./__tests__/smoke-plugins/run-smoke.sh
+
+  run-smoke-plugins--npm--node-14:
+    needs: prepare-smoke-plugins
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke-plugins/
+          key: cache-plugins-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 14
+      - run: bash ./__tests__/smoke-plugins/run-smoke.sh
diff --git a/.github/workflows/smoke.yaml b/.github/workflows/smoke.yaml
index 4bd36466..b70f1032 100644
--- a/.github/workflows/smoke.yaml
+++ b/.github/workflows/smoke.yaml
@@ -26,6 +26,32 @@ jobs:
           path: __tests__/smoke/
           key: cache-${{ github.run_id }}-${{ github.run_attempt }}
 
+  run-smoke--npm--node-22:
+    needs: prepare-smoke
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke/
+          key: cache-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 22
+      - run: bash ./__tests__/smoke/run-smoke.sh "npm i redocly-cli.tgz" "npm run"
+
+  run-smoke--npm--node-22--redoc:
+    needs: prepare-smoke
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke/
+          key: cache-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 22
+      - run: bash ./__tests__/smoke/run-smoke.sh "npm i redoc redocly-cli.tgz" "npm run"
+
   run-smoke--npm--node-20:
     needs: prepare-smoke
     runs-on: ubuntu-latest
@@ -130,6 +156,32 @@ jobs:
           node-version: 14
       - run: bash ./__tests__/smoke/run-smoke.sh "npm i redoc redocly-cli.tgz" "npm run"
 
+  run-smoke--yarn--node-22:
+    needs: prepare-smoke
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke/
+          key: cache-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 22
+      - run: bash ./__tests__/smoke/run-smoke.sh "yarn add ./redocly-cli.tgz" "yarn"
+
+  run-smoke--yarn--node-22--redoc:
+    needs: prepare-smoke
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/cache@v3
+        with:
+          path: __tests__/smoke/
+          key: cache-${{ github.run_id }}-${{ github.run_attempt }}
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 22
+      - run: bash ./__tests__/smoke/run-smoke.sh "yarn add redoc ./redocly-cli.tgz" "yarn"
+
   run-smoke--yarn--node-20:
     needs: prepare-smoke
     runs-on: ubuntu-latest
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index 92628cef..b25e7095 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -139,7 +139,7 @@ export async function resolvePlugins(
         const absolutePluginPath = existsSync(maybeAbsolutePluginPath)
           ? maybeAbsolutePluginPath
           : // For plugins imported from packages specifically
-            require.resolve(plugin);
+            require.resolve(plugin, { paths: [configDir] });
 
         if (!pluginsCache.has(absolutePluginPath)) {
           let requiredPlugin: ImportedPlugin | undefined;
diff --git a/scripts/prepare-smoke-plugins.sh b/scripts/prepare-smoke-plugins.sh
new file mode 100644
index 00000000..c6903005
--- /dev/null
+++ b/scripts/prepare-smoke-plugins.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+# For npm (Mutates packages/cli/package.json)
+npm run pack:prepare
+
+cp ./redocly-cli.tgz ./openapi-core.tgz ./__tests__/smoke-plugins
+
+echo "Current directory:"
+pwd
+echo
+echo "Current directory content:"
+ls -a
+echo
+echo "Target directory content:"
+ls -a __tests__/smoke-plugins/
+echo


Test patch:
diff --git a/__tests__/smoke-plugins/local-plugin.js b/__tests__/smoke-plugins/local-plugin.js
new file mode 100644
index 00000000..106df691
--- /dev/null
+++ b/__tests__/smoke-plugins/local-plugin.js
@@ -0,0 +1,3 @@
+module.exports = function localPlugin() {
+  return { id: 'local-plugin' };
+};
diff --git a/__tests__/smoke-plugins/message-schema.yaml b/__tests__/smoke-plugins/message-schema.yaml
new file mode 100644
index 00000000..6acdcbdd
--- /dev/null
+++ b/__tests__/smoke-plugins/message-schema.yaml
@@ -0,0 +1,4 @@
+type: object
+properties:
+  message:
+    type: string
diff --git a/__tests__/smoke-plugins/openapi.yaml b/__tests__/smoke-plugins/openapi.yaml
new file mode 100644
index 00000000..4375c769
--- /dev/null
+++ b/__tests__/smoke-plugins/openapi.yaml
@@ -0,0 +1,19 @@
+openapi: 3.1.0
+servers:
+  - url: http://redocly-example.com
+info:
+  title: Sample API
+  version: 1.0.0
+paths:
+  /hello:
+    get:
+      operationId: getMessage
+      security: []
+      summary: Get a greeting message
+      responses:
+        200:
+          description: OK
+          content:
+            application/json:
+              schema:
+                $ref: ./message-schema.yaml
diff --git a/__tests__/smoke-plugins/package.json b/__tests__/smoke-plugins/package.json
new file mode 100644
index 00000000..2a4d3ff1
--- /dev/null
+++ b/__tests__/smoke-plugins/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "test-project-smoke-plugins",
+  "scripts": {
+    "redocly-version": "redocly --version",
+    "redocly-lint": "redocly lint openapi.yaml"
+  },
+  "dependencies": {
+    "@redocly/portal-plugin-async-api": "1.0.273"
+  }
+}
diff --git a/__tests__/smoke-plugins/redocly.yaml b/__tests__/smoke-plugins/redocly.yaml
new file mode 100644
index 00000000..de864fa8
--- /dev/null
+++ b/__tests__/smoke-plugins/redocly.yaml
@@ -0,0 +1,5 @@
+extends:
+  - recommended
+plugins:
+  - '@redocly/portal-plugin-async-api'
+  - local-plugin.js
diff --git a/__tests__/smoke-plugins/run-smoke.sh b/__tests__/smoke-plugins/run-smoke.sh
new file mode 100644
index 00000000..1e4ae366
--- /dev/null
+++ b/__tests__/smoke-plugins/run-smoke.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+set -eo pipefail # Fail on script errors
+
+cd __tests__/smoke-plugins
+echo
+echo "Directory content:"
+ls -a
+echo
+
+# Install plugin package
+npm i
+
+# Install CLI globally
+npm i redocly-cli.tgz -g
+
+# Actual smoke test - executing the command provided as the second argument
+npm run redocly-version
+npm run  redocly-lint
\ No newline at end of file