1const cacache = require('cacache')
2const pacote = require('pacote')
3const fs = require('fs/promises')
4const { join } = require('path')
5const semver = require('semver')
6const BaseCommand = require('../base-command.js')
7const npa = require('npm-package-arg')
8const jsonParse = require('json-parse-even-better-errors')
9const localeCompare = require('@isaacs/string-locale-compare')('en')
10const log = require('../utils/log-shim')
11
12const searchCachePackage = async (path, parsed, cacheKeys) => {
13  /* eslint-disable-next-line max-len */
14  const searchMFH = new RegExp(`^make-fetch-happen:request-cache:.*(?<!/[@a-zA-Z]+)/${parsed.name}/-/(${parsed.name}[^/]+.tgz)$`)
15  const searchPack = new RegExp(`^make-fetch-happen:request-cache:.*/${parsed.escapedName}$`)
16  const results = new Set()
17  cacheKeys = new Set(cacheKeys)
18  for (const key of cacheKeys) {
19    // match on the public key registry url format
20    if (searchMFH.test(key)) {
21      // extract the version from the filename
22      const filename = key.match(searchMFH)[1]
23      const noExt = filename.slice(0, -4)
24      const noScope = `${parsed.name.split('/').pop()}-`
25      const ver = noExt.slice(noScope.length)
26      if (semver.satisfies(ver, parsed.rawSpec)) {
27        results.add(key)
28      }
29      continue
30    }
31    // is this key a packument?
32    if (!searchPack.test(key)) {
33      continue
34    }
35
36    results.add(key)
37    let packument, details
38    try {
39      details = await cacache.get(path, key)
40      packument = jsonParse(details.data)
41    } catch (_) {
42      // if we couldn't parse the packument, abort
43      continue
44    }
45    if (!packument.versions || typeof packument.versions !== 'object') {
46      continue
47    }
48
49    // assuming this is a packument
50    for (const ver of Object.keys(packument.versions)) {
51      if (semver.satisfies(ver, parsed.rawSpec)) {
52        if (packument.versions[ver].dist &&
53          typeof packument.versions[ver].dist === 'object' &&
54          packument.versions[ver].dist.tarball !== undefined &&
55          cacheKeys.has(`make-fetch-happen:request-cache:${packument.versions[ver].dist.tarball}`)
56        ) {
57          results.add(`make-fetch-happen:request-cache:${packument.versions[ver].dist.tarball}`)
58        }
59      }
60    }
61  }
62  return results
63}
64
65class Cache extends BaseCommand {
66  static description = 'Manipulates packages cache'
67  static name = 'cache'
68  static params = ['cache']
69  static usage = [
70    'add <package-spec>',
71    'clean [<key>]',
72    'ls [<name>@<version>]',
73    'verify',
74  ]
75
76  static async completion (opts) {
77    const argv = opts.conf.argv.remain
78    if (argv.length === 2) {
79      return ['add', 'clean', 'verify', 'ls']
80    }
81
82    // TODO - eventually...
83    switch (argv[2]) {
84      case 'verify':
85      case 'clean':
86      case 'add':
87      case 'ls':
88        return []
89    }
90  }
91
92  async exec (args) {
93    const cmd = args.shift()
94    switch (cmd) {
95      case 'rm': case 'clear': case 'clean':
96        return await this.clean(args)
97      case 'add':
98        return await this.add(args)
99      case 'verify': case 'check':
100        return await this.verify()
101      case 'ls':
102        return await this.ls(args)
103      default:
104        throw this.usageError()
105    }
106  }
107
108  // npm cache clean [pkg]*
109  async clean (args) {
110    const cachePath = join(this.npm.cache, '_cacache')
111    if (args.length === 0) {
112      if (!this.npm.config.get('force')) {
113        throw new Error(`As of npm@5, the npm cache self-heals from corruption issues
114  by treating integrity mismatches as cache misses.  As a result,
115  data extracted from the cache is guaranteed to be valid.  If you
116  want to make sure everything is consistent, use \`npm cache verify\`
117  instead.  Deleting the cache can only make npm go slower, and is
118  not likely to correct any problems you may be encountering!
119
120  On the other hand, if you're debugging an issue with the installer,
121  or race conditions that depend on the timing of writing to an empty
122  cache, you can use \`npm install --cache /tmp/empty-cache\` to use a
123  temporary cache instead of nuking the actual one.
124
125  If you're sure you want to delete the entire cache, rerun this command
126  with --force.`)
127      }
128      return fs.rm(cachePath, { recursive: true, force: true })
129    }
130    for (const key of args) {
131      let entry
132      try {
133        entry = await cacache.get(cachePath, key)
134      } catch (err) {
135        log.warn(`Not Found: ${key}`)
136        break
137      }
138      this.npm.output(`Deleted: ${key}`)
139      await cacache.rm.entry(cachePath, key)
140      // XXX this could leave other entries without content!
141      await cacache.rm.content(cachePath, entry.integrity)
142    }
143  }
144
145  // npm cache add <tarball-url>...
146  // npm cache add <pkg> <ver>...
147  // npm cache add <tarball>...
148  // npm cache add <folder>...
149  async add (args) {
150    log.silly('cache add', 'args', args)
151    if (args.length === 0) {
152      throw this.usageError('First argument to `add` is required')
153    }
154
155    return Promise.all(args.map(spec => {
156      log.silly('cache add', 'spec', spec)
157      // we ask pacote for the thing, and then just throw the data
158      // away so that it tee-pipes it into the cache like it does
159      // for a normal request.
160      return pacote.tarball.stream(spec, stream => {
161        stream.resume()
162        return stream.promise()
163      }, { ...this.npm.flatOptions })
164    }))
165  }
166
167  async verify () {
168    const cache = join(this.npm.cache, '_cacache')
169    const prefix = cache.indexOf(process.env.HOME) === 0
170      ? `~${cache.slice(process.env.HOME.length)}`
171      : cache
172    const stats = await cacache.verify(cache)
173    this.npm.output(`Cache verified and compressed (${prefix})`)
174    this.npm.output(`Content verified: ${stats.verifiedContent} (${stats.keptSize} bytes)`)
175    if (stats.badContentCount) {
176      this.npm.output(`Corrupted content removed: ${stats.badContentCount}`)
177    }
178    if (stats.reclaimedCount) {
179      /* eslint-disable-next-line max-len */
180      this.npm.output(`Content garbage-collected: ${stats.reclaimedCount} (${stats.reclaimedSize} bytes)`)
181    }
182    if (stats.missingContent) {
183      this.npm.output(`Missing content: ${stats.missingContent}`)
184    }
185    this.npm.output(`Index entries: ${stats.totalEntries}`)
186    this.npm.output(`Finished in ${stats.runTime.total / 1000}s`)
187  }
188
189  // npm cache ls [--package <spec> ...]
190  async ls (specs) {
191    const cachePath = join(this.npm.cache, '_cacache')
192    const cacheKeys = Object.keys(await cacache.ls(cachePath))
193    if (specs.length > 0) {
194      // get results for each package spec specified
195      const results = new Set()
196      for (const spec of specs) {
197        const parsed = npa(spec)
198        if (parsed.rawSpec !== '' && parsed.type === 'tag') {
199          throw this.usageError('Cannot list cache keys for a tagged package.')
200        }
201        const keySet = await searchCachePackage(cachePath, parsed, cacheKeys)
202        for (const key of keySet) {
203          results.add(key)
204        }
205      }
206      [...results].sort(localeCompare).forEach(key => this.npm.output(key))
207      return
208    }
209    cacheKeys.sort(localeCompare).forEach(key => this.npm.output(key))
210  }
211}
212
213module.exports = Cache
214