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