Skip to content

Commit

Permalink
fund: add fund command
Browse files Browse the repository at this point in the history
This commit introduces the `npm fund` command that lists all `funding`
info provided by the installed dependencies of a given project.

Notes on implementation:

- `lib/utils/funding.js` Provides helpers to validate funding info and
return a tree-shaped structure containing the funding data for all deps.
- `lib/fund.js` Implements `npm fund <pkg>` command
- Added tests
  - `npm install` mention of funding
  - `npm fund <pkg>` variations
  - unit tests for added `lib/utils` and `lib/install` helpers
- Added docs for `npm fund`, `funding` `package.json` property
- Fixed `lib/utils/open-url` to support `--json` config
- Documented `unicode` on `npm install` docs
- fix tests
- fix planned tap tests
- alternative solution to --no-browser arg
- docs: moved fund docs to new location

Refs: https://github.com/npm/rfcs/blob/2d2f00457ab19b3003eb6ac5ab3d250259fd5a81/accepted/0017-add-funding-support.md

PR-URL: #273
Credit: @ruyadorno
Close: #273
Reviewed-by: @darcyclarke
  • Loading branch information
ruyadorno committed Nov 5, 2019
1 parent 266d076 commit 4414b06
Show file tree
Hide file tree
Showing 25 changed files with 1,663 additions and 255 deletions.
2 changes: 1 addition & 1 deletion docs/content/cli-commands/npm-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: Run a security audit

## Run a security audit

### Synposis
### Synopsis

```bash
npm audit [--json|--parseable|--audit-level=(low|moderate|high|critical)]
Expand Down
60 changes: 60 additions & 0 deletions docs/content/cli-commands/npm-fund.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
section: cli-commands
title: npm-fund
description: Retrieve funding information
---

# npm-fund

## Retrieve funding information

### Synopsis

```bash
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](/cli-commands/npm-docs)
* [npm-config](/cli-commands/npm-config)
* [npm-install](/cli-commands/npm-install)
* [npm-ls](/cli-commands/npm-ls)

5 changes: 5 additions & 0 deletions docs/content/cli-commands/npm-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,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](/docs/configuring-npm/folders).
Expand Down Expand Up @@ -481,6 +485,7 @@ affects a real use-case, it will be investigated.
* [npm folders](/configuring-npm/folders)
* [npm update](/cli-commands/npm-update)
* [npm audit](/cli-commands/npm-audit)
* [npm fund](/cli-commands/npm-fund)
* [npm link](/cli-commands/npm-link)
* [npm rebuild](/cli-commands/npm-rebuild)
* [npm scripts](/using-npm/scripts)
Expand Down
8 changes: 8 additions & 0 deletions docs/content/cli-commands/npm-ls.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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](/cli-commands/npm-config)
Expand Down
22 changes: 16 additions & 6 deletions docs/content/configuring-npm/package-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,25 @@ Both email and url are optional either way.

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

### support
### funding

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

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

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

Users can use the `npm support` subcommand to list the `support` URLs
of all dependencies of the project, direct and indirect.
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

Expand Down
9 changes: 9 additions & 0 deletions docs/content/using-npm/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,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`](/docs/cli-commands/npm-fund) for details.

#### git

* Default: `"git"`
Expand Down
2 changes: 1 addition & 1 deletion lib/config/cmd-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ var cmdList = [
'token',
'profile',
'audit',
'support',
'fund',
'org',

'help',
Expand Down
3 changes: 3 additions & 0 deletions lib/config/defaults.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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)
})
})
}
Loading

0 comments on commit 4414b06

Please sign in to comment.