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