Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support subcommand #246

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions doc/files/package.json.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,25 @@ 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 an HTTP endpoint for up-to-date information about ways
to support development of your package:

{ "support": "https://example.com/support.json" }

For example, you might like to develop your support data file in your
source code repository:

{ "support": "https://raw.githubusercontent.com/{user}/{repo}/master/support.json" }

The URL you specify should respond to unauthenticated GET requests
with a JSON object. If the JSON object contains a `contributors`
array, `npm support` will interpret it as a `support.json` file.
If the JSON object contains a `versions` array, `npm support`
will interpret it as [Node.js Package Maintenance Working
Group](https://github.com/nodejs/package-maintenance) metadata.

## files

The optional `files` field is an array of file patterns that describes
Expand Down
57 changes: 57 additions & 0 deletions doc/files/support.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
spuport.json(5) -- Specifics of npm's support.json handling
===========================================================

## DESCRIPTION

This document describes the format of `support.json` files, which you
can use to share information about how to support your work and projects
through `npm support`.

`support.json` data must be actual JSON, not just a JavaScript object
literal.

## contributors

Each `support.json` file must contain a `contributors` property whose
value is an array. That array can contain two types of objects.

Contributor objects provide information about people and organizations
that produce a package, and how to suppor them. For example:
kemitchell marked this conversation as resolved.
Show resolved Hide resolved

```json
{
"name": "Ana Exemplar",
"homepage": "http://example.com/anaexemplar",
"links": [
"http://patreon.com/anaexemplar"
]
}
```

```json
{
"name": "Ana Exemplar",
"homepage": "http://example.com/anaexemplar",
"links": [
"http://patreon.com/anaexemplar"
]
}

```json
{
"name": "JS Foundation",
"type": "organization",
"homepage": "https://js.foundation",
"links": [
"https://js.foundation/about/donate"
]
}
```

`contributors` array items may also include URLs for contributor objects:

```json
{
"url": "http://example.com/support-anaexemplar.json"
}
```
1 change: 1 addition & 0 deletions lib/config/cmd-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ var cmdList = [
'token',
'profile',
'audit',
'support',
'org',

'help',
Expand Down
12 changes: 11 additions & 1 deletion lib/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -802,13 +802,18 @@ 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()
diffs.forEach(function (action) {
var mutation = action[0]
var pkg = action[1]
if (pkg.failed) return
if (mutation !== 'remove' && pkg.package.support) {
haveSupportable = true
}
if (mutation === 'remove') {
++removed
} else if (mutation === 'move') {
Expand Down Expand Up @@ -872,7 +877,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 the projects you depend on.')
}
if (auditResult) {
audit.printInstallReport(auditResult)
}

function packages (num) {
return num + ' package' + (num > 1 ? 's' : '')
Expand Down
232 changes: 232 additions & 0 deletions lib/support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
'use strict'

var npm = require('./npm.js')
var output = require('./utils/output.js')
var readPackageTree = require('read-package-tree')
var runParallelLimit = require('run-parallel-limit')
var simpleGet = require('simple-get')
var semver = require('semver')
var hasANSI = require('has-ansi')

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'))
}
}

function support (args, silent, cb) {
readPackageTree(npm.dir, function (error, tree) {
if (error) return cb(error)
var supportablePackages = Array.from(findSupportablePackages(tree))
downloadSupportData(supportablePackages, function (error, data) {
if (error) return cb(error)

if (typeof cb !== 'function') {
cb = silent
silent = false
}
if (silent) return cb(null, data)

var out
var json = npm.config.get('json')
if (json) {
out = JSON.stringify(data, null, 2)
} else {
out = data
.sort(function (a, b) {
var comparison = a.name.localeCompare(b.name)
return comparison === 0
? semver.compare(a.version, b.version)
: comparison
})
.map(displaySupportData)
.join('\n\n')
}
output(out)
if (error) process.exitCode = 1
cb(error, data)
})
})
}

function findSupportablePackages (root) {
var set = new Set()
iterate(root)
return set

function iterate (node) {
node.children.forEach(recurse)
}

function recurse (node) {
var metadata = node.package
if (metadata.support) {
set.add({
name: metadata.name,
version: metadata.version,
homepage: metadata.homepage,
repository: metadata.repository,
support: metadata.support,
parent: node.parent,
path: node.path
})
}
if (node.children) iterate(node)
}
}

function downloadSupportData (supportablePackages, cb) {
var cache = new Map()
var headers = { 'user-agent': npm.config.get('user-agent') }
runParallelLimit(supportablePackages.map(function (entry) {
return function task (done) {
var url = entry.support
get(url, function (error, response, projectData) {
if (error) {
return done(null, {
url: url,
error: 'could not download data'
})
}
if (typeof projectData !== 'object' || Array.isArray(projectData)) {
return done(null, {
url: url,
error: 'not an object'
})
}
var contributors = projectData.contributors
if (!Array.isArray(contributors)) {
return done(null, projectData)
}
runParallelLimit(contributors.map(function (contributor) {
return function (done) {
if (
typeof contributor !== 'object' ||
typeof contributor.url !== 'string'
) {
return setImmediate(function () {
done(null, contributor)
})
}
get(contributor.url, function (error, response, contributorData) {
if (error) {
return done(null, {
url: contributor.url,
error: error
})
}
contributorData.url = contributor.url
var result = {
name: contributorData.name,
type: contributorData.type,
url: contributor.url
}
if (looksLikeURL(contributorData.homepage)) {
result.homepage = contributorData.homepage
}
if (
Array.isArray(contributorData.links) &&
contributorData.links.every(function (element) {
return looksLikeURL(element)
})
) {
result.links = contributorData.links
}
done(null, result)
})
}
}), 5, function (error, resolvedContributors) {
if (error) return done(error)
done(null, {
name: entry.name,
version: entry.version,
url: entry.support,
homepage: entry.homepage,
contributors: resolvedContributors
})
})
})
}
}), 5, cb)

function get (url, cb) {
var cached = cache.get(url)
if (cached) {
return setImmediate(function () {
cb(null, { cached: true }, cached)
})
}
simpleGet.concat({
url: url,
json: true,
headers: headers
}, function (err, response, data) {
if (err) return cb(err)
cache.set(url, data)
cb(null, response, data)
})
}
}

function displaySupportData (entry) {
var returned = [entry.name + '@' + entry.version]
if (looksLikeURL(entry.homepage)) {
returned[0] += ' (' + entry.homepage + ')'
}
if (Array.isArray(entry.contributors)) {
entry.contributors.forEach(function (contributor) {
var name = contributor.name
if (looksLikeSafeString(name)) {
var item = ['- ' + name]
var email = contributor.email
if (looksLikeSafeString(email)) {
item[0] += ' <' + email + '>'
}
var homepage = contributor.homepage
if (looksLikeURL(homepage)) {
item[0] += ' (' + homepage + ')'
}
var links = contributor.links
if (Array.isArray(links)) {
links.forEach(function (link) {
if (looksLikeURL(link)) item.push(' ' + link)
})
}
returned.push(item.join('\n'))
}
})
}
return returned.join('\n')
}

function looksLikeSafeString (argument) {
return (
typeof argument === 'string' &&
argument.length > 0 &&
argument.length < 80 &&
!hasANSI(argument)
)
}

function looksLikeURL (argument) {
return (
looksLikeSafeString(argument) &&
(
argument.indexOf('https://') === 0 ||
argument.indexOf('http://') === 0
)
)
}
Loading