From 2143c975f97517039e1497ca175b8ad7768dd1df Mon Sep 17 00:00:00 2001
From: Florian Greinacher <florian.greinacher@siemens.com>
Date: Tue, 31 Jan 2023 07:26:02 +0100
Subject: [PATCH] feat(manager/npm): read registry URLs from .yarnrc.yml
 (#19864)

Co-authored-by: Valentin Agachi <github-com@agachi.name>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Closes https://github.com/renovatebot/renovate/issues/16353
---
 .../manager/npm/__fixtures__/inputs/02.json   |   6 +-
 .../extract/__snapshots__/index.spec.ts.snap  |   1 +
 lib/modules/manager/npm/extract/index.spec.ts |  18 ++++
 lib/modules/manager/npm/extract/index.ts      |  17 +++
 .../manager/npm/extract/yarnrc.spec.ts        | 100 ++++++++++++++++++
 lib/modules/manager/npm/extract/yarnrc.ts     |  46 ++++++++
 6 files changed, 183 insertions(+), 5 deletions(-)
 create mode 100644 lib/modules/manager/npm/extract/yarnrc.spec.ts
 create mode 100644 lib/modules/manager/npm/extract/yarnrc.ts

diff --git a/lib/modules/manager/npm/__fixtures__/inputs/02.json b/lib/modules/manager/npm/__fixtures__/inputs/02.json
index 6c2d055b3b..d17132e5db 100644
--- a/lib/modules/manager/npm/__fixtures__/inputs/02.json
+++ b/lib/modules/manager/npm/__fixtures__/inputs/02.json
@@ -10,11 +10,7 @@
     }
   ],
   "dependencies": {
-      "autoprefixer": "6.5.0",
-      "bower": "~1.6.0",
-      "browserify": "13.1.0",
-    "browserify-css": "0.9.2",
-    "cheerio": "0.22.0",
+    "@babel/core": "7.0.0",
     "config": "1.21.0"
   },
   "homepage": "https://keylocation.sg",
