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 limited support for devEngines #643

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ use in the archive).
}
```

#### `devEngines.packageManager`

When a `devEngines.packageManager` field is defined, and is an object containing
a `"name"` field (can also optionally contain `version` and `onFail` fields),
Corepack will use it to validate you're using a compatible package manager.

Depending on the value of `devEngines.packageManager.onFail`:

- if set to `ignore`, Corepack won't print any warning or error.
- if unset or set to `error`, Corepack will throw an error in case of a mismatch.
- if set to `warn` or some other value, Corepack will print a warning in case
of mismatch.

If the top-level `packageManager` field is missing, Corepack will use the
package manager defined in `devEngines.packageManager` – in which case you must
provide a specific version in `devEngines.packageManager.version`, ideally with
a hash, as explained in the previous section:

```json
{
"devEngines":{
"packageManager": {
"name": "yarn",
"version": "3.2.3+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa"
}
}
}
```

## Known Good Releases

When running Corepack within projects that don't list a supported package
Expand Down Expand Up @@ -246,6 +275,7 @@ it.

Unlike `corepack use` this command doesn't take a package manager name nor a
version range, as it will always select the latest available version from the
range specified in `devEngines.packageManager.version`, or fallback to the
same major line. Should you need to upgrade to a new major, use an explicit
`corepack use {name}@latest` call (or simply `corepack use {name}`).

Expand Down
2 changes: 1 addition & 1 deletion sources/commands/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export abstract class BaseCommand extends Command<Context> {
throw new UsageError(`The local project doesn't feature a 'packageManager' field - please explicit the package manager to pack, or update the manifest to reference it`);

default: {
return [lookup.spec];
return [lookup.range ?? lookup.spec];
}
}
}
Expand Down
79 changes: 76 additions & 3 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {UsageError} from 'clipanion';
import fs from 'fs';
import path from 'path';
import semverSatisfies from 'semver/functions/satisfies';
import semverValid from 'semver/functions/valid';
import semverValidRange from 'semver/ranges/valid';

import {PreparedPackageManagerInfo} from './Engine';
import * as debugUtils from './debugUtils';
Expand Down Expand Up @@ -52,6 +54,70 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t
};
}

type CorepackPackageJSON = {
packageManager?: string;
devEngines?: { packageManager?: DevEngineDependency };
};

interface DevEngineDependency {
name: string;
version: string;
onFail?: 'ignore' | 'warn' | 'error';
}
function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency['onFail']) {
switch (onFail) {
case `ignore`:
break;
case `error`:
case undefined:
throw new UsageError(errorMessage);
default:
console.warn(`! Corepack validation warning: ${errorMessage}`);
}
}
function parsePackageJSON(packageJSONContent: CorepackPackageJSON) {
if (packageJSONContent.devEngines?.packageManager != null) {
const {packageManager} = packageJSONContent.devEngines;

if (typeof packageManager !== `object`) {
console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(packageManager)}) will be ignored.`);
return packageJSONContent.packageManager;
}
if (Array.isArray(packageManager)) {
console.warn(`! Corepack does not currently support array values for devEngines.packageManager`);
return packageJSONContent.packageManager;
}

const {name, version, onFail} = packageManager;
if (typeof name !== `string` || name.includes(`@`)) {
warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail);
return packageJSONContent.packageManager;
}
if (version != null && (typeof version !== `string` || !semverValidRange(version))) {
warnOrThrow(`The value of devEngines.packageManager.version ${JSON.stringify(version)} is not a valid semver range`, onFail);
return packageJSONContent.packageManager;
}

debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`);

const {packageManager: pm} = packageJSONContent;
if (pm) {
if (!pm.startsWith(`${name}@`))
warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the "devEngines.packageManager" field set to ${JSON.stringify(name)}`, onFail);

else if (version != null && !semverSatisfies(pm.slice(packageManager.name.length + 1), version))
warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the value defined in "devEngines.packageManager" for ${JSON.stringify(name)} of ${JSON.stringify(version)}`, onFail);

return pm;
}


return `${name}@${version ?? `*`}`;
}

return packageJSONContent.packageManager;
}

export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) {
const lookup = await loadSpec(cwd);

Expand All @@ -75,7 +141,7 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM
export type LoadSpecResult =
| {type: `NoProject`, target: string}
| {type: `NoSpec`, target: string}
| {type: `Found`, target: string, spec: Descriptor};
| {type: `Found`, target: string, spec: Descriptor, range?: Descriptor};

export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
let nextCwd = initialCwd;
Expand Down Expand Up @@ -117,13 +183,20 @@ export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
if (selection === null)
return {type: `NoProject`, target: path.join(initialCwd, `package.json`)};

const rawPmSpec = selection.data.packageManager;
const rawPmSpec = parsePackageJSON(selection.data);
if (typeof rawPmSpec === `undefined`)
return {type: `NoSpec`, target: selection.manifestPath};

debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`);

const spec = parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath));
return {
type: `Found`,
target: selection.manifestPath,
spec: parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)),
spec,
range: selection.data.devEngines?.packageManager?.version && {
name: selection.data.devEngines.packageManager.name,
range: selection.data.devEngines.packageManager.version,
},
};
}
85 changes: 72 additions & 13 deletions tests/Up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,83 @@ beforeEach(async () => {
});

describe(`UpCommand`, () => {
it(`should upgrade the package manager from the current project`, async () => {
await xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
packageManager: `yarn@2.1.0`,
});
describe(`should update the "packageManager" field from the current project`, () => {
it(`to the same major if no devEngines range`, async () => {
await xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
packageManager: `yarn@2.1.0`,
});

await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
exitCode: 0,
stderr: ``,
});

await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
});

await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
exitCode: 0,
stderr: ``,
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `2.4.3\n`,
});
});
});

it(`to whichever range devEngines defines`, async () => {
await xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
packageManager: `yarn@1.1.0`,
devEngines: {
packageManager: {
name: `yarn`,
version: `1.x || 2.x`,
},
},
});

await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
exitCode: 0,
stderr: ``,
});

await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
});

await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `2.4.3\n`,
});
});
});

it(`to whichever range devEngines defines even if onFail is set to ignore`, async () => {
await xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
packageManager: `pnpm@10.1.0`,
devEngines: {
packageManager: {
name: `yarn`,
version: `1.x || 2.x`,
onFail: `ignore`,
},
},
});

await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
exitCode: 0,
stderr: ``,
});

await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
});

await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `2.4.3\n`,
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `2.4.3\n`,
});
});
});
});
Expand Down
Loading