From 6eb06ed110289e048944eb164bebea24f67f07a6 Mon Sep 17 00:00:00 2001
From: Nathan Fritz <fritzy@github.com>
Date: Wed, 4 May 2022 16:49:48 -0700
Subject: [PATCH] feat: add --replace-registry-host=<npmjs|always|never>

---
 docs/content/using-npm/config.md              |  16 +++
 lib/utils/config/definitions.js               |  16 +++
 .../test/lib/commands/config.js.test.cjs      |   2 +
 .../lib/utils/config/definitions.js.test.cjs  |  16 +++
 .../lib/utils/config/describe-all.js.test.cjs |  16 +++
 workspaces/arborist/lib/arborist/index.js     |   6 +
 workspaces/arborist/lib/arborist/reify.js     |  12 +-
 workspaces/arborist/test/arborist/index.js    |   9 ++
 workspaces/arborist/test/arborist/reify.js    | 130 ++++++++++++++++++
 workspaces/arborist/test/fixtures/tnock.js    |  14 ++
 10 files changed, 235 insertions(+), 2 deletions(-)
 create mode 100644 workspaces/arborist/test/fixtures/tnock.js

diff --git a/docs/content/using-npm/config.md b/docs/content/using-npm/config.md
index ba79dd505a88e..973bc37877624 100644
--- a/docs/content/using-npm/config.md
+++ b/docs/content/using-npm/config.md
@@ -1359,6 +1359,22 @@ The base URL of the npm registry.
 <!-- automatically generated, do not edit manually -->
 <!-- see lib/utils/config/definitions.js -->
 
