diff --git a/README.md b/README.md index 18bcf88e9..a9508e70a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,17 @@ along with the SHA-224 hash of this version for validation. recommended as a security practice. Permitted values for the package manager are `yarn`, `npm`, and `pnpm`. +You can also provide a URL to a `.js` file (which will be interpreted as a +CommonJS module) or a `.tgz` file (which will be interpreted as a package, and +the `"bin"` field of the `package.json` will be used to determine which file to +use in the archive). + +```json +{ + "packageManager": "yarn@https://registry.npmjs.org/@yarnpkg/cli-dist/-/cli-dist-3.2.3.tgz#sha224.16a0797d1710d1fb7ec40ab5c3801b68370a612a9b66ba117ad9924b" +} +``` + ## Known Good Releases When running Corepack within projects that don't list a supported package diff --git a/sources/Engine.ts b/sources/Engine.ts index baad5dac7..f477bba13 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -1,16 +1,16 @@ -import {UsageError} from 'clipanion'; -import fs from 'fs'; -import path from 'path'; -import process from 'process'; -import semver from 'semver'; +import {UsageError} from 'clipanion'; +import fs from 'fs'; +import path from 'path'; +import process from 'process'; +import semver from 'semver'; -import defaultConfig from '../config.json'; +import defaultConfig from '../config.json'; -import * as corepackUtils from './corepackUtils'; -import * as folderUtils from './folderUtils'; -import * as semverUtils from './semverUtils'; -import {Config, Descriptor, Locator} from './types'; -import {SupportedPackageManagers, SupportedPackageManagerSet} from './types'; +import * as corepackUtils from './corepackUtils'; +import * as folderUtils from './folderUtils'; +import * as semverUtils from './semverUtils'; +import {Config, Descriptor, Locator, SupportedPackageManagerDescriptor, SupportedPackageManagerLocator, URLDescriptor} from './types'; +import {SupportedPackageManagers, SupportedPackageManagerSet} from './types'; export type PreparedPackageManagerInfo = Awaited>; @@ -35,6 +35,18 @@ export class Engine { } getPackageManagerSpecFor(locator: Locator) { + if (!corepackUtils.isSupportedPackageManagerLocator(locator)) { + return { + url: locator.reference, + bin: {}, + registry: { + type: `url`, + url: locator.reference, + fields: {}, + }, + }; + } + const definition = this.config.definitions[locator.name]; if (typeof definition === `undefined`) throw new UsageError(`This package manager (${locator.name}) isn't supported by this corepack build`); @@ -143,6 +155,13 @@ export class Engine { } async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}) { + if (!corepackUtils.isSupportedPackageManagerDescriptor(descriptor)) { + return { + name: descriptor.name, + reference: new URL(descriptor.range), + }; + } + const definition = this.config.definitions[descriptor.name]; if (typeof definition === `undefined`) throw new UsageError(`This package manager (${descriptor.name}) isn't supported by this corepack build`); diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index db7b39d22..995ec58ad 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -1,18 +1,18 @@ -import {createHash} from 'crypto'; -import {once} from 'events'; -import fs from 'fs'; -import type {Dir} from 'fs'; -import Module from 'module'; -import path from 'path'; -import semver from 'semver'; - -import * as debugUtils from './debugUtils'; -import * as folderUtils from './folderUtils'; -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'; +import {createHash} from 'crypto'; +import {once} from 'events'; +import fs from 'fs'; +import type {Dir} from 'fs'; +import Module from 'module'; +import path from 'path'; +import semver from 'semver'; + +import * as debugUtils from './debugUtils'; +import * as folderUtils from './folderUtils'; +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, SupportedPackageManagerDescriptor, SupportedPackageManagerLocator, URLLocator} from './types'; export function getRegistryFromPackageManagerSpec(spec: PackageManagerSpec) { return process.env.COREPACK_NPM_REGISTRY @@ -102,17 +102,46 @@ export async function findInstalledVersion(installTarget: string, descriptor: De return bestMatch; } +export function isSupportedPackageManagerDescriptor(descriptor: Descriptor): descriptor is SupportedPackageManagerDescriptor { + return !URL.canParse(descriptor.range); +} + +export function isSupportedPackageManagerLocator(locator: Locator): locator is SupportedPackageManagerLocator { + return typeof locator.reference === `string`; +} + +function parseURLReference(locator: URLLocator) { + const {hash, href} = locator.reference; + if (hash) { + return { + version: encodeURIComponent(href.slice(0, -hash.length)), + build: hash.slice(1).split(`.`), + }; + } + return {version: href, build: []}; +} + export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}) { const {default: tar} = await import(`tar`); - const {version, build} = semver.parse(locator.reference)!; + const locatorIsASupportedPackageManager = isSupportedPackageManagerLocator(locator); + const {version, build} = locatorIsASupportedPackageManager ? semver.parse(locator.reference)! : parseURLReference(locator); const installFolder = path.join(installTarget, locator.name, version); const corepackFile = path.join(installFolder, `.corepack`); + let corepackContent; + try { + if (locatorIsASupportedPackageManager) { + corepackContent = await fs.promises.readFile(corepackFile, `utf8`); + } + } catch (err) { + if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) { + throw err; + } + } // Older versions of Corepack didn't generate the `.corepack` file; in // that case we just download the package manager anew. - if (fs.existsSync(corepackFile)) { - const corepackContent = await fs.promises.readFile(corepackFile, `utf8`); + if (corepackContent) { const corepackData = JSON.parse(corepackContent); debugUtils.log(`Reusing ${locator.name}@${locator.reference}`); @@ -123,13 +152,18 @@ export async function installVersion(installTarget: string, locator: Locator, {s }; } - const defaultNpmRegistryURL = spec.url.replace(`{}`, version); - const url = process.env.COREPACK_NPM_REGISTRY ? - defaultNpmRegistryURL.replace( - npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL, - () => process.env.COREPACK_NPM_REGISTRY!, - ) : - defaultNpmRegistryURL; + let url: string; + if (locatorIsASupportedPackageManager) { + const defaultNpmRegistryURL = spec.url.replace(`{}`, version); + url = process.env.COREPACK_NPM_REGISTRY ? + defaultNpmRegistryURL.replace( + npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL, + () => process.env.COREPACK_NPM_REGISTRY!, + ) : + defaultNpmRegistryURL; + } else { + url = decodeURIComponent(version); + } // Creating a temporary folder inside the install folder means that we // are sure it'll be in the same drive as the destination, so we can @@ -158,6 +192,14 @@ export async function installVersion(installTarget: string, locator: Locator, {s const hash = stream.pipe(createHash(algo)); await once(sendTo, `finish`); + if (!locatorIsASupportedPackageManager) { + if (ext === `.tgz`) { + spec.bin = require(path.join(tmpFolder, `package.json`)).bin; + } else if (ext === `.js`) { + spec.bin = [locator.name]; + } + } + const actualHash = hash.digest(`hex`); if (build[1] && actualHash !== build[1]) throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`); diff --git a/sources/specUtils.ts b/sources/specUtils.ts index dcf51f9b3..2e13802e6 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -12,16 +12,38 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t if (typeof raw !== `string`) throw new UsageError(`Invalid package manager specification in ${source}; expected a string`); - const match = raw.match(/^(?!_)([^@]+)(?:@(.+))?$/); - if (match === null || (enforceExactVersion && (!match[2] || !semver.valid(match[2])))) - throw new UsageError(`Invalid package manager specification in ${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`); + const atIndex = raw.indexOf(`@`); + + if (atIndex === -1 || atIndex === raw.length - 1) { + if (enforceExactVersion) + throw new UsageError(`No version specified for ${raw} in "packageManager" of ${source}`); + + const name = atIndex === -1 ? raw : raw.slice(0, -1); + if (!isSupportedPackageManager(name)) + throw new UsageError(`Unsupported package manager specification (${name})`); + + return { + name, range: `*`, + }; + } + + const name = raw.slice(0, atIndex); + const range = raw.slice(atIndex + 1); + + const isURL = URL.canParse(range); + if (!isURL) { + if (enforceExactVersion && !semver.valid(range)) + throw new UsageError(`Invalid package manager specification in ${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`); + + if (!isSupportedPackageManager(name)) { + throw new UsageError(`Unsupported package manager specification (${raw})`); + } + } - if (!isSupportedPackageManager(match[1])) - throw new UsageError(`Unsupported package manager specification (${match})`); return { - name: match[1], - range: match[2] ?? `*`, + name, + range, }; } diff --git a/sources/types.ts b/sources/types.ts index abf4cdb8b..994e654b9 100644 --- a/sources/types.ts +++ b/sources/types.ts @@ -96,7 +96,8 @@ export interface Config { * A structure containing the information needed to locate the package * manager to use for the active project. */ -export interface Descriptor { +export type Descriptor = SupportedPackageManagerDescriptor | URLDescriptor; +export interface SupportedPackageManagerDescriptor { /** * The name of the package manager required. */ @@ -107,11 +108,24 @@ export interface Descriptor { */ range: string; } +interface URLDescriptor { + /** + * The name of the package manager required. + */ + name: string; + + /** + * The range of versions allowed. + */ + range: string; +} /** * */ -export interface Locator { +export type Locator = SupportedPackageManagerLocator | URLLocator; + +export interface SupportedPackageManagerLocator { /** * The name of the package manager required. */ @@ -122,3 +136,7 @@ export interface Locator { */ reference: string; } +export interface URLLocator { + name: string; + reference: URL; +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 83bacb94c..3e4f8de07 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -63,10 +63,12 @@ it(`should require a version to be specified`, async () => { }); }); -const testedPackageManagers: Array<[string, string]> = [ +const testedPackageManagers: Array<[string, string] | [string, string, string]> = [ [`yarn`, `1.22.4`], [`yarn`, `1.22.4+sha1.01c1197ca5b27f21edc8bc472cd4c8ce0e5a470e`], [`yarn`, `1.22.4+sha224.0d6eecaf4d82ec12566fdd97143794d0f0c317e0d652bd4d1b305430`], + [`yarn`, `https://registry.npmjs.com/yarn/-/yarn-1.22.21.tgz`, `1.22.21`], + [`yarn`, `https://registry.npmjs.com/yarn/-/yarn-1.22.21.tgz#sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72`, `1.22.21`], [`yarn`, `2.0.0-rc.30`], [`yarn`, `2.0.0-rc.30+sha1.4f0423b01bcb57f8e390b4e0f1990831f92dd1da`], [`yarn`, `2.0.0-rc.30+sha224.0e7a64468c358596db21c401ffeb11b6534fce7367afd3ae640eadf1`], @@ -84,9 +86,15 @@ const testedPackageManagers: Array<[string, string]> = [ [`npm`, `6.14.2+sha224.50512c1eb404900ee78586faa6d756b8d867ff46a328e6fb4cdf3a87`], ]; -for (const [name, version] of testedPackageManagers) { +for (const [name, version, expectedVersion = version.split(`+`, 1)[0]] of testedPackageManagers) { it(`should use the right package manager version for a given project (${name}@${version})`, async () => { await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`${name}@${version}`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `${expectedVersion}\n`, + }); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { packageManager: `${name}@${version}`, }); @@ -94,7 +102,7 @@ for (const [name, version] of testedPackageManagers) { await expect(runCli(cwd, [name, `--version`])).resolves.toMatchObject({ exitCode: 0, stderr: ``, - stdout: `${version.split(`+`, 1)[0]}\n`, + stdout: `${expectedVersion}\n`, }); }); });