diff --git a/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap b/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap
index 9962ebc67a..149a291354 100644
--- a/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap
+++ b/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap
@@ -1150,6 +1150,7 @@ exports[`modules/manager/npm/extract/index .extractPackageFile() finds simple ya
 }
 `;
 
+
 exports[`modules/manager/npm/extract/index .extractPackageFile() returns an array of dependencies 1`] = `
 {
   "constraints": {},
diff --git a/lib/modules/manager/npm/extract/index.spec.ts b/lib/modules/manager/npm/extract/index.spec.ts
index 0529099f6f..0b966aab70 100644
--- a/lib/modules/manager/npm/extract/index.spec.ts
+++ b/lib/modules/manager/npm/extract/index.spec.ts
@@ -9,6 +9,7 @@ const fs: any = _fs;
 const defaultConfig = getConfig();
 
 const input01Content = Fixtures.get('inputs/01.json', '..');
+const input02Content = Fixtures.get('inputs/02.json', '..');
 const input01GlobContent = Fixtures.get('inputs/01-glob.json', '..');
 const workspacesContent = Fixtures.get('inputs/workspaces.json', '..');
 const workspacesSimpleContent = Fixtures.get(
@@ -223,6 +224,23 @@ describe('modules/manager/npm/extract/index', () => {
       expect(res?.npmrc).toBe('registry=https://registry.npmjs.org\n');
     });
 
+    it('reads registryUrls from .yarnrc.yml', async () => {
+      fs.readLocalFile = jest.fn((fileName) => {
+        if (fileName === '.yarnrc.yml') {
+          return 'npmRegistryServer: https://registry.example.com';
+        }
+        return null;
+      });
+      const res = await npmExtract.extractPackageFile(
+        input02Content,
+        'package.json',
+        {}
+      );
+      expect(
+        res?.deps.flatMap((dep) => dep.registryUrls)
+      ).toBeArrayIncludingOnly(['https://registry.example.com']);
+    });
+
     it('finds lerna', async () => {
       fs.readLocalFile = jest.fn((fileName) => {
         if (fileName === 'lerna.json') {
diff --git a/lib/modules/manager/npm/extract/index.ts b/lib/modules/manager/npm/extract/index.ts
index a69b98eac1..a4425de015 100644
--- a/lib/modules/manager/npm/extract/index.ts
+++ b/lib/modules/manager/npm/extract/index.ts
@@ -20,6 +20,11 @@ import { getLockedVersions } from './locked-versions';
 import { detectMonorepos } from './monorepo';
 import type { NpmPackage, NpmPackageDependency } from './types';
 import { isZeroInstall } from './yarn';
+import {
+  YarnConfig,
+  loadConfigFromYarnrcYml,
+  resolveRegistryUrl,
+} from './yarnrc';
 
 function parseDepName(depType: string, key: string): string {
   if (depType !== 'resolutions') {
@@ -138,6 +143,12 @@ export async function extractPackageFile(
   const yarnrcYmlFileName = getSiblingFileName(fileName, '.yarnrc.yml');
   const yarnZeroInstall = await isZeroInstall(yarnrcYmlFileName);
 
+  let yarnConfig: YarnConfig | null = null;
+  const repoYarnrcYml = await readLocalFile(yarnrcYmlFileName, 'utf8');
+  if (is.string(repoYarnrcYml)) {
+    yarnConfig = loadConfigFromYarnrcYml(repoYarnrcYml);
+  }
+
   let lernaJsonFile: string | undefined;
   let lernaPackages: string[] | undefined;
   let lernaClient: 'yarn' | 'npm' | undefined;
@@ -272,6 +283,12 @@ export async function extractPackageFile(
       hasFancyRefs = true;
       return dep;
     }
+    if (yarnConfig) {
+      const registryUrlFromYarnConfig = resolveRegistryUrl(depName, yarnConfig);
+      if (registryUrlFromYarnConfig) {
+        dep.registryUrls = [registryUrlFromYarnConfig];
+      }
+    }
     if (isValid(dep.currentValue)) {
       dep.datasource = NpmDatasource.id;
       if (dep.currentValue === '') {
diff --git a/lib/modules/manager/npm/extract/yarnrc.spec.ts b/lib/modules/manager/npm/extract/yarnrc.spec.ts
new file mode 100644
index 0000000000..4997416260
--- /dev/null
+++ b/lib/modules/manager/npm/extract/yarnrc.spec.ts
@@ -0,0 +1,100 @@
+import { codeBlock } from 'common-tags';
+import { loadConfigFromYarnrcYml, resolveRegistryUrl } from './yarnrc';
+
+describe('modules/manager/npm/extract/yarnrc', () => {
+  describe('resolveRegistryUrl()', () => {
+    it('considers default registry', () => {
+      const registryUrl = resolveRegistryUrl('a-package', {
+        npmRegistryServer: 'https://private.example.com/npm',
+      });
+      expect(registryUrl).toBe('https://private.example.com/npm');
+    });
+
+    it('chooses matching scoped registry over default registry', () => {
+      const registryUrl = resolveRegistryUrl('@scope/a-package', {
+        npmRegistryServer: 'https://private.example.com/npm',
+        npmScopes: {
+          scope: {
+            npmRegistryServer: 'https://scope.example.com/npm',
+          },
+        },
+      });
+      expect(registryUrl).toBe('https://scope.example.com/npm');
+    });
+
+    it('ignores non matching scoped registry', () => {
+      const registryUrl = resolveRegistryUrl('@scope/a-package', {
+        npmScopes: {
+          'other-scope': {
+            npmRegistryServer: 'https://other-scope.example.com/npm',
+          },
+        },
+      });
+      expect(registryUrl).toBeNull();
+    });
+
+    it('ignores partial scope match', () => {
+      const registryUrl = resolveRegistryUrl('@scope-2/a-package', {
+        npmScopes: {
+          scope: {
+            npmRegistryServer: 'https://scope.example.com/npm',
+          },
+        },
+      });
+      expect(registryUrl).toBeNull();
+    });
+  });
+
+  describe('loadConfigFromYarnrcYml()', () => {
+    it.each([
+      [
+        'npmRegistryServer: https://npm.example.com',
+        { npmRegistryServer: 'https://npm.example.com' },
+      ],
+      [
+        codeBlock`
+          npmRegistryServer: https://npm.example.com
+          npmScopes:
+            foo:
+              npmRegistryServer: https://npm-foo.example.com
+        `,
+        {
+          npmRegistryServer: 'https://npm.example.com',
+          npmScopes: {
+            foo: {
+              npmRegistryServer: 'https://npm-foo.example.com',
+            },
+          },
+        },
+      ],
+      [
+        codeBlock`
+          npmRegistryServer: https://npm.example.com
+          nodeLinker: pnp
+        `,
+        { npmRegistryServer: 'https://npm.example.com' },
+      ],
+      ['npmRegistryServer: 42', null],
+      ['npmScopes: 42', null],
+      [
+        codeBlock`
+          npmScopes:
+            foo: 42
+        `,
+        null,
+      ],
+      [
+        codeBlock`
+          npmScopes:
+            foo:
+              npmRegistryServer: 42
+        `,
+        null,
+      ],
+    ])('produces expected config (%s)', (yarnrcYml, expectedConfig) => {
+      const config = loadConfigFromYarnrcYml(yarnrcYml);
+
+      expect(config).toEqual(expectedConfig);
+    });
+  });
+});
diff --git a/lib/modules/manager/npm/extract/yarnrc.ts b/lib/modules/manager/npm/extract/yarnrc.ts
new file mode 100644
index 0000000000..81f3448518
--- /dev/null
+++ b/lib/modules/manager/npm/extract/yarnrc.ts
@@ -0,0 +1,46 @@
+import { load } from 'js-yaml';
+import { z } from 'zod';
+import { logger } from '../../../../logger';
+
+const YarnrcYmlSchema = z.object({
+  npmRegistryServer: z.string().optional(),
+  npmScopes: z
+    .record(
+      z.object({
+        npmRegistryServer: z.string().optional(),
+      })
+    )
+    .optional(),
+});
+
+export type YarnConfig = z.infer<typeof YarnrcYmlSchema>;
+
+export function loadConfigFromYarnrcYml(yarnrcYml: string): YarnConfig | null {
+  try {
+    return YarnrcYmlSchema.parse(
+      load(yarnrcYml, {
+        json: true,
+      })
+    );
+  } catch (err) {
+    logger.warn({ yarnrcYml, err }, `Failed to load yarnrc file`);
+    return null;
+  }
+}
+
+export function resolveRegistryUrl(
+  packageName: string,
+  yarnConfig: YarnConfig
+): string | null {
+  if (yarnConfig.npmScopes) {
+    for (const scope in yarnConfig.npmScopes) {
+      if (packageName.startsWith(`@${scope}/`)) {
+        return yarnConfig.npmScopes[scope].npmRegistryServer ?? null;
+      }
+    }
+  }
+  if (yarnConfig.npmRegistryServer) {
+    return yarnConfig.npmRegistryServer;
+  }
+  return null;
+}
-- 
GitLab