+#### `replace-registry-host`
+
+* Default: "npmjs"
+* Type: "npmjs", "never", or "always"
+
+Defines behavior for replacing the registry host in a lockfile with the
+configured registry.
+
+The default behavior is to replace package dist URLs from the default
+registry (https://registry.npmjs.org) to the configured registry. If set to
+"never", then use the registry value. If set to "always", then replace the
+registry host with the configured host every time.
+
+<!-- automatically generated, do not edit manually -->
+<!-- see lib/utils/config/definitions.js -->
+
 #### `save`
 
 * Default: `true` unless when using `npm update` where it defaults to `false`
diff --git a/lib/utils/config/definitions.js b/lib/utils/config/definitions.js
index 4a1f971d85436..b7ee1bfe84f12 100644
--- a/lib/utils/config/definitions.js
+++ b/lib/utils/config/definitions.js
@@ -1618,6 +1618,22 @@ define('registry', {
   flatten,
 })
 
+define('replace-registry-host', {
+  default: 'npmjs',
+  hint: '<npmjs|never|always>',
+  type: ['npmjs', 'never', 'always'],
+  description: `
+    Defines behavior for replacing the registry host in a lockfile with the
+    configured registry.
+
+    The default behavior is to replace package dist URLs from the default
+    registry (https://registry.npmjs.org) to the configured registry. If set to
+    "never", then use the registry value. If set to "always", then replace the
+    registry host with the configured host every time.
+  `,
+  flatten,
+})
+
 define('save', {
   default: true,
   defaultDescription: `\`true\` unless when using \`npm update\` where it
diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs
index c6f07dd326cb2..4a2609aa44c53 100644
--- a/tap-snapshots/test/lib/commands/config.js.test.cjs
+++ b/tap-snapshots/test/lib/commands/config.js.test.cjs
@@ -120,6 +120,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
   "read-only": false,
   "rebuild-bundle": true,
   "registry": "https://registry.npmjs.org/",
+  "replace-registry-host": "npmjs",
   "save": true,
   "save-bundle": false,
   "save-dev": false,
@@ -275,6 +276,7 @@ proxy = null
 read-only = false 
 rebuild-bundle = true 
 registry = "https://registry.npmjs.org/" 
+replace-registry-host = "npmjs" 
 save = true 
 save-bundle = false 
 save-dev = false 
diff --git a/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs b/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs
index ff00f9a0f9b3d..b83354ff3a3b3 100644
--- a/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs
+++ b/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs
@@ -115,6 +115,7 @@ Array [
   "read-only",
   "rebuild-bundle",
   "registry",
+  "replace-registry-host",
   "save",
   "save-bundle",
   "save-dev",
@@ -1439,6 +1440,21 @@ exports[`test/lib/utils/config/definitions.js TAP > config description for regis
 The base URL of the npm registry.
 `
 
+exports[`test/lib/utils/config/definitions.js TAP > config description for replace-registry-host 1`] = `
+#### \`replace-registry-host\`
+
+* Default: "npmjs"
+* Type: "npmjs", "never", or "always"
+
+Defines behavior for replacing the registry host in a lockfile with the
+configured registry.
+
+The default behavior is to replace package dist URLs from the default
+registry (https://registry.npmjs.org) to the configured registry. If set to
+"never", then use the registry value. If set to "always", then replace the
+registry host with the configured host every time.
+`
+
 exports[`test/lib/utils/config/definitions.js TAP > config description for save 1`] = `
 #### \`save\`
 
diff --git a/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs b/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs
index 6740b94c772c8..ff13162242eb8 100644
--- a/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs
+++ b/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs
@@ -1233,6 +1233,22 @@ The base URL of the npm registry.
 <!-- automatically generated, do not edit manually -->
 <!-- see lib/utils/config/definitions.js -->
 
+#### \`replace-registry-host\`
+
+* Default: "npmjs"
+* Type: "npmjs", "never", or "always"
+
+Defines behavior for replacing the registry host in a lockfile with the
+configured registry.
+
+The default behavior is to replace package dist URLs from the default
+registry (https://registry.npmjs.org) to the configured registry. If set to
+"never", then use the registry value. If set to "always", then replace the
+registry host with the configured host every time.
+
+<!-- automatically generated, do not edit manually -->
+<!-- see lib/utils/config/definitions.js -->
+
 #### \`save\`
 
 * Default: \`true\` unless when using \`npm update\` where it defaults to \`false\`
diff --git a/workspaces/arborist/lib/arborist/index.js b/workspaces/arborist/lib/arborist/index.js
index cb6ef1e0c2ccb..96e0a37afdab8 100644
--- a/workspaces/arborist/lib/arborist/index.js
+++ b/workspaces/arborist/lib/arborist/index.js
@@ -74,8 +74,14 @@ class Arborist extends Base {
       cache: options.cache || `${homedir()}/.npm/_cacache`,
       packumentCache: options.packumentCache || new Map(),
       workspacesEnabled: options.workspacesEnabled !== false,
+      replaceRegistryHost: options.replaceRegistryHost,
       lockfileVersion: lockfileVersion(options.lockfileVersion),
     }
+    if (options.replaceRegistryHost !== 'never'
+      && options.replaceRegistryHost !== 'always'
+    ) {
+      this.options.replaceRegistryHost = 'npmjs'
+    }
 
     this[_workspacesEnabled] = this.options.workspacesEnabled
 
diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js
index 7fd0ca7f60740..feb3f988b5b0a 100644
--- a/workspaces/arborist/lib/arborist/reify.js
+++ b/workspaces/arborist/lib/arborist/reify.js
@@ -716,8 +716,16 @@ module.exports = cls => class Reifier extends cls {
     // ${REGISTRY} or something.  This has to be threaded through the
     // Shrinkwrap and Node classes carefully, so for now, just treat
     // the default reg as the magical animal that it has been.
-    return resolved && resolved
-      .replace(/^https?:\/\/registry\.npmjs\.org\//, this.registry)
+    const resolvedURL = new URL(resolved)
+    if (resolved
+      && ((this.options.replaceRegistryHost === 'npmjs'
+        && resolvedURL.hostname === 'registry.npmjs.org')
+      || this.options.replaceRegistryHost === 'always')
+    ) {
+      // this.registry always has a trailing slash
+      resolved = `${this.registry.slice(0, -1)}${resolvedURL.pathname}${resolvedURL.searchParams}`
+    }
+    return resolved
   }
 
   // bundles are *sort of* like shrinkwraps, in that the branch is defined
diff --git a/workspaces/arborist/test/arborist/index.js b/workspaces/arborist/test/arborist/index.js
index 3469c5c73591d..8dde148cfec13 100644
--- a/workspaces/arborist/test/arborist/index.js
+++ b/workspaces/arborist/test/arborist/index.js
@@ -236,3 +236,12 @@ t.test('lockfileVersion config validation', async t => {
     message: 'Invalid lockfileVersion config: banana',
   })
 })
+
+t.test('valid replaceRegistryHost values', t => {
+  t.equal(new Arborist({ replaceRegistryHost: 'garbage' }).options.replaceRegistryHost, 'npmjs')
+  t.equal(new Arborist({ replaceRegistryHost: 'npmjs' }).options.replaceRegistryHost, 'npmjs')
+  t.equal(new Arborist({ replaceRegistryHost: undefined }).options.replaceRegistryHost, 'npmjs')
+  t.equal(new Arborist({ replaceRegistryHost: 'always' }).options.replaceRegistryHost, 'always')
+  t.equal(new Arborist({ replaceRegistryHost: 'never' }).options.replaceRegistryHost, 'never')
+  t.end()
+})
diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js
index 5097004041174..9e19a01649e62 100644
--- a/workspaces/arborist/test/arborist/reify.js
+++ b/workspaces/arborist/test/arborist/reify.js
@@ -2,6 +2,7 @@ const { resolve, basename } = require('path')
 const t = require('tap')
 const runScript = require('@npmcli/run-script')
 const localeCompare = require('@isaacs/string-locale-compare')('en')
+const tnock = require('../fixtures/tnock')
 
 // mock rimraf so we can make it fail in rollback tests
 const realRimraf = require('rimraf')
@@ -2865,3 +2866,132 @@ t.test('installLinks', (t) => {
 
   t.end()
 })
+
+t.only('should preserve exact ranges, missing actual tree', async (t) => {
+  const Arborist = require('../../lib/index.js')
+  const abbrev = resolve(__dirname,
+    '../fixtures/registry-mocks/content/abbrev/-/abbrev-1.1.1.tgz')
+  const abbrevTGZ = fs.readFileSync(abbrev)
+
+  const abbrevPackument = JSON.stringify({
+    _id: 'abbrev',
+    _rev: 'lkjadflkjasdf',
+    name: 'abbrev',
+    'dist-tags': { latest: '1.1.1' },
+    versions: {
+      '1.1.1': {
+        name: 'abbrev',
+        version: '1.1.1',
+        dist: {
+          tarball: 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz',
+        },
+      },
+    },
+  })
+
+  const abbrevPackument2 = JSON.stringify({
+    _id: 'abbrev',
+    _rev: 'lkjadflkjasdf',
+    name: 'abbrev',
+    'dist-tags': { latest: '1.1.1' },
+    versions: {
+      '1.1.1': {
+        name: 'abbrev',
+        version: '1.1.1',
+        dist: {
+          tarball: 'https://registry.garbage.org/abbrev/-/abbrev-1.1.1.tgz',
+        },
+      },
+    },
+  })
+
+  t.only('host should not be replaced replaceRegistryHost=never', async (t) => {
+    const testdir = t.testdir({
+      project: {
+        'package.json': JSON.stringify({
+          name: 'myproject',
+          version: '1.0.0',
+          dependencies: {
+            abbrev: '1.1.1',
+          },
+        }),
+      },
+    })
+
+    tnock(t, 'https://registry.github.com')
+      .get('/abbrev')
+      .reply(200, abbrevPackument)
+
+    tnock(t, 'https://registry.npmjs.org')
+      .get('/abbrev/-/abbrev-1.1.1.tgz')
+      .reply(200, abbrevTGZ)
+
+    const arb = new Arborist({
+      path: resolve(testdir, 'project'),
+      registry: 'https://registry.github.com',
+      cache: resolve(testdir, 'cache'),
+      replaceRegistryHost: 'never',
+    })
+    await arb.reify()
+  })
+
+  t.only('host should be replaced replaceRegistryHost=npmjs', async (t) => {
+    const testdir = t.testdir({
+      project: {
+        'package.json': JSON.stringify({
+          name: 'myproject',
+          version: '1.0.0',
+          dependencies: {
+            abbrev: '1.1.1',
+          },
+        }),
+      },
+    })
+
+    tnock(t, 'https://registry.github.com')
+      .get('/abbrev')
+      .reply(200, abbrevPackument)
+
+    tnock(t, 'https://registry.github.com')
+      .get('/abbrev/-/abbrev-1.1.1.tgz')
+      .reply(200, abbrevTGZ)
+
+    const arb = new Arborist({
+      path: resolve(testdir, 'project'),
+      registry: 'https://registry.github.com',
+      cache: resolve(testdir, 'cache'),
+      replaceRegistryHost: 'npmjs',
+    })
+    await arb.reify()
+  })
+
+  t.only('host should be always replaceRegistryHost=always', async (t) => {
+    const testdir = t.testdir({
+      project: {
+        'package.json': JSON.stringify({
+          name: 'myproject',
+          version: '1.0.0',
+          dependencies: {
+            abbrev: '1.1.1',
+          },
+        }),
+      },
+    })
+
+    tnock(t, 'https://registry.github.com')
+      .get('/abbrev')
+      .reply(200, abbrevPackument2)
+
+    tnock(t, 'https://registry.github.com')
+      .get('/abbrev/-/abbrev-1.1.1.tgz')
+      .reply(200, abbrevTGZ)
+
+    const arb = new Arborist({
+      path: resolve(testdir, 'project'),
+      registry: 'https://registry.github.com',
+      cache: resolve(testdir, 'cache'),
+      replaceRegistryHost: 'always',
+    })
+    await arb.reify()
+  })
+})
diff --git a/workspaces/arborist/test/fixtures/tnock.js b/workspaces/arborist/test/fixtures/tnock.js
new file mode 100644
index 0000000000000..2e07f73647892
--- /dev/null
+++ b/workspaces/arborist/test/fixtures/tnock.js
@@ -0,0 +1,14 @@
+'use strict'
+
+const nock = require('nock')
+
+module.exports = tnock
+function tnock (t, host) {
+  const server = nock(host)
+  nock.disableNetConnect()
+  t.teardown(function () {
+    nock.enableNetConnect()
+    server.done()
+  })
+  return server
+}