UNPKG

oss-attribution-generator

Version:

utility to parse bower and npm packages used in a project and generate an attribution file to include in your product

382 lines (330 loc) 13.5 kB
#!/usr/bin/env node // usage var yargs = require('yargs') .usage('Calculate the npm and bower modules used in this project and generate a third-party attribution (credits) text.', { outputDir: { alias: 'o', default: './oss-attribution' }, baseDir: { alias: 'b', default: process.cwd(), } }) .array('baseDir') .example('$0 -o ./tpn', 'run the tool and output text and backing json to ${projectRoot}/tpn directory.') .example('$0 -b ./some/path/to/projectDir', 'run the tool for Bower/NPM projects in another directory.') .example('$0 -o tpn -b ./some/path/to/projectDir', 'run the tool in some other directory and dump the output in a directory called "tpn" there.'); if (yargs.argv.help) { yargs.showHelp(); process.exit(1); } // dependencies var bluebird = require('bluebird'); var _ = require('lodash'); var npmchecker = require('license-checker'); var bower = require('bower'); var path = require('path'); var jetpack = require('fs-jetpack'); var cp = require('child_process'); var os = require('os'); var taim = require('taim'); // const var licenseCheckerCustomFormat = { name: '', version: '', description: '', repository: '', publisher: '', email: '', url: '', licenses: '', licenseFile: '', licenseModified: false } /** * Helpers */ function getAttributionForAuthor(a) { return _.isString(a) ? a : a.name + ((a.email || a.homepage || a.url) ? ` <${a.email || a.homepage || a.url}>` : ''); } function getNpmLicenses() { var npmDirs; if (!Array.isArray(options.baseDir)) { npmDirs = [options.baseDir]; } else { npmDirs = options.baseDir; } // first - check that this is even an NPM project for (var i = 0; i < npmDirs.length; i++) { if (!jetpack.exists(path.join(npmDirs[i], 'package.json'))) { console.log('directory at "' + npmDirs[i] + '" does not look like an NPM project, skipping NPM checks for path ' + npmDirs[i]); return []; } } console.log('Looking at directories: ' + npmDirs) var res = [] var checkers = []; for (var i = 0; i < npmDirs.length; i++) { checkers.push( bluebird.fromCallback((cb) => { var dir = npmDirs[i]; return npmchecker.init({ start: npmDirs[i], production: true, customFormat: licenseCheckerCustomFormat }, function (err, json) { if (err) { //Handle error console.error(err); } else { Object.getOwnPropertyNames(json).forEach(k => { json[k]['dir'] = dir; }) } cb(err, json); }); }) ); } if (checkers.length === 0) { return []; } return bluebird.all(checkers) .then((raw_result) => { // the result is passed in as an array, one element per npmDir passed in // de-dupe the entries and merge it into a single object var merged = {}; for (var i = 0; i < raw_result.length; i++) { merged = Object.assign(raw_result[i], merged); } return merged; }).then((result) => { // we want to exclude the top-level project from being included var dir = result[Object.keys(result)[0]]['dir']; var topLevelProjectInfo = jetpack.read(path.join(dir, 'package.json'), 'json'); var keys = Object.getOwnPropertyNames(result).filter((k) => { return k !== `${topLevelProjectInfo.name}@${topLevelProjectInfo.version}`; }); return bluebird.map(keys, (key) => { console.log('processing', key); var package = result[key]; var defaultPackagePath = `${package['dir']}/node_modules/${package.name}/package.json`; var itemAtPath = jetpack.exists(defaultPackagePath); var packagePath = [defaultPackagePath]; if (itemAtPath !== 'file') { packagePath = jetpack.find(package['dir'], { matching: `**/node_modules/${package.name}/package.json` }); } var packageJson = ""; if (packagePath && packagePath[0]) { packageJson = jetpack.read(packagePath[0], 'json'); } else { return Promise.reject(`${package.name}: unable to locate package.json`); } console.log('processing', packageJson.name, 'for authors and licenseText'); var props = {}; props.authors = (packageJson.author && getAttributionForAuthor(packageJson.author)) || (packageJson.contributors && packageJson.contributors .map(c => { return getAttributionForAuthor(c); }).join(', ')) || (packageJson.maintainers && packageJson.maintainers .map(m => { return getAttributionForAuthor(m); }).join(', ')); var licenseFile = package.licenseFile; try { if (licenseFile && jetpack.exists(licenseFile) && path.basename(licenseFile).match(/license/i)) { props.licenseText = jetpack.read(licenseFile); } else { props.licenseText = ''; } } catch (e) { console.warn(e); return { authors: '', licenseText: '' }; } return { ignore: false, name: package.name, version: package.version, authors: props.authors, url: package.repository, license: package.licenses, licenseText: props.licenseText }; }, { concurrency: os.cpus().length }); }); } /** * TL;DR - normalizing the output format for NPM & Bower license info * * The output from license-checker gives us what we need: * - component name * - version * - authors (note: not returned by license-checker, we have to apply our heuristic) * - url * - license(s) * - license contents OR license snippet (in case of license embedded in markdown) * * Where we calculate the license information manually for Bower components, * we'll return an object with these properties. */ function getBowerLicenses() { // first - check that this is even a bower project var baseDir; if (Array.isArray(options.baseDir)) { baseDir = options.baseDir[0]; if (options.baseDir.length > 1) { console.warn("Checking multiple directories is not yet supported for Bower projects.\n" + "Checking only the first directory: " + baseDir); } } if (!jetpack.exists(path.join(baseDir, 'bower.json'))) { console.log('this does not look like a Bower project, skipping Bower checks.'); return []; } bower.config.cwd = baseDir; var bowerComponentsDir = path.join(bower.config.cwd, bower.config.directory); return jetpack.inspectTreeAsync(bowerComponentsDir, { relativePath: true }) .then((result) => { /** * for each component, try to calculate the license from the NPM package info * if it is a available because license-checker more closely aligns with our * objective. */ return bluebird.map(result.children, (component) => { var absPath = path.join(bowerComponentsDir, component.relativePath); // npm license check didn't work // try to get the license and package info from .bower.json first // because it has more metadata than the plain bower.json var package = ''; try { package = jetpack.read(path.join(absPath, '.bower.json'), 'json'); } catch (e) { package = jetpack.read(path.join(absPath, 'bower.json'), 'json'); } console.log('processing', package.name); // assumptions here based on https://github.com/bower/spec/blob/master/json.md // extract necessary properties as described in TL;DR above var url = package["_source"] || (package.repository && package.repository.url) || package.url || package.homepage; var authors = ''; if (package.authors) { authors = _.map(package.authors, a => { return getAttributionForAuthor(a); }).join(', '); } else { // extrapolate author from url if it's a github repository var githubMatch = url.match(/github\.com\/.*\//); if (githubMatch) { authors = githubMatch[0] .replace('github.com', '') .replace(/\//g, ''); } } // normalize the license object package.license = package.license || package.licenses; var licenses = package.license && _.isString(package.license) ? package.license : _.isArray(package.license) ? package.license.join(',') : package.licenses; // find the license file if it exists var licensePath = _.find(component.children, c => { return /licen[cs]e/i.test(c.name); }); var licenseText = null; if (licensePath) { licenseText = jetpack.read(path.join(bowerComponentsDir, licensePath.relativePath)); } return { ignore: false, name: package.name, version: package.version || package['_release'], authors: authors, url: url, license: licenses, licenseText: licenseText }; }, { concurrency: os.cpus().length }); }); } /*********************** * * MAIN * ***********************/ // sanitize inputs var options = { baseDir: [], outputDir: path.resolve(yargs.argv.outputDir) }; for (var i = 0; i < yargs.argv.baseDir.length; i++) { options.baseDir.push(path.resolve(yargs.argv.baseDir[i])); } taim('Total Processing', bluebird.all([ taim('Npm Licenses', getNpmLicenses()), getBowerLicenses() ])) .catch((err) => { console.log(err); process.exit(1); }) .spread((npmOutput, bowerOutput) => { var o = {}; npmOutput = npmOutput || {}; bowerOutput = bowerOutput || {}; _.concat(npmOutput, bowerOutput).forEach((v) => { o[v.name] = v; }); var userOverridesPath = path.join(options.outputDir, 'overrides.json'); if (jetpack.exists(userOverridesPath)) { var userOverrides = jetpack.read(userOverridesPath, 'json'); console.log('using overrides:', userOverrides); // foreach override, loop through the properties and assign them to the base object. o = _.defaultsDeep(userOverrides, o); } return o; }) .catch(e => { console.error('ERROR processing overrides', e); process.exit(1); }) .then((licenseInfos) => { var attributionSequence = _(licenseInfos).filter(licenseInfo => { return !licenseInfo.ignore && licenseInfo.name != undefined; }).sortBy(licenseInfo => { return licenseInfo.name.toLowerCase(); }).map(licenseInfo => { return [licenseInfo.name,`${licenseInfo.version} <${licenseInfo.url}>`, licenseInfo.licenseText || `license: ${licenseInfo.license}${os.EOL}authors: ${licenseInfo.authors}`].join(os.EOL); }).value(); var attribution = attributionSequence.join(`${os.EOL}${os.EOL}******************************${os.EOL}${os.EOL}`); var headerPath = path.join(options.outputDir, 'header.txt'); if (jetpack.exists(headerPath)) { var template = jetpack.read(headerPath); console.log('using template', template); attribution = template + os.EOL + os.EOL + attribution; } jetpack.write(path.join(options.outputDir, 'licenseInfos.json'), JSON.stringify(licenseInfos)); return jetpack.write(path.join(options.outputDir, 'attribution.txt'), attribution); }) .catch(e => { console.error('ERROR writing attribution file', e); process.exit(1); }) .then(() => { console.log('done'); process.exit(); });