diff --git a/README.md b/README.md index e2adeb949..9dcf7dbb1 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,18 @@ Package managers thus installed will be configured as the new default when calling their respective binaries outside of projects defining the `packageManager` field. +### `corepack list <-g,--global>` + +List the versions of npm, pnpm, and Yarn installed in the local `COREPACK_HOME` +cache, sorted by name and then by version (newest first). `corepack ls -g` and +`corepack -g ls` are equivalent. Add `--json` to print an array of objects with +`name`, `version`, and `isDefault` fields. + +The recorded global default from `lastKnownGood.json` is marked with `(default)`. +Only cached versions are listed; an uncached default is not included. This command +does not contact the registry, install anything, or update the defaults. It does +not report package managers installed outside Corepack or the project's selection. + ### `corepack pack [... name[@]]` | Option | Description | diff --git a/sources/commands/List.ts b/sources/commands/List.ts new file mode 100644 index 000000000..22f1674ad --- /dev/null +++ b/sources/commands/List.ts @@ -0,0 +1,90 @@ +import {Command, Option} from 'clipanion'; +import fs from 'fs'; +import path from 'path'; +import semverParse from 'semver/functions/parse.js'; +import semverRcompare from 'semver/functions/rcompare.js'; + +import {getLastKnownGood} from '../Engine.ts'; +import {getInstallFolder} from '../folderUtils.ts'; +import type {Context} from '../main.ts'; +import {isNodeError} from '../nodeUtils.ts'; +import {loadSpecAndEnv} from '../specUtils.ts'; +import {SupportedPackageManagerSet} from '../types.ts'; + +export class ListCommand extends Command { + static paths = [ + [`list`], + [`ls`], + ]; + + static usage = Command.Usage({ + description: `List installed package managers`, + details: ` + List locally cached versions of the supported package managers, marking + each recorded global default with \`(default)\`. This command does not + access the network or change the cache. + `, + examples: [[ + `List installed package managers and their recorded defaults`, + `corepack list -g`, + ]], + }); + + global = Option.Boolean(`-g,--global`, {required: true}); + + json = Option.Boolean(`--json`, false, { + description: `Print the installed versions as a JSON array`, + }); + + async execute() { + await loadSpecAndEnv(this.context.cwd, {envOnly: true}); + const lastKnownGood = await getLastKnownGood(); + const installed: Array<{name: string, version: string, isDefault: boolean}> = []; + + for (const name of SupportedPackageManagerSet) { + const folder = path.join(getInstallFolder(), name); + let entries; + try { + entries = await fs.promises.readdir(folder, {withFileTypes: true}); + } catch (error) { + if (isNodeError(error) && error.code === `ENOENT`) + continue; + throw error; + } + + const defaultVersion = semverParse(lastKnownGood[name])?.version; + for (const entry of entries) { + if (!entry.isDirectory() || !semverParse(entry.name)) + continue; + + // The metadata file is written only after a successful download. + let metadata; + try { + metadata = JSON.parse(await fs.promises.readFile(path.join(folder, entry.name, `.corepack`), `utf8`)); + } catch (error) { + if (error instanceof SyntaxError || (isNodeError(error) && error.code === `ENOENT`)) + continue; + throw error; + } + + const version = semverParse(metadata?.locator?.reference)?.version; + if (metadata?.locator?.name !== name || version !== entry.name) + continue; + + installed.push({name, version, isDefault: version === defaultVersion}); + } + } + + installed.sort((a, b) => a.name.localeCompare(b.name) || semverRcompare(a.version, b.version)); + + if (this.json) { + this.context.stdout.write(`${JSON.stringify(installed)}\n`); + } else if (installed.length === 0) { + this.context.stdout.write(`No package managers are installed in the Corepack cache.\n`); + } else { + for (const {name, version, isDefault} of installed) { + this.context.stdout.write(`${name}@${version}${isDefault ? ` (default)` : ``}\n`); + } + } + } +} diff --git a/sources/main.ts b/sources/main.ts index e623af1f5..488ef5f31 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -10,6 +10,7 @@ import {DisableCommand} from './commands/Disable.ts'; import {EnableCommand} from './commands/Enable.ts'; import {InstallGlobalCommand} from './commands/InstallGlobal.ts'; import {InstallLocalCommand} from './commands/InstallLocal.ts'; +import {ListCommand} from './commands/List.ts'; import {PackCommand} from './commands/Pack.ts'; import {UpCommand} from './commands/Up.ts'; import {UseCommand} from './commands/Use.ts'; @@ -67,6 +68,7 @@ export async function runMain(argv: Array) { cli.register(EnableCommand); cli.register(InstallGlobalCommand); cli.register(InstallLocalCommand); + cli.register(ListCommand); cli.register(PackCommand); cli.register(UpCommand); cli.register(UseCommand); diff --git a/tests/List.test.ts b/tests/List.test.ts new file mode 100644 index 000000000..037143aee --- /dev/null +++ b/tests/List.test.ts @@ -0,0 +1,136 @@ +import type {Filename} from '@yarnpkg/fslib'; +import {ppath, xfs, npath} from '@yarnpkg/fslib'; +import process from 'node:process'; +import {describe, beforeEach, it, expect} from 'vitest'; + +import {runCli} from './_runCli.ts'; + +beforeEach(async () => { + const home = await xfs.mktempPromise(); + process.env.COREPACK_HOME = npath.fromPortablePath(home); + process.env.COREPACK_ENABLE_NETWORK = `0`; + return async () => { + await xfs.removePromise(home, {recursive: true}); + }; +}); + +function getHome() { + return npath.toPortablePath(process.env.COREPACK_HOME!); +} + +async function cacheVersion(name: string, reference: string) { + const folder = ppath.join(getHome(), `v1`, name as Filename, reference.split(`+`)[0] as Filename); + await xfs.mkdirpPromise(folder); + await xfs.writeJsonPromise(ppath.join(folder, `.corepack`), { + locator: {name, reference}, + bin: {[name]: `bin.js`}, + hash: `sha512.deadbeef`, + }); + return folder; +} + +describe(`ListCommand`, () => { + it.each([ + [`list`, `-g`], + [`ls`, `--global`], + [`-g`, `ls`], + ])(`supports %j`, async (...args) => { + await cacheVersion(`pnpm`, `9.10.0`); + await cacheVersion(`pnpm`, `9.9.0`); + await cacheVersion(`npm`, `10.0.0`); + await cacheVersion(`yarn`, `4.0.0-rc.1`); + await cacheVersion(`yarn`, `4.0.0`); + await xfs.writeJsonPromise(ppath.join(getHome(), `lastKnownGood.json`), { + pnpm: `9.9.0+sha512.deadbeef`, + }); + + await expect(runCli(getHome(), args)).resolves.toEqual({ + exitCode: 0, + stdout: `npm@10.0.0\npnpm@9.10.0\npnpm@9.9.0 (default)\nyarn@4.0.0\nyarn@4.0.0-rc.1\n`, + stderr: ``, + }); + }); + + it(`lists installed versions as JSON without modifying defaults or the project`, async () => { + await cacheVersion(`pnpm`, `9.10.0+sha512.deadbeef`); + const defaults = {pnpm: `9.10.0`, yarn: `4.0.0`}; + const manifest = {packageManager: `yarn@3.0.0`}; + await xfs.writeJsonPromise(ppath.join(getHome(), `lastKnownGood.json`), defaults); + await xfs.writeJsonPromise(ppath.join(getHome(), `package.json`), manifest); + + const result = await runCli(getHome(), [`list`, `-g`, `--json`]); + expect(result).toMatchObject({exitCode: 0, stderr: ``}); + expect(JSON.parse(result.stdout)).toEqual([{name: `pnpm`, version: `9.10.0`, isDefault: true}]); + await expect(xfs.readJsonPromise(ppath.join(getHome(), `lastKnownGood.json`))).resolves.toEqual(defaults); + await expect(xfs.readJsonPromise(ppath.join(getHome(), `package.json`))).resolves.toEqual(manifest); + }); + + it(`does not create a missing cache`, async () => { + const cwd = getHome(); + process.env.COREPACK_HOME = npath.fromPortablePath(ppath.join(cwd, `missing`)); + await expect(runCli(cwd, [`list`, `-g`, `--json`])).resolves.toEqual({ + exitCode: 0, stdout: `[]\n`, stderr: ``, + }); + await expect(xfs.existsPromise(getHome())).resolves.toBe(false); + }); + + it(`loads COREPACK_HOME from the closest environment file`, async () => { + await cacheVersion(`pnpm`, `9.0.0`); + const cwd = getHome(); + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env`), `COREPACK_HOME=${JSON.stringify(process.env.COREPACK_HOME)}\n`); + delete process.env.COREPACK_HOME; + await expect(runCli(cwd, [`list`, `-g`])).resolves.toEqual({ + exitCode: 0, stdout: `pnpm@9.0.0\n`, stderr: ``, + }); + }); + + it(`explains an empty cache`, async () => { + await expect(runCli(getHome(), [`list`, `-g`])).resolves.toEqual({ + exitCode: 0, + stdout: `No package managers are installed in the Corepack cache.\n`, + stderr: ``, + }); + }); + + it(`ignores temporary, incomplete, and invalid cache entries`, async () => { + await cacheVersion(`pnpm`, `9.0.0`); + const folder = ppath.join(getHome(), `v1`, `pnpm`); + await xfs.mkdirPromise(ppath.join(folder, `10.0.0`)); + await xfs.mkdirPromise(ppath.join(folder, `.DS_Store`)); + await xfs.writeFilePromise(ppath.join(folder, `11.0.0`), ``); + for (const [version, content] of [ + [`12.0.0`, `{`], + [`13.0.0`, `null`], + [`14.0.0`, JSON.stringify({locator: {name: `yarn`, reference: `14.0.0`}})], + [`15.0.0`, JSON.stringify({locator: {name: `pnpm`, reference: `16.0.0`}})], + ]) { + const entry = await cacheVersion(`pnpm`, version); + await xfs.writeFilePromise(ppath.join(entry, `.corepack`), content); + } + await expect(runCli(getHome(), [`list`, `-g`])).resolves.toEqual({ + exitCode: 0, stdout: `pnpm@9.0.0\n`, stderr: ``, + }); + }); + + it(`tolerates invalid Last Known Good data`, async () => { + await cacheVersion(`pnpm`, `9.0.0`); + await xfs.writeFilePromise(ppath.join(getHome(), `lastKnownGood.json`), `{`); + await expect(runCli(getHome(), [`list`, `-g`])).resolves.toEqual({ + exitCode: 0, stdout: `pnpm@9.0.0\n`, stderr: ``, + }); + }); + + it(`lists versions installed by Corepack and updates after cache clean`, async () => { + delete process.env.COREPACK_ENABLE_NETWORK; + process.env.COREPACK_DEFAULT_TO_LATEST = `0`; + await expect(runCli(getHome(), [`install`, `-g`, `yarn@1.22.4`])).resolves.toMatchObject({exitCode: 0}); + process.env.COREPACK_ENABLE_NETWORK = `0`; + await expect(runCli(getHome(), [`ls`, `-g`])).resolves.toEqual({ + exitCode: 0, stdout: `yarn@1.22.4 (default)\n`, stderr: ``, + }); + await expect(runCli(getHome(), [`cache`, `clean`])).resolves.toMatchObject({exitCode: 0}); + await expect(runCli(getHome(), [`ls`, `-g`, `--json`])).resolves.toEqual({ + exitCode: 0, stdout: `[]\n`, stderr: ``, + }); + }); +});