Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,51 @@ 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}`).

## JavaScript API

Install Corepack as a dependency to use its version-resolution API without
spawning the CLI:

```js
import {
getSupportedPackageManagers,
parsePackageManagerSpec,
resolvePackageManager,
} from 'corepack/api';

getSupportedPackageManagers(); // ['npm', 'pnpm', 'yarn']
parsePackageManagerSpec('npm@latest'); // {name: 'npm', range: 'latest'}
await resolvePackageManager('yarn@3.2.3'); // {name: 'yarn', reference: '3.2.3'}
```

CommonJS callers can use `require('corepack/api')`. TypeScript declarations are
included. The API exports these functions and types:

- `getSupportedPackageManagers(): SupportedPackageManager[]` returns a new array
of the supported names; changing it does not affect Corepack.
- `parsePackageManagerSpec(raw, {enforceExactVersion = false} = {})` returns a
`PackageManagerDescriptor` with `name` and `range` fields. It accepts exact
versions (including hashes), semver ranges, registry tags, and bare names
(equivalent to `*`). Set `enforceExactVersion: true` to apply the same exact
version requirement as the project's `packageManager` field. Invalid input
throws an error. Parsing does not access the network or check release existence.
- `resolvePackageManager(raw, {allowTags = true, useCache = true} = {})` returns
a promise for a `PackageManagerLocator` with `name` and `reference` fields, or
`null` when no release matches a range. It prefers a cached matching version;
set `useCache: false` to query the registry for ranges. Tags always require a
registry lookup; set `allowTags: false` to reject them. Exact versions are
returned without checking whether the release exists. Registry and input
errors reject the promise.

Both functions accept the same custom URL specifications as the CLI, including
its `COREPACK_ENABLE_UNSAFE_CUSTOM_URLS` restriction for known package managers.
Resolving a URL returns it without fetching or validating its contents. Resolution
honors the process's Corepack environment variables, including `COREPACK_HOME`,
`COREPACK_NPM_REGISTRY`, authentication, and `COREPACK_ENABLE_NETWORK`. It does not
read project manifests or `.corepack.env` files, install or execute a package
manager, or update the Last Known Good versions. Importing the API has no CLI
side effects.

## Environment Variables

- `COREPACK_DEFAULT_TO_LATEST` can be set to `0` in order to instruct Corepack
Expand Down
32 changes: 32 additions & 0 deletions api.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type SupportedPackageManager = `npm` | `pnpm` | `yarn`;

export interface PackageManagerDescriptor {
name: string;
range: string;
}

export interface PackageManagerLocator {
name: string;
reference: string;
}

export interface ParsePackageManagerSpecOptions {
/** Require an exact version instead of accepting tags, ranges, or bare names. Defaults to false. */
enforceExactVersion?: boolean;
}

export interface ResolvePackageManagerOptions {
/** Allow registry tags such as `latest`. Defaults to true. */
allowTags?: boolean;
/** Prefer an installed version satisfying the requested range. Defaults to true. */
useCache?: boolean;
}

/** Return a new array of the package managers supported by this Corepack build. */
export function getSupportedPackageManagers(): Array<SupportedPackageManager>;

/** Parse a specification without network access. Throws on invalid input; does not check release existence. */
export function parsePackageManagerSpec(raw: unknown, options?: ParsePackageManagerSpecOptions): PackageManagerDescriptor;

/** Resolve a specification without installing or running it. Returns null if no version matches a range. */
export function resolvePackageManager(raw: unknown, options?: ResolvePackageManagerOptions): Promise<PackageManagerLocator | null>;
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"exports": {
"./api": {
"types": "./api.d.ts",
"default": "./dist/lib/api.cjs"
},
"./package.json": "./package.json"
},
"license": "MIT",
Expand Down Expand Up @@ -45,7 +49,7 @@
},
"scripts": {
"build": "run clean && run build:bundle && node ./mkshims.ts",
"build:bundle": "esbuild ./sources/_lib.ts --bundle --platform=node --target=node22.22.2 --external:corepack --outfile='./dist/lib/corepack.cjs' --resolve-extensions='.ts,.mjs,.js'",
"build:bundle": "esbuild corepack=./sources/_lib.ts api=./sources/api.ts --bundle --platform=node --target=node22.22.2 --external:corepack --outdir='./dist/lib' --out-extension:.js=.cjs --resolve-extensions='.ts,.mjs,.js'",
"clean": "run rimraf dist shims",
"corepack": "node ./sources/_cli.ts",
"lint": "eslint",
Expand All @@ -56,6 +60,7 @@
"test": "vitest"
},
"files": [
"api.d.ts",
"dist",
"shims",
"LICENSE.md"
Expand Down
20 changes: 20 additions & 0 deletions sources/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {ParsePackageManagerSpecOptions, ResolvePackageManagerOptions} from '../api.d.ts';
import type {PackageManagerDescriptor, PackageManagerLocator} from '../api.d.ts';
import type {SupportedPackageManager} from '../api.d.ts';

import {Engine} from './Engine.ts';
import {parseSpec} from './specUtils.ts';
import {SupportedPackageManagerSet} from './types.ts';

export function getSupportedPackageManagers(): Array<SupportedPackageManager> {
return [...SupportedPackageManagerSet];
}

export function parsePackageManagerSpec(raw: unknown, {enforceExactVersion = false}: ParsePackageManagerSpecOptions = {}): PackageManagerDescriptor {
return parseSpec(raw, `corepack/api`, {enforceExactVersion});
}

export async function resolvePackageManager(raw: unknown, {allowTags = true, useCache = true}: ResolvePackageManagerOptions = {}): Promise<PackageManagerLocator | null> {
const descriptor = parsePackageManagerSpec(raw);
return await new Engine().resolveDescriptor(descriptor, {allowTags, useCache});
}
32 changes: 32 additions & 0 deletions tests/ApiPackage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {expect, it} from 'vitest';

const exec = promisify(execFile);

it(`provides TypeScript declarations for CommonJS and ES module callers`, async () => {
const {stdout, stderr} = await exec(process.execPath, [
require.resolve(`typescript/bin/tsc`),
`--noEmit`, `--strict`, `--skipLibCheck`,
`--module`, `node16`, `--moduleResolution`, `node16`, `--target`, `es2022`,
`tests/types/api.mts`, `tests/types/api.cts`,
]);
expect(stdout).toBe(``);
expect(stderr).toBe(``);
});

it.each([`commonjs`, `module`])(`loads the public API from %s without invoking the CLI`, async moduleType => {
const script = moduleType === `module`
? `import {getSupportedPackageManagers, parsePackageManagerSpec, resolvePackageManager} from 'corepack/api';`
: `const {getSupportedPackageManagers, parsePackageManagerSpec, resolvePackageManager} = require('corepack/api');`;
const {stdout, stderr} = await exec(process.execPath, [`--input-type=${moduleType}`, `-e`, `${script}
const assert = ${moduleType === `module` ? `(await import('node:assert/strict')).default` : `require('node:assert/strict')`};
assert.deepEqual(getSupportedPackageManagers(), ['npm', 'pnpm', 'yarn']);
assert.deepEqual(parsePackageManagerSpec('npm@latest'), {name: 'npm', range: 'latest'});
resolvePackageManager('yarn@3.2.3').then(locator => {
assert.deepEqual(locator, {name: 'yarn', reference: '3.2.3'});
}).catch(error => { console.error(error); process.exitCode = 1; });
`]);
expect(stdout).toBe(``);
expect(stderr).toBe(``);
});
117 changes: 117 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {beforeEach, afterEach, describe, expect, it, vi} from 'vitest';

import {getSupportedPackageManagers, parsePackageManagerSpec} from '../sources/api.ts';
import {resolvePackageManager} from '../sources/api.ts';
import * as httpUtils from '../sources/httpUtils.ts';

vi.mock(`../sources/httpUtils.ts`);

let home: string;

beforeEach(async () => {
vi.resetAllMocks();
home = await fs.mkdtemp(path.join(os.tmpdir(), `corepack-api-`));
process.env.COREPACK_HOME = home;
});

afterEach(async () => {
await fs.rm(home, {recursive: true, force: true});
});

describe(`getSupportedPackageManagers`, () => {
it(`returns supported names without exposing the internal collection`, () => {
const names = getSupportedPackageManagers();
expect(names).toEqual([`npm`, `pnpm`, `yarn`]);
names.length = 0;
expect(getSupportedPackageManagers()).toEqual([`npm`, `pnpm`, `yarn`]);
});
});

describe(`parsePackageManagerSpec`, () => {
it.each([
[`yarn@3.2.3+sha224.abcdef`, {name: `yarn`, range: `3.2.3+sha224.abcdef`}],
[`npm@latest`, {name: `npm`, range: `latest`}],
[`pnpm@^10.0.0`, {name: `pnpm`, range: `^10.0.0`}],
[`yarn`, {name: `yarn`, range: `*`}],
])(`parses %s without network access`, (raw, expected) => {
expect(parsePackageManagerSpec(raw)).toEqual(expected);
expect(httpUtils.fetchAsJson).not.toHaveBeenCalled();
});

it.each([null, 123, {}, `unsupported@1.0.0`])(`rejects invalid input %j`, raw => {
expect(() => parsePackageManagerSpec(raw)).toThrow();
});

it(`supports exact-version validation`, () => {
expect(parsePackageManagerSpec(`npm@1.0.0`, {enforceExactVersion: true})).toEqual({name: `npm`, range: `1.0.0`});
for (const raw of [`npm`, `npm@latest`, `npm@^1.0.0`]) {
expect(() => parsePackageManagerSpec(raw, {enforceExactVersion: true})).toThrow();
}
});

it(`keeps the CLI's custom URL policy`, () => {
const url = `https://example.com/manager.tgz`;
expect(parsePackageManagerSpec(`custom@${url}`)).toEqual({name: `custom`, range: url});
expect(() => parsePackageManagerSpec(`yarn@${url}`)).toThrow(`Illegal use of URL`);
process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS = `1`;
expect(parsePackageManagerSpec(`yarn@${url}`)).toEqual({name: `yarn`, range: url});
});
});

