Skip to content

Commit

Permalink
chore: arborist fund cmd refactor
Browse files Browse the repository at this point in the history
- npm fund cmd:
  - no longer depends on `lib/install` modules
  - now it uses arborist tree and inventory to retrieve funding data
  - refactor to use same exports patterns to new commands
  - changed human output to reinstate representation of nested deps
- install:
  - no longer breaks on missing audit report
  - refactored `reify-output` to use `libnpmfund` module
  - added tests for utils.reify-output fund summary
- moved logic from `lib/utils/funding.js` into a new `libnpmfund` pkg

PR-URL: npm#1311
Credit: @ruyadorno
Close: npm#1311
Reviewed-by: @darcyclarke
  • Loading branch information
ruyadorno committed Jun 12, 2020
1 parent d7397ba commit a16f172
Show file tree
Hide file tree
Showing 18 changed files with 1,222 additions and 1,417 deletions.
2 changes: 1 addition & 1 deletion docs/content/cli-commands/npm-fund.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ config param; if there are multiple funding sources for the package, the
user will be instructed to pass the `--which` command to disambiguate.

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
that share the same 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
Expand Down
5 changes: 4 additions & 1 deletion lib/config/flat-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,10 @@ const flatOptions = npm => npm.flatOptions || Object.freeze({
},
userAgent: npm.config.get('user-agent'),

...getScopesAndAuths(npm)
...getScopesAndAuths(npm),

// npm fund exclusive option to select an item from a funding list
which: npm.config.get('which')
})

