Skip to content

Commit 7e908c2

Browse files
committed
chore: arborist fund cmd refactor
- 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
1 parent d7397ba commit 7e908c2

File tree

18 files changed

+1222
-1417
lines changed

18 files changed

+1222
-1417
lines changed

docs/content/cli-commands/npm-fund.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ config param; if there are multiple funding sources for the package, the
2525
user will be instructed to pass the `--which` command to disambiguate.
2626

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

3131
### Configuration

lib/config/flat-options.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ const flatOptions = npm => npm.flatOptions || Object.freeze({
193193
},
194194
userAgent: npm.config.get('user-agent'),
195195

196-
...getScopesAndAuths(npm)
196+
...getScopesAndAuths(npm),
197+
198+
// npm fund exclusive option to select an item from a funding list
199+
which: npm.config.get('which')
197200
})
198201

199202
const getPreferOnline = npm => {

lib/fund.js

Lines changed: 168 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,29 @@
11
'use strict'
22

3-
const path = require('path')
4-
53
const archy = require('archy')
6-
const readPackageTree = require('read-package-tree')
4+
const Arborist = require('@npmcli/arborist')
5+
const pacote = require('pacote')
6+
const semver = require('semver')
7+
const npa = require('npm-package-arg')
8+
const { depth } = require('treeverse')
9+
const {
10+
readTree: getFundingInfo,
11+
normalizeFunding,
12+
isValidFunding
13+
} = require('libnpmfund')
714

815
const npm = require('./npm.js')
9-
const fetchPackageMetadata = require('./fetch-package-metadata.js')
10-
const computeMetadata = require('./install/deps.js').computeMetadata
11-
const readShrinkwrap = require('./install/read-shrinkwrap.js')
12-
const mutateIntoLogicalTree = require('./install/mutate-into-logical-tree.js')
1316
const output = require('./utils/output.js')
1417
const openUrl = require('./utils/open-url.js')
15-
const {
16-
getFundingInfo,
17-
retrieveFunding,
18-
validFundingUrl
19-
} = require('./utils/funding.js')
18+
const usageUtil = require('./utils/usage.js')
2019

21-
module.exports = fundCmd
22-
23-
const usage = require('./utils/usage')
24-
fundCmd.usage = usage(
20+
const usage = usageUtil(
2521
'fund',
26-
'npm fund [--json]',
27-
'npm fund [--browser] [[<@scope>/]<pkg>'
22+
'npm fund',
23+
'npm fund [--json] [--browser] [--unicode] [[<@scope>/]<pkg> [--which=<fundingSourceNumber>]'
2824
)
2925

30-
fundCmd.completion = function (opts, cb) {
26+
const completion = (opts, cb) => {
3127
const argv = opts.conf.argv.remain
3228
switch (argv[2]) {
3329
case 'fund':
@@ -37,161 +33,190 @@ fundCmd.completion = function (opts, cb) {
3733
}
3834
}
3935

36+
const cmd = (args, cb) => fund(args).then(() => cb()).catch(cb)
37+
4038
function printJSON (fundingInfo) {
4139
return JSON.stringify(fundingInfo, null, 2)
4240
}
4341

44-
// the human-printable version does some special things that turned out to
45-
// be very verbose but hopefully not hard to follow: we stack up items
46-
// that have a shared url/type and make sure they're printed at the highest
47-
// level possible, in that process they also carry their dependencies along
48-
// with them, moving those up in the visual tree
49-
function printHuman (fundingInfo, opts) {
50-
// mapping logic that keeps track of seen items in order to be able
51-
// to push all other items from the same type/url in the same place
52-
const seen = new Map()
53-
54-
function seenKey ({ type, url } = {}) {
55-
return url ? String(type) + String(url) : null
56-
}
57-
58-
function setStackedItem (funding, result) {
59-
const key = seenKey(funding)
60-
if (key && !seen.has(key)) seen.set(key, result)
61-
}
62-
63-
function retrieveStackedItem (funding) {
64-
const key = seenKey(funding)
65-
if (key && seen.has(key)) return seen.get(key)
66-
}
42+
const getPrintableName = ({ name, version }) => {
43+
const printableVersion = version ? `@${version}` : ''
44+
return `${name}${printableVersion}`
45+
}
6746

68-
// ---
69-
70-
const getFundingItems = (fundingItems) =>
71-
Object.keys(fundingItems || {}).map((fundingItemName) => {
72-
// first-level loop, prepare the pretty-printed formatted data
73-
const fundingItem = fundingItems[fundingItemName]
74-
const { version, funding } = fundingItem
75-
const { type, url } = funding || {}
76-
77-
const printableVersion = version ? `@${version}` : ''
78-
const printableType = type && { label: `type: ${funding.type}` }
79-
const printableUrl = url && { label: `url: ${funding.url}` }
80-
const result = {
81-
fundingItem,
82-
label: fundingItemName + printableVersion,
83-
nodes: []
47+
function printHuman (fundingInfo, { unicode }) {
48+
const seenUrls = new Map()
49+
50+
const tree = obj =>
51+
archy(obj, '', { unicode })
52+
53+
const result = depth({
54+
tree: fundingInfo,
55+
visit: ({ name, version, funding }) => {
56+
// composes human readable package name
57+
// and creates a new archy item for readable output
58+
const { url } = funding || {}
59+
const pkgRef = getPrintableName({ name, version })
60+
const label = url ? tree({
61+
label: url,
62+
nodes: [pkgRef]
63+
}).trim() : pkgRef
64+
let item = {
65+
label
8466
}
8567

86-
if (printableType) {
87-
result.nodes.push(printableType)
68+
// stacks all packages together under the same item
69+
if (seenUrls.has(url)) {
70+
item = seenUrls.get(url)
71+
item.label += `, ${pkgRef}`
72+
return null
73+
} else {
74+
seenUrls.set(url, item)
8875
}
8976

90-
if (printableUrl) {
91-
result.nodes.push(printableUrl)
92-
}
77+
return item
78+
},
79+
80+
// puts child nodes back into returned archy
81+
// output while also filtering out missing items
82+
leave: (item, children) => {
83+
if (item)
84+
item.nodes = children.filter(Boolean)
85+
86+
return item
87+
},
88+
89+
// turns tree-like object return by libnpmfund
90+
// into children to be properly read by treeverse
91+
getChildren: (node) =>
92+
Object.keys(node.dependencies || {})
93+
.map(key => ({
94+
name: key,
95+
...node.dependencies[key]
96+
}))
97+
})
98+
99+
return tree(result)
100+
}
93101

94-
setStackedItem(funding, result)
95-
96-
return result
97-
}).reduce((res, result) => {
98-
// recurse and exclude nodes that are going to be stacked together
99-
const { fundingItem } = result
100-
const { dependencies, funding } = fundingItem
101-
const items = getFundingItems(dependencies)
102-
const stackedResult = retrieveStackedItem(funding)
103-
items.forEach(i => result.nodes.push(i))
104-
105-
if (stackedResult && stackedResult !== result) {
106-
stackedResult.label += `, ${result.label}`
107-
items.forEach(i => stackedResult.nodes.push(i))
108-
return res
102+
async function openFundingUrl ({ path, tree, spec, fundingSourceNumber }) {
103+
const arg = npa(spec, path)
104+
const retrievePackageMetadata = () => {
105+
106+
if (arg.type === 'directory') {
107+
if (tree.path === arg.fetchSpec) {
108+
// matches cwd, e.g: npm fund .
109+
return tree.package
110+
} else {
111+
// matches any file path within current arborist inventory
112+
for (const item of tree.inventory.values()) {
113+
if (item.path === arg.fetchSpec) {
114+
return item.package
115+
}
116+
}
117+
}
118+
} else {
119+
// tries to retrieve a package from arborist inventory
120+
// by matching resulted package name from the provided spec
121+
const [ item ] = [...tree.inventory.query('name', arg.name)]
122+
.filter(i => semver.valid(i.package.version))
123+
.sort((a, b) => semver.rcompare(a.package.version, b.package.version))
124+
125+
if (item) {
126+
return item.package
109127
}
128+
}
129+
}
110130

111-
res.push(result)
131+
let { funding } = retrievePackageMetadata() || {}
112132

113-
return res
114-
}, [])
133+
if (!funding) {
134+
// if still has not funding info, let's try
135+
// fetching metadata from the registry then
136+
const manifest = await pacote.manifest(arg, npm.flatOptions)
137+
funding = manifest.funding
138+
}
115139

116-
const [ result ] = getFundingItems({
117-
[fundingInfo.name]: {
118-
dependencies: fundingInfo.dependencies,
119-
funding: fundingInfo.funding,
120-
version: fundingInfo.version
121-
}
122-
})
140+
const validSources = []
141+
.concat(normalizeFunding(funding))
142+
.filter(isValidFunding)
123143

124-
return archy(result, '', { unicode: opts.unicode })
125-
}
144+
const matchesValidSource =
145+
validSources.length === 1 ||
146+
(fundingSourceNumber > 0 && fundingSourceNumber <= validSources.length)
126147

127-
function openFundingUrl (packageName, cb) {
128-
function getUrlAndOpen (packageMetadata) {
129-
const { funding } = packageMetadata
130-
const { type, url } = retrieveFunding(funding) || {}
131-
const noFundingError =
132-
new Error(`No funding method available for: ${packageName}`)
133-
noFundingError.code = 'ENOFUND'
148+
if (matchesValidSource) {
149+
const index = fundingSourceNumber ? fundingSourceNumber - 1 : 0
150+
const { type, url } = validSources[index]
134151
const typePrefix = type ? `${type} funding` : 'Funding'
135152
const msg = `${typePrefix} available at the following URL`
153+
return new Promise((resolve, reject) =>
154+
openUrl(url, msg, err => err
155+
? reject(err)
156+
: resolve()
157+
))
158+
} else if (validSources.length && !(fundingSourceNumber >= 1)) {
159+
validSources.forEach(({ type, url }, i) => {
160+
const typePrefix = type ? `${type} funding` : 'Funding'
161+
const msg = `${typePrefix} available at the following URL`
162+
output(`${i + 1}: ${msg}: ${url}`)
163+
})
164+
output('Run `npm fund [<@scope>/]<pkg> --which=1`, for example, to open the first funding URL listed in that package')
165+
} else {
166+
const noFundingError = new Error(`No valid funding method available for: ${spec}`)
167+
noFundingError.code = 'ENOFUND'
136168

137-
if (validFundingUrl(funding)) {
138-
openUrl(url, msg, cb)
139-
} else {
140-
throw noFundingError
141-
}
169+
throw noFundingError
142170
}
143-
144-
fetchPackageMetadata(
145-
packageName,
146-
'.',
147-
{ fullMetadata: true },
148-
function (err, packageMetadata) {
149-
if (err) return cb(err)
150-
getUrlAndOpen(packageMetadata)
151-
}
152-
)
153171
}
154172

155-
function fundCmd (args, cb) {
173+
const fund = async (args) => {
156174
const opts = npm.flatOptions
157-
const dir = path.resolve(npm.dir, '..')
158-
const packageName = args[0]
175+
const spec = args[0]
176+
const numberArg = opts.which
177+
178+
const fundingSourceNumber = numberArg && parseInt(numberArg, 10)
179+
180+
const badFundingSourceNumber =
181+
numberArg !== undefined &&
182+
(String(fundingSourceNumber) !== numberArg || fundingSourceNumber < 1)
183+
184+
if (badFundingSourceNumber) {
185+
const err = new Error('`npm fund [<@scope>/]<pkg> [--which=fundingSourceNumber]` must be given a positive integer')
186+
err.code = 'EFUNDNUMBER'
187+
throw err
188+
}
159189

160190
if (opts.global) {
161-
const err = new Error('`npm fund` does not support globals')
191+
const err = new Error('`npm fund` does not support global packages')
162192
err.code = 'EFUNDGLOBAL'
163193
throw err
164194
}
165195

166-
if (packageName) {
167-
openFundingUrl(packageName, cb)
196+
const where = npm.prefix
197+
const arb = new Arborist({ ...opts, path: where })
198+
const tree = await arb.loadActual()
199+
200+
if (spec) {
201+
await openFundingUrl({
202+
path: where,
203+
tree,
204+
spec,
205+
fundingSourceNumber
206+
})
168207
return
169208
}
170209

171-
readPackageTree(dir, function (err, tree) {
172-
if (err) {
173-
process.exitCode = 1
174-
return cb(err)
175-
}
210+
const print = opts.json
211+
? printJSON
212+
: printHuman
176213

177-
readShrinkwrap.andInflate(tree, function () {
178-
const fundingInfo = getFundingInfo(
179-
mutateIntoLogicalTree.asReadInstalled(
180-
computeMetadata(tree)
181-
)
182-
)
183-
184-
const print = opts.json
185-
? printJSON
186-
: printHuman
187-
188-
output(
189-
print(
190-
fundingInfo,
191-
opts
192-
)
193-
)
194-
cb(err, tree)
195-
})
196-
})
214+
output(
215+
print(
216+
getFundingInfo(tree),
217+
opts
218+
)
219+
)
197220
}
221+
222+
module.exports = Object.assign(cmd, { usage, completion })

0 commit comments

Comments
 (0)