Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for URL in "packageManager" #359

Merged
merged 20 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
}
```

## 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
44 changes: 37 additions & 7 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,7 +80,23 @@ export class Engine {
return null;
}

getPackageManagerSpecFor(locator: Locator) {
getPackageManagerSpecFor(locator: Locator): PackageManagerSpec {
if (!corepackUtils.isSupportedPackageManagerLocator(locator)) {
const url = `${locator.reference}`;
return {
url,
bin: {},
registry: {
type: `url`,
url,
fields: {
tags: ``,
versions: ``,
},
},
};
}

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`);
Expand Down Expand Up @@ -112,7 +129,7 @@ export class Engine {
const locators: Array<Descriptor> = [];

for (const name of SupportedPackageManagerSet as Set<SupportedPackageManagers>)
locators.push({name, range: await this.getDefaultVersion(name)});
locators.push({name, range: await this.getDefaultVersion(name), isURL: false});

return locators;
}
Expand Down Expand Up @@ -145,6 +162,7 @@ export class Engine {
await activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, {
name: packageManager,
reference,
isURL: false,
});

return reference;
Expand Down Expand Up @@ -188,7 +206,18 @@ export class Engine {

}

async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}) {
async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}): Promise<Locator | null> {
if (!corepackUtils.isNotURLDescriptor(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,
isURL: true,
};
}

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`);
Expand All @@ -212,19 +241,20 @@ export class Engine {
finalDescriptor = {
name: descriptor.name,
range: tags[descriptor.range],
isURL: false,
};
}

// If a compatible version is already installed, no need to query one
// from the remote listings
const cachedVersion = await corepackUtils.findInstalledVersion(folderUtils.getInstallFolder(), finalDescriptor);
if (cachedVersion !== null && useCache)
return {name: finalDescriptor.name, reference: cachedVersion};
return {name: finalDescriptor.name, reference: cachedVersion, isURL: false};

// If the user asked for a specific version, no need to request the list of
// available versions from the registry.
if (semver.valid(finalDescriptor.range))
return {name: finalDescriptor.name, reference: finalDescriptor.range};
return {name: finalDescriptor.name, reference: finalDescriptor.range, isURL: false};

const versions = await Promise.all(Object.keys(definition.ranges).map(async range => {
const packageManagerSpec = definition.ranges[range];
Expand All @@ -238,6 +268,6 @@ export class Engine {
if (highestVersion.length === 0)
return null;

return {name: finalDescriptor.name, reference: highestVersion[0]};
return {name: finalDescriptor.name, reference: highestVersion[0], isURL: false};
}
}
4 changes: 2 additions & 2 deletions sources/commands/InstallGlobal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,15 @@ export class InstallGlobalCommand extends BaseCommand {
if (!isSupportedPackageManager(name))
throw new UsageError(`Unsupported package manager '${name}'`);

this.log({name, reference});
this.log({name, reference, isURL: false});

// Recreate the folder in case it was deleted somewhere else:
await fs.promises.mkdir(installFolder, {recursive: true});

await tar.x({file: p, cwd: installFolder}, [`${name}/${reference}`]);

if (!this.cacheOnly) {
await this.context.engine.activatePackageManager({name, reference});
await this.context.engine.activatePackageManager({name, reference, isURL: false});
}
}
}
Expand Down
11 changes: 6 additions & 5 deletions sources/commands/Up.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Command, UsageError} from 'clipanion';
import semver from 'semver';
import {Command, UsageError} from 'clipanion';
import semver from 'semver';
import type {SupportedPackageManagerLocator, SupportedPackageManagers} from 'sources/types';

import {BaseCommand} from './Base';
import {BaseCommand} from './Base';

export class UpCommand extends BaseCommand {
static paths = [
Expand Down Expand Up @@ -39,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 as SupportedPackageManagerLocator)?.reference);
const majorDescriptor = {name: descriptor.name as SupportedPackageManagers, range: `^${majorVersion}.0.0`, isURL: false};

const highestVersion = await this.context.engine.resolveDescriptor(majorDescriptor, {useCache: false});
if (!highestVersion)
Expand Down
2 changes: 1 addition & 1 deletion sources/commands/deprecated/Hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class HydrateCommand extends Command<Context> {
await tar.x({file: fileName, cwd: installFolder}, [`${name}/${reference}`]);

if (this.activate) {
await this.context.engine.activatePackageManager({name, reference});
await this.context.engine.activatePackageManager({name, reference, isURL: false});
}
}
}
Expand Down
77 changes: 60 additions & 17 deletions sources/corepackUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import * as httpUtils from './httpUtils
import * as nodeUtils from './nodeUtils';
import * as npmRegistryUtils from './npmRegistryUtils';
import {RegistrySpec, Descriptor, Locator, PackageManagerSpec} from './types';
import {SupportedPackageManagerDescriptor} from './types';
import {SupportedPackageManagerLocator, URLLocator} from './types';

export function getRegistryFromPackageManagerSpec(spec: PackageManagerSpec) {
return process.env.COREPACK_NPM_REGISTRY
Expand Down Expand Up @@ -104,15 +106,46 @@ export async function findInstalledVersion(installTarget: string, descriptor: De
return bestMatch;
}

export function isNotURLDescriptor(descriptor: Descriptor): descriptor is SupportedPackageManagerDescriptor {
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
return !URL.canParse(descriptor.range);
}

export function isSupportedPackageManagerLocator(locator: Locator): locator is SupportedPackageManagerLocator {
return !locator.isURL;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this isURL property? Why not just do a URL.canParse(locator.reference) like we do with descriptors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's to avoid calling URL.canParse more than one – it's a way to cache the value rather than re-compute it every time we need it.

Copy link
Contributor

@arcanis arcanis Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That feels a little unnecessary imo, we're only going to make a single such check per command, I doubt it'd have a significant perf impact compared to the complexity cost.

I'd also tend to just check whether the string startsWith('https://') rather than validate the full correctness - validating everything feels a little too susceptible to typos (what if I add a character that makes the string an invalid url? should it go in the semver path?).

Copy link
Contributor Author

@aduh95 aduh95 Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if I add a character that makes the string an invalid url? should it go in the semver path?

In all likeliness, an invalid URL due to a typo won't be a valid semver version either, so I don't think it matters.

validating everything feels a little too susceptible to typos

Well we can only work with well formed reference (either semver or URL), so I'm not sure where you going with that, I don't see how we could be forgiving, we need full correctness.

That feels a little unnecessary imo, we're only going to make a single such check per command, I doubt it'd have a significant perf impact compared to the complexity cost.

I feel like I'm missing something, I don't see how it can be simplified (except maybe by using URL instances) – because otherwise TS cannot differenciate between the URL-based spec and the semver-based ones.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because otherwise TS cannot differenciate between the URL-based spec and the semver-based ones.

Why does it need to differenciate? That's the same thing we do in Yarn: we treat all ranges the same (as string), and we just branch their behaviour by runtime pattern matching. I don't see what we gain by using strict typing here.

}

function parseURLReference(locator: URLLocator) {
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);

let corepackContent;
try {
const corepackFile = path.join(installFolder, `.corepack`);
const corepackContent = await fs.promises.readFile(corepackFile, `utf8`);
if (locatorIsASupportedPackageManager) {
aduh95 marked this conversation as resolved.
Show resolved Hide resolved
const corepackFile = path.join(installFolder, `.corepack`);
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 (corepackContent) {
const corepackData = JSON.parse(corepackContent);

debugUtils.log(`Reusing ${locator.name}@${locator.reference}`);
Expand All @@ -121,19 +154,20 @@ export async function installVersion(installTarget: string, locator: Locator, {s
hash: corepackData.hash as string,
location: installFolder,
};
} catch (err) {
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 +197,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}`);
Expand Down Expand Up @@ -190,15 +232,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 Down Expand Up @@ -249,7 +292,7 @@ export async function runVersion(locator: Locator, installSpec: { location: stri
// Node.js segfaults when using npm@>=9.7.0 and v8-compile-cache
// $ docker run -it node:20.3.0-slim corepack [email protected] --version
// [SIGSEGV]
if (locator.name !== `npm` || semver.lt(locator.reference, `9.7.0`))
if (locator.name !== `npm` || semver.lt((locator as SupportedPackageManagerLocator).reference, `9.7.0`))
// @ts-expect-error - No types
await import(`v8-compile-cache`);

Expand Down
50 changes: 29 additions & 21 deletions sources/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,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 @@ -47,28 +47,36 @@ 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,
isURL: false,
};
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,
isURL: false,
};
}

let descriptor: Descriptor;
try {
Expand Down
Loading