[7.x] [dev/license_checker][dev/npm] reactor, ts-ify, de-grunt (#37807) (#38293)

This commit is contained in:
Spencer 2019-06-06 22:47:07 -07:00 committed by GitHub
parent f775bbe086
commit fd125bc2db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 318 additions and 354 deletions

2
.gitignore vendored
View file

@ -5,7 +5,7 @@
.DS_Store
.node_binaries
node_modules
!/src/dev/npm/__tests__/fixtures/fixture1/node_modules
!/src/dev/npm/integration_tests/__fixtures__/fixture1/node_modules
!/src/dev/notice/__fixtures__/node_modules
trash
/optimize

View file

@ -51,7 +51,7 @@
"test:server": "grunt test:server",
"test:coverage": "grunt test:coverage",
"typespec": "typings-tester --config x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts",
"checkLicenses": "grunt licenses --dev",
"checkLicenses": "node scripts/check_licenses --dev",
"build": "node scripts/build --all-platforms",
"start": "node --trace-warnings --trace-deprecation scripts/kibana --dev ",
"debug": "node --nolazy --inspect scripts/kibana --dev",
@ -302,6 +302,7 @@
"@types/jquery": "^3.3.6",
"@types/js-yaml": "^3.11.1",
"@types/json5": "^0.0.30",
"@types/license-checker": "15.0.0",
"@types/listr": "^0.14.0",
"@types/lodash": "^3.10.1",
"@types/lru-cache": "^5.1.0",

21
scripts/check_licenses.js Normal file
View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
require('../src/setup_node_env');
require('../src/dev/license_checker/run_check_licenses_cli');

View file

@ -56,6 +56,7 @@ export const LICENSE_WHITELIST = [
'ISC',
'ISC*',
'MIT OR GPL-2.0',
'(MIT OR CC0-1.0)',
'MIT',
'MIT*',
'MIT/X11',
@ -69,16 +70,14 @@ export const LICENSE_WHITELIST = [
// The following list only applies to licenses that
// we wanna allow in packages only used as dev dependencies
export const DEV_ONLY_LICENSE_WHITELIST = [
'MPL-2.0'
];
export const DEV_ONLY_LICENSE_WHITELIST = ['MPL-2.0'];
// Globally overrides a license for a given package@version
export const LICENSE_OVERRIDES = {
'react-lib-adler32@1.0.1': ['BSD'], // adler32 extracted from react source,
'cycle@1.0.3': ['CC0-1.0'], // conversion to a public-domain like license
'jsts@1.1.2': ['Eclipse Distribution License - v 1.0'], //cf. https://github.com/bjornharrtell/jsts
'@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], //license in readme https://github.com/tmcw/jsonlint
'jsts@1.1.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts
'@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint
// TODO can be removed once we upgrade past elasticsearch-browser@14.0.0
'elasticsearch-browser@13.0.1': ['Apache-2.0'],
@ -106,5 +105,5 @@ export const LICENSE_OVERRIDES = {
'walk@2.3.9': ['MIT'],
// TODO remove this once we upgrade past or equal to v1.0.2
'babel-plugin-mock-imports@1.0.1': ['MIT']
'babel-plugin-mock-imports@1.0.1': ['MIT'],
};

View file

@ -0,0 +1,61 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getInstalledPackages } from '../npm';
import { run } from '../run';
import { LICENSE_WHITELIST, DEV_ONLY_LICENSE_WHITELIST, LICENSE_OVERRIDES } from './config';
import { assertLicensesValid } from './valid';
import { REPO_ROOT } from '../constants';
run(
async ({ log, flags }) => {
const packages = await getInstalledPackages({
directory: REPO_ROOT,
licenseOverrides: LICENSE_OVERRIDES,
includeDev: !!flags.dev,
});
// Assert if the found licenses in the production
// packages are valid
assertLicensesValid({
packages: packages.filter(pkg => !pkg.isDevOnly),
validLicenses: LICENSE_WHITELIST,
});
log.success('All production dependency licenses are allowed');
// Do the same as above for the packages only used in development
// if the dev flag is found
if (flags.dev) {
assertLicensesValid({
packages: packages.filter(pkg => pkg.isDevOnly),
validLicenses: LICENSE_WHITELIST.concat(DEV_ONLY_LICENSE_WHITELIST),
});
log.success('All development dependency licenses are allowed');
}
},
{
flags: {
boolean: ['dev'],
help: `
--dev Also check dev dependencies
`,
},
}
);

View file

@ -1,66 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const describeInvalidLicenses = getInvalid => pkg => (
`
${pkg.name}
version: ${pkg.version}
all licenses: ${pkg.licenses}
invalid licenses: ${getInvalid(pkg.licenses).join(', ')}
path: ${pkg.relative}
`
);
/**
* When given a list of packages and the valid license
* options, either throws an error with details about
* violations or returns undefined.
*
* @param {Object} [options={}]
* @property {Array<Package>} options.packages List of packages to check, see
* getInstalledPackages() in ../packages
* @property {Array<string>} options.validLicenses
* @return {undefined}
*/
export function assertLicensesValid(options = {}) {
const {
packages,
validLicenses
} = options;
if (!packages || !validLicenses) {
throw new Error('packages and validLicenses options are required');
}
const getInvalid = licenses => (
licenses.filter(license => !validLicenses.includes(license))
);
const isPackageInvalid = pkg => (
!pkg.licenses.length || getInvalid(pkg.licenses).length > 0
);
const invalidMsgs = packages
.filter(isPackageInvalid)
.map(describeInvalidLicenses(getInvalid));
if (invalidMsgs.length) {
throw new Error(`Non-conforming licenses: ${invalidMsgs.join('')}`);
}
}

View file

@ -0,0 +1,67 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import dedent from 'dedent';
import { createFailError } from '../run';
interface Options {
packages: Array<{
name: string;
version: string;
relative: string;
licenses: string[];
}>;
validLicenses: string[];
}
/**
* When given a list of packages and the valid license
* options, either throws an error with details about
* violations or returns undefined.
*/
export function assertLicensesValid({ packages, validLicenses }: Options) {
const invalidMsgs = packages.reduce(
(acc, pkg) => {
const invalidLicenses = pkg.licenses.filter(license => !validLicenses.includes(license));
if (pkg.licenses.length && !invalidLicenses.length) {
return acc;
}
return acc.concat(dedent`
${pkg.name}
version: ${pkg.version}
all licenses: ${pkg.licenses}
invalid licenses: ${invalidLicenses.join(', ')}
path: ${pkg.relative}
`);
},
[] as string[]
);
if (invalidMsgs.length) {
throw createFailError(
`Non-conforming licenses:\n${invalidMsgs
.join('\n')
.split('\n')
.map(l => ` ${l}`)
.join('\n')}`
);
}
}

View file

@ -1,101 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { relative, resolve } from 'path';
import { readFileSync } from 'fs';
import { callLicenseChecker } from './license_checker';
function resolveLicense(licenseInfo, key, licenseOverrides) {
const {
private: isPrivate,
licenses: detectedLicenses,
realPath,
} = licenseInfo[key];
// `license-checker` marks all packages that have `private: true`
// in their `package.json` as "UNLICENSED", so we try to lookup the
// actual license by reading the license field from their package.json
if (isPrivate && detectedLicenses === 'UNLICENSED') {
try {
const pkg = JSON.parse(readFileSync(resolve(realPath, 'package.json')));
if (!pkg.license) {
throw new Error('no license field');
}
return [pkg.license];
} catch (error) {
throw new Error(`Unable to detect license for \`"private": true\` package at ${realPath}: ${error.message}`);
}
}
return [].concat(
licenseOverrides[key]
? licenseOverrides[key]
: detectedLicenses
);
}
/**
* Get a list of objects with details about each installed
* NPM package.
*
* @param {Object} [options={}]
* @property {String} options.directory root of the project to read
* @property {Boolean} [options.dev=false] should development dependencies be included?
* @property {Object} [options.licenseOverrides] map of `${name}@${version}` to a list of
* license ids to override the automatically
* detected ones
* @return {Array<Object>}
*/
export async function getInstalledPackages(options = {}) {
const {
directory,
dev = false,
licenseOverrides = {}
} = options;
if (!directory) {
throw new Error('You must specify a directory to read installed packages from');
}
const licenseInfo = await callLicenseChecker({ directory, dev });
return Object
.keys(licenseInfo)
.map(key => {
const { realPath, repository, isDevOnly } = licenseInfo[key];
if (realPath === directory) return;
const keyParts = key.split('@');
const name = keyParts.slice(0, -1).join('@');
const version = keyParts[keyParts.length - 1];
const licenses = resolveLicense(licenseInfo, key, licenseOverrides);
return {
name,
version,
repository,
licenses,
directory: realPath,
relative: relative(directory, realPath),
isDevOnly
};
})
.filter(Boolean);
}

View file

@ -0,0 +1,124 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { relative, resolve } from 'path';
import { readFileSync } from 'fs';
import { promisify } from 'util';
import licenseChecker from 'license-checker';
export type InstalledPackage = NonNullable<ReturnType<typeof readModuleInfo>>;
interface Options {
directory: string;
includeDev?: boolean;
licenseOverrides?: { [pgkNameAndVersion: string]: string[] };
}
const toArray = <T>(input: T | T[]) => ([] as T[]).concat(input);
function resolveLicenses(
isPrivate: boolean,
realPath: string,
licenses: string[] | string | undefined
) {
// `license-checker` marks all packages that have `private: true`
// in their `package.json` as "UNLICENSED", so we try to lookup the
// actual license by reading the license field from their package.json
if (isPrivate && licenses === 'UNLICENSED') {
try {
const pkg = JSON.parse(readFileSync(resolve(realPath, 'package.json'), 'utf8'));
if (!pkg.license) {
throw new Error('no license field');
}
return [pkg.license as string];
} catch (error) {
throw new Error(
`Unable to detect license for \`"private": true\` package at ${realPath}: ${error.message}`
);
}
}
return toArray(licenses || []);
}
function readModuleInfo(
pkgAndVersion: string,
moduleInfo: licenseChecker.ModuleInfo,
dev: boolean,
options: Options
) {
const directory = (moduleInfo as any).realPath as string;
if (directory === options.directory) {
return;
}
const isPrivate = !!(moduleInfo as any).private as boolean;
const keyParts = pkgAndVersion.split('@');
const name = keyParts.slice(0, -1).join('@');
const version = keyParts[keyParts.length - 1];
const override = options.licenseOverrides && options.licenseOverrides[pkgAndVersion];
return {
name,
version,
isDevOnly: dev,
repository: moduleInfo.repository,
directory,
relative: relative(options.directory, directory),
licenses: toArray(
override ? override : resolveLicenses(isPrivate, directory, moduleInfo.licenses)
),
};
}
async function _getInstalledPackages(dev: boolean, options: Options) {
const lcResult = await promisify(licenseChecker.init)({
start: options.directory,
development: dev,
production: !dev,
json: true,
customFormat: {
realPath: true,
licenseText: false,
licenseFile: false,
},
} as any);
const result = [];
for (const [pkgAndVersion, moduleInfo] of Object.entries(lcResult)) {
const installedPackage = readModuleInfo(pkgAndVersion, moduleInfo, dev, options);
if (installedPackage) {
result.push(installedPackage);
}
}
return result;
}
/**
* Get a list of objects with details about each installed
* NPM package.
*/
export async function getInstalledPackages(options: Options) {
return [
...(await _getInstalledPackages(false, options)),
...(options.includeDev ? await _getInstalledPackages(true, options) : []),
];
}

View file

@ -20,46 +20,35 @@
import { resolve, sep } from 'path';
import { uniq } from 'lodash';
import expect from '@kbn/expect';
import { getInstalledPackages } from '../installed_packages';
import { getInstalledPackages, InstalledPackage } from '../installed_packages';
import { REPO_ROOT } from '../../constants';
const KIBANA_ROOT = resolve(__dirname, '../../../../');
const FIXTURE1_ROOT = resolve(__dirname, 'fixtures/fixture1');
const FIXTURE1_ROOT = resolve(__dirname, '__fixtures__/fixture1');
describe('src/dev/npm/installed_packages', () => {
describe('getInstalledPackages()', function () {
describe('getInstalledPackages()', function() {
let kibanaPackages: InstalledPackage[];
let fixture1Packages: InstalledPackage[];
let kibanaPackages;
let fixture1Packages;
before(async function () {
this.timeout(30 * 1000);
beforeAll(async function() {
[kibanaPackages, fixture1Packages] = await Promise.all([
getInstalledPackages({
directory: KIBANA_ROOT
directory: REPO_ROOT,
}),
getInstalledPackages({
directory: FIXTURE1_ROOT,
dev: true
includeDev: true,
}),
]);
});
it('requires a directory', async () => {
try {
await getInstalledPackages({});
throw new Error('expected getInstalledPackages() to reject');
} catch (err) {
expect(err.message).to.contain('directory');
}
});
}, 30 * 1000);
it('reads all installed packages of a module', () => {
expect(fixture1Packages).to.eql([
expect(fixture1Packages).toEqual([
{
name: 'dep1',
version: '0.0.2',
licenses: [ 'Apache-2.0' ],
licenses: ['Apache-2.0'],
repository: 'https://github.com/mycorp/dep1',
directory: resolve(FIXTURE1_ROOT, 'node_modules/dep1'),
relative: ['node_modules', 'dep1'].join(sep),
@ -69,7 +58,7 @@ describe('src/dev/npm/installed_packages', () => {
name: 'privatedep',
version: '0.0.2',
repository: 'https://github.com/mycorp/privatedep',
licenses: [ 'Apache-2.0' ],
licenses: ['Apache-2.0'],
directory: resolve(FIXTURE1_ROOT, 'node_modules/privatedep'),
relative: ['node_modules', 'privatedep'].join(sep),
isDevOnly: false,
@ -77,23 +66,28 @@ describe('src/dev/npm/installed_packages', () => {
{
name: 'dep2',
version: '0.0.2',
licenses: [ 'Apache-2.0' ],
licenses: ['Apache-2.0'],
repository: 'https://github.com/mycorp/dep2',
directory: resolve(FIXTURE1_ROOT, 'node_modules/dep2'),
relative: ['node_modules', 'dep2'].join(sep),
isDevOnly: true,
}
},
]);
});
it('returns a single entry for every package/version combo', () => {
const tags = kibanaPackages.map(pkg => `${pkg.name}@${pkg.version}`);
expect(tags).to.eql(uniq(tags));
expect(tags).toEqual(uniq(tags));
});
it('does not include root package in the list', async () => {
expect(kibanaPackages.find(pkg => pkg.name === 'kibana')).to.be(undefined);
expect(fixture1Packages.find(pkg => pkg.name === 'fixture1')).to.be(undefined);
if (kibanaPackages.find(pkg => pkg.name === 'kibana')) {
throw new Error('Expected getInstalledPackages(kibana) to not include kibana pkg');
}
if (fixture1Packages.find(pkg => pkg.name === 'fixture1')) {
throw new Error('Expected getInstalledPackages(fixture1) to not include fixture1 pkg');
}
});
});
});

View file

@ -1,81 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import licenseChecker from 'license-checker';
async function runLicenseChecker(directory, dev) {
return new Promise((resolve, reject) => {
licenseChecker.init({
start: directory,
development: dev,
production: !dev,
json: true,
customFormat: {
realPath: true,
licenseText: false,
licenseFile: false
}
}, (err, licenseInfo) => {
if (err) reject(err);
else {
resolve(
// Extend original licenseInfo object with a new attribute
// stating whether a license was found in a package used
// only as a dev dependency or not
Object.keys(licenseInfo).reduce(function (result, key) {
result[key] = Object.assign(licenseInfo[key], { isDevOnly: dev });
return result;
}, {})
);
}
});
});
}
export async function callLicenseChecker(options = {}) {
const {
directory,
dev = false
} = options;
if (!directory) {
throw new Error('You must specify the directory where license checker should start');
}
return new Promise(async (resolve, reject) => {
try {
// Run license checker for prod only packages
const prodOnlyLicenses = await runLicenseChecker(directory, false);
if (!dev) {
resolve(prodOnlyLicenses);
return;
}
// In case we have the dev option
// also run the license checker for the
// dev only packages and build a final object
// merging the previous results too
const devOnlyLicenses = await runLicenseChecker(directory, true);
resolve(Object.assign(prodOnlyLicenses, devOnlyLicenses));
} catch (e) {
reject(e);
}
});
}

View file

@ -269,7 +269,15 @@ module.exports = function (grunt) {
],
}),
licenses: gruntTaskWithGithubChecks('Licenses', 'licenses'),
licenses: scriptWithGithubChecks({
title: 'Check licenses',
cmd: NODE,
args: [
'scripts/check_licenses',
'--dev',
],
}),
verifyDependencyVersions:
gruntTaskWithGithubChecks('Verify dependency versions', 'verifyDependencyVersions'),
test_server:

View file

@ -1,68 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getInstalledPackages } from '../src/dev/npm';
import {
assertLicensesValid,
LICENSE_WHITELIST,
DEV_ONLY_LICENSE_WHITELIST,
LICENSE_OVERRIDES,
} from '../src/dev/license_checker';
export default function licenses(grunt) {
grunt.registerTask('licenses', 'Checks dependency licenses', async function () {
const done = this.async();
try {
const dev = Boolean(grunt.option('dev'));
// Get full packages list according dev flag
const packages = await getInstalledPackages({
directory: grunt.config.get('root'),
licenseOverrides: LICENSE_OVERRIDES,
dev
});
// Filter the packages only used in production
const prodPackages = packages.filter(pkg => !pkg.isDevOnly);
// Assert if the found licenses in the production
// packages are valid
assertLicensesValid({
packages: prodPackages,
validLicenses: LICENSE_WHITELIST
});
// Do the same as above for the packages only used in development
// if the dev flag is found
if (dev) {
const devPackages = packages.filter(pkg => pkg.isDevOnly);
assertLicensesValid({
packages: devPackages,
validLicenses: LICENSE_WHITELIST.concat(DEV_ONLY_LICENSE_WHITELIST)
});
}
done();
} catch (err) {
grunt.fail.fatal(err);
done(err);
}
});
}

View file

@ -3591,6 +3591,11 @@
dependencies:
"@types/node" "*"
"@types/license-checker@15.0.0":
version "15.0.0"
resolved "https://registry.yarnpkg.com/@types/license-checker/-/license-checker-15.0.0.tgz#685d69e2cf61ffd862320434601f51c85e28bba1"
integrity sha512-dHZdn+VxvPGwKyKUlqi6Dj/t3Q4KFmbmPpsCOwagytr8P98jmz/nXzyxzz9wbfgpw72mVMx7PMlR/PT0xNsF7A==
"@types/listr@^0.14.0":
version "0.14.0"
resolved "https://registry.yarnpkg.com/@types/listr/-/listr-0.14.0.tgz#55161177ed5043987871bca5f66d87ca0a63a0b7"