Team Ai
Datasetpublic

codekingpro/portable-devtools

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