From cd72cdf2ac932aac1c590b8d57463091c735a226 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Wed, 29 Sep 2021 21:58:42 +0200
Subject: [PATCH] feat(config): detectGlobalManagerConfig (#11951)

---
 docs/usage/self-hosted-configuration.md       |  9 ++++++
 lib/config/options/index.ts                   |  8 +++++
 lib/manager/index.spec.ts                     |  8 +++++
 lib/manager/index.ts                          | 13 +++++++++
 lib/manager/npm/detect.spec.ts                | 29 +++++++++++++++++++
 lib/manager/npm/detect.ts                     | 23 +++++++++++++++
 lib/manager/npm/index.ts                      |  1 +
 lib/manager/types.ts                          |  7 +++++
 lib/workers/global/config/parse/index.spec.ts |  7 +++++
 lib/workers/global/config/parse/index.ts      |  8 +++++
 10 files changed, 113 insertions(+)
 create mode 100644 lib/manager/npm/detect.spec.ts
 create mode 100644 lib/manager/npm/detect.ts

diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md
index 279077a95f..8bb68d14ec 100644
--- a/docs/usage/self-hosted-configuration.md
+++ b/docs/usage/self-hosted-configuration.md
@@ -130,6 +130,15 @@ e.g.
 
 This configuration will be applied after all other environment variables so that it can be used to override defaults.
 
+## detectGlobalManagerConfig
+
+The purpose of this capability is to allow a bot admin to configure manager-specific files such as a global `.npmrc` file, instead of configuring it in Renovate config.
+
+This feature is disabled by default because it may prove surprising or undesirable for some users who don't expect Renovate to go into their home directory and import registry or credential information.
+
+Currently this capability is supported for the `npm` manager only - specifically the `~/.npmrc` file.
+If found, it will be imported into `config.npmrc` with `config.npmrcMerge` will be set to `true`.
+
 ## dockerChildPrefix
 
 Adds a custom prefix to the default Renovate sidecar Docker containers name and label.
diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index 90105c4e4d..382a560472 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -7,6 +7,14 @@ import * as pep440Versioning from '../../versioning/pep440';
 import type { RenovateOptions } from '../types';
 
 const options: RenovateOptions[] = [
+  {
+    name: 'detectGlobalManagerConfig',
+    description:
+      'If true, Renovate will attempt to read global manager config from the file system.',
+    type: 'boolean',
+    default: false,
+    globalOnly: true,
+  },
   {
     name: 'allowPostUpgradeCommandTemplating',
     description: 'If true allow templating for post-upgrade commands.',
diff --git a/lib/manager/index.spec.ts b/lib/manager/index.spec.ts
index 36bdc75504..b3eaa34b18 100644
--- a/lib/manager/index.spec.ts
+++ b/lib/manager/index.spec.ts
@@ -2,6 +2,8 @@ import { loadModules } from '../util/modules';
 import type { ManagerApi } from './types';
 import * as manager from '.';
 
+jest.mock('../util/fs');
+
 describe('manager/index', () => {
   describe('get()', () => {
     it('gets something', () => {
@@ -43,6 +45,12 @@ describe('manager/index', () => {
     }
   });
 
+  describe('detectGlobalConfig()', () => {
+    it('iterates through managers', async () => {
+      expect(await manager.detectAllGlobalConfig()).toEqual({});
+    });
+  });
+
   describe('extractAllPackageFiles()', () => {
     it('returns null', async () => {
       manager.getManagers().set('dummy', {
diff --git a/lib/manager/index.ts b/lib/manager/index.ts
index b4122323dc..1741962272 100644
--- a/lib/manager/index.ts
+++ b/lib/manager/index.ts
@@ -15,6 +15,7 @@ import type { RangeStrategy } from '../types';
 import managers from './api';
 import type {
   ExtractConfig,
+  GlobalManagerConfig,
   ManagerApi,
   PackageFile,
   RangeConfig,
@@ -47,6 +48,18 @@ export const getLanguageList = (): string[] => languageList;
 export const getManagerList = (): string[] => managerList;
 export const getManagers = (): Map<string, ManagerApi> => managers;
 
+export async function detectAllGlobalConfig(): Promise<GlobalManagerConfig> {
+  let config: GlobalManagerConfig = {};
+  for (const managerName of managerList) {
+    const manager = managers.get(managerName);
+    if (manager.detectGlobalConfig) {
+      // This should use mergeChildConfig once more than one manager is supported, but introduces a cyclic dependency
+      config = { ...config, ...(await manager.detectGlobalConfig()) };
+    }
+  }
+  return config;
+}
+
 export async function extractAllPackageFiles(
   manager: string,
   config: ExtractConfig,
diff --git a/lib/manager/npm/detect.spec.ts b/lib/manager/npm/detect.spec.ts
new file mode 100644
index 0000000000..b997f7f3ba
--- /dev/null
+++ b/lib/manager/npm/detect.spec.ts
@@ -0,0 +1,29 @@
+import { fs } from '../../../test/util';
+import { detectGlobalConfig } from './detect';
+
+jest.mock('../../util/fs');
+
+describe('manager/npm/detect', () => {
+  describe('.detectGlobalConfig()', () => {
+    it('detects .npmrc in home directory', async () => {
+      fs.readFile.mockResolvedValueOnce(
+        'registry=https://registry.npmjs.org\n'
+      );
+      const res = await detectGlobalConfig();
+      expect(res).toMatchInlineSnapshot(`
+Object {
+  "npmrc": "registry=https://registry.npmjs.org
+",
+  "npmrcMerge": true,
+}
+`);
+      expect(res.npmrc).toBeDefined();
+      expect(res.npmrcMerge).toBe(true);
+    });
+    it('handles no .npmrc', async () => {
+      fs.readFile.mockImplementationOnce(() => Promise.reject());
+      const res = await detectGlobalConfig();
+      expect(res).toEqual({});
+    });
+  });
+});
diff --git a/lib/manager/npm/detect.ts b/lib/manager/npm/detect.ts
new file mode 100644
index 0000000000..f0ca2e72c5
--- /dev/null
+++ b/lib/manager/npm/detect.ts
@@ -0,0 +1,23 @@
+import os from 'os';
+import is from '@sindresorhus/is';
+import { join } from 'upath';
+import { logger } from '../../logger';
+import { readFile } from '../../util/fs';
+import { GlobalManagerConfig } from '../types';
+
+export async function detectGlobalConfig(): Promise<GlobalManagerConfig> {
+  const res: GlobalManagerConfig = {};
+  const homedir = os.homedir();
+  const npmrcFileName = join(homedir, '.npmrc');
+  try {
+    const npmrc = await readFile(npmrcFileName, 'utf8');
+    if (is.nonEmptyString(npmrc)) {
+      res.npmrc = npmrc;
+      res.npmrcMerge = true;
+      logger.debug(`Detected ${npmrcFileName} and adding it to global config`);
+    }
+  } catch (err) {
+    logger.warn({ npmrcFileName }, 'Error reading .npmrc file');
+  }
+  return res;
+}
diff --git a/lib/manager/npm/index.ts b/lib/manager/npm/index.ts
index 0fc2489fbd..fdccb12bc1 100644
--- a/lib/manager/npm/index.ts
+++ b/lib/manager/npm/index.ts
@@ -1,6 +1,7 @@
 import { LANGUAGE_JAVASCRIPT } from '../../constants/languages';
 import * as npmVersioning from '../../versioning/npm';
 
+export { detectGlobalConfig } from './detect';
 export { extractAllPackageFiles } from './extract';
 export {
   bumpPackageVersion,
diff --git a/lib/manager/types.ts b/lib/manager/types.ts
index 5e1842c53d..bb512be8b2 100644
--- a/lib/manager/types.ts
+++ b/lib/manager/types.ts
@@ -220,6 +220,11 @@ export interface UpdateLockedConfig {
   newVersion?: string;
 }
 
+export interface GlobalManagerConfig {
+  npmrc?: string;
+  npmrcMerge?: boolean;
+}
+
 export interface ManagerApi {
   defaultConfig: Record<string, unknown>;
   language?: string;
@@ -231,6 +236,8 @@ export interface ManagerApi {
     bumpVersion: ReleaseType | string
   ): Result<BumpPackageVersionResult>;
 
+  detectGlobalConfig?(): Result<GlobalManagerConfig>;
+
   extractAllPackageFiles?(
     config: ExtractConfig,
     files: string[]
diff --git a/lib/workers/global/config/parse/index.spec.ts b/lib/workers/global/config/parse/index.spec.ts
index 5a75c6724e..b69f970372 100644
--- a/lib/workers/global/config/parse/index.spec.ts
+++ b/lib/workers/global/config/parse/index.spec.ts
@@ -3,6 +3,8 @@ import { readFile } from '../../../../util/fs';
 import getArgv from './__fixtures__/argv';
 
 jest.mock('../../../../datasource/npm');
+jest.mock('../../../../util/fs');
+
 try {
   jest.mock('../../config.js');
 } catch (err) {
@@ -118,5 +120,10 @@ describe('workers/global/config/parse/index', () => {
       const parsed = await configParser.parseConfigs(defaultEnv, defaultArgv);
       expect(parsed.endpoint).toEqual('https://github.renovatebot.com/api/v3/');
     });
+    it('parses global manager config', async () => {
+      defaultArgv = defaultArgv.concat(['--detect-global-manager-config=true']);
+      const parsed = await configParser.parseConfigs(defaultEnv, defaultArgv);
+      expect(parsed.npmrc).toBeNull();
+    });
   });
 });
diff --git a/lib/workers/global/config/parse/index.ts b/lib/workers/global/config/parse/index.ts
index 7f9bcc757b..e6b35f742b 100644
--- a/lib/workers/global/config/parse/index.ts
+++ b/lib/workers/global/config/parse/index.ts
@@ -2,6 +2,7 @@ import * as defaultsParser from '../../../../config/defaults';
 import { AllConfig } from '../../../../config/types';
 import { mergeChildConfig } from '../../../../config/utils';
 import { addStream, logger, setContext } from '../../../../logger';
+import { detectAllGlobalConfig } from '../../../../manager';
 import { ensureDir, getSubDirectory, readFile } from '../../../../util/fs';
 import { ensureTrailingSlash } from '../../../../util/url';
 import * as cliParser from './cli';
@@ -73,6 +74,13 @@ export async function parseConfigs(
   logger.debug({ config: envConfig }, 'Env config');
   logger.debug({ config: combinedConfig }, 'Combined config');
 
+  if (config.detectGlobalManagerConfig) {
+    logger.debug('Detecting global manager config');
+    const globalManagerConfig = await detectAllGlobalConfig();
+    logger.debug({ config: globalManagerConfig }, 'Global manager config');
+    config = mergeChildConfig(config, globalManagerConfig);
+  }
+
   // Get global config
   logger.trace({ config }, 'Full config');
 
-- 
GitLab