Skip to content

Commit 4a8ce6d

Browse files
authored
feat: add support for URL in "packageManager" (#359)
1 parent d9c70b9 commit 4a8ce6d

11 files changed

+225
-55
lines changed

README.md

+15
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ along with the SHA-224 hash of this version for validation.
8181
recommended as a security practice. Permitted values for the package manager are
8282
`yarn`, `npm`, and `pnpm`.
8383

84+
You can also provide a URL to a `.js` file (which will be interpreted as a
85+
CommonJS module) or a `.tgz` file (which will be interpreted as a package, and
86+
the `"bin"` field of the `package.json` will be used to determine which file to
87+
use in the archive).
88+
89+
```json
90+
{
91+
"packageManager": "yarn@https://registry.npmjs.org/@yarnpkg/cli-dist/-/cli-dist-3.2.3.tgz#sha224.16a0797d1710d1fb7ec40ab5c3801b68370a612a9b66ba117ad9924b"
92+
}
93+
```
94+
8495
## Known Good Releases
8596

8697
When running Corepack within projects that don't list a supported package
@@ -232,6 +243,10 @@ same major line. Should you need to upgrade to a new major, use an explicit
232243
When standard input is a TTY and no CI environment is detected, Corepack will
233244
ask for user input before starting the download.
234245

246+
- `COREPACK_ENABLE_UNSAFE_CUSTOM_URLS` can be set to `1` to allow use of
247+
custom URLs to load a package manager known by Corepack (`yarn`, `npm`, and
248+
`pnpm`).
249+
235250
- `COREPACK_ENABLE_NETWORK` can be set to `0` to prevent Corepack from accessing
236251
the network (in which case you'll be responsible for hydrating the package
237252
manager versions that will be required for the projects you'll run, using

sources/Engine.ts

+33-5
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import * as debugUtils from './debugUtils
1212
import * as folderUtils from './folderUtils';
1313
import type {NodeError} from './nodeUtils';
1414
import * as semverUtils from './semverUtils';
15-
import {Config, Descriptor, Locator} from './types';
15+
import {Config, Descriptor, Locator, PackageManagerSpec} from './types';
1616
import {SupportedPackageManagers, SupportedPackageManagerSet} from './types';
17+
import {isSupportedPackageManager} from './types';
1718

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

@@ -79,8 +80,24 @@ export class Engine {
7980
return null;
8081
}
8182

82-
getPackageManagerSpecFor(locator: Locator) {
83-
const definition = this.config.definitions[locator.name];
83+
getPackageManagerSpecFor(locator: Locator): PackageManagerSpec {
84+
if (!corepackUtils.isSupportedPackageManagerLocator(locator)) {
85+
const url = `${locator.reference}`;
86+
return {
87+
url,
88+
bin: undefined as any, // bin will be set later
89+
registry: {
90+
type: `url`,
91+
url,
92+
fields: {
93+
tags: ``,
94+
versions: ``,
95+
},
96+
},
97+
};
98+
}
99+
100+
const definition = this.config.definitions[locator.name as SupportedPackageManagers];
84101
if (typeof definition === `undefined`)
85102
throw new UsageError(`This package manager (${locator.name}) isn't supported by this corepack build`);
86103

@@ -176,6 +193,7 @@ export class Engine {
176193
const packageManagerInfo = await corepackUtils.installVersion(folderUtils.getInstallFolder(), locator, {
177194
spec,
178195
});
196+
spec.bin ??= packageManagerInfo.bin;
179197

180198
return {
181199
...packageManagerInfo,
@@ -188,8 +206,18 @@ export class Engine {
188206

189207
}
190208

191-
async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}) {
192-
const definition = this.config.definitions[descriptor.name];
209+
async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}): Promise<Locator | null> {
210+
if (!corepackUtils.isSupportedPackageManagerDescriptor(descriptor)) {
211+
if (process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS !== `1` && isSupportedPackageManager(descriptor.name))
212+
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})`);
213+
214+
return {
215+
name: descriptor.name,
216+
reference: descriptor.range,
217+
};
218+
}
219+
220+
const definition = this.config.definitions[descriptor.name as SupportedPackageManagers];
193221
if (typeof definition === `undefined`)
194222
throw new UsageError(`This package manager (${descriptor.name}) isn't supported by this corepack build`);
195223

sources/commands/Up.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import {Command, UsageError} from 'clipanion';
2-
import semver from 'semver';
1+
import {Command, UsageError} from 'clipanion';
2+
import semver from 'semver';
33

4-
import {BaseCommand} from './Base';
4+
import type {SupportedPackageManagers} from '../types';
5+
6+
import {BaseCommand} from './Base';
57

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

41-
const majorVersion = semver.major(resolved?.reference);
42-
const majorDescriptor = {name: descriptor.name, range: `^${majorVersion}.0.0`};
43+
const majorVersion = semver.major(resolved.reference);
44+
const majorDescriptor = {name: descriptor.name as SupportedPackageManagers, range: `^${majorVersion}.0.0`};
4345

4446
const highestVersion = await this.context.engine.resolveDescriptor(majorDescriptor, {useCache: false});
4547
if (!highestVersion)

sources/corepackUtils.ts

+50-11
Original file line numberDiff line numberDiff line change
@@ -104,36 +104,63 @@ export async function findInstalledVersion(installTarget: string, descriptor: De
104104
return bestMatch;
105105
}
106106

107+
export function isSupportedPackageManagerDescriptor(descriptor: Descriptor) {
108+
return !URL.canParse(descriptor.range);
109+
}
110+
111+
export function isSupportedPackageManagerLocator(locator: Locator) {
112+
return !URL.canParse(locator.reference);
113+
}
114+
115+
function parseURLReference(locator: Locator) {
116+
const {hash, href} = new URL(locator.reference);
117+
if (hash) {
118+
return {
119+
version: encodeURIComponent(href.slice(0, -hash.length)),
120+
build: hash.slice(1).split(`.`),
121+
};
122+
}
123+
return {version: encodeURIComponent(href), build: []};
124+
}
125+
107126
export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}) {
108-
const locatorReference = semver.parse(locator.reference)!;
127+
const locatorIsASupportedPackageManager = isSupportedPackageManagerLocator(locator);
128+
const locatorReference = locatorIsASupportedPackageManager ? semver.parse(locator.reference)! : parseURLReference(locator);
109129
const {version, build} = locatorReference;
110130

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

113133
try {
114134
const corepackFile = path.join(installFolder, `.corepack`);
115135
const corepackContent = await fs.promises.readFile(corepackFile, `utf8`);
136+
116137
const corepackData = JSON.parse(corepackContent);
117138

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

120141
return {
121142
hash: corepackData.hash as string,
122143
location: installFolder,
144+
bin: corepackData.bin,
123145
};
124146
} catch (err) {
125-
if ((err as nodeUtils.NodeError).code !== `ENOENT`) {
147+
if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) {
126148
throw err;
127149
}
128150
}
129151

130-
const defaultNpmRegistryURL = spec.url.replace(`{}`, version);
131-
const url = process.env.COREPACK_NPM_REGISTRY ?
132-
defaultNpmRegistryURL.replace(
133-
npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL,
134-
() => process.env.COREPACK_NPM_REGISTRY!,
135-
) :
136-
defaultNpmRegistryURL;
152+
let url: string;
153+
if (locatorIsASupportedPackageManager) {
154+
const defaultNpmRegistryURL = spec.url.replace(`{}`, version);
155+
url = process.env.COREPACK_NPM_REGISTRY ?
156+
defaultNpmRegistryURL.replace(
157+
npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL,
158+
() => process.env.COREPACK_NPM_REGISTRY!,
159+
) :
160+
defaultNpmRegistryURL;
161+
} else {
162+
url = decodeURIComponent(version);
163+
}
137164

138165
// Creating a temporary folder inside the install folder means that we
139166
// are sure it'll be in the same drive as the destination, so we can
@@ -163,6 +190,15 @@ export async function installVersion(installTarget: string, locator: Locator, {s
163190
const hash = stream.pipe(createHash(algo));
164191
await once(sendTo, `finish`);
165192

193+
let bin;
194+
if (!locatorIsASupportedPackageManager) {
195+
if (ext === `.tgz`) {
196+
bin = require(path.join(tmpFolder, `package.json`)).bin;
197+
} else if (ext === `.js`) {
198+
bin = [locator.name];
199+
}
200+
}
201+
166202
const actualHash = hash.digest(`hex`);
167203
if (build[1] && actualHash !== build[1])
168204
throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`);
@@ -171,6 +207,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s
171207

172208
await fs.promises.writeFile(path.join(tmpFolder, `.corepack`), JSON.stringify({
173209
locator,
210+
bin,
174211
hash: serializedHash,
175212
}));
176213

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

193-
if (process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
230+
if (locatorIsASupportedPackageManager && process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
194231
let lastKnownGoodFile: FileHandle;
195232
try {
196233
lastKnownGoodFile = await engine.getLastKnownGoodFile(`r+`);
197234
const lastKnownGood = await engine.getJSONFileContent(lastKnownGoodFile);
198235
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
199236
if (defaultVersion) {
200237
const currentDefault = semver.parse(defaultVersion)!;
201-
if (currentDefault.major === locatorReference.major && semver.lt(currentDefault, locatorReference)) {
238+
const downloadedVersion = locatorReference as semver.SemVer;
239+
if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) {
202240
await engine.activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, locator);
203241
}
204242
}
@@ -217,6 +255,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s
217255

218256
return {
219257
location: installFolder,
258+
bin,
220259
hash: serializedHash,
221260
};
222261
}

sources/main.ts

+27-21
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context:
3636
return null;
3737

3838
const [, binaryName, binaryVersion] = match;
39-
const packageManager = context.engine.getPackageManagerFor(binaryName);
40-
if (!packageManager)
41-
return null;
39+
const packageManager = context.engine.getPackageManagerFor(binaryName)!;
40+
41+
if (packageManager == null && binaryVersion == null) return null;
4242

4343
return {
4444
packageManager,
@@ -48,28 +48,34 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context:
4848
}
4949

5050
async function executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, args: Array<string>, context: Context) {
51-
const defaultVersion = await context.engine.getDefaultVersion(packageManager);
52-
const definition = context.engine.config.definitions[packageManager]!;
53-
54-
// If all leading segments match one of the patterns defined in the `transparent`
55-
// key, we tolerate calling this binary even if the local project isn't explicitly
56-
// configured for it, and we use the special default version if requested.
51+
let fallbackLocator: Locator = {
52+
name: binaryName as SupportedPackageManagers,
53+
reference: undefined as any,
54+
};
5755
let isTransparentCommand = false;
58-
for (const transparentPath of definition.transparent.commands) {
59-
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
60-
isTransparentCommand = true;
61-
break;
56+
if (packageManager != null) {
57+
const defaultVersion = await context.engine.getDefaultVersion(packageManager);
58+
const definition = context.engine.config.definitions[packageManager]!;
59+
60+
// If all leading segments match one of the patterns defined in the `transparent`
61+
// key, we tolerate calling this binary even if the local project isn't explicitly
62+
// configured for it, and we use the special default version if requested.
63+
for (const transparentPath of definition.transparent.commands) {
64+
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
65+
isTransparentCommand = true;
66+
break;
67+
}
6268
}
63-
}
6469

65-
const fallbackReference = isTransparentCommand
66-
? definition.transparent.default ?? defaultVersion
67-
: defaultVersion;
70+
const fallbackReference = isTransparentCommand
71+
? definition.transparent.default ?? defaultVersion
72+
: defaultVersion;
6873

69-
const fallbackLocator: Locator = {
70-
name: packageManager,
71-
reference: fallbackReference,
72-
};
74+
fallbackLocator = {
75+
name: packageManager,
76+
reference: fallbackReference,
77+
};
78+
}
7379

7480
let descriptor: Descriptor;
7581
try {

sources/specUtils.ts

+32-8
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,40 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t
1212
if (typeof raw !== `string`)
1313
throw new UsageError(`Invalid package manager specification in ${source}; expected a string`);
1414

15-
const match = raw.match(/^(?!_)([^@]+)(?:@(.+))?$/);
16-
if (match === null || (enforceExactVersion && (!match[2] || !semver.valid(match[2]))))
17-
throw new UsageError(`Invalid package manager specification in ${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`);
15+
const atIndex = raw.indexOf(`@`);
16+
17+
if (atIndex === -1 || atIndex === raw.length - 1) {
18+
if (enforceExactVersion)
19+
throw new UsageError(`No version specified for ${raw} in "packageManager" of ${source}`);
20+
21+
const name = atIndex === -1 ? raw : raw.slice(0, -1);
22+
if (!isSupportedPackageManager(name))
23+
throw new UsageError(`Unsupported package manager specification (${name})`);
24+
25+
return {
26+
name, range: `*`,
27+
};
28+
}
29+
30+
const name = raw.slice(0, atIndex);
31+
const range = raw.slice(atIndex + 1);
32+
33+
const isURL = URL.canParse(range);
34+
if (!isURL) {
35+
if (enforceExactVersion && !semver.valid(range))
36+
throw new UsageError(`Invalid package manager specification in ${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`);
37+
38+
if (!isSupportedPackageManager(name)) {
39+
throw new UsageError(`Unsupported package manager specification (${raw})`);
40+
}
41+
} else if (isSupportedPackageManager(name) && process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS !== `1`) {
42+
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})`);
43+
}
1844

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

2246
return {
23-
name: match[1],
24-
range: match[2] ?? `*`,
47+
name,
48+
range,
2549
};
2650
}
2751

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

4872
if (process.env.COREPACK_ENABLE_PROJECT_SPEC === `0`)
4973
return fallbackLocator;

sources/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export interface Descriptor {
100100
/**
101101
* The name of the package manager required.
102102
*/
103-
name: SupportedPackageManagers;
103+
name: string;
104104

105105
/**
106106
* The range of versions allowed.
@@ -115,7 +115,7 @@ export interface Locator {
115115
/**
116116
* The name of the package manager required.
117117
*/
118-
name: SupportedPackageManagers;
118+
name: string;
119119

120120
/**
121121
* The exact version required.

0 commit comments

Comments
 (0)