• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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