const getPreferOnline = npm => {
Expand Down
311 changes: 168 additions & 143 deletions lib/fund.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
'use strict'

const path = require('path')

const archy = require('archy')
const readPackageTree = require('read-package-tree')
const Arborist = require('@npmcli/arborist')
const pacote = require('pacote')
const semver = require('semver')
const npa = require('npm-package-arg')
const { depth } = require('treeverse')
const {
readTree: getFundingInfo,
normalizeFunding,
isValidFunding
} = require('libnpmfund')

const npm = require('./npm.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,
retrieveFunding,
validFundingUrl
} = require('./utils/funding.js')
const usageUtil = require('./utils/usage.js')

module.exports = fundCmd

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

fundCmd.completion = function (opts, cb) {
const completion = (opts, cb) => {
const argv = opts.conf.argv.remain
switch (argv[2]) {
case 'fund':
Expand All @@ -37,161 +33,190 @@ fundCmd.completion = function (opts, cb) {
}
}

const cmd = (args, cb) => fund(args).then(() => cb()).catch(cb)

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 getPrintableName = ({ name, version }) => {
const printableVersion = version ? `@${version}` : ''
return `${name}${printableVersion}`
}

// ---

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: []
function printHuman (fundingInfo, { unicode }) {
const seenUrls = new Map()

const tree = obj =>
archy(obj, '', { unicode })

const result = depth({
tree: fundingInfo,
visit: ({ name, version, funding }) => {
// composes human readable package name
// and creates a new archy item for readable output
const { url } = funding || {}
const pkgRef = getPrintableName({ name, version })
const label = url ? tree({
label: url,
nodes: [pkgRef]
}).trim() : pkgRef
let item = {
label
}

if (printableType) {
result.nodes.push(printableType)
// stacks all packages together under the same item
if (seenUrls.has(url)) {
item = seenUrls.get(url)
item.label += `, ${pkgRef}`
return null
} else {
seenUrls.set(url, item)
}

if (printableUrl) {
result.nodes.push(printableUrl)
}
return item
},

// puts child nodes back into returned archy
// output while also filtering out missing items
leave: (item, children) => {
if (item)
item.nodes = children.filter(Boolean)

return item
},

// turns tree-like object return by libnpmfund
// into children to be properly read by treeverse
getChildren: (node) =>
Object.keys(node.dependencies || {})
.map(key => ({
name: key,
...node.dependencies[key]
}))
})

return tree(result)
}

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
async function openFundingUrl ({ path, tree, spec, fundingSourceNumber }) {
const arg = npa(spec, path)
const retrievePackageMetadata = () => {

if (arg.type === 'directory') {
if (tree.path === arg.fetchSpec) {
// matches cwd, e.g: npm fund .
return tree.package
} else {
// matches any file path within current arborist inventory
for (const item of tree.inventory.values()) {
if (item.path === arg.fetchSpec) {
return item.package
}
}
}
} else {
// tries to retrieve a package from arborist inventory
// by matching resulted package name from the provided spec
const [ item ] = [...tree.inventory.query('name', arg.name)]
.filter(i => semver.valid(i.package.version))
.sort((a, b) => semver.rcompare(a.package.version, b.package.version))

if (item) {
return item.package
}
}
}

res.push(result)
let { funding } = retrievePackageMetadata() || {}

return res
}, [])
if (!funding) {
// if still has not funding info, let's try
// fetching metadata from the registry then
const manifest = await pacote.manifest(arg, npm.flatOptions)
funding = manifest.funding
}

const [ result ] = getFundingItems({
[fundingInfo.name]: {
dependencies: fundingInfo.dependencies,
funding: fundingInfo.funding,
version: fundingInfo.version
}
})
const validSources = []
.concat(normalizeFunding(funding))
.filter(isValidFunding)

return archy(result, '', { unicode: opts.unicode })
}
const matchesValidSource =
validSources.length === 1 ||
(fundingSourceNumber > 0 && fundingSourceNumber <= validSources.length)

function openFundingUrl (packageName, cb) {
function getUrlAndOpen (packageMetadata) {
const { funding } = packageMetadata
const { type, url } = retrieveFunding(funding) || {}
const noFundingError =
new Error(`No funding method available for: ${packageName}`)
noFundingError.code = 'ENOFUND'
if (matchesValidSource) {
const index = fundingSourceNumber ? fundingSourceNumber - 1 : 0
const { type, url } = validSources[index]
const typePrefix = type ? `${type} funding` : 'Funding'
const msg = `${typePrefix} available at the following URL`
return new Promise((resolve, reject) =>
openUrl(url, msg, err => err
? reject(err)
: resolve()
))
} else if (validSources.length && !(fundingSourceNumber >= 1)) {
validSources.forEach(({ type, url }, i) => {
const typePrefix = type ? `${type} funding` : 'Funding'
const msg = `${typePrefix} available at the following URL`
output(`${i + 1}: ${msg}: ${url}`)
})
output('Run `npm fund [<@scope>/]<pkg> --which=1`, for example, to open the first funding URL listed in that package')
} else {
const noFundingError = new Error(`No valid funding method available for: ${spec}`)
noFundingError.code = 'ENOFUND'

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

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

function fundCmd (args, cb) {
const fund = async (args) => {
const opts = npm.flatOptions
const dir = path.resolve(npm.dir, '..')
const packageName = args[0]
const spec = args[0]
const numberArg = opts.which

const fundingSourceNumber = numberArg && parseInt(numberArg, 10)

const badFundingSourceNumber =
numberArg !== undefined &&
(String(fundingSourceNumber) !== numberArg || fundingSourceNumber < 1)

if (badFundingSourceNumber) {
const err = new Error('`npm fund [<@scope>/]<pkg> [--which=fundingSourceNumber]` must be given a positive integer')
err.code = 'EFUNDNUMBER'
throw err
}

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

if (packageName) {
openFundingUrl(packageName, cb)
const where = npm.prefix
const arb = new Arborist({ ...opts, path: where })
const tree = await arb.loadActual()

if (spec) {
await openFundingUrl({
path: where,
tree,
spec,
fundingSourceNumber
})
return
}

readPackageTree(dir, function (err, tree) {
if (err) {
process.exitCode = 1
return cb(err)
}
const print = opts.json
? printJSON
: printHuman

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

const print = opts.json
? printJSON
: printHuman

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

module.exports = Object.assign(cmd, { usage, completion })
Loading

0 comments on commit a16f172

Please sign in to comment.