From f60d6c46f0c97771799884300d1fe7884fca06d0 Mon Sep 17 00:00:00 2001 From: chlorine Date: Sun, 13 Nov 2022 06:41:48 +0800 Subject: [PATCH] feat: support `pnpm env list` to list global or remote Node.js versions (#5625) close #5546 Co-authored-by: Zoltan Kochan --- .changeset/orange-seahorses-attack.md | 6 +++ .changeset/six-cats-travel.md | 5 ++ packages/node.resolver/src/index.ts | 52 ++++++++++++++---- .../test/resolveNodeVersions.test.ts | 20 +++++++ packages/plugin-commands-env/package.json | 2 + packages/plugin-commands-env/src/env.ts | 51 +++++++++++++----- packages/plugin-commands-env/src/envList.ts | 38 +++++++++++++ packages/plugin-commands-env/src/node.ts | 4 +- packages/plugin-commands-env/src/utils.ts | 21 ++++++++ packages/plugin-commands-env/test/env.test.ts | 54 +++++++++++++++++++ pnpm-lock.yaml | 6 +++ 11 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 .changeset/orange-seahorses-attack.md create mode 100644 .changeset/six-cats-travel.md create mode 100644 packages/node.resolver/test/resolveNodeVersions.test.ts create mode 100644 packages/plugin-commands-env/src/envList.ts create mode 100644 packages/plugin-commands-env/src/utils.ts diff --git a/.changeset/orange-seahorses-attack.md b/.changeset/orange-seahorses-attack.md new file mode 100644 index 00000000000..f72582b8b17 --- /dev/null +++ b/.changeset/orange-seahorses-attack.md @@ -0,0 +1,6 @@ +--- +"pnpm": minor +"@pnpm/plugin-commands-env": minor +--- + +Support `pnpm env list` to list global or remote Node.js versions [#5546](https://github.com/pnpm/pnpm/issues/5546). diff --git a/.changeset/six-cats-travel.md b/.changeset/six-cats-travel.md new file mode 100644 index 00000000000..8c2b0745a0d --- /dev/null +++ b/.changeset/six-cats-travel.md @@ -0,0 +1,5 @@ +--- +"@pnpm/node.resolver": minor +--- + +Export a new function: resolveNodeVersions. diff --git a/packages/node.resolver/src/index.ts b/packages/node.resolver/src/index.ts index 2ea875fb89e..a553bb767eb 100644 --- a/packages/node.resolver/src/index.ts +++ b/packages/node.resolver/src/index.ts @@ -7,27 +7,54 @@ interface NodeVersion { lts: false | string } +const SEMVER_OPTS = { + includePrerelease: true, + loose: true, +} + export async function resolveNodeVersion ( fetch: FetchFromRegistry, versionSpec: string, nodeMirrorBaseUrl?: string ): Promise { - const response = await fetch(`${nodeMirrorBaseUrl ?? 'https://nodejs.org/download/release/'}index.json`) - const allVersions = (await response.json()) as NodeVersion[] + const allVersions = await fetchAllVersions(fetch, nodeMirrorBaseUrl) if (versionSpec === 'latest') { - return allVersions[0].version.substring(1) + return allVersions[0].version } const { versions, versionRange } = filterVersions(allVersions, versionSpec) - const pickedVersion = semver.maxSatisfying( - versions.map(({ version }) => version), versionRange, { includePrerelease: true, loose: true }) - if (!pickedVersion) return null - return pickedVersion.substring(1) + return semver.maxSatisfying(versions, versionRange, SEMVER_OPTS) ?? null +} + +export async function resolveNodeVersions ( + fetch: FetchFromRegistry, + versionSpec?: string, + nodeMirrorBaseUrl?: string +): Promise { + const allVersions = await fetchAllVersions(fetch, nodeMirrorBaseUrl) + if (!versionSpec) { + return allVersions.map(({ version }) => version) + } + if (versionSpec === 'latest') { + return [allVersions[0].version] + } + const { versions, versionRange } = filterVersions(allVersions, versionSpec) + return versions.filter(version => semver.satisfies(version, versionRange, SEMVER_OPTS)) +} + +async function fetchAllVersions (fetch: FetchFromRegistry, nodeMirrorBaseUrl?: string): Promise { + const response = await fetch(`${nodeMirrorBaseUrl ?? 'https://nodejs.org/download/release/'}index.json`) + return ((await response.json()) as NodeVersion[]).map(({ version, lts }) => ({ + version: version.substring(1), + lts, + })) } function filterVersions (versions: NodeVersion[], versionSelector: string) { if (versionSelector === 'lts') { return { - versions: versions.filter(({ lts }) => lts !== false), + versions: versions + .filter(({ lts }) => lts !== false) + .map(({ version }) => version), versionRange: '*', } } @@ -35,9 +62,14 @@ function filterVersions (versions: NodeVersion[], versionSelector: string) { if (vst?.type === 'tag') { const wantedLtsVersion = vst.normalized.toLowerCase() return { - versions: versions.filter(({ lts }) => typeof lts === 'string' && lts.toLowerCase() === wantedLtsVersion), + versions: versions + .filter(({ lts }) => typeof lts === 'string' && lts.toLowerCase() === wantedLtsVersion) + .map(({ version }) => version), versionRange: '*', } } - return { versions, versionRange: versionSelector } + return { + versions: versions.map(({ version }) => version), + versionRange: versionSelector, + } } diff --git a/packages/node.resolver/test/resolveNodeVersions.test.ts b/packages/node.resolver/test/resolveNodeVersions.test.ts new file mode 100644 index 00000000000..12e4709c130 --- /dev/null +++ b/packages/node.resolver/test/resolveNodeVersions.test.ts @@ -0,0 +1,20 @@ +import { createFetchFromRegistry } from '@pnpm/fetch' +import { resolveNodeVersions } from '@pnpm/node.resolver' + +const fetch = createFetchFromRegistry({}) + +test('resolve specified version list', async () => { + const versions = await resolveNodeVersions(fetch, '16') + expect(versions.length).toBeGreaterThan(1) + expect(versions.every(version => version.match(/^16.+/))).toBeTruthy() +}) + +test('resolve latest version', async () => { + const versions = await resolveNodeVersions(fetch, 'latest') + expect(versions.length).toEqual(1) +}) + +test('resolve all versions', async () => { + const versions = await resolveNodeVersions(fetch) + expect(versions.length).toBeGreaterThan(1) +}) diff --git a/packages/plugin-commands-env/package.json b/packages/plugin-commands-env/package.json index ada472c7720..17a105e2620 100644 --- a/packages/plugin-commands-env/package.json +++ b/packages/plugin-commands-env/package.json @@ -43,6 +43,7 @@ "@zkochan/rimraf": "^2.1.2", "load-json-file": "^6.2.0", "render-help": "^1.0.2", + "semver": "^7.3.8", "write-json-file": "^4.3.0" }, "funding": "https://opencollective.com/pnpm", @@ -50,6 +51,7 @@ "@pnpm/plugin-commands-env": "workspace:*", "@pnpm/prepare": "workspace:*", "@types/adm-zip": "^0.4.34", + "@types/semver": "7.3.13", "adm-zip": "^0.5.9", "execa": "npm:safe-execa@^0.1.2", "nock": "13.2.9", diff --git a/packages/plugin-commands-env/src/env.ts b/packages/plugin-commands-env/src/env.ts index 675184b22fe..6393e279463 100644 --- a/packages/plugin-commands-env/src/env.ts +++ b/packages/plugin-commands-env/src/env.ts @@ -12,6 +12,8 @@ import renderHelp from 'render-help' import { getNodeDir, NvmNodeCommandOptions, getNodeVersionsBaseDir } from './node' import { getNodeMirror } from './getNodeMirror' import { parseNodeEditionSpecifier } from './parseNodeEditionSpecifier' +import { listLocalVersions, listRemoteVersions } from './envList' +import { getNodeExecPathInBinDir, getNodeExecPathAndTargetDir, getNodeExecPathInNodeDir } from './utils' export function rcOptionsTypes () { return {} @@ -20,6 +22,7 @@ export function rcOptionsTypes () { export function cliOptionsTypes () { return { global: Boolean, + remote: Boolean, } } @@ -33,14 +36,19 @@ export function help () { title: 'Commands', list: [ { - description: 'Installs the specified version of Node.JS. The npm CLI bundled with the given Node.js version gets installed as well.', + description: 'Installs the specified version of Node.js. The npm CLI bundled with the given Node.js version gets installed as well.', name: 'use', }, { - description: 'Removes the specified version of Node.JS.', + description: 'Removes the specified version of Node.js.', name: 'remove', shortAlias: 'rm', }, + { + description: 'List Node.js versions available locally or remotely', + name: 'list', + shortAlias: 'ls', + }, ], }, { @@ -51,6 +59,10 @@ export function help () { name: '--global', shortAlias: '-g', }, + { + description: 'List the remote versions of Node.js', + name: '--remote', + }, ], }, ], @@ -67,6 +79,13 @@ export function help () { 'pnpm env remove --global argon', 'pnpm env remove --global latest', 'pnpm env remove --global rc/16', + 'pnpm env list', + 'pnpm env list --remote', + 'pnpm env list --remote 16', + 'pnpm env list --remote lts', + 'pnpm env list --remote argon', + 'pnpm env list --remote latest', + 'pnpm env list --remote rc/16', ], }) } @@ -92,8 +111,8 @@ export async function handler (opts: NvmNodeCommandOptions, params: string[]) { useNodeVersion: nodeVersion, nodeMirrorBaseUrl, }) - const src = path.join(nodeDir, process.platform === 'win32' ? 'node.exe' : 'bin/node') - const dest = path.join(opts.bin, process.platform === 'win32' ? 'node.exe' : 'node') + const src = getNodeExecPathInNodeDir(nodeDir) + const dest = getNodeExecPathInBinDir(opts.bin) try { await fs.unlink(dest) } catch (err) {} @@ -143,16 +162,10 @@ export async function handler (opts: NvmNodeCommandOptions, params: string[]) { throw new PnpmError('ENV_NO_NODE_DIRECTORY', `Couldn't find Node.js directory in ${versionDir}`) } - const nodePath = path.resolve(opts.pnpmHomeDir, process.platform === 'win32' ? 'node.exe' : 'node') - let nodeLink: string | undefined - try { - nodeLink = await fs.readlink(nodePath) - } catch (err) { - nodeLink = undefined - } + const { nodePath, nodeLink } = await getNodeExecPathAndTargetDir(opts.pnpmHomeDir) if (nodeLink?.includes(versionDir)) { - globalInfo(`Node.JS version ${nodeVersion} was detected as the default one, removing ...`) + globalInfo(`Node.js version ${nodeVersion as string} was detected as the default one, removing ...`) const npmPath = path.resolve(opts.pnpmHomeDir, 'npm') const npxPath = path.resolve(opts.pnpmHomeDir, 'npx') @@ -170,9 +183,21 @@ export async function handler (opts: NvmNodeCommandOptions, params: string[]) { await rimraf(versionDir) - return `Node.js ${nodeVersion} is removed + return `Node.js ${nodeVersion as string} is removed ${versionDir}` } + case 'list': + case 'ls': { + if (opts.remote) { + const nodeVersionList = await listRemoteVersions(opts, params[1]) + // Make the newest version located in the end of output + return nodeVersionList.reverse().join('\n') + } + const { currentVersion, versions } = await listLocalVersions(opts) + return versions + .map(nodeVersion => `${nodeVersion === currentVersion ? '*' : ' '} ${nodeVersion}`) + .join('\n') + } default: { throw new PnpmError('ENV_UNKNOWN_SUBCOMMAND', 'This subcommand is not known') } diff --git a/packages/plugin-commands-env/src/envList.ts b/packages/plugin-commands-env/src/envList.ts new file mode 100644 index 00000000000..190a25b2b0c --- /dev/null +++ b/packages/plugin-commands-env/src/envList.ts @@ -0,0 +1,38 @@ +import { promises as fs, existsSync } from 'fs' +import path from 'path' +import { createFetchFromRegistry } from '@pnpm/fetch' +import { resolveNodeVersions } from '@pnpm/node.resolver' +import { PnpmError } from '@pnpm/error' +import semver from 'semver' +import { getNodeMirror } from './getNodeMirror' +import { getNodeVersionsBaseDir, NvmNodeCommandOptions } from './node' +import { parseNodeEditionSpecifier } from './parseNodeEditionSpecifier' +import { getNodeExecPathAndTargetDir, getNodeExecPathInNodeDir } from './utils' + +export async function listLocalVersions (opts: NvmNodeCommandOptions) { + const nodeBaseDir = getNodeVersionsBaseDir(opts.pnpmHomeDir) + if (!existsSync(nodeBaseDir)) { + throw new PnpmError('ENV_NO_NODE_DIRECTORY', `Couldn't find Node.js directory in ${nodeBaseDir}`) + } + const { nodeLink } = await getNodeExecPathAndTargetDir(opts.pnpmHomeDir) + const nodeVersionDirs = await fs.readdir(nodeBaseDir) + return nodeVersionDirs.reduce(({ currentVersion, versions }, nodeVersion) => { + const nodeVersionDir = path.join(nodeBaseDir, nodeVersion) + const nodeExec = getNodeExecPathInNodeDir(nodeVersionDir) + if (nodeLink?.startsWith(nodeVersionDir)) { + currentVersion = nodeVersion + } + if (semver.valid(nodeVersion) && existsSync(nodeExec)) { + versions.push(nodeVersion) + } + return { currentVersion, versions } + }, { currentVersion: undefined as string | undefined, versions: [] as string[] }) +} + +export async function listRemoteVersions (opts: NvmNodeCommandOptions, versionSpec?: string) { + const fetch = createFetchFromRegistry(opts) + const { releaseChannel, versionSpecifier } = parseNodeEditionSpecifier(versionSpec ?? '') + const nodeMirrorBaseUrl = getNodeMirror(opts.rawConfig, releaseChannel) + const nodeVersionList = await resolveNodeVersions(fetch, versionSpecifier, nodeMirrorBaseUrl) + return nodeVersionList +} diff --git a/packages/plugin-commands-env/src/node.ts b/packages/plugin-commands-env/src/node.ts index 7071a9fbda4..dd9c02abb3d 100644 --- a/packages/plugin-commands-env/src/node.ts +++ b/packages/plugin-commands-env/src/node.ts @@ -31,7 +31,9 @@ export type NvmNodeCommandOptions = Pick & Partial> +> & Partial> & { + remote?: boolean +} export async function getNodeBinDir (opts: NvmNodeCommandOptions) { const fetch = createFetchFromRegistry(opts) diff --git a/packages/plugin-commands-env/src/utils.ts b/packages/plugin-commands-env/src/utils.ts new file mode 100644 index 00000000000..b945e458e5d --- /dev/null +++ b/packages/plugin-commands-env/src/utils.ts @@ -0,0 +1,21 @@ +import { promises as fs } from 'fs' +import path from 'path' + +export async function getNodeExecPathAndTargetDir (pnpmHomeDir: string) { + const nodePath = getNodeExecPathInBinDir(pnpmHomeDir) + let nodeLink: string | undefined + try { + nodeLink = await fs.readlink(nodePath) + } catch (err) { + nodeLink = undefined + } + return { nodePath, nodeLink } +} + +export function getNodeExecPathInBinDir (pnpmHomeDir: string) { + return path.resolve(pnpmHomeDir, process.platform === 'win32' ? 'node.exe' : 'node') +} + +export function getNodeExecPathInNodeDir (nodeDir: string) { + return path.join(nodeDir, process.platform === 'win32' ? 'node.exe' : 'bin/node') +} diff --git a/packages/plugin-commands-env/test/env.test.ts b/packages/plugin-commands-env/test/env.test.ts index fcca3e99f3e..cf733378087 100644 --- a/packages/plugin-commands-env/test/env.test.ts +++ b/packages/plugin-commands-env/test/env.test.ts @@ -6,6 +6,7 @@ import { env, node } from '@pnpm/plugin-commands-env' import * as execa from 'execa' import nock from 'nock' import PATH from 'path-name' +import semver from 'semver' test('install Node (and npm, npx) by exact version of Node.js', async () => { tempDir() @@ -211,3 +212,56 @@ describe('env remove', () => { expect(() => execa.sync('node', ['-v'], opts)).toThrowError() }) }) + +describe('env list', () => { + test('list local Node.js versions', async () => { + tempDir() + const configDir = path.resolve('config') + + await env.handler({ + bin: process.cwd(), + configDir, + global: true, + pnpmHomeDir: process.cwd(), + rawConfig: {}, + }, ['use', '16.4.0']) + + const version = await env.handler({ + bin: process.cwd(), + configDir, + pnpmHomeDir: process.cwd(), + rawConfig: {}, + }, ['list']) + + expect(version).toMatch('16.4.0') + }) + test('list local versions fails if Node.js directory not found', async () => { + tempDir() + const configDir = path.resolve('config') + const pnpmHomeDir = path.resolve('specified-dir') + + await expect( + env.handler({ + bin: process.cwd(), + configDir, + pnpmHomeDir, + rawConfig: {}, + }, ['list']) + ).rejects.toEqual(new PnpmError('ENV_NO_NODE_DIRECTORY', `Couldn't find Node.js directory in ${path.join(pnpmHomeDir, 'nodejs')}`)) + }) + test('list remote Node.js versions', async () => { + tempDir() + const configDir = path.resolve('config') + + const versionStr = await env.handler({ + bin: process.cwd(), + configDir, + pnpmHomeDir: process.cwd(), + rawConfig: {}, + remote: true, + }, ['list', '16']) + + const versions = versionStr.split('\n') + expect(versions.every(version => semver.satisfies(version, '16'))).toBeTruthy() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c05f838683..f57be463c88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3203,6 +3203,9 @@ importers: render-help: specifier: ^1.0.2 version: 1.0.2 + semver: + specifier: ^7.3.8 + version: 7.3.8 write-json-file: specifier: ^4.3.0 version: 4.3.0 @@ -3216,6 +3219,9 @@ importers: '@types/adm-zip': specifier: ^0.4.34 version: 0.4.34 + '@types/semver': + specifier: 7.3.13 + version: 7.3.13 adm-zip: specifier: ^0.5.9 version: 0.5.9