ReactFlow前端拖拽模型与参数配置功能MVP
This commit is contained in:
1 parent
70c91ed019
commit
d3b99304fb
2001 files changed
+349044
-548
No files matched your search
+245
@@ -0,0 +1,245 @@
|
||||
const { log, output } = require('proc-log')
|
||||
const pkgJson = require('@npmcli/package-json')
|
||||
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
|
||||
const checkAllowScripts = require('./check-allow-scripts.js')
|
||||
const resolveAllowScripts = require('./resolve-allow-scripts.js')
|
||||
const {
|
||||
applyApprovalForPackage,
|
||||
applyDenyForPackage,
|
||||
nameKeyFor,
|
||||
} = require('./allow-scripts-writer.js')
|
||||
const BaseCommand = require('../base-cmd.js')
|
||||
|
||||
// Shared implementation for `npm approve-scripts` and `npm deny-scripts`.
|
||||
// Subclasses set `verb` to `'approve'` or `'deny'`.
|
||||
//
|
||||
// Extends `BaseCommand` rather than `ArboristCmd` on purpose. Per RFC,
|
||||
// `allowScripts` is read from the workspace root's `package.json` only;
|
||||
// individual workspaces don't have their own `allowScripts` field, and
|
||||
// running approve/deny inside a sub-workspace is identical to running
|
||||
// it at the root. There's no per-workspace targeting to do, so the
|
||||
// `--workspace` / `--workspaces` / `--include-workspace-root` params
|
||||
// from `ArboristCmd` would be misleading no-ops.
|
||||
class AllowScriptsCmd extends BaseCommand {
|
||||
static params = ['all', 'allow-scripts-pending', 'allow-scripts-pin', 'json']
|
||||
static ignoreImplicitWorkspace = false
|
||||
|
||||
// Subclasses set `static verb = 'approve' | 'deny'`.
|
||||
get verb () {
|
||||
/* istanbul ignore next: every concrete subclass declares static verb */
|
||||
return this.constructor.verb
|
||||
}
|
||||
|
||||
async exec (args) {
|
||||
if (this.npm.global) {
|
||||
throw Object.assign(
|
||||
new Error(`\`npm ${this.constructor.name}\` does not work for global installs`),
|
||||
{ code: 'EGLOBAL' }
|
||||
)
|
||||
}
|
||||
|
||||
const pending = !!this.npm.config.get('allow-scripts-pending')
|
||||
const all = !!this.npm.config.get('all')
|
||||
|
||||
if (pending && (args.length > 0 || all)) {
|
||||
throw this.usageError(
|
||||
'`--allow-scripts-pending` cannot be combined with positional arguments or `--all`.'
|
||||
)
|
||||
}
|
||||
if (!pending && !all && args.length === 0) {
|
||||
throw this.usageError()
|
||||
}
|
||||
if (this.verb === 'deny' && pending) {
|
||||
throw this.usageError('`npm deny-scripts --allow-scripts-pending` is not supported.')
|
||||
}
|
||||
|
||||
const Arborist = require('@npmcli/arborist')
|
||||
const { policy } = await resolveAllowScripts(this.npm)
|
||||
const arb = new Arborist({
|
||||
...this.npm.flatOptions,
|
||||
path: this.npm.prefix,
|
||||
allowScripts: policy,
|
||||
})
|
||||
await arb.loadActual()
|
||||
|
||||
const unreviewed = await checkAllowScripts({ arb, npm: this.npm })
|
||||
|
||||
if (pending) {
|
||||
return this.runPending(unreviewed)
|
||||
}
|
||||
|
||||
if (all) {
|
||||
return this.runAll(unreviewed)
|
||||
}
|
||||
|
||||
return this.runPositional(args, arb)
|
||||
}
|
||||
|
||||
runPending (unreviewed) {
|
||||
if (unreviewed.length === 0) {
|
||||
output.standard('No packages with unreviewed install scripts.')
|
||||
return
|
||||
}
|
||||
const count = unreviewed.length
|
||||
const has = count === 1 ? 'has' : 'have'
|
||||
const pkg = count === 1 ? 'package' : 'packages'
|
||||
output.standard(
|
||||
`${count} ${pkg} ${has} install scripts not yet covered by allowScripts:`
|
||||
)
|
||||
for (const { node, scripts } of unreviewed) {
|
||||
const { name, version } = trustedDisplay(node)
|
||||
/* istanbul ignore next: every test node has a name */
|
||||
const display = name || '<unknown>'
|
||||
const ver = version ? `@${version}` : ''
|
||||
const events = Object.entries(scripts)
|
||||
.map(([event, cmd]) => `${event}: ${cmd}`)
|
||||
.join('; ')
|
||||
output.standard(` ${display}${ver} (${events})`)
|
||||
}
|
||||
output.standard('')
|
||||
output.standard(
|
||||
'Run `npm approve-scripts <pkg>` to allow, or `npm deny-scripts <pkg>` to deny.'
|
||||
)
|
||||
}
|
||||
|
||||
async runAll (unreviewed) {
|
||||
if (unreviewed.length === 0) {
|
||||
output.standard('No packages with unreviewed install scripts.')
|
||||
return
|
||||
}
|
||||
// Bundled dependencies cannot be allowlisted in Phase 1 (RFC defers
|
||||
// this to a follow-up because matching by name@version from the
|
||||
// bundled tarball would reintroduce manifest confusion). Exclude
|
||||
// them from `--all` so we don't silently write a policy entry under
|
||||
// attacker-controlled identity.
|
||||
const candidates = unreviewed.filter(({ node }) => !node.inBundle)
|
||||
const skipped = unreviewed.length - candidates.length
|
||||
if (skipped > 0) {
|
||||
/* istanbul ignore next: plural variant covered separately */
|
||||
const noun = skipped === 1 ? 'dependency' : 'dependencies'
|
||||
log.warn(
|
||||
this.logTitle,
|
||||
`Skipping ${skipped} bundled ${noun}; bundled deps with install ` +
|
||||
'scripts cannot be allowlisted in this release.'
|
||||
)
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
output.standard('No packages eligible for approval.')
|
||||
return
|
||||
}
|
||||
const groups = this.groupByPackage(candidates.map(({ node }) => node))
|
||||
await this.writePolicyChanges(groups)
|
||||
}
|
||||
|
||||
async runPositional (args, arb) {
|
||||
const matched = this.findNodesForArgs(args, arb)
|
||||
const groups = this.groupByPackage(matched)
|
||||
if (Object.keys(groups).length === 0) {
|
||||
throw Object.assign(
|
||||
new Error(`No installed packages match: ${args.join(', ')}`),
|
||||
{ code: 'ENOMATCH' }
|
||||
)
|
||||
}
|
||||
await this.writePolicyChanges(groups)
|
||||
}
|
||||
|
||||
findNodesForArgs (args, arb) {
|
||||
// Match positional args against each node's trusted name. Registry
|
||||
// deps use the URL-derived name; non-registry deps fall back to the
|
||||
// dependency edge name. Bundled deps are excluded for the same reason
|
||||
// as --all.
|
||||
const wanted = new Set(args)
|
||||
const matched = []
|
||||
for (const node of arb.actualTree.inventory.values()) {
|
||||
if (node.isProjectRoot || node.isWorkspace || node.inBundle) {
|
||||
continue
|
||||
}
|
||||
const { name } = trustedDisplay(node)
|
||||
if (name && wanted.has(name)) {
|
||||
matched.push(node)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
get logTitle () {
|
||||
return this.constructor.name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
groupByPackage (nodes) {
|
||||
const groups = {}
|
||||
for (const node of nodes) {
|
||||
const key = nameKeyFor(node)
|
||||
/* istanbul ignore if: callers prefilter via inBundle and trustedDisplay so untrusted nodes don't reach here */
|
||||
if (!key) {
|
||||
log.warn(
|
||||
this.logTitle,
|
||||
`skipping ${node.name || '<unknown>'}: no trusted identity for policy key`
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!groups[key]) {
|
||||
groups[key] = []
|
||||
}
|
||||
groups[key].push(node)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
async writePolicyChanges (groups) {
|
||||
const pin = this.npm.config.get('allow-scripts-pin') !== false
|
||||
|
||||
const pkg = await pkgJson.load(this.npm.prefix)
|
||||
const content = pkg.content
|
||||
const existing = content.allowScripts && typeof content.allowScripts === 'object'
|
||||
? content.allowScripts
|
||||
: {}
|
||||
|
||||
let updated = existing
|
||||
const summary = []
|
||||
|
||||
for (const [name, nodes] of Object.entries(groups)) {
|
||||
const result = this.verb === 'approve'
|
||||
? applyApprovalForPackage(updated, nodes, { pin })
|
||||
: applyDenyForPackage(updated, nodes)
|
||||
|
||||
if (result.warning) {
|
||||
log.warn(this.logTitle, result.warning)
|
||||
}
|
||||
updated = result.allowScripts
|
||||
summary.push({ name, changes: result.changes })
|
||||
}
|
||||
|
||||
/* istanbul ignore else: writePolicyChanges only called when changes are expected */
|
||||
if (updated !== existing) {
|
||||
pkg.update({ allowScripts: updated })
|
||||
await pkg.save()
|
||||
}
|
||||
|
||||
this.printSummary(summary)
|
||||
}
|
||||
|
||||
printSummary (summary) {
|
||||
if (this.npm.flatOptions.json) {
|
||||
output.buffer({ allowScripts: summary })
|
||||
return
|
||||
}
|
||||
const verb = this.verb === 'approve' ? 'Approved' : 'Denied'
|
||||
let touched = 0
|
||||
for (const { name, changes } of summary) {
|
||||
if (changes.length === 0) {
|
||||
continue
|
||||
}
|
||||
touched++
|
||||
output.standard(`${verb} ${name}:`)
|
||||
for (const { key, change } of changes) {
|
||||
output.standard(` ${change} ${key}`)
|
||||
}
|
||||
}
|
||||
if (touched === 0) {
|
||||
output.standard(`Nothing to ${this.verb}; allowScripts unchanged.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AllowScriptsCmd
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
const npa = require('npm-package-arg')
|
||||
const { log } = require('proc-log')
|
||||
const { getTrustedRegistryIdentity } = require('@npmcli/arborist/lib/script-allowed.js')
|
||||
|
||||
// Pure helpers that implement the RFC's pin-mismatch table for
|
||||
// `npm approve-scripts` and `npm deny-scripts`.
|
||||
//
|
||||
// Approving writes either `"<spec>": true` or `"<name>": true` to the
|
||||
// project's `allowScripts` field, depending on `--allow-scripts-pin` and the currently
|
||||
// installed versions.
|
||||
//
|
||||
// Denying always writes `"<name>": false`, regardless of `--allow-scripts-pin`, per the
|
||||
// RFC's asymmetric-pin rule.
|
||||
|
||||
// Convert an arborist Node into the spec string used for a versioned policy
|
||||
// entry. Returns `null` if the node cannot be represented as a versioned key
|
||||
// derived from trusted sources (lockfile URL for registry, hosted shortcut
|
||||
// for git, the resolved file path for local installs). Never falls back to
|
||||
// `node.packageName` / `node.version`, which are tarball-controlled.
|
||||
const versionedKeyFor = (node) => {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
/* istanbul ignore next: callers guarantee a string resolved */
|
||||
const resolved = typeof node.resolved === 'string' ? node.resolved : ''
|
||||
if (resolved.startsWith('git')) {
|
||||
try {
|
||||
const parsed = npa(resolved)
|
||||
if (parsed.hosted) {
|
||||
const committish = parsed.gitCommittish || parsed.hosted.committish
|
||||
const base = parsed.hosted.shortcut({ noCommittish: true })
|
||||
return committish ? `${base}#${committish}` : base
|
||||
}
|
||||
} catch {
|
||||
/* istanbul ignore next: npa already parsed this string in keyTargetsNode */
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (/^https?:\/\//.test(resolved)) {
|
||||
const trusted = getTrustedRegistryIdentity(node)
|
||||
if (trusted && trusted.version) {
|
||||
return `${trusted.name}@${trusted.version}`
|
||||
}
|
||||
// Registry node with a resolved URL that versionFromTgz couldn't
|
||||
// parse (private-registry mirror, alternate CDN URL shape). Leave a
|
||||
// breadcrumb so users notice when policy keys are silently pruned.
|
||||
log.silly(
|
||||
'allow-scripts',
|
||||
`unable to derive trusted versioned key for ${node.path || node.name || '<unknown>'} ` +
|
||||
`(resolved: ${resolved}); key will be pruned on next save`
|
||||
)
|
||||
return null
|
||||
}
|
||||
/* istanbul ignore next: 'file:' and '/' branches are each covered separately */
|
||||
if (resolved.startsWith('file:') || resolved.startsWith('/')) {
|
||||
return resolved
|
||||
}
|
||||
// No trusted source. Refuse to compose a key from attacker-controlled
|
||||
// `node.packageName` / `node.version`.
|
||||
/* istanbul ignore next: callers filter out non-registry/non-file nodes before reaching this fallback */
|
||||
return null
|
||||
}
|
||||
|
||||
// Convert an arborist Node into the spec string used for a name-only policy
|
||||
// entry. Same trust rules as versionedKeyFor — returns `null` rather than
|
||||
// falling back to tarball-controlled fields.
|
||||
const nameKeyFor = (node) => {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
/* istanbul ignore next: callers guarantee a string resolved */
|
||||
const resolved = typeof node.resolved === 'string' ? node.resolved : ''
|
||||
if (resolved.startsWith('git')) {
|
||||
try {
|
||||
const parsed = npa(resolved)
|
||||
if (parsed.hosted) {
|
||||
return parsed.hosted.shortcut({ noCommittish: true })
|
||||
}
|
||||
} catch {
|
||||
/* istanbul ignore next: npa already parsed this string in keyTargetsNode */
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (resolved.startsWith('file:') || resolved.startsWith('/')) {
|
||||
return resolved
|
||||
}
|
||||
// Registry deps: only the URL-derived (or edges-derived, in the
|
||||
// omit-lockfile case) trusted name is acceptable.
|
||||
const trusted = getTrustedRegistryIdentity(node)
|
||||
return trusted ? trusted.name : null
|
||||
}
|
||||
|
||||
const isSingleVersionPin = (key) => {
|
||||
try {
|
||||
const parsed = npa(key)
|
||||
return parsed.type === 'version'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Build the warning string emitted when an existing deny entry blocks
|
||||
// an approval. Per RFC, a name-only deny ("pkg": false) is widest and
|
||||
// the only remediation is to remove the entry. A versioned deny
|
||||
// ("pkg@1.2.3": false or a disjunction) blocks only specific versions;
|
||||
// the user can either widen it via `npm deny-scripts <name>` or remove
|
||||
// it to approve the currently-installed version only.
|
||||
const denyWarning = (key, subject, name) => {
|
||||
if (isNameOnlyKey(key)) {
|
||||
return `${key} is denied; remove the entry from allowScripts to approve ${subject}.`
|
||||
}
|
||||
/* istanbul ignore next: name fallback is defensive; callers pass nameKeyFor(sample) */
|
||||
const widenTarget = name || 'this package'
|
||||
return `${key} is a versioned deny; run \`npm deny-scripts ${widenTarget}\` ` +
|
||||
`to widen the deny to all versions of ${widenTarget}, or remove the entry ` +
|
||||
`to approve ${subject}.`
|
||||
}
|
||||
|
||||
const isNameOnlyKey = (key) => {
|
||||
try {
|
||||
const parsed = npa(key)
|
||||
if (parsed.type === 'tag') {
|
||||
return true
|
||||
}
|
||||
if (parsed.type === 'range') {
|
||||
return parsed.fetchSpec === '*'
|
||||
|| parsed.rawSpec === ''
|
||||
|| parsed.rawSpec === '*'
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
/* istanbul ignore next: keys reaching this helper have already parsed via keyTargetsNode */
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Does this policy key target this node by identity (ignoring the
|
||||
// allow/deny value)?
|
||||
//
|
||||
// Registry keys (`tag`, `range`, `version`) require a trusted identity on
|
||||
// the node. If the node has no `getTrustedRegistryIdentity` result, the
|
||||
// key does not match — never fall back to `node.name`, which is the
|
||||
// install-directory name and is forgeable through aliases / manifest
|
||||
// confusion.
|
||||
const keyTargetsNode = (key, node) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = npa(key)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
switch (parsed.type) {
|
||||
case 'tag':
|
||||
case 'range':
|
||||
case 'version': {
|
||||
const trusted = getTrustedRegistryIdentity(node)
|
||||
if (!trusted) {
|
||||
return false
|
||||
}
|
||||
return trusted.name === parsed.name
|
||||
}
|
||||
case 'git': {
|
||||
let resolvedParsed
|
||||
try {
|
||||
resolvedParsed = node.resolved ? npa(node.resolved) : null
|
||||
} catch {
|
||||
/* istanbul ignore next */
|
||||
return false
|
||||
}
|
||||
const keyHost = parsed.hosted?.ssh({ noCommittish: true })
|
||||
const nodeHost = resolvedParsed?.hosted?.ssh({ noCommittish: true })
|
||||
return !!(keyHost && nodeHost && keyHost === nodeHost)
|
||||
}
|
||||
case 'file':
|
||||
case 'directory':
|
||||
case 'remote':
|
||||
return node.resolved === parsed.saveSpec || node.resolved === parsed.fetchSpec
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Apply approvals for all currently-installed versions of a single package.
|
||||
//
|
||||
// `nodes` must all share an identity (same package name for registry deps,
|
||||
// or same hosted shortcut for git deps, etc.). The caller is responsible
|
||||
// for grouping nodes correctly.
|
||||
//
|
||||
// Returns `{ allowScripts, changes, warning }` where:
|
||||
// - `allowScripts` is the new object (the input is never mutated)
|
||||
// - `changes` is a list of `{ key, change }` entries describing edits
|
||||
// - `warning` is an optional message to surface to the user
|
||||
const applyApprovalForPackage = (existing, nodes, { pin = true } = {}) => {
|
||||
const allowScripts = { ...existing }
|
||||
const changes = []
|
||||
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
return { allowScripts, changes }
|
||||
}
|
||||
|
||||
const sample = nodes[0]
|
||||
const name = nameKeyFor(sample)
|
||||
|
||||
// Deny-wins: any existing false that targets any installed version aborts.
|
||||
for (const node of nodes) {
|
||||
for (const [key, value] of Object.entries(allowScripts)) {
|
||||
if (value === false && keyTargetsNode(key, node)) {
|
||||
/* istanbul ignore next: name fallback covers the empty-name edge case */
|
||||
const subject = name || 'this package'
|
||||
return {
|
||||
allowScripts,
|
||||
changes,
|
||||
warning: denyWarning(key, subject, name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pin) {
|
||||
// Name-only mode: collapse any single-version pins for this package
|
||||
// into a single name-only entry.
|
||||
for (const key of Object.keys(allowScripts)) {
|
||||
if (
|
||||
keyTargetsNode(key, sample) &&
|
||||
key !== name &&
|
||||
isSingleVersionPin(key) &&
|
||||
allowScripts[key] === true
|
||||
) {
|
||||
delete allowScripts[key]
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore else: name === null is the no-identity path tested separately */
|
||||
if (name && allowScripts[name] !== true) {
|
||||
allowScripts[name] = true
|
||||
changes.push({ key: name, change: 'added' })
|
||||
}
|
||||
return { allowScripts, changes }
|
||||
}
|
||||
|
||||
// Pin mode. For each currently installed version, write a single-version
|
||||
// pin if one is not already in place. Stale single-version pins for this
|
||||
// package are removed. Per the RFC's pin-mismatch table, an existing
|
||||
// name-only entry (`pkg: true`) is replaced by `pkg@x.y.z: true` once
|
||||
// every installed version has a pin.
|
||||
const installedKeys = new Set(nodes.map(versionedKeyFor).filter(Boolean))
|
||||
|
||||
for (const key of Object.keys(allowScripts)) {
|
||||
if (
|
||||
keyTargetsNode(key, sample) &&
|
||||
isSingleVersionPin(key) &&
|
||||
allowScripts[key] === true &&
|
||||
!installedKeys.has(key)
|
||||
) {
|
||||
delete allowScripts[key]
|
||||
changes.push({ key, change: 'removed-stale' })
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of installedKeys) {
|
||||
if (allowScripts[key] !== true) {
|
||||
allowScripts[key] = true
|
||||
changes.push({ key, change: 'added' })
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade: drop the name-only entry once every installed version has a
|
||||
// pin. The operation is convergent: running the command twice produces
|
||||
// the same shape regardless of the starting state.
|
||||
if (
|
||||
installedKeys.size > 0 &&
|
||||
name &&
|
||||
!installedKeys.has(name) &&
|
||||
allowScripts[name] === true
|
||||
) {
|
||||
delete allowScripts[name]
|
||||
changes.push({ key: name, change: 'replaced-by-pin' })
|
||||
}
|
||||
|
||||
return { allowScripts, changes }
|
||||
}
|
||||
|
||||
// Apply a deny for a single package. Always name-only; ignores `--allow-scripts-pin`.
|
||||
const applyDenyForPackage = (existing, nodes) => {
|
||||
const allowScripts = { ...existing }
|
||||
const changes = []
|
||||
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
return { allowScripts, changes }
|
||||
}
|
||||
|
||||
const sample = nodes[0]
|
||||
const name = nameKeyFor(sample)
|
||||
if (!name) {
|
||||
return { allowScripts, changes }
|
||||
}
|
||||
|
||||
// Drop any pinned allow entries for this package: the name-only deny
|
||||
// overrides them anyway, and leaving them in place is confusing.
|
||||
for (const key of Object.keys(allowScripts)) {
|
||||
if (keyTargetsNode(key, sample) && key !== name) {
|
||||
delete allowScripts[key]
|
||||
changes.push({ key, change: 'removed-pinned-allow' })
|
||||
}
|
||||
}
|
||||
|
||||
if (allowScripts[name] !== false) {
|
||||
allowScripts[name] = false
|
||||
changes.push({ key: name, change: 'added' })
|
||||
}
|
||||
return { allowScripts, changes }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyApprovalForPackage,
|
||||
applyDenyForPackage,
|
||||
versionedKeyFor,
|
||||
nameKeyFor,
|
||||
keyTargetsNode,
|
||||
isSingleVersionPin,
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
const { log, output } = require('proc-log')
|
||||
const { redactLog: replaceInfo } = require('@npmcli/redact')
|
||||
|
||||
// Print an error or just nothing if the audit report has an error.
|
||||
// This is called by the audit command, and by the reify-output util prints a JSON version of the error if it's --json.
|
||||
// Returns 'true' if there was an error, false otherwise.
|
||||
|
||||
const auditError = (npm, report) => {
|
||||
if (!report?.error) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (npm.command !== 'audit') {
|
||||
return true
|
||||
}
|
||||
|
||||
const { error } = report
|
||||
|
||||
// ok, we care about it, then
|
||||
log.warn('audit', error.message)
|
||||
const { body: errBody } = error
|
||||
const body = Buffer.isBuffer(errBody) ? errBody.toString() : errBody
|
||||
if (npm.flatOptions.json) {
|
||||
output.buffer({
|
||||
message: error.message,
|
||||
method: error.method,
|
||||
uri: replaceInfo(error.uri),
|
||||
headers: error.headers,
|
||||
statusCode: error.statusCode,
|
||||
body,
|
||||
})
|
||||
} else {
|
||||
output.standard(body)
|
||||
}
|
||||
|
||||
// XXX we should throw a real error here
|
||||
throw 'audit endpoint returned an error'
|
||||
}
|
||||
|
||||
module.exports = auditError
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
const { webAuthOpener, adduserWeb, loginWeb, loginCouch, adduserCouch } = require('npm-profile')
|
||||
const { log } = require('proc-log')
|
||||
const { createOpener } = require('../utils/open-url.js')
|
||||
const read = require('../utils/read-user-info.js')
|
||||
|
||||
const otplease = async (npm, opts, fn) => {
|
||||
try {
|
||||
return await fn(opts)
|
||||
} catch (err) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// web otp
|
||||
if (err.code === 'EOTP' && err.body?.authUrl && err.body?.doneUrl) {
|
||||
const { token: otp } = await webAuthOpener(
|
||||
createOpener(npm, 'Authenticate your account at'),
|
||||
err.body.authUrl,
|
||||
err.body.doneUrl,
|
||||
opts
|
||||
)
|
||||
return await fn({ ...opts, otp })
|
||||
}
|
||||
|
||||
// classic otp
|
||||
if (err.code === 'EOTP' || (err.code === 'E401' && /one-time pass/.test(err.body))) {
|
||||
const otp = await read.otp('This operation requires a one-time password.\nEnter OTP:')
|
||||
return await fn({ ...opts, otp })
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const adduser = async (npm, { creds, ...opts }) => {
|
||||
const authType = npm.config.get('auth-type')
|
||||
let res
|
||||
if (authType === 'web') {
|
||||
try {
|
||||
res = await adduserWeb(createOpener(npm, 'Create your account at'), opts)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENYI') {
|
||||
log.verbose('web add user not supported, trying couch')
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// auth type !== web or ENYI error w/ web adduser
|
||||
if (!res) {
|
||||
const username = await read.username('Username:', creds.username)
|
||||
const password = await read.password('Password:', creds.password)
|
||||
const email = await read.email('Email (this will be public):', creds.email)
|
||||
// npm registry quirk:
|
||||
// If you "add" an existing user with their current password, it's effectively a login, and if that account has otp you'll be prompted for it.
|
||||
res = await otplease(npm, opts, (reqOpts) => adduserCouch(username, email, password, reqOpts))
|
||||
}
|
||||
|
||||
// We don't know the username if it was a web login, all we can reliably log is scope and registry
|
||||
const message = `Logged in${opts.scope ? ` to scope ${opts.scope}` : ''} on ${opts.registry}.`
|
||||
|
||||
log.info('adduser', message)
|
||||
|
||||
return {
|
||||
message,
|
||||
newCreds: { token: res.token },
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (npm, { creds, ...opts }) => {
|
||||
const authType = npm.config.get('auth-type')
|
||||
let res
|
||||
if (authType === 'web') {
|
||||
try {
|
||||
res = await loginWeb(createOpener(npm, 'Login at'), opts)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENYI') {
|
||||
log.verbose('web login not supported, trying couch')
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// auth type !== web or ENYI error w/ web login
|
||||
if (!res) {
|
||||
const username = await read.username('Username:', creds.username)
|
||||
const password = await read.password('Password:', creds.password)
|
||||
res = await otplease(npm, opts, (reqOpts) => loginCouch(username, password, reqOpts))
|
||||
}
|
||||
|
||||
// We don't know the username if it was a web login, all we can reliably log is scope and registry
|
||||
const message = `Logged in${opts.scope ? ` to scope ${opts.scope}` : ''} on ${opts.registry}.`
|
||||
|
||||
log.info('login', message)
|
||||
|
||||
return {
|
||||
message,
|
||||
newCreds: { token: res.token },
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adduser,
|
||||
login,
|
||||
otplease,
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
const isScriptAllowed = require('@npmcli/arborist/lib/script-allowed.js')
|
||||
const getInstallScripts = require('@npmcli/arborist/lib/install-scripts.js')
|
||||
|
||||
// Walks arb.actualTree.inventory and returns the list of dep nodes that
|
||||
// have install-relevant lifecycle scripts and are not yet covered (or
|
||||
// explicitly denied) by the allowScripts policy.
|
||||
//
|
||||
// Returns an array of `{ node, scripts }` entries. `scripts` is an object
|
||||
// describing the relevant lifecycle scripts that would run.
|
||||
|
||||
const checkAllowScripts = async ({ arb, npm, tree }) => {
|
||||
const ignoreScripts = !!arb.options?.ignoreScripts
|
||||
const dangerouslyAllowAll = !!npm?.flatOptions?.dangerouslyAllowAllScripts
|
||||
|
||||
if (ignoreScripts || dangerouslyAllowAll) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Defaults to actualTree (post-reify) but accepts an explicit tree so
|
||||
// callers can pre-flight against the idealTree before scripts run.
|
||||
const targetTree = tree || arb.actualTree
|
||||
if (!targetTree?.inventory) {
|
||||
return []
|
||||
}
|
||||
|
||||
const policy = arb.options?.allowScripts || null
|
||||
|
||||
const unreviewed = []
|
||||
for (const node of targetTree.inventory.values()) {
|
||||
if (node.isProjectRoot || node.isWorkspace) {
|
||||
continue
|
||||
}
|
||||
if (node.isLink) {
|
||||
// Linked workspace dependencies are managed by the workspace owner.
|
||||
continue
|
||||
}
|
||||
|
||||
const verdict = isScriptAllowed(node, policy)
|
||||
if (verdict === true || verdict === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
const scripts = await getInstallScripts(node)
|
||||
if (Object.keys(scripts).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
unreviewed.push({ node, scripts })
|
||||
}
|
||||
|
||||
return unreviewed
|
||||
}
|
||||
|
||||
module.exports = checkAllowScripts
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
const abbrev = require('abbrev')
|
||||
|
||||
// These correspond to filenames in lib/commands
|
||||
// Please keep this list sorted alphabetically
|
||||
const commands = [
|
||||
'access',
|
||||
'adduser',
|
||||
'approve-scripts',
|
||||
'audit',
|
||||
'bugs',
|
||||
'cache',
|
||||
'ci',
|
||||
'completion',
|
||||
'config',
|
||||
'dedupe',
|
||||
'deny-scripts',
|
||||
'deprecate',
|
||||
'diff',
|
||||
'dist-tag',
|
||||
'docs',
|
||||
'doctor',
|
||||
'edit',
|
||||
'exec',
|
||||
'explain',
|
||||
'explore',
|
||||
'find-dupes',
|
||||
'fund',
|
||||
'get',
|
||||
'help',
|
||||
'help-search',
|
||||
'init',
|
||||
'install',
|
||||
'install-ci-test',
|
||||
'install-test',
|
||||
'link',
|
||||
'll',
|
||||
'login',
|
||||
'logout',
|
||||
'ls',
|
||||
'org',
|
||||
'outdated',
|
||||
'owner',
|
||||
'pack',
|
||||
'ping',
|
||||
'pkg',
|
||||
'prefix',
|
||||
'profile',
|
||||
'prune',
|
||||
'publish',
|
||||
'query',
|
||||
'rebuild',
|
||||
'repo',
|
||||
'restart',
|
||||
'root',
|
||||
'run',
|
||||
'sbom',
|
||||
'search',
|
||||
'set',
|
||||
'shrinkwrap',
|
||||
'stage',
|
||||
'star',
|
||||
'stars',
|
||||
'start',
|
||||
'stop',
|
||||
'team',
|
||||
'test',
|
||||
'token',
|
||||
'trust',
|
||||
'undeprecate',
|
||||
'uninstall',
|
||||
'unpublish',
|
||||
'unstar',
|
||||
'update',
|
||||
'version',
|
||||
'view',
|
||||
'whoami',
|
||||
]
|
||||
|
||||
// These must resolve to an entry in commands
|
||||
const aliases = {
|
||||
|
||||
// aliases
|
||||
author: 'owner',
|
||||
home: 'docs',
|
||||
issues: 'bugs',
|
||||
info: 'view',
|
||||
show: 'view',
|
||||
find: 'search',
|
||||
add: 'install',
|
||||
unlink: 'uninstall',
|
||||
remove: 'uninstall',
|
||||
rm: 'uninstall',
|
||||
r: 'uninstall',
|
||||
|
||||
// short names for common things
|
||||
un: 'uninstall',
|
||||
rb: 'rebuild',
|
||||
list: 'ls',
|
||||
ln: 'link',
|
||||
create: 'init',
|
||||
i: 'install',
|
||||
it: 'install-test',
|
||||
cit: 'install-ci-test',
|
||||
u: 'update',
|
||||
up: 'update',
|
||||
c: 'config',
|
||||
s: 'search',
|
||||
se: 'search',
|
||||
tst: 'test',
|
||||
t: 'test',
|
||||
ddp: 'dedupe',
|
||||
v: 'view',
|
||||
'run-script': 'run',
|
||||
'clean-install': 'ci',
|
||||
'clean-install-test': 'install-ci-test',
|
||||
x: 'exec',
|
||||
why: 'explain',
|
||||
la: 'll',
|
||||
verison: 'version',
|
||||
ic: 'ci',
|
||||
|
||||
// typos
|
||||
innit: 'init',
|
||||
// manually abbrev so that install-test doesn't make insta stop working
|
||||
in: 'install',
|
||||
ins: 'install',
|
||||
inst: 'install',
|
||||
insta: 'install',
|
||||
instal: 'install',
|
||||
isnt: 'install',
|
||||
isnta: 'install',
|
||||
isntal: 'install',
|
||||
isntall: 'install',
|
||||
'install-clean': 'ci',
|
||||
'isntall-clean': 'ci',
|
||||
hlep: 'help',
|
||||
'dist-tags': 'dist-tag',
|
||||
upgrade: 'update',
|
||||
udpate: 'update',
|
||||
rum: 'run',
|
||||
sit: 'install-ci-test',
|
||||
urn: 'run',
|
||||
ogr: 'org',
|
||||
'add-user': 'adduser',
|
||||
}
|
||||
|
||||
const deref = (c) => {
|
||||
if (!c) {
|
||||
return
|
||||
}
|
||||
|
||||
// Translate camelCase to snake-case (i.e. installTest to install-test)
|
||||
if (c.match(/[A-Z]/)) {
|
||||
c = c.replace(/([A-Z])/g, m => '-' + m.toLowerCase())
|
||||
}
|
||||
|
||||
// if they asked for something exactly we are done
|
||||
if (commands.includes(c)) {
|
||||
return c
|
||||
}
|
||||
|
||||
// if they asked for a direct alias
|
||||
if (aliases[c]) {
|
||||
return aliases[c]
|
||||
}
|
||||
|
||||
const abbrevs = abbrev(commands.concat(Object.keys(aliases)))
|
||||
|
||||
// first deref the abbrev,
|
||||
// if there is one then resolve any aliases so `npm install-cl` will resolve to `install-clean` then to `ci`
|
||||
let a = abbrevs[c]
|
||||
while (aliases[a]) {
|
||||
a = aliases[a]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
aliases,
|
||||
commands,
|
||||
deref,
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# npm completions for Fish shell
|
||||
# This script is a work in progress and does not fall under the normal semver contract as the rest of npm.
|
||||
|
||||
# __fish_npm_needs_command taken from:
|
||||
# https://stackoverflow.com/questions/16657803/creating-autocomplete-script-with-sub-commands
|
||||
function __fish_npm_needs_command
|
||||
set -l cmd (commandline -opc)
|
||||
|
||||
if test (count $cmd) -eq 1
|
||||
return 0
|
||||
end
|
||||
|
||||
return 1
|
||||
end
|
||||
|
||||
# Taken from https://github.com/fish-shell/fish-shell/blob/HEAD/share/completions/npm.fish
|
||||
function __fish_complete_npm -d "Complete the commandline using npm's 'completion' tool"
|
||||
# tell npm we are fish shell
|
||||
set -lx COMP_FISH true
|
||||
if command -sq npm
|
||||
# npm completion is bash-centric, so we need to translate fish's "commandline" stuff to bash's $COMP_* stuff
|
||||
# COMP_LINE is an array with the words in the commandline
|
||||
set -lx COMP_LINE (commandline -opc)
|
||||
# COMP_CWORD is the index of the current word in COMP_LINE
|
||||
# bash starts arrays with 0, so subtract 1
|
||||
set -lx COMP_CWORD (math (count $COMP_LINE) - 1)
|
||||
# COMP_POINT is the index of point/cursor when the commandline is viewed as a string
|
||||
set -lx COMP_POINT (commandline -C)
|
||||
# If the cursor is after the last word, the empty token will disappear in the expansion
|
||||
# Readd it
|
||||
if test (commandline -ct) = ""
|
||||
set COMP_CWORD (math $COMP_CWORD + 1)
|
||||
set COMP_LINE $COMP_LINE ""
|
||||
end
|
||||
command npm completion -- $COMP_LINE 2>/dev/null
|
||||
end
|
||||
end
|
||||
|
||||
# flush out what ships with fish
|
||||
complete -e npm
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
###-begin-npm-completion-###
|
||||
#
|
||||
# npm command completion script
|
||||
#
|
||||
# Installation: npm completion >> ~/.bashrc (or ~/.zshrc)
|
||||
# Or, maybe: npm completion > /usr/local/etc/bash_completion.d/npm
|
||||
#
|
||||
|
||||
if type complete &>/dev/null; then
|
||||
_npm_completion () {
|
||||
local words cword
|
||||
if type _get_comp_words_by_ref &>/dev/null; then
|
||||
_get_comp_words_by_ref -n = -n @ -n : -w words -i cword
|
||||
else
|
||||
cword="$COMP_CWORD"
|
||||
words=("${COMP_WORDS[@]}")
|
||||
fi
|
||||
|
||||
local si="$IFS"
|
||||
if ! IFS=$'\n' COMPREPLY=($(COMP_CWORD="$cword" \
|
||||
COMP_LINE="$COMP_LINE" \
|
||||
COMP_POINT="$COMP_POINT" \
|
||||
npm completion -- "${words[@]}" \
|
||||
2>/dev/null)); then
|
||||
local ret=$?
|
||||
IFS="$si"
|
||||
return $ret
|
||||
fi
|
||||
IFS="$si"
|
||||
if type __ltrim_colon_completions &>/dev/null; then
|
||||
__ltrim_colon_completions "${words[cword]}"
|
||||
fi
|
||||
}
|
||||
complete -o default -F _npm_completion npm
|
||||
elif type compdef &>/dev/null; then
|
||||
_npm_completion() {
|
||||
local si=$IFS
|
||||
compadd -- $(COMP_CWORD=$((CURRENT-1)) \
|
||||
COMP_LINE=$BUFFER \
|
||||
COMP_POINT=0 \
|
||||
npm completion -- "${words[@]}" \
|
||||
2>/dev/null)
|
||||
IFS=$si
|
||||
}
|
||||
compdef _npm_completion npm
|
||||
elif type compctl &>/dev/null; then
|
||||
_npm_completion () {
|
||||
local cword line point words si
|
||||
read -Ac words
|
||||
read -cn cword
|
||||
let cword-=1
|
||||
read -l line
|
||||
read -ln point
|
||||
si="$IFS"
|
||||
if ! IFS=$'\n' reply=($(COMP_CWORD="$cword" \
|
||||
COMP_LINE="$line" \
|
||||
COMP_POINT="$point" \
|
||||
npm completion -- "${words[@]}" \
|
||||
2>/dev/null)); then
|
||||
|
||||
local ret=$?
|
||||
IFS="$si"
|
||||
return $ret
|
||||
fi
|
||||
IFS="$si"
|
||||
}
|
||||
compctl -K _npm_completion npm
|
||||
fi
|
||||
###-end-npm-completion-###
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
const Npm = require('../npm')
|
||||
const { distance } = require('fastest-levenshtein')
|
||||
const { commands } = require('./cmd-list.js')
|
||||
|
||||
const runScripts = ['stop', 'start', 'test', 'restart']
|
||||
|
||||
const isClose = (scmd, cmd) => distance(scmd, cmd) < scmd.length * 0.4
|
||||
|
||||
const didYouMean = (pkg, scmd) => {
|
||||
const { scripts = {}, bin = {} } = pkg || {}
|
||||
|
||||
const best = [
|
||||
...commands
|
||||
.filter(cmd => isClose(scmd, cmd) && scmd !== cmd)
|
||||
.map(str => [str, Npm.cmd(str).description]),
|
||||
...Object.keys(scripts)
|
||||
// We would already be suggesting this in `npm x` so omit them here
|
||||
.filter(cmd => isClose(scmd, cmd) && !runScripts.includes(cmd))
|
||||
.map(str => [`run ${str}`, `run the "${str}" package script`]),
|
||||
...Object.keys(bin)
|
||||
.filter(cmd => isClose(scmd, cmd))
|
||||
.map(str => [`exec ${str}`, `run the "${str}" command from either this or a remote npm package`]),
|
||||
]
|
||||
|
||||
if (best.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `\n\nDid you mean ${best.length === 1 ? 'this' : 'one of these'}?\n` +
|
||||
best.slice(0, 3).map(([msg, comment]) => ` npm ${msg} # ${comment}`).join('\n')
|
||||
}
|
||||
|
||||
module.exports = didYouMean
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
const { log, output, input, META } = require('proc-log')
|
||||
const { explain } = require('./explain-eresolve.js')
|
||||
const { formatWithOptions } = require('./format')
|
||||
const { redactLog } = require('@npmcli/redact')
|
||||
|
||||
// This is the general approach to color:
|
||||
// Eventually this will be exposed somewhere we can refer to these by name.
|
||||
// Foreground colors only. Never set the background color.
|
||||
/*
|
||||
* Black # (Don't use)
|
||||
* Red # Danger
|
||||
* Green # Success
|
||||
* Yellow # Warning
|
||||
* Blue # Accent
|
||||
* Magenta # Done
|
||||
* Cyan # Emphasis
|
||||
* White # (Don't use)
|
||||
*/
|
||||
|
||||
// Translates log levels to chalk colors
|
||||
const COLOR_PALETTE = ({ chalk: c }) => ({
|
||||
heading: c.bold,
|
||||
title: c.blueBright,
|
||||
timing: c.magentaBright,
|
||||
// loglevels
|
||||
error: c.red,
|
||||
warn: c.yellow,
|
||||
notice: c.cyanBright,
|
||||
http: c.green,
|
||||
info: c.cyan,
|
||||
verbose: c.blue,
|
||||
silly: c.blue.dim,
|
||||
})
|
||||
|
||||
const LEVEL_OPTIONS = {
|
||||
silent: {
|
||||
index: 0,
|
||||
},
|
||||
error: {
|
||||
index: 1,
|
||||
},
|
||||
warn: {
|
||||
index: 2,
|
||||
},
|
||||
notice: {
|
||||
index: 3,
|
||||
},
|
||||
http: {
|
||||
index: 4,
|
||||
},
|
||||
info: {
|
||||
index: 5,
|
||||
},
|
||||
verbose: {
|
||||
index: 6,
|
||||
},
|
||||
silly: {
|
||||
index: 7,
|
||||
},
|
||||
}
|
||||
|
||||
const LEVEL_METHODS = {
|
||||
...LEVEL_OPTIONS,
|
||||
[log.KEYS.timing]: {
|
||||
show: ({ timing, index }) => !!timing && index !== 0,
|
||||
},
|
||||
}
|
||||
|
||||
const setBlocking = (stream) => {
|
||||
// Copied from https://github.com/yargs/set-blocking
|
||||
// https://raw.githubusercontent.com/yargs/set-blocking/master/LICENSE.txt
|
||||
/* istanbul ignore next - we trust that this works */
|
||||
if (stream._handle && stream.isTTY && typeof stream._handle.setBlocking === 'function') {
|
||||
stream._handle.setBlocking(true)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
// This is the key that is returned to the user for errors
|
||||
const ERROR_KEY = 'error'
|
||||
// This is the key producers use to indicate that there is a json error that should be merged into the finished output
|
||||
const JSON_ERROR_KEY = 'jsonError'
|
||||
|
||||
const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
const getArrayOrObject = (items) => {
|
||||
if (items.length) {
|
||||
const foundNonObject = items.find(o => !isPlainObject(o))
|
||||
// Non-objects and arrays cant be merged, so just return the first item
|
||||
if (foundNonObject) {
|
||||
return foundNonObject
|
||||
}
|
||||
// We use objects with 0,1,2,etc keys to merge array
|
||||
if (items.every((o, i) => Object.hasOwn(o, i))) {
|
||||
return Object.assign([], ...items)
|
||||
}
|
||||
}
|
||||
// Otherwise its an object with all object items merged together
|
||||
return Object.assign({}, ...items.filter(o => isPlainObject(o)))
|
||||
}
|
||||
|
||||
const redactValue = (obj) => JSON.parse(redactLog(JSON.stringify(obj)))
|
||||
|
||||
const getJsonBuffer = ({ [JSON_ERROR_KEY]: metaError }, buffer) => {
|
||||
const items = []
|
||||
// meta also contains the meta object passed to flush
|
||||
const errors = metaError ? [metaError] : []
|
||||
// index 1 is the meta, 2 is the logged argument
|
||||
for (const [, { [JSON_ERROR_KEY]: error, redact = true }, obj] of buffer) {
|
||||
if (obj) {
|
||||
items.push(redact ? redactValue(obj) : obj)
|
||||
}
|
||||
if (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!items.length && !errors.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const res = getArrayOrObject(items)
|
||||
|
||||
// This skips any error checking since we can only set an error property on an object that can be stringified
|
||||
// XXX(BREAKING_CHANGE): remove this in favor of always returning an object with result and error keys
|
||||
if (isPlainObject(res) && errors.length) {
|
||||
// This is not ideal.
|
||||
// JSON output has always been keyed at the root with an `error` key, so we cant change that without it being a breaking change. At the same time some commands output arbitrary keys at the top level of the output, such as package names.
|
||||
// So the output could already have the same key. The choice here is to overwrite it with our error since that is (probably?) more important.
|
||||
// XXX(BREAKING_CHANGE): all json output should be keyed under well known keys, eg `result` and `error`
|
||||
if (res[ERROR_KEY]) {
|
||||
log.warn('', `overwriting existing ${ERROR_KEY} on json output`)
|
||||
}
|
||||
res[ERROR_KEY] = getArrayOrObject(errors)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
const withMeta = (handler) => (level, ...args) => {
|
||||
let meta = {}
|
||||
const last = args.at(-1)
|
||||
if (last && typeof last === 'object' && Object.hasOwn(last, META)) {
|
||||
meta = args.pop()
|
||||
}
|
||||
return handler(level, meta, ...args)
|
||||
}
|
||||
|
||||
class Display {
|
||||
#logState = {
|
||||
buffering: true,
|
||||
buffer: [],
|
||||
}
|
||||
|
||||
#outputState = {
|
||||
buffering: true,
|
||||
buffer: [],
|
||||
}
|
||||
|
||||
// colors
|
||||
#noColorChalk
|
||||
#stdoutChalk
|
||||
#stdoutColor
|
||||
#stderrChalk
|
||||
#stderrColor
|
||||
#logColors
|
||||
|
||||
// progress
|
||||
#progress
|
||||
|
||||
// options
|
||||
#command
|
||||
#levelIndex
|
||||
#timing
|
||||
#json
|
||||
#heading
|
||||
#silent
|
||||
|
||||
// display streams
|
||||
#stdout
|
||||
#stderr
|
||||
|
||||
#seenNotices = new Set()
|
||||
|
||||
constructor ({ stdout, stderr }) {
|
||||
this.#stdout = setBlocking(stdout)
|
||||
this.#stderr = setBlocking(stderr)
|
||||
|
||||
// Handlers are set immediately so they can buffer all events
|
||||
process.on('log', this.#logHandler)
|
||||
process.on('output', this.#outputHandler)
|
||||
process.on('input', this.#inputHandler)
|
||||
this.#progress = new Progress({ stream: stderr })
|
||||
}
|
||||
|
||||
off () {
|
||||
process.off('log', this.#logHandler)
|
||||
this.#logState.buffer.length = 0
|
||||
process.off('output', this.#outputHandler)
|
||||
this.#outputState.buffer.length = 0
|
||||
process.off('input', this.#inputHandler)
|
||||
this.#progress.off()
|
||||
this.#seenNotices.clear()
|
||||
}
|
||||
|
||||
get chalk () {
|
||||
return {
|
||||
noColor: this.#noColorChalk,
|
||||
stdout: this.#stdoutChalk,
|
||||
stderr: this.#stderrChalk,
|
||||
}
|
||||
}
|
||||
|
||||
async load ({
|
||||
command,
|
||||
heading,
|
||||
json,
|
||||
loglevel,
|
||||
progress,
|
||||
stderrColor,
|
||||
stdoutColor,
|
||||
timing,
|
||||
unicode,
|
||||
}) {
|
||||
const [{ Chalk }, { createSupportsColor }] = await Promise.all([
|
||||
import('chalk'),
|
||||
import('supports-color'),
|
||||
])
|
||||
// We get the chalk level based on a null stream, meaning chalk will only use what it knows about the environment to get color support since we already determined in our definitions that we want to show colors.
|
||||
const level = Math.max(createSupportsColor(null).level, 1)
|
||||
this.#noColorChalk = new Chalk({ level: 0 })
|
||||
this.#stdoutColor = stdoutColor
|
||||
this.#stdoutChalk = stdoutColor ? new Chalk({ level }) : this.#noColorChalk
|
||||
this.#stderrColor = stderrColor
|
||||
this.#stderrChalk = stderrColor ? new Chalk({ level }) : this.#noColorChalk
|
||||
this.#logColors = COLOR_PALETTE({ chalk: this.#stderrChalk })
|
||||
|
||||
this.#command = command
|
||||
this.#levelIndex = LEVEL_OPTIONS[loglevel].index
|
||||
this.#timing = timing
|
||||
this.#json = json
|
||||
this.#heading = heading
|
||||
this.#silent = this.#levelIndex <= 0
|
||||
|
||||
// Emit resume event on the logs which will flush output
|
||||
log.resume()
|
||||
output.flush()
|
||||
this.#progress.load({
|
||||
unicode,
|
||||
enabled: !!progress && !this.#silent,
|
||||
})
|
||||
}
|
||||
|
||||
// STREAM WRITES
|
||||
|
||||
// Write formatted and (non-)colorized output to streams
|
||||
#write (stream, options, ...args) {
|
||||
const colors = stream === this.#stdout ? this.#stdoutColor : this.#stderrColor
|
||||
const value = formatWithOptions({ colors, ...options }, ...args)
|
||||
this.#progress.write(() => stream.write(value))
|
||||
}
|
||||
|
||||
// HANDLERS
|
||||
|
||||
// Arrow function assigned to a private class field so it can be passed directly as a listener and still reference "this"
|
||||
#logHandler = withMeta((level, meta, ...args) => {
|
||||
switch (level) {
|
||||
case log.KEYS.resume:
|
||||
this.#logState.buffering = false
|
||||
this.#logState.buffer.forEach((item) => this.#tryWriteLog(...item))
|
||||
this.#logState.buffer.length = 0
|
||||
break
|
||||
|
||||
case log.KEYS.pause:
|
||||
this.#logState.buffering = true
|
||||
break
|
||||
|
||||
default:
|
||||
if (this.#logState.buffering) {
|
||||
this.#logState.buffer.push([level, meta, ...args])
|
||||
} else {
|
||||
this.#tryWriteLog(level, meta, ...args)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Arrow function assigned to a private class field so it can be passed directly as a listener and still reference "this"
|
||||
#outputHandler = withMeta((level, meta, ...args) => {
|
||||
this.#json = typeof meta.json === 'boolean' ? meta.json : this.#json
|
||||
switch (level) {
|
||||
case output.KEYS.flush: {
|
||||
this.#outputState.buffering = false
|
||||
if (this.#json) {
|
||||
const json = getJsonBuffer(meta, this.#outputState.buffer)
|
||||
if (json) {
|
||||
// Per-item redaction already applied in getJsonBuffer, skip string-level redaction
|
||||
const jsonMeta = { ...meta, redact: false }
|
||||
this.#writeOutput(output.KEYS.standard, jsonMeta, JSON.stringify(json, null, 2))
|
||||
}
|
||||
} else {
|
||||
this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
|
||||
}
|
||||
this.#outputState.buffer.length = 0
|
||||
break
|
||||
}
|
||||
|
||||
case output.KEYS.buffer:
|
||||
this.#outputState.buffer.push([output.KEYS.standard, meta, ...args])
|
||||
break
|
||||
|
||||
default:
|
||||
if (this.#outputState.buffering) {
|
||||
this.#outputState.buffer.push([level, meta, ...args])
|
||||
} else {
|
||||
// XXX: Check if the argument looks like a run-script banner. This should be replaced with proc-log.META in @npmcli/run-script
|
||||
if (typeof args[0] === 'string' && args[0].startsWith('\n> ') && args[0].endsWith('\n')) {
|
||||
if (this.#silent || ['exec', 'explore'].includes(this.#command)) {
|
||||
// Silent mode and some specific commands always hide run script banners
|
||||
break
|
||||
} else if (this.#json) {
|
||||
// In json mode, change output to stderr since we don't want to break json parsing on stdout if the user is piping to jq or something.
|
||||
// XXX: in a future (breaking?) change it might make sense for run-script to always output these banners with proc-log.output.error if we think they align closer with "logging" instead of "output".
|
||||
level = output.KEYS.error
|
||||
}
|
||||
}
|
||||
this.#writeOutput(level, meta, ...args)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
#inputHandler = withMeta((level, meta, ...args) => {
|
||||
switch (level) {
|
||||
case input.KEYS.start:
|
||||
log.pause()
|
||||
this.#outputState.buffering = true
|
||||
this.#progress.off()
|
||||
break
|
||||
|
||||
case input.KEYS.end: {
|
||||
log.resume()
|
||||
// For silent prompts (like password), add newline to preserve output
|
||||
if (meta?.silent) {
|
||||
output.standard()
|
||||
}
|
||||
output.flush()
|
||||
this.#progress.resume()
|
||||
break
|
||||
}
|
||||
|
||||
case input.KEYS.read: {
|
||||
// The convention when calling input.read is to pass in a single fn that returns the promise to await. Resolve and reject are provided by proc-log.
|
||||
const [res, rej, p] = args
|
||||
|
||||
// Use sequential input management to avoid race condition which causes issues with spinner and adding newlines.
|
||||
input.start()
|
||||
|
||||
return p()
|
||||
.then((result) => {
|
||||
// If user hits enter, process end event and return input.
|
||||
input.end({ [META]: true, silent: meta?.silent })
|
||||
res(result)
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
// If user hits ctrl+c, add newline to preserve output.
|
||||
output.standard()
|
||||
input.end()
|
||||
rej(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// OUTPUT
|
||||
|
||||
#writeOutput (level, meta, ...args) {
|
||||
switch (level) {
|
||||
case output.KEYS.standard:
|
||||
this.#write(this.#stdout, meta, ...args)
|
||||
break
|
||||
|
||||
case output.KEYS.error:
|
||||
this.#write(this.#stderr, meta, ...args)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// LOGS
|
||||
|
||||
#tryWriteLog (level, meta, ...args) {
|
||||
try {
|
||||
// Also (and this is a really inexcusable kludge), we patch the log.warn() method so that when we see a peerDep override explanation from Arborist, we can replace the object with a highly abbreviated explanation of what's being overridden.
|
||||
// TODO: this could probably be moved to arborist now that display is refactored
|
||||
const [heading, message, expl] = args
|
||||
if (level === log.KEYS.warn && heading === 'ERESOLVE' && expl && typeof expl === 'object') {
|
||||
this.#writeLog(level, meta, heading, message)
|
||||
this.#writeLog(level, meta, '', explain(expl, this.#stderrChalk, 2))
|
||||
return
|
||||
}
|
||||
this.#writeLog(level, meta, ...args)
|
||||
} catch (ex) {
|
||||
try {
|
||||
// if it crashed once, it might again!
|
||||
this.#writeLog(log.KEYS.verbose, meta, '', `attempt to log crashed`, ...args, ex)
|
||||
} catch (ex2) {
|
||||
// This happens if the object has an inspect method that crashes so just console.error with the errors but don't do anything else that might error again.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`attempt to log crashed`, ex, ex2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#writeLog (level, meta, ...args) {
|
||||
const levelOpts = LEVEL_METHODS[level]
|
||||
const show = levelOpts.show ?? (({ index }) => levelOpts.index <= index)
|
||||
const force = meta.force && !this.#silent
|
||||
|
||||
if (force || show({ index: this.#levelIndex, timing: this.#timing })) {
|
||||
// this mutates the array so we can pass args directly to format later
|
||||
const title = args.shift()
|
||||
const prefix = [
|
||||
this.#logColors.heading(this.#heading),
|
||||
this.#logColors[level](level),
|
||||
title ? this.#logColors.title(title) : null,
|
||||
]
|
||||
const writeOpts = { prefix }
|
||||
// notice logs typically come from `npm-notice` headers in responses. Some of them have 2fa login links so we skip redaction.
|
||||
if (level === 'notice') {
|
||||
writeOpts.redact = false
|
||||
// Deduplicate notices within a single command execution, unless in verbose mode
|
||||
if (this.#levelIndex < LEVEL_OPTIONS.verbose.index) {
|
||||
const noticeKey = JSON.stringify([title, ...args])
|
||||
if (this.#seenNotices.has(noticeKey)) {
|
||||
return
|
||||
}
|
||||
this.#seenNotices.add(noticeKey)
|
||||
}
|
||||
}
|
||||
this.#write(this.#stderr, writeOpts, ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Progress {
|
||||
// Taken from https://github.com/sindresorhus/cli-spinners
|
||||
// MIT License
|
||||
// Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
static dots = { duration: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] }
|
||||
static lines = { duration: 130, frames: ['-', '\\', '|', '/'] }
|
||||
|
||||
#enabled = false
|
||||
#frameIndex = 0
|
||||
#interval
|
||||
#lastUpdate = 0
|
||||
#spinner
|
||||
#stream
|
||||
// Initial timeout to wait to start rendering
|
||||
#timeout
|
||||
#rendered = false
|
||||
|
||||
// We are rendering if enabled option is set and we are not waiting for the render timeout
|
||||
get #rendering () {
|
||||
return this.#enabled && !this.#timeout
|
||||
}
|
||||
|
||||
// We are spinning if enabled option is set and the render interval has been set
|
||||
get #spinning () {
|
||||
return this.#enabled && this.#interval
|
||||
}
|
||||
|
||||
constructor ({ stream }) {
|
||||
this.#stream = stream
|
||||
}
|
||||
|
||||
load ({ enabled, unicode }) {
|
||||
this.#enabled = enabled
|
||||
this.#spinner = unicode ? Progress.dots : Progress.lines
|
||||
// Wait 200 ms so we don't render the spinner for short durations
|
||||
this.#timeout = setTimeout(() => {
|
||||
this.#timeout = null
|
||||
this.#render()
|
||||
}, 200)
|
||||
// Make sure this timeout does not keep the process open
|
||||
this.#timeout.unref()
|
||||
}
|
||||
|
||||
off () {
|
||||
if (!this.#enabled) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.#timeout)
|
||||
this.#timeout = null
|
||||
clearInterval(this.#interval)
|
||||
this.#interval = null
|
||||
this.#frameIndex = 0
|
||||
this.#lastUpdate = 0
|
||||
this.#clearSpinner()
|
||||
}
|
||||
|
||||
resume () {
|
||||
this.#render(true)
|
||||
}
|
||||
|
||||
// If we are currently rendering the spinner we clear it before writing our line and then re-render the spinner after.
|
||||
// If not then all we need to do is write the line.
|
||||
write (write) {
|
||||
if (this.#spinning) {
|
||||
this.#clearSpinner()
|
||||
}
|
||||
write()
|
||||
if (this.#spinning) {
|
||||
this.#render()
|
||||
}
|
||||
}
|
||||
|
||||
#render (resuming) {
|
||||
if (!this.#rendering) {
|
||||
return
|
||||
}
|
||||
// We always attempt to render immediately but we only request to move to the next frame if it has been longer than our spinner frame duration since our last update
|
||||
this.#renderFrame(Date.now() - this.#lastUpdate >= this.#spinner.duration, resuming)
|
||||
if (!this.#interval) {
|
||||
this.#interval = setInterval(() => this.#renderFrame(true), this.#spinner.duration)
|
||||
// Make sure this timeout does not keep the process open
|
||||
this.#interval.unref()
|
||||
}
|
||||
this.#interval.refresh()
|
||||
}
|
||||
|
||||
#renderFrame (next, resuming) {
|
||||
if (next) {
|
||||
this.#lastUpdate = Date.now()
|
||||
this.#frameIndex++
|
||||
if (this.#frameIndex >= this.#spinner.frames.length) {
|
||||
this.#frameIndex = 0
|
||||
}
|
||||
}
|
||||
if (!resuming) {
|
||||
this.#clearSpinner()
|
||||
}
|
||||
this.#stream.write(this.#spinner.frames[this.#frameIndex])
|
||||
this.#rendered = true
|
||||
}
|
||||
|
||||
#clearSpinner () {
|
||||
if (!this.#rendered) {
|
||||
return
|
||||
}
|
||||
// Move to the start of the line and clear the rest of the line
|
||||
this.#stream.cursorTo(0)
|
||||
this.#stream.clearLine(1)
|
||||
this.#rendered = false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Display
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
const { format } = require('node:util')
|
||||
const { resolve } = require('node:path')
|
||||
const { redactLog: replaceInfo } = require('@npmcli/redact')
|
||||
const { log } = require('proc-log')
|
||||
|
||||
const errorMessage = (er, npm) => {
|
||||
const summary = []
|
||||
const detail = []
|
||||
const files = []
|
||||
let json
|
||||
|
||||
er.message &&= replaceInfo(er.message)
|
||||
er.stack &&= replaceInfo(er.stack)
|
||||
|
||||
switch (er.code) {
|
||||
case 'ERESOLVE': {
|
||||
const { report } = require('./explain-eresolve.js')
|
||||
summary.push(['ERESOLVE', er.message])
|
||||
detail.push(['', ''])
|
||||
// XXX(display): error messages are logged so we use the logColor since that is based on stderr.
|
||||
// This should be handled solely by the display layer so it could also be printed to stdout if necessary.
|
||||
const { explanation, file } = report(er, npm.logChalk, npm.noColorChalk)
|
||||
detail.push(['', explanation])
|
||||
files.push(['eresolve-report.txt', file])
|
||||
break
|
||||
}
|
||||
|
||||
case 'ENOLOCK': {
|
||||
const cmd = npm.command || ''
|
||||
summary.push([cmd, 'This command requires an existing lockfile.'])
|
||||
detail.push([cmd, 'Try creating one first with: npm i --package-lock-only'])
|
||||
detail.push([cmd, `Original error: ${er.message}`])
|
||||
break
|
||||
}
|
||||
|
||||
case 'ENOAUDIT':
|
||||
summary.push(['audit', er.message])
|
||||
break
|
||||
|
||||
case 'ECONNREFUSED':
|
||||
summary.push(['', er])
|
||||
detail.push(['', [
|
||||
'',
|
||||
`If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config'`,
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EACCES':
|
||||
case 'EPERM': {
|
||||
const isCachePath =
|
||||
typeof er.path === 'string' && npm.loaded && er.path.startsWith(npm.config.get('cache'))
|
||||
const isCacheDest =
|
||||
typeof er.dest === 'string' && npm.loaded && er.dest.startsWith(npm.config.get('cache'))
|
||||
|
||||
if (process.platform !== 'win32' && (isCachePath || isCacheDest)) {
|
||||
// user probably doesn't need this, but still add it to the debug log
|
||||
log.verbose(er.stack)
|
||||
summary.push(['', [
|
||||
'',
|
||||
'Your cache folder contains root-owned files, due to a bug in previous versions of npm which has since been addressed.',
|
||||
'',
|
||||
'To permanently fix this problem, please run:',
|
||||
` sudo chown -R ${process.getuid()}:${process.getgid()} "${npm.config.get('cache')}"`,
|
||||
].join('\n')])
|
||||
} else {
|
||||
summary.push(['', er])
|
||||
detail.push(['', [
|
||||
'',
|
||||
'The operation was rejected by your operating system.',
|
||||
...process.platform === 'win32' ? [
|
||||
`It's possible that the file was already in use (by a text editor or antivirus), or that you lack permissions to access it.`,
|
||||
] : [
|
||||
'It is likely you do not have the permissions to access this file as the current user',
|
||||
],
|
||||
'',
|
||||
'If you believe this might be a permissions issue, please double-check the permissions of the file and its containing directories, or try running the command again as root/Administrator.',
|
||||
].join('\n')])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'EALLOWGIT':
|
||||
summary.push(['', er.message])
|
||||
detail.push(['', `Refusing to fetch "${er.package}"`])
|
||||
break
|
||||
|
||||
case 'ENOGIT':
|
||||
summary.push(['', er.message])
|
||||
detail.push(['', [
|
||||
'',
|
||||
'Failed using git.',
|
||||
'Please check if you have git installed and in your PATH.',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EJSONPARSE':
|
||||
// Check whether we ran into a conflict in our own package.json
|
||||
if (er.path === resolve(npm.prefix, 'package.json')) {
|
||||
const { isDiff } = require('parse-conflict-json')
|
||||
const txt = require('node:fs').readFileSync(er.path, 'utf8').replace(/\r\n/g, '\n')
|
||||
if (isDiff(txt)) {
|
||||
detail.push(['', [
|
||||
'Merge conflict detected in your package.json.',
|
||||
'',
|
||||
'Please resolve the package.json conflict and retry.',
|
||||
].join('\n')])
|
||||
break
|
||||
}
|
||||
}
|
||||
summary.push(['JSON.parse', er.message])
|
||||
detail.push(['JSON.parse', [
|
||||
'Failed to parse JSON data.',
|
||||
'Note: package.json must be actual JSON, not just JavaScript.',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EOTP':
|
||||
case 'E401':
|
||||
// E401 is for places where we accidentally neglect OTP stuff
|
||||
if (er.code === 'EOTP' || /one-time pass/.test(er.message)) {
|
||||
const authUrl = er.body?.authUrl
|
||||
const doneUrl = er.body?.doneUrl
|
||||
if (authUrl && doneUrl) {
|
||||
json = { authUrl, doneUrl }
|
||||
summary.push(['', 'This operation requires a one-time password.'])
|
||||
detail.push(['', `Open this URL in your browser to authenticate:`])
|
||||
detail.push(['', ` ${authUrl}`])
|
||||
detail.push(['', ''])
|
||||
detail.push(['', `After authenticating, your token can be retrieved from:`])
|
||||
detail.push(['', ` ${doneUrl}`])
|
||||
} else {
|
||||
summary.push(['', 'This operation requires a one-time password from your authenticator.'])
|
||||
detail.push(['', [
|
||||
'You can provide a one-time password by passing --otp=<code> to the command you ran.',
|
||||
'If you already provided a one-time password then it is likely that you either typoed it, or it timed out. Please try again.',
|
||||
].join('\n')])
|
||||
}
|
||||
} else {
|
||||
// npm ERR! code E401
|
||||
// npm ERR! Unable to authenticate, need: Basic
|
||||
const auth = !er.headers || !er.headers['www-authenticate']
|
||||
? []
|
||||
: er.headers['www-authenticate'].map(au => au.split(/[,\s]+/))[0]
|
||||
|
||||
if (auth.includes('Bearer')) {
|
||||
summary.push(['',
|
||||
'Unable to authenticate, your authentication token seems to be invalid.',
|
||||
])
|
||||
detail.push(['', [
|
||||
'To correct this please try logging in again with:',
|
||||
' npm login',
|
||||
].join('\n')])
|
||||
} else if (auth.includes('Basic')) {
|
||||
summary.push(['', 'Incorrect or missing password.'])
|
||||
detail.push(['', [
|
||||
'If you were trying to login, change your password, create an authentication token or enable two-factor authentication then that means you likely typed your password in incorrectly.',
|
||||
'Please try again, or recover your password at:',
|
||||
' https://www.npmjs.com/forgot',
|
||||
'',
|
||||
'If you were doing some other operation then your saved credentials are probably out of date.',
|
||||
'To correct this please try logging in again with:',
|
||||
' npm login',
|
||||
].join('\n')])
|
||||
} else {
|
||||
summary.push(['', er.message || er])
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'E404':
|
||||
// There's no need to have 404 in the message as well.
|
||||
summary.push(['404', er.message.replace(/^404\s+/, '')])
|
||||
if (er.pkgid && er.pkgid !== '-') {
|
||||
const pkg = er.pkgid.replace(/(?!^)@.*$/, '')
|
||||
|
||||
detail.push(['404', ''])
|
||||
detail.push([
|
||||
'404',
|
||||
'',
|
||||
`The requested resource '${replaceInfo(er.pkgid)}' could not be found or you do not have permission to access it.`,
|
||||
])
|
||||
|
||||
const nameValidator = require('validate-npm-package-name')
|
||||
const valResult = nameValidator(pkg)
|
||||
|
||||
if (!valResult.validForNewPackages) {
|
||||
detail.push(['404', 'This package name is not valid, because', ''])
|
||||
|
||||
const errorsArray = [...(valResult.errors || []), ...(valResult.warnings || [])]
|
||||
errorsArray.forEach((item, idx) => detail.push(['404', ' ' + (idx + 1) + '. ' + item]))
|
||||
}
|
||||
|
||||
detail.push(['404', ''])
|
||||
detail.push(['404', 'Note that you can also install from a'])
|
||||
detail.push(['404', 'tarball, folder, http url, or git url.'])
|
||||
}
|
||||
break
|
||||
|
||||
case 'EPUBLISHCONFLICT':
|
||||
summary.push(['publish fail', 'Cannot publish over existing version.'])
|
||||
detail.push(['publish fail', `Update the 'version' field in package.json and try again.`])
|
||||
detail.push(['publish fail', ''])
|
||||
detail.push(['publish fail', 'To automatically increment version numbers, see:'])
|
||||
detail.push(['publish fail', ' npm help version'])
|
||||
break
|
||||
|
||||
case 'EISGIT':
|
||||
summary.push(['git', er.message])
|
||||
summary.push(['git', ` ${er.path}`])
|
||||
detail.push(['git', [
|
||||
'Refusing to remove it. Update manually, or move it out of the way first.',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EBADDEVENGINES': {
|
||||
const { current, required } = er
|
||||
summary.push(['EBADDEVENGINES', er.message])
|
||||
detail.push(['EBADDEVENGINES', { current, required }])
|
||||
break
|
||||
}
|
||||
|
||||
case 'EBADPLATFORM': {
|
||||
const actual = er.current
|
||||
const expected = { ...er.required }
|
||||
const checkedKeys = []
|
||||
for (const key in expected) {
|
||||
if (Array.isArray(expected[key]) && expected[key].length > 0) {
|
||||
expected[key] = expected[key].join(',')
|
||||
checkedKeys.push(key)
|
||||
} else if (expected[key] === undefined ||
|
||||
Array.isArray(expected[key]) && expected[key].length === 0) {
|
||||
delete expected[key]
|
||||
delete actual[key]
|
||||
} else {
|
||||
checkedKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
const longestKey = Math.max(...checkedKeys.map((key) => key.length))
|
||||
const detailEntry = []
|
||||
for (const key of checkedKeys) {
|
||||
const padding = key.length === longestKey
|
||||
? 1
|
||||
: 1 + (longestKey - key.length)
|
||||
|
||||
// padding + 1 because 'actual' is longer than 'valid'
|
||||
detailEntry.push(`Valid ${key}:${' '.repeat(padding + 1)}${expected[key]}`)
|
||||
detailEntry.push(`Actual ${key}:${' '.repeat(padding)}${actual[key]}`)
|
||||
}
|
||||
|
||||
summary.push(['notsup', format(
|
||||
'Unsupported platform for %s: wanted %j (current: %j)',
|
||||
er.pkgid,
|
||||
expected,
|
||||
actual
|
||||
)])
|
||||
detail.push(['notsup', detailEntry.join('\n')])
|
||||
break
|
||||
}
|
||||
|
||||
case 'EEXIST':
|
||||
summary.push(['', er.message])
|
||||
summary.push(['', 'File exists: ' + (er.dest || er.path)])
|
||||
detail.push(['', 'Remove the existing file and try again, or run npm'])
|
||||
detail.push(['', 'with --force to overwrite files recklessly.'])
|
||||
break
|
||||
|
||||
case 'ENEEDAUTH':
|
||||
summary.push(['need auth', er.message])
|
||||
detail.push(['need auth', 'You need to authorize this machine using `npm adduser`'])
|
||||
break
|
||||
|
||||
case 'ECONNRESET':
|
||||
case 'ENOTFOUND':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ERR_SOCKET_TIMEOUT':
|
||||
case 'EAI_FAIL':
|
||||
summary.push(['network', er.message])
|
||||
detail.push(['network', [
|
||||
'This is a problem related to network connectivity.',
|
||||
'In most cases you are behind a proxy or have bad network settings.',
|
||||
'',
|
||||
`If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config'`,
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'ETARGET':
|
||||
summary.push(['notarget', er.message])
|
||||
detail.push(['notarget', [
|
||||
`In most cases you or one of your dependencies are requesting a package version that doesn't exist.`,
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'E403':
|
||||
summary.push(['403', er.message])
|
||||
detail.push(['403', [
|
||||
'In most cases, you or one of your dependencies are requesting a package version that is forbidden by your security policy, or on a server you do not have access to.',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EBADENGINE':
|
||||
summary.push(['engine', er.message])
|
||||
summary.push(['engine', 'Not compatible with your version of node/npm: ' + er.pkgid])
|
||||
detail.push(['notsup', [
|
||||
'Not compatible with your version of node/npm: ' + er.pkgid,
|
||||
'Required: ' + JSON.stringify(er.required),
|
||||
'Actual: ' +
|
||||
JSON.stringify({ node: process.version, npm: npm.version }),
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'ENOSPC':
|
||||
summary.push(['nospc', er.message])
|
||||
detail.push(['nospc', [
|
||||
'There appears to be insufficient space on your system to finish.',
|
||||
'Clear up some disk space and try again.',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EROFS':
|
||||
summary.push(['rofs', er.message])
|
||||
detail.push(['rofs', [
|
||||
`Often virtualized file systems, or other file systems that don't support symlinks, give this error.`,
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'ENOENT':
|
||||
summary.push(['enoent', er.message])
|
||||
detail.push(['enoent', [
|
||||
'This is related to npm not being able to find a file.',
|
||||
er.file ? `\nCheck if the file '${er.file}' is present.` : '',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
case 'EMISSINGARG':
|
||||
case 'EUNKNOWNTYPE':
|
||||
case 'EINVALIDTYPE':
|
||||
case 'ETOOMANYARGS':
|
||||
summary.push(['typeerror', er.stack])
|
||||
detail.push(['typeerror', [
|
||||
'This is an error with npm itself. Please report this error at:',
|
||||
'https://github.com/npm/cli/issues',
|
||||
].join('\n')])
|
||||
break
|
||||
|
||||
default:
|
||||
summary.push(['', er.message || er])
|
||||
if (er.cause) {
|
||||
detail.push(['cause', er.cause.message])
|
||||
}
|
||||
if (er.signal) {
|
||||
detail.push(['signal', er.signal])
|
||||
}
|
||||
if (er.cmd && Array.isArray(er.args)) {
|
||||
detail.push(['command', ...[er.cmd, ...er.args.map(replaceInfo)]])
|
||||
}
|
||||
if (er.stdout) {
|
||||
detail.push(['', er.stdout.trim()])
|
||||
}
|
||||
if (er.stderr) {
|
||||
detail.push(['', er.stderr.trim()])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
detail,
|
||||
files,
|
||||
json,
|
||||
}
|
||||
}
|
||||
|
||||
const getExitCodeFromError = (err) => {
|
||||
if (typeof err?.errno === 'number') {
|
||||
return err.errno
|
||||
} else if (typeof err?.code === 'number') {
|
||||
return err.code
|
||||
}
|
||||
}
|
||||
|
||||
const getError = (err, { npm, command, pkg }) => {
|
||||
// if we got a command that just shells out to something else, then it will presumably print its own errors and exit with a proper status code if there's a problem.
|
||||
// If we got an error with a code=0, then... something else went wrong along the way, so maybe an npm problem?
|
||||
if (command?.constructor?.isShellout && typeof err.code === 'number' && err.code) {
|
||||
return {
|
||||
exitCode: err.code,
|
||||
suppressError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: we should stop throwing strings
|
||||
if (typeof err === 'string') {
|
||||
return {
|
||||
exitCode: 1,
|
||||
suppressError: true,
|
||||
summary: [['', err]],
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: we should stop throwing other non-errors
|
||||
if (!(err instanceof Error)) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
suppressError: true,
|
||||
summary: [['weird error', err]],
|
||||
}
|
||||
}
|
||||
|
||||
if (err.code === 'EUNKNOWNCOMMAND') {
|
||||
const suggestions = require('./did-you-mean.js')(pkg, err.command)
|
||||
return {
|
||||
exitCode: 1,
|
||||
suppressError: true,
|
||||
standard: [
|
||||
`Unknown command: "${err.command}"`,
|
||||
suggestions,
|
||||
'To see a list of supported npm commands, run:',
|
||||
' npm help',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Anything after this is not suppressed and gets more logged information
|
||||
|
||||
// add a code to the error if it doesnt have one and mutate some properties so they have redacted information
|
||||
err.code ??= err.message.match(/^(?:Error: )?(E[A-Z]+)/)?.[1]
|
||||
// this mutates the error and redacts stack/message
|
||||
const { summary, detail, files, json } = errorMessage(err, npm)
|
||||
|
||||
return {
|
||||
err,
|
||||
code: err.code,
|
||||
exitCode: getExitCodeFromError(err) || 1,
|
||||
suppressError: false,
|
||||
summary,
|
||||
detail,
|
||||
files,
|
||||
json,
|
||||
verbose: ['type', 'stack', 'statusCode', 'pkgid']
|
||||
.filter(k => err[k])
|
||||
.map(k => [k, replaceInfo(err[k])]),
|
||||
error: ['code', 'syscall', 'file', 'path', 'dest', 'errno']
|
||||
.filter(k => err[k])
|
||||
.map(k => [k, err[k]]),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getExitCodeFromError,
|
||||
errorMessage,
|
||||
getError,
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
const { relative } = require('node:path')
|
||||
|
||||
const explainNode = (node, depth, chalk, seen = new Set()) =>
|
||||
printNode(node, chalk) +
|
||||
explainDependents(node, depth, chalk, seen) +
|
||||
explainLinksIn(node, depth, chalk, seen)
|
||||
|
||||
const colorType = (type, chalk) => {
|
||||
const style = type === 'extraneous' ? chalk.red
|
||||
: type === 'dev' ? chalk.blue
|
||||
: type === 'optional' ? chalk.magenta
|
||||
: type === 'peer' ? chalk.magentaBright
|
||||
: type === 'bundled' ? chalk.underline.cyan
|
||||
: type === 'workspace' ? chalk.blueBright
|
||||
: type === 'overridden' ? chalk.dim
|
||||
: /* istanbul ignore next */ s => s
|
||||
return style(type)
|
||||
}
|
||||
|
||||
const printNode = (node, chalk) => {
|
||||
const extra = []
|
||||
|
||||
for (const meta of ['extraneous', 'dev', 'optional', 'peer', 'bundled', 'overridden']) {
|
||||
if (node[meta]) {
|
||||
extra.push(` ${colorType(meta, chalk)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const pkgid = node.isWorkspace
|
||||
? chalk.blueBright(`${node.name}@${node.version}`)
|
||||
: `${node.name}@${node.version}`
|
||||
|
||||
return `${pkgid}${extra.join('')}` +
|
||||
(node.location ? chalk.dim(`\n${node.location}`) : '')
|
||||
}
|
||||
|
||||
const explainLinksIn = ({ linksIn }, depth, chalk, seen) => {
|
||||
if (!linksIn || !linksIn.length || depth <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const messages = linksIn.map(link => explainNode(link, depth - 1, chalk, seen))
|
||||
const str = '\n' + messages.join('\n')
|
||||
return str.split('\n').join('\n ')
|
||||
}
|
||||
|
||||
const explainDependents = ({ dependents }, depth, chalk, seen) => {
|
||||
if (!dependents || !dependents.length || depth <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const max = Math.ceil(depth / 2)
|
||||
const messages = dependents.slice(0, max)
|
||||
.map(edge => explainEdge(edge, depth, chalk, seen))
|
||||
|
||||
// show just the names of the first 5 deps that overflowed the list
|
||||
if (dependents.length > max) {
|
||||
let len = 0
|
||||
const maxLen = 50
|
||||
const showNames = []
|
||||
for (let i = max; i < dependents.length; i++) {
|
||||
const { from: { name: depName = 'the root project' } } = dependents[i]
|
||||
len += depName.length
|
||||
if (len >= maxLen && i < dependents.length - 1) {
|
||||
showNames.push('...')
|
||||
break
|
||||
}
|
||||
showNames.push(depName)
|
||||
}
|
||||
const show = `(${showNames.join(', ')})`
|
||||
messages.push(`${dependents.length - max} more ${show}`)
|
||||
}
|
||||
|
||||
const str = '\n' + messages.join('\n')
|
||||
return str.split('\n').join('\n ')
|
||||
}
|
||||
|
||||
const explainEdge = (
|
||||
{ name, type, bundled, from, spec, rawSpec, overridden },
|
||||
depth, chalk, seen = new Set()
|
||||
) => {
|
||||
let dep = type === 'workspace'
|
||||
? chalk.bold(relative(from.location, spec.slice('file:'.length)))
|
||||
: `${name}@"${spec}"`
|
||||
if (overridden) {
|
||||
dep = `${colorType('overridden', chalk)} ${dep} (was "${rawSpec}")`
|
||||
}
|
||||
|
||||
const fromMsg = ` from ${explainFrom(from, depth, chalk, seen)}`
|
||||
|
||||
return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
|
||||
(bundled ? `${colorType('bundled', chalk)} ` : '') +
|
||||
`${dep}${fromMsg}`
|
||||
}
|
||||
|
||||
const explainFrom = (from, depth, chalk, seen) => {
|
||||
if (!from.name && !from.version) {
|
||||
return 'the root project'
|
||||
}
|
||||
|
||||
// Prevent infinite recursion from cycles in the dependency graph (e.g. linked strategy store nodes). Use stack-based tracking so diamond dependencies (same node reached via different paths) are still explained, but recursive cycles are broken.
|
||||
const nodeId = `${from.name}@${from.version}:${from.location}`
|
||||
if (seen.has(nodeId)) {
|
||||
return printNode(from, chalk)
|
||||
}
|
||||
seen.add(nodeId)
|
||||
|
||||
const result = printNode(from, chalk) +
|
||||
explainDependents(from, depth - 1, chalk, seen) +
|
||||
explainLinksIn(from, depth - 1, chalk, seen)
|
||||
|
||||
seen.delete(nodeId)
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { explainNode, printNode, explainEdge }
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// this is called when an ERESOLVE error is caught in the exit-handler, or when there's a log.warn('eresolve', msg, explanation), to turn it into a human-intelligible explanation of what's wrong and how to fix.
|
||||
const { explainEdge, explainNode, printNode } = require('./explain-dep.js')
|
||||
|
||||
// expl is an explanation object that comes from Arborist.
|
||||
// Depth is how far we want to want to descend into the object making a report.
|
||||
// The full report (ie, depth=Infinity) is always written to the cache folder at ${cache}/eresolve-report.txt along with full json.
|
||||
const explain = (expl, chalk, depth) => {
|
||||
const { edge, dep, current, peerConflict, currentEdge } = expl
|
||||
|
||||
const out = []
|
||||
const whileInstalling = dep && dep.whileInstalling ||
|
||||
current && current.whileInstalling ||
|
||||
edge && edge.from && edge.from.whileInstalling
|
||||
if (whileInstalling) {
|
||||
out.push('While resolving: ' + printNode(whileInstalling, chalk))
|
||||
}
|
||||
|
||||
// it "should" be impossible for an ERESOLVE explanation to lack both current and currentEdge, but better to have a less helpful error than a crashing failure.
|
||||
if (current) {
|
||||
out.push('Found: ' + explainNode(current, depth, chalk))
|
||||
} else if (peerConflict && peerConflict.current) {
|
||||
out.push('Found: ' + explainNode(peerConflict.current, depth, chalk))
|
||||
} else if (currentEdge) {
|
||||
out.push('Found: ' + explainEdge(currentEdge, depth, chalk))
|
||||
} else /* istanbul ignore else - should always have one */ if (edge) {
|
||||
out.push('Found: ' + explainEdge(edge, depth, chalk))
|
||||
}
|
||||
|
||||
out.push('\nCould not resolve dependency:\n' +
|
||||
explainEdge(edge, depth, chalk))
|
||||
|
||||
if (peerConflict) {
|
||||
const heading = '\nConflicting peer dependency:'
|
||||
const pc = explainNode(peerConflict.peer, depth, chalk)
|
||||
out.push(heading + ' ' + pc)
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
// generate a full verbose report and tell the user how to fix it
|
||||
const report = (expl, chalk, noColorChalk) => {
|
||||
const flags = [
|
||||
expl.strictPeerDeps ? '--no-strict-peer-deps' : '',
|
||||
'--force',
|
||||
'--legacy-peer-deps',
|
||||
].filter(Boolean)
|
||||
|
||||
const or = (arr) => arr.length <= 2
|
||||
? arr.join(' or ') :
|
||||
arr.map((v, i, l) => i + 1 === l.length ? `or ${v}` : v).join(', ')
|
||||
|
||||
const fix = `Fix the upstream dependency conflict, or retry this command with ${or(flags)} to accept an incorrect (and potentially broken) dependency resolution.`
|
||||
|
||||
return {
|
||||
explanation: `${explain(expl, chalk, 4)}\n\n${fix}`,
|
||||
file: `# npm resolution error report\n\n${explain(expl, noColorChalk, Infinity)}\n\n${fix}`,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
explain,
|
||||
report,
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// Convert bytes to printable output, for file reporting in tarballs
|
||||
// Only supports up to GB because that's way larger than anything the registry supports anyways.
|
||||
|
||||
const formatBytes = (bytes, space = true) => {
|
||||
let spacer = ''
|
||||
if (space) {
|
||||
spacer = ' '
|
||||
}
|
||||
|
||||
if (bytes < 1000) {
|
||||
// B
|
||||
return `${bytes}${spacer}B`
|
||||
}
|
||||
|
||||
if (bytes < 999950) {
|
||||
// kB
|
||||
return `${(bytes / 1000).toFixed(1)}${spacer}kB`
|
||||
}
|
||||
|
||||
if (bytes < 999950000) {
|
||||
// MB
|
||||
return `${(bytes / 1000000).toFixed(1)}${spacer}MB`
|
||||
}
|
||||
|
||||
// GB
|
||||
return `${(bytes / 1000000000).toFixed(1)}${spacer}GB`
|
||||
}
|
||||
|
||||
module.exports = formatBytes
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
const { stripVTControlCharacters: strip } = require('node:util')
|
||||
const { Minipass } = require('minipass')
|
||||
|
||||
// This module consumes package data in the following format:
|
||||
//
|
||||
// {
|
||||
// name: String,
|
||||
// description: String,
|
||||
// maintainers: [{ username: String, email: String }],
|
||||
// keywords: String | [String],
|
||||
// version: String,
|
||||
// date: Date // can be null,
|
||||
// }
|
||||
//
|
||||
// The returned stream will format this package data into a byte stream of formatted, displayable output.
|
||||
|
||||
function filter (data, exclude) {
|
||||
const words = [data.name]
|
||||
.concat(data.maintainers.map(m => m.username))
|
||||
.concat(data.keywords || [])
|
||||
.map(f => f?.trim?.())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
if (exclude.find(pattern => {
|
||||
// Treats both /foo and /foo/ as regex searches
|
||||
if (pattern.startsWith('/')) {
|
||||
if (pattern.endsWith('/')) {
|
||||
pattern = pattern.slice(0, -1)
|
||||
}
|
||||
return words.match(new RegExp(pattern.slice(1)))
|
||||
}
|
||||
return words.includes(pattern)
|
||||
})) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = (opts) => {
|
||||
return opts.json ? new JSONOutputStream(opts) : new TextOutputStream(opts)
|
||||
}
|
||||
|
||||
class JSONOutputStream extends Minipass {
|
||||
#didFirst = false
|
||||
#exclude
|
||||
|
||||
constructor (opts) {
|
||||
super()
|
||||
this.#exclude = opts.exclude
|
||||
}
|
||||
|
||||
write (obj) {
|
||||
if (!filter(obj, this.#exclude)) {
|
||||
return
|
||||
}
|
||||
if (!this.#didFirst) {
|
||||
super.write('[\n')
|
||||
this.#didFirst = true
|
||||
} else {
|
||||
super.write('\n,\n')
|
||||
}
|
||||
|
||||
return super.write(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
end () {
|
||||
super.write(this.#didFirst ? ']\n' : '\n[]\n')
|
||||
super.end()
|
||||
}
|
||||
}
|
||||
|
||||
class TextOutputStream extends Minipass {
|
||||
#args
|
||||
#chalk
|
||||
#exclude
|
||||
#parseable
|
||||
|
||||
constructor (opts) {
|
||||
super()
|
||||
// Consider a search for "cowboys" and "boy".
|
||||
// If we highlight "boys" first the "cowboys" string will no longer string match because of the ansi highlighting added to "boys".
|
||||
// If we highlight "boy" second then the ansi reset at the end will make the highlighting only on "cowboy" with a normal "s".
|
||||
// Neither is perfect but at least the first option doesn't do partial highlighting. So, we sort strings smaller to larger
|
||||
this.#args = opts.args
|
||||
.map(s => s.toLowerCase())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.length - b.length)
|
||||
this.#chalk = opts.npm.chalk
|
||||
this.#exclude = opts.exclude
|
||||
this.#parseable = opts.parseable
|
||||
}
|
||||
|
||||
write (data) {
|
||||
if (!filter(data, this.#exclude)) {
|
||||
return
|
||||
}
|
||||
// Normalize
|
||||
const pkg = {
|
||||
authors: data.maintainers.map((m) => `${strip(m.username)}`).join(' '),
|
||||
publisher: strip(data.publisher?.username || ''),
|
||||
date: data.date ? data.date.toISOString().slice(0, 10) : 'prehistoric',
|
||||
description: strip(data.description ?? ''),
|
||||
keywords: [],
|
||||
name: strip(data.name),
|
||||
version: data.version,
|
||||
}
|
||||
if (Array.isArray(data.keywords)) {
|
||||
pkg.keywords = data.keywords.map(strip)
|
||||
} else if (typeof data.keywords === 'string') {
|
||||
pkg.keywords = strip(data.keywords.replace(/[,\s]+/, ' ')).split(' ')
|
||||
}
|
||||
|
||||
let output
|
||||
if (this.#parseable) {
|
||||
output = [pkg.name, pkg.description, pkg.author, pkg.date, pkg.version, pkg.keywords]
|
||||
.filter(Boolean)
|
||||
.map(col => ('' + col).replace(/\t/g, ' ')).join('\t')
|
||||
return super.write(output)
|
||||
}
|
||||
|
||||
const keywords = pkg.keywords.map(k => {
|
||||
if (this.#args.includes(k)) {
|
||||
return this.#chalk.cyan(k)
|
||||
} else {
|
||||
return k
|
||||
}
|
||||
}).join(' ')
|
||||
|
||||
const description = this.#highlight(pkg.description)
|
||||
let name
|
||||
if (this.#args.includes(pkg.name)) {
|
||||
name = this.#chalk.cyan(pkg.name)
|
||||
} else {
|
||||
name = this.#highlight(pkg.name)
|
||||
name = this.#chalk.blue(name)
|
||||
}
|
||||
|
||||
if (description.length) {
|
||||
output = `${name}\n${description}\n`
|
||||
} else {
|
||||
output = `${name}\n`
|
||||
}
|
||||
if (pkg.publisher) {
|
||||
output += `Version ${this.#chalk.blue(pkg.version)} published ${this.#chalk.blue(pkg.date)} by ${this.#chalk.blue(pkg.publisher)}\n`
|
||||
} else {
|
||||
output += `Version ${this.#chalk.blue(pkg.version)} published ${this.#chalk.blue(pkg.date)} by ${this.#chalk.yellow('???')}\n`
|
||||
}
|
||||
output += `Maintainers: ${pkg.authors}\n`
|
||||
if (keywords) {
|
||||
output += `Keywords: ${keywords}\n`
|
||||
}
|
||||
output += `${this.#chalk.blue(`https://npm.im/${pkg.name}`)}\n`
|
||||
return super.write(output)
|
||||
}
|
||||
|
||||
#highlight (input) {
|
||||
let output = input
|
||||
for (const arg of this.#args) {
|
||||
let i = output.toLowerCase().indexOf(arg)
|
||||
while (i > -1) {
|
||||
const highlit = this.#chalk.cyan(output.slice(i, i + arg.length))
|
||||
output = [
|
||||
output.slice(0, i),
|
||||
highlit,
|
||||
output.slice(i + arg.length),
|
||||
].join('')
|
||||
i = output.toLowerCase().indexOf(arg, i + highlit.length)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// All logging goes through here, both to console and log files
|
||||
|
||||
const { formatWithOptions: baseFormatWithOptions } = require('node:util')
|
||||
const { redactLog } = require('@npmcli/redact')
|
||||
|
||||
// These are most assuredly not a mistake
|
||||
// https://eslint.org/docs/latest/rules/no-control-regex
|
||||
// \x00 through \x1f, \x7f through \x9f, not including \x09 \x0a \x0b \x0d
|
||||
/* eslint-disable-next-line no-control-regex */
|
||||
const HAS_C01 = /[\x00-\x08\x0c\x0e-\x1f\x7f-\x9f]/
|
||||
|
||||
// Allows everything up to '[38;5;255m' in 8 bit notation
|
||||
const ALLOWED_SGR = /^\[[0-9;]{0,8}m/
|
||||
|
||||
// '[38;5;255m'.length
|
||||
const SGR_MAX_LEN = 10
|
||||
|
||||
// Strips all ANSI C0 and C1 control characters (except for SGR up to 8 bit)
|
||||
function STRIP_C01 (str) {
|
||||
if (!HAS_C01.test(str)) {
|
||||
return str
|
||||
}
|
||||
let result = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str[i]
|
||||
const code = char.charCodeAt(0)
|
||||
if (!HAS_C01.test(char)) {
|
||||
// Most characters are in this set so continue early if we can
|
||||
result = `${result}${char}`
|
||||
} else if (code === 27 && ALLOWED_SGR.test(str.slice(i + 1, i + SGR_MAX_LEN + 1))) {
|
||||
// \x1b with allowed SGR
|
||||
result = `${result}\x1b`
|
||||
} else if (code <= 31) {
|
||||
// escape all other C0 control characters besides \x7f
|
||||
result = `${result}^${String.fromCharCode(code + 64)}`
|
||||
} else {
|
||||
// hasC01 ensures this is now a C1 control character or \x7f
|
||||
result = `${result}^${String.fromCharCode(code - 64)}`
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const formatWithOptions = ({ prefix: prefixes = [], eol = '\n', redact = true, ...options }, ...args) => {
|
||||
const prefix = prefixes.filter(p => p != null).join(' ')
|
||||
let formatted = STRIP_C01(baseFormatWithOptions(options, ...args))
|
||||
if (redact) {
|
||||
formatted = redactLog(formatted)
|
||||
}
|
||||
// Splitting could be changed to only `\n` once we are sure we only emit unix newlines.
|
||||
// The eol param to this function will put the correct newlines in place for the returned string.
|
||||
const lines = formatted.split(/\r?\n/)
|
||||
return lines.reduce((acc, l) => `${acc}${prefix}${prefix && l ? ' ' : ''}${l}${eol}`, '')
|
||||
}
|
||||
|
||||
module.exports = { formatWithOptions }
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
const npmFetch = require('npm-registry-fetch')
|
||||
|
||||
module.exports = async (npm, opts) => {
|
||||
const { registry } = opts
|
||||
|
||||
// First, check if we have a user/pass-based auth
|
||||
const creds = npm.config.getCredentialsByURI(registry)
|
||||
if (creds.username) {
|
||||
return creds.username
|
||||
}
|
||||
|
||||
// No username, but we have other credentials; fetch the username from registry
|
||||
if (creds.token || creds.certfile && creds.keyfile) {
|
||||
const registryData = await npmFetch.json('/-/whoami', { ...opts })
|
||||
if (typeof registryData?.username === 'string') {
|
||||
return registryData.username
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, even if they have a credentials object, it doesn't have a valid token.
|
||||
throw Object.assign(
|
||||
new Error('This command requires you to be logged in.'),
|
||||
{ code: 'ENEEDAUTH' }
|
||||
)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
const { resolve, relative } = require('node:path')
|
||||
const mapWorkspaces = require('@npmcli/map-workspaces')
|
||||
const { minimatch } = require('minimatch')
|
||||
const pkgJson = require('@npmcli/package-json')
|
||||
|
||||
// minimatch wants forward slashes only for glob patterns
|
||||
const globify = pattern => pattern.split('\\').join('/')
|
||||
|
||||
// Returns an Map of paths to workspaces indexed by workspace name
|
||||
// { foo => '/path/to/foo' }
|
||||
const getWorkspaces = async (filters, { path, includeWorkspaceRoot, relativeFrom }) => {
|
||||
// TODO we need a better error to be bubbled up here if this call fails
|
||||
const { content: pkg } = await pkgJson.normalize(path)
|
||||
const workspaces = await mapWorkspaces({ cwd: path, pkg })
|
||||
let res = new Map()
|
||||
if (includeWorkspaceRoot) {
|
||||
res.set(pkg.name, path)
|
||||
}
|
||||
|
||||
if (!filters.length) {
|
||||
res = new Map([...res, ...workspaces])
|
||||
}
|
||||
|
||||
for (const filterArg of filters) {
|
||||
for (const [workspaceName, workspacePath] of workspaces.entries()) {
|
||||
let relativePath = relative(relativeFrom, workspacePath)
|
||||
if (filterArg.startsWith('./')) {
|
||||
relativePath = `./${relativePath}`
|
||||
}
|
||||
const relativeFilter = relative(path, filterArg)
|
||||
if (filterArg === workspaceName
|
||||
|| resolve(relativeFrom, filterArg) === workspacePath
|
||||
|| minimatch(relativePath, `${globify(relativeFilter)}/*`)
|
||||
|| minimatch(relativePath, `${globify(filterArg)}/*`)
|
||||
) {
|
||||
res.set(workspaceName, workspacePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.size) {
|
||||
let msg = '!'
|
||||
if (filters.length) {
|
||||
msg = `:\n ${filters.reduce(
|
||||
(acc, filterArg) => `${acc} --workspace=${filterArg}`, '')}`
|
||||
}
|
||||
|
||||
throw new Error(`No workspaces found${msg}`)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
module.exports = getWorkspaces
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
const { resolve } = require('node:path')
|
||||
const localeCompare = require('@isaacs/string-locale-compare')('en')
|
||||
|
||||
const installedDeep = async (npm) => {
|
||||
const Arborist = require('@npmcli/arborist')
|
||||
const {
|
||||
depth,
|
||||
global,
|
||||
prefix,
|
||||
workspacesEnabled,
|
||||
} = npm.flatOptions
|
||||
|
||||
const getValues = (tree) =>
|
||||
[...tree.inventory.values()]
|
||||
.filter(i => i.location !== '' && !i.isRoot)
|
||||
.map(i => {
|
||||
return i
|
||||
})
|
||||
.filter(i => (i.depth - 1) <= depth)
|
||||
.sort((a, b) => (a.depth - b.depth) || localeCompare(a.name, b.name))
|
||||
|
||||
const res = new Set()
|
||||
const gArb = new Arborist({
|
||||
global: true,
|
||||
path: resolve(npm.globalDir, '..'),
|
||||
workspacesEnabled,
|
||||
})
|
||||
const gTree = await gArb.loadActual({ global: true })
|
||||
|
||||
for (const node of getValues(gTree)) {
|
||||
res.add(global ? node.name : [node.name, '-g'])
|
||||
}
|
||||
|
||||
if (!global) {
|
||||
const arb = new Arborist({ global: false, path: prefix, workspacesEnabled })
|
||||
const tree = await arb.loadActual()
|
||||
for (const node of getValues(tree)) {
|
||||
res.add(node.name)
|
||||
}
|
||||
}
|
||||
|
||||
return [...res]
|
||||
}
|
||||
|
||||
module.exports = installedDeep
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
const { readdirScoped } = require('@npmcli/fs')
|
||||
|
||||
const installedShallow = async (npm, opts) => {
|
||||
const names = async global => {
|
||||
const paths = await readdirScoped(global ? npm.globalDir : npm.localDir)
|
||||
return paths.map(p => p.replace(/\\/g, '/'))
|
||||
}
|
||||
const { conf: { argv: { remain } } } = opts
|
||||
if (remain.length > 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { global } = npm.flatOptions
|
||||
const locals = global ? [] : await names(false)
|
||||
const globals = (await names(true)).map(n => global ? n : `${n} -g`)
|
||||
return [...locals, ...globals]
|
||||
}
|
||||
|
||||
module.exports = installedShallow
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
const isWindowsShell = (process.platform === 'win32') &&
|
||||
!/^MINGW(32|64)$/.test(process.env.MSYSTEM) && process.env.TERM !== 'cygwin'
|
||||
|
||||
exports.isWindowsShell = isWindowsShell
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
const { output, META } = require('proc-log')
|
||||
|
||||
const defaultPredicate = (key, value, chalk) => {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
return chalk.green(value)
|
||||
}
|
||||
|
||||
function logObject (values, { chalk, json, predicate = defaultPredicate }) {
|
||||
if (json) {
|
||||
output.standard(JSON.stringify(values, null, 2), { [META]: true, redact: false })
|
||||
return
|
||||
}
|
||||
|
||||
const lines = []
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const formatted = predicate(key, value, chalk)
|
||||
if (formatted !== null) {
|
||||
lines.push(`${chalk.cyan(key)}: ${formatted}`)
|
||||
}
|
||||
}
|
||||
if (lines.length) {
|
||||
output.standard(lines.join('\n'), { [META]: true, redact: false })
|
||||
}
|
||||
}
|
||||
|
||||
function logStageItem (item, { chalk }) {
|
||||
const { id, packageName, version, tag, createdAt, actor, actorType, shasum, ...rest } = item
|
||||
logObject({
|
||||
id,
|
||||
'package name': packageName,
|
||||
version,
|
||||
tag,
|
||||
'date staged': createdAt,
|
||||
'staged by': actorType ? `${actor} (${actorType})` : actor,
|
||||
shasum,
|
||||
...rest,
|
||||
}, { chalk })
|
||||
}
|
||||
|
||||
module.exports = { logObject, logStageItem, defaultPredicate }
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
const os = require('node:os')
|
||||
const { join, dirname, basename } = require('node:path')
|
||||
const fsMiniPass = require('fs-minipass')
|
||||
const fs = require('node:fs/promises')
|
||||
const { log } = require('proc-log')
|
||||
const { formatWithOptions } = require('./format')
|
||||
|
||||
const padZero = (n, length) => n.toString().padStart(length.toString().length, '0')
|
||||
|
||||
class LogFiles {
|
||||
// Default to an array so we can buffer
|
||||
// initial writes before we know the cache location
|
||||
#logStream = []
|
||||
|
||||
// We cap log files at a certain number of log events per file.
|
||||
// Note that each log event can write more than one line to the file.
|
||||
// Then we rotate log files once this number of events is reached
|
||||
#MAX_LOGS_PER_FILE = null
|
||||
|
||||
// Now that we write logs continuously we need to have a backstop here for infinite loops that still log.
|
||||
// This is also partially handled by the config.get('max-files') option, but this is a failsafe to prevent runaway log file creation
|
||||
#MAX_FILES_PER_PROCESS = null
|
||||
|
||||
#fileLogCount = 0
|
||||
#totalLogCount = 0
|
||||
#path = null
|
||||
#logsMax = null
|
||||
#files = []
|
||||
#timing = false
|
||||
|
||||
constructor ({
|
||||
maxLogsPerFile = 50_000,
|
||||
maxFilesPerProcess = 5,
|
||||
} = {}) {
|
||||
this.#MAX_LOGS_PER_FILE = maxLogsPerFile
|
||||
this.#MAX_FILES_PER_PROCESS = maxFilesPerProcess
|
||||
this.on()
|
||||
}
|
||||
|
||||
on () {
|
||||
process.on('log', this.#logHandler)
|
||||
}
|
||||
|
||||
off () {
|
||||
process.off('log', this.#logHandler)
|
||||
this.#endStream()
|
||||
}
|
||||
|
||||
load ({ command, path, logsMax = Infinity, timing } = {}) {
|
||||
if (['completion'].includes(command)) {
|
||||
return
|
||||
}
|
||||
|
||||
// dir is user configurable and is required to exist so this can error if the dir is missing or not configured correctly
|
||||
this.#path = path
|
||||
this.#logsMax = logsMax
|
||||
this.#timing = timing
|
||||
|
||||
// Log stream has already ended
|
||||
if (!this.#logStream) {
|
||||
return
|
||||
}
|
||||
|
||||
log.verbose('logfile', `logs-max:${logsMax} dir:${this.#path}`)
|
||||
|
||||
// Write the contents of our array buffer to our new file stream and set that as the new log logstream for future writes
|
||||
// if logs max is 0 then the user does not want a log file
|
||||
if (this.#logsMax > 0) {
|
||||
const initialFile = this.#openLogFile()
|
||||
if (initialFile) {
|
||||
for (const item of this.#logStream) {
|
||||
const formatted = this.#formatLogItem(...item)
|
||||
if (formatted !== null) {
|
||||
initialFile.write(formatted)
|
||||
}
|
||||
}
|
||||
this.#logStream = initialFile
|
||||
}
|
||||
}
|
||||
|
||||
log.verbose('logfile', this.files[0] || 'no logfile created')
|
||||
|
||||
// Kickoff cleaning process, even if we aren't writing a logfile.
|
||||
// This is async but it will always ignore the current logfile
|
||||
// Return the result so it can be awaited in tests
|
||||
return this.#cleanLogs()
|
||||
}
|
||||
|
||||
get files () {
|
||||
return this.#files
|
||||
}
|
||||
|
||||
get #isBuffered () {
|
||||
return Array.isArray(this.#logStream)
|
||||
}
|
||||
|
||||
#endStream (output) {
|
||||
if (this.#logStream && !this.#isBuffered) {
|
||||
this.#logStream.end(output)
|
||||
this.#logStream = null
|
||||
}
|
||||
}
|
||||
|
||||
#logHandler = (level, ...args) => {
|
||||
// Ignore pause and resume events since we write everything to the log file
|
||||
if (level === 'pause' || level === 'resume') {
|
||||
return
|
||||
}
|
||||
|
||||
// If the stream is ended then do nothing
|
||||
if (!this.#logStream) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.#isBuffered) {
|
||||
// Cant do anything but buffer the output if we don't have a file stream yet
|
||||
this.#logStream.push([level, ...args])
|
||||
return
|
||||
}
|
||||
|
||||
const logOutput = this.#formatLogItem(level, ...args)
|
||||
if (logOutput === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Open a new log file if we've written too many logs to this one
|
||||
if (this.#fileLogCount >= this.#MAX_LOGS_PER_FILE) {
|
||||
// Write last chunk to the file and close it
|
||||
this.#endStream(logOutput)
|
||||
if (this.#files.length >= this.#MAX_FILES_PER_PROCESS) {
|
||||
// but if its way too many then we just stop listening
|
||||
this.off()
|
||||
} else {
|
||||
// otherwise we are ready for a new file for the next event
|
||||
this.#logStream = this.#openLogFile()
|
||||
}
|
||||
} else {
|
||||
this.#logStream.write(logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
#formatLogItem (level, title, ...args) {
|
||||
// Only right timing logs to logfile if explicitly requests
|
||||
if (level === log.KEYS.timing && !this.#timing) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.#fileLogCount += 1
|
||||
const prefix = [this.#totalLogCount++, level, title || null]
|
||||
return formatWithOptions({ prefix, eol: os.EOL, colors: false }, ...args)
|
||||
}
|
||||
|
||||
#getLogFilePath (count = '') {
|
||||
return `${this.#path}debug-${count}.log`
|
||||
}
|
||||
|
||||
#openLogFile () {
|
||||
// Count in filename will be 0 indexed
|
||||
const count = this.#files.length
|
||||
|
||||
try {
|
||||
// Pad with zeros so that our log files are always sorted properly
|
||||
// We never want to write files ending in `-9.log` and `-10.log` because log file cleaning is done by deleting the oldest.
|
||||
// So in this example `-10.log` would be deleted next.
|
||||
const f = this.#getLogFilePath(padZero(count, this.#MAX_FILES_PER_PROCESS))
|
||||
// Some effort was made to make the async, but we need to write logs during process.on('exit') which has to be synchronous.
|
||||
// So in order to never drop log messages, it is easiest to make it sync all the time and this was measured to be about 1.5% slower for 40k lines of output
|
||||
const logStream = new fsMiniPass.WriteStreamSync(f, { flags: 'a' })
|
||||
if (count > 0) {
|
||||
// Reset file log count if we are opening after our first file
|
||||
this.#fileLogCount = 0
|
||||
}
|
||||
this.#files.push(logStream.path)
|
||||
return logStream
|
||||
} catch (e) {
|
||||
// If the user has a readonly logdir then we don't want to warn this on every command so it should be verbose
|
||||
log.verbose('logfile', `could not be created: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async #cleanLogs () {
|
||||
// module to clean out the old log files this is a best-effort attempt.
|
||||
// if a rm fails, we just log a message about it and move on.
|
||||
// We do return a Promise that succeeds when we've tried to delete everything, just for the benefit of testing this function properly.
|
||||
|
||||
try {
|
||||
const logPath = this.#getLogFilePath()
|
||||
const patternFileName = basename(logPath)
|
||||
// tell glob to only match digits
|
||||
.replace(/\d/g, 'd')
|
||||
// Handle the old (prior to 8.2.0) log file names which did not have a counter suffix
|
||||
.replace('-.log', '')
|
||||
|
||||
let files = await fs.readdir(
|
||||
dirname(logPath), {
|
||||
withFileTypes: true,
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
files = files.sort((a, b) => basename(a.name).localeCompare(basename(b.name), 'en'))
|
||||
|
||||
const logFiles = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const genericFileName = file.name.replace(/\d/g, 'd')
|
||||
const filePath = join(dirname(logPath), basename(file.name))
|
||||
|
||||
// Always ignore the currently written files
|
||||
if (
|
||||
genericFileName.includes(patternFileName)
|
||||
&& genericFileName.endsWith('.log')
|
||||
&& !this.#files.includes(filePath)
|
||||
) {
|
||||
logFiles.push(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
const toDelete = logFiles.length - this.#logsMax
|
||||
|
||||
if (toDelete <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
log.silly('logfile', `start cleaning logs, removing ${toDelete} files`)
|
||||
|
||||
for (const file of logFiles.slice(0, toDelete)) {
|
||||
try {
|
||||
await fs.rm(file, { force: true })
|
||||
} catch (e) {
|
||||
log.silly('logfile', 'error removing log file', file, e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Disable cleanup failure warnings when log writing is disabled
|
||||
if (this.#logsMax > 0) {
|
||||
log.verbose('logfile', 'error cleaning log files', e)
|
||||
}
|
||||
} finally {
|
||||
log.silly('logfile', 'done cleaning log files')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LogFiles
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
const { commands } = require('./cmd-list')
|
||||
|
||||
const COL_MAX = 60
|
||||
const COL_MIN = 24
|
||||
const COL_GUTTER = 16
|
||||
const INDENT = 4
|
||||
|
||||
const indent = (repeat = INDENT) => ' '.repeat(repeat)
|
||||
const indentNewline = (repeat) => `\n${indent(repeat)}`
|
||||
|
||||
module.exports = (npm) => {
|
||||
const browser = npm.config.get('viewer') === 'browser' ? ' (in a browser)' : ''
|
||||
const allCommands = npm.config.get('long') ? cmdUsages(npm.constructor) : cmdNames()
|
||||
|
||||
return `npm <command>
|
||||
|
||||
Usage:
|
||||
|
||||
npm install install all the dependencies in your project
|
||||
npm install <foo> add the <foo> dependency to your project
|
||||
npm test run this project's tests
|
||||
npm run <foo> run the script named <foo>
|
||||
npm <command> -h quick help on <command>
|
||||
npm -l display usage info for all commands
|
||||
npm help <term> search for help on <term>${browser}
|
||||
npm help npm more involved overview${browser}
|
||||
|
||||
All commands:
|
||||
${allCommands}
|
||||
|
||||
Specify configs in the ini-formatted file:
|
||||
${indent() + npm.config.get('userconfig')}
|
||||
or on the command line via: npm <command> --key=value
|
||||
|
||||
More configuration info: npm help config
|
||||
Configuration fields: npm help 7 config
|
||||
|
||||
npm@${npm.version} ${npm.npmRoot}`
|
||||
}
|
||||
|
||||
const cmdNames = () => {
|
||||
const out = ['']
|
||||
|
||||
const line = !process.stdout.columns ? COL_MAX
|
||||
: Math.min(COL_MAX, Math.max(process.stdout.columns - COL_GUTTER, COL_MIN))
|
||||
|
||||
let l = 0
|
||||
for (const c of commands) {
|
||||
if (out[l].length + c.length + 2 < line) {
|
||||
out[l] += ', ' + c
|
||||
} else {
|
||||
out[l++] += ','
|
||||
out[l] = c
|
||||
}
|
||||
}
|
||||
|
||||
return indentNewline() + out.join(indentNewline()).slice(2)
|
||||
}
|
||||
|
||||
const cmdUsages = (Npm) => {
|
||||
// return a string of <command>: <usage>
|
||||
let maxLen = 0
|
||||
const set = []
|
||||
for (const c of commands) {
|
||||
set.push([c, Npm.cmd(c).getUsage(null, false).split('\n')])
|
||||
maxLen = Math.max(maxLen, c.length)
|
||||
}
|
||||
|
||||
return set.map(([name, usageLines]) => {
|
||||
const gutter = indent(maxLen - name.length + 1)
|
||||
const usage = usageLines.join(indentNewline(INDENT + maxLen + 1))
|
||||
return indentNewline() + name + gutter + usage
|
||||
}).join('\n')
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
const { log } = require('proc-log')
|
||||
const npmFetch = require('npm-registry-fetch')
|
||||
const ciInfo = require('ci-info')
|
||||
const fetch = require('make-fetch-happen')
|
||||
const npa = require('npm-package-arg')
|
||||
const libaccess = require('libnpmaccess')
|
||||
|
||||
/**
|
||||
* Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
|
||||
*
|
||||
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions, GitLab, and CircleCI.
|
||||
* It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and sets the token in the provided configuration for authentication with the npm registry.
|
||||
*
|
||||
* This function is intended to never throw, as it mutates the state of the `opts` and `config` objects on success.
|
||||
* OIDC is always an optional feature, and the function should not throw if OIDC is not configured by the registry.
|
||||
*
|
||||
* @see https://github.com/watson/ci-info for CI environment detection.
|
||||
* @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect for GitHub Actions OIDC.
|
||||
* @see https://circleci.com/docs/openid-connect-tokens/ for CircleCI OIDC.
|
||||
*/
|
||||
async function oidc ({ packageName, registry, opts, config }) {
|
||||
/*
|
||||
* This code should never run when people try to publish locally on their machines.
|
||||
* It is designed to execute only in Continuous Integration (CI) environments.
|
||||
*/
|
||||
|
||||
try {
|
||||
if (!(
|
||||
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */
|
||||
ciInfo.GITHUB_ACTIONS ||
|
||||
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
|
||||
ciInfo.GITLAB ||
|
||||
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L78 */
|
||||
ciInfo.CIRCLE
|
||||
)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the environment variable `NPM_ID_TOKEN` is set.
|
||||
* In GitLab CI, the ID token is provided via an environment variable,
|
||||
* with `NPM_ID_TOKEN` serving as a predefined default. For consistency,
|
||||
* all supported CI environments are expected to support this variable.
|
||||
* In contrast, GitHub Actions uses a request-based approach to retrieve the ID token.
|
||||
* The presence of this token within GitHub Actions will override the request-based approach.
|
||||
* This variable follows the prefix/suffix convention from sigstore (e.g., `SIGSTORE_ID_TOKEN`).
|
||||
* @see https://docs.sigstore.dev/cosign/signing/overview/
|
||||
*/
|
||||
let idToken = process.env.NPM_ID_TOKEN
|
||||
|
||||
if (!idToken && ciInfo.GITHUB_ACTIONS) {
|
||||
/**
|
||||
* GitHub Actions provides these environment variables:
|
||||
* - `ACTIONS_ID_TOKEN_REQUEST_URL`: The URL to request the ID token.
|
||||
* - `ACTIONS_ID_TOKEN_REQUEST_TOKEN`: The token to authenticate the request.
|
||||
* Only when a workflow has the following permissions:
|
||||
* ```
|
||||
* permissions:
|
||||
* id-token: write
|
||||
* ```
|
||||
* @see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings
|
||||
*/
|
||||
if (!(
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||
)) {
|
||||
log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow')
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The specification for an audience is `npm:registry.npmjs.org`, where "registry.npmjs.org" can be any supported registry.
|
||||
*/
|
||||
const audience = `npm:${new URL(registry).hostname}`
|
||||
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
|
||||
url.searchParams.append('audience', audience)
|
||||
const startTime = Date.now()
|
||||
const response = await fetch(url.href, {
|
||||
retry: opts.retry,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
const elapsedTime = Date.now() - startTime
|
||||
|
||||
log.http(
|
||||
'fetch',
|
||||
`GET ${url.href} ${response.status} ${elapsedTime}ms`
|
||||
)
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!json.value) {
|
||||
log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
idToken = json.value
|
||||
}
|
||||
|
||||
if (!idToken) {
|
||||
log.silly('oidc', 'Skipped because no id_token available')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsedRegistry = new URL(registry)
|
||||
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
|
||||
const authTokenKey = `${regKey}:_authToken`
|
||||
|
||||
const escapedPackageName = npa(packageName).escapedName
|
||||
let response
|
||||
try {
|
||||
response = await npmFetch.json(new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry), {
|
||||
...opts,
|
||||
[authTokenKey]: idToken, // Use the idToken as the auth token for the request
|
||||
method: 'POST',
|
||||
})
|
||||
} catch (error) {
|
||||
log.verbose('oidc', `Failed token exchange request with body message: ${error?.body?.message || 'Unknown error'}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!response?.token) {
|
||||
log.verbose('oidc', 'Failed because token exchange was missing the token in the response body')
|
||||
return undefined
|
||||
}
|
||||
|
||||
/*
|
||||
* The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command, eventually reaching `otplease`.
|
||||
* To ensure the token is accessible during the publishing process, it must be directly attached to the `opts` object.
|
||||
* Additionally, the token is required by the "live" configuration or getters within `config`.
|
||||
*/
|
||||
opts[authTokenKey] = response.token
|
||||
config.set(authTokenKey, response.token, 'user')
|
||||
log.verbose('oidc', `Successfully retrieved and set token`)
|
||||
|
||||
try {
|
||||
const isDefaultProvenance = config.isDefault('provenance')
|
||||
// CircleCI doesn't support provenance yet, so skip the auto-enable logic
|
||||
if (isDefaultProvenance && !ciInfo.CIRCLE) {
|
||||
const [headerB64, payloadB64] = idToken.split('.')
|
||||
if (headerB64 && payloadB64) {
|
||||
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
|
||||
const payload = JSON.parse(payloadJson)
|
||||
if (
|
||||
(ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') ||
|
||||
// only set provenance for gitlab if the repo is public and SIGSTORE_ID_TOKEN is available
|
||||
(ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN)
|
||||
) {
|
||||
const visibility = await libaccess.getVisibility(packageName, opts)
|
||||
if (visibility?.public) {
|
||||
log.verbose('oidc', `Enabling provenance`)
|
||||
opts.provenance = true
|
||||
config.set('provenance', true, 'user')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.verbose('oidc', `Failed to set provenance with message: ${error?.message || 'Unknown error'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
oidc,
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
const { open } = require('@npmcli/promise-spawn')
|
||||
const { output, input, META } = require('proc-log')
|
||||
const { URL } = require('node:url')
|
||||
const readline = require('node:readline/promises')
|
||||
const { once } = require('node:events')
|
||||
|
||||
const assertValidUrl = (url) => {
|
||||
try {
|
||||
if (!/^https?:$/.test(new URL(url).protocol)) {
|
||||
throw new Error()
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Invalid URL: ' + url)
|
||||
}
|
||||
}
|
||||
|
||||
const outputMsg = (json, title, url) => {
|
||||
if (json) {
|
||||
output.buffer({ title, url })
|
||||
} else {
|
||||
// These urls are sometimes specifically login urls so we have to turn off redaction to standard output
|
||||
output.standard(`${title}:\n${url}`, { [META]: true, redact: false })
|
||||
}
|
||||
}
|
||||
|
||||
// attempt to open URL in web-browser, print address otherwise:
|
||||
const openUrl = async (npm, url, title, isFile) => {
|
||||
url = encodeURI(url)
|
||||
const browser = npm.config.get('browser')
|
||||
const json = npm.config.get('json')
|
||||
|
||||
if (browser === false) {
|
||||
outputMsg(json, title, url)
|
||||
return
|
||||
}
|
||||
|
||||
// We pass this in as true from the help command so we know we don't have to check the protocol
|
||||
if (!isFile) {
|
||||
assertValidUrl(url)
|
||||
}
|
||||
|
||||
try {
|
||||
await input.start(() => open(url, {
|
||||
command: browser === true ? null : browser,
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err.code !== 127) {
|
||||
throw err
|
||||
}
|
||||
outputMsg(json, title, url)
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt to open URL in browser if possible
|
||||
const openUrlPrompt = async (npm, url, title, prompt, { signal }) => {
|
||||
const browser = npm.config.get('browser')
|
||||
const json = npm.config.get('json')
|
||||
|
||||
assertValidUrl(url)
|
||||
outputMsg(json, title, url)
|
||||
|
||||
if (browser === false || !process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
return
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
try {
|
||||
await input.read(() => Promise.race([
|
||||
rl.question(prompt, { signal }),
|
||||
once(rl, 'error'),
|
||||
once(rl, 'SIGINT').then(() => {
|
||||
throw new Error('canceled')
|
||||
}),
|
||||
]))
|
||||
rl.close()
|
||||
await openUrl(npm, url, 'Browser unavailable. Please open the URL manually')
|
||||
} catch (err) {
|
||||
rl.close()
|
||||
if (err.name !== 'AbortError') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rearrange arguments and return a function that takes the two arguments returned from the npm-profile methods that take an opener
|
||||
const createOpener = (npm, title, prompt = 'Press ENTER to open in the browser...') =>
|
||||
(url, opts) => openUrlPrompt(npm, url, title, prompt, opts)
|
||||
|
||||
module.exports = {
|
||||
openUrl,
|
||||
openUrlPrompt,
|
||||
createOpener,
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
const { log, output } = require('proc-log')
|
||||
|
||||
const outputError = ({ standard = [], verbose = [], error = [], summary = [], detail = [] }) => {
|
||||
for (const line of standard) {
|
||||
// Each output line is just a single string
|
||||
output.standard(line)
|
||||
}
|
||||
for (const line of verbose) {
|
||||
log.verbose(...line)
|
||||
}
|
||||
for (const line of [...error, ...summary, ...detail]) {
|
||||
log.error(...line)
|
||||
}
|
||||
}
|
||||
|
||||
const jsonError = (error, npm) => {
|
||||
if (error && npm?.loaded && npm?.config.get('json')) {
|
||||
return {
|
||||
code: error.code,
|
||||
summary: (error.summary || []).map(l => l.slice(1).join(' ')).join('\n').trim(),
|
||||
detail: (error.detail || []).map(l => l.slice(1).join(' ')).join('\n').trim(),
|
||||
...error.json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
outputError,
|
||||
jsonError,
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// ping the npm registry used by the ping and doctor commands
|
||||
const npmFetch = require('npm-registry-fetch')
|
||||
module.exports = async (flatOptions) => {
|
||||
const res = await npmFetch('/-/ping', { ...flatOptions, cache: false })
|
||||
return res.json().catch(() => ({}))
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
const util = require('node:util')
|
||||
const _delete = Symbol('delete')
|
||||
const _append = Symbol('append')
|
||||
|
||||
const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/)
|
||||
|
||||
// replaces any occurrence of an empty-brackets (e.g: []) with a special Symbol(append) to represent it
|
||||
// this is going to be useful for the setter method that will push values to the end of the array when finding these
|
||||
const replaceAppendSymbols = str => {
|
||||
const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/)
|
||||
|
||||
if (matchEmptyBracket) {
|
||||
const [, pre, post] = matchEmptyBracket
|
||||
return [...replaceAppendSymbols(pre), _append, post].filter(Boolean)
|
||||
}
|
||||
|
||||
return [str]
|
||||
}
|
||||
|
||||
const parseKeys = key => {
|
||||
const sqBracketItems = new Set()
|
||||
sqBracketItems.add(_append)
|
||||
const parseSqBrackets = str => {
|
||||
const index = sqBracketsMatcher(str)
|
||||
|
||||
// once we find square brackets, we recursively parse all these
|
||||
if (index) {
|
||||
const preSqBracketPortion = index[1]
|
||||
|
||||
// we want to have a `new String` wrapper here in order to differentiate between multiple occurrences of the same string,
|
||||
// e.g: foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } }
|
||||
/* eslint-disable-next-line no-new-wrappers */
|
||||
const foundKey = new String(index[2])
|
||||
const postSqBracketPortion = index[3]
|
||||
|
||||
// we keep track of items found during this step to make sure we don't try to split-separate keys that were defined within square brackets, since the key name itself might contain dots
|
||||
sqBracketItems.add(foundKey)
|
||||
|
||||
// returns an array that contains either dot-separate items (that will be split apart during the next step OR the fully parsed keys read from square brackets
|
||||
// e.g: foo.bar[1.0.0].a.b -> ['foo.bar', '1.0.0', 'a.b']
|
||||
return [
|
||||
...parseSqBrackets(preSqBracketPortion),
|
||||
foundKey,
|
||||
...(postSqBracketPortion ? parseSqBrackets(postSqBracketPortion) : []),
|
||||
]
|
||||
}
|
||||
|
||||
// at the end of parsing, any usage of the special empty-bracket syntax (e.g: foo.array[]) has not yet been parsed
|
||||
// here we'll take care of parsing it and adding a special symbol to represent it in the resulting list of keys
|
||||
return replaceAppendSymbols(str)
|
||||
}
|
||||
|
||||
const res = []
|
||||
// starts by parsing items defined as square brackets
|
||||
// those might be representing properties that have a dot in the name or just array indexes
|
||||
// e.g: foo[1.0.0] or list[0]
|
||||
const sqBracketKeys = parseSqBrackets(key.trim())
|
||||
|
||||
for (const k of sqBracketKeys) {
|
||||
// keys parsed from square brackets should just be added to list of resulting keys as they might have dots as part of the key
|
||||
if (sqBracketItems.has(k)) {
|
||||
res.push(k)
|
||||
} else {
|
||||
// splits the dot-sep property names and add them to the list of keys
|
||||
/* eslint-disable-next-line no-new-wrappers */
|
||||
for (const splitKey of k.split('.')) {
|
||||
res.push(String(splitKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// returns an ordered list of strings in which each entry represents a key in an object defined by the previous entry
|
||||
return res
|
||||
}
|
||||
|
||||
const getter = ({ data, key }, { unwrapSingleItemArrays = true } = {}) => {
|
||||
// keys are a list in which each entry represents the name of a property that should be walked through the object in order to return the final found value
|
||||
const keys = parseKeys(key)
|
||||
let _data = data
|
||||
let label = ''
|
||||
|
||||
for (const k of keys) {
|
||||
// empty-bracket-shortcut-syntax is not supported on getter
|
||||
if (k === _append) {
|
||||
throw Object.assign(new Error('Empty brackets are not valid syntax for retrieving values.'), {
|
||||
code: 'EINVALIDSYNTAX',
|
||||
})
|
||||
}
|
||||
|
||||
// extra logic to take into account printing array, along with its special syntax in which using a dot-sep property name after an array will expand it's results
|
||||
// e.g: arr.name -> arr[0].name=value, arr[1].name=value, ...
|
||||
const maybeIndex = Number(k)
|
||||
if (Array.isArray(_data) && !Number.isInteger(maybeIndex)) {
|
||||
_data = _data.reduce((acc, i, index) => {
|
||||
acc[`${label}[${index}].${k}`] = i[k]
|
||||
return acc
|
||||
}, {})
|
||||
return _data
|
||||
} else {
|
||||
if (!Object.hasOwn(_data, k)) {
|
||||
return undefined
|
||||
}
|
||||
_data = _data[k]
|
||||
}
|
||||
|
||||
label += k
|
||||
}
|
||||
|
||||
// these are some legacy expectations from the old API consumed by lib/view.js
|
||||
if (unwrapSingleItemArrays && Array.isArray(_data) && _data.length <= 1) {
|
||||
_data = _data[0]
|
||||
}
|
||||
|
||||
return {
|
||||
[key]: _data,
|
||||
}
|
||||
}
|
||||
|
||||
const setter = ({ data, key, value, force }) => {
|
||||
// setter goes to recursively transform the provided data obj
|
||||
// setting properties from the list of parsed keys
|
||||
// e.g: ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } }
|
||||
const keys = parseKeys(key)
|
||||
const setKeys = (_data, _key) => {
|
||||
// handles array indexes, converting valid integers to numbers
|
||||
// note that occurrences of Symbol(append) will throw so we just ignore these for now
|
||||
let maybeIndex = Number.NaN
|
||||
try {
|
||||
maybeIndex = Number(_key)
|
||||
} catch {
|
||||
// leave it NaN
|
||||
}
|
||||
if (!Number.isNaN(maybeIndex)) {
|
||||
_key = maybeIndex
|
||||
}
|
||||
|
||||
// creates new array in case key is an index and the array obj is not yet defined
|
||||
const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
|
||||
const dataHasNoItems = !Object.keys(_data).length
|
||||
if (keyIsAnArrayIndex && dataHasNoItems && !Array.isArray(_data)) {
|
||||
_data = []
|
||||
}
|
||||
|
||||
// converting from array to an object is also possible, in case the user is using force mode
|
||||
// we should also convert existing arrays to an empty object if the current _data is an array
|
||||
if (force && Array.isArray(_data) && !keyIsAnArrayIndex) {
|
||||
_data = { ..._data }
|
||||
}
|
||||
|
||||
// the _append key is a special key that is used to represent the empty-bracket notation
|
||||
// e.g: arr[] -> arr[arr.length]
|
||||
if (_key === _append) {
|
||||
if (!Array.isArray(_data)) {
|
||||
throw Object.assign(new Error(`Can't use append syntax in non-Array element`), {
|
||||
code: 'ENOAPPEND',
|
||||
})
|
||||
}
|
||||
_key = _data.length
|
||||
}
|
||||
|
||||
// retrieves the next data object to recursively iterate on
|
||||
// throws if trying to override a literal value or add props to an array
|
||||
const next = () => {
|
||||
const haveContents = !force && _data[_key] != null && value !== _delete
|
||||
const shouldNotOverrideLiteralValue = !(typeof _data[_key] === 'object')
|
||||
// if the next obj to recurse is an array and the next key to be appended to the resulting obj is not an array index, then it should throw since we can't append arbitrary props to arrays
|
||||
const shouldNotAddPropsToArrays =
|
||||
typeof keys[0] !== 'symbol' && Array.isArray(_data[_key]) && Number.isNaN(Number(keys[0]))
|
||||
|
||||
const overrideError = haveContents && shouldNotOverrideLiteralValue
|
||||
if (overrideError) {
|
||||
throw Object.assign(
|
||||
new Error(`Property ${_key} already exists and is not an Array or Object.`),
|
||||
{ code: 'EOVERRIDEVALUE' }
|
||||
)
|
||||
}
|
||||
|
||||
const addPropsToArrayError = haveContents && shouldNotAddPropsToArrays
|
||||
if (addPropsToArrayError) {
|
||||
throw Object.assign(new Error(`Can't add property ${key} to an Array.`), {
|
||||
code: 'ENOADDPROP',
|
||||
})
|
||||
}
|
||||
|
||||
return typeof _data[_key] === 'object' ? _data[_key] || {} : {}
|
||||
}
|
||||
|
||||
// sets items from the parsed array of keys as objects, recurses to setKeys in case there are still items to be handled
|
||||
// otherwise, it just sets the original value set by the user
|
||||
if (keys.length) {
|
||||
_data[_key] = setKeys(next(), keys.shift())
|
||||
} else {
|
||||
// handles special deletion cases for obj props / array items
|
||||
if (value === _delete) {
|
||||
if (Array.isArray(_data)) {
|
||||
_data.splice(_key, 1)
|
||||
} else {
|
||||
delete _data[_key]
|
||||
}
|
||||
} else {
|
||||
// finally, sets the value in its right place
|
||||
_data[_key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return _data
|
||||
}
|
||||
|
||||
setKeys(data, keys.shift())
|
||||
}
|
||||
|
||||
class Queryable {
|
||||
static ALL = ''
|
||||
|
||||
#data = null
|
||||
|
||||
constructor (obj) {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
throw Object.assign(new Error('Queryable needs an object to query properties from.'), {
|
||||
code: 'ENOQUERYABLEOBJ',
|
||||
})
|
||||
}
|
||||
|
||||
this.#data = obj
|
||||
}
|
||||
|
||||
query (queries, opts) {
|
||||
// this ugly interface here is meant to be a compatibility layer with the legacy API lib/view.js is consuming
|
||||
// if at some point we refactor that command then we can revisit making this nicer
|
||||
if (queries === Queryable.ALL) {
|
||||
return { [Queryable.ALL]: this.#data }
|
||||
}
|
||||
|
||||
const q = query =>
|
||||
getter({
|
||||
data: this.#data,
|
||||
key: query,
|
||||
}, opts)
|
||||
|
||||
if (Array.isArray(queries)) {
|
||||
let res = {}
|
||||
for (const query of queries) {
|
||||
res = { ...res, ...q(query) }
|
||||
}
|
||||
return res
|
||||
} else {
|
||||
return q(queries)
|
||||
}
|
||||
}
|
||||
|
||||
// return the value for a single query if found; otherwise, returns undefined
|
||||
get (query) {
|
||||
const obj = this.query(query)
|
||||
if (obj) {
|
||||
return obj[query]
|
||||
}
|
||||
}
|
||||
|
||||
// creates objects along the way for the provided `query` parameter and assigns `value` to the last property of the query chain
|
||||
set (query, value, { force } = {}) {
|
||||
setter({
|
||||
data: this.#data,
|
||||
key: query,
|
||||
value,
|
||||
force,
|
||||
})
|
||||
}
|
||||
|
||||
// deletes the value of the property found at `query`
|
||||
delete (query) {
|
||||
setter({
|
||||
data: this.#data,
|
||||
key: query,
|
||||
value: _delete,
|
||||
})
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
return this.#data
|
||||
}
|
||||
|
||||
[util.inspect.custom] () {
|
||||
return this.toJSON()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Queryable
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
const { read: _read } = require('read')
|
||||
const userValidate = require('npm-user-validate')
|
||||
const { log, input, META } = require('proc-log')
|
||||
|
||||
const otpPrompt = `This command requires a one-time password (OTP) from your authenticator app.
|
||||
Enter one below. You can also pass one on the command line by appending --otp=123456.
|
||||
For more information, see:
|
||||
https://docs.npmjs.com/getting-started/using-two-factor-authentication
|
||||
Enter OTP: `
|
||||
const passwordPrompt = 'npm password: '
|
||||
const usernamePrompt = 'npm username: '
|
||||
const emailPrompt = 'email (this will be public): '
|
||||
|
||||
// Pass options through so we can differentiate between regular and silent prompts
|
||||
const read = (options) =>
|
||||
input.read(() => _read(options), { [META]: true, silent: options?.silent })
|
||||
|
||||
function readOTP (msg = otpPrompt, otp, isRetry) {
|
||||
if (isRetry && otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) {
|
||||
return otp.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
return read({ prompt: msg, default: otp || '' })
|
||||
.then((rOtp) => readOTP(msg, rOtp, true))
|
||||
}
|
||||
|
||||
function readPassword (msg = passwordPrompt, password, isRetry) {
|
||||
if (isRetry && password) {
|
||||
return password
|
||||
}
|
||||
|
||||
return read({ prompt: msg, silent: true, default: password || '' })
|
||||
.then((rPassword) => readPassword(msg, rPassword, true))
|
||||
}
|
||||
|
||||
function readUsername (msg = usernamePrompt, username, isRetry) {
|
||||
if (isRetry && username) {
|
||||
const error = userValidate.username(username)
|
||||
if (error) {
|
||||
log.warn(error.message)
|
||||
} else {
|
||||
return Promise.resolve(username.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return read({ prompt: msg, default: username || '' })
|
||||
.then((rUsername) => readUsername(msg, rUsername, true))
|
||||
}
|
||||
|
||||
function readEmail (msg = emailPrompt, email, isRetry) {
|
||||
if (isRetry && email) {
|
||||
const error = userValidate.email(email)
|
||||
if (error) {
|
||||
log.warn(error.message)
|
||||
} else {
|
||||
return email.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return read({ prompt: msg, default: email || '' })
|
||||
.then((username) => readEmail(msg, username, true))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
otp: readOTP,
|
||||
password: readPassword,
|
||||
username: readUsername,
|
||||
email: readEmail,
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
const reifyOutput = require('./reify-output.js')
|
||||
const checkAllowScripts = require('./check-allow-scripts.js')
|
||||
const warnWorkspaceAllowScripts = require('./warn-workspace-allow-scripts.js')
|
||||
const ini = require('ini')
|
||||
const { writeFile } = require('node:fs/promises')
|
||||
const { resolve } = require('node:path')
|
||||
|
||||
const reifyFinish = async (npm, arb) => {
|
||||
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
|
||||
if (arb.options.global) {
|
||||
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
|
||||
if (npmNode) {
|
||||
const builtinConf = npm.config.data.get('builtin')
|
||||
if (!builtinConf.loadError) {
|
||||
const content = ini.stringify(builtinConf.raw).trim() + '\n'
|
||||
await writeFile(resolve(npmNode.path, 'npmrc'), content)
|
||||
}
|
||||
}
|
||||
}
|
||||
warnWorkspaceAllowScripts(arb.actualTree)
|
||||
const unreviewedScripts = await checkAllowScripts({ arb, npm })
|
||||
reifyOutput(npm, arb, { unreviewedScripts })
|
||||
}
|
||||
|
||||
module.exports = reifyFinish
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
// pass in an arborist object, and it'll output the data about what
|
||||
// was done, what was audited, etc.
|
||||
//
|
||||
// added ## packages, removed ## packages, and audited ## packages in 19.157s
|
||||
//
|
||||
// 1 package is looking for funding
|
||||
// run `npm fund` for details
|
||||
//
|
||||
// found 37 vulnerabilities (5 low, 7 moderate, 25 high)
|
||||
// run `npm audit fix` to fix them, or `npm audit` for details
|
||||
|
||||
const { log, output } = require('proc-log')
|
||||
const { depth } = require('treeverse')
|
||||
const ms = require('ms')
|
||||
const npmAuditReport = require('npm-audit-report')
|
||||
const { readTree: getFundingInfo } = require('libnpmfund')
|
||||
const { trustedDisplay } = require('@npmcli/arborist/lib/script-allowed.js')
|
||||
const auditError = require('./audit-error.js')
|
||||
|
||||
const reifyOutput = (npm, arb, extras = {}) => {
|
||||
const { diff, actualTree } = arb
|
||||
const unreviewedScripts = extras.unreviewedScripts || []
|
||||
|
||||
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
|
||||
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport
|
||||
|
||||
// don't print any info in --silent mode, but we still need to set the exitCode properly from the audit report, if we have one.
|
||||
if (npm.silent) {
|
||||
getAuditReport(npm, auditReport)
|
||||
return
|
||||
}
|
||||
|
||||
const summary = {
|
||||
add: [],
|
||||
added: 0,
|
||||
// audit gets added later
|
||||
audited: auditReport && !auditReport.error ? actualTree.inventory.size : 0,
|
||||
change: [],
|
||||
changed: 0,
|
||||
funding: 0,
|
||||
remove: [],
|
||||
removed: 0,
|
||||
}
|
||||
|
||||
if (diff) {
|
||||
const showDiff = npm.config.get('dry-run') || npm.config.get('long')
|
||||
const chalk = npm.chalk
|
||||
|
||||
depth({
|
||||
tree: diff,
|
||||
visit: d => {
|
||||
switch (d.action) {
|
||||
case 'REMOVE':
|
||||
if (showDiff) {
|
||||
output.standard(`${chalk.blue('remove')} ${d.actual.name} ${d.actual.package.version}`)
|
||||
}
|
||||
summary.removed++
|
||||
summary.remove.push({
|
||||
name: d.actual.name,
|
||||
version: d.actual.package.version,
|
||||
path: d.actual.path,
|
||||
})
|
||||
break
|
||||
case 'ADD':
|
||||
if (showDiff) {
|
||||
output.standard(`${chalk.green('add')} ${d.ideal.name} ${d.ideal.package.version}`)
|
||||
}
|
||||
if (actualTree.inventory.has(d.ideal)) {
|
||||
summary.added++
|
||||
summary.add.push({
|
||||
name: d.ideal.name,
|
||||
version: d.ideal.package.version,
|
||||
path: d.ideal.path,
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'CHANGE':
|
||||
if (showDiff) {
|
||||
output.standard(`${chalk.cyan('change')} ${d.actual.name} ${d.actual.package.version} => ${d.ideal.package.version}`)
|
||||
}
|
||||
summary.changed++
|
||||
summary.change.push({
|
||||
from: {
|
||||
name: d.actual.name,
|
||||
version: d.actual.package.version,
|
||||
path: d.actual.path,
|
||||
},
|
||||
to: {
|
||||
name: d.ideal.name,
|
||||
version: d.ideal.package.version,
|
||||
path: d.ideal.path,
|
||||
},
|
||||
})
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
const node = d.actual || d.ideal
|
||||
log.silly(d.action, node.location)
|
||||
},
|
||||
getChildren: d => d.children,
|
||||
})
|
||||
}
|
||||
|
||||
if (npm.flatOptions.fund) {
|
||||
const fundingInfo = getFundingInfo(actualTree, { countOnly: true })
|
||||
summary.funding = fundingInfo.length
|
||||
}
|
||||
|
||||
if (npm.flatOptions.json) {
|
||||
if (auditReport) {
|
||||
// call this to set the exit code properly
|
||||
getAuditReport(npm, auditReport)
|
||||
summary.audit = npm.command === 'audit' ? auditReport
|
||||
: auditReport.toJSON().metadata
|
||||
}
|
||||
if (unreviewedScripts.length) {
|
||||
summary.unreviewedScripts = unreviewedScripts.map(({ node, scripts }) => {
|
||||
const { name, version } = trustedDisplay(node)
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
path: node.path,
|
||||
scripts,
|
||||
}
|
||||
})
|
||||
}
|
||||
output.buffer(summary)
|
||||
} else {
|
||||
packagesChangedMessage(npm, summary)
|
||||
packagesFundingMessage(npm, summary)
|
||||
printAuditReport(npm, auditReport)
|
||||
unreviewedScriptsMessage(npm, unreviewedScripts)
|
||||
}
|
||||
}
|
||||
|
||||
// if we're running `npm audit fix`, then we print the full audit report at the end if there's still stuff, because it's silly for `npm audit` to tell you to run `npm audit` for details.
|
||||
// otherwise, use the summary report.
|
||||
// if we get here, we know it's not quiet or json.
|
||||
// If the loglevel is silent, then we just run the report to get the exitCode set appropriately.
|
||||
const printAuditReport = (npm, report) => {
|
||||
const res = getAuditReport(npm, report)
|
||||
if (!res || !res.report) {
|
||||
return
|
||||
}
|
||||
output.standard(`\n${res.report}`)
|
||||
}
|
||||
|
||||
const getAuditReport = (npm, report) => {
|
||||
if (!report) {
|
||||
return
|
||||
}
|
||||
|
||||
// when in silent mode, we print nothing.
|
||||
// the JSON output is going to just JSON.stringify() the report object.
|
||||
const reporter = npm.silent ? 'quiet'
|
||||
: npm.flatOptions.json ? 'quiet'
|
||||
: npm.command !== 'audit' ? 'install'
|
||||
: 'detail'
|
||||
const defaultAuditLevel = npm.command !== 'audit' ? 'none' : 'low'
|
||||
const auditLevel = npm.flatOptions.auditLevel || defaultAuditLevel
|
||||
|
||||
const res = npmAuditReport(report, {
|
||||
reporter,
|
||||
...npm.flatOptions,
|
||||
auditLevel,
|
||||
chalk: npm.chalk,
|
||||
})
|
||||
if (npm.command === 'audit') {
|
||||
process.exitCode = process.exitCode || res.exitCode
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const packagesChangedMessage = (npm, { added, removed, changed, audited }) => {
|
||||
const msg = ['\n']
|
||||
if (added === 0 && removed === 0 && changed === 0) {
|
||||
msg.push('up to date')
|
||||
if (audited) {
|
||||
msg.push(', ')
|
||||
}
|
||||
} else {
|
||||
if (added) {
|
||||
msg.push(`added ${added} package${added === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
if (added) {
|
||||
msg.push(', ')
|
||||
}
|
||||
|
||||
if (added && !audited && !changed) {
|
||||
msg.push('and ')
|
||||
}
|
||||
|
||||
msg.push(`removed ${removed} package${removed === 1 ? '' : 's'}`)
|
||||
}
|
||||
if (changed) {
|
||||
if (added || removed) {
|
||||
msg.push(', ')
|
||||
}
|
||||
|
||||
if (!audited && (added || removed)) {
|
||||
msg.push('and ')
|
||||
}
|
||||
|
||||
msg.push(`changed ${changed} package${changed === 1 ? '' : 's'}`)
|
||||
}
|
||||
if (audited) {
|
||||
msg.push(', and ')
|
||||
}
|
||||
}
|
||||
if (audited) {
|
||||
msg.push(`audited ${audited} package${audited === 1 ? '' : 's'}`)
|
||||
}
|
||||
|
||||
msg.push(` in ${ms(Date.now() - npm.started)}`)
|
||||
output.standard(msg.join(''))
|
||||
}
|
||||
|
||||
const packagesFundingMessage = (npm, { funding }) => {
|
||||
if (!funding) {
|
||||
return
|
||||
}
|
||||
|
||||
output.standard()
|
||||
const pkg = funding === 1 ? 'package' : 'packages'
|
||||
const is = funding === 1 ? 'is' : 'are'
|
||||
output.standard(`${funding} ${pkg} ${is} looking for funding`)
|
||||
output.standard(' run `npm fund` for details')
|
||||
}
|
||||
|
||||
const unreviewedScriptsMessage = (npm, unreviewedScripts) => {
|
||||
if (!unreviewedScripts.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// Goes through log.warn so it respects --loglevel / --silent and lands
|
||||
// on stderr like every other "FYI, here's something to know" message.
|
||||
// stdout is reserved for things the user explicitly asked to see
|
||||
// (npm ls, npm view).
|
||||
const count = unreviewedScripts.length
|
||||
const pkg = count === 1 ? 'package has' : 'packages have'
|
||||
const header = `${count} ${pkg} install scripts not yet covered by allowScripts:`
|
||||
|
||||
const lines = unreviewedScripts.map(({ node, scripts }) => {
|
||||
const { name, version } = trustedDisplay(node)
|
||||
/* istanbul ignore next: every test node has a name */
|
||||
const display = name || '<unknown>'
|
||||
const ver = version ? `@${version}` : ''
|
||||
const events = Object.entries(scripts)
|
||||
.map(([event, cmd]) => `${event}: ${cmd}`)
|
||||
.join('; ')
|
||||
return ` ${display}${ver} (${events})`
|
||||
})
|
||||
|
||||
log.warn(
|
||||
'allow-scripts',
|
||||
[
|
||||
header,
|
||||
...lines,
|
||||
'',
|
||||
'Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.',
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = reifyOutput
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
const { log } = require('proc-log')
|
||||
const npa = require('npm-package-arg')
|
||||
const pkgJson = require('@npmcli/package-json')
|
||||
const { isExactVersionDisjunction } = require('@npmcli/arborist/lib/script-allowed.js')
|
||||
const parseAllowScriptsList = require('@npmcli/config/lib/parse-allow-scripts-list.js')
|
||||
|
||||
const buildPolicyFromNames = (names) => {
|
||||
/* istanbul ignore if: callers only pass non-empty arrays */
|
||||
if (!names.length) {
|
||||
return null
|
||||
}
|
||||
const policy = {}
|
||||
for (const name of names) {
|
||||
policy[name] = true
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
// Read the `allow-scripts` value from one or more named config sources and
|
||||
// build a policy object. Returns `null` if none of the sources has a value.
|
||||
const policyFromSources = (npm, sources) => {
|
||||
for (const where of sources) {
|
||||
const value = npm.config.get?.('allow-scripts', where)
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
const names = parseAllowScriptsList(value)
|
||||
/* istanbul ignore else: parseAllowScriptsList returns non-empty when value is set */
|
||||
if (names.length) {
|
||||
return buildPolicyFromNames(names)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const validatePolicy = (policy, sourceLabel) => {
|
||||
// Drop and warn about keys with forbidden semver ranges (^, ~, >=, <, *).
|
||||
// The RFC only permits exact versions joined by `||`. Bare names like
|
||||
// `canvas` and explicit name-only wildcards (`canvas@*`) are allowed.
|
||||
if (!policy) {
|
||||
return policy
|
||||
}
|
||||
const cleaned = {}
|
||||
for (const [key, value] of Object.entries(policy)) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = npa(key)
|
||||
} catch {
|
||||
log.warn('allow-scripts', `${sourceLabel}: ignoring unparseable entry "${key}"`)
|
||||
continue
|
||||
}
|
||||
if (parsed.type === 'tag') {
|
||||
// `pkg@latest`, `pkg@next`, etc. look like a pin but behave name-
|
||||
// only — the matcher has no way to verify what the tag points at
|
||||
// when scripts run. Reject for the same reason as semver ranges.
|
||||
log.warn(
|
||||
'allow-scripts',
|
||||
`${sourceLabel}: ignoring "${key}" — dist-tag specs (@latest, @next, ...) are not allowed; ` +
|
||||
'use exact versions joined by "||", or the bare package name, instead'
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (parsed.type === 'range') {
|
||||
const isNameOnly = parsed.fetchSpec === '*'
|
||||
|| parsed.rawSpec === ''
|
||||
|| parsed.rawSpec === '*'
|
||||
if (!isNameOnly && !isExactVersionDisjunction(parsed.fetchSpec)) {
|
||||
log.warn(
|
||||
'allow-scripts',
|
||||
`${sourceLabel}: ignoring "${key}" — semver ranges (^, ~, >=, <) are not allowed; ` +
|
||||
'use exact versions joined by "||" instead'
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
cleaned[key] = value
|
||||
}
|
||||
return Object.keys(cleaned).length > 0 ? cleaned : null
|
||||
}
|
||||
|
||||
// Resolve the effective allowScripts policy from the layered sources.
|
||||
// Returns `{ policy, source }` where:
|
||||
// - `policy` is an object map of `package-spec` -> boolean, or `null` if
|
||||
// no layer has any configuration
|
||||
// - `source` is one of `'cli'`, `'package.json'`, `'.npmrc'`, or `null`
|
||||
//
|
||||
// Precedence order (highest to lowest), per RFC npm/rfcs#868:
|
||||
// 1. CLI flags (--allow-scripts) and env vars
|
||||
// 2. Root `package.json#allowScripts`
|
||||
// 3. `.npmrc` cascade (project, user, global)
|
||||
//
|
||||
// The project `package.json` layer is skipped when:
|
||||
// - `npm.global` is true (no project context exists for global installs)
|
||||
// - `skipProjectConfig` is true (e.g. npm exec / npx, which per the RFC
|
||||
// consult only user/global .npmrc)
|
||||
//
|
||||
// In both skipped cases, the CLI and .npmrc layers are still consulted;
|
||||
// only the project package.json layer is skipped.
|
||||
//
|
||||
// The first source with any configuration wins for the entire install;
|
||||
// lower layers are ignored. A `log.warn` is emitted whenever a setting is
|
||||
// being suppressed by a higher-priority source.
|
||||
//
|
||||
// Reads `package.json` from `npm.prefix` (not `npm.localPrefix`) so an
|
||||
// install run from a workspace sub-directory still picks up the project
|
||||
// root's policy.
|
||||
const resolveAllowScripts = async (npm, { skipProjectConfig = false } = {}) => {
|
||||
// Independently probe each RFC layer.
|
||||
const cliPolicy = policyFromSources(npm, ['cli', 'env'])
|
||||
const npmrcPolicy = policyFromSources(npm, ['project', 'user', 'global', 'builtin'])
|
||||
|
||||
// The --allow-scripts CLI flag is intended for one-off and global
|
||||
// contexts (npm exec, npx, npm install -g). In a project-scoped install,
|
||||
// team policy belongs in package.json or .npmrc, so reject the flag
|
||||
// outright to avoid the "works on my machine" footgun.
|
||||
if (cliPolicy && !npm.global && !skipProjectConfig) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'--allow-scripts is not allowed in project-scoped installs. ' +
|
||||
'Add the entries to the "allowScripts" field in package.json, ' +
|
||||
'or to .npmrc, instead.'
|
||||
),
|
||||
{ code: 'EALLOWSCRIPTS' }
|
||||
)
|
||||
}
|
||||
|
||||
// Project package.json is consulted only when the caller is operating
|
||||
// inside a real project (not -g, not npx).
|
||||
let pkgPolicy = null
|
||||
if (!npm.global && !skipProjectConfig) {
|
||||
try {
|
||||
const { content } = await pkgJson.normalize(npm.prefix)
|
||||
if (content?.allowScripts && typeof content.allowScripts === 'object') {
|
||||
const entries = Object.entries(content.allowScripts)
|
||||
if (entries.length > 0) {
|
||||
pkgPolicy = Object.fromEntries(entries)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.silly('allow-scripts', 'no package.json at prefix', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each candidate layer: drop forbidden ranges, warn the user.
|
||||
const cli = validatePolicy(cliPolicy, 'CLI flag')
|
||||
const pkg = validatePolicy(pkgPolicy, 'package.json')
|
||||
const rc = validatePolicy(npmrcPolicy, '.npmrc')
|
||||
|
||||
// Apply RFC precedence.
|
||||
if (cli) {
|
||||
// Note: `pkg` is never set alongside `cli` here. Project package.json is
|
||||
// only read when `!npm.global && !skipProjectConfig`, but in that same
|
||||
// case a CLI policy throws above. With `npm.global` or skipProjectConfig
|
||||
// set, package.json is never consulted.
|
||||
if (rc) {
|
||||
log.warn(
|
||||
'allow-scripts',
|
||||
'.npmrc allow-scripts setting is being ignored because --allow-scripts was passed on the command line'
|
||||
)
|
||||
}
|
||||
return { policy: cli, source: 'cli' }
|
||||
}
|
||||
|
||||
if (pkg) {
|
||||
if (rc) {
|
||||
log.warn(
|
||||
'allow-scripts',
|
||||
'.npmrc allow-scripts setting is being ignored because package.json declares its own allowScripts field'
|
||||
)
|
||||
}
|
||||
return { policy: pkg, source: 'package.json' }
|
||||
}
|
||||
|
||||
if (rc) {
|
||||
return { policy: rc, source: '.npmrc' }
|
||||
}
|
||||
|
||||
return { policy: null, source: null }
|
||||
}
|
||||
|
||||
module.exports = resolveAllowScripts
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
const crypto = require('node:crypto')
|
||||
const parseLicense = require('spdx-expression-parse')
|
||||
const PackageJson = require('@npmcli/package-json')
|
||||
const npa = require('npm-package-arg')
|
||||
const ssri = require('ssri')
|
||||
|
||||
const CYCLONEDX_SCHEMA = 'http://cyclonedx.org/schema/bom-1.5.schema.json'
|
||||
const CYCLONEDX_FORMAT = 'CycloneDX'
|
||||
const CYCLONEDX_SCHEMA_VERSION = '1.5'
|
||||
|
||||
const PROP_BUNDLED = 'cdx:npm:package:bundled'
|
||||
const PROP_DEVELOPMENT = 'cdx:npm:package:development'
|
||||
const PROP_EXTRANEOUS = 'cdx:npm:package:extraneous'
|
||||
const PROP_PRIVATE = 'cdx:npm:package:private'
|
||||
|
||||
const REF_VCS = 'vcs'
|
||||
const REF_WEBSITE = 'website'
|
||||
const REF_ISSUE_TRACKER = 'issue-tracker'
|
||||
const REF_DISTRIBUTION = 'distribution'
|
||||
|
||||
const ALGO_MAP = {
|
||||
sha1: 'SHA-1',
|
||||
sha256: 'SHA-256',
|
||||
sha384: 'SHA-384',
|
||||
sha512: 'SHA-512',
|
||||
}
|
||||
|
||||
const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
|
||||
const rootNode = nodes.find(node => node.isRoot)
|
||||
const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
|
||||
const uuid = crypto.randomUUID()
|
||||
|
||||
// Create list of child nodes w/ unique IDs
|
||||
const childNodeMap = new Map()
|
||||
for (const item of childNodes) {
|
||||
const id = toCyclonedxID(item)
|
||||
if (!childNodeMap.has(id)) {
|
||||
childNodeMap.set(id, item)
|
||||
}
|
||||
}
|
||||
const uniqueChildNodes = Array.from(childNodeMap.values())
|
||||
|
||||
const deps = [rootNode, ...uniqueChildNodes]
|
||||
.map(node => toCyclonedxDependency(node, nodes))
|
||||
|
||||
const bom = {
|
||||
$schema: CYCLONEDX_SCHEMA,
|
||||
bomFormat: CYCLONEDX_FORMAT,
|
||||
specVersion: CYCLONEDX_SCHEMA_VERSION,
|
||||
serialNumber: `urn:uuid:${uuid}`,
|
||||
version: 1,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
lifecycles: [
|
||||
{ phase: packageLockOnly ? 'pre-build' : 'build' },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
vendor: 'npm',
|
||||
name: 'cli',
|
||||
version: npm.version,
|
||||
},
|
||||
],
|
||||
component: toCyclonedxItem(rootNode, { packageType }),
|
||||
},
|
||||
components: uniqueChildNodes.map(toCyclonedxItem),
|
||||
dependencies: deps,
|
||||
}
|
||||
|
||||
return bom
|
||||
}
|
||||
|
||||
const toCyclonedxItem = (node, { packageType }) => {
|
||||
packageType = packageType || 'library'
|
||||
|
||||
// Calculate purl from package spec
|
||||
let spec = npa(node.pkgid)
|
||||
spec = (spec.type === 'alias') ? spec.subSpec : spec
|
||||
const purl = npa.toPurl(spec) + (isGitNode(node) ? `?vcs_url=${node.resolved}` : '')
|
||||
|
||||
if (node.package) {
|
||||
const toNormalize = new PackageJson()
|
||||
toNormalize.fromContent(node.package).normalize({ steps: ['normalizeData'] })
|
||||
node.package = toNormalize.content
|
||||
}
|
||||
|
||||
let license = node.package?.license
|
||||
if (license) {
|
||||
if (typeof license === 'object') {
|
||||
license = license.type
|
||||
}
|
||||
} else if (Array.isArray(node.package?.licenses)) {
|
||||
license = node.package.licenses
|
||||
.map(l => (typeof l === 'object' ? l.type : l))
|
||||
.filter(Boolean)
|
||||
.join(' OR ')
|
||||
}
|
||||
|
||||
let parsedLicense
|
||||
try {
|
||||
parsedLicense = parseLicense(license)
|
||||
} catch {
|
||||
parsedLicense = null
|
||||
}
|
||||
|
||||
const component = {
|
||||
'bom-ref': toCyclonedxID(node),
|
||||
type: packageType,
|
||||
name: node.name,
|
||||
version: node.version,
|
||||
scope: (node.optional || node.devOptional) ? 'optional' : 'required',
|
||||
author: (typeof node.package?.author === 'object')
|
||||
? node.package.author.name
|
||||
: (node.package?.author || undefined),
|
||||
description: node.package?.description || undefined,
|
||||
purl: purl,
|
||||
properties: [],
|
||||
externalReferences: [],
|
||||
}
|
||||
|
||||
if (node.integrity) {
|
||||
const integrity = ssri.parse(node.integrity, { single: true })
|
||||
component.hashes = [{
|
||||
alg: ALGO_MAP[integrity.algorithm] || /* istanbul ignore next */ 'SHA-512',
|
||||
content: integrity.hexDigest(),
|
||||
}]
|
||||
}
|
||||
|
||||
if (node.dev === true) {
|
||||
component.properties.push(prop(PROP_DEVELOPMENT))
|
||||
}
|
||||
|
||||
if (node.package?.private === true) {
|
||||
component.properties.push(prop(PROP_PRIVATE))
|
||||
}
|
||||
|
||||
if (node.extraneous === true) {
|
||||
component.properties.push(prop(PROP_EXTRANEOUS))
|
||||
}
|
||||
|
||||
if (node.inBundle === true) {
|
||||
component.properties.push(prop(PROP_BUNDLED))
|
||||
}
|
||||
|
||||
if (!node.isLink && node.resolved) {
|
||||
component.externalReferences.push(extRef(REF_DISTRIBUTION, node.resolved))
|
||||
}
|
||||
|
||||
if (node.package?.repository?.url) {
|
||||
component.externalReferences.push(extRef(REF_VCS, node.package.repository.url))
|
||||
}
|
||||
|
||||
if (node.package?.homepage) {
|
||||
component.externalReferences.push(extRef(REF_WEBSITE, node.package.homepage))
|
||||
}
|
||||
|
||||
if (node.package?.bugs?.url) {
|
||||
component.externalReferences.push(extRef(REF_ISSUE_TRACKER, node.package.bugs.url))
|
||||
}
|
||||
|
||||
// If license is a single SPDX license, use the license field
|
||||
if (parsedLicense?.license) {
|
||||
component.licenses = [{ license: { id: parsedLicense.license } }]
|
||||
// If license is a conjunction, use the expression field
|
||||
} else if (parsedLicense?.conjunction) {
|
||||
component.licenses = [{ expression: license }]
|
||||
}
|
||||
|
||||
return component
|
||||
}
|
||||
|
||||
const toCyclonedxDependency = (node, nodes) => {
|
||||
// A node can have multiple outgoing edges resolving to the same
|
||||
// `name@version` (e.g. via npm aliases like `foo: npm:bar@1` alongside a
|
||||
// direct `bar: ^1` dep), which would produce duplicate entries in
|
||||
// `dependsOn`. CycloneDX 1.5 requires unique items, so dedupe by ref.
|
||||
const dependsOn = [...new Set(
|
||||
[...node.edgesOut.values()]
|
||||
// Filter out edges that are linking to nodes not in the list
|
||||
.filter(edge => nodes.find(n => n === edge.to))
|
||||
.map(edge => toCyclonedxID(edge.to))
|
||||
.filter(id => id)
|
||||
)]
|
||||
return {
|
||||
ref: toCyclonedxID(node),
|
||||
dependsOn,
|
||||
}
|
||||
}
|
||||
|
||||
const toCyclonedxID = (node) => `${node.packageName}@${node.version}`
|
||||
|
||||
const prop = (name) => ({ name, value: 'true' })
|
||||
|
||||
const extRef = (type, url) => ({ type, url })
|
||||
|
||||
const isGitNode = (node) => {
|
||||
if (!node.resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { type } = npa(node.resolved)
|
||||
return type === 'git' || type === 'hosted'
|
||||
} catch {
|
||||
/* istanbul ignore next */
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { cyclonedxOutput }
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
const PackageJson = require('@npmcli/package-json')
|
||||
const npa = require('npm-package-arg')
|
||||
const ssri = require('ssri')
|
||||
|
||||
const SPDX_SCHEMA_VERSION = 'SPDX-2.3'
|
||||
const SPDX_DATA_LICENSE = 'CC0-1.0'
|
||||
const SPDX_IDENTIFER = 'SPDXRef-DOCUMENT'
|
||||
|
||||
const NO_ASSERTION = 'NOASSERTION'
|
||||
|
||||
const REL_DESCRIBES = 'DESCRIBES'
|
||||
const REL_PREREQ = 'PREREQUISITE_FOR'
|
||||
const REL_OPTIONAL = 'OPTIONAL_DEPENDENCY_OF'
|
||||
const REL_DEV = 'DEV_DEPENDENCY_OF'
|
||||
const REL_DEP = 'DEPENDENCY_OF'
|
||||
|
||||
const REF_CAT_PACKAGE_MANAGER = 'PACKAGE-MANAGER'
|
||||
const REF_TYPE_PURL = 'purl'
|
||||
|
||||
const spdxOutput = ({ npm, nodes, packageType }) => {
|
||||
const rootNode = nodes.find(node => node.isRoot)
|
||||
const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
|
||||
const rootID = rootNode.pkgid
|
||||
const uuid = crypto.randomUUID()
|
||||
const ns = `http://spdx.org/spdxdocs/${npa(rootID).escapedName}-${rootNode.version}-${uuid}`
|
||||
|
||||
// Create list of child nodes w/ unique IDs
|
||||
const childNodeMap = new Map()
|
||||
for (const item of childNodes) {
|
||||
const id = toSpdxID(item)
|
||||
if (!childNodeMap.has(id)) {
|
||||
childNodeMap.set(id, item)
|
||||
}
|
||||
}
|
||||
const uniqueChildNodes = Array.from(childNodeMap.values())
|
||||
|
||||
const relationships = []
|
||||
const seen = new Set()
|
||||
for (let node of nodes) {
|
||||
if (node.isLink) {
|
||||
node = node.target
|
||||
}
|
||||
|
||||
if (seen.has(node)) {
|
||||
continue
|
||||
}
|
||||
seen.add(node)
|
||||
|
||||
// A node can have multiple outgoing edges resolving to the same
|
||||
// `name@version` of the same edge type (e.g. via npm aliases), which
|
||||
// would produce identical relationship triples. Dedupe per source node.
|
||||
const seenRels = new Set()
|
||||
const rels = [...node.edgesOut.values()]
|
||||
// Filter out edges that are linking to nodes not in the list
|
||||
.filter(edge => nodes.find(n => n === edge.to))
|
||||
.map(edge => toSpdxRelationship(node, edge))
|
||||
.filter(rel => rel)
|
||||
.filter(rel => {
|
||||
const key = `${rel.spdxElementId}|${rel.relatedSpdxElement}|${rel.relationshipType}`
|
||||
if (seenRels.has(key)) {
|
||||
return false
|
||||
}
|
||||
seenRels.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
relationships.push(...rels)
|
||||
}
|
||||
|
||||
const extraRelationships = nodes.filter(node => node.extraneous)
|
||||
.map(node => toSpdxRelationship(rootNode, { to: node, type: 'optional' }))
|
||||
|
||||
relationships.push(...extraRelationships)
|
||||
|
||||
const bom = {
|
||||
spdxVersion: SPDX_SCHEMA_VERSION,
|
||||
dataLicense: SPDX_DATA_LICENSE,
|
||||
SPDXID: SPDX_IDENTIFER,
|
||||
name: rootID,
|
||||
documentNamespace: ns,
|
||||
creationInfo: {
|
||||
created: new Date().toISOString(),
|
||||
creators: [
|
||||
`Tool: npm/cli-${npm.version}`,
|
||||
],
|
||||
},
|
||||
documentDescribes: [toSpdxID(rootNode)],
|
||||
packages: [toSpdxItem(rootNode, { packageType }), ...uniqueChildNodes.map(toSpdxItem)],
|
||||
relationships: [
|
||||
{
|
||||
spdxElementId: SPDX_IDENTIFER,
|
||||
relatedSpdxElement: toSpdxID(rootNode),
|
||||
relationshipType: REL_DESCRIBES,
|
||||
},
|
||||
...relationships,
|
||||
],
|
||||
}
|
||||
|
||||
return bom
|
||||
}
|
||||
|
||||
const toSpdxItem = (node, { packageType }) => {
|
||||
const toNormalize = new PackageJson()
|
||||
toNormalize.fromContent(node.package).normalize({ steps: ['normalizeData'] })
|
||||
node.package = toNormalize.content
|
||||
|
||||
// Calculate purl from package spec
|
||||
let spec = npa(node.pkgid)
|
||||
spec = (spec.type === 'alias') ? spec.subSpec : spec
|
||||
const purl = npa.toPurl(spec) + (isGitNode(node) ? `?vcs_url=${node.resolved}` : '')
|
||||
|
||||
/* For workspace nodes, use the location from their linkNode */
|
||||
let location = node.location
|
||||
if (node.isWorkspace && node.linksIn.size > 0) {
|
||||
location = node.linksIn.values().next().value.location
|
||||
}
|
||||
|
||||
let license = node.package?.license
|
||||
if (license) {
|
||||
if (typeof license === 'object') {
|
||||
license = license.type
|
||||
}
|
||||
} else if (Array.isArray(node.package?.licenses)) {
|
||||
license = node.package.licenses
|
||||
.map(l => (typeof l === 'object' ? l.type : l))
|
||||
.filter(Boolean)
|
||||
.join(' OR ')
|
||||
}
|
||||
|
||||
const pkg = {
|
||||
name: node.packageName,
|
||||
SPDXID: toSpdxID(node),
|
||||
versionInfo: node.version,
|
||||
packageFileName: location,
|
||||
description: node.package?.description || undefined,
|
||||
primaryPackagePurpose: packageType ? packageType.toUpperCase() : undefined,
|
||||
downloadLocation: (node.isLink ? undefined : node.resolved) || NO_ASSERTION,
|
||||
filesAnalyzed: false,
|
||||
homepage: node.package?.homepage || NO_ASSERTION,
|
||||
licenseDeclared: license || NO_ASSERTION,
|
||||
externalRefs: [
|
||||
{
|
||||
referenceCategory: REF_CAT_PACKAGE_MANAGER,
|
||||
referenceType: REF_TYPE_PURL,
|
||||
referenceLocator: purl,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (node.integrity) {
|
||||
const integrity = ssri.parse(node.integrity, { single: true })
|
||||
pkg.checksums = [{
|
||||
algorithm: integrity.algorithm.toUpperCase(),
|
||||
checksumValue: integrity.hexDigest(),
|
||||
}]
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
const toSpdxRelationship = (node, edge) => {
|
||||
let type
|
||||
switch (edge.type) {
|
||||
case 'peer':
|
||||
type = REL_PREREQ
|
||||
break
|
||||
case 'optional':
|
||||
type = REL_OPTIONAL
|
||||
break
|
||||
case 'dev':
|
||||
type = REL_DEV
|
||||
break
|
||||
default:
|
||||
type = REL_DEP
|
||||
}
|
||||
|
||||
return {
|
||||
spdxElementId: toSpdxID(edge.to),
|
||||
relatedSpdxElement: toSpdxID(node),
|
||||
relationshipType: type,
|
||||
}
|
||||
}
|
||||
|
||||
const toSpdxID = (node) => {
|
||||
let name = node.packageName
|
||||
|
||||
// Strip leading @ for scoped packages
|
||||
name = name.replace(/^@/, '')
|
||||
|
||||
// Replace slashes with dots
|
||||
name = name.replace(/\//g, '.')
|
||||
|
||||
return `SPDXRef-Package-${name}-${node.version}`
|
||||
}
|
||||
|
||||
const isGitNode = (node) => {
|
||||
if (!node.resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { type } = npa(node.resolved)
|
||||
return type === 'git' || type === 'hosted'
|
||||
} catch {
|
||||
/* istanbul ignore next */
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { spdxOutput }
|
||||
Generated
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
const checkAllowScripts = require('./check-allow-scripts.js')
|
||||
|
||||
// Pre-flight check for `--strict-allow-scripts`. Call after arborist has
|
||||
// been constructed but before `arb.reify()` runs, so that install scripts
|
||||
// never execute when strict mode would block them.
|
||||
//
|
||||
// Behaviour:
|
||||
// - No-op unless `npm.flatOptions.strictAllowScripts` is set.
|
||||
// - Bypassed by `--dangerously-allow-all-scripts` and `--ignore-scripts`
|
||||
// (the per-flag short-circuits already live in checkAllowScripts).
|
||||
// - Builds the ideal tree (idempotent — subsequent reify reuses it),
|
||||
// walks it for nodes whose install scripts have not been covered by
|
||||
// the `allowScripts` policy, and throws if any are found.
|
||||
const strictAllowScriptsPreflight = async ({ arb, npm, idealTreeOpts }) => {
|
||||
if (!npm?.flatOptions?.strictAllowScripts) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the idealTree when reify is about to run; fall back to
|
||||
// actualTree for npm rebuild (which never builds an ideal tree).
|
||||
let tree
|
||||
if (idealTreeOpts !== undefined) {
|
||||
// `npm ci` builds the ideal tree before calling the preflight, so
|
||||
// skip the rebuild when one already exists. `npm install` calls the
|
||||
// preflight before reify and needs us to build.
|
||||
if (!arb.idealTree) {
|
||||
await arb.buildIdealTree(idealTreeOpts)
|
||||
}
|
||||
tree = arb.idealTree
|
||||
} else {
|
||||
tree = arb.actualTree
|
||||
}
|
||||
|
||||
const unreviewed = await checkAllowScripts({ arb, npm, tree })
|
||||
if (unreviewed.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = unreviewed.map(({ node, scripts }) => {
|
||||
const events = Object.entries(scripts)
|
||||
.map(([event, body]) => `${event}: ${body}`)
|
||||
.join('; ')
|
||||
const name = node.package?.name || node.name
|
||||
const version = node.package?.version || ''
|
||||
const label = version ? `${name}@${version}` : name
|
||||
return ` ${label} (${events})`
|
||||
}).join('\n')
|
||||
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`--strict-allow-scripts: ${unreviewed.length} package(s) have install ` +
|
||||
`scripts not covered by allowScripts:\n${lines}\n` +
|
||||
'Approve them with `npm approve-scripts`, deny them with ' +
|
||||
'`npm deny-scripts`, or bypass this check with ' +
|
||||
'`--dangerously-allow-all-scripts`.'
|
||||
),
|
||||
{ code: 'ESTRICTALLOWSCRIPTS' }
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = strictAllowScriptsPreflight
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
const tar = require('tar')
|
||||
const ssri = require('ssri')
|
||||
const { log, output, META } = require('proc-log')
|
||||
const formatBytes = require('./format-bytes.js')
|
||||
const localeCompare = require('@isaacs/string-locale-compare')('en', {
|
||||
sensitivity: 'case',
|
||||
numeric: true,
|
||||
})
|
||||
|
||||
const logTar = (tarball, { unicode = false, json, key, redact } = {}) => {
|
||||
if (json) {
|
||||
const meta = redact === false ? { [META]: true, redact: false } : undefined
|
||||
output.buffer(key == null ? tarball : { [key]: tarball }, meta)
|
||||
return
|
||||
}
|
||||
log.notice('')
|
||||
log.notice('', `${unicode ? '📦 ' : 'package:'} ${tarball.name}@${tarball.version}`)
|
||||
log.notice('Tarball Contents')
|
||||
if (tarball.files.length) {
|
||||
log.notice(
|
||||
'',
|
||||
tarball.files.map(f =>
|
||||
/^node_modules\//.test(f.path) ? null : `${formatBytes(f.size, false)} ${f.path}`
|
||||
).filter(f => f).join('\n')
|
||||
)
|
||||
}
|
||||
if (tarball.bundled.length) {
|
||||
log.notice('Bundled Dependencies')
|
||||
tarball.bundled.forEach(name => log.notice('', name))
|
||||
}
|
||||
log.notice('Tarball Details')
|
||||
log.notice('', `name: ${tarball.name}`)
|
||||
log.notice('', `version: ${tarball.version}`)
|
||||
if (tarball.filename) {
|
||||
log.notice('', `filename: ${tarball.filename}`)
|
||||
}
|
||||
log.notice('', `package size: ${formatBytes(tarball.size)}`)
|
||||
log.notice('', `unpacked size: ${formatBytes(tarball.unpackedSize)}`)
|
||||
log.notice('', `shasum: ${tarball.shasum}`)
|
||||
log.notice('', `integrity: ${tarball.integrity.toString().slice(0, 20)}[...]${tarball.integrity.toString().slice(80)}`)
|
||||
if (tarball.bundled.length) {
|
||||
log.notice('', `bundled deps: ${tarball.bundled.length}`)
|
||||
log.notice('', `bundled files: ${tarball.entryCount - tarball.files.length}`)
|
||||
log.notice('', `own files: ${tarball.files.length}`)
|
||||
}
|
||||
log.notice('', `total files: ${tarball.entryCount}`)
|
||||
log.notice('', '')
|
||||
}
|
||||
|
||||
const getContents = async (manifest, tarball) => {
|
||||
const files = []
|
||||
const bundled = new Set()
|
||||
let totalEntries = 0
|
||||
let totalEntrySize = 0
|
||||
|
||||
// reads contents of tarball
|
||||
const stream = tar.t({
|
||||
onentry (entry) {
|
||||
totalEntries++
|
||||
totalEntrySize += entry.size
|
||||
const p = entry.path
|
||||
if (p.startsWith('package/node_modules/') && p !== 'package/node_modules/') {
|
||||
const name = p.match(/^package\/node_modules\/((?:@[^/]+\/)?[^/]+)/)[1]
|
||||
bundled.add(name)
|
||||
}
|
||||
files.push({
|
||||
path: entry.path.replace(/^package\//, ''),
|
||||
size: entry.size,
|
||||
mode: entry.mode,
|
||||
})
|
||||
},
|
||||
})
|
||||
stream.end(tarball)
|
||||
|
||||
const integrity = ssri.fromData(tarball, {
|
||||
algorithms: ['sha1', 'sha512'],
|
||||
})
|
||||
|
||||
const comparator = ({ path: a }, { path: b }) => localeCompare(a, b)
|
||||
|
||||
const isUpper = str => {
|
||||
const ch = str.charAt(0)
|
||||
return ch === ch.toUpperCase()
|
||||
}
|
||||
|
||||
const uppers = files.filter(file => isUpper(file.path))
|
||||
const others = files.filter(file => !isUpper(file.path))
|
||||
|
||||
uppers.sort(comparator)
|
||||
others.sort(comparator)
|
||||
|
||||
const shasum = integrity.sha1[0].hexDigest()
|
||||
return {
|
||||
id: manifest._id || `${manifest.name}@${manifest.version}`,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
size: tarball.length,
|
||||
unpackedSize: totalEntrySize,
|
||||
shasum,
|
||||
integrity: ssri.parse(integrity.sha512[0]),
|
||||
// @scope/packagename.tgz => scope-packagename.tgz
|
||||
// we can safely use these global replace rules due to npm package naming rules
|
||||
filename: `${manifest.name.replace('@', '').replace('/', '-')}-${manifest.version}.tgz`,
|
||||
files: uppers.concat(others),
|
||||
entryCount: totalEntries,
|
||||
bundled: Array.from(bundled),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { logTar, getContents }
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
const EE = require('node:events')
|
||||
const fs = require('node:fs')
|
||||
const { log, time } = require('proc-log')
|
||||
|
||||
const INITIAL_TIMER = 'npm'
|
||||
|
||||
class Timers extends EE {
|
||||
#file
|
||||
#timing
|
||||
|
||||
#unfinished = new Map()
|
||||
#finished = {}
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
this.on()
|
||||
time.start(INITIAL_TIMER)
|
||||
this.started = this.#unfinished.get(INITIAL_TIMER)
|
||||
}
|
||||
|
||||
on () {
|
||||
process.on('time', this.#timeHandler)
|
||||
}
|
||||
|
||||
off () {
|
||||
process.off('time', this.#timeHandler)
|
||||
}
|
||||
|
||||
load ({ path, timing } = {}) {
|
||||
this.#timing = timing
|
||||
this.#file = `${path}timing.json`
|
||||
}
|
||||
|
||||
finish (metadata) {
|
||||
time.end(INITIAL_TIMER)
|
||||
|
||||
for (const [name, timer] of this.#unfinished) {
|
||||
log.silly('unfinished npm timer', name, timer)
|
||||
}
|
||||
|
||||
if (!this.#timing) {
|
||||
// Not in timing mode, nothing else to do here
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.#writeFile(metadata)
|
||||
log.info('timing', `Timing info written to: ${this.#file}`)
|
||||
} catch (e) {
|
||||
log.warn('timing', `could not write timing file: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
#writeFile (metadata) {
|
||||
const globalStart = this.started
|
||||
const globalEnd = this.#finished[INITIAL_TIMER]
|
||||
const content = {
|
||||
metadata,
|
||||
timers: this.#finished,
|
||||
// add any unfinished timers with their relative start/end
|
||||
unfinishedTimers: [...this.#unfinished.entries()].reduce((acc, [name, start]) => {
|
||||
acc[name] = [start - globalStart, globalEnd - globalStart]
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
fs.writeFileSync(this.#file, JSON.stringify(content) + '\n')
|
||||
}
|
||||
|
||||
#timeHandler = (level, name) => {
|
||||
const now = Date.now()
|
||||
switch (level) {
|
||||
case time.KEYS.start:
|
||||
this.#unfinished.set(name, now)
|
||||
break
|
||||
case time.KEYS.end: {
|
||||
if (this.#unfinished.has(name)) {
|
||||
const ms = now - this.#unfinished.get(name)
|
||||
this.#finished[name] = ms
|
||||
this.#unfinished.delete(name)
|
||||
log.timing(name, `Completed in ${ms}ms`)
|
||||
} else {
|
||||
log.silly('timing', `Tried to end timer that doesn't exist: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Timers
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
'use strict'
|
||||
|
||||
const reifyFinish = require('../utils/reify-finish.js')
|
||||
|
||||
async function updateWorkspaces ({
|
||||
config,
|
||||
flatOptions,
|
||||
localPrefix,
|
||||
npm,
|
||||
workspaces,
|
||||
}) {
|
||||
if (!flatOptions.workspacesUpdate || !workspaces.length) {
|
||||
return
|
||||
}
|
||||
|
||||
// default behavior is to not save by default in order to avoid race condition problems when publishing multiple workspaces that have dependencies on one another
|
||||
// it might still be useful in some cases, which then need to set --save
|
||||
const save = config.isDefault('save')
|
||||
? false
|
||||
: config.get('save')
|
||||
|
||||
// runs a minimalistic reify update, targeting only the workspaces that had version updates and skipping fund/audit/save
|
||||
const opts = {
|
||||
...flatOptions,
|
||||
audit: false,
|
||||
fund: false,
|
||||
path: localPrefix,
|
||||
save,
|
||||
}
|
||||
const Arborist = require('@npmcli/arborist')
|
||||
const arb = new Arborist(opts)
|
||||
|
||||
await arb.reify({ ...opts, update: workspaces })
|
||||
await reifyFinish(npm, arb)
|
||||
}
|
||||
|
||||
module.exports = updateWorkspaces
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// compares the inventory of package items in the tree
|
||||
// that is about to be installed (idealTree) with the inventory
|
||||
// of items stored in the package-lock file (virtualTree)
|
||||
//
|
||||
// Returns empty array if no errors found or an array populated
|
||||
// with an entry for each validation error found.
|
||||
function validateLockfile (virtualTree, idealTree) {
|
||||
const errors = []
|
||||
|
||||
// loops through the inventory of packages resulted by ideal tree,
|
||||
// for each package compares the versions with the version stored in the
|
||||
// package-lock and adds an error to the list in case of mismatches
|
||||
for (const [key, entry] of idealTree.entries()) {
|
||||
const lock = virtualTree.get(key)
|
||||
|
||||
if (!lock) {
|
||||
errors.push(`Missing: ${entry.name}@${entry.version} from lock file`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.version !== lock.version) {
|
||||
errors.push(`Invalid: lock file's ${lock.name}@${lock.version} does ` +
|
||||
`not satisfy ${entry.name}@${entry.version}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
module.exports = validateLockfile
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// UUID validation regex
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
const validateUUID = (value, fieldName) => {
|
||||
if (!UUID_REGEX.test(value)) {
|
||||
throw new Error(`${fieldName} must be a valid UUID`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UUID_REGEX, validateUUID }
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
const npmFetch = require('npm-registry-fetch')
|
||||
const localeCompare = require('@isaacs/string-locale-compare')('en')
|
||||
const npa = require('npm-package-arg')
|
||||
const pacote = require('pacote')
|
||||
const tufClient = require('@sigstore/tuf')
|
||||
const { log, output } = require('proc-log')
|
||||
|
||||
const sortAlphabetically = (a, b) => localeCompare(a.name, b.name)
|
||||
|
||||
class VerifySignatures {
|
||||
constructor (tree, filterSet, npm, opts) {
|
||||
this.tree = tree
|
||||
this.filterSet = filterSet
|
||||
this.npm = npm
|
||||
this.opts = opts
|
||||
this.keys = new Map()
|
||||
this.invalid = []
|
||||
this.missing = []
|
||||
this.checkedPackages = new Set()
|
||||
this.verified = []
|
||||
this.auditedWithKeysCount = 0
|
||||
this.verifiedSignatureCount = 0
|
||||
this.verifiedAttestationCount = 0
|
||||
this.exitCode = 0
|
||||
}
|
||||
|
||||
async run () {
|
||||
const start = process.hrtime.bigint()
|
||||
const { default: pMap } = await import('p-map')
|
||||
|
||||
// Find all deps in tree
|
||||
const { edges, registries } = this.getEdgesOut(this.tree.inventory.values(), this.filterSet)
|
||||
if (edges.size === 0) {
|
||||
throw new Error('found no installed dependencies to audit')
|
||||
}
|
||||
|
||||
const tuf = await tufClient.initTUF({
|
||||
cachePath: this.opts.tufCache,
|
||||
retry: this.opts.retry,
|
||||
timeout: this.opts.timeout,
|
||||
})
|
||||
await Promise.all([...registries].map(registry => this.setKeys({ registry, tuf })))
|
||||
|
||||
log.verbose('verifying registry signatures')
|
||||
await pMap(edges, (e) => this.getVerifiedInfo(e), { concurrency: 20, stopOnError: true })
|
||||
|
||||
// Didn't find any dependencies that could be verified, e.g. only local deps, missing version, not on a registry etc.
|
||||
if (!this.auditedWithKeysCount && !this.verifiedAttestationCount) {
|
||||
throw new Error('found no dependencies to audit that were installed from ' +
|
||||
'a supported registry')
|
||||
}
|
||||
|
||||
const invalid = this.invalid.sort(sortAlphabetically)
|
||||
const missing = this.missing.sort(sortAlphabetically)
|
||||
|
||||
const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0
|
||||
|
||||
if (!hasNoInvalidOrMissing) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (this.npm.config.get('json')) {
|
||||
const result = { invalid, missing }
|
||||
if (this.npm.config.get('include-attestations')) {
|
||||
result.verified = this.verified
|
||||
}
|
||||
output.buffer(result)
|
||||
return
|
||||
}
|
||||
const end = process.hrtime.bigint()
|
||||
const elapsed = end - start
|
||||
|
||||
const auditedPlural = this.auditedWithKeysCount > 1 ? 's' : ''
|
||||
const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` +
|
||||
`${Math.floor(Number(elapsed) / 1e9)}s`
|
||||
output.standard(timing)
|
||||
output.standard()
|
||||
|
||||
const verifiedBold = this.npm.chalk.bold('verified')
|
||||
if (this.verifiedSignatureCount) {
|
||||
if (this.verifiedSignatureCount === 1) {
|
||||
output.standard(`${this.verifiedSignatureCount} package has a ${verifiedBold} registry signature`)
|
||||
} else {
|
||||
output.standard(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`)
|
||||
}
|
||||
output.standard()
|
||||
}
|
||||
|
||||
if (this.verifiedAttestationCount) {
|
||||
if (this.verifiedAttestationCount === 1) {
|
||||
output.standard(`${this.verifiedAttestationCount} package has a ${verifiedBold} attestation`)
|
||||
} else {
|
||||
output.standard(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`)
|
||||
}
|
||||
if (!this.npm.config.get('include-attestations')) {
|
||||
output.standard('(use --json --include-attestations to view attestation details)')
|
||||
}
|
||||
output.standard()
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
const missingClr = this.npm.chalk.redBright('missing')
|
||||
if (missing.length === 1) {
|
||||
output.standard(`1 package has a ${missingClr} registry signature but the registry is providing signing keys:`)
|
||||
} else {
|
||||
output.standard(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`)
|
||||
}
|
||||
output.standard()
|
||||
missing.map(m =>
|
||||
output.standard(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`)
|
||||
)
|
||||
}
|
||||
|
||||
if (invalid.length) {
|
||||
if (missing.length) {
|
||||
output.standard()
|
||||
}
|
||||
const invalidClr = this.npm.chalk.redBright('invalid')
|
||||
// We can have either invalid signatures or invalid provenance
|
||||
const invalidSignatures = this.invalid.filter(i => i.code === 'EINTEGRITYSIGNATURE')
|
||||
if (invalidSignatures.length) {
|
||||
if (invalidSignatures.length === 1) {
|
||||
output.standard(`1 package has an ${invalidClr} registry signature:`)
|
||||
} else {
|
||||
output.standard(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`)
|
||||
}
|
||||
output.standard()
|
||||
invalidSignatures.map(i =>
|
||||
output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
|
||||
)
|
||||
output.standard()
|
||||
}
|
||||
|
||||
const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY')
|
||||
if (invalidAttestations.length) {
|
||||
if (invalidAttestations.length === 1) {
|
||||
output.standard(`1 package has an ${invalidClr} attestation:`)
|
||||
} else {
|
||||
output.standard(`${invalidAttestations.length} packages have ${invalidClr} attestations:`)
|
||||
}
|
||||
output.standard()
|
||||
invalidAttestations.map(i =>
|
||||
output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
|
||||
)
|
||||
output.standard()
|
||||
}
|
||||
|
||||
if (invalid.length === 1) {
|
||||
output.standard(`Someone might have tampered with this package since it was published on the registry!`)
|
||||
} else {
|
||||
output.standard(`Someone might have tampered with these packages since they were published on the registry!`)
|
||||
}
|
||||
output.standard()
|
||||
}
|
||||
}
|
||||
|
||||
getEdgesOut (nodes, filterSet) {
|
||||
const edges = new Set()
|
||||
const registries = new Set()
|
||||
for (const node of nodes) {
|
||||
for (const edge of node.edgesOut.values()) {
|
||||
const filteredOut =
|
||||
edge.from
|
||||
&& filterSet
|
||||
&& filterSet.size > 0
|
||||
&& !filterSet.has(edge.from.target)
|
||||
|
||||
if (!filteredOut) {
|
||||
const spec = this.getEdgeSpec(edge)
|
||||
if (spec) {
|
||||
// Prefetch and cache public keys from used registries
|
||||
registries.add(this.getSpecRegistry(spec))
|
||||
}
|
||||
edges.add(edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { edges, registries }
|
||||
}
|
||||
|
||||
async setKeys ({ registry, tuf }) {
|
||||
const { host, pathname } = new URL(registry)
|
||||
// Strip any trailing slashes from pathname
|
||||
const regKey = `${host}${pathname.replace(/\/$/, '')}/keys.json`
|
||||
let keys = await tuf.getTarget(regKey)
|
||||
.then((target) => JSON.parse(target))
|
||||
.then(({ keys: ks }) => ks.map((key) => ({
|
||||
...key,
|
||||
keyid: key.keyId,
|
||||
pemkey: `-----BEGIN PUBLIC KEY-----\n${key.publicKey.rawBytes}\n-----END PUBLIC KEY-----`,
|
||||
expires: key.publicKey.validFor.end || null,
|
||||
}))).catch(err => {
|
||||
if (err.code === 'TUF_FIND_TARGET_ERROR') {
|
||||
return null
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// If keys not found in Sigstore TUF repo, fall back to registry keys API
|
||||
if (!keys) {
|
||||
log.warn(`Fetching verification keys using TUF failed. Fetching directly from ${registry}.`)
|
||||
keys = await npmFetch.json('/-/npm/v1/keys', {
|
||||
...this.npm.flatOptions,
|
||||
registry,
|
||||
}).then(({ keys: ks }) => ks.map((key) => ({
|
||||
...key,
|
||||
pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`,
|
||||
}))).catch(err => {
|
||||
if (err.code === 'E404' || err.code === 'E400') {
|
||||
return null
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (keys) {
|
||||
this.keys.set(registry, keys)
|
||||
}
|
||||
}
|
||||
|
||||
getEdgeType (edge) {
|
||||
return edge.optional ? 'optionalDependencies'
|
||||
: edge.peer ? 'peerDependencies'
|
||||
: edge.dev ? 'devDependencies'
|
||||
: 'dependencies'
|
||||
}
|
||||
|
||||
getEdgeSpec (edge) {
|
||||
let name = edge.name
|
||||
try {
|
||||
name = npa(edge.spec).subSpec.name
|
||||
} catch {
|
||||
// leave it as edge.name
|
||||
}
|
||||
try {
|
||||
return npa(`${name}@${edge.spec}`)
|
||||
} catch {
|
||||
// Skip packages with invalid spec
|
||||
}
|
||||
}
|
||||
|
||||
buildRegistryConfig (registry) {
|
||||
const keys = this.keys.get(registry) || []
|
||||
const parsedRegistry = new URL(registry)
|
||||
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
|
||||
return {
|
||||
[`${regKey}:_keys`]: keys,
|
||||
}
|
||||
}
|
||||
|
||||
getSpecRegistry (spec) {
|
||||
return npmFetch.pickRegistry(spec, this.npm.flatOptions)
|
||||
}
|
||||
|
||||
getValidPackageInfo (edge) {
|
||||
const type = this.getEdgeType(edge)
|
||||
// Skip potentially optional packages that are not on disk, as these could
|
||||
// be omitted during install
|
||||
if (edge.error === 'MISSING' && type !== 'dependencies') {
|
||||
return
|
||||
}
|
||||
|
||||
const spec = this.getEdgeSpec(edge)
|
||||
// Skip invalid version requirements
|
||||
if (!spec) {
|
||||
return
|
||||
}
|
||||
const node = edge.to || edge
|
||||
const { version } = node.package || {}
|
||||
|
||||
if (node.isWorkspace || // Skip local workspaces packages
|
||||
!version || // Skip packages that don't have an installed version, e.g. optional dependencies
|
||||
!spec.registry) { // Skip if not from registry, e.g. git package
|
||||
return
|
||||
}
|
||||
|
||||
for (const omitType of this.npm.config.get('omit')) {
|
||||
if (node[omitType]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
version,
|
||||
type,
|
||||
location: node.location,
|
||||
registry: this.getSpecRegistry(spec),
|
||||
}
|
||||
}
|
||||
|
||||
async verifySignatures (name, version, registry) {
|
||||
const {
|
||||
_integrity: integrity,
|
||||
_signatures,
|
||||
_attestations,
|
||||
_attestationBundles,
|
||||
_resolved: resolved,
|
||||
} = await pacote.manifest(`${name}@${version}`, {
|
||||
verifySignatures: true,
|
||||
verifyAttestations: true,
|
||||
...this.buildRegistryConfig(registry),
|
||||
...this.npm.flatOptions,
|
||||
})
|
||||
const signatures = _signatures || []
|
||||
const result = {
|
||||
integrity,
|
||||
signatures,
|
||||
attestations: _attestations,
|
||||
attestationBundles: _attestationBundles,
|
||||
resolved,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async getVerifiedInfo (edge) {
|
||||
const info = this.getValidPackageInfo(edge)
|
||||
if (!info) {
|
||||
return
|
||||
}
|
||||
const { name, version, location, registry, type } = info
|
||||
if (this.checkedPackages.has(location)) {
|
||||
// we already did or are doing this one
|
||||
return
|
||||
}
|
||||
this.checkedPackages.add(location)
|
||||
|
||||
// We only "audit" or verify the signature, or the presence of it, on packages whose registry returns signing keys
|
||||
const keys = this.keys.get(registry) || []
|
||||
if (keys.length) {
|
||||
this.auditedWithKeysCount += 1
|
||||
}
|
||||
|
||||
try {
|
||||
const { integrity, signatures, attestations, attestationBundles, resolved } =
|
||||
await this.verifySignatures(name, version, registry)
|
||||
|
||||
// Currently we only care about missing signatures on registries that provide a public key
|
||||
// We could make this configurable in the future with a strict/paranoid mode
|
||||
if (signatures.length) {
|
||||
this.verifiedSignatureCount += 1
|
||||
} else if (keys.length) {
|
||||
this.missing.push({
|
||||
integrity,
|
||||
location,
|
||||
name,
|
||||
registry,
|
||||
resolved,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
// Track verified attestations separately to registry signatures, as all packages on registries with signing keys are expected to have registry signatures, but not all packages have provenance and publish attestations.
|
||||
if (attestations) {
|
||||
this.verifiedAttestationCount += 1
|
||||
if (this.npm.config.get('include-attestations')) {
|
||||
this.verified.push({
|
||||
name,
|
||||
version,
|
||||
location,
|
||||
registry,
|
||||
attestations,
|
||||
attestationBundles,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') {
|
||||
this.invalid.push({
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
integrity: e.integrity,
|
||||
keyid: e.keyid,
|
||||
location,
|
||||
name,
|
||||
registry,
|
||||
resolved: e.resolved,
|
||||
signature: e.signature,
|
||||
predicateType: e.predicateType,
|
||||
type,
|
||||
version,
|
||||
})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VerifySignatures
|
||||
Generated
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
const { log } = require('proc-log')
|
||||
|
||||
// The allowScripts policy MUST live at the project root (RFC npm/rfcs#868).
|
||||
// A non-root workspace declaring its own allowScripts is almost always a
|
||||
// mistake: that policy would be silently ignored at install time.
|
||||
//
|
||||
// `findWorkspaceAllowScripts` returns the list of offending workspace nodes.
|
||||
// `warnWorkspaceAllowScripts` is the side-effecting variant that emits one
|
||||
// install-time `log.warn` per offender.
|
||||
|
||||
const findWorkspaceAllowScripts = (tree) => {
|
||||
const offenders = []
|
||||
if (!tree?.inventory) {
|
||||
return offenders
|
||||
}
|
||||
for (const node of tree.inventory.values()) {
|
||||
if (!node.isWorkspace || node.isProjectRoot) {
|
||||
continue
|
||||
}
|
||||
if (node.package?.allowScripts !== undefined) {
|
||||
offenders.push(node)
|
||||
}
|
||||
}
|
||||
return offenders
|
||||
}
|
||||
|
||||
const warnWorkspaceAllowScripts = (tree) => {
|
||||
for (const node of findWorkspaceAllowScripts(tree)) {
|
||||
const name = node.packageName || node.name
|
||||
log.warn(
|
||||
'allow-scripts',
|
||||
`allowScripts in workspace ${name} (${node.path}) is ignored. ` +
|
||||
'Move the field to the project root package.json.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = warnWorkspaceAllowScripts
|
||||
module.exports.warnWorkspaceAllowScripts = warnWorkspaceAllowScripts
|
||||
module.exports.findWorkspaceAllowScripts = findWorkspaceAllowScripts
|
||||
Reference in new issue
Block a user