diff --git a/doc/files/package.json.md b/doc/files/package.json.md index 6324caf64a517..19d4704d0752d 100644 --- a/doc/files/package.json.md +++ b/doc/files/package.json.md @@ -170,6 +170,16 @@ Both email and url are optional either way. npm also sets a top-level "maintainers" field with your npm user info. +## support + +You can specify a URL for up-to-date information about ways to support +development of your package: + + { "support": "https://example.com/project/support" } + +Users can use the `npm support` subcommand to list the `support` URLs +of all dependencies of the project, direct and indirect. + ## files The optional `files` field is an array of file patterns that describes diff --git a/lib/config/cmd-list.js b/lib/config/cmd-list.js index fa4390fcdcba7..c11c8c30982b8 100644 --- a/lib/config/cmd-list.js +++ b/lib/config/cmd-list.js @@ -91,6 +91,7 @@ var cmdList = [ 'token', 'profile', 'audit', + 'support', 'org', 'help', diff --git a/lib/install.js b/lib/install.js index 8cc6d16bdd169..52fe96c47f5ff 100644 --- a/lib/install.js +++ b/lib/install.js @@ -119,6 +119,7 @@ var unlock = locker.unlock var parseJSON = require('./utils/parse-json.js') var output = require('./utils/output.js') var saveMetrics = require('./utils/metrics.js').save +var validSupportURL = require('./utils/valid-support-url') // install specific libraries var copyTree = require('./install/copy-tree.js') @@ -802,6 +803,8 @@ Installer.prototype.printInstalledForHuman = function (diffs, auditResult) { var added = 0 var updated = 0 var moved = 0 + // Check if any installed packages have support properties. + var haveSupportable = false // Count the number of contributors to packages added, tracking // contributors we've seen, so we can produce a running unique count. var contributors = new Set() @@ -809,6 +812,11 @@ Installer.prototype.printInstalledForHuman = function (diffs, auditResult) { var mutation = action[0] var pkg = action[1] if (pkg.failed) return + if ( + mutation !== 'remove' && validSupportURL(pkg.package.support) + ) { + haveSupportable = true + } if (mutation === 'remove') { ++removed } else if (mutation === 'move') { @@ -872,7 +880,12 @@ Installer.prototype.printInstalledForHuman = function (diffs, auditResult) { report += ' in ' + ((Date.now() - this.started) / 1000) + 's' output(report) - return auditResult && audit.printInstallReport(auditResult) + if (haveSupportable) { + output('Run `npm support` to support projects you depend on.') + } + if (auditResult) { + audit.printInstallReport(auditResult) + } function packages (num) { return num + ' package' + (num > 1 ? 's' : '') diff --git a/lib/support.js b/lib/support.js new file mode 100644 index 0000000000000..5813df93ff2a6 --- /dev/null +++ b/lib/support.js @@ -0,0 +1,88 @@ +'use strict' + +const npm = require('./npm.js') +const output = require('./utils/output.js') +const path = require('path') +const readPackageTree = require('read-package-tree') +const semver = require('semver') +const validSupportURL = require('./utils/valid-support-url') + +module.exports = support + +const usage = require('./utils/usage') +support.usage = usage( + 'support', + '\nnpm support [--json]' +) + +support.completion = function (opts, cb) { + const argv = opts.conf.argv.remain + switch (argv[2]) { + case 'support': + return cb(null, []) + default: + return cb(new Error(argv[2] + ' not recognized')) + } +} + +// Compare lib/ls.js. +function support (args, silent, cb) { + if (typeof cb !== 'function') { + cb = silent + silent = false + } + const dir = path.resolve(npm.dir, '..') + readPackageTree(dir, function (err, tree) { + if (err) { + process.exitCode = 1 + return cb(err) + } + const data = findPackages(tree) + if (silent) return cb(null, data) + var out + if (npm.config.get('json')) { + out = JSON.stringify(data, null, 2) + } else { + out = data.map(displayPackage).join('\n\n') + } + output(out) + cb(err, data) + }) +} + +function findPackages (root) { + const set = new Set() + iterate(root) + return Array.from(set).sort(function (a, b) { + const comparison = a.name + .toLowerCase() + .localeCompare(b.name.toLowerCase()) + return comparison === 0 + ? semver.compare(a.version, b.version) + : comparison + }) + + function iterate (node) { + node.children.forEach(recurse) + } + + function recurse (node) { + const metadata = node.package + const support = metadata.support + if (support && validSupportURL(support)) { + set.add({ + name: metadata.name, + version: metadata.version, + path: node.path, + homepage: metadata.homepage, + repository: metadata.repository, + support: metadata.support + }) + } + if (node.children) iterate(node) + } +} + +function displayPackage (entry) { + return entry.name + '@' + entry.version + ': ' + entry.support +} diff --git a/lib/utils/valid-support-url.js b/lib/utils/valid-support-url.js new file mode 100644 index 0000000000000..d575dcdf03b52 --- /dev/null +++ b/lib/utils/valid-support-url.js @@ -0,0 +1,19 @@ +const URL = require('url').URL + +// Is the value of a `support` property of a `package.json` object +// a valid URL for `npm support` to display? +module.exports = function (argument) { + if (typeof argument !== 'string' || argument.length === 0) { + return false + } + try { + var parsed = new URL(argument) + } catch (error) { + return false + } + if ( + parsed.protocol !== 'https:' && + parsed.protocol !== 'http:' + ) return false + return parsed.host +} diff --git a/package-lock.json b/package-lock.json index 37297b2f6bad4..58daf760c4245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -438,6 +438,17 @@ "has-ansi": "^2.0.0", "strip-ansi": "^3.0.0", "supports-color": "^2.0.0" + }, + "dependencies": { + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } } }, "supports-color": { @@ -2415,15 +2426,6 @@ "function-bind": "^1.1.1" } }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", diff --git a/test/tap/install-mention-support.js b/test/tap/install-mention-support.js new file mode 100644 index 0000000000000..a1cb2c4aaf7d7 --- /dev/null +++ b/test/tap/install-mention-support.js @@ -0,0 +1,39 @@ +'use strict' +var test = require('tap').test +var Tacks = require('tacks') +var Dir = Tacks.Dir +var File = Tacks.File +var common = require('../common-tap.js') + +var fixturepath = common.pkg +var fixture = new Tacks(Dir({ + 'package.json': File({}), + 'hassupport': Dir({ + 'package.json': File({ + name: 'hassupport', + version: '7.7.7', + support: 'http://example.com/project/support' + }) + }) +})) + +test('setup', function (t) { + fixture.remove(fixturepath) + fixture.create(fixturepath) + t.end() +}) + +test('install-report', function (t) { + common.npm(['install', '--no-save', './hassupport'], {cwd: fixturepath}, function (err, code, stdout, stderr) { + if (err) throw err + t.is(code, 0, 'installed successfully') + t.is(stderr, '', 'no warnings') + t.includes(stdout, '`npm support`', 'mentions `npm support`') + t.end() + }) +}) + +test('cleanup', function (t) { + fixture.remove(fixturepath) + t.end() +}) diff --git a/test/tap/support.js b/test/tap/support.js new file mode 100644 index 0000000000000..93d4887423a13 --- /dev/null +++ b/test/tap/support.js @@ -0,0 +1,77 @@ +'use strict' +var test = require('tap').test +var Tacks = require('tacks') +var path = require('path') +var Dir = Tacks.Dir +var File = Tacks.File +var common = require('../common-tap.js') + +var fixturepath = common.pkg +var fixture = new Tacks(Dir({ + 'package.json': File({ + name: 'a', + version: '0.0.0', + dependencies: { 'hassupport': '7.7.7' } + }), + 'node_modules': Dir({ + hassupport: Dir({ + 'package.json': File({ + name: 'hassupport', + version: '7.7.7', + homepage: 'http://example.com/project', + support: 'http://example.com/project/donate' + }) + }) + }) +})) + +test('setup', function (t) { + fixture.remove(fixturepath) + fixture.create(fixturepath) + t.end() +}) + +test('support --json', function (t) { + common.npm(['support', '--json'], {cwd: fixturepath}, function (err, code, stdout, stderr) { + if (err) throw err + t.is(code, 0, 'exited 0') + t.is(stderr, '', 'no warnings') + var parsed + t.doesNotThrow(function () { + parsed = JSON.parse(stdout) + }, 'valid JSON') + t.deepEqual( + parsed, + [ + { + name: 'hassupport', + version: '7.7.7', + homepage: 'http://example.com/project', + support: 'http://example.com/project/donate', + path: path.resolve(fixturepath, 'node_modules', 'hassupport') + } + ], + 'output data' + ) + t.end() + }) +}) + +test('support', function (t) { + common.npm(['support'], {cwd: fixturepath}, function (err, code, stdout, stderr) { + if (err) throw err + t.is(code, 0, 'exited 0') + t.is(stderr, '', 'no warnings') + t.includes(stdout, 'hassupport', 'outputs project name') + t.includes(stdout, '7.7.7', 'outputs project version') + t.includes(stdout, 'http://example.com/project', 'outputs contributor homepage') + t.includes(stdout, 'http://example.com/project/donate', 'outputs support link') + t.end() + }) +}) + +test('cleanup', function (t) { + t.pass(fixturepath) + fixture.remove(fixturepath) + t.end() +})