1const archy = require('archy') 2const pacote = require('pacote') 3const semver = require('semver') 4const npa = require('npm-package-arg') 5const { depth } = require('treeverse') 6const { readTree: getFundingInfo, normalizeFunding, isValidFunding } = require('libnpmfund') 7 8const openUrl = require('../utils/open-url.js') 9const ArboristWorkspaceCmd = require('../arborist-cmd.js') 10 11const getPrintableName = ({ name, version }) => { 12 const printableVersion = version ? `@${version}` : '' 13 return `${name}${printableVersion}` 14} 15 16const errCode = (msg, code) => Object.assign(new Error(msg), { code }) 17 18class Fund extends ArboristWorkspaceCmd { 19 static description = 'Retrieve funding information' 20 static name = 'fund' 21 static params = ['json', 'browser', 'unicode', 'workspace', 'which'] 22 static usage = ['[<package-spec>]'] 23 24 // XXX: maybe worth making this generic for all commands? 25 usageMessage (paramsObj = {}) { 26 let msg = `\`npm ${this.constructor.name}` 27 const params = Object.entries(paramsObj) 28 if (params.length) { 29 msg += ` ${this.constructor.usage}` 30 } 31 for (const [key, value] of params) { 32 msg += ` --${key}=${value}` 33 } 34 return `${msg}\`` 35 } 36 37 // TODO 38 /* istanbul ignore next */ 39 static async completion (opts, npm) { 40 const completion = require('../utils/completion/installed-deep.js') 41 return completion(npm, opts) 42 } 43 44 async exec (args) { 45 const spec = args[0] 46 47 let fundingSourceNumber = this.npm.config.get('which') 48 if (fundingSourceNumber != null) { 49 fundingSourceNumber = parseInt(fundingSourceNumber, 10) 50 if (isNaN(fundingSourceNumber) || fundingSourceNumber < 1) { 51 throw errCode( 52 `${this.usageMessage({ which: 'fundingSourceNumber' })} must be given a positive integer`, 53 'EFUNDNUMBER' 54 ) 55 } 56 } 57 58 if (this.npm.global) { 59 throw errCode( 60 `${this.usageMessage()} does not support global packages`, 61 'EFUNDGLOBAL' 62 ) 63 } 64 65 const where = this.npm.prefix 66 const Arborist = require('@npmcli/arborist') 67 const arb = new Arborist({ ...this.npm.flatOptions, path: where }) 68 const tree = await arb.loadActual() 69 70 if (spec) { 71 await this.openFundingUrl({ 72 path: where, 73 tree, 74 spec, 75 fundingSourceNumber, 76 }) 77 return 78 } 79 80 // TODO: add !workspacesEnabled option handling to libnpmfund 81 const fundingInfo = getFundingInfo(tree, { 82 ...this.flatOptions, 83 Arborist, 84 workspaces: this.workspaceNames, 85 }) 86 87 if (this.npm.config.get('json')) { 88 this.npm.output(this.printJSON(fundingInfo)) 89 } else { 90 this.npm.output(this.printHuman(fundingInfo)) 91 } 92 } 93 94 printJSON (fundingInfo) { 95 return JSON.stringify(fundingInfo, null, 2) 96 } 97 98 printHuman (fundingInfo) { 99 const unicode = this.npm.config.get('unicode') 100 const seenUrls = new Map() 101 102 const tree = obj => archy(obj, '', { unicode }) 103 104 const result = depth({ 105 tree: fundingInfo, 106 107 // composes human readable package name 108 // and creates a new archy item for readable output 109 visit: ({ name, version, funding }) => { 110 const [fundingSource] = [].concat(normalizeFunding(funding)).filter(isValidFunding) 111 const { url } = fundingSource || {} 112 const pkgRef = getPrintableName({ name, version }) 113 let item = { 114 label: pkgRef, 115 } 116 117 if (url) { 118 item.label = tree({ 119 label: this.npm.chalk.bgBlack.white(url), 120 nodes: [pkgRef], 121 }).trim() 122 123 // stacks all packages together under the same item 124 if (seenUrls.has(url)) { 125 item = seenUrls.get(url) 126 item.label += `, ${pkgRef}` 127 return null 128 } else { 129 seenUrls.set(url, item) 130 } 131 } 132 133 return item 134 }, 135 136 // puts child nodes back into returned archy 137 // output while also filtering out missing items 138 leave: (item, children) => { 139 if (item) { 140 item.nodes = children.filter(Boolean) 141 } 142 143 return item 144 }, 145 146 // turns tree-like object return by libnpmfund 147 // into children to be properly read by treeverse 148 getChildren: node => 149 Object.keys(node.dependencies || {}).map(key => ({ 150 name: key, 151 ...node.dependencies[key], 152 })), 153 }) 154 155 const res = tree(result) 156 return this.npm.chalk.reset(res) 157 } 158 159 async openFundingUrl ({ path, tree, spec, fundingSourceNumber }) { 160 const arg = npa(spec, path) 161 162 const retrievePackageMetadata = () => { 163 if (arg.type === 'directory') { 164 if (tree.path === arg.fetchSpec) { 165 // matches cwd, e.g: npm fund . 166 return tree.package 167 } else { 168 // matches any file path within current arborist inventory 169 for (const item of tree.inventory.values()) { 170 if (item.path === arg.fetchSpec) { 171 return item.package 172 } 173 } 174 } 175 } else { 176 // tries to retrieve a package from arborist inventory 177 // by matching resulted package name from the provided spec 178 const [item] = [...tree.inventory.query('name', arg.name)] 179 .filter(i => semver.valid(i.package.version)) 180 .sort((a, b) => semver.rcompare(a.package.version, b.package.version)) 181 182 if (item) { 183 return item.package 184 } 185 } 186 } 187 188 const { funding } = 189 retrievePackageMetadata() || 190 (await pacote.manifest(arg, this.npm.flatOptions).catch(() => ({}))) 191 192 const validSources = [].concat(normalizeFunding(funding)).filter(isValidFunding) 193 194 if (!validSources.length) { 195 throw errCode(`No valid funding method available for: ${spec}`, 'ENOFUND') 196 } 197 198 const fundSource = fundingSourceNumber 199 ? validSources[fundingSourceNumber - 1] 200 : validSources.length === 1 ? validSources[0] 201 : null 202 203 if (fundSource) { 204 return openUrl(this.npm, ...this.urlMessage(fundSource)) 205 } 206 207 const ambiguousUrlMsg = [ 208 ...validSources.map((s, i) => `${i + 1}: ${this.urlMessage(s).reverse().join(': ')}`), 209 `Run ${this.usageMessage({ which: '1' })}` + 210 ', for example, to open the first funding URL listed in that package', 211 ] 212 if (fundingSourceNumber) { 213 ambiguousUrlMsg.unshift(`--which=${fundingSourceNumber} is not a valid index`) 214 } 215 this.npm.output(ambiguousUrlMsg.join('\n')) 216 } 217 218 urlMessage (source) { 219 const { type, url } = source 220 const typePrefix = type ? `${type} funding` : 'Funding' 221 const message = `${typePrefix} available at the following URL` 222 return [url, message] 223 } 224} 225module.exports = Fund 226