Skip to content

Commit

Permalink
feat: create licenses-command for PNPM
Browse files Browse the repository at this point in the history
Introduces a new command `licenses`-command which allows to list
the licenses of the packages

refs pnpm#2825
  • Loading branch information
Weyert de Boer authored and zkochan committed Nov 17, 2022
1 parent eb6ccbb commit 5674706
Show file tree
Hide file tree
Showing 28 changed files with 2,539 additions and 0 deletions.
17 changes: 17 additions & 0 deletions packages/licenses/README.md
@@ -0,0 +1,17 @@
# @pnpm/licenses

> Check for the licensse of packages
<!--@shields('npm')-->
[![npm version](https://img.shields.io/npm/v/@pnpm/licenses.svg)](https://www.npmjs.com/package/@pnpm/licenses)
<!--/@-->

## Installation

```sh
pnpm add @pnpm/licenses
```

## License

[MIT](LICENSE)
1 change: 1 addition & 0 deletions packages/licenses/jest.config.js
@@ -0,0 +1 @@
module.exports = require('../../jest.config.js');
66 changes: 66 additions & 0 deletions packages/licenses/package.json
@@ -0,0 +1,66 @@
{
"name": "@pnpm/licenses",
"version": "11.0.2",
"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/licenses",
"keywords": [
"pnpm7",
"pnpm",
"outdated"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/packages/licenses#readme",
"peerDependencies": {
"@pnpm/logger": "^5.0.0"
},
"dependencies": {
"@pnpm/client": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/lockfile-file": "workspace:*",
"@pnpm/lockfile-utils": "workspace:*",
"@pnpm/manifest-utils": "workspace:*",
"@pnpm/matcher": "workspace:*",
"@pnpm/modules-yaml": "workspace:*",
"@pnpm/npm-resolver": "workspace:*",
"@pnpm/pick-registry-for-package": "workspace:*",
"@pnpm/read-package-json": "workspace:*",
"@pnpm/types": "workspace:*",
"dependency-path": "workspace:*",
"p-limit": "^3.1.0",
"ramda": "npm:@pnpm/ramda@0.28.1",
"semver": "^7.3.8"
},
"devDependencies": {
"@pnpm/licenses": "workspace:*",
"@types/ramda": "0.28.15",
"@types/semver": "7.3.12",
"npm-run-all": "^4.1.5"
},
"funding": "https://opencollective.com/pnpm",
"exports": {
".": "./lib/index.js"
}
}
131 changes: 131 additions & 0 deletions packages/licenses/src/getPkgInfo.ts
@@ -0,0 +1,131 @@
import path from 'node:path'
import fs from 'node:fs/promises'
import { readPackageJson } from '@pnpm/read-package-json'
import pLimit from 'p-limit'
import { PackageManifest } from '@pnpm/types'

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']

export type 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
}

function parseLicenseManifestField (field: unknown) {
if (Array.isArray(field)) {
const licenses = field
const licenseTypes = licenses.reduce((listOfLicenseTypes, license) => {
const type = coerceToString(license.type)
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)
}
}

/**
*
* @param {*} packageInfo
* @returns
*/
async function parseLicense (packageInfo: {
manifest: PackageManifest
path: string
}): Promise<LicenseInfo> {
const license = parseLicenseManifestField(packageInfo.manifest.license)

// check if we discovered a license, if not attempt to parse the LICENSE file
if (
(!license || /see license/i.test(license))
) {
for (const filename of LICENSE_FILES) {
try {
const licensePath = path.join(packageInfo.path, filename)
const licenseContents = await fs.readFile(licensePath)
return {
name: 'Unknown',
licenseFile: licenseContents.toString('utf-8'),
}
} catch (err) {
// NOOP
}
}
}

return { name: license ?? 'Unknown' }
}

export async function getPkgInfo (
pkg: {
alias: string
name: string
version: string
prefix: string
}
) {
let manifest
let packageModulePath
let licenseInfo: LicenseInfo
try {
packageModulePath = path.join(pkg.prefix, 'node_modules', pkg.name)
const packageManifestPath = path.join(packageModulePath, 'package.json')
manifest = await readPkg(packageManifestPath)

licenseInfo = await parseLicense({ manifest, path: packageModulePath })

manifest.license = licenseInfo.name
} catch (err: any) { // eslint-disable-line
// This will probably never happen
throw new Error(`Failed to fetch manifest data for ${pkg.name}`)
}

return {
packageManifest: manifest,
packageInfo: {
alias: pkg.alias,
from: pkg.name,
path: packageModulePath,
version: pkg.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,
},
}
}
2 changes: 2 additions & 0 deletions packages/licenses/src/index.ts
@@ -0,0 +1,2 @@
export { licensesDepsOfProjects } from './licensesDepsOfProjects'
export { LicensePackage } from './licenses'
125 changes: 125 additions & 0 deletions packages/licenses/src/licenses.ts
@@ -0,0 +1,125 @@
import { WANTED_LOCKFILE } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import {
getLockfileImporterId,
Lockfile,
} from '@pnpm/lockfile-file'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile-utils'
import { getAllDependenciesFromManifest } from '@pnpm/manifest-utils'
import {
DependenciesField,
DEPENDENCIES_FIELDS,
IncludedDependencies,
PackageManifest,
ProjectManifest,
Registries,
} from '@pnpm/types'
import * as dp from 'dependency-path'
import { getPkgInfo } from './getPkgInfo'

export interface LicensePackage {
alias: string
belongsTo: DependenciesField
version: string
packageManifest?: PackageManifest
packageName: string
license: string
licenseContents?: string
author?: string
packageDirectory?: string
}

export async function licences (
opts: {
compatible?: boolean
currentLockfile: Lockfile | null
ignoreDependencies?: Set<string>
include?: IncludedDependencies
lockfileDir: string
manifest: ProjectManifest
match?: (dependencyName: string) => boolean
prefix: string
registries: Registries
wantedLockfile: Lockfile | null
}
): Promise<LicensePackage[]> {
if (packageHasNoDeps(opts.manifest)) return []
if (opts.wantedLockfile == null) {
throw new PnpmError('LICENSES_NO_LOCKFILE', `No lockfile in directory "${opts.lockfileDir}". Run \`pnpm install\` to generate one.`)
}
const allDeps = getAllDependenciesFromManifest(opts.manifest)
const importerId = getLockfileImporterId(opts.lockfileDir, opts.prefix)
const licenses: LicensePackage[] = []

await Promise.all(
DEPENDENCIES_FIELDS.map(async (depType) => {
if (
opts.include?.[depType] === false ||
(opts.wantedLockfile!.importers[importerId][depType] == null)
) return

let pkgs = Object.keys(opts.wantedLockfile!.importers[importerId][depType]!)
if (opts.match != null) {
pkgs = pkgs.filter((pkgName) => opts.match!(pkgName))
}

await Promise.all(
pkgs.map(async (alias) => {
if (!allDeps[alias]) return
const ref = opts.wantedLockfile!.importers[importerId][depType]![alias]
if (
ref.startsWith('file:') || // ignoring linked packages. (For backward compatibility)
opts.ignoreDependencies?.has(alias)
) {
return
}

const relativeDepPath = dp.refToRelative(ref, alias)

// ignoring linked packages
if (relativeDepPath === null) return

const pkgSnapshot = opts.wantedLockfile!.packages?.[relativeDepPath]
if (pkgSnapshot == null) {
throw new Error(`Invalid ${WANTED_LOCKFILE} file. ${relativeDepPath} not found in packages field`)
}

const { name: packageName, version: packageVersion } = nameVerFromPkgSnapshot(relativeDepPath, pkgSnapshot)
const name = dp.parse(relativeDepPath).name ?? packageName

// Fetch the most recent package by the give name
const { packageManifest, packageInfo } = await getPkgInfo({
alias,
name,
version: packageVersion,
prefix: opts.prefix,
})

licenses.push({
alias,
belongsTo: depType,
version: packageVersion,
packageManifest,
packageName,
license: packageInfo.license,
licenseContents: packageInfo.licenseContents,
author: packageInfo.author,
packageDirectory: packageInfo.path,
})
})
)
})
)

return licenses.sort((pkg1, pkg2) => pkg1.packageName.localeCompare(pkg2.packageName))
}

function packageHasNoDeps (manifest: ProjectManifest) {
return ((manifest.dependencies == null) || isEmpty(manifest.dependencies)) &&
((manifest.devDependencies == null) || isEmpty(manifest.devDependencies)) &&
((manifest.optionalDependencies == null) || isEmpty(manifest.optionalDependencies))
}

function isEmpty (obj: object) {
return Object.keys(obj).length === 0
}

0 comments on commit 5674706

Please sign in to comment.