diff --git a/packages/cli/generators/copyright/fs.js b/packages/cli/generators/copyright/fs.js new file mode 100644 index 000000000000..d9b672c82a83 --- /dev/null +++ b/packages/cli/generators/copyright/fs.js @@ -0,0 +1,46 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const fse = require('fs-extra'); +const _ = require('lodash'); +const {promisify} = require('util'); +const glob = promisify(require('glob')); + +const defaultFS = { + write: fse.writeFile, + read: fse.readFile, + writeJSON: fse.writeJson, + readJSON: fse.readJson, + exists: fse.exists, +}; + +/** + * List all JS/TS files + * @param {string[]} paths - Paths to search + */ +async function jsOrTsFiles(cwd, paths = []) { + paths = [].concat(paths); + let globs; + if (paths.length === 0) { + globs = [glob('**/*.{js,ts}', {nodir: true, follow: false, cwd})]; + } else { + globs = paths.map(p => { + if (/\/$/.test(p)) { + p += '**/*.{js,ts}'; + } else if (!/[^*]\.(js|ts)$/.test(p)) { + p += '/**/*.{js,ts}'; + } + return glob(p, {nodir: true, follow: false, cwd}); + }); + } + paths = await Promise.all(globs); + paths = _.flatten(paths); + return _.filter(paths, /\.(js|ts)$/); +} + +exports.FSE = defaultFS; +exports.jsOrTsFiles = jsOrTsFiles; diff --git a/packages/cli/generators/copyright/git.js b/packages/cli/generators/copyright/git.js index 98179a81dc63..d9a0ef2730f9 100644 --- a/packages/cli/generators/copyright/git.js +++ b/packages/cli/generators/copyright/git.js @@ -10,8 +10,6 @@ const cp = require('child_process'); const util = require('util'); const debug = require('debug')('loopback:cli:copyright:git'); -module.exports = git; - const cache = new Map(); /** @@ -34,7 +32,8 @@ async function git(cwd, ...args) { .filter() .value(); if (err) { - reject(err); + // reject(err); + resolve([]); } else { cache.set(key, stdout); debug('Stdout', stdout); @@ -43,3 +42,33 @@ async function git(cwd, ...args) { }); }); } + +/** + * Inspect years for a given file based on git history + * @param {string} file - JS/TS file + */ +async function getYears(file) { + file = file || '.'; + let dates = await git( + process.cwd(), + '--no-pager log --pretty=%%ai --all -- %s', + file, + ); + debug('Dates for %s', file, dates); + if (_.isEmpty(dates)) { + // if the given path doesn't have any git history, assume it is new + dates = [new Date().toJSON()]; + } else { + dates = [_.head(dates), _.last(dates)]; + } + const years = _.map(dates, getYear); + return _.uniq(years).sort(); +} + +// assumes ISO-8601 (or similar) format +function getYear(str) { + return str.slice(0, 4); +} + +exports.git = git; +exports.getYears = getYears; diff --git a/packages/cli/generators/copyright/header.js b/packages/cli/generators/copyright/header.js index 5106d1be4ed0..99bfd112518a 100644 --- a/packages/cli/generators/copyright/header.js +++ b/packages/cli/generators/copyright/header.js @@ -4,14 +4,13 @@ // License text available at https://opensource.org/licenses/MIT const _ = require('lodash'); -const git = require('./git'); +const {git, getYears} = require('./git'); const path = require('path'); -const fs = require('fs-extra'); +const {FSE, jsOrTsFiles} = require('./fs'); const chalk = require('chalk'); const Project = require('@lerna/project'); -const {promisify} = require('util'); -const glob = promisify(require('glob')); +const {spdxLicenseList} = require('./license'); const debug = require('debug')('loopback:cli:copyright'); @@ -40,57 +39,19 @@ const ANY = COPYRIGHT.concat(LICENSE, CUSTOM_LICENSE).map( l => new RegExp(l.replace(/<%[^>]+%>/g, '.*')), ); -const spdxLicenses = require('spdx-license-list'); -const spdxLicenseList = {}; -for (const id in spdxLicenses) { - spdxLicenseList[id.toLowerCase()] = {id, ...spdxLicenses[id]}; -} - /** - * Inspect years for a given file based on git history - * @param {string} file - JS/TS file - */ -async function copyYears(file) { - file = file || '.'; - let dates = await git( - process.cwd(), - '--no-pager log --pretty=%%ai --all -- %s', - file, - ); - debug('Dates for %s', file, dates); - if (_.isEmpty(dates)) { - // if the given path doesn't have any git history, assume it is new - dates = [new Date().toJSON()]; - } else { - dates = [_.head(dates), _.last(dates)]; - } - const years = _.map(dates, getYear); - return _.uniq(years).sort(); -} - -// assumes ISO-8601 (or similar) format -function getYear(str) { - return str.slice(0, 4); -} - -/** - * Copy header for a file + * Build header for a file * @param {string} file - JS/TS file * @param {object} pkg - Package json object * @param {object} options - Options */ -async function copyHeader(file, pkg, options) { +async function buildHeader(file, pkg, options) { const license = options.license || _.get(pkg, 'license') || options.defaultLicense; - const years = await copyYears(file); + const years = await getYears(file); const params = expandLicense(license); params.years = years.join(','); - const owner = - options.copyrightOwner || - _.get(pkg, 'copyright.owner') || - _.get(pkg, 'author.name') || - options.defaultCopyrightOwner || - 'Owner'; + const owner = getCopyrightOwner(pkg, options); const name = options.copyrightIdentifer || @@ -109,6 +70,16 @@ async function copyHeader(file, pkg, options) { return params.template(params); } +function getCopyrightOwner(pkg, options) { + return ( + options.copyrightOwner || + _.get(pkg, 'copyright.owner') || + _.get(pkg, 'author.name') || + options.defaultCopyrightOwner || + 'Owner' + ); +} + /** * Build the license template params * @param {string|object} spdxLicense - SPDX license id or object @@ -137,7 +108,7 @@ function expandLicense(spdxLicense) { * @param {object} options - Options */ async function formatHeader(file, pkg, options) { - const header = await copyHeader(file, pkg, options); + const header = await buildHeader(file, pkg, options); return header.split('\n').map(line => `// ${line}`); } @@ -148,12 +119,13 @@ async function formatHeader(file, pkg, options) { * @param {object} options - Options */ async function ensureHeader(file, pkg, options = {}) { + const fs = options.fs || FSE; const header = await formatHeader(file, pkg, options); debug('Header: %s', header); - const current = await fs.readFile(file, 'utf8'); + const current = await fs.read(file, 'utf8'); const content = mergeHeaderWithContent(header, current); if (!options.dryRun) { - await fs.writeFile(file, content, 'utf8'); + await fs.write(file, content, 'utf8'); } else { const log = options.log || console.log; log(file, header); @@ -185,30 +157,6 @@ function headerOrBlankLine(line) { return BLANK.test(line) || ANY.some(pat => pat.test(line)); } -/** - * List all JS/TS files - * @param {string[]} paths - Paths to search - */ -async function jsOrTsFiles(cwd, paths = []) { - paths = [].concat(paths); - let globs; - if (paths.length === 0) { - globs = [glob('**/*.{js,ts}', {nodir: true, follow: false, cwd})]; - } else { - globs = paths.map(p => { - if (/\/$/.test(p)) { - p += '**/*.{js,ts}'; - } else if (!/[^*]\.(js|ts)$/.test(p)) { - p += '/**/*.{js,ts}'; - } - return glob(p, {nodir: true, follow: false, cwd}); - }); - } - paths = await Promise.all(globs); - paths = _.flatten(paths); - return _.filter(paths, /\.(js|ts)$/); -} - /** * Update file headers for the given project * @param {string} projectRoot - Root directory of a package or a lerna monorepo @@ -222,6 +170,7 @@ async function updateFileHeaders(projectRoot, options = {}) { ...options, }; + const fs = options.fs || FSE; const isMonorepo = await fs.exists(path.join(projectRoot, 'lerna.json')); if (isMonorepo) { // List all packages for the monorepo @@ -275,12 +224,13 @@ async function updateFileHeadersForSinglePackage(projectRoot, options) { debug('Project root: %s', projectRoot); const log = options.log || console.log; const pkgFile = path.join(projectRoot, 'package.json'); + const fs = options.fs || FSE; const exists = await fs.exists(pkgFile); if (!exists) { log(chalk.red(`No package.json exists at ${projectRoot}`)); return; } - const pkg = await fs.readJson(pkgFile); + const pkg = await fs.readJSON(pkgFile); log( 'Updating project %s (%s)', @@ -301,7 +251,7 @@ async function updateFileHeadersForSinglePackage(projectRoot, options) { } exports.updateFileHeaders = updateFileHeaders; -exports.spdxLicenseList = spdxLicenseList; +exports.getYears = getYears; if (require.main === module) { updateFileHeaders(process.cwd()).catch(err => { diff --git a/packages/cli/generators/copyright/index.js b/packages/cli/generators/copyright/index.js index 469b8203c799..7825ad307a8f 100644 --- a/packages/cli/generators/copyright/index.js +++ b/packages/cli/generators/copyright/index.js @@ -5,7 +5,8 @@ 'use strict'; const BaseGenerator = require('../../lib/base-generator'); -const {updateFileHeaders, spdxLicenseList} = require('./header'); +const {updateFileHeaders} = require('./header'); +const {spdxLicenseList, updateLicense} = require('./license'); const g = require('../../lib/globalize'); const _ = require('lodash'); const autocomplete = require('inquirer-autocomplete-prompt'); @@ -50,6 +51,11 @@ module.exports = class CopyrightGenerator extends BaseGenerator { required: false, description: g.f('License'), }); + this.option('updateLicense', { + type: Boolean, + required: false, + description: g.f('Update license in package.json and LICENSE'), + }); this.option('gitOnly', { type: Boolean, required: false, @@ -66,6 +72,7 @@ module.exports = class CopyrightGenerator extends BaseGenerator { this.exit(`${pkgFile} does not exist.`); return; } + this.packageJson = pkg; let author = _.get(pkg, 'author'); if (typeof author === 'object') { author = author.name; @@ -120,9 +127,32 @@ module.exports = class CopyrightGenerator extends BaseGenerator { license: answers.license || this.options.license, log: this.log, gitOnly: this.options.gitOnly, + fs: this.fs, }; } + async updateLicense() { + if (this.shouldExit()) return; + const answers = await this.prompt([ + { + type: 'confirm', + name: 'updateLicense', + message: g.f('Do you want to update package.json and LICENSE?'), + default: false, + when: this.options.updateLicense == null, + }, + ]); + const updateLicenseFile = + (answers && answers.updateLicense) || this.options.updateLicense; + if (!updateLicenseFile) return; + this.headerOptions.updateLicense = updateLicenseFile; + await updateLicense( + this.destinationRoot(), + this.packageJson, + this.headerOptions, + ); + } + async updateHeaders() { if (this.shouldExit()) return; await updateFileHeaders(this.destinationRoot(), this.headerOptions); diff --git a/packages/cli/generators/copyright/license.js b/packages/cli/generators/copyright/license.js new file mode 100644 index 000000000000..fd25872aa0b9 --- /dev/null +++ b/packages/cli/generators/copyright/license.js @@ -0,0 +1,99 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const path = require('path'); +const {FSE} = require('./fs'); +const {getYears} = require('./git'); +const spdxLicenses = require('spdx-license-list/full'); +const spdxLicenseList = {}; +for (const id in spdxLicenses) { + spdxLicenseList[id.toLowerCase()] = {id, ...spdxLicenses[id]}; +} + +/** + * Render license text + * @param name - Module name + * @param owner - Copyright owner + * @param years - Years of update + * @param license - License object + */ +function renderLicense({name, owner, years, license}) { + if (typeof license === 'string') { + license = spdxLicenseList[license.toLowerCase()]; + } + const text = replaceCopyRight(license.licenseText, {owner, years}); + return `Copyright (c) ${owner} ${years}. All Rights Reserved. +Node module: ${name} +This project is licensed under the ${license.name}, full text below. + +-------- + +${license.name} + +${text} +`; + /* +Copyright (c) IBM Corp. 2018,2019. All Rights Reserved. +Node module: @loopback/boot +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +} + +function replaceCopyRight(text, {owner, years}) { + // Copyright (c) + return text + .replace( + /Copyright \(c\) <[^<>]+> <[^<>]+>/gim, + `Copyright (c) ${owner} ${years}`, + ) + .replace(/\n\n/gm, '\n'); +} + +async function updateLicense(projectRoot, pkg, options) { + if (!options.updateLicense) return; + const fs = options.fs || FSE; + let licenseId = options.license; + if (typeof licenseId === 'object') { + licenseId = licenseId.id; + } + pkg.license = licenseId; + pkg['copyright.owner'] = options.copyrightOwner; + await fs.writeJSON(path.join(projectRoot, 'package.json'), pkg); + await fs.write( + path.join(projectRoot, 'LICENSE'), + renderLicense({ + name: pkg.name, + owner: options.copyrightOwner, + license: options.license, + years: await getYears(projectRoot), + }), + ); +} + +exports.renderLicense = renderLicense; +exports.spdxLicenseList = spdxLicenseList; +exports.updateLicense = updateLicense; diff --git a/packages/cli/test/integration/generators/copyright-git.integration.js b/packages/cli/test/integration/generators/copyright-git.integration.js index e0997daca870..d01702f9d579 100644 --- a/packages/cli/test/integration/generators/copyright-git.integration.js +++ b/packages/cli/test/integration/generators/copyright-git.integration.js @@ -8,10 +8,10 @@ const path = require('path'); const fs = require('fs-extra'); const assert = require('yeoman-assert'); -const git = require('../../../generators/copyright/git'); +const {git} = require('../../../generators/copyright/git'); const generator = path.join(__dirname, '../../../generators/copyright'); -const {spdxLicenseList} = require('../../../generators/copyright/header'); +const {spdxLicenseList} = require('../../../generators/copyright/license'); const FIXTURES = path.join(__dirname, '../../fixtures/copyright'); const LOCATION = 'single-package'; const PROJECT_ROOT = path.join(FIXTURES, LOCATION); diff --git a/packages/cli/test/integration/generators/copyright-monorepo.integration.js b/packages/cli/test/integration/generators/copyright-monorepo.integration.js index b1eade6beb95..85900d979c62 100644 --- a/packages/cli/test/integration/generators/copyright-monorepo.integration.js +++ b/packages/cli/test/integration/generators/copyright-monorepo.integration.js @@ -14,7 +14,7 @@ const generator = path.join(__dirname, '../../../generators/copyright'); const SANDBOX_FILES = require('../../fixtures/copyright/monorepo') .SANDBOX_FILES; const testUtils = require('../../test-utils'); -const {spdxLicenseList} = require('../../../generators/copyright/header'); +const {spdxLicenseList} = require('../../../generators/copyright/license'); // Test Sandbox const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); diff --git a/packages/cli/test/integration/generators/copyright.integration.js b/packages/cli/test/integration/generators/copyright.integration.js index 1d9b1a37fe38..49d794cf5e4b 100644 --- a/packages/cli/test/integration/generators/copyright.integration.js +++ b/packages/cli/test/integration/generators/copyright.integration.js @@ -11,7 +11,7 @@ const testlab = require('@loopback/testlab'); const TestSandbox = testlab.TestSandbox; const generator = path.join(__dirname, '../../../generators/copyright'); -const {spdxLicenseList} = require('../../../generators/copyright/header'); +const {spdxLicenseList} = require('../../../generators/copyright/license'); const SANDBOX_FILES = require('../../fixtures/copyright/single-package') .SANDBOX_FILES; const testUtils = require('../../test-utils'); @@ -72,6 +72,50 @@ describe('lb4 copyright', function () { `// License text available at ${spdxLicenseList['isc'].url}`, ); }); + + it('updates LICENSE and package.json', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + excludePackageJSON: true, + additionalFiles: SANDBOX_FILES, + }), + ) + .withOptions({ + owner: 'ACME Inc.', + license: 'ISC', + gitOnly: false, + updateLicense: true, + }); + + assert.fileContent( + path.join(SANDBOX_PATH, 'package.json'), + '"license": "ISC"', + ); + assert.fileContent( + path.join(SANDBOX_PATH, 'package.json'), + '"copyright.owner": "ACME Inc."', + ); + + /* + Copyright (c) ACME Inc. 2020. All Rights Reserved. + Node module: myapp + + */ + assert.fileContent( + path.join(SANDBOX_PATH, 'LICENSE'), + 'This project is licensed under the ISC License, full text below.', + ); + assert.fileContent( + path.join(SANDBOX_PATH, 'LICENSE'), + `Copyright (c) ACME Inc. ${year}. All Rights Reserved.`, + ); + assert.fileContent( + path.join(SANDBOX_PATH, 'LICENSE'), + 'Node module: myapp', + ); + }); }); function assertHeader(fileNames, ...expected) {