• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const semver = require('semver')
2const fs = require('fs/promises')
3const { glob } = require('glob')
4const legacyFixer = require('normalize-package-data/lib/fixer.js')
5const legacyMakeWarning = require('normalize-package-data/lib/make_warning.js')
6const path = require('path')
7const log = require('proc-log')
8const git = require('@npmcli/git')
9const hostedGitInfo = require('hosted-git-info')
10
11// used to be npm-normalize-package-bin
12function normalizePackageBin (pkg, changes) {
13  if (pkg.bin) {
14    if (typeof pkg.bin === 'string' && pkg.name) {
15      changes?.push('"bin" was converted to an object')
16      pkg.bin = { [pkg.name]: pkg.bin }
17    } else if (Array.isArray(pkg.bin)) {
18      changes?.push('"bin" was converted to an object')
19      pkg.bin = pkg.bin.reduce((acc, k) => {
20        acc[path.basename(k)] = k
21        return acc
22      }, {})
23    }
24    if (typeof pkg.bin === 'object') {
25      for (const binKey in pkg.bin) {
26        if (typeof pkg.bin[binKey] !== 'string') {
27          delete pkg.bin[binKey]
28          changes?.push(`removed invalid "bin[${binKey}]"`)
29          continue
30        }
31        const base = path.join('/', path.basename(binKey.replace(/\\|:/g, '/'))).slice(1)
32        if (!base) {
33          delete pkg.bin[binKey]
34          changes?.push(`removed invalid "bin[${binKey}]"`)
35          continue
36        }
37
38        const binTarget = path.join('/', pkg.bin[binKey].replace(/\\/g, '/'))
39          .replace(/\\/g, '/').slice(1)
40
41        if (!binTarget) {
42          delete pkg.bin[binKey]
43          changes?.push(`removed invalid "bin[${binKey}]"`)
44          continue
45        }
46
47        if (base !== binKey) {
48          delete pkg.bin[binKey]
49          changes?.push(`"bin[${binKey}]" was renamed to "bin[${base}]"`)
50        }
51        if (binTarget !== pkg.bin[binKey]) {
52          changes?.push(`"bin[${base}]" script name was cleaned`)
53        }
54        pkg.bin[base] = binTarget
55      }
56
57      if (Object.keys(pkg.bin).length === 0) {
58        changes?.push('empty "bin" was removed')
59        delete pkg.bin
60      }
61
62      return pkg
63    }
64  }
65  delete pkg.bin
66}
67
68function isCorrectlyEncodedName (spec) {
69  return !spec.match(/[/@\s+%:]/) &&
70    spec === encodeURIComponent(spec)
71}
72
73function isValidScopedPackageName (spec) {
74  if (spec.charAt(0) !== '@') {
75    return false
76  }
77
78  const rest = spec.slice(1).split('/')
79  if (rest.length !== 2) {
80    return false
81  }
82
83  return rest[0] && rest[1] &&
84    rest[0] === encodeURIComponent(rest[0]) &&
85    rest[1] === encodeURIComponent(rest[1])
86}
87
88// We don't want the `changes` array in here by default because this is a hot
89// path for parsing packuments during install.  So the calling method passes it
90// in if it wants to track changes.
91const normalize = async (pkg, { strict, steps, root, changes, allowLegacyCase }) => {
92  if (!pkg.content) {
93    throw new Error('Can not normalize without content')
94  }
95  const data = pkg.content
96  const scripts = data.scripts || {}
97  const pkgId = `${data.name ?? ''}@${data.version ?? ''}`
98
99  // name and version are load bearing so we have to clean them up first
100  if (steps.includes('fixNameField') || steps.includes('normalizeData')) {
101    if (!data.name && !strict) {
102      changes?.push('Missing "name" field was set to an empty string')
103      data.name = ''
104    } else {
105      if (typeof data.name !== 'string') {
106        throw new Error('name field must be a string.')
107      }
108      if (!strict) {
109        const name = data.name.trim()
110        if (data.name !== name) {
111          changes?.push(`Whitespace was trimmed from "name"`)
112          data.name = name
113        }
114      }
115
116      if (data.name.startsWith('.') ||
117        !(isValidScopedPackageName(data.name) || isCorrectlyEncodedName(data.name)) ||
118        (strict && (!allowLegacyCase) && data.name !== data.name.toLowerCase()) ||
119        data.name.toLowerCase() === 'node_modules' ||
120        data.name.toLowerCase() === 'favicon.ico') {
121        throw new Error('Invalid name: ' + JSON.stringify(data.name))
122      }
123    }
124  }
125
126  if (steps.includes('fixVersionField') || steps.includes('normalizeData')) {
127    // allow "loose" semver 1.0 versions in non-strict mode
128    // enforce strict semver 2.0 compliance in strict mode
129    const loose = !strict
130    if (!data.version) {
131      data.version = ''
132    } else {
133      if (!semver.valid(data.version, loose)) {
134        throw new Error(`Invalid version: "${data.version}"`)
135      }
136      const version = semver.clean(data.version, loose)
137      if (version !== data.version) {
138        changes?.push(`"version" was cleaned and set to "${version}"`)
139        data.version = version
140      }
141    }
142  }
143  // remove attributes that start with "_"
144  if (steps.includes('_attributes')) {
145    for (const key in data) {
146      if (key.startsWith('_')) {
147        changes?.push(`"${key}" was removed`)
148        delete pkg.content[key]
149      }
150    }
151  }
152
153  // build the "_id" attribute
154  if (steps.includes('_id')) {
155    if (data.name && data.version) {
156      changes?.push(`"_id" was set to ${pkgId}`)
157      data._id = pkgId
158    }
159  }
160
161  // fix bundledDependencies typo
162  // normalize bundleDependencies
163  if (steps.includes('bundledDependencies')) {
164    if (data.bundleDependencies === undefined && data.bundledDependencies !== undefined) {
165      data.bundleDependencies = data.bundledDependencies
166    }
167    changes?.push(`Deleted incorrect "bundledDependencies"`)
168    delete data.bundledDependencies
169  }
170  // expand "bundleDependencies: true or translate from object"
171  if (steps.includes('bundleDependencies')) {
172    const bd = data.bundleDependencies
173    if (bd === false && !steps.includes('bundleDependenciesDeleteFalse')) {
174      changes?.push(`"bundleDependencies" was changed from "false" to "[]"`)
175      data.bundleDependencies = []
176    } else if (bd === true) {
177      changes?.push(`"bundleDependencies" was auto-populated from "dependencies"`)
178      data.bundleDependencies = Object.keys(data.dependencies || {})
179    } else if (bd && typeof bd === 'object') {
180      if (!Array.isArray(bd)) {
181        changes?.push(`"bundleDependencies" was changed from an object to an array`)
182        data.bundleDependencies = Object.keys(bd)
183      }
184    } else if ('bundleDependencies' in data) {
185      changes?.push(`"bundleDependencies" was removed`)
186      delete data.bundleDependencies
187    }
188  }
189
190  // it was once common practice to list deps both in optionalDependencies and
191  // in dependencies, to support npm versions that did not know about
192  // optionalDependencies.  This is no longer a relevant need, so duplicating
193  // the deps in two places is unnecessary and excessive.
194  if (steps.includes('optionalDedupe')) {
195    if (data.dependencies &&
196      data.optionalDependencies && typeof data.optionalDependencies === 'object') {
197      for (const name in data.optionalDependencies) {
198        changes?.push(`optionalDependencies."${name}" was removed`)
199        delete data.dependencies[name]
200      }
201      if (!Object.keys(data.dependencies).length) {
202        changes?.push(`Empty "optionalDependencies" was removed`)
203        delete data.dependencies
204      }
205    }
206  }
207
208  // add "install" attribute if any "*.gyp" files exist
209  if (steps.includes('gypfile')) {
210    if (!scripts.install && !scripts.preinstall && data.gypfile !== false) {
211      const files = await glob('*.gyp', { cwd: pkg.path })
212      if (files.length) {
213        scripts.install = 'node-gyp rebuild'
214        data.scripts = scripts
215        data.gypfile = true
216        changes?.push(`"scripts.install" was set to "node-gyp rebuild"`)
217        changes?.push(`"gypfile" was set to "true"`)
218      }
219    }
220  }
221
222  // add "start" attribute if "server.js" exists
223  if (steps.includes('serverjs') && !scripts.start) {
224    try {
225      await fs.access(path.join(pkg.path, 'server.js'))
226      scripts.start = 'node server.js'
227      data.scripts = scripts
228      changes?.push('"scripts.start" was set to "node server.js"')
229    } catch {
230      // do nothing
231    }
232  }
233
234  // strip "node_modules/.bin" from scripts entries
235  // remove invalid scripts entries (non-strings)
236  if (steps.includes('scripts') || steps.includes('scriptpath')) {
237    const spre = /^(\.[/\\])?node_modules[/\\].bin[\\/]/
238    if (typeof data.scripts === 'object') {
239      for (const name in data.scripts) {
240        if (typeof data.scripts[name] !== 'string') {
241          delete data.scripts[name]
242          changes?.push(`Invalid scripts."${name}" was removed`)
243        } else if (steps.includes('scriptpath') && spre.test(data.scripts[name])) {
244          data.scripts[name] = data.scripts[name].replace(spre, '')
245          changes?.push(`scripts entry "${name}" was fixed to remove node_modules/.bin reference`)
246        }
247      }
248    } else {
249      changes?.push(`Removed invalid "scripts"`)
250      delete data.scripts
251    }
252  }
253
254  if (steps.includes('funding')) {
255    if (data.funding && typeof data.funding === 'string') {
256      data.funding = { url: data.funding }
257      changes?.push(`"funding" was changed to an object with a url attribute`)
258    }
259  }
260
261  // populate "authors" attribute
262  if (steps.includes('authors') && !data.contributors) {
263    try {
264      const authorData = await fs.readFile(path.join(pkg.path, 'AUTHORS'), 'utf8')
265      const authors = authorData.split(/\r?\n/g)
266        .map(line => line.replace(/^\s*#.*$/, '').trim())
267        .filter(line => line)
268      data.contributors = authors
269      changes?.push('"contributors" was auto-populated with the contents of the "AUTHORS" file')
270    } catch {
271      // do nothing
272    }
273  }
274
275  // populate "readme" attribute
276  if (steps.includes('readme') && !data.readme) {
277    const mdre = /\.m?a?r?k?d?o?w?n?$/i
278    const files = await glob('{README,README.*}', { cwd: pkg.path, nocase: true, mark: true })
279    let readmeFile
280    for (const file of files) {
281      // don't accept directories.
282      if (!file.endsWith(path.sep)) {
283        if (file.match(mdre)) {
284          readmeFile = file
285          break
286        }
287        if (file.endsWith('README')) {
288          readmeFile = file
289        }
290      }
291    }
292    if (readmeFile) {
293      const readmeData = await fs.readFile(path.join(pkg.path, readmeFile), 'utf8')
294      data.readme = readmeData
295      data.readmeFilename = readmeFile
296      changes?.push(`"readme" was set to the contents of ${readmeFile}`)
297      changes?.push(`"readmeFilename" was set to ${readmeFile}`)
298    }
299    if (!data.readme) {
300      // this.warn('missingReadme')
301      data.readme = 'ERROR: No README data found!'
302    }
303  }
304
305  // expand directories.man
306  if (steps.includes('mans') && !data.man && data.directories?.man) {
307    const manDir = data.directories.man
308    const cwd = path.resolve(pkg.path, manDir)
309    const files = await glob('**/*.[0-9]', { cwd })
310    data.man = files.map(man =>
311      path.relative(pkg.path, path.join(cwd, man)).split(path.sep).join('/')
312    )
313  }
314
315  if (steps.includes('bin') || steps.includes('binDir') || steps.includes('binRefs')) {
316    normalizePackageBin(data, changes)
317  }
318
319  // expand "directories.bin"
320  if (steps.includes('binDir') && data.directories?.bin && !data.bin) {
321    const binsDir = path.resolve(pkg.path, path.join('.', path.join('/', data.directories.bin)))
322    const bins = await glob('**', { cwd: binsDir })
323    data.bin = bins.reduce((acc, binFile) => {
324      if (binFile && !binFile.startsWith('.')) {
325        const binName = path.basename(binFile)
326        acc[binName] = path.join(data.directories.bin, binFile)
327      }
328      return acc
329    }, {})
330    // *sigh*
331    normalizePackageBin(data, changes)
332  }
333
334  // populate "gitHead" attribute
335  if (steps.includes('gitHead') && !data.gitHead) {
336    const gitRoot = await git.find({ cwd: pkg.path, root })
337    let head
338    if (gitRoot) {
339      try {
340        head = await fs.readFile(path.resolve(gitRoot, '.git/HEAD'), 'utf8')
341      } catch (err) {
342      // do nothing
343      }
344    }
345    let headData
346    if (head) {
347      if (head.startsWith('ref: ')) {
348        const headRef = head.replace(/^ref: /, '').trim()
349        const headFile = path.resolve(gitRoot, '.git', headRef)
350        try {
351          headData = await fs.readFile(headFile, 'utf8')
352          headData = headData.replace(/^ref: /, '').trim()
353        } catch (err) {
354          // do nothing
355        }
356        if (!headData) {
357          const packFile = path.resolve(gitRoot, '.git/packed-refs')
358          try {
359            let refs = await fs.readFile(packFile, 'utf8')
360            if (refs) {
361              refs = refs.split('\n')
362              for (let i = 0; i < refs.length; i++) {
363                const match = refs[i].match(/^([0-9a-f]{40}) (.+)$/)
364                if (match && match[2].trim() === headRef) {
365                  headData = match[1]
366                  break
367                }
368              }
369            }
370          } catch {
371            // do nothing
372          }
373        }
374      } else {
375        headData = head.trim()
376      }
377    }
378    if (headData) {
379      data.gitHead = headData
380    }
381  }
382
383  // populate "types" attribute
384  if (steps.includes('fillTypes')) {
385    const index = data.main || 'index.js'
386
387    if (typeof index !== 'string') {
388      throw new TypeError('The "main" attribute must be of type string.')
389    }
390
391    // TODO exports is much more complicated than this in verbose format
392    // We need to support for instance
393
394    // "exports": {
395    //   ".": [
396    //     {
397    //       "default": "./lib/npm.js"
398    //     },
399    //     "./lib/npm.js"
400    //   ],
401    //   "./package.json": "./package.json"
402    // },
403    // as well as conditional exports
404
405    // if (data.exports && typeof data.exports === 'string') {
406    //   index = data.exports
407    // }
408
409    // if (data.exports && data.exports['.']) {
410    //   index = data.exports['.']
411    //   if (typeof index !== 'string') {
412    //   }
413    // }
414    const extless = path.join(path.dirname(index), path.basename(index, path.extname(index)))
415    const dts = `./${extless}.d.ts`
416    const hasDTSFields = 'types' in data || 'typings' in data
417    if (!hasDTSFields) {
418      try {
419        await fs.access(path.join(pkg.path, dts))
420        data.types = dts.split(path.sep).join('/')
421      } catch {
422        // do nothing
423      }
424    }
425  }
426
427  // "normalizeData" from "read-package-json", which was just a call through to
428  // "normalize-package-data".  We only call the "fixer" functions because
429  // outside of that it was also clobbering _id (which we already conditionally
430  // do) and also adding the gypfile script (which we also already
431  // conditionally do)
432
433  // Some steps are isolated so we can do a limited subset of these in `fix`
434  if (steps.includes('fixRepositoryField') || steps.includes('normalizeData')) {
435    if (data.repositories) {
436      /* eslint-disable-next-line max-len */
437      changes?.push(`"repository" was set to the first entry in "repositories" (${data.repository})`)
438      data.repository = data.repositories[0]
439    }
440    if (data.repository) {
441      if (typeof data.repository === 'string') {
442        changes?.push('"repository" was changed from a string to an object')
443        data.repository = {
444          type: 'git',
445          url: data.repository,
446        }
447      }
448      if (data.repository.url) {
449        const hosted = hostedGitInfo.fromUrl(data.repository.url)
450        let r
451        if (hosted) {
452          if (hosted.getDefaultRepresentation() === 'shortcut') {
453            r = hosted.https()
454          } else {
455            r = hosted.toString()
456          }
457          if (r !== data.repository.url) {
458            changes?.push(`"repository.url" was normalized to "${r}"`)
459            data.repository.url = r
460          }
461        }
462      }
463    }
464  }
465
466  if (steps.includes('fixDependencies') || steps.includes('normalizeData')) {
467    // peerDependencies?
468    // devDependencies is meaningless here, it's ignored on an installed package
469    for (const type of ['dependencies', 'devDependencies', 'optionalDependencies']) {
470      if (data[type]) {
471        let secondWarning = true
472        if (typeof data[type] === 'string') {
473          changes?.push(`"${type}" was converted from a string into an object`)
474          data[type] = data[type].trim().split(/[\n\r\s\t ,]+/)
475          secondWarning = false
476        }
477        if (Array.isArray(data[type])) {
478          if (secondWarning) {
479            changes?.push(`"${type}" was converted from an array into an object`)
480          }
481          const o = {}
482          for (const d of data[type]) {
483            if (typeof d === 'string') {
484              const dep = d.trim().split(/(:?[@\s><=])/)
485              const dn = dep.shift()
486              const dv = dep.join('').replace(/^@/, '').trim()
487              o[dn] = dv
488            }
489          }
490          data[type] = o
491        }
492      }
493    }
494    // normalize-package-data used to put optional dependencies BACK into
495    // dependencies here, we no longer do this
496
497    for (const deps of ['dependencies', 'devDependencies']) {
498      if (deps in data) {
499        if (!data[deps] || typeof data[deps] !== 'object') {
500          changes?.push(`Removed invalid "${deps}"`)
501          delete data[deps]
502        } else {
503          for (const d in data[deps]) {
504            const r = data[deps][d]
505            if (typeof r !== 'string') {
506              changes?.push(`Removed invalid "${deps}.${d}"`)
507              delete data[deps][d]
508            }
509            const hosted = hostedGitInfo.fromUrl(data[deps][d])?.toString()
510            if (hosted && hosted !== data[deps][d]) {
511              changes?.push(`Normalized git reference to "${deps}.${d}"`)
512              data[deps][d] = hosted.toString()
513            }
514          }
515        }
516      }
517    }
518  }
519
520  if (steps.includes('normalizeData')) {
521    legacyFixer.warn = function () {
522      changes?.push(legacyMakeWarning.apply(null, arguments))
523    }
524
525    const legacySteps = [
526      'fixDescriptionField',
527      'fixModulesField',
528      'fixFilesField',
529      'fixManField',
530      'fixBugsField',
531      'fixKeywordsField',
532      'fixBundleDependenciesField',
533      'fixHomepageField',
534      'fixReadmeField',
535      'fixLicenseField',
536      'fixPeople',
537      'fixTypos',
538    ]
539    for (const legacyStep of legacySteps) {
540      legacyFixer[legacyStep](data)
541    }
542  }
543
544  // Warn if the bin references don't point to anything.  This might be better
545  // in normalize-package-data if it had access to the file path.
546  if (steps.includes('binRefs') && data.bin instanceof Object) {
547    for (const key in data.bin) {
548      try {
549        await fs.access(path.resolve(pkg.path, data.bin[key]))
550      } catch {
551        log.warn('package-json', pkgId, `No bin file found at ${data.bin[key]}`)
552        // XXX: should a future breaking change delete bin entries that cannot be accessed?
553      }
554    }
555  }
556}
557
558module.exports = normalize
559