• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict'
2
3const { Walker: IgnoreWalker } = require('ignore-walk')
4const { lstatSync: lstat, readFileSync: readFile } = require('fs')
5const { basename, dirname, extname, join, relative, resolve, sep } = require('path')
6
7// symbols used to represent synthetic rule sets
8const defaultRules = Symbol('npm-packlist.rules.default')
9const strictRules = Symbol('npm-packlist.rules.strict')
10
11// There may be others, but :?|<> are handled by node-tar
12const nameIsBadForWindows = file => /\*/.test(file)
13
14// these are the default rules that are applied to everything except for non-link bundled deps
15const defaults = [
16  '.npmignore',
17  '.gitignore',
18  '**/.git',
19  '**/.svn',
20  '**/.hg',
21  '**/CVS',
22  '**/.git/**',
23  '**/.svn/**',
24  '**/.hg/**',
25  '**/CVS/**',
26  '/.lock-wscript',
27  '/.wafpickle-*',
28  '/build/config.gypi',
29  'npm-debug.log',
30  '**/.npmrc',
31  '.*.swp',
32  '.DS_Store',
33  '**/.DS_Store/**',
34  '._*',
35  '**/._*/**',
36  '*.orig',
37  '/archived-packages/**',
38]
39
40const strictDefaults = [
41  // these are forcibly excluded
42  '/.git',
43]
44
45const normalizePath = (path) => path.split('\\').join('/')
46
47const readOutOfTreeIgnoreFiles = (root, rel, result = []) => {
48  for (const file of ['.npmignore', '.gitignore']) {
49    try {
50      const ignoreContent = readFile(join(root, file), { encoding: 'utf8' })
51      result.push(ignoreContent)
52      // break the loop immediately after reading, this allows us to prioritize
53      // the .npmignore and discard the .gitignore if one is present
54      break
55    } catch (err) {
56      // we ignore ENOENT errors completely because we don't care if the file doesn't exist
57      // but we throw everything else because failing to read a file that does exist is
58      // something that the user likely wants to know about
59      // istanbul ignore next -- we do not need to test a thrown error
60      if (err.code !== 'ENOENT') {
61        throw err
62      }
63    }
64  }
65
66  if (!rel) {
67    return result
68  }
69
70  const firstRel = rel.split(sep, 1)[0]
71  const newRoot = join(root, firstRel)
72  const newRel = relative(newRoot, join(root, rel))
73
74  return readOutOfTreeIgnoreFiles(newRoot, newRel, result)
75}
76
77class PackWalker extends IgnoreWalker {
78  constructor (tree, opts) {
79    const options = {
80      ...opts,
81      includeEmpty: false,
82      follow: false,
83      // we path.resolve() here because ignore-walk doesn't do it and we want full paths
84      path: resolve(opts?.path || tree.path).replace(/\\/g, '/'),
85      ignoreFiles: opts?.ignoreFiles || [
86        defaultRules,
87        'package.json',
88        '.npmignore',
89        '.gitignore',
90        strictRules,
91      ],
92    }
93
94    super(options)
95    this.isPackage = options.isPackage
96    this.seen = options.seen || new Set()
97    this.tree = tree
98    this.requiredFiles = options.requiredFiles || []
99
100    const additionalDefaults = []
101    if (options.prefix && options.workspaces) {
102      const path = normalizePath(options.path)
103      const prefix = normalizePath(options.prefix)
104      const workspaces = options.workspaces.map((ws) => normalizePath(ws))
105
106      // istanbul ignore else - this does nothing unless we need it to
107      if (path !== prefix && workspaces.includes(path)) {
108        // if path and prefix are not the same directory, and workspaces has path in it
109        // then we know path is a workspace directory. in order to not drop ignore rules
110        // from directories between the workspaces root (prefix) and the workspace itself
111        // (path) we need to find and read those now
112        const relpath = relative(options.prefix, dirname(options.path))
113        additionalDefaults.push(...readOutOfTreeIgnoreFiles(options.prefix, relpath))
114      } else if (path === prefix) {
115        // on the other hand, if the path and prefix are the same, then we ignore workspaces
116        // so that we don't pack a workspace as part of the root project. append them as
117        // normalized relative paths from the root
118        additionalDefaults.push(...workspaces.map((w) => normalizePath(relative(options.path, w))))
119      }
120    }
121
122    // go ahead and inject the default rules now
123    this.injectRules(defaultRules, [...defaults, ...additionalDefaults])
124
125    if (!this.isPackage) {
126      // if this instance is not a package, then place some strict default rules, and append
127      // known required files for this directory
128      this.injectRules(strictRules, [
129        ...strictDefaults,
130        ...this.requiredFiles.map((file) => `!${file}`),
131      ])
132    }
133  }
134
135  // overridden method: we intercept the reading of the package.json file here so that we can
136  // process it into both the package.json file rules as well as the strictRules synthetic rule set
137  addIgnoreFile (file, callback) {
138    // if we're adding anything other than package.json, then let ignore-walk handle it
139    if (file !== 'package.json' || !this.isPackage) {
140      return super.addIgnoreFile(file, callback)
141    }
142
143    return this.processPackage(callback)
144  }
145
146  // overridden method: if we're done, but we're a package, then we also need to evaluate bundles
147  // before we actually emit our done event
148  emit (ev, data) {
149    if (ev !== 'done' || !this.isPackage) {
150      return super.emit(ev, data)
151    }
152
153    // we intentionally delay the done event while keeping the function sync here
154    // eslint-disable-next-line promise/catch-or-return, promise/always-return
155    this.gatherBundles().then(() => {
156      super.emit('done', this.result)
157    })
158    return true
159  }
160
161  // overridden method: before actually filtering, we make sure that we've removed the rules for
162  // files that should no longer take effect due to our order of precedence
163  filterEntries () {
164    if (this.ignoreRules['package.json']) {
165      // package.json means no .npmignore or .gitignore
166      this.ignoreRules['.npmignore'] = null
167      this.ignoreRules['.gitignore'] = null
168    } else if (this.ignoreRules['.npmignore']) {
169      // .npmignore means no .gitignore
170      this.ignoreRules['.gitignore'] = null
171    }
172
173    return super.filterEntries()
174  }
175
176  // overridden method: we never want to include anything that isn't a file or directory
177  onstat (opts, callback) {
178    if (!opts.st.isFile() && !opts.st.isDirectory()) {
179      return callback()
180    }
181
182    return super.onstat(opts, callback)
183  }
184
185  // overridden method: we want to refuse to pack files that are invalid, node-tar protects us from
186  // a lot of them but not all
187  stat (opts, callback) {
188    if (nameIsBadForWindows(opts.entry)) {
189      return callback()
190    }
191
192    return super.stat(opts, callback)
193  }
194
195  // overridden method: this is called to create options for a child walker when we step
196  // in to a normal child directory (this will never be a bundle). the default method here
197  // copies the root's `ignoreFiles` value, but we don't want to respect package.json for
198  // subdirectories, so we override it with a list that intentionally omits package.json
199  walkerOpt (entry, opts) {
200    let ignoreFiles = null
201
202    // however, if we have a tree, and we have workspaces, and the directory we're about
203    // to step into is a workspace, then we _do_ want to respect its package.json
204    if (this.tree.workspaces) {
205      const workspaceDirs = [...this.tree.workspaces.values()]
206        .map((dir) => dir.replace(/\\/g, '/'))
207
208      const entryPath = join(this.path, entry).replace(/\\/g, '/')
209      if (workspaceDirs.includes(entryPath)) {
210        ignoreFiles = [
211          defaultRules,
212          'package.json',
213          '.npmignore',
214          '.gitignore',
215          strictRules,
216        ]
217      }
218    } else {
219      ignoreFiles = [
220        defaultRules,
221        '.npmignore',
222        '.gitignore',
223        strictRules,
224      ]
225    }
226
227    return {
228      ...super.walkerOpt(entry, opts),
229      ignoreFiles,
230      // we map over our own requiredFiles and pass ones that are within this entry
231      requiredFiles: this.requiredFiles
232        .map((file) => {
233          if (relative(file, entry) === '..') {
234            return relative(entry, file).replace(/\\/g, '/')
235          }
236          return false
237        })
238        .filter(Boolean),
239    }
240  }
241
242  // overridden method: we want child walkers to be instances of this class, not ignore-walk
243  walker (entry, opts, callback) {
244    new PackWalker(this.tree, this.walkerOpt(entry, opts)).on('done', callback).start()
245  }
246
247  // overridden method: we use a custom sort method to help compressibility
248  sort (a, b) {
249    // optimize for compressibility
250    // extname, then basename, then locale alphabetically
251    // https://twitter.com/isntitvacant/status/1131094910923231232
252    const exta = extname(a).toLowerCase()
253    const extb = extname(b).toLowerCase()
254    const basea = basename(a).toLowerCase()
255    const baseb = basename(b).toLowerCase()
256
257    return exta.localeCompare(extb, 'en') ||
258      basea.localeCompare(baseb, 'en') ||
259      a.localeCompare(b, 'en')
260  }
261
262  // convenience method: this joins the given rules with newlines, appends a trailing newline,
263  // and calls the internal onReadIgnoreFile method
264  injectRules (filename, rules, callback = () => {}) {
265    this.onReadIgnoreFile(filename, `${rules.join('\n')}\n`, callback)
266  }
267
268  // custom method: this is called by addIgnoreFile when we find a package.json, it uses the
269  // arborist tree to pull both default rules and strict rules for the package
270  processPackage (callback) {
271    const {
272      bin,
273      browser,
274      files,
275      main,
276    } = this.tree.package
277
278    // rules in these arrays are inverted since they are patterns we want to _not_ ignore
279    const ignores = []
280    const strict = [
281      ...strictDefaults,
282      '!/package.json',
283      '!/readme{,.*[^~$]}',
284      '!/copying{,.*[^~$]}',
285      '!/license{,.*[^~$]}',
286      '!/licence{,.*[^~$]}',
287      '/.git',
288      '/node_modules',
289      '.npmrc',
290      '/package-lock.json',
291      '/yarn.lock',
292      '/pnpm-lock.yaml',
293    ]
294
295    // if we have a files array in our package, we need to pull rules from it
296    if (files) {
297      for (let file of files) {
298        // invert the rule because these are things we want to include
299        if (file.startsWith('./')) {
300          file = file.slice(1)
301        }
302        if (file.endsWith('/*')) {
303          file += '*'
304        }
305        const inverse = `!${file}`
306        try {
307          // if an entry in the files array is a specific file, then we need to include it as a
308          // strict requirement for this package. if it's a directory or a pattern, it's a default
309          // pattern instead. this is ugly, but we have to stat to find out if it's a file
310          const stat = lstat(join(this.path, file.replace(/^!+/, '')).replace(/\\/g, '/'))
311          // if we have a file and we know that, it's strictly required
312          if (stat.isFile()) {
313            strict.unshift(inverse)
314            this.requiredFiles.push(file.startsWith('/') ? file.slice(1) : file)
315          } else if (stat.isDirectory()) {
316            // otherwise, it's a default ignore, and since we got here we know it's not a pattern
317            // so we include the directory contents
318            ignores.push(inverse)
319            ignores.push(`${inverse}/**`)
320          }
321          // if the thing exists, but is neither a file or a directory, we don't want it at all
322        } catch (err) {
323          // if lstat throws, then we assume we're looking at a pattern and treat it as a default
324          ignores.push(inverse)
325        }
326      }
327
328      // we prepend a '*' to exclude everything, followed by our inverted file rules
329      // which now mean to include those
330      this.injectRules('package.json', ['*', ...ignores])
331    }
332
333    // browser is required
334    if (browser) {
335      strict.push(`!/${browser}`)
336    }
337
338    // main is required
339    if (main) {
340      strict.push(`!/${main}`)
341    }
342
343    // each bin is required
344    if (bin) {
345      for (const key in bin) {
346        strict.push(`!/${bin[key]}`)
347      }
348    }
349
350    // and now we add all of the strict rules to our synthetic file
351    this.injectRules(strictRules, strict, callback)
352  }
353
354  // custom method: after we've finished gathering the files for the root package, we call this
355  // before emitting the 'done' event in order to gather all of the files for bundled deps
356  async gatherBundles () {
357    if (this.seen.has(this.tree)) {
358      return
359    }
360
361    // add this node to our seen tracker
362    this.seen.add(this.tree)
363
364    // if we're the project root, then we look at our bundleDependencies, otherwise we got here
365    // because we're a bundled dependency of the root, which means we need to include all prod
366    // and optional dependencies in the bundle
367    let toBundle
368    if (this.tree.isProjectRoot) {
369      const { bundleDependencies } = this.tree.package
370      toBundle = bundleDependencies || []
371    } else {
372      const { dependencies, optionalDependencies } = this.tree.package
373      toBundle = Object.keys(dependencies || {}).concat(Object.keys(optionalDependencies || {}))
374    }
375
376    for (const dep of toBundle) {
377      const edge = this.tree.edgesOut.get(dep)
378      // no edgeOut = missing node, so skip it. we can't pack it if it's not here
379      // we also refuse to pack peer dependencies and dev dependencies
380      if (!edge || edge.peer || edge.dev) {
381        continue
382      }
383
384      // get a reference to the node we're bundling
385      const node = this.tree.edgesOut.get(dep).to
386      // if there's no node, this is most likely an optional dependency that hasn't been
387      // installed. just skip it.
388      if (!node) {
389        continue
390      }
391      // we use node.path for the path because we want the location the node was linked to,
392      // not where it actually lives on disk
393      const path = node.path
394      // but link nodes don't have edgesOut, so we need to pass in the target of the node
395      // in order to make sure we correctly traverse its dependencies
396      const tree = node.target
397
398      // and start building options to be passed to the walker for this package
399      const walkerOpts = {
400        path,
401        isPackage: true,
402        ignoreFiles: [],
403        seen: this.seen, // pass through seen so we can prevent infinite circular loops
404      }
405
406      // if our node is a link, we apply defaultRules. we don't do this for regular bundled
407      // deps because their .npmignore and .gitignore files are excluded by default and may
408      // override defaults
409      if (node.isLink) {
410        walkerOpts.ignoreFiles.push(defaultRules)
411      }
412
413      // _all_ nodes will follow package.json rules from their package root
414      walkerOpts.ignoreFiles.push('package.json')
415
416      // only link nodes will obey .npmignore or .gitignore
417      if (node.isLink) {
418        walkerOpts.ignoreFiles.push('.npmignore')
419        walkerOpts.ignoreFiles.push('.gitignore')
420      }
421
422      // _all_ nodes follow strict rules
423      walkerOpts.ignoreFiles.push(strictRules)
424
425      // create a walker for this dependency and gather its results
426      const walker = new PackWalker(tree, walkerOpts)
427      const bundled = await new Promise((pResolve, pReject) => {
428        walker.on('error', pReject)
429        walker.on('done', pResolve)
430        walker.start()
431      })
432
433      // now we make sure we have our paths correct from the root, and accumulate everything into
434      // our own result set to deduplicate
435      const relativeFrom = relative(this.root, walker.path)
436      for (const file of bundled) {
437        this.result.add(join(relativeFrom, file).replace(/\\/g, '/'))
438      }
439    }
440  }
441}
442
443const walk = (tree, options, callback) => {
444  if (typeof options === 'function') {
445    callback = options
446    options = {}
447  }
448  const p = new Promise((pResolve, pReject) => {
449    new PackWalker(tree, { ...options, isPackage: true })
450      .on('done', pResolve).on('error', pReject).start()
451  })
452  return callback ? p.then(res => callback(null, res), callback) : p
453}
454
455module.exports = walk
456walk.Walker = PackWalker
457