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 fund subcommand #273

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
51 changes: 51 additions & 0 deletions doc/cli/npm-fund.md
@@ -0,0 +1,51 @@
npm-fund(1) -- Retrieve funding information
========================================================

## SYNOPSIS

npm fund [<pkg>]

## DESCRIPTION

This command retrieves information on how to fund the dependencies of
a given project. If no package name is provided, it will list all
dependencies that are looking for funding in a tree-structure in which
are listed the type of funding and the url to visit. If a package name
is provided then it tries to open its funding url using the `--browser`
config param.

The list will avoid duplicated entries and will stack all packages
that share the same type/url as a single entry. Given this nature the
list is not going to have the same shape of the output from `npm ls`.

## CONFIGURATION

### browser

* Default: OS X: `"open"`, Windows: `"start"`, Others: `"xdg-open"`
* Type: String

The browser that is called by the `npm fund` command to open websites.

### json

* Default: false
* Type: Boolean

Show information in JSON format.

### unicode

* Type: Boolean
* Default: true

Whether to represent the tree structure using unicode characters.
Set it to `false` in order to use all-ansi output.

## SEE ALSO

* npm-docs(1)
* npm-config(1)
* npm-install(1)
* npm-ls(1)

5 changes: 5 additions & 0 deletions doc/cli/npm-install.md
Expand Up @@ -326,6 +326,10 @@ local copy exists on disk.

npm install sax --force

The `--no-fund` argument will hide the message displayed at the end of each
install that aknowledges the number of dependencies looking for funding.
See `npm-fund(1)`

The `-g` or `--global` argument will cause npm to install the package globally
rather than locally. See `npm-folders(5)`.

Expand Down Expand Up @@ -443,6 +447,7 @@ affects a real use-case, it will be investigated.
* npm-folders(5)
* npm-update(1)
* npm-audit(1)
* npm-fund(1)
* npm-link(1)
* npm-rebuild(1)
* npm-scripts(7)
Expand Down
8 changes: 8 additions & 0 deletions doc/cli/npm-ls.md
Expand Up @@ -98,6 +98,14 @@ When "prod" or "production", is an alias to `production`.

Display only dependencies which are linked

### unicode

* Type: Boolean
* Default: true

Whether to represent the tree structure using unicode characters.
Set it to false in order to use all-ansi output.

## SEE ALSO

* npm-config(1)
Expand Down
20 changes: 20 additions & 0 deletions doc/files/package.json.md
Expand Up @@ -170,6 +170,26 @@ Both email and url are optional either way.

npm also sets a top-level "maintainers" field with your npm user info.

## funding

You can specify an object containing an URL that provides up-to-date
information about ways to help fund development of your package:

"funding": {
"type" : "individual",
"url" : "http://example.com/donate"
}

"funding": {
"type" : "patreon",
"url" : "https://www.patreon.com/my-account"
}

Users can use the `npm fund` subcommand to list the `funding` URLs of all
dependencies of their project, direct and indirect. A shortcut to visit each
funding url is also available when providing the project name such as:
`npm fund <projectname>`.

## files

The optional `files` field is an array of file patterns that describes
Expand Down
9 changes: 9 additions & 0 deletions doc/misc/npm-config.md
Expand Up @@ -426,6 +426,15 @@ packages.
The "maxTimeout" config for the `retry` module to use when fetching
packages.

### fund

* Default: true
* Type: Boolean

When "true" displays the message at the end of each `npm install`
aknowledging the number of dependencies looking for funding.
See `npm-fund(1)` for details.

### git