describe(`resolvePackageManager`, () => {
it(`resolves exact versions with hashes offline without installing them`, async () => {
process.env.COREPACK_ENABLE_NETWORK = `0`;
await expect(resolvePackageManager(`pnpm@10.0.0+sha512.abcdef`)).resolves.toEqual({name: `pnpm`, reference: `10.0.0+sha512.abcdef`});
expect(httpUtils.fetchAsJson).not.toHaveBeenCalled();
expect(await fs.readdir(home)).toEqual([]);
});

it(`resolves tags using the configured registry without writing to the cache`, async () => {
process.env.COREPACK_NPM_REGISTRY = `https://example.com/registry`;
vi.mocked(httpUtils.fetchAsJson).mockResolvedValue({[`dist-tags`]: {latest: `10.0.0`}});
await expect(resolvePackageManager(`pnpm@latest`)).resolves.toEqual({name: `pnpm`, reference: `10.0.0`});
expect(httpUtils.fetchAsJson).toHaveBeenCalledWith(`https://example.com/registry/pnpm`, expect.anything());
expect(await fs.readdir(home)).toEqual([]);
});

it(`can disable tag resolution`, async () => {
await expect(resolvePackageManager(`npm@latest`, {allowTags: false})).rejects.toThrow(`can't be referenced via tags`);
expect(httpUtils.fetchAsJson).not.toHaveBeenCalled();
});

it(`prefers cached versions for ranges and supports bypassing the cache`, async () => {
await fs.mkdir(path.join(home, `v1`, `npm`, `1.0.0`), {recursive: true});
await expect(resolvePackageManager(`npm@^1.0.0`)).resolves.toEqual({name: `npm`, reference: `1.0.0`});
expect(httpUtils.fetchAsJson).not.toHaveBeenCalled();
vi.mocked(httpUtils.fetchAsJson).mockResolvedValue({versions: {"1.0.0": {}, "1.2.0": {}, "2.0.0": {}}});
await expect(resolvePackageManager(`npm@^1.0.0`, {useCache: false})).resolves.toEqual({name: `npm`, reference: `1.2.0`});
});

it(`returns null when no version satisfies a range`, async () => {
vi.mocked(httpUtils.fetchAsJson).mockResolvedValue({versions: {"2.0.0": {}}});
await expect(resolvePackageManager(`npm@^1.0.0`)).resolves.toBeNull();
});

it(`rejects invalid input, unknown tags, and registry errors`, async () => {
await expect(resolvePackageManager(`unsupported@1.0.0`)).rejects.toThrow(`Unsupported package manager`);
vi.mocked(httpUtils.fetchAsJson).mockResolvedValue({[`dist-tags`]: {}});
await expect(resolvePackageManager(`npm@missing`)).rejects.toThrow(`Tag not found`);
vi.mocked(httpUtils.fetchAsJson).mockRejectedValue(new Error(`registry unavailable`));
await expect(resolvePackageManager(`npm@latest`)).rejects.toThrow(`registry unavailable`);
});

it(`honors disabled network access for uncached ranges`, async () => {
process.env.COREPACK_ENABLE_NETWORK = `0`;
await expect(resolvePackageManager(`npm@^1.0.0`)).rejects.toThrow(`Network access disabled`);
expect(httpUtils.fetchAsJson).not.toHaveBeenCalled();
});

it(`returns custom URLs without fetching them`, async () => {
const url = `https://example.com/manager.tgz#sha224.abcdef`;
await expect(resolvePackageManager(`custom@${url}`)).resolves.toEqual({name: `custom`, reference: url});
expect(httpUtils.fetchAsJson).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions tests/types/api.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as api from 'corepack/api';

const managers: Array<api.SupportedPackageManager> = api.getSupportedPackageManagers();
const descriptor: api.PackageManagerDescriptor = api.parsePackageManagerSpec(`yarn@3.2.3`);
const locator: Promise<api.PackageManagerLocator | null> = api.resolvePackageManager(`yarn@3.2.3`);

void managers;
void descriptor;
void locator;
17 changes: 17 additions & 0 deletions tests/types/api.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {getSupportedPackageManagers, parsePackageManagerSpec, resolvePackageManager} from 'corepack/api';
import type {PackageManagerDescriptor, PackageManagerLocator} from 'corepack/api';
import type {SupportedPackageManager} from 'corepack/api';

const managers: Array<SupportedPackageManager> = getSupportedPackageManagers();
const descriptor: PackageManagerDescriptor = parsePackageManagerSpec(`npm@latest`, {enforceExactVersion: false});
const locator: Promise<PackageManagerLocator | null> = resolvePackageManager(`npm@latest`, {allowTags: true, useCache: false});

// @ts-expect-error Only supported manager names are allowed in this list.
managers.push(`unsupported`);
// @ts-expect-error The exact-version option is a boolean.
parsePackageManagerSpec(`npm@latest`, {enforceExactVersion: `true`});
// @ts-expect-error Resolution is asynchronous and may find no matching version.
const resolved: PackageManagerLocator = locator;

void descriptor;
void resolved;