1'use strict'
2
3const npa = require('npm-package-arg')
4const semver = require('semver')
5const { checkEngine } = require('npm-install-checks')
6const normalizeBin = require('npm-normalize-package-bin')
7
8const engineOk = (manifest, npmVersion, nodeVersion) => {
9  try {
10    checkEngine(manifest, npmVersion, nodeVersion)
11    return true
12  } catch (_) {
13    return false
14  }
15}
16
17const isBefore = (verTimes, ver, time) =>
18  !verTimes || !verTimes[ver] || Date.parse(verTimes[ver]) <= time
19
20const avoidSemverOpt = { includePrerelease: true, loose: true }
21const shouldAvoid = (ver, avoid) =>
22  avoid && semver.satisfies(ver, avoid, avoidSemverOpt)
23
24const decorateAvoid = (result, avoid) =>
25  result && shouldAvoid(result.version, avoid)
26    ? { ...result, _shouldAvoid: true }
27    : result
28
29const pickManifest = (packument, wanted, opts) => {
30  const {
31    defaultTag = 'latest',
32    before = null,
33    nodeVersion = process.version,
34    npmVersion = null,
35    includeStaged = false,
36    avoid = null,
37    avoidStrict = false,
38  } = opts
39
40  const { name, time: verTimes } = packument
41  const versions = packument.versions || {}
42
43  if (avoidStrict) {
44    const looseOpts = {
45      ...opts,
46      avoidStrict: false,
47    }
48
49    const result = pickManifest(packument, wanted, looseOpts)
50    if (!result || !result._shouldAvoid) {
51      return result
52    }
53
54    const caret = pickManifest(packument, `^${result.version}`, looseOpts)
55    if (!caret || !caret._shouldAvoid) {
56      return {
57        ...caret,
58        _outsideDependencyRange: true,
59        _isSemVerMajor: false,
60      }
61    }
62
63    const star = pickManifest(packument, '*', looseOpts)
64    if (!star || !star._shouldAvoid) {
65      return {
66        ...star,
67        _outsideDependencyRange: true,
68        _isSemVerMajor: true,
69      }
70    }
71
72    throw Object.assign(new Error(`No avoidable versions for ${name}`), {
73      code: 'ETARGET',
74      name,
75      wanted,
76      avoid,
77      before,
78      versions: Object.keys(versions),
79    })
80  }
81
82  const staged = (includeStaged && packument.stagedVersions &&
83    packument.stagedVersions.versions) || {}
84  const restricted = (packument.policyRestrictions &&
85    packument.policyRestrictions.versions) || {}
86
87  const time = before && verTimes ? +(new Date(before)) : Infinity
88  const spec = npa.resolve(name, wanted || defaultTag)
89  const type = spec.type
90  const distTags = packument['dist-tags'] || {}
91
92  if (type !== 'tag' && type !== 'version' && type !== 'range') {
93    throw new Error('Only tag, version, and range are supported')
94  }
95
96  // if the type is 'tag', and not just the implicit default, then it must
97  // be that exactly, or nothing else will do.
98  if (wanted && type === 'tag') {
99    const ver = distTags[wanted]
100    // if the version in the dist-tags is before the before date, then
101    // we use that.  Otherwise, we get the highest precedence version
102    // prior to the dist-tag.
103    if (isBefore(verTimes, ver, time)) {
104      return decorateAvoid(versions[ver] || staged[ver] || restricted[ver], avoid)
105    } else {
106      return pickManifest(packument, `<=${ver}`, opts)
107    }
108  }
109
110  // similarly, if a specific version, then only that version will do
111  if (wanted && type === 'version') {
112    const ver = semver.clean(wanted, { loose: true })
113    const mani = versions[ver] || staged[ver] || restricted[ver]
114    return isBefore(verTimes, ver, time) ? decorateAvoid(mani, avoid) : null
115  }
116
117  // ok, sort based on our heuristics, and pick the best fit
118  const range = type === 'range' ? wanted : '*'
119
120  // if the range is *, then we prefer the 'latest' if available
121  // but skip this if it should be avoided, in that case we have
122  // to try a little harder.
123  const defaultVer = distTags[defaultTag]
124  if (defaultVer &&
125      (range === '*' || semver.satisfies(defaultVer, range, { loose: true })) &&
126      !shouldAvoid(defaultVer, avoid)) {
127    const mani = versions[defaultVer]
128    if (mani && isBefore(verTimes, defaultVer, time)) {
129      return mani
130    }
131  }
132
133  // ok, actually have to sort the list and take the winner
134  const allEntries = Object.entries(versions)
135    .concat(Object.entries(staged))
136    .concat(Object.entries(restricted))
137    .filter(([ver, mani]) => isBefore(verTimes, ver, time))
138
139  if (!allEntries.length) {
140    throw Object.assign(new Error(`No versions available for ${name}`), {
141      code: 'ENOVERSIONS',
142      name,
143      type,
144      wanted,
145      before,
146      versions: Object.keys(versions),
147    })
148  }
149
150  const sortSemverOpt = { loose: true }
151  const entries = allEntries.filter(([ver, mani]) =>
152    semver.satisfies(ver, range, { loose: true }))
153    .sort((a, b) => {
154      const [vera, mania] = a
155      const [verb, manib] = b
156      const notavoida = !shouldAvoid(vera, avoid)
157      const notavoidb = !shouldAvoid(verb, avoid)
158      const notrestra = !restricted[a]
159      const notrestrb = !restricted[b]
160      const notstagea = !staged[a]
161      const notstageb = !staged[b]
162      const notdepra = !mania.deprecated
163      const notdeprb = !manib.deprecated
164      const enginea = engineOk(mania, npmVersion, nodeVersion)
165      const engineb = engineOk(manib, npmVersion, nodeVersion)
166      // sort by:
167      // - not an avoided version
168      // - not restricted
169      // - not staged
170      // - not deprecated and engine ok
171      // - engine ok
172      // - not deprecated
173      // - semver
174      return (notavoidb - notavoida) ||
175        (notrestrb - notrestra) ||
176        (notstageb - notstagea) ||
177        ((notdeprb && engineb) - (notdepra && enginea)) ||
178        (engineb - enginea) ||
179        (notdeprb - notdepra) ||
180        semver.rcompare(vera, verb, sortSemverOpt)
181    })
182
183  return decorateAvoid(entries[0] && entries[0][1], avoid)
184}
185
186module.exports = (packument, wanted, opts = {}) => {
187  const mani = pickManifest(packument, wanted, opts)
188  const picked = mani && normalizeBin(mani)
189  const policyRestrictions = packument.policyRestrictions
190  const restricted = (policyRestrictions && policyRestrictions.versions) || {}
191
192  if (picked && !restricted[picked.version]) {
193    return picked
194  }
195
196  const { before = null, defaultTag = 'latest' } = opts
197  const bstr = before ? new Date(before).toLocaleString() : ''
198  const { name } = packument
199  const pckg = `${name}@${wanted}` +
200    (before ? ` with a date before ${bstr}` : '')
201
202  const isForbidden = picked && !!restricted[picked.version]
203  const polMsg = isForbidden ? policyRestrictions.message : ''
204
205  const msg = !isForbidden ? `No matching version found for ${pckg}.`
206    : `Could not download ${pckg} due to policy violations:\n${polMsg}`
207
208  const code = isForbidden ? 'E403' : 'ETARGET'
209  throw Object.assign(new Error(msg), {
210    code,
211    type: npa.resolve(packument.name, wanted).type,
212    wanted,
213    versions: Object.keys(packument.versions ?? {}),
214    name,
215    distTags: packument['dist-tags'],
216    defaultTag,
217  })
218}
219