diff --git a/README.md b/README.md index e2adeb949..a4ad7f909 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api.d.ts b/api.d.ts new file mode 100644 index 000000000..4d21f35e3 --- /dev/null +++ b/api.d.ts @@ -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; + +/** 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; diff --git a/package.json b/package.json index fc7234995..49b89f817 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -56,6 +60,7 @@ "test": "vitest" }, "files": [ + "api.d.ts", "dist", "shims", "LICENSE.md" diff --git a/sources/api.ts b/sources/api.ts new file mode 100644 index 000000000..28adc01f6 --- /dev/null +++ b/sources/api.ts @@ -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 { + 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 { + const descriptor = parsePackageManagerSpec(raw); + return await new Engine().resolveDescriptor(descriptor, {allowTags, useCache}); +} diff --git a/tests/ApiPackage.test.ts b/tests/ApiPackage.test.ts new file mode 100644 index 000000000..d8d7d64e5 --- /dev/null +++ b/tests/ApiPackage.test.ts @@ -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(``); +}); diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 000000000..366eddb7d --- /dev/null +++ b/tests/api.test.ts @@ -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(); + }); +}); diff --git a/tests/types/api.cts b/tests/types/api.cts new file mode 100644 index 000000000..288eff0a9 --- /dev/null +++ b/tests/types/api.cts @@ -0,0 +1,9 @@ +import * as api from 'corepack/api'; + +const managers: Array = api.getSupportedPackageManagers(); +const descriptor: api.PackageManagerDescriptor = api.parsePackageManagerSpec(`yarn@3.2.3`); +const locator: Promise = api.resolvePackageManager(`yarn@3.2.3`); + +void managers; +void descriptor; +void locator; diff --git a/tests/types/api.mts b/tests/types/api.mts new file mode 100644 index 000000000..3be77526f --- /dev/null +++ b/tests/types/api.mts @@ -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 = getSupportedPackageManagers(); +const descriptor: PackageManagerDescriptor = parsePackageManagerSpec(`npm@latest`, {enforceExactVersion: false}); +const locator: Promise = 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;