Skip to content

Commit

Permalink
feat: add support for URL in "packageManager" (#359)
Browse files Browse the repository at this point in the history
  • Loading branch information
aduh95 authored Feb 20, 2024
1 parent d9c70b9 commit 4a8ce6d
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 55 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -232,6 +243,10 @@ same major line. Should you need to upgrade to a new major, use an explicit
When standard input is a TTY and no CI environment is detected, Corepack will
ask for user input before starting the download.

- `COREPACK_ENABLE_UNSAFE_CUSTOM_URLS` can be set to `1` to allow use of
custom URLs to load a package manager known by Corepack (`yarn`, `npm`, and
`pnpm`).

- `COREPACK_ENABLE_NETWORK` can be set to `0` to prevent Corepack from accessing
the network (in which case you'll be responsible for hydrating the package
manager versions that will be required for the projects you'll run, using
Expand Down
38 changes: 33 additions & 5 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import * as debugUtils from './debugUtils
import * as folderUtils from './folderUtils';
import type {NodeError} from './nodeUtils';
import * as semverUtils from './semverUtils';
import {Config, Descriptor, Locator} from './types';
import {Config, Descriptor, Locator, PackageManagerSpec} from './types';
import {SupportedPackageManagers, SupportedPackageManagerSet} from './types';
import {isSupportedPackageManager} from './types';

export type PreparedPackageManagerInfo = Awaited<ReturnType<Engine[`ensurePackageManager`]>>;

Expand Down Expand Up @@ -79,8 +80,24 @@ export class Engine {
return null;
}

getPackageManagerSpecFor(locator: Locator) {
const definition = this.config.definitions[locator.name];
getPackageManagerSpecFor(locator: Locator): PackageManagerSpec {
if (!corepackUtils.isSupportedPackageManagerLocator(locator)) {
const url = `${locator.reference}`;
return {
url,
bin: undefined as any, // bin will be set later
registry: {
type: `url`,
url,
fields: {
tags: ``,
versions: ``,
},
},
};
}

const definition = this.config.definitions[locator.name as SupportedPackageManagers];
if (typeof definition === `undefined`)
throw new UsageError(`This package manager (${locator.name}) isn't supported by this corepack build`);

Expand Down Expand Up @@ -176,6 +193,7 @@ export class Engine {
const packageManagerInfo = await corepackUtils.installVersion(folderUtils.getInstallFolder(), locator, {
spec,
});
spec.bin ??= packageManagerInfo.bin;

return {
...packageManagerInfo,
Expand All @@ -188,8 +206,18 @@ export class Engine {

}

async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}) {
const definition = this.config.definitions[descriptor.name];
async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}): Promise<Locator | null> {
if (!corepackUtils.isSupportedPackageManagerDescriptor(descriptor)) {
if (process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS !== `1` && isSupportedPackageManager(descriptor.name))
throw new UsageError(`Illegal use of URL for known package manager. Instead, select a specific version, or set COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 in your environment (${descriptor.name}@${descriptor.range})`);

return {
name: descriptor.name,
reference: descriptor.range,
};
}

const definition = this.config.definitions[descriptor.name as SupportedPackageManagers];
if (typeof definition === `undefined`)
throw new UsageError(`This package manager (${descriptor.name}) isn't supported by this corepack build`);

Expand Down
12 changes: 7 additions & 5 deletions sources/commands/Up.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {Command, UsageError} from 'clipanion';
import semver from 'semver';
import {Command, UsageError} from 'clipanion';
import semver from 'semver';

import {BaseCommand} from './Base';
import type {SupportedPackageManagers} from '../types';

import {BaseCommand} from './Base';

export class UpCommand extends BaseCommand {
static paths = [
Expand Down Expand Up @@ -38,8 +40,8 @@ export class UpCommand extends BaseCommand {
if (!resolved)
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);

const majorVersion = semver.major(resolved?.reference);
const majorDescriptor = {name: descriptor.name, range: `^${majorVersion}.0.0`};
const majorVersion = semver.major(resolved.reference);
const majorDescriptor = {name: descriptor.name as SupportedPackageManagers, range: `^${majorVersion}.0.0`};

const highestVersion = await this.context.engine.resolveDescriptor(majorDescriptor, {useCache: false});
if (!highestVersion)
Expand Down
61 changes: 50 additions & 11 deletions sources/corepackUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,36 +104,63 @@ export async function findInstalledVersion(installTarget: string, descriptor: De
return bestMatch;
}

export function isSupportedPackageManagerDescriptor(descriptor: Descriptor) {
return !URL.canParse(descriptor.range);
}

export function isSupportedPackageManagerLocator(locator: Locator) {
return !URL.canParse(locator.reference);
}

function parseURLReference(locator: Locator) {
const {hash, href} = new URL(locator.reference);
if (hash) {
return {
version: encodeURIComponent(href.slice(0, -hash.length)),
build: hash.slice(1).split(`.`),
};
}
return {version: encodeURIComponent(href), build: []};
}

export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}) {
const locatorReference = semver.parse(locator.reference)!;
const locatorIsASupportedPackageManager = isSupportedPackageManagerLocator(locator);
const locatorReference = locatorIsASupportedPackageManager ? semver.parse(locator.reference)! : parseURLReference(locator);
const {version, build} = locatorReference;

const installFolder = path.join(installTarget, locator.name, version);

try {
const corepackFile = path.join(installFolder, `.corepack`);
const corepackContent = await fs.promises.readFile(corepackFile, `utf8`);

const corepackData = JSON.parse(corepackContent);

debugUtils.log(`Reusing ${locator.name}@${locator.reference}`);

return {
hash: corepackData.hash as string,
location: installFolder,
bin: corepackData.bin,
};
} catch (err) {
if ((err as nodeUtils.NodeError).code !== `ENOENT`) {
if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) {
throw err;
}
}

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
Expand Down Expand Up @@ -163,6 +190,15 @@ export async function installVersion(installTarget: string, locator: Locator, {s
const hash = stream.pipe(createHash(algo));
await once(sendTo, `finish`);

let bin;
if (!locatorIsASupportedPackageManager) {
if (ext === `.tgz`) {
bin = require(path.join(tmpFolder, `package.json`)).bin;
} else if (ext === `.js`) {
bin = [locator.name];
}
}

const actualHash = hash.digest(`hex`);
if (build[1] && actualHash !== build[1])
throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`);
Expand All @@ -171,6 +207,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s

await fs.promises.writeFile(path.join(tmpFolder, `.corepack`), JSON.stringify({
locator,
bin,
hash: serializedHash,
}));

Expand All @@ -190,15 +227,16 @@ export async function installVersion(installTarget: string, locator: Locator, {s
}
}

if (process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
if (locatorIsASupportedPackageManager && process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
let lastKnownGoodFile: FileHandle;
try {
lastKnownGoodFile = await engine.getLastKnownGoodFile(`r+`);
const lastKnownGood = await engine.getJSONFileContent(lastKnownGoodFile);
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
if (defaultVersion) {
const currentDefault = semver.parse(defaultVersion)!;
if (currentDefault.major === locatorReference.major && semver.lt(currentDefault, locatorReference)) {
const downloadedVersion = locatorReference as semver.SemVer;
if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) {
await engine.activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, locator);
}
}
Expand All @@ -217,6 +255,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s

return {
location: installFolder,
bin,
hash: serializedHash,
};
}
Expand Down
48 changes: 27 additions & 21 deletions sources/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context:
return null;

const [, binaryName, binaryVersion] = match;
const packageManager = context.engine.getPackageManagerFor(binaryName);
if (!packageManager)
return null;
const packageManager = context.engine.getPackageManagerFor(binaryName)!;

if (packageManager == null && binaryVersion == null) return null;

return {
packageManager,
Expand All @@ -48,28 +48,34 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context:
}

async function executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, args: Array<string>, context: Context) {
const defaultVersion = await context.engine.getDefaultVersion(packageManager);
const definition = context.engine.config.definitions[packageManager]!;

// If all leading segments match one of the patterns defined in the `transparent`
// key, we tolerate calling this binary even if the local project isn't explicitly
// configured for it, and we use the special default version if requested.
let fallbackLocator: Locator = {
name: binaryName as SupportedPackageManagers,
reference: undefined as any,
};
let isTransparentCommand = false;
for (const transparentPath of definition.transparent.commands) {
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
isTransparentCommand = true;
break;
if (packageManager != null) {
const defaultVersion = await context.engine.getDefaultVersion(packageManager);
const definition = context.engine.config.definitions[packageManager]!;

// If all leading segments match one of the patterns defined in the `transparent`
// key, we tolerate calling this binary even if the local project isn't explicitly
// configured for it, and we use the special default version if requested.
for (const transparentPath of definition.transparent.commands) {
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
isTransparentCommand = true;
break;
}
}
}

const fallbackReference = isTransparentCommand
? definition.transparent.default ?? defaultVersion
: defaultVersion;
const fallbackReference = isTransparentCommand
? definition.transparent.default ?? defaultVersion
: defaultVersion;

const fallbackLocator: Locator = {
name: packageManager,
reference: fallbackReference,
};
fallbackLocator = {
name: packageManager,
reference: fallbackReference,
};
}

let descriptor: Descriptor;
try {
Expand Down
40 changes: 32 additions & 8 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,40 @@ 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})`);
}
} else if (isSupportedPackageManager(name) && process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS !== `1`) {
throw new UsageError(`Illegal use of URL for known package manager. Instead, select a specific version, or set COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 in your environment (${raw})`);
}

if (!isSupportedPackageManager(match[1]))
throw new UsageError(`Unsupported package manager specification (${match})`);

return {
name: match[1],
range: match[2] ?? `*`,
name,
range,
};
}

Expand All @@ -43,7 +67,7 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t
*/
export async function findProjectSpec(initialCwd: string, locator: Locator, {transparent = false}: {transparent?: boolean} = {}): Promise<Descriptor> {
// A locator is a valid descriptor (but not the other way around)
const fallbackLocator = {name: locator.name, range: locator.reference};
const fallbackLocator = {name: locator.name, range: `${locator.reference}`};

if (process.env.COREPACK_ENABLE_PROJECT_SPEC === `0`)
return fallbackLocator;
Expand Down
4 changes: 2 additions & 2 deletions sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export interface Descriptor {
/**
* The name of the package manager required.
*/
name: SupportedPackageManagers;
name: string;

/**
* The range of versions allowed.
Expand All @@ -115,7 +115,7 @@ export interface Locator {
/**
* The name of the package manager required.
*/
name: SupportedPackageManagers;
name: string;

/**
* The exact version required.
Expand Down
Loading

0 comments on commit 4a8ce6d

Please sign in to comment.