* Default: `"git"`
Expand Down
1 change: 1 addition & 0 deletions lib/config/cmd-list.js
Expand Up @@ -91,6 +91,7 @@ var cmdList = [
'token',
'profile',
'audit',
'fund',
'org',

'help',
Expand Down
3 changes: 3 additions & 0 deletions lib/config/defaults.js
Expand Up @@ -143,6 +143,8 @@ Object.defineProperty(exports, 'defaults', {get: function () {
force: false,
'format-package-lock': true,

fund: true,

'fetch-retries': 2,
'fetch-retry-factor': 10,
'fetch-retry-mintimeout': 10000,
Expand Down Expand Up @@ -284,6 +286,7 @@ exports.types = {
editor: String,
'engine-strict': Boolean,
force: Boolean,
fund: Boolean,
'format-package-lock': Boolean,
'fetch-retries': Number,
'fetch-retry-factor': Number,
Expand Down
202 changes: 202 additions & 0 deletions lib/fund.js
@@ -0,0 +1,202 @@
'use strict'

const path = require('path')

const archy = require('archy')
const figgyPudding = require('figgy-pudding')
const readPackageTree = require('read-package-tree')

const npm = require('./npm.js')
const npmConfig = require('./config/figgy-config.js')
const fetchPackageMetadata = require('./fetch-package-metadata.js')
const computeMetadata = require('./install/deps.js').computeMetadata
const readShrinkwrap = require('./install/read-shrinkwrap.js')
const mutateIntoLogicalTree = require('./install/mutate-into-logical-tree.js')
const output = require('./utils/output.js')
const openUrl = require('./utils/open-url.js')
const { getFundingInfo, validFundingUrl } = require('./utils/funding.js')

const FundConfig = figgyPudding({
browser: {}, // used by ./utils/open-url
global: {},
json: {},
unicode: {}
})

module.exports = fundCmd

const usage = require('./utils/usage')
fundCmd.usage = usage(
'fund',
'npm fund [--json]',
'npm fund [--browser] [[<@scope>/]<pkg>'
)

fundCmd.completion = function (opts, cb) {
const argv = opts.conf.argv.remain
switch (argv[2]) {
case 'fund':
return cb(null, [])
default:
return cb(new Error(argv[2] + ' not recognized'))
}
}

function printJSON (fundingInfo) {
return JSON.stringify(fundingInfo, null, 2)
}

// the human-printable version does some special things that turned out to
// be very verbose but hopefully not hard to follow: we stack up items
// that have a shared url/type and make sure they're printed at the highest
// level possible, in that process they also carry their dependencies along
// with them, moving those up in the visual tree
function printHuman (fundingInfo, opts) {
// mapping logic that keeps track of seen items in order to be able
// to push all other items from the same type/url in the same place
const seen = new Map()

function seenKey ({ type, url } = {}) {
return url ? String(type) + String(url) : null
}

function setStackedItem (funding, result) {
const key = seenKey(funding)
if (key && !seen.has(key)) seen.set(key, result)
}

function retrieveStackedItem (funding) {
const key = seenKey(funding)
if (key && seen.has(key)) return seen.get(key)
}

// ---

const getFundingItems = (fundingItems) =>
Object.keys(fundingItems || {}).map((fundingItemName) => {
// first-level loop, prepare the pretty-printed formatted data
const fundingItem = fundingItems[fundingItemName]
const { version, funding } = fundingItem
const { type, url } = funding || {}

const printableVersion = version ? `@${version}` : ''
const printableType = type && { label: `type: ${funding.type}` }
const printableUrl = url && { label: `url: ${funding.url}` }
const result = {
fundingItem,
label: fundingItemName + printableVersion,
nodes: []
}

if (printableType) {
result.nodes.push(printableType)
}

if (printableUrl) {
result.nodes.push(printableUrl)
}

setStackedItem(funding, result)

return result
}).reduce((res, result) => {
// recurse and exclude nodes that are going to be stacked together
const { fundingItem } = result
const { dependencies, funding } = fundingItem
const items = getFundingItems(dependencies)
const stackedResult = retrieveStackedItem(funding)
items.forEach(i => result.nodes.push(i))

if (stackedResult && stackedResult !== result) {
stackedResult.label += `, ${result.label}`
items.forEach(i => stackedResult.nodes.push(i))
return res
}

res.push(result)

return res
}, [])

const [ result ] = getFundingItems({
[fundingInfo.name]: {
dependencies: fundingInfo.dependencies,
funding: fundingInfo.funding,
version: fundingInfo.version
}
})

return archy(result, '', { unicode: opts.unicode })
}

function openFundingUrl (packageName, cb) {
function getUrlAndOpen (packageMetadata) {
const { funding } = packageMetadata
const { type, url } = funding || {}
const noFundingError =
new Error(`No funding method available for: ${packageName}`)
noFundingError.code = 'ENOFUND'
const typePrefix = type ? `${type} funding` : 'Funding'
const msg = `${typePrefix} available at the following URL`

if (validFundingUrl(funding)) {
openUrl(url, msg, cb)
} else {
throw noFundingError
}
}

fetchPackageMetadata(
packageName,
'.',
{ fullMetadata: true },
function (err, packageMetadata) {
if (err) return cb(err)
getUrlAndOpen(packageMetadata)
}
)
}

function fundCmd (args, cb) {
const opts = FundConfig(npmConfig())
const dir = path.resolve(npm.dir, '..')
const packageName = args[0]

if (opts.global) {
const err = new Error('`npm fund` does not support globals')
err.code = 'EFUNDGLOBAL'
throw err
}

if (packageName) {
openFundingUrl(packageName, cb)
return
}

readPackageTree(dir, function (err, tree) {
if (err) {
process.exitCode = 1
return cb(err)
}

readShrinkwrap.andInflate(tree, function () {
const fundingInfo = getFundingInfo(
mutateIntoLogicalTree.asReadInstalled(
computeMetadata(tree)
)
)

const print = opts.json
? printJSON
: printHuman

output(
print(
fundingInfo,
opts
)
)
cb(err, tree)
})
})
}