From d84a30a049e05d842aff8d7119072088c65fb679 Mon Sep 17 00:00:00 2001 From: Weyert de Boer Date: Thu, 17 Nov 2022 10:51:39 +0000 Subject: [PATCH] feat: add licenses command (#5567) Introduces a new command `licenses list` which allows to list the licenses of the packages close #2825 --- .changeset/nice-cycles-search.md | 7 + packages/license-scanner/README.md | 17 + packages/license-scanner/jest.config.js | 1 + packages/license-scanner/package.json | 63 +++ packages/license-scanner/src/getPkgInfo.ts | 362 ++++++++++++++++++ packages/license-scanner/src/index.ts | 1 + packages/license-scanner/src/licenses.ts | 106 +++++ .../src/lockfileToLicenseNodeTree.ts | 163 ++++++++ .../license-scanner/test/getPkgInfo.spec.ts | 31 ++ .../license-scanner/test/licenses.spec.ts | 101 +++++ packages/license-scanner/tsconfig.json | 49 +++ packages/license-scanner/tsconfig.lint.json | 8 + packages/plugin-commands-licenses/README.md | 15 + .../plugin-commands-licenses/jest.config.js | 1 + .../plugin-commands-licenses/package.json | 67 ++++ .../plugin-commands-licenses/src/index.ts | 3 + .../plugin-commands-licenses/src/licenses.ts | 106 +++++ .../src/licensesList.ts | 70 ++++ .../src/outputRenderer.ts | 111 ++++++ .../test/__snapshots__/index.ts.snap | 36 ++ .../test/fixtures/.gitignore | 1 + .../test/fixtures/.npmrc | 1 + .../fixtures/complex-licenses/package.json | 24 ++ .../fixtures/complex-licenses/pnpm-lock.yaml | 55 +++ .../test/fixtures/pnpm-workspace.yaml | 1 + .../fixtures/simple-licenses/package.json | 15 + .../fixtures/simple-licenses/pnpm-lock.yaml | 14 + .../fixtures/with-dev-dependency/package.json | 18 + .../with-dev-dependency/pnpm-lock.yaml | 24 ++ .../plugin-commands-licenses/test/index.ts | 136 +++++++ .../test/utils/index.ts | 48 +++ .../plugin-commands-licenses/tsconfig.json | 49 +++ .../tsconfig.lint.json | 8 + packages/pnpm/package.json | 1 + packages/pnpm/src/cmd/help.ts | 4 + packages/pnpm/src/cmd/index.ts | 2 + packages/pnpm/tsconfig.json | 3 + pnpm-lock.yaml | 134 +++++++ 38 files changed, 1856 insertions(+) create mode 100644 .changeset/nice-cycles-search.md create mode 100644 packages/license-scanner/README.md create mode 100644 packages/license-scanner/jest.config.js create mode 100644 packages/license-scanner/package.json create mode 100644 packages/license-scanner/src/getPkgInfo.ts create mode 100644 packages/license-scanner/src/index.ts create mode 100644 packages/license-scanner/src/licenses.ts create mode 100644 packages/license-scanner/src/lockfileToLicenseNodeTree.ts create mode 100644 packages/license-scanner/test/getPkgInfo.spec.ts create mode 100644 packages/license-scanner/test/licenses.spec.ts create mode 100644 packages/license-scanner/tsconfig.json create mode 100644 packages/license-scanner/tsconfig.lint.json create mode 100644 packages/plugin-commands-licenses/README.md create mode 100644 packages/plugin-commands-licenses/jest.config.js create mode 100644 packages/plugin-commands-licenses/package.json create mode 100644 packages/plugin-commands-licenses/src/index.ts create mode 100644 packages/plugin-commands-licenses/src/licenses.ts create mode 100644 packages/plugin-commands-licenses/src/licensesList.ts create mode 100644 packages/plugin-commands-licenses/src/outputRenderer.ts create mode 100644 packages/plugin-commands-licenses/test/__snapshots__/index.ts.snap create mode 100644 packages/plugin-commands-licenses/test/fixtures/.gitignore create mode 100644 packages/plugin-commands-licenses/test/fixtures/.npmrc create mode 100644 packages/plugin-commands-licenses/test/fixtures/complex-licenses/package.json create mode 100644 packages/plugin-commands-licenses/test/fixtures/complex-licenses/pnpm-lock.yaml create mode 100644 packages/plugin-commands-licenses/test/fixtures/pnpm-workspace.yaml create mode 100644 packages/plugin-commands-licenses/test/fixtures/simple-licenses/package.json create mode 100644 packages/plugin-commands-licenses/test/fixtures/simple-licenses/pnpm-lock.yaml create mode 100644 packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/package.json create mode 100644 packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/pnpm-lock.yaml create mode 100644 packages/plugin-commands-licenses/test/index.ts create mode 100644 packages/plugin-commands-licenses/test/utils/index.ts create mode 100644 packages/plugin-commands-licenses/tsconfig.json create mode 100644 packages/plugin-commands-licenses/tsconfig.lint.json diff --git a/.changeset/nice-cycles-search.md b/.changeset/nice-cycles-search.md new file mode 100644 index 00000000000..ffc77ae688f --- /dev/null +++ b/.changeset/nice-cycles-search.md @@ -0,0 +1,7 @@ +--- +'@pnpm/license-scanner': major +'@pnpm/plugin-commands-licenses': major +"pnpm": minor +--- + +Added a new command `pnpm licenses list`, which displays the licenses of the packages [#2825](https://github.com/pnpm/pnpm/issues/2825) diff --git a/packages/license-scanner/README.md b/packages/license-scanner/README.md new file mode 100644 index 00000000000..b1b61a065fb --- /dev/null +++ b/packages/license-scanner/README.md @@ -0,0 +1,17 @@ +# @pnpm/license-scanner + +> Check for the licensse of packages + + +[![npm version](https://img.shields.io/npm/v/@pnpm/license-scanner.svg)](https://www.npmjs.com/package/@pnpm/license-scanner) + + +## Installation + +```sh +pnpm add @pnpm/license-scanner +``` + +## License + +[MIT](LICENSE) diff --git a/packages/license-scanner/jest.config.js b/packages/license-scanner/jest.config.js new file mode 100644 index 00000000000..58141f076dc --- /dev/null +++ b/packages/license-scanner/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest.config.js'); diff --git a/packages/license-scanner/package.json b/packages/license-scanner/package.json new file mode 100644 index 00000000000..0bed5baa23c --- /dev/null +++ b/packages/license-scanner/package.json @@ -0,0 +1,63 @@ +{ + "name": "@pnpm/license-scanner", + "version": "0.0.0", + "description": "Check for licenses packages", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "engines": { + "node": ">=14.6" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "test": "pnpm run compile && pnpm run _test", + "lint": "eslint src/**/*.ts test/**/*.ts", + "prepublishOnly": "pnpm run compile", + "registry-mock": "registry-mock", + "test:jest": "jest", + "_test": "jest", + "test:e2e": "registry-mock prepare && run-p -r registry-mock test:jest", + "compile": "tsc --build && pnpm run lint --fix" + }, + "repository": "https://github.com/pnpm/pnpm/blob/main/packages/license-scanner", + "keywords": [ + "pnpm7", + "pnpm", + "licenses" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "homepage": "https://github.com/pnpm/pnpm/blob/main/packages/license-scanner#readme", + "peerDependencies": { + "@pnpm/logger": "^5.0.0" + }, + "dependencies": { + "@pnpm/cafs": "workspace:*", + "@pnpm/directory-fetcher": "workspace:*", + "@pnpm/error": "workspace:*", + "@pnpm/lockfile-file": "workspace:*", + "@pnpm/lockfile-types": "workspace:*", + "@pnpm/lockfile-utils": "workspace:*", + "@pnpm/lockfile-walker": "workspace:*", + "@pnpm/package-is-installable": "workspace:*", + "@pnpm/read-package-json": "workspace:*", + "@pnpm/types": "workspace:*", + "dependency-path": "workspace:*", + "load-json-file": "^6.2.0", + "p-limit": "^3.1.0", + "path-absolute": "^1.0.1" + }, + "devDependencies": { + "@pnpm/constants": "workspace:*", + "@pnpm/license-scanner": "workspace:*", + "@types/ramda": "0.28.15" + }, + "funding": "https://opencollective.com/pnpm", + "exports": { + ".": "./lib/index.js" + } +} diff --git a/packages/license-scanner/src/getPkgInfo.ts b/packages/license-scanner/src/getPkgInfo.ts new file mode 100644 index 00000000000..380a17032a7 --- /dev/null +++ b/packages/license-scanner/src/getPkgInfo.ts @@ -0,0 +1,362 @@ +import path from 'path' +import pathAbsolute from 'path-absolute' +import { readFile } from 'fs/promises' +import { readPackageJson } from '@pnpm/read-package-json' +import { depPathToFilename } from 'dependency-path' +import pLimit from 'p-limit' +import { PackageManifest, Registries } from '@pnpm/types' +import { + getFilePathByModeInCafs, + getFilePathInCafs, + PackageFileInfo, + PackageFilesIndex, +} from '@pnpm/cafs' +import loadJsonFile from 'load-json-file' +import { PnpmError } from '@pnpm/error' +import { LicensePackage } from './licenses' +import { DirectoryResolution, PackageSnapshot, pkgSnapshotToResolution, Resolution } from '@pnpm/lockfile-utils' +import { fetchFromDir } from '@pnpm/directory-fetcher' + +const limitPkgReads = pLimit(4) + +export async function readPkg (pkgPath: string) { + return limitPkgReads(async () => readPackageJson(pkgPath)) +} + +/** + * @const + * List of typical names for license files + */ +const LICENSE_FILES = [ + 'LICENSE', + 'LICENCE', + 'LICENSE.md', + 'LICENCE.md', + 'LICENSE.txt', + 'LICENCE.txt', + 'MIT-LICENSE.txt', + 'MIT-LICENSE.md', + 'MIT-LICENSE', +] + +export interface LicenseInfo { + name: string + licenseFile?: string +} + +/** + * Coerce the given value to a string or a null value + * @param field the string to be converted + * @returns string | null + */ +function coerceToString (field: unknown): string | null { + const string = String(field) + return typeof field === 'string' || field === string ? string : null +} + +/** + * Parses the value of the license-property of a + * package manifest and return it as a string + * @param field the value to parse + * @returns string + */ +function parseLicenseManifestField (field: unknown) { + if (Array.isArray(field)) { + const licenses = field + const licenseTypes = licenses.reduce((listOfLicenseTypes, license) => { + const type = coerceToString(license.type) ?? coerceToString(license.name) + if (type) { + listOfLicenseTypes.push(type) + } + return listOfLicenseTypes + }, []) + + if (licenseTypes.length > 1) { + const combinedLicenseTypes = licenseTypes.join(' OR ') as string + return `(${combinedLicenseTypes})` + } + + return licenseTypes[0] ?? null + } else { + return (field as { type: string })?.type ?? coerceToString(field) + } +} + +/** + * Reads the license field or LICENSE file from + * the directory of the given package manifest + * + * If the package.json file is missing the `license`-property + * the root of the manifest directory will be scanned for + * files named listed in the array LICENSE_FILES and the + * contents will be returned. + * + * @param {*} pkg the package to check + * @param {*} opts the options for parsing licenses + * @returns Promise + */ +async function parseLicense ( + pkg: { + manifest: PackageManifest + files: + | { local: true, files: Record } + | { local: false, files: Record } + }, + opts: { cafsDir: string } +): Promise { + let licenseField: unknown = pkg.manifest.license + if ('licenses' in pkg.manifest) { + licenseField = ( + pkg.manifest as PackageManifest & { + licenses: unknown + } + ).licenses + } + const license = parseLicenseManifestField(licenseField) + + // check if we discovered a license, if not attempt to parse the LICENSE file + if (!license || /see license/i.test(license)) { + const { files: pkgFileIndex } = pkg.files + for (const filename of LICENSE_FILES) { + // check if the a file with the expected name exists in the file index + if (!(filename in pkgFileIndex)) { + continue + } + + const licensePackageFileInfo = pkgFileIndex[filename] + let licenseContents: Buffer | undefined + if (pkg.files.local) { + licenseContents = await readFile(licensePackageFileInfo as string) + } else { + licenseContents = await readLicenseFileFromCafs( + opts.cafsDir, + (licensePackageFileInfo as PackageFileInfo).integrity + ) + } + + return { + name: 'Unknown', + licenseFile: licenseContents?.toString('utf-8'), + } + } + } + + return { name: license ?? 'Unknown' } +} + +/** + * Fetch a file by integrity id from the content-addressable store + * @param cafsDir the cafs directory + * @param opts the options for reading file + * @returns Promise + */ +async function readLicenseFileFromCafs (cafsDir: string, fileIntegrity: string) { + const fileName = getFilePathByModeInCafs(cafsDir, fileIntegrity, 0) + const fileContents = await readFile(fileName) + return fileContents +} + +/** + * Returns the index of files included in + * the package identified by the integrity id + * @param packageResolution the resolution package information + * @param depPath the package reference + * @param opts options for fetching package file index + */ +export async function readPackageIndexFile ( + packageResolution: Resolution, + depPath: string, + opts: { cafsDir: string, storeDir: string, lockfileDir: string } +): Promise< + | { + local: false + files: Record + } + | { + local: true + files: Record + } + > { + const isPackageWithIntegrity = 'integrity' in packageResolution + + let pkgIndexFilePath + if (isPackageWithIntegrity) { + // Retrieve all the index file of all files included in the package + pkgIndexFilePath = await getFilePathInCafs( + opts.cafsDir, + packageResolution.integrity as string, + 'index' + ) + } else if (!packageResolution.type && packageResolution.tarball) { + // If the package resolution has a tarball then we need to clean up + // the call to depPathToFilename as it adds '_[hash]' part to the + // directory for the package in the content-addressable store + const packageDirInStore = depPathToFilename(depPath.split('_')[0]) + pkgIndexFilePath = path.join( + opts.storeDir, + packageDirInStore, + 'integrity.json' + ) + } else { + throw new PnpmError( + 'UNSUPPORTED_PACKAGE_TYPE', + `Unsupported package resolution type for ${depPath}` + ) + } + + // If the package resolution is of type directory we need to do things + // differently and generate our own package index file + const isLocalPkg = packageResolution.type === 'directory' + if (isLocalPkg) { + const localInfo = await fetchFromDir( + path.join(opts.lockfileDir, (packageResolution as DirectoryResolution).directory), + {} + ) + return { + local: true, + files: localInfo.filesIndex, + } + } else { + try { + const { files } = await loadJsonFile(pkgIndexFilePath) + return { + local: false, + files, + } + } catch (err: any) { // eslint-disable-line + if (err.code === 'ENOENT') { + throw new PnpmError( + 'MISSING_PACKAGE_INDEX_FILE', + `Failed to find package index file for ${depPath}, please consider running 'pnpm install'` + ) + } + + throw err + } + } +} + +export interface PackageInfo { + name?: string + version?: string + depPath: string + snapshot: PackageSnapshot + registries: Registries +} + +export interface GetPackageInfoOptions { + storeDir: string + virtualStoreDir: string + dir: string + modulesDir: string +} + +/** + * Returns the package manifest information for a give package name and path + * @param pkg the package to fetch information for + * @param opts the fetching options + * @returns Promise<{ from: string; description?: string } & Omit> + */ +export async function getPkgInfo ( + pkg: PackageInfo, + opts: GetPackageInfoOptions +): Promise< + { + from: string + description?: string + } & Omit + > { + const cafsDir = path.join(opts.storeDir, 'files') + + // Retrieve file index for the requested package + const packageResolution = pkgSnapshotToResolution( + pkg.depPath, + pkg.snapshot, + pkg.registries + ) + + const packageFileIndexInfo = await readPackageIndexFile( + packageResolution as Resolution, + pkg.depPath, + { + cafsDir, + storeDir: opts.storeDir, + lockfileDir: opts.dir, + } + ) + + // Fetch the package manifest + let packageManifestDir!: string + if (packageFileIndexInfo.local) { + packageManifestDir = packageFileIndexInfo.files['package.json'] + } else { + const packageFileIndex = packageFileIndexInfo.files as Record< + string, + PackageFileInfo + > + const packageManifestFile = packageFileIndex['package.json'] + packageManifestDir = await getFilePathByModeInCafs( + cafsDir, + packageManifestFile.integrity, + packageManifestFile.mode + ) + } + + let manifest + try { + manifest = await readPkg(packageManifestDir) + } catch (err: any) { // eslint-disable-line + if (err.code === 'ENOENT') { + throw new PnpmError( + 'MISSING_PACKAGE_MANIFEST', + `Failed to find package manifest file at ${packageManifestDir}` + ) + } + throw err + } + + // Determine the path to the package as known by the user + const modulesDir = opts.modulesDir ?? 'node_modules' + const virtualStoreDir = pathAbsolute( + opts.virtualStoreDir ?? path.join(modulesDir, '.pnpm'), + opts.dir + ) + + // TODO: fix issue that path is only correct when using node-linked=isolated + const packageModulePath = path.join( + virtualStoreDir, + depPathToFilename(pkg.depPath), + modulesDir, + manifest.name + ) + + const licenseInfo = await parseLicense( + { manifest, files: packageFileIndexInfo }, + { cafsDir } + ) + + const packageInfo = { + from: manifest.name, + path: packageModulePath, + name: manifest.name, + version: manifest.version, + description: manifest.description, + license: licenseInfo.name, + licenseContents: licenseInfo.licenseFile, + author: + (manifest.author && + (typeof manifest.author === 'string' + ? manifest.author + : (manifest.author as { name: string }).name)) ?? + undefined, + homepage: manifest.homepage, + repository: + (manifest.repository && + (typeof manifest.repository === 'string' + ? manifest.repository + : manifest.repository.url)) ?? + undefined, + } + + return packageInfo +} diff --git a/packages/license-scanner/src/index.ts b/packages/license-scanner/src/index.ts new file mode 100644 index 00000000000..fadf00f3c7c --- /dev/null +++ b/packages/license-scanner/src/index.ts @@ -0,0 +1 @@ +export { LicensePackage, findDependencyLicenses } from './licenses' diff --git a/packages/license-scanner/src/licenses.ts b/packages/license-scanner/src/licenses.ts new file mode 100644 index 00000000000..8a2f79080da --- /dev/null +++ b/packages/license-scanner/src/licenses.ts @@ -0,0 +1,106 @@ +import { PnpmError } from '@pnpm/error' +import { Lockfile } from '@pnpm/lockfile-file' +import { + DependenciesField, + IncludedDependencies, + ProjectManifest, + Registries, +} from '@pnpm/types' +import { + LicenseNode, + lockfileToLicenseNodeTree, +} from './lockfileToLicenseNodeTree' + +export interface LicensePackage { + belongsTo: DependenciesField + version: string + name: string + license: string + licenseContents?: string + author?: string + homepage?: string + repository?: string + path?: string +} + +/** + * @private + * Returns an array of LicensePackages from the given LicenseNode + * @param licenseNode the license node + * @returns LicensePackage[] + */ +function getDependenciesFromLicenseNode ( + licenseNode: LicenseNode +): LicensePackage[] { + if (!licenseNode.dependencies) { + return [] + } + + let dependencies: LicensePackage[] = [] + for (const dependencyName in licenseNode.dependencies) { + const dependencyNode = licenseNode.dependencies[dependencyName] + const dependenciesOfNode = getDependenciesFromLicenseNode(dependencyNode) + + dependencies = [ + ...dependencies, + ...dependenciesOfNode, + { + belongsTo: dependencyNode.dev ? 'devDependencies' : 'dependencies', + version: dependencyNode.version as string, + name: dependencyName, + license: dependencyNode.license as string, + licenseContents: dependencyNode.licenseContents, + author: dependencyNode.author as string, + homepage: dependencyNode.homepage as string, + repository: dependencyNode.repository as string, + path: dependencyNode.dir, + }, + ] + } + + return dependencies +} + +export async function findDependencyLicenses (opts: { + ignoreDependencies?: Set + include?: IncludedDependencies + lockfileDir: string + manifest: ProjectManifest + storeDir: string + virtualStoreDir: string + modulesDir?: string + registries: Registries + wantedLockfile: Lockfile | null +}): Promise { + if (opts.wantedLockfile == null) { + throw new PnpmError( + 'LICENSES_NO_LOCKFILE', + `No lockfile in directory "${opts.lockfileDir}". Run \`pnpm install\` to generate one.` + ) + } + + const licenseNodeTree = await lockfileToLicenseNodeTree(opts.wantedLockfile, { + dir: opts.lockfileDir, + modulesDir: opts.modulesDir, + storeDir: opts.storeDir, + virtualStoreDir: opts.virtualStoreDir, + include: opts.include, + registries: opts.registries, + }) + + const licensePackages = new Map() + for (const dependencyName in licenseNodeTree.dependencies) { + const licenseNode = licenseNodeTree.dependencies[dependencyName] + const dependenciesOfNode = getDependenciesFromLicenseNode(licenseNode) + + dependenciesOfNode.forEach((dependencyNode) => { + licensePackages.set(dependencyNode.name, dependencyNode) + }) + } + + // Get all non-duplicate dependencies of the project + const projectDependencies = Array.from(licensePackages.values()) + return Array.from(projectDependencies).sort((pkg1, pkg2) => + pkg1.name.localeCompare(pkg2.name) + ) +} diff --git a/packages/license-scanner/src/lockfileToLicenseNodeTree.ts b/packages/license-scanner/src/lockfileToLicenseNodeTree.ts new file mode 100644 index 00000000000..76238422ff3 --- /dev/null +++ b/packages/license-scanner/src/lockfileToLicenseNodeTree.ts @@ -0,0 +1,163 @@ +import { Lockfile } from '@pnpm/lockfile-types' +import { nameVerFromPkgSnapshot } from '@pnpm/lockfile-utils' +import { packageIsInstallable } from '@pnpm/package-is-installable' +import { + lockfileWalkerGroupImporterSteps, + LockfileWalkerStep, +} from '@pnpm/lockfile-walker' +import { DependenciesField, Registries } from '@pnpm/types' +import { getPkgInfo } from './getPkgInfo' + +export interface LicenseNode { + name?: string + version?: string + license: string + licenseContents?: string + dir: string + author?: string + homepage?: string + repository?: string + integrity?: string + requires?: Record + dependencies?: { [name: string]: LicenseNode } + dev: boolean +} + +export type LicenseNodeTree = Omit< +LicenseNode, +'dir' | 'license' | 'licenseContents' | 'author' | 'homepages' | 'repository' +> + +export interface LicenseExtractOptions { + storeDir: string + virtualStoreDir: string + modulesDir?: string + dir: string + registries: Registries +} + +export async function lockfileToLicenseNode ( + step: LockfileWalkerStep, + options: LicenseExtractOptions +) { + const dependencies = {} + for (const dependency of step.dependencies) { + const { depPath, pkgSnapshot, next } = dependency + const { name, version } = nameVerFromPkgSnapshot(depPath, pkgSnapshot) + + const packageInstallable = packageIsInstallable(pkgSnapshot.id ?? depPath, { + name, + version, + cpu: pkgSnapshot.cpu, + os: pkgSnapshot.os, + libc: pkgSnapshot.libc, + }, { + optional: pkgSnapshot.optional ?? false, + lockfileDir: options.dir, + }) + + // If the package is not installable on the given platform, we ignore the + // package, typically the case for platform prebuild packages + if (!packageInstallable) { + continue + } + + const packageInfo = await getPkgInfo( + { + name, + version, + depPath, + snapshot: pkgSnapshot, + registries: options.registries, + }, + { + storeDir: options.storeDir, + virtualStoreDir: options.virtualStoreDir, + dir: options.dir, + modulesDir: options.modulesDir ?? 'node_modules', + } + ) + + const subdeps = await lockfileToLicenseNode(next(), options) + + const dep: LicenseNode = { + name, + dev: pkgSnapshot.dev === true, + integrity: pkgSnapshot.resolution['integrity'], + version, + license: packageInfo.license, + licenseContents: packageInfo.licenseContents, + author: packageInfo.author, + homepage: packageInfo.homepage, + repository: packageInfo.repository, + dir: packageInfo.path as string, + } + + if (Object.keys(subdeps).length > 0) { + dep.dependencies = subdeps + dep.requires = toRequires(subdeps) + } + + // If the package details could be fetched, we consider it part of the tree + dependencies[name] = dep + } + + return dependencies +} + +/** + * Reads the lockfile and converts it in a node tree of information necessary + * to generate the licenses summary + * @param lockfile the lockfile to process + * @param opts parsing instructions + * @returns + */ +export async function lockfileToLicenseNodeTree ( + lockfile: Lockfile, + opts: { + include?: { [dependenciesField in DependenciesField]: boolean } + } & LicenseExtractOptions +): Promise { + const importerWalkers = lockfileWalkerGroupImporterSteps( + lockfile, + Object.keys(lockfile.importers), + { include: opts?.include } + ) + const dependencies = {} + + for (const importerWalker of importerWalkers) { + const importerDeps = await lockfileToLicenseNode(importerWalker.step, { + storeDir: opts.storeDir, + virtualStoreDir: opts.virtualStoreDir, + modulesDir: opts.modulesDir, + dir: opts.dir, + registries: opts.registries, + }) + + const depName = importerWalker.importerId + dependencies[depName] = { + dependencies: importerDeps, + requires: toRequires(importerDeps), + version: '0.0.0', + } + } + + const licenseNodeTree: LicenseNodeTree = { + name: undefined, + version: undefined, + dependencies, + dev: false, + integrity: undefined, + requires: toRequires(dependencies), + } + + return licenseNodeTree +} + +function toRequires (licenseNodesByDepName: Record) { + const requires = {} + for (const subdepName of Object.keys(licenseNodesByDepName)) { + requires[subdepName] = licenseNodesByDepName[subdepName].version + } + return requires +} diff --git a/packages/license-scanner/test/getPkgInfo.spec.ts b/packages/license-scanner/test/getPkgInfo.spec.ts new file mode 100644 index 00000000000..c4b524ad073 --- /dev/null +++ b/packages/license-scanner/test/getPkgInfo.spec.ts @@ -0,0 +1,31 @@ +import { getPkgInfo } from '../lib/getPkgInfo' + +export const DEFAULT_REGISTRIES = { + default: 'https://registry.npmjs.org/', +} + +describe('licences', () => { + test('getPkgInfo() should throw error when package info can not be fetched', async () => { + await expect( + getPkgInfo( + { + name: 'bogus-package', + version: '1.0.0', + depPath: '/bogus-package/1.0.0', + snapshot: { + resolution: { + integrity: 'integrity-sha', + }, + }, + registries: DEFAULT_REGISTRIES, + }, + { + storeDir: 'store-dir', + virtualStoreDir: 'virtual-store-dir', + modulesDir: 'modules-dir', + dir: 'workspace-dir', + } + ) + ).rejects.toThrow('Failed to find package index file for /bogus-package/1.0.0, please consider running \'pnpm install\'') + }) +}) diff --git a/packages/license-scanner/test/licenses.spec.ts b/packages/license-scanner/test/licenses.spec.ts new file mode 100644 index 00000000000..3a606320bc9 --- /dev/null +++ b/packages/license-scanner/test/licenses.spec.ts @@ -0,0 +1,101 @@ +import { findDependencyLicenses } from '@pnpm/license-scanner' +import { LOCKFILE_VERSION } from '@pnpm/constants' +import { ProjectManifest, Registries } from '@pnpm/types' +import { Lockfile } from '@pnpm/lockfile-file' +import { LicensePackage } from '../lib/licenses' +import { GetPackageInfoOptions, PackageInfo } from '../lib/getPkgInfo' + +jest.mock('../lib/getPkgInfo', () => { + const actualModule = jest.requireActual('../lib/getPkgInfo') + return { + ...actualModule, + getPkgInfo: async (pkg: PackageInfo, _opts: GetPackageInfoOptions): Promise< + { + from: string + description?: string + } & Omit + > => { + const packageInfo = { + from: pkg.name!, + name: pkg.name!, + version: pkg.version!, + description: 'Package Description', + license: pkg.name === 'bar' ? 'MIT' : 'Unknown', + licenseContents: pkg.name === 'bar' ? undefined : 'The MIT License', + author: 'Package Author', + homepage: 'Homepage', + repository: 'Repository', + path: `/path/to/package/${pkg.name!}@${pkg.version!}/node_modules`, + } + + return packageInfo + }, + } +}) + +describe('licences', () => { + test('findDependencyLicenses()', async () => { + const lockfile: Lockfile = { + importers: { + '.': { + dependencies: { + foo: '1.0.0', + }, + specifiers: { + foo: '^1.0.0', + }, + }, + }, + lockfileVersion: LOCKFILE_VERSION, + packages: { + '/bar/1.0.0': { + resolution: { + integrity: 'bar-integrity', + }, + }, + '/foo/1.0.0': { + dependencies: { + bar: '1.0.0', + }, + resolution: { + integrity: 'foo-integrity', + }, + }, + }, + } + + const licensePackages = await findDependencyLicenses({ + lockfileDir: '/opt/pnpm', + manifest: {} as ProjectManifest, + virtualStoreDir: '/.pnpm', + registries: {} as Registries, + wantedLockfile: lockfile, + storeDir: '/opt/.pnpm', + }) + + expect(licensePackages).toEqual([ + { + belongsTo: 'dependencies', + version: '1.0.0', + name: 'bar', + license: 'MIT', + licenseContents: undefined, + author: 'Package Author', + homepage: 'Homepage', + repository: 'Repository', + path: '/path/to/package/bar@1.0.0/node_modules', + }, + { + belongsTo: 'dependencies', + version: '1.0.0', + name: 'foo', + license: 'Unknown', + licenseContents: 'The MIT License', + author: 'Package Author', + homepage: 'Homepage', + repository: 'Repository', + path: '/path/to/package/foo@1.0.0/node_modules', + }, + ] as LicensePackage[]) + }) +}) diff --git a/packages/license-scanner/tsconfig.json b/packages/license-scanner/tsconfig.json new file mode 100644 index 00000000000..3c3d18c8ec1 --- /dev/null +++ b/packages/license-scanner/tsconfig.json @@ -0,0 +1,49 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../typings/**/*.d.ts" + ], + "references": [ + { + "path": "../cafs" + }, + { + "path": "../constants" + }, + { + "path": "../dependency-path" + }, + { + "path": "../directory-fetcher" + }, + { + "path": "../error" + }, + { + "path": "../lockfile-file" + }, + { + "path": "../lockfile-types" + }, + { + "path": "../lockfile-utils" + }, + { + "path": "../lockfile-walker" + }, + { + "path": "../package-is-installable" + }, + { + "path": "../read-package-json" + }, + { + "path": "../types" + } + ] +} diff --git a/packages/license-scanner/tsconfig.lint.json b/packages/license-scanner/tsconfig.lint.json new file mode 100644 index 00000000000..0dc5add6b7b --- /dev/null +++ b/packages/license-scanner/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../typings/**/*.d.ts" + ] +} diff --git a/packages/plugin-commands-licenses/README.md b/packages/plugin-commands-licenses/README.md new file mode 100644 index 00000000000..ad0091e3d9a --- /dev/null +++ b/packages/plugin-commands-licenses/README.md @@ -0,0 +1,15 @@ +# @pnpm/plugin-commands-licenses + +> The licenses command of pnpm + +[![npm version](https://img.shields.io/npm/v/@pnpm/plugin-commands-licenses.svg)](https://www.npmjs.com/package/@pnpm/plugin-commands-licenses) + +## Installation + +```sh +pnpm add @pnpm/plugin-commands-licenses +``` + +## License + +MIT diff --git a/packages/plugin-commands-licenses/jest.config.js b/packages/plugin-commands-licenses/jest.config.js new file mode 100644 index 00000000000..f697d831691 --- /dev/null +++ b/packages/plugin-commands-licenses/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest.config.js') diff --git a/packages/plugin-commands-licenses/package.json b/packages/plugin-commands-licenses/package.json new file mode 100644 index 00000000000..fefc441e226 --- /dev/null +++ b/packages/plugin-commands-licenses/package.json @@ -0,0 +1,67 @@ +{ + "name": "@pnpm/plugin-commands-licenses", + "version": "0.0.0", + "description": "The licenses command of pnpm", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib", + "!*.map" + ], + "engines": { + "node": ">=14.6" + }, + "scripts": { + "lint": "eslint src/**/*.ts test/**/*.ts", + "registry-mock": "registry-mock", + "test:jest": "jest", + "test:e2e": "registry-mock prepare && run-p -r registry-mock test:jest", + "_test": "jest", + "test": "pnpm run compile && pnpm run _test", + "prepublishOnly": "pnpm run compile", + "compile": "tsc --build && pnpm run lint --fix" + }, + "repository": "https://github.com/pnpm/pnpm/blob/main/packages/plugin-commands-licenses", + "keywords": [ + "pnpm7", + "pnpm", + "licenses" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "homepage": "https://github.com/pnpm/pnpm/blob/main/packages/plugin-commands-licenses#readme", + "devDependencies": { + "@pnpm/constants": "workspace:*", + "@pnpm/filter-workspace-packages": "workspace:*", + "@pnpm/plugin-commands-installation": "workspace:*", + "@pnpm/plugin-commands-licenses": "workspace:*", + "@pnpm/read-package-json": "workspace:*", + "@pnpm/registry-mock": "3.1.0", + "@types/ramda": "0.28.15", + "@types/wrap-ansi": "^3.0.0", + "@types/zkochan__table": "npm:@types/table@6.0.0", + "strip-ansi": "^6.0.1", + "tempy": "^1.0.1" + }, + "dependencies": { + "@pnpm/cli-utils": "workspace:*", + "@pnpm/command": "workspace:*", + "@pnpm/common-cli-options-help": "workspace:*", + "@pnpm/config": "workspace:*", + "@pnpm/constants": "workspace:*", + "@pnpm/error": "workspace:*", + "@pnpm/license-scanner": "workspace:*", + "@pnpm/lockfile-file": "workspace:*", + "@pnpm/store-path": "workspace:*", + "@zkochan/table": "^1.0.0", + "chalk": "^4.1.2", + "ramda": "npm:@pnpm/ramda@0.28.1", + "render-help": "^1.0.2" + }, + "funding": "https://opencollective.com/pnpm", + "exports": { + ".": "./lib/index.js" + } +} diff --git a/packages/plugin-commands-licenses/src/index.ts b/packages/plugin-commands-licenses/src/index.ts new file mode 100644 index 00000000000..9b5ab8ecd4c --- /dev/null +++ b/packages/plugin-commands-licenses/src/index.ts @@ -0,0 +1,3 @@ +import * as licenses from './licenses' + +export { licenses } diff --git a/packages/plugin-commands-licenses/src/licenses.ts b/packages/plugin-commands-licenses/src/licenses.ts new file mode 100644 index 00000000000..7bfee3d12d2 --- /dev/null +++ b/packages/plugin-commands-licenses/src/licenses.ts @@ -0,0 +1,106 @@ +import { + docsUrl, + readDepNameCompletions, +} from '@pnpm/cli-utils' +import { CompletionFunc } from '@pnpm/command' +import { + FILTERING, + OPTIONS, + UNIVERSAL_OPTIONS, +} from '@pnpm/common-cli-options-help' +import { types as allTypes } from '@pnpm/config' +import { PnpmError } from '@pnpm/error' +import pick from 'ramda/src/pick' +import renderHelp from 'render-help' +import { licensesList, LicensesCommandOptions } from './licensesList' + +export function rcOptionsTypes () { + return { + ...pick( + ['dev', 'global-dir', 'global', 'json', 'long', 'optional', 'production'], + allTypes + ), + compatible: Boolean, + table: Boolean, + } +} + +export const cliOptionsTypes = () => ({ + ...rcOptionsTypes(), + recursive: Boolean, +}) + +export const shorthands = { + D: '--dev', + P: '--production', +} + +export const commandNames = ['licenses'] + +export function help () { + return renderHelp({ + description: `Check for licenses packages. The check can be limited to a subset of the installed packages by providing arguments (patterns are supported). + +Examples: +pnpm licenses list +pnpm licenses list --long`, + descriptionLists: [ + { + title: 'Options', + + list: [ + { + description: + 'By default, details about the packages (such as a link to the repo) are not displayed. \ +To display the details, pass this option.', + name: '--long', + }, + { + description: 'Show information in JSON format', + name: '--json', + }, + { + description: 'Check only "dependencies" and "optionalDependencies"', + name: '--prod', + shortAlias: '-P', + }, + { + description: 'Check only "devDependencies"', + name: '--dev', + shortAlias: '-D', + }, + { + description: 'Don\'t check "optionalDependencies"', + name: '--no-optional', + }, + OPTIONS.globalDir, + ...UNIVERSAL_OPTIONS, + ], + }, + FILTERING, + ], + url: docsUrl('licenses'), + usages: ['pnpm licenses [options]'], + }) +} + +export const completion: CompletionFunc = async (cliOpts) => { + return readDepNameCompletions(cliOpts.dir as string) +} + +export async function handler ( + opts: LicensesCommandOptions, + params: string[] = [] +) { + if (params.length === 0) { + throw new PnpmError('LICENCES_NO_SUBCOMMAND', 'Please specify the subcommand') + } + switch (params[0]) { + case 'list': + case 'ls': + return licensesList(opts) + default: { + throw new PnpmError('LICENSES_UNKNOWN_SUBCOMMAND', 'This subcommand is not known') + } + } +} diff --git a/packages/plugin-commands-licenses/src/licensesList.ts b/packages/plugin-commands-licenses/src/licensesList.ts new file mode 100644 index 00000000000..d1f01596cda --- /dev/null +++ b/packages/plugin-commands-licenses/src/licensesList.ts @@ -0,0 +1,70 @@ +import { readProjectManifestOnly } from '@pnpm/cli-utils' +import { Config } from '@pnpm/config' +import { PnpmError } from '@pnpm/error' +import { getStorePath } from '@pnpm/store-path' +import { WANTED_LOCKFILE } from '@pnpm/constants' +import { readWantedLockfile } from '@pnpm/lockfile-file' +import { findDependencyLicenses } from '@pnpm/license-scanner' +import { renderLicences } from './outputRenderer' + +export type LicensesCommandOptions = { + compatible?: boolean + long?: boolean + recursive?: boolean + json?: boolean +} & Pick< +Config, +| 'dev' +| 'dir' +| 'lockfileDir' +| 'registries' +| 'optional' +| 'production' +| 'storeDir' +| 'virtualStoreDir' +| 'modulesDir' +| 'pnpmHomeDir' +> & +Partial> + +export async function licensesList (opts: LicensesCommandOptions) { + const lockfile = await readWantedLockfile(opts.lockfileDir ?? opts.dir, { + ignoreIncompatible: true, + }) + if (lockfile == null) { + throw new PnpmError( + 'LICENSES_NO_LOCKFILE', + `No ${WANTED_LOCKFILE} found: Cannot check a project without a lockfile` + ) + } + + const include = { + dependencies: opts.production !== false, + devDependencies: opts.dev !== false, + optionalDependencies: opts.optional !== false, + } + + const manifest = await readProjectManifestOnly(opts.dir, {}) + + const storeDir = await getStorePath({ + pkgRoot: opts.dir, + storePath: opts.storeDir, + pnpmHomeDir: opts.pnpmHomeDir, + }) + + const licensePackages = await findDependencyLicenses({ + include, + lockfileDir: opts.dir, + storeDir, + virtualStoreDir: opts.virtualStoreDir ?? '.', + modulesDir: opts.modulesDir, + registries: opts.registries, + wantedLockfile: lockfile, + manifest, + }) + + if (licensePackages.length === 0) + return { output: 'No licenses in packages found', exitCode: 0 } + + return renderLicences(licensePackages, opts) +} diff --git a/packages/plugin-commands-licenses/src/outputRenderer.ts b/packages/plugin-commands-licenses/src/outputRenderer.ts new file mode 100644 index 00000000000..e628343a5f3 --- /dev/null +++ b/packages/plugin-commands-licenses/src/outputRenderer.ts @@ -0,0 +1,111 @@ +import { TABLE_OPTIONS } from '@pnpm/cli-utils' +import { LicensePackage } from '@pnpm/license-scanner' +import chalk from 'chalk' +import { table } from '@zkochan/table' +import { groupBy, sortWith } from 'ramda' + +function sortLicensesPackages (licensePackages: readonly LicensePackage[]) { + return sortWith( + [ + (o1: LicensePackage, o2: LicensePackage) => + o1.license.localeCompare(o2.license), + ], + licensePackages + ) +} + +function renderPackageName ({ belongsTo, name: packageName }: LicensePackage) { + switch (belongsTo) { + case 'devDependencies': + return `${packageName} ${chalk.dim('(dev)')}` + case 'optionalDependencies': + return `${packageName} ${chalk.dim('(optional)')}` + default: + return packageName as string + } +} + +function renderPackageLicense ({ license }: LicensePackage) { + const output = license ?? 'Unknown' + return output as string +} + +function renderDetails (licensePackage: LicensePackage) { + const outputs = [] + if (licensePackage.author) { + outputs.push(licensePackage.author) + } + if (licensePackage.homepage) { + outputs.push(chalk.underline(licensePackage.homepage)) + } + return outputs.join('\n') +} + +export function renderLicences ( + licensesMap: LicensePackage[], + opts: { long?: boolean, json?: boolean } +) { + if (opts.json) { + return { output: renderLicensesJson(licensesMap), exitCode: 0 } + } + + return { output: renderLicensesTable(licensesMap, opts), exitCode: 0 } +} + +function renderLicensesJson (licensePackages: readonly LicensePackage[]) { + const data = [ + ...licensePackages.map((licensePkg) => { + return { + name: licensePkg.name, + version: licensePkg.version, + path: licensePkg.path, + license: licensePkg.license, + licenseContents: licensePkg.licenseContents, + author: licensePkg.author, + homepage: licensePkg.homepage, + } as LicensePackageJson + }), + ].flat() + + // Group the package by license + const groupByLicense = groupBy((item: LicensePackageJson) => item.license) + const groupedByLicense = groupByLicense(data) + + return JSON.stringify(groupedByLicense, null, 2) +} + +export interface LicensePackageJson { + name: string + license: string + author: string + homepage: string + path: string +} + +function renderLicensesTable ( + licensePackages: readonly LicensePackage[], + opts: { long?: boolean } +) { + const columnNames = ['Package', 'License'] + + const columnFns = [renderPackageName, renderPackageLicense] + + if (opts.long) { + columnNames.push('Details') + columnFns.push(renderDetails) + } + + // Avoid the overhead of allocating a new array caused by calling `array.map()` + for (let i = 0; i < columnNames.length; i++) + columnNames[i] = chalk.blueBright(columnNames[i]) + + return table( + [ + columnNames, + ...sortLicensesPackages(licensePackages).map((licensePkg) => { + return columnFns.map((fn) => fn(licensePkg)) + }), + ], + TABLE_OPTIONS + ) +} diff --git a/packages/plugin-commands-licenses/test/__snapshots__/index.ts.snap b/packages/plugin-commands-licenses/test/__snapshots__/index.ts.snap new file mode 100644 index 00000000000..4284e210bc7 --- /dev/null +++ b/packages/plugin-commands-licenses/test/__snapshots__/index.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pnpm licenses: output as json: found-license-types 1`] = ` +[ + "MIT", +] +`; + +exports[`pnpm licenses: show details: show-packages-details 1`] = ` +"┌─────────────┬─────────┬─────────────────────────────────────────────┐ +│ Package │ License │ Details │ +├─────────────┼─────────┼─────────────────────────────────────────────┤ +│ is-positive │ MIT │ Kevin Martensson │ +│ │ │ https://github.com/kevva/is-positive#readme │ +└─────────────┴─────────┴─────────────────────────────────────────────┘ +" +`; + +exports[`pnpm licenses: show-packages 1`] = ` +"┌──────────────────┬────────────┐ +│ Package │ License │ +├──────────────────┼────────────┤ +│ typescript (dev) │ Apache-2.0 │ +├──────────────────┼────────────┤ +│ js-tokens │ MIT │ +├──────────────────┼────────────┤ +│ loose-envify │ MIT │ +├──────────────────┼────────────┤ +│ react │ MIT │ +├──────────────────┼────────────┤ +│ react-dom │ MIT │ +├──────────────────┼────────────┤ +│ scheduler │ MIT │ +└──────────────────┴────────────┘ +" +`; diff --git a/packages/plugin-commands-licenses/test/fixtures/.gitignore b/packages/plugin-commands-licenses/test/fixtures/.gitignore new file mode 100644 index 00000000000..b25ca904634 --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/.gitignore @@ -0,0 +1 @@ +store diff --git a/packages/plugin-commands-licenses/test/fixtures/.npmrc b/packages/plugin-commands-licenses/test/fixtures/.npmrc new file mode 100644 index 00000000000..a0bf438cc6b --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/.npmrc @@ -0,0 +1 @@ +shared-workspace-lockfile = false diff --git a/packages/plugin-commands-licenses/test/fixtures/complex-licenses/package.json b/packages/plugin-commands-licenses/test/fixtures/complex-licenses/package.json new file mode 100644 index 00000000000..758f8b1185e --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/complex-licenses/package.json @@ -0,0 +1,24 @@ +{ + "name": "pnpm-licenses", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "volta": { + "node": "18.12.0", + "npm": "8.19.2", + "yarn": "3.2.4" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "typescript": "^4.8.4" + } +} diff --git a/packages/plugin-commands-licenses/test/fixtures/complex-licenses/pnpm-lock.yaml b/packages/plugin-commands-licenses/test/fixtures/complex-licenses/pnpm-lock.yaml new file mode 100644 index 00000000000..35895509dff --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/complex-licenses/pnpm-lock.yaml @@ -0,0 +1,55 @@ +lockfileVersion: 5.4 + +specifiers: + react: ^18.2.0 + react-dom: ^18.2.0 + typescript: ^4.8.4 + +dependencies: + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + +devDependencies: + typescript: 4.8.4 + +packages: + + /js-tokens/4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: false + + /loose-envify/1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + + /react-dom/18.2.0_react@18.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + dev: false + + /react/18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + + /scheduler/0.23.0: + resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /typescript/4.8.4: + resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true diff --git a/packages/plugin-commands-licenses/test/fixtures/pnpm-workspace.yaml b/packages/plugin-commands-licenses/test/fixtures/pnpm-workspace.yaml new file mode 100644 index 00000000000..9a3cd4defa9 --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/pnpm-workspace.yaml @@ -0,0 +1 @@ +# This file is only created so that tests don't look up the pnpm-workspace.yaml in the root diff --git a/packages/plugin-commands-licenses/test/fixtures/simple-licenses/package.json b/packages/plugin-commands-licenses/test/fixtures/simple-licenses/package.json new file mode 100644 index 00000000000..b2cc6220de0 --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/simple-licenses/package.json @@ -0,0 +1,15 @@ +{ + "name": "pnpm-simple-licenses", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "is-positive": "^3.1.0" + } +} diff --git a/packages/plugin-commands-licenses/test/fixtures/simple-licenses/pnpm-lock.yaml b/packages/plugin-commands-licenses/test/fixtures/simple-licenses/pnpm-lock.yaml new file mode 100644 index 00000000000..e609654eaa5 --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/simple-licenses/pnpm-lock.yaml @@ -0,0 +1,14 @@ +lockfileVersion: 5.4 + +specifiers: + is-positive: ^3.1.0 + +dependencies: + is-positive: 3.1.0 + +packages: + + /is-positive/3.1.0: + resolution: {integrity: sha512-8ND1j3y9/HP94TOvGzr69/FgbkX2ruOldhLEsTWwcJVfo4oRjwemJmJxt7RJkKYH8tz7vYBP9JcKQY8CLuJ90Q==} + engines: {node: '>=0.10.0'} + dev: false diff --git a/packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/package.json b/packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/package.json new file mode 100644 index 00000000000..c25ec8ce836 --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/package.json @@ -0,0 +1,18 @@ +{ + "name": "pnpm-with-dev-dependency", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "is-positive": "^3.1.0" + }, + "devDependencies": { + "typescript": "^4.8.4" + } +} diff --git a/packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/pnpm-lock.yaml b/packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/pnpm-lock.yaml new file mode 100644 index 00000000000..5867290b26d --- /dev/null +++ b/packages/plugin-commands-licenses/test/fixtures/with-dev-dependency/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: 5.4 + +specifiers: + is-positive: ^3.1.0 + typescript: ^4.8.4 + +dependencies: + is-positive: 3.1.0 + +devDependencies: + typescript: 4.8.4 + +packages: + + /is-positive/3.1.0: + resolution: {integrity: sha512-8ND1j3y9/HP94TOvGzr69/FgbkX2ruOldhLEsTWwcJVfo4oRjwemJmJxt7RJkKYH8tz7vYBP9JcKQY8CLuJ90Q==} + engines: {node: '>=0.10.0'} + dev: false + + /typescript/4.8.4: + resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true diff --git a/packages/plugin-commands-licenses/test/index.ts b/packages/plugin-commands-licenses/test/index.ts new file mode 100644 index 00000000000..ad382686ca0 --- /dev/null +++ b/packages/plugin-commands-licenses/test/index.ts @@ -0,0 +1,136 @@ +/// +import path from 'path' +import { licenses } from '@pnpm/plugin-commands-licenses' +import { install } from '@pnpm/plugin-commands-installation' +import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock' +import stripAnsi from 'strip-ansi' +import { DEFAULT_OPTS } from './utils' +import tempy from 'tempy' + +const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}` + +const LICENSES_OPTIONS = { + cacheDir: 'cache', + fetchRetries: 1, + fetchRetryFactor: 1, + fetchRetryMaxtimeout: 60, + fetchRetryMintimeout: 10, + global: false, + networkConcurrency: 16, + offline: false, + rawConfig: { registry: REGISTRY_URL }, + registries: { default: REGISTRY_URL }, + strictSsl: false, + tag: 'latest', + userAgent: '', + userConfig: {}, +} + +test('pnpm licenses', async () => { + const workspaceDir = path.resolve('./test/fixtures/complex-licenses') + + const tmp = tempy.directory() + const storeDir = path.join(tmp, 'store') + await install.handler({ + ...DEFAULT_OPTS, + dir: workspaceDir, + pnpmHomeDir: '', + storeDir, + }) + + // Attempt to run the licenses command now + const { output, exitCode } = await licenses.handler({ + ...LICENSES_OPTIONS, + dir: workspaceDir, + pnpmHomeDir: '', + long: false, + // we need to prefix it with v3 otherwise licenses tool can't find anything + // in the content-addressable directory + storeDir: path.resolve(storeDir, 'v3'), + }, ['list']) + + expect(exitCode).toBe(0) + expect(stripAnsi(output)).toMatchSnapshot('show-packages') +}) + +test('pnpm licenses: show details', async () => { + const workspaceDir = path.resolve('./test/fixtures/simple-licenses') + + const tmp = tempy.directory() + const storeDir = path.join(tmp, 'store') + await install.handler({ + ...DEFAULT_OPTS, + dir: workspaceDir, + pnpmHomeDir: '', + storeDir, + }) + + // Attempt to run the licenses command now + const { output, exitCode } = await licenses.handler({ + ...LICENSES_OPTIONS, + dir: workspaceDir, + pnpmHomeDir: '', + long: true, + // we need to prefix it with v3 otherwise licenses tool can't find anything + // in the content-addressable directory + storeDir: path.resolve(storeDir, 'v3'), + }, ['list']) + + expect(exitCode).toBe(0) + expect(stripAnsi(output)).toMatchSnapshot('show-packages-details') +}) + +test('pnpm licenses: output as json', async () => { + const workspaceDir = path.resolve('./test/fixtures/simple-licenses') + + const tmp = tempy.directory() + const storeDir = path.join(tmp, 'store') + await install.handler({ + ...DEFAULT_OPTS, + dir: workspaceDir, + pnpmHomeDir: '', + storeDir, + }) + + // Attempt to run the licenses command now + const { output, exitCode } = await licenses.handler({ + ...LICENSES_OPTIONS, + dir: workspaceDir, + pnpmHomeDir: '', + long: false, + json: true, + // we need to prefix it with v3 otherwise licenses tool can't find anything + // in the content-addressable directory + storeDir: path.resolve(storeDir, 'v3'), + }, ['list']) + + expect(exitCode).toBe(0) + expect(output).not.toHaveLength(0) + expect(output).not.toBe('No licenses in packages found') + const parsedOutput = JSON.parse(output) + expect(Object.keys(parsedOutput)).toMatchSnapshot('found-license-types') + const packagesWithMIT = parsedOutput['MIT'] + expect(packagesWithMIT.length).toBeGreaterThan(0) + expect(Object.keys(packagesWithMIT[0])).toEqual([ + 'name', + 'version', + 'path', + 'license', + 'author', + 'homepage', + ]) + expect(packagesWithMIT[0].name).toBe('is-positive') +}) + +test('pnpm licenses: fails when lockfile is missing', async () => { + await expect( + licenses.handler({ + ...LICENSES_OPTIONS, + dir: path.resolve('./test/fixtures/invalid'), + pnpmHomeDir: '', + long: true, + }, ['list']) + ).rejects.toThrowErrorMatchingInlineSnapshot( + '"No pnpm-lock.yaml found: Cannot check a project without a lockfile"' + ) +}) diff --git a/packages/plugin-commands-licenses/test/utils/index.ts b/packages/plugin-commands-licenses/test/utils/index.ts new file mode 100644 index 00000000000..3a7b96d586e --- /dev/null +++ b/packages/plugin-commands-licenses/test/utils/index.ts @@ -0,0 +1,48 @@ +const REGISTRY = 'https://registry.npmjs.org' + +export const DEFAULT_OPTS = { + argv: { + original: [], + }, + bail: true, + bin: 'node_modules/.bin', + ca: undefined, + cacheDir: '../cache', + cert: undefined, + extraEnv: {}, + cliOptions: {}, + fetchRetries: 2, + fetchRetryFactor: 90, + fetchRetryMaxtimeout: 90, + fetchRetryMintimeout: 10, + filter: [] as string[], + httpsProxy: undefined, + include: { + dependencies: true, + devDependencies: true, + optionalDependencies: true, + }, + key: undefined, + linkWorkspacePackages: true, + localAddress: undefined, + lock: false, + lockStaleDuration: 90, + networkConcurrency: 16, + offline: false, + pending: false, + pnpmfile: './.pnpmfile.cjs', + pnpmHomeDir: '', + proxy: undefined, + rawConfig: { registry: REGISTRY }, + rawLocalConfig: {}, + registries: { default: REGISTRY }, + // registry: REGISTRY, + sort: true, + storeDir: '../store', + strictSsl: false, + userAgent: 'pnpm', + userConfig: {}, + useRunningStoreServer: false, + useStoreServer: false, + workspaceConcurrency: 4, +} diff --git a/packages/plugin-commands-licenses/tsconfig.json b/packages/plugin-commands-licenses/tsconfig.json new file mode 100644 index 00000000000..df44f24266b --- /dev/null +++ b/packages/plugin-commands-licenses/tsconfig.json @@ -0,0 +1,49 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../typings/**/*.d.ts" + ], + "references": [ + { + "path": "../cli-utils" + }, + { + "path": "../command" + }, + { + "path": "../common-cli-options-help" + }, + { + "path": "../config" + }, + { + "path": "../constants" + }, + { + "path": "../error" + }, + { + "path": "../filter-workspace-packages" + }, + { + "path": "../license-scanner" + }, + { + "path": "../lockfile-file" + }, + { + "path": "../plugin-commands-installation" + }, + { + "path": "../read-package-json" + }, + { + "path": "../store-path" + } + ] +} diff --git a/packages/plugin-commands-licenses/tsconfig.lint.json b/packages/plugin-commands-licenses/tsconfig.lint.json new file mode 100644 index 00000000000..0dc5add6b7b --- /dev/null +++ b/packages/plugin-commands-licenses/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../typings/**/*.d.ts" + ] +} diff --git a/packages/pnpm/package.json b/packages/pnpm/package.json index ebf2fef0755..c7e7ba4354b 100644 --- a/packages/pnpm/package.json +++ b/packages/pnpm/package.json @@ -45,6 +45,7 @@ "@pnpm/plugin-commands-env": "workspace:*", "@pnpm/plugin-commands-init": "workspace:*", "@pnpm/plugin-commands-installation": "workspace:*", + "@pnpm/plugin-commands-licenses": "workspace:*", "@pnpm/plugin-commands-listing": "workspace:*", "@pnpm/plugin-commands-outdated": "workspace:*", "@pnpm/plugin-commands-patching": "workspace:*", diff --git a/packages/pnpm/src/cmd/help.ts b/packages/pnpm/src/cmd/help.ts index e7baf7abea7..e91686d786e 100644 --- a/packages/pnpm/src/cmd/help.ts +++ b/packages/pnpm/src/cmd/help.ts @@ -89,6 +89,10 @@ function getHelpText () { description: 'Check for outdated packages', name: 'outdated', }, + { + description: 'Check licenses in consumed packages', + name: 'licenses', + }, ], }, { diff --git a/packages/pnpm/src/cmd/index.ts b/packages/pnpm/src/cmd/index.ts index 720f7d07206..f56eab94494 100644 --- a/packages/pnpm/src/cmd/index.ts +++ b/packages/pnpm/src/cmd/index.ts @@ -6,6 +6,7 @@ import { env } from '@pnpm/plugin-commands-env' import { deploy } from '@pnpm/plugin-commands-deploy' import { add, fetch, install, link, prune, remove, unlink, update, importCommand } from '@pnpm/plugin-commands-installation' import { list, ll, why } from '@pnpm/plugin-commands-listing' +import { licenses } from '@pnpm/plugin-commands-licenses' import { outdated } from '@pnpm/plugin-commands-outdated' import { pack, publish } from '@pnpm/plugin-commands-publishing' import { patch, patchCommit } from '@pnpm/plugin-commands-patching' @@ -112,6 +113,7 @@ const commands: CommandDefinition[] = [ link, list, ll, + licenses, outdated, pack, patch, diff --git a/packages/pnpm/tsconfig.json b/packages/pnpm/tsconfig.json index 399cf79e9b1..839b222b97d 100644 --- a/packages/pnpm/tsconfig.json +++ b/packages/pnpm/tsconfig.json @@ -90,6 +90,9 @@ { "path": "../plugin-commands-installation" }, + { + "path": "../plugin-commands-licenses" + }, { "path": "../plugin-commands-listing" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2013e6b7f3..22589750d05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1807,6 +1807,64 @@ importers: specifier: 4.0.0-rc.27 version: 4.0.0-rc.27_typanion@3.12.0 + packages/license-scanner: + dependencies: + '@pnpm/cafs': + specifier: workspace:* + version: link:../cafs + '@pnpm/directory-fetcher': + specifier: workspace:* + version: link:../directory-fetcher + '@pnpm/error': + specifier: workspace:* + version: link:../error + '@pnpm/lockfile-file': + specifier: workspace:* + version: link:../lockfile-file + '@pnpm/lockfile-types': + specifier: workspace:* + version: link:../lockfile-types + '@pnpm/lockfile-utils': + specifier: workspace:* + version: link:../lockfile-utils + '@pnpm/lockfile-walker': + specifier: workspace:* + version: link:../lockfile-walker + '@pnpm/logger': + specifier: ^5.0.0 + version: 5.0.0 + '@pnpm/package-is-installable': + specifier: workspace:* + version: link:../package-is-installable + '@pnpm/read-package-json': + specifier: workspace:* + version: link:../read-package-json + '@pnpm/types': + specifier: workspace:* + version: link:../types + dependency-path: + specifier: workspace:* + version: link:../dependency-path + load-json-file: + specifier: ^6.2.0 + version: 6.2.0 + p-limit: + specifier: ^3.1.0 + version: 3.1.0 + path-absolute: + specifier: ^1.0.1 + version: 1.0.1 + devDependencies: + '@pnpm/constants': + specifier: workspace:* + version: link:../constants + '@pnpm/license-scanner': + specifier: workspace:* + version: 'link:' + '@types/ramda': + specifier: 0.28.15 + version: 0.28.15 + packages/lifecycle: dependencies: '@pnpm/core-loggers': @@ -3483,6 +3541,79 @@ importers: specifier: ^4.2.0 version: 4.2.0 + packages/plugin-commands-licenses: + dependencies: + '@pnpm/cli-utils': + specifier: workspace:* + version: link:../cli-utils + '@pnpm/command': + specifier: workspace:* + version: link:../command + '@pnpm/common-cli-options-help': + specifier: workspace:* + version: link:../common-cli-options-help + '@pnpm/config': + specifier: workspace:* + version: link:../config + '@pnpm/constants': + specifier: workspace:* + version: link:../constants + '@pnpm/error': + specifier: workspace:* + version: link:../error + '@pnpm/license-scanner': + specifier: workspace:* + version: link:../license-scanner + '@pnpm/lockfile-file': + specifier: workspace:* + version: link:../lockfile-file + '@pnpm/store-path': + specifier: workspace:* + version: link:../store-path + '@zkochan/table': + specifier: ^1.0.0 + version: 1.0.0 + chalk: + specifier: ^4.1.2 + version: 4.1.2 + ramda: + specifier: npm:@pnpm/ramda@0.28.1 + version: /@pnpm/ramda/0.28.1 + render-help: + specifier: ^1.0.2 + version: 1.0.2 + devDependencies: + '@pnpm/filter-workspace-packages': + specifier: workspace:* + version: link:../filter-workspace-packages + '@pnpm/plugin-commands-installation': + specifier: workspace:* + version: link:../plugin-commands-installation + '@pnpm/plugin-commands-licenses': + specifier: workspace:* + version: 'link:' + '@pnpm/read-package-json': + specifier: workspace:* + version: link:../read-package-json + '@pnpm/registry-mock': + specifier: 3.1.0 + version: 3.1.0_typanion@3.12.0 + '@types/ramda': + specifier: 0.28.15 + version: 0.28.15 + '@types/wrap-ansi': + specifier: ^3.0.0 + version: 3.0.0 + '@types/zkochan__table': + specifier: npm:@types/table@6.0.0 + version: /@types/table/6.0.0 + strip-ansi: + specifier: ^6.0.1 + version: 6.0.1 + tempy: + specifier: ^1.0.1 + version: 1.0.1 + packages/plugin-commands-listing: dependencies: '@pnpm/cli-utils': @@ -4350,6 +4481,9 @@ importers: '@pnpm/plugin-commands-installation': specifier: workspace:* version: link:../plugin-commands-installation + '@pnpm/plugin-commands-licenses': + specifier: workspace:* + version: link:../plugin-commands-licenses '@pnpm/plugin-commands-listing': specifier: workspace:* version: link:../plugin-commands-listing