From 662ae9057c7360cb05e9476914e611a9bf0074db Mon Sep 17 00:00:00 2001 From: Mike Scott Date: Fri, 28 Oct 2022 03:50:07 +0100 Subject: [PATCH] feat: add support for configurable registries and applicable auth options (#186) Fixes: https://github.com/nodejs/corepack/issues/66 --- README.md | 6 +++ sources/corepackUtils.ts | 11 ++--- sources/npmRegistryUtils.ts | 50 +++++++++++++++++++ tests/npmRegistryUtils.test.ts | 89 ++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 sources/npmRegistryUtils.ts create mode 100644 tests/npmRegistryUtils.test.ts diff --git a/README.md b/README.md index 68d157d66..8d76baea4 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ This command will retrieve the given package manager from the specified archive - `COREPACK_ROOT` has no functional impact on Corepack itself; it's automatically being set in your environment by Corepack when it shells out to the underlying package managers, so that they can feature-detect its presence (useful for commands like `yarn init`). +- `COREPACK_NPM_REGISTRY` sets the registry base url used when retrieving package managers from npm. Default value is `https://registry.npmjs.org` + +- `COREPACK_NPM_TOKEN` sets a Bearer token authorization header when connecting to a npm type registry. + +- `COREPACK_NPM_USERNAME` and `COREPACK_NPM_PASSWORD` to set a Basic authorization header when connecting to a npm type registry. Note that both environment variables are required and as plain text. If you want to send an empty password, explicitly set `COREPACK_NPM_PASSWORD` to an empty string. + - `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` are supported through [`node-proxy-agent`](https://github.com/TooTallNate/node-proxy-agent). ## Contributing diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index b4ae4e9a3..81e28501d 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -10,14 +10,13 @@ import * as folderUtils from './folderUti import * as fsUtils from './fsUtils'; import * as httpUtils from './httpUtils'; import * as nodeUtils from './nodeUtils'; +import * as npmRegistryUtils from './npmRegistryUtils'; import {RegistrySpec, Descriptor, Locator, PackageManagerSpec} from './types'; export async function fetchLatestStableVersion(spec: RegistrySpec) { switch (spec.type) { case `npm`: { - const {[`dist-tags`]: {latest}, versions: {[latest]: {dist: {shasum}}}} = - await httpUtils.fetchAsJson(`https://registry.npmjs.org/${spec.package}`); - return `${latest}+sha1.${shasum}`; + return await npmRegistryUtils.fetchLatestStableVersion(spec.package); } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); @@ -32,8 +31,7 @@ export async function fetchLatestStableVersion(spec: RegistrySpec) { export async function fetchAvailableTags(spec: RegistrySpec): Promise> { switch (spec.type) { case `npm`: { - const data = await httpUtils.fetchAsJson(`https://registry.npmjs.org/${spec.package}`, {headers: {[`Accept`]: `application/vnd.npm.install-v1+json`}}); - return data[`dist-tags`]; + return await npmRegistryUtils.fetchAvailableTags(spec.package); } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); @@ -48,8 +46,7 @@ export async function fetchAvailableTags(spec: RegistrySpec): Promise> { switch (spec.type) { case `npm`: { - const data = await httpUtils.fetchAsJson(`https://registry.npmjs.org/${spec.package}`, {headers: {[`Accept`]: `application/vnd.npm.install-v1+json`}}); - return Object.keys(data.versions); + return await npmRegistryUtils.fetchAvailableVersions(spec.package); } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); diff --git a/sources/npmRegistryUtils.ts b/sources/npmRegistryUtils.ts new file mode 100644 index 000000000..443552d9d --- /dev/null +++ b/sources/npmRegistryUtils.ts @@ -0,0 +1,50 @@ +import {UsageError} from 'clipanion'; +import {OutgoingHttpHeaders} from 'http2'; + +import * as httpUtils from './httpUtils'; + +// load abbreviated metadata as that's all we need for these calls +// see: https://github.com/npm/registry/blob/cfe04736f34db9274a780184d1cdb2fb3e4ead2a/docs/responses/package-metadata.md +export const DEFAULT_HEADERS: OutgoingHttpHeaders = { + [`Accept`]: `application/vnd.npm.install-v1+json`, +}; +export const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org`; + +export async function fetchAsJson(packageName: string) { + const npmRegistryUrl = process.env.COREPACK_NPM_REGISTRY || DEFAULT_NPM_REGISTRY_URL; + + if (process.env.COREPACK_ENABLE_NETWORK === `0`) + throw new UsageError(`Network access disabled by the environment; can't reach npm repository ${npmRegistryUrl}`); + + const headers = {...DEFAULT_HEADERS}; + + if (`COREPACK_NPM_TOKEN` in process.env) { + headers.authorization = `Bearer ${process.env.COREPACK_NPM_TOKEN}`; + } else if (`COREPACK_NPM_USERNAME` in process.env + && `COREPACK_NPM_PASSWORD` in process.env) { + const encodedCreds = Buffer.from(`${process.env.COREPACK_NPM_USERNAME}:${process.env.COREPACK_NPM_PASSWORD}`, `utf8`).toString(`base64`); + headers.authorization = `Basic ${encodedCreds}`; + } + + return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}`, {headers}); +} + +export async function fetchLatestStableVersion(packageName: string) { + const metadata = await fetchAsJson(packageName); + const {latest} = metadata[`dist-tags`]; + if (latest === undefined) throw new Error(`${packageName} does not have a "latest" tag.`); + + const {shasum} = metadata.versions[latest].dist; + + return `${latest}+sha1.${shasum}`; +} + +export async function fetchAvailableTags(packageName: string) { + const metadata = await fetchAsJson(packageName); + return metadata[`dist-tags`]; +} + +export async function fetchAvailableVersions(packageName: string) { + const metadata = await fetchAsJson(packageName); + return Object.keys(metadata.versions); +} diff --git a/tests/npmRegistryUtils.test.ts b/tests/npmRegistryUtils.test.ts new file mode 100644 index 000000000..6675c896c --- /dev/null +++ b/tests/npmRegistryUtils.test.ts @@ -0,0 +1,89 @@ +import {fetchAsJson as httpFetchAsJson} from '../sources/httpUtils'; +import {DEFAULT_HEADERS, DEFAULT_NPM_REGISTRY_URL, fetchAsJson} from '../sources/npmRegistryUtils'; + +jest.mock(`../sources/httpUtils`); + +describe(`npm registry utils fetchAsJson`, () => { + const OLD_ENV = process.env; + + beforeEach(() => { + process.env = {...OLD_ENV}; // Make a copy + jest.resetAllMocks(); + }); + + afterEach(() => { + process.env = OLD_ENV; // Restore old environment + }); + + it(`throw usage error if COREPACK_ENABLE_NETWORK env is set to 0`, async () => { + process.env.COREPACK_ENABLE_NETWORK = `0`; + + await expect(fetchAsJson(`package-name`)).rejects.toThrowError(); + }); + + it(`loads from DEFAULT_NPM_REGISTRY_URL by default`, async () => { + await fetchAsJson(`package-name`); + + expect(httpFetchAsJson).toBeCalled(); + expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: DEFAULT_HEADERS}); + }); + + it(`loads from custom COREPACK_NPM_REGISTRY if set`, async () => { + process.env.COREPACK_NPM_REGISTRY = `https://registry.example.org`; + await fetchAsJson(`package-name`); + + expect(httpFetchAsJson).toBeCalled(); + expect(httpFetchAsJson).lastCalledWith(`${process.env.COREPACK_NPM_REGISTRY}/package-name`, {headers: DEFAULT_HEADERS}); + }); + + it(`adds authorization header with bearer token if COREPACK_NPM_TOKEN is set`, async () => { + process.env.COREPACK_NPM_TOKEN = `foo`; + + await fetchAsJson(`package-name`); + + expect(httpFetchAsJson).toBeCalled(); + expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: { + ...DEFAULT_HEADERS, + authorization: `Bearer ${process.env.COREPACK_NPM_TOKEN}`, + }}); + }); + + it(`only adds authorization header with bearer token if COREPACK_NPM_TOKEN and COREPACK_NPM_USERNAME are set`, async () => { + process.env.COREPACK_NPM_TOKEN = `foo`; + process.env.COREPACK_NPM_USERNAME = `bar`; + process.env.COREPACK_NPM_PASSWORD = `foobar`; + + await fetchAsJson(`package-name`); + + expect(httpFetchAsJson).toBeCalled(); + expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: { + ...DEFAULT_HEADERS, + authorization: `Bearer ${process.env.COREPACK_NPM_TOKEN}`, + }}); + }); + + + it(`adds authorization header with basic auth if COREPACK_NPM_USERNAME and COREPACK_NPM_PASSWORD are set`, async () => { + process.env.COREPACK_NPM_USERNAME = `foo`; + process.env.COREPACK_NPM_PASSWORD = `bar`; + + const encodedCreds = Buffer.from(`${process.env.COREPACK_NPM_USERNAME}:${process.env.COREPACK_NPM_PASSWORD}`, `utf8`).toString(`base64`); + + await fetchAsJson(`package-name`); + + expect(httpFetchAsJson).toBeCalled(); + expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: { + ...DEFAULT_HEADERS, + authorization: `Basic ${encodedCreds}`, + }}); + }); + + it(`does not add authorization header if COREPACK_NPM_USERNAME is set and COREPACK_NPM_PASSWORD is not.`, async () => { + process.env.COREPACK_NPM_USERNAME = `foo`; + + await fetchAsJson(`package-name`); + + expect(httpFetchAsJson).toBeCalled(); + expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: DEFAULT_HEADERS}); + }); +});