ReactFlow前端拖拽模型与参数配置功能MVP

This commit is contained in:
ljz committed 2026-07-11 10:49:11 +08:00
1 parent 70c91ed019
commit d3b99304fb
2001 files changed
+349044 -548

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
const { log } = require('proc-log')
const BaseCommand = require('./base-cmd.js')
// This is the base for all commands whose execWorkspaces just gets a list of workspace names and passes it on to new Arborist() to be able to run a filtered Arborist.reify() at some point.
class ArboristCmd extends BaseCommand {
get isArboristCmd () {
return true
}
static params = [
'workspace',
'workspaces',
'include-workspace-root',
'install-links',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static checkDevEngines = true
constructor (npm) {
super(npm)
const { config } = this.npm
// when location isn't set and global isn't true check for a package.json at the localPrefix and set the location to project if found
const locationProject = config.get('location') === 'project' || (
config.isDefault('location')
// this is different then `npm.global` which falls back to checking
// location which we do not want to use here
&& !config.get('global')
&& npm.localPackage
)
// if audit is not set and we are in global mode and location is not project and we assume its not a project related context, then we set audit=false
if (config.isDefault('audit') && (this.npm.global || !locationProject)) {
config.set('audit', false)
} else if (this.npm.global && config.get('audit')) {
log.warn('config', 'includes both --global and --audit, which is currently unsupported.')
}
}
async execWorkspaces (args) {
await this.setWorkspaces()
return this.exec(args)
}
}
module.exports = ArboristCmd
+435
View File
@@ -0,0 +1,435 @@
const { log } = require('proc-log')
const { definitions, shorthands } = require('@npmcli/config/lib/definitions')
const nopt = require('nopt')
class BaseCommand {
// these defaults can be overridden by individual commands
static workspaces = false
static ignoreImplicitWorkspace = true
static checkDevEngines = false
// these should always be overridden by individual commands
static name = null
static description = null
static params = null
static definitions = null
static subcommands = null
// Number of expected positional arguments (null = unlimited/unchecked)
static positionals = null
// this is a static so that we can read from it without instantiating a command which would require loading the config
static get describeUsage () {
return this.getUsage()
}
static getUsage (parentName = null, includeDescriptions = true) {
const { aliases: cmdAliases } = require('./utils/cmd-list')
const seenExclusive = new Set()
const wrapWidth = 80
const { description, usage = [''], name } = this
// Resolve to a definitions array: if the command has its own definitions, use those directly; otherwise resolve params from the global definitions pool.
let cmdDefs
if (this.definitions) {
cmdDefs = this.definitions
} else if (this.params) {
cmdDefs = this.params.map(p => definitions[p]).filter(Boolean)
}
// If this is a subcommand, prepend parent name
const fullCommandName = parentName ? `${parentName} ${name}` : name
const fullUsage = [
`${description}`,
'',
'Usage:',
]
if (usage) {
fullUsage.push(...usage.map(u => `npm ${fullCommandName} ${u}`.trim()))
}
if (this.subcommands) {
for (const sub in this.subcommands) {
fullUsage.push(`npm ${fullCommandName} ${sub} ${this.subcommands[sub].usage}`)
}
fullUsage.push('')
fullUsage.push('Subcommands:')
const subcommandEntries = Object.entries(this.subcommands)
for (let i = 0; i < subcommandEntries.length; i++) {
const [subName, SubCommand] = subcommandEntries[i]
fullUsage.push(` ${subName}`)
if (SubCommand.description) {
fullUsage.push(` ${SubCommand.description}`)
}
// Add space between subcommands except after the last one
if (i < subcommandEntries.length - 1) {
fullUsage.push('')
}
}
fullUsage.push('')
fullUsage.push(`Run "npm ${name} <subcommand> --help" for more info on a subcommand.`)
}
if (cmdDefs) {
let results = ''
let line = ''
for (const def of cmdDefs) {
/* istanbul ignore next */
if (seenExclusive.has(def.key)) {
continue
}
let paramUsage = def.usage
if (def.exclusive) {
const exclusiveParams = [paramUsage]
for (const e of def.exclusive) {
seenExclusive.add(e)
const eDef = cmdDefs.find(d => d.key === e) || definitions[e]
exclusiveParams.push(eDef?.usage)
}
paramUsage = `${exclusiveParams.join('|')}`
}
paramUsage = `[${paramUsage}]`
if (line.length + paramUsage.length > wrapWidth) {
results = [results, line].filter(Boolean).join('\n')
line = ''
}
line = [line, paramUsage].filter(Boolean).join(' ')
}
fullUsage.push('')
fullUsage.push('Options:')
fullUsage.push([results, line].filter(Boolean).join('\n'))
// Add flag descriptions
if (cmdDefs.length > 0 && includeDescriptions) {
fullUsage.push('')
for (const def of cmdDefs) {
if (def.description) {
const desc = def.description.trim().split('\n')[0]
const shortcuts = def.short ? `-${def.short}` : ''
const aliases = (def.alias || []).map(v => `--${v}`).join('|')
const mainFlag = `--${def.key}`
const flagName = [shortcuts, mainFlag, aliases].filter(Boolean).join('|')
const requiredNote = def.required ? ' (required)' : ''
fullUsage.push(` ${flagName}${requiredNote}`)
fullUsage.push(` ${desc}`)
fullUsage.push('')
}
}
}
}
const aliases = Object.entries(cmdAliases).reduce((p, [k, v]) => {
return p.concat(v === name ? k : [])
}, [])
if (aliases.length) {
const plural = aliases.length === 1 ? '' : 'es'
fullUsage.push('')
fullUsage.push(`alias${plural}: ${aliases.join(', ')}`)
}
fullUsage.push('')
fullUsage.push(`Run "npm help ${name}" for more info`)
return fullUsage.join('\n')
}
constructor (npm) {
this.npm = npm
this.commandArgs = null
const { config } = this
if (!this.constructor.skipConfigValidation) {
config.validate()
}
if (config.get('workspaces') === false && config.get('workspace').length) {
throw new Error('Cannot use --no-workspaces and --workspace at the same time')
}
}
get config () {
// Return command-specific config if it exists, otherwise use npm's config
return this.npm.config
}
get name () {
return this.constructor.name
}
get description () {
return this.constructor.description
}
get params () {
return this.constructor.params
}
get usage () {
return this.constructor.describeUsage
}
usageError (prefix = '') {
if (prefix) {
prefix += '\n\n'
}
return Object.assign(new Error(`\n${prefix}${this.usage}`), {
code: 'EUSAGE',
})
}
// Compare the number of entries with what was expected
checkExpected (entries) {
if (!this.npm.config.isDefault('expect-results')) {
const expected = this.npm.config.get('expect-results')
if (!!entries !== !!expected) {
log.warn(this.name, `Expected ${expected ? '' : 'no '}results, got ${entries}`)
process.exitCode = 1
}
} else if (!this.npm.config.isDefault('expect-result-count')) {
const expected = this.npm.config.get('expect-result-count')
if (expected !== entries) {
log.warn(this.name, `Expected ${expected} result${expected === 1 ? '' : 's'}, got ${entries}`)
process.exitCode = 1
}
}
}
// Checks the devEngines entry in the package.json at this.localPrefix
async checkDevEngines () {
const force = this.npm.flatOptions.force
const { devEngines } = await require('@npmcli/package-json')
.normalize(this.npm.config.localPrefix)
.then(p => p.content)
.catch(() => ({}))
if (typeof devEngines === 'undefined') {
return
}
const { checkDevEngines, currentEnv } = require('npm-install-checks')
const current = currentEnv.devEngines({
nodeVersion: this.npm.nodeVersion,
npmVersion: this.npm.version,
})
const failures = checkDevEngines(devEngines, current)
const warnings = failures.filter(f => f.isWarn)
const errors = failures.filter(f => f.isError)
const genMsg = (failure, i = 0) => {
return [...new Set([
// eslint-disable-next-line
i === 0 ? 'The developer of this package has specified the following through devEngines' : '',
`${failure.message}`,
`${failure.errors.map(e => e.message).join('\n')}`,
])].filter(v => v).join('\n')
}
[...warnings, ...(force ? errors : [])].forEach((failure, i) => {
const message = genMsg(failure, i)
log.warn('EBADDEVENGINES', message)
log.warn('EBADDEVENGINES', {
current: failure.current,
required: failure.required,
})
})
if (force) {
return
}
if (errors.length) {
const failure = errors[0]
const message = genMsg(failure)
throw Object.assign(new Error(message), {
engine: failure.engine,
code: 'EBADDEVENGINES',
current: failure.current,
required: failure.required,
})
}
}
async setWorkspaces () {
const { relative } = require('node:path')
const includeWorkspaceRoot = this.isArboristCmd
? false
: this.npm.config.get('include-workspace-root')
const prefixInsideCwd = relative(this.npm.localPrefix, process.cwd()).startsWith('..')
const relativeFrom = prefixInsideCwd ? this.npm.localPrefix : process.cwd()
const filters = this.npm.config.get('workspace')
const getWorkspaces = require('./utils/get-workspaces.js')
const ws = await getWorkspaces(filters, {
path: this.npm.localPrefix,
includeWorkspaceRoot,
relativeFrom,
})
this.workspaces = ws
this.workspaceNames = [...ws.keys()]
this.workspacePaths = [...ws.values()]
}
flags (depth = 1) {
const commandDefinitions = this.constructor.definitions || []
// Build types, shorthands, and defaults from definitions
const types = {}
const defaults = {}
const cmdShorthands = {}
const aliasMap = {} // Track which aliases map to which main keys
for (const def of commandDefinitions) {
defaults[def.key] = def.default
types[def.key] = def.type
// Handle aliases defined in the definition
if (def.alias && Array.isArray(def.alias)) {
for (const aliasKey of def.alias) {
types[aliasKey] = def.type // Needed for nopt to parse aliases
if (!aliasMap[def.key]) {
aliasMap[def.key] = []
}
aliasMap[def.key].push(aliasKey)
}
}
// Handle short options
if (def.short) {
const shorts = Array.isArray(def.short) ? def.short : [def.short]
for (const short of shorts) {
cmdShorthands[short] = [`--${def.key}`]
}
}
}
// Parse args
let parsed = {}
let remains = []
const argv = this.config.argv
if (argv && argv.length > 0) {
// config.argv contains the full command line including node, npm, and command names
// Format: ['node', 'npm', 'command', 'subcommand', 'positional', '--flags']
// depth tells us how many command names to skip (1 for top-level, 2 for subcommand, etc.)
const offset = 2 + depth // Skip 'node', 'npm', and all command/subcommand names
parsed = nopt(types, cmdShorthands, argv, offset)
remains = parsed.argv.remain
delete parsed.argv
}
// Validate flags - only if command has definitions (new system)
if (this.constructor.definitions && this.constructor.definitions.length > 0) {
this.#validateFlags(parsed, commandDefinitions, remains)
}
// Check for conflicts between main flags and their aliases
// Also map aliases back to their main keys
for (const [mainKey, aliases] of Object.entries(aliasMap)) {
const providedKeys = []
if (mainKey in parsed) {
providedKeys.push(mainKey)
}
for (const alias of aliases) {
if (alias in parsed) {
providedKeys.push(alias)
}
}
if (providedKeys.length > 1) {
const flagList = providedKeys.map(k => `--${k}`).join(' or ')
throw new Error(`Please provide only one of ${flagList}`)
}
// If an alias was provided, map it to the main key
if (providedKeys.length === 1 && providedKeys[0] !== mainKey) {
const aliasKey = providedKeys[0]
parsed[mainKey] = parsed[aliasKey]
delete parsed[aliasKey]
}
}
// Only include keys that are defined in commandDefinitions (main keys only)
const filtered = {}
for (const def of commandDefinitions) {
if (def.key in parsed) {
filtered[def.key] = parsed[def.key]
}
}
return [{ ...defaults, ...filtered }, remains]
}
// Validate flags and throw errors for unknown flags or unexpected positionals
#validateFlags (parsed, commandDefinitions, remains) {
// Build a set of all valid flag names (global + command-specific + shorthands)
const validFlags = new Set([
...Object.keys(definitions),
...commandDefinitions.map(d => d.key),
...Object.keys(shorthands), // Add global shorthands like 'verbose', 'dd', etc.
])
// Add aliases to valid flags
for (const def of commandDefinitions) {
if (def.alias && Array.isArray(def.alias)) {
for (const alias of def.alias) {
validFlags.add(alias)
}
}
}
// Check parsed flags against valid flags
const unknownFlags = []
for (const key of Object.keys(parsed)) {
if (!validFlags.has(key)) {
unknownFlags.push(key)
}
}
// Throw error if unknown flags were found
if (unknownFlags.length > 0) {
const flagList = unknownFlags.map(f => `--${f}`).join(', ')
throw this.usageError(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`)
}
// Remove warnings for command-specific definitions that npm's global config doesn't know about (these were queued as "unknown" during config.load())
for (const def of commandDefinitions) {
this.npm.config.removeWarning(def.key)
if (def.alias && Array.isArray(def.alias)) {
for (const alias of def.alias) {
this.npm.config.removeWarning(alias)
}
}
}
// Remove warnings for unknown positionals that were actually consumed as flag values by command-specific definitions (e.g., --id <value> where --id is command-specific)
const remainsSet = new Set(remains)
for (const unknownPos of this.npm.config.getUnknownPositionals()) {
if (!remainsSet.has(unknownPos)) {
// This value was consumed as a flag value, not truly a positional
this.npm.config.removeUnknownPositional(unknownPos)
}
}
// Warn about extra positional arguments beyond what the command expects
const expectedPositionals = this.constructor.positionals
if (expectedPositionals !== null && remains.length > expectedPositionals) {
const extraPositionals = remains.slice(expectedPositionals)
for (const extra of extraPositionals) {
throw new Error(`Unknown positional argument: ${extra}`)
}
}
this.npm.config.logWarnings()
}
async exec () {
// This method should be overridden by commands
// Subcommand routing is handled in npm.js #exec
}
}
module.exports = BaseCommand
+12
View File
@@ -0,0 +1,12 @@
try {
const { enableCompileCache } = require('node:module')
/* istanbul ignore next */
if (enableCompileCache) {
enableCompileCache()
}
} catch (e) { /* istanbul ignore next */ }
const validateEngines = require('./cli/validate-engines.js')
const cliEntry = require('node:path').resolve(__dirname, 'cli/entry.js')
module.exports = (process) => validateEngines(process, () => require(cliEntry))
+72
View File
@@ -0,0 +1,72 @@
// Separated out for easier unit testing
module.exports = async (process, validateEngines) => {
// set it here so that regardless of what happens later, we don't leak any private CLI configs to other programs
process.title = 'npm'
// Patch the global fs module here at the app level
require('graceful-fs').gracefulify(require('node:fs'))
const satisfies = require('semver/functions/satisfies')
const ExitHandler = require('./exit-handler.js')
const exitHandler = new ExitHandler({ process })
const Npm = require('../npm.js')
const npm = new Npm()
exitHandler.setNpm(npm)
// only log node and npm paths in argv initially since argv can contain sensitive info. a cleaned version will be logged later
const { log, output } = require('proc-log')
log.verbose('cli', process.argv.slice(0, 2).join(' '))
log.info('using', 'npm@%s', npm.version)
log.info('using', 'node@%s', process.version)
// At this point we've required a few files and can be pretty sure we don't contain invalid syntax for this version of node.
// It's possible a lazy require would, but that's unlikely enough that it's not worth catching anymore and we attach the more important exit handlers.
validateEngines.off()
exitHandler.registerUncaughtHandlers()
// It is now safe to log a warning if they are using a version of node that is not going to fail on syntax errors but is still unsupported and untested and might not work reliably.
// This is safe to use the logger now which we want since this will show up in the error log too.
if (!satisfies(validateEngines.node, validateEngines.engines)) {
log.warn('cli', validateEngines.unsupportedMessage)
}
// Now actually fire up npm and run the command.
// This is how to use npm programmatically:
try {
const { exec, command, args } = await npm.load()
if (!exec) {
return exitHandler.exit()
}
if (!command) {
output.standard(npm.usage)
process.exitCode = 1
return exitHandler.exit()
}
// Options are prefixed by a hyphen-minus (-, \u2d).
// Other dash-type chars look similar but are invalid.
const nonDashArgs = npm.argv.filter(a => /^[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/.test(a))
if (nonDashArgs.length) {
log.error(
'arg',
'Argument starts with non-ascii dash, this is probably invalid:',
require('@npmcli/redact').redactLog(nonDashArgs.join(', '))
)
}
const execPromise = npm.exec(command, args)
// this is async but we don't await it, since its ok if it doesnt finish before the command finishes running.
// it uses command and argv so it must be initiated here, after the command name is set
const updateNotifier = require('./update-notifier.js')
// eslint-disable-next-line promise/catch-or-return
updateNotifier(npm).then((msg) => (npm.updateNotification = msg))
await execPromise
return exitHandler.exit()
} catch (err) {
return exitHandler.exit(err)
}
}
+168
View File
@@ -0,0 +1,168 @@
const { log, output, META } = require('proc-log')
const { errorMessage, getExitCodeFromError } = require('../utils/error-message.js')
class ExitHandler {
#npm = null
#process = null
#exited = false
#exitErrorMessage = false
#noNpmError = false
get #hasNpm () {
return !!this.#npm
}
get #loaded () {
return !!this.#npm?.loaded
}
get #showExitErrorMessage () {
if (!this.#loaded) {
return false
}
if (!this.#exited) {
return true
}
return this.#exitErrorMessage
}
get #notLoadedOrExited () {
return !this.#loaded && !this.#exited
}
setNpm (npm) {
this.#npm = npm
}
constructor ({ process }) {
this.#process = process
this.#process.on('exit', this.#handleProcessExitAndReset)
}
registerUncaughtHandlers () {
this.#process.on('uncaughtException', this.#handleExit)
this.#process.on('unhandledRejection', this.#handleExit)
}
exit (err) {
this.#handleExit(err)
}
#handleProcessExitAndReset = (code) => {
this.#handleProcessExit(code)
// Reset all the state. This is only relevant for tests since in reality the process fully exits here.
this.#process.off('exit', this.#handleProcessExitAndReset)
this.#process.off('uncaughtException', this.#handleExit)
this.#process.off('unhandledRejection', this.#handleExit)
if (this.#loaded) {
this.#npm.unload()
}
this.#npm = null
this.#exited = false
this.#exitErrorMessage = false
}
#handleProcessExit (code) {
const numCode = Number(code) || 0
// Always exit w/ a non-zero code if exit handler was not called
const exitCode = this.#exited ? numCode : (numCode || 1)
this.#process.exitCode = exitCode
if (this.#notLoadedOrExited) {
// Exit handler was not called and npm was not loaded so we have to log something
this.#logConsoleError(new Error(`Process exited unexpectedly with code: ${exitCode}`))
return
}
if (this.#logNoNpmError()) {
return
}
const os = require('node:os')
log.verbose('cwd', this.#process.cwd())
log.verbose('os', `${os.type()} ${os.release()}`)
log.verbose('node', this.#process.version)
log.verbose('npm ', `v${this.#npm.version}`)
// only show the notification if it finished
if (typeof this.#npm.updateNotification === 'string') {
log.notice('', this.#npm.updateNotification, { [META]: true, force: true })
}
if (!this.#exited) {
log.error('', 'Exit handler never called!')
log.error('', 'This is an error with npm itself. Please report this error at:')
log.error('', ' <https://github.com/npm/cli/issues>')
if (this.#npm.silent) {
output.error('')
}
}
log.verbose('exit', exitCode)
if (exitCode) {
log.verbose('code', exitCode)
} else {
log.info('ok')
}
if (this.#showExitErrorMessage) {
log.error('', this.#npm.exitErrorMessage())
}
}
#logConsoleError (err) {
// Run our error message formatters on all errors even if we have no npm or an unloaded npm.
// This will clean the error and possible return a formatted message about EACCESS or something.
const { summary, detail } = errorMessage(err, this.#npm)
const formatted = [...new Set([...summary, ...detail].flat().filter(Boolean))].join('\n')
// If we didn't get anything from the formatted message then just display the full stack
// eslint-disable-next-line no-console
console.error(formatted === err.message ? err.stack : formatted)
}
#logNoNpmError (err) {
if (this.#hasNpm) {
return false
}
// Make sure we only log this error once
if (!this.#noNpmError) {
this.#noNpmError = true
this.#logConsoleError(
new Error(`Exit prior to setting npm in exit handler`, err ? { cause: err } : {})
)
}
return true
}
#handleExit = (err) => {
this.#exited = true
// No npm at all
if (this.#logNoNpmError(err)) {
return this.#process.exit(this.#process.exitCode || getExitCodeFromError(err) || 1)
}
// npm was never loaded but we still might have a config loading error or something similar that we can run through the error message formatter to give the user a clue as to what happened.
if (!this.#loaded) {
this.#logConsoleError(new Error('Exit prior to config file resolving', { cause: err }))
return this.#process.exit(this.#process.exitCode || getExitCodeFromError(err) || 1)
}
this.#exitErrorMessage = err?.suppressError === true ? false : !!err
// Prefer the exit code of the error, then the current process exit code, then set it to 1 if we still have an error.
// Otherwise, we call process.exit with undefined so that it can determine the final exit code
const exitCode = err?.exitCode ?? this.#process.exitCode ?? (err ? 1 : undefined)
// explicitly call process.exit now so we don't hang on things like the update notifier
// also flush stdout/err beforehand because process.exit doesn't wait for that to happen.
this.#process.stderr.write('', () => this.#process.stdout.write('', () => {
this.#process.exit(exitCode)
}))
}
}
module.exports = ExitHandler
@@ -0,0 +1,115 @@
// print a banner telling the user to upgrade npm to latest but not in CI, and not if we're doing that already.
const ciInfo = require('ci-info')
const gt = require('semver/functions/gt')
const gte = require('semver/functions/gte')
const parse = require('semver/functions/parse')
const { stat, writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')
// update check frequency
const DAILY = 1000 * 60 * 60 * 24
const WEEKLY = DAILY * 7
// don't put it in the _cacache folder, just in npm's cache
const lastCheckedFile = npm => resolve(npm.flatOptions.cache, '../_update-notifier-last-checked')
// Actual check for updates. This is a separate function so that we only load this if we are doing the actual update
const updateCheck = async (npm, spec, version, current) => {
const pacote = require('pacote')
const mani = await pacote.manifest(`npm@${spec}`, {
// always prefer latest, even if doing --tag=whatever on the cmd
defaultTag: 'latest',
...npm.flatOptions,
cache: false,
}).catch(() => null)
// if pacote failed, give up
if (!mani) {
return null
}
const latest = mani.version
// if the current version is *greater* than latest, we're on a 'next' and should get the updates from that release train.
// Note that this isn't another http request over the network, because the packument will be cached by pacote from previous request.
if (gt(version, latest) && spec === '*') {
return updateNotifier(npm, `^${version}`)
}
// if we already have something >= the desired spec, then we're done
if (gte(version, latest)) {
return null
}
const chalk = npm.logChalk
// ok! notify the user about this update they should get.
// The message is saved for printing at process exit so it will not get lost in any other messages being printed as part of the command.
const update = parse(mani.version)
const type = update.major !== current.major ? 'major'
: update.minor !== current.minor ? 'minor'
: update.patch !== current.patch ? 'patch'
: 'prerelease'
const typec = type === 'major' ? 'red'
: type === 'minor' ? 'yellow'
: 'cyan'
const message = [
'',
`New ${chalk[typec](type)} version of npm available! ${chalk[typec](current)} -> ${chalk.blue(latest)}`,
`Changelog: ${chalk.blue(`https://github.com/npm/cli/releases/tag/v${latest}`)}`,
`To update run: ${chalk.underline(`npm install -g npm@${latest}`)}`,
'',
].join('\n')
return message
}
const updateNotifier = async (npm, spec = '*') => {
// if we're on a prerelease train, then updates are coming fast check for a new one daily.
// otherwise, weekly.
const { version } = npm
const current = parse(version)
// if we're on a beta train, always get the next beta
if (current.prerelease.length) {
spec = `^${version}`
}
// while on a beta train, get updates daily
const duration = current.prerelease.length ? DAILY : WEEKLY
const t = new Date(Date.now() - duration)
// if we don't have a file, then definitely check it.
const st = await stat(lastCheckedFile(npm)).catch(() => ({ mtime: t - 1 }))
// if we've already checked within the specified duration, don't check again
if (!(t > st.mtime)) {
return null
}
// intentional. do not await this. it's a best-effort update.
// if this fails, it's ok.
// might be using /dev/null as the cache or something weird like that.
writeFile(lastCheckedFile(npm), '').catch(() => {})
return updateCheck(npm, spec, version, current)
}
module.exports = async npm => {
if (
// opted out
!npm.config.get('update-notifier')
// global npm update
|| (npm.flatOptions.global &&
['install', 'update'].includes(npm.command) &&
npm.argv.some(arg => /^npm(@|$)/.test(arg)))
// CI
|| ciInfo.isCI
) {
return null
}
return updateNotifier(npm)
}
@@ -0,0 +1,43 @@
// This is separate to indicate that it should contain code we expect to work in all versions of node >= 6.
// This is a best effort to catch syntax errors to give users a good error message if they are using a node version that doesn't allow syntax we are using such as private properties, etc.
// This file is linted with ecmaVersion=6 so we don't use invalid syntax, which is set in the .eslintrc.local.json file
const { engines: { node: engines }, version } = require('../../package.json')
const npm = `v${version}`
module.exports = (process, getCli) => {
const node = process.version
const unsupportedMessage = `npm ${npm} does not support Node.js ${node}. This version of npm supports the following node versions: \`${engines}\`. You can find the latest version at https://nodejs.org/.`
const brokenMessage = `ERROR: npm ${npm} is known not to run on Node.js ${node}. This version of npm supports the following node versions: \`${engines}\`. You can find the latest version at https://nodejs.org/.`
// coverage ignored because this is only hit in very unsupported node versions and it's a best effort attempt to show something nice in those cases
/* istanbul ignore next */
const syntaxErrorHandler = (err) => {
if (err instanceof SyntaxError) {
// eslint-disable-next-line no-console
console.error(`${brokenMessage}\n\nERROR:`)
// eslint-disable-next-line no-console
console.error(err)
return process.exit(1)
}
throw err
}
process.on('uncaughtException', syntaxErrorHandler)
process.on('unhandledRejection', syntaxErrorHandler)
// require this only after setting up the error handlers
const cli = getCli()
return cli(process, {
node,
npm,
engines,
unsupportedMessage,
off: () => {
process.off('uncaughtException', syntaxErrorHandler)
process.off('unhandledRejection', syntaxErrorHandler)
},
})
}
+226
View File
@@ -0,0 +1,226 @@
const libnpmaccess = require('libnpmaccess')
const npa = require('npm-package-arg')
const { output } = require('proc-log')
const pkgJson = require('@npmcli/package-json')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const { otplease } = require('../utils/auth.js')
const getIdentity = require('../utils/get-identity.js')
const BaseCommand = require('../base-cmd.js')
const commands = [
'get',
'grant',
'list',
'revoke',
'set',
]
const setCommands = [
'status=public',
'status=private',
'mfa=none',
'mfa=publish',
'mfa=automation',
'2fa=none',
'2fa=publish',
'2fa=automation',
]
class Access extends BaseCommand {
static description = 'Set access level on published packages'
static name = 'access'
static params = [
'json',
'otp',
'registry',
]
static usage = [
'list packages [<user>|<scope>|<scope:team>] [<package>]',
'list collaborators [<package> [<user>]]',
'get status [<package>]',
'set status=public|private [<package>]',
'set mfa=none|publish|automation [<package>]',
'grant <read-only|read-write> <scope:team> [<package>]',
'revoke <scope:team> [<package>]',
]
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return commands
}
if (argv.length === 3) {
switch (argv[2]) {
case 'grant':
return ['read-only', 'read-write']
case 'revoke':
return []
case 'list':
case 'ls':
return ['packages', 'collaborators']
case 'get':
return ['status']
case 'set':
return setCommands
default:
throw new Error(argv[2] + ' not recognized')
}
}
}
async exec ([cmd, subcmd, ...args]) {
if (!cmd) {
throw this.usageError()
}
if (!commands.includes(cmd)) {
throw this.usageError(`${cmd} is not a valid access command`)
}
// All commands take at least one more parameter so we can do this check up front
if (!subcmd) {
throw this.usageError()
}
switch (cmd) {
case 'grant':
if (!['read-only', 'read-write'].includes(subcmd)) {
throw this.usageError('grant must be either `read-only` or `read-write`')
}
if (!args[0]) {
throw this.usageError('`<scope:team>` argument is required')
}
return this.#grant(subcmd, args[0], args[1])
case 'revoke':
return this.#revoke(subcmd, args[0])
case 'list':
case 'ls':
if (subcmd === 'packages') {
return this.#listPackages(args[0], args[1])
}
if (subcmd === 'collaborators') {
return this.#listCollaborators(args[0], args[1])
}
throw this.usageError(`list ${subcmd} is not a valid access command`)
case 'get':
if (subcmd !== 'status') {
throw this.usageError(`get ${subcmd} is not a valid access command`)
}
return this.#getStatus(args[0])
case 'set':
if (!setCommands.includes(subcmd)) {
throw this.usageError(`set ${subcmd} is not a valid access command`)
}
return this.#set(subcmd, args[0])
}
}
async #grant (permissions, scope, pkg) {
await otplease(this.npm, this.npm.flatOptions, async (opts) => {
await libnpmaccess.setPermissions(scope, pkg, permissions, opts)
})
}
async #revoke (scope, pkg) {
await otplease(this.npm, this.npm.flatOptions, async (opts) => {
await libnpmaccess.removePermissions(scope, pkg, opts)
})
}
async #listPackages (owner, pkg) {
if (!owner) {
owner = await getIdentity(this.npm, this.npm.flatOptions)
}
const pkgs = await libnpmaccess.getPackages(owner, this.npm.flatOptions)
this.#output(pkgs, pkg)
}
async #listCollaborators (pkg, user) {
const pkgName = await this.#getPackage(pkg, false)
const collabs = await libnpmaccess.getCollaborators(pkgName, this.npm.flatOptions)
this.#output(collabs, user)
}
async #getStatus (pkg) {
const pkgName = await this.#getPackage(pkg, false)
const visibility = await libnpmaccess.getVisibility(pkgName, this.npm.flatOptions)
this.#output({ [pkgName]: visibility.public ? 'public' : 'private' })
}
async #set (subcmd, pkg) {
const [subkey, subval] = subcmd.split('=')
switch (subkey) {
case 'mfa':
case '2fa':
return this.#setMfa(pkg, subval)
case 'status':
return this.#setStatus(pkg, subval)
}
}
async #setMfa (pkg, level) {
const pkgName = await this.#getPackage(pkg, false)
await otplease(this.npm, this.npm.flatOptions, (opts) => {
return libnpmaccess.setMfa(pkgName, level, opts)
})
}
async #setStatus (pkg, status) {
// only scoped packages can have their access changed
const pkgName = await this.#getPackage(pkg, true)
if (status === 'private') {
status = 'restricted'
}
await otplease(this.npm, this.npm.flatOptions, (opts) => {
return libnpmaccess.setAccess(pkgName, status, opts)
})
return this.#getStatus(pkgName)
}
async #getPackage (name, requireScope) {
if (!name) {
try {
const { content } = await pkgJson.normalize(this.npm.prefix)
name = content.name
} catch (err) {
if (err.code === 'ENOENT') {
throw Object.assign(new Error('no package name given and no package.json found'), {
code: 'ENOENT',
})
} else {
throw err
}
}
}
const spec = npa(name)
if (requireScope && !spec.scope) {
throw this.usageError('This command is only available for scoped packages.')
}
return name
}
#output (items, limiter) {
const outputs = {}
const lookup = {
__proto__: null,
read: 'read-only',
write: 'read-write',
}
for (const item in items) {
const val = items[item]
outputs[item] = lookup[val] || val
}
if (this.npm.config.get('json')) {
output.buffer(outputs)
} else {
for (const item of Object.keys(outputs).sort(localeCompare)) {
if (!limiter || limiter === item) {
output.standard(`${item}: ${outputs[item]}`)
}
}
}
}
}
module.exports = Access
+50
View File
@@ -0,0 +1,50 @@
const { log, output } = require('proc-log')
const { redactLog: replaceInfo } = require('@npmcli/redact')
const auth = require('../utils/auth.js')
const BaseCommand = require('../base-cmd.js')
class AddUser extends BaseCommand {
static description = 'Add a registry user account'
static name = 'adduser'
static params = [
'registry',
'scope',
'auth-type',
]
async exec () {
const scope = this.npm.config.get('scope')
let registry = this.npm.config.get('registry')
if (scope) {
const scopedRegistry = this.npm.config.get(`${scope}:registry`)
const cliRegistry = this.npm.config.get('registry', 'cli')
if (scopedRegistry && !cliRegistry) {
registry = scopedRegistry
}
}
const creds = this.npm.config.getCredentialsByURI(registry)
log.notice('', `Log in on ${replaceInfo(registry)}`)
const { message, newCreds } = await auth.adduser(this.npm, {
...this.npm.flatOptions,
creds,
registry,
})
this.npm.config.delete('_token', 'user') // prevent legacy pollution
this.npm.config.setCredentialsByURI(registry, newCreds)
if (scope) {
this.npm.config.set(scope + ':registry', registry, 'user')
}
await this.npm.config.save('user')
output.standard(message)
}
}
module.exports = AddUser
@@ -0,0 +1,10 @@
const AllowScriptsCmd = require('../utils/allow-scripts-cmd.js')
class ApproveScripts extends AllowScriptsCmd {
static description = 'Approve install scripts for specific dependencies'
static name = 'approve-scripts'
static usage = ['<pkg> [<pkg> ...]', '--all', '--allow-scripts-pending']
static verb = 'approve'
}
module.exports = ApproveScripts
+122
View File
@@ -0,0 +1,122 @@
const npmAuditReport = require('npm-audit-report')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const auditError = require('../utils/audit-error.js')
const { log, output } = require('proc-log')
const reifyFinish = require('../utils/reify-finish.js')
const VerifySignatures = require('../utils/verify-signatures.js')
class Audit extends ArboristWorkspaceCmd {
static description = 'Run a security audit'
static name = 'audit'
static params = [
'audit-level',
'dry-run',
'force',
'json',
'package-lock-only',
'package-lock',
'omit',
'include',
'foreground-scripts',
'ignore-scripts',
'include-attestations',
...super.params,
]
static usage = ['[fix|signatures]']
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return ['fix', 'signatures']
}
switch (argv[2]) {
case 'fix':
case 'signatures':
return []
default:
throw Object.assign(new Error(`${argv[2]} not recognized`), {
code: 'EUSAGE',
})
}
}
async exec (args) {
if (args[0] === 'signatures') {
await this.auditSignatures()
} else {
await this.auditAdvisories(args)
}
}
async auditAdvisories (args) {
const fix = args[0] === 'fix'
if (this.npm.config.get('package-lock') === false && fix) {
throw this.usageError('fix cannot be used without a package-lock')
}
const reporter = this.npm.config.get('json') ? 'json' : 'detail'
const Arborist = require('@npmcli/arborist')
const opts = {
...this.npm.flatOptions,
audit: true,
path: this.npm.prefix,
reporter,
workspaces: this.workspaceNames,
}
const arb = new Arborist(opts)
await arb.audit({ fix })
if (fix) {
await reifyFinish(this.npm, arb)
} else {
// will throw if there's an error, because this is an audit command
auditError(this.npm, arb.auditReport)
const result = npmAuditReport(arb.auditReport, {
...opts,
chalk: this.npm.chalk,
})
process.exitCode = process.exitCode || result.exitCode
output.standard(result.report)
}
}
async auditSignatures () {
if (this.npm.global) {
throw Object.assign(
new Error('`npm audit signatures` does not support global packages'), {
code: 'EAUDITGLOBAL',
}
)
}
log.verbose('audit', 'loading installed dependencies')
const Arborist = require('@npmcli/arborist')
const opts = {
...this.npm.flatOptions,
path: this.npm.prefix,
workspaces: this.workspaceNames,
}
const arb = new Arborist(opts)
const tree = await arb.loadActual()
let filterSet = new Set()
if (opts.workspaces && opts.workspaces.length) {
filterSet =
arb.workspaceDependencySet(
tree,
opts.workspaces,
this.npm.flatOptions.includeWorkspaceRoot
)
} else if (!this.npm.flatOptions.workspacesEnabled) {
filterSet =
arb.excludeWorkspacesDependencySet(tree)
}
const verify = new VerifySignatures(tree, filterSet, this.npm, { ...opts })
await verify.run()
}
}
module.exports = Audit
+34
View File
@@ -0,0 +1,34 @@
const PackageUrlCmd = require('../package-url-cmd.js')
class Bugs extends PackageUrlCmd {
static description = 'Report bugs for a package in a web browser'
static name = 'bugs'
getUrl (spec, mani) {
if (mani.bugs) {
if (typeof mani.bugs === 'string') {
return mani.bugs
}
if (typeof mani.bugs === 'object' && mani.bugs.url) {
return mani.bugs.url
}
if (typeof mani.bugs === 'object' && mani.bugs.email) {
return `mailto:${mani.bugs.email}`
}
}
// try to get it from the repo, if possible
const info = this.hostedFromMani(mani)
const infoUrl = info?.bugs()
if (infoUrl) {
return infoUrl
}
// just send them to the website, hopefully that has some info!
return `https://www.npmjs.com/package/${mani.name}`
}
}
module.exports = Bugs
+363
View File
@@ -0,0 +1,363 @@
const fs = require('node:fs/promises')
const { join } = require('node:path')
const cacache = require('cacache')
const pacote = require('pacote')
const semver = require('semver')
const npa = require('npm-package-arg')
const jsonParse = require('json-parse-even-better-errors')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const { log, output } = require('proc-log')
const PkgJson = require('@npmcli/package-json')
const abbrev = require('abbrev')
const BaseCommand = require('../base-cmd.js')
const searchCachePackage = async (path, parsed, cacheKeys) => {
const searchMFH = new RegExp(`^make-fetch-happen:request-cache:.*(?<!/[@a-zA-Z]+)/${parsed.name}/-/(${parsed.name}[^/]+.tgz)$`)
const searchPack = new RegExp(`^make-fetch-happen:request-cache:.*/${parsed.escapedName}$`)
const results = new Set()
cacheKeys = new Set(cacheKeys)
for (const key of cacheKeys) {
// match on the public key registry url format
if (searchMFH.test(key)) {
// extract the version from the filename
const filename = key.match(searchMFH)[1]
const noExt = filename.slice(0, -4)
const noScope = `${parsed.name.split('/').pop()}-`
const ver = noExt.slice(noScope.length)
if (semver.satisfies(ver, parsed.rawSpec)) {
results.add(key)
}
continue
}
// is this key a packument?
if (!searchPack.test(key)) {
continue
}
results.add(key)
let packument, details
try {
details = await cacache.get(path, key)
packument = jsonParse(details.data)
} catch {
// if we couldn't parse the packument, abort
continue
}
if (!packument.versions || typeof packument.versions !== 'object') {
continue
}
// assuming this is a packument
for (const ver of Object.keys(packument.versions)) {
if (semver.satisfies(ver, parsed.rawSpec)) {
if (packument.versions[ver].dist &&
typeof packument.versions[ver].dist === 'object' &&
packument.versions[ver].dist.tarball !== undefined &&
cacheKeys.has(`make-fetch-happen:request-cache:${packument.versions[ver].dist.tarball}`)
) {
results.add(`make-fetch-happen:request-cache:${packument.versions[ver].dist.tarball}`)
}
}
}
}
return results
}
class Cache extends BaseCommand {
static description = 'Manipulates packages and npx cache'
static name = 'cache'
static params = ['cache']
static usage = [
'add <package-spec>',
'clean [<key>]',
'ls [<name>@<version>]',
'verify',
'npx ls',
'npx rm [<key>...]',
'npx info <key>...',
]
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return ['add', 'clean', 'verify', 'ls', 'npx']
}
// TODO - eventually...
switch (argv[2]) {
case 'verify':
case 'clean':
case 'add':
case 'ls':
return []
}
}
async exec (args) {
const cmd = args.shift()
switch (cmd) {
case 'rm': case 'clear': case 'clean':
return await this.clean(args)
case 'add':
return await this.add(args)
case 'verify': case 'check':
return await this.verify()
case 'ls':
return await this.ls(args)
case 'npx':
return await this.npx(args)
default:
throw this.usageError()
}
}
// npm cache npx
async npx ([cmd, ...keys]) {
switch (cmd) {
case 'ls':
return await this.npxLs(keys)
case 'rm':
return await this.npxRm(keys)
case 'info':
return await this.npxInfo(keys)
default:
throw this.usageError()
}
}
// npm cache clean [spec]*
async clean (args) {
// this is a derived value
const cachePath = this.npm.flatOptions.cache
if (args.length === 0) {
if (!this.npm.config.get('force')) {
throw new Error(`As of npm@5, the npm cache self-heals from corruption issues by treating integrity mismatches as cache misses.
As a result, data extracted from the cache is guaranteed to be valid.
If you want to make sure everything is consistent, use \`npm cache verify\` instead.
Deleting the cache can only make npm go slower, and is not likely to correct any problems you may be encountering!
On the other hand, if you're debugging an issue with the installer, or race conditions that depend on the timing of writing to an empty cache, you can use \`npm install --cache /tmp/empty-cache\` to use a temporary cache instead of removing the actual one.
If you're sure you want to delete the entire cache, rerun this command with --force.`)
}
return fs.rm(cachePath, { recursive: true, force: true })
}
for (const key of args) {
let entry
try {
entry = await cacache.get(cachePath, key)
} catch {
log.warn('cache', `Not Found: ${key}`)
break
}
output.standard(`Deleted: ${key}`)
await cacache.rm.entry(cachePath, key)
// XXX this could leave other entries without content!
await cacache.rm.content(cachePath, entry.integrity)
}
}
// npm cache add <tarball-url>...
// npm cache add <pkg> <ver>...
// npm cache add <tarball>...
// npm cache add <folder>...
async add (args) {
log.silly('cache add', 'args', args)
if (args.length === 0) {
throw this.usageError('First argument to `add` is required')
}
await Promise.all(args.map(async spec => {
log.silly('cache add', 'spec', spec)
// we ask pacote for the thing, and then just throw the data away so that it tee-pipes it into the cache like it does for a normal request.
await pacote.tarball.stream(spec, stream => {
stream.resume()
return stream.promise()
}, { ...this.npm.flatOptions, _isRoot: true })
await pacote.manifest(spec, {
...this.npm.flatOptions,
fullMetadata: true,
_isRoot: true,
})
}))
}
async verify () {
// this is a derived value
const cachePath = this.npm.flatOptions.cache
const prefix = cachePath.indexOf(process.env.HOME) === 0
? `~${cachePath.slice(process.env.HOME.length)}`
: cachePath
const stats = await cacache.verify(cachePath)
output.standard(`Cache verified and compressed (${prefix})`)
output.standard(`Content verified: ${stats.verifiedContent} (${stats.keptSize} bytes)`)
if (stats.badContentCount) {
output.standard(`Corrupted content removed: ${stats.badContentCount}`)
}
if (stats.reclaimedCount) {
output.standard(`Content garbage-collected: ${stats.reclaimedCount} (${stats.reclaimedSize} bytes)`)
}
if (stats.missingContent) {
output.standard(`Missing content: ${stats.missingContent}`)
}
output.standard(`Index entries: ${stats.totalEntries}`)
output.standard(`Finished in ${stats.runTime.total / 1000}s`)
}
// npm cache ls [<spec> ...]
async ls (specs) {
// This is a derived value
const { cache: cachePath } = this.npm.flatOptions
const cacheKeys = Object.keys(await cacache.ls(cachePath))
if (specs.length > 0) {
// get results for each package spec specified
const results = new Set()
for (const spec of specs) {
const parsed = npa(spec)
if (parsed.rawSpec !== '' && parsed.type === 'tag') {
throw this.usageError('Cannot list cache keys for a tagged package.')
}
const keySet = await searchCachePackage(cachePath, parsed, cacheKeys)
for (const key of keySet) {
results.add(key)
}
}
[...results].sort(localeCompare).forEach(key => output.standard(key))
return
}
cacheKeys.sort(localeCompare).forEach(key => output.standard(key))
}
async #npxCache (keys = []) {
// This is a derived value
const { npxCache } = this.npm.flatOptions
let dirs
try {
dirs = await fs.readdir(npxCache, { encoding: 'utf-8' })
} catch {
output.standard('npx cache does not exist')
return
}
const cache = {}
const { default: pMap } = await import('p-map')
await pMap(dirs, async e => {
const pkgPath = join(npxCache, e)
cache[e] = {
hash: e,
path: pkgPath,
valid: false,
}
try {
const pkgJson = await PkgJson.load(pkgPath)
cache[e].package = pkgJson.content
cache[e].valid = true
} catch {
// Defaults to not valid already
}
}, { concurrency: 20 })
if (!keys.length) {
return cache
}
const result = {}
const abbrevs = abbrev(Object.keys(cache))
for (const key of keys) {
if (!abbrevs[key]) {
throw this.usageError(`Invalid npx key ${key}`)
}
result[abbrevs[key]] = cache[abbrevs[key]]
}
return result
}
async npxLs () {
const cache = await this.#npxCache()
for (const key in cache) {
const { hash, valid, package: pkg } = cache[key]
let result = `${hash}:`
if (!valid) {
result = `${result} (empty/invalid)`
} else if (pkg?._npx) {
result = `${result} ${pkg._npx.packages.join(', ')}`
} else {
result = `${result} (unknown)`
}
output.standard(result)
}
}
async npxRm (keys) {
if (!keys.length) {
if (!this.npm.config.get('force')) {
throw this.usageError('Please use --force to remove entire npx cache')
}
const { npxCache } = this.npm.flatOptions
if (!this.npm.config.get('dry-run')) {
return fs.rm(npxCache, { recursive: true, force: true })
}
}
const cache = await this.#npxCache(keys)
for (const key in cache) {
const { path: cachePath } = cache[key]
output.standard(`Removing npx key at ${cachePath}`)
if (!this.npm.config.get('dry-run')) {
await fs.rm(cachePath, { recursive: true })
}
}
}
async npxInfo (keys) {
const chalk = this.npm.chalk
if (!keys.length) {
throw this.usageError()
}
const cache = await this.#npxCache(keys)
const Arborist = require('@npmcli/arborist')
for (const key in cache) {
const { hash, path, package: pkg } = cache[key]
let valid = cache[key].valid
const results = []
try {
if (valid) {
const arb = new Arborist({ path })
const tree = await arb.loadVirtual()
if (pkg._npx) {
results.push('packages:')
for (const p of pkg._npx.packages) {
const parsed = npa(p)
if (parsed.type === 'directory') {
// in the tree the spec is relative, even if the dependency spec is absolute, so we can't find it by name or spec.
results.push(`- ${chalk.cyan(p)}`)
} else {
results.push(`- ${chalk.cyan(p)} (${chalk.blue(tree.children.get(parsed.name).pkgid)})`)
}
}
} else {
results.push('packages: (unknown)')
results.push(`dependencies:`)
for (const dep in pkg.dependencies) {
const child = tree.children.get(dep)
if (child.isLink) {
results.push(`- ${chalk.cyan(child.realpath)}`)
} else {
results.push(`- ${chalk.cyan(child.pkgid)}`)
}
}
}
}
} catch (ex) {
valid = false
}
const v = valid ? chalk.green('valid') : chalk.red('invalid')
output.standard(`${v} npx cache entry with key ${chalk.blue(hash)}`)
output.standard(`location: ${chalk.blue(path)}`)
if (valid) {
output.standard(results.join('\n'))
}
output.standard()
}
}
}
module.exports = Cache
+138
View File
@@ -0,0 +1,138 @@
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
const runScript = require('@npmcli/run-script')
const fs = require('node:fs/promises')
const path = require('node:path')
const { log, time } = require('proc-log')
const validateLockfile = require('../utils/validate-lockfile.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getWorkspaces = require('../utils/get-workspaces.js')
class CI extends ArboristWorkspaceCmd {
static description = 'Clean install a project'
static name = 'ci'
// These are in the order they will show up in when running "-h"
static params = [
'install-strategy',
'legacy-bundling',
'global-style',
'omit',
'include',
'strict-peer-deps',
'foreground-scripts',
'ignore-scripts',
'allow-directory',
'allow-file',
'allow-git',
'allow-remote',
'allow-scripts',
'strict-allow-scripts',
'dangerously-allow-all-scripts',
'audit',
'bin-links',
'fund',
'dry-run',
...super.params,
]
async exec () {
if (this.npm.global) {
throw Object.assign(new Error('`npm ci` does not work for global packages'), {
code: 'ECIGLOBAL',
})
}
const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
const opts = {
...this.npm.flatOptions,
packageLock: true, // npm ci should never skip lock files
path: where,
save: false, // npm ci should never modify the lockfile or package.json
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
}
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()
await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts })
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
includeWorkspaceRoot: true,
})
// Only remove node_modules after we've successfully loaded the virtual tree and validated the lockfile
await time.start('npm-ci:rm', async () => {
return await Promise.all([...workspacePaths.values()].map(async modulePath => {
const fullPath = path.join(modulePath, 'node_modules')
// get the list of entries so we can skip the glob for performance
const entries = await fs.readdir(fullPath, null).catch(() => [])
return Promise.all(entries.map(folder => {
return fs.rm(path.join(fullPath, folder), { force: true, recursive: true })
}))
}))
})
}
await arb.reify(opts)
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
'preinstall',
'install',
'postinstall',
'prepublish', // XXX should we remove this finally??
'preprepare',
'prepare',
'postprepare',
]
const scriptShell = this.npm.config.get('script-shell') || undefined
for (const event of scripts) {
await runScript({
path: where,
args: [],
scriptShell,
stdio: 'inherit',
event,
})
}
}
await reifyFinish(this.npm, arb)
}
}
module.exports = CI
@@ -0,0 +1,329 @@
// Each command has a completion function that takes an options object and a cb The callback gets called with an error and an array of possible completions.
// The options object is built up based on the environment variables set by zsh or bash when calling a function for completion, based on the cursor position and the command line thus far.
// These are:
// COMP_CWORD: the index of the "word" in the command line being completed
// COMP_LINE: the full command line thus far as a string
// COMP_POINT: the cursor index at the point of triggering completion
//
// We parse the command line with nopt, like npm does, and then create an options object containing:
// words: array of words in the command line
// w: the index of the word being completed (ie, COMP_CWORD)
// word: the word being completed
// line: the COMP_LINE
// lineLength
// point: the COMP_POINT, usually equal to line length, but not always, eg if the user has pressed the left-arrow to complete an earlier word
// partialLine: the line up to the point
// partialWord: the word being completed (which might be ''), up to the point
// conf: a nopt parse of the command line
//
// When the implementation completion method returns its list of strings, and arrays of strings, we filter that by any that start with the partialWord, since only those can possibly be valid matches.
//
// Matches are wrapped with ' to escape them, if necessary, and then printed one per line for the shell completion method to consume in IFS=$'\n' mode as an array.
const fs = require('node:fs/promises')
const nopt = require('nopt')
const { resolve } = require('node:path')
const { output } = require('proc-log')
const Npm = require('../npm.js')
const { definitions, shorthands } = require('@npmcli/config/lib/definitions')
const { commands, aliases, deref } = require('../utils/cmd-list.js')
const { isWindowsShell } = require('../utils/is-windows.js')
const BaseCommand = require('../base-cmd.js')
const fileExists = (file) => fs.stat(file).then(s => s.isFile()).catch(() => false)
class Completion extends BaseCommand {
static description = 'Tab Completion for npm'
static name = 'completion'
// Completion command uses args differently - they represent the command line being completed, not actual arguments to this command, so we use an empty definitions object to prevent flag validation
static definitions = []
// completion for the completion command
static async completion (opts) {
if (opts.w > 2) {
return
}
const [bashExists, zshExists] = await Promise.all([
fileExists(resolve(process.env.HOME, '.bashrc')),
fileExists(resolve(process.env.HOME, '.zshrc')),
])
const out = []
if (zshExists) {
out.push(['>>', '~/.zshrc'])
}
if (bashExists) {
out.push(['>>', '~/.bashrc'])
}
return out
}
async exec (args) {
if (isWindowsShell) {
const msg = 'npm completion supported only in MINGW / Git bash on Windows'
throw Object.assign(new Error(msg), {
code: 'ENOTSUP',
})
}
const { COMP_CWORD, COMP_LINE, COMP_POINT, COMP_FISH } = process.env
// if the COMP_* isn't in the env, then just dump the script.
if (COMP_CWORD === undefined || COMP_LINE === undefined || COMP_POINT === undefined) {
return dumpScript(resolve(this.npm.npmRoot, 'lib', 'utils', 'completion.sh'))
}
// ok we're actually looking at the envs and outputting the suggestions get the partial line and partial word, if the point isn't at the end.
// ie, tabbing at: npm foo b|ar
const w = +COMP_CWORD
const line = COMP_LINE
// Use COMP_LINE to get words if args doesn't include flags (e.g., in tests)
const hasFlags = line.includes(' -') && !args.some(arg => arg.startsWith('-'))
const words = (hasFlags ? line.split(/\s+/) : args).map(unescape)
const word = words[w] || ''
const point = +COMP_POINT
const partialLine = line.slice(0, point)
const partialWords = words.slice(0, w)
// figure out where in that last word the point is.
const partialWordRaw = args[w] || ''
let i = partialWordRaw.length
while (partialWordRaw.slice(0, i) !== partialLine.slice(-1 * i) && i > 0) {
i--
}
const partialWord = unescape(partialWordRaw.slice(0, i))
partialWords.push(partialWord)
const opts = {
isFish: COMP_FISH === 'true',
words,
w,
word,
line,
lineLength: line.length,
point,
partialLine,
partialWords,
partialWord,
raw: args,
}
// try to find the npm command and subcommand early for flag completion this helps with custom command definitions from subcommands
const types = Object.entries(definitions).reduce((acc, [key, def]) => {
acc[key] = def.type
return acc
}, {})
const parsed = opts.conf =
nopt(types, shorthands, partialWords.slice(0, -1), 0)
const cmd = parsed.argv.remain[1]
const subCmd = parsed.argv.remain[2]
if (partialWords.slice(0, -1).indexOf('--') === -1) {
if (word && word.charAt(0) === '-') {
return this.wrap(opts, configCompl(opts, cmd, subCmd, this.npm))
}
if (words[w - 1] &&
words[w - 1].charAt(0) === '-' &&
!isFlag(words[w - 1], cmd, subCmd, this.npm)) {
// awaiting a value for a non-bool config.
// don't even try to do this for now
return this.wrap(opts, configValueCompl(opts))
}
}
// check if there's a command already.
if (!cmd) {
return this.wrap(opts, cmdCompl(opts, this.npm))
}
Object.keys(parsed).forEach(k => this.npm.config.set(k, parsed[k]))
// at this point, if words[1] is some kind of npm command, then complete on it.
// otherwise, do nothing
try {
const { completion } = Npm.cmd(cmd)
if (completion) {
const comps = await completion(opts, this.npm)
return this.wrap(opts, comps)
}
} catch {
// it wasn't a valid command, so do nothing
}
}
// The command should respond with an array.
// Loop over that, wrapping quotes around any that have spaces, and writing them to stdout.
// If any of the items are arrays, then join them with a space.
// e.g. returning ['a', 'b c', ['d', 'e']] would allow it to expand to: 'a', 'b c', or 'd' 'e'
wrap (opts, compls) {
if (opts.partialWord) {
compls = compls.filter(c => c.startsWith(opts.partialWord))
}
if (compls.length > 0) {
output.standard(compls.join('\n'))
}
}
}
const dumpScript = async (p) => {
const d = (await fs.readFile(p, 'utf8')).replace(/^#!.*?\n/, '')
await new Promise((res, rej) => {
let done = false
process.stdout.on('error', er => {
if (done) {
return
}
done = true
// Darwin is a pain sometimes.
//
// This is necessary because the "source" or "." program in bash on OS X closes its file argument before reading from it, meaning that you get exactly 1 write, which will work most of the time, and will always raise an EPIPE.
//
// Really, one should not be tossing away EPIPE errors, or any errors, so casually.
// But, without this, `. <(npm completion)` can never ever work on OS X.
// TODO Ignoring coverage, see 'non EPIPE errors cause failures' test.
/* istanbul ignore next */
if (er.errno === 'EPIPE') {
res()
} else {
rej(er)
}
})
process.stdout.write(d, () => {
if (done) {
return
}
done = true
res()
})
})
}
const unescape = w => w.charAt(0) === '\'' ? w.replace(/^'|'$/g, '')
: w.replace(/\\ /g, ' ')
// Helper to get custom definitions from a command/subcommand
const getCustomDefinitions = (cmd, subCmd) => {
if (!cmd) {
return []
}
try {
const command = Npm.cmd(cmd)
// Check if the command has subcommands
if (subCmd && command.subcommands && command.subcommands[subCmd]) {
const subcommand = command.subcommands[subCmd]
// All subcommands have definitions
return subcommand.definitions
}
// Check if the command itself has definitions
if (command.definitions) {
return command.definitions
}
} catch {
// Command not found or no definitions
}
return []
}
// Helper to get all config names including aliases from custom definitions
const getCustomConfigNames = (customDefs) => {
const names = new Set()
for (const def of customDefs) {
names.add(def.key)
if (def.alias && Array.isArray(def.alias)) {
def.alias.forEach(a => names.add(a))
}
}
return [...names]
}
// the current word has a dash.
// Return the config names with the same number of dashes as the current word has.
const configCompl = (opts, cmd, subCmd, npm) => {
const word = opts.word
const split = word.match(/^(-+)((?:no-)*)(.*)$/)
const dashes = split[1]
const no = split[2]
// Get custom definitions from the command/subcommand
const customDefs = getCustomDefinitions(cmd, subCmd, npm)
const customNames = getCustomConfigNames(customDefs)
// If there are custom definitions, return only those (new feature)
// Otherwise, return empty array (historical behavior - no global flag completion)
if (customNames.length > 0) {
const flags = customNames.filter(name => isFlag(name, cmd, subCmd, npm))
return customNames.map(c => dashes + c)
.concat(flags.map(f => dashes + (no || 'no-') + f))
}
return []
}
// expand with the valid values of various config values.
// not yet implemented.
const configValueCompl = () => []
// check if the thing is a flag or not.
const isFlag = (word, cmd, subCmd, npm) => {
// shorthands never take args.
const split = word.match(/^(-*)((?:no-)+)?(.*)$/)
const no = split[2]
const conf = split[3]
// Check custom definitions first
const customDefs = getCustomDefinitions(cmd, subCmd, npm)
// Check if conf is in custom definitions or is an alias
let customDef = customDefs.find(d => d.key === conf)
if (!customDef) {
// Check if conf is an alias for any of the custom definitions
for (const def of customDefs) {
if (def.alias && Array.isArray(def.alias) && def.alias.includes(conf)) {
customDef = def
break
}
}
}
if (customDef) {
const { type } = customDef
return no ||
type === Boolean ||
(Array.isArray(type) && type.includes(Boolean))
}
// No custom definitions found, should not reach here in normal flow since configCompl returns empty array when no custom defs exist
return false
}
// complete against the npm commands
// if they all resolve to the same thing, just return the thing it already is
const cmdCompl = (opts) => {
const allCommands = commands.concat(Object.keys(aliases))
const matches = allCommands.filter(c => c.startsWith(opts.partialWord))
if (!matches.length) {
return matches
}
const derefs = new Set([...matches.map(c => deref(c))])
if (derefs.size === 1) {
return [...derefs]
}
return allCommands
}
module.exports = Completion
+414
View File
@@ -0,0 +1,414 @@
const { mkdir, readFile, writeFile } = require('node:fs/promises')
const { dirname, resolve } = require('node:path')
const { spawn } = require('node:child_process')
const { EOL } = require('node:os')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const pkgJson = require('@npmcli/package-json')
const { defaults, definitions, nerfDarts, proxyEnv } = require('@npmcli/config/lib/definitions')
const { log, output, input } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
const { redact } = require('@npmcli/redact')
// These are the config values to swap with "protected".
// It does not catch every single sensitive thing a user may put in the npmrc file but it gets the common ones.
// This is distinct from nerfDarts because that is used to validate valid configs during "npm config set", and folks may have old invalid entries lying around in a config file that we still want to protect when running "npm config list"
// This is a more general list of values to consider protected.
// You cannot "npm config get" them, and they will not display during "npm config list"
const protected = [
'auth',
'authToken',
'certfile',
'email',
'keyfile',
'password',
'username',
]
// take an array of `[key, value, k2=v2, k3, v3, ...]` and turn into { key: value, k2: v2, k3: v3 }
const keyValues = args => {
const kv = {}
for (let i = 0; i < args.length; i++) {
const arg = args[i].split('=')
const key = arg.shift()
const val = arg.length ? arg.join('=')
: i < args.length - 1 ? args[++i]
: ''
kv[key.trim()] = val.trim()
}
return kv
}
const isProtected = (k) => {
// _password
if (k.startsWith('_')) {
return true
}
if (protected.includes(k)) {
return true
}
// //localhost:8080/:_password
if (k.startsWith('//')) {
if (k.includes(':_')) {
return true
}
// //registry:_authToken or //registry:authToken
for (const p of protected) {
if (k.endsWith(`:${p}`) || k.endsWith(`:_${p}`)) {
return true
}
}
}
return false
}
// Private fields are either protected or they can redacted info
const isPrivate = (k, v) => isProtected(k) || redact(v) !== v
const displayVar = (k, v) =>
`${k} = ${isProtected(k, v) ? '(protected)' : JSON.stringify(redact(v))}`
class Config extends BaseCommand {
static description = 'Manage the npm configuration files'
static name = 'config'
static usage = [
'set <key>=<value> [<key>=<value> ...]',
'get [<key> [<key> ...]]',
'delete <key> [<key> ...]',
'list [--json]',
'edit',
'fix',
]
static params = [
'json',
'global',
'editor',
'location',
'long',
]
static ignoreImplicitWorkspace = false
static skipConfigValidation = true
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv[1] !== 'config') {
argv.unshift('config')
}
if (argv.length === 2) {
const cmds = ['get', 'set', 'delete', 'ls', 'rm', 'edit', 'fix']
if (opts.partialWord !== 'l') {
cmds.push('list')
}
return cmds
}
const action = argv[2]
switch (action) {
case 'set':
// TODO: complete with valid values, if possible.
if (argv.length > 3) {
return []
}
// fallthrough
/* eslint no-fallthrough:0 */
case 'get':
case 'delete':
case 'rm':
return Object.keys(definitions)
case 'edit':
case 'list':
case 'ls':
case 'fix':
default:
return []
}
}
async exec ([action, ...args]) {
switch (action) {
case 'set':
await this.set(args)
break
case 'get':
await this.get(args)
break
case 'delete':
case 'rm':
case 'del':
await this.del(args)
break
case 'list':
case 'ls':
await (this.npm.flatOptions.json ? this.listJson() : this.list())
break
case 'edit':
await this.edit()
break
case 'fix':
await this.fix()
break
default:
throw this.usageError()
}
}
async set (args) {
if (!args.length) {
throw this.usageError()
}
const where = this.npm.flatOptions.location
for (const [key, val] of Object.entries(keyValues(args))) {
log.info('config', 'set %j %j', key, val)
const baseKey = key.split(':').pop()
if (!this.npm.config.definitions[baseKey] && !nerfDarts.includes(baseKey)) {
throw new Error(`\`${baseKey}\` is not a valid npm option`)
}
const deprecated = this.npm.config.definitions[baseKey]?.deprecated
if (deprecated) {
throw new Error(
`The \`${baseKey}\` option is deprecated, and cannot be set in this way${deprecated}`
)
}
if (val === '') {
this.npm.config.delete(key, where)
} else {
this.npm.config.set(key, val, where)
}
if (!this.npm.config.validate(where)) {
log.warn('config', 'omitting invalid config values')
}
}
await this.npm.config.save(where)
}
async get (keys) {
if (!keys.length) {
return this.list()
}
const out = []
for (const key of keys) {
const val = this.npm.config.get(key)
if (isPrivate(key, val)) {
throw new Error(`The ${key} option is protected, and cannot be retrieved in this way`)
}
const pref = keys.length > 1 ? `${key}=` : ''
out.push(pref + val)
}
output.standard(out.join('\n'))
}
async del (keys) {
if (!keys.length) {
throw this.usageError()
}
const where = this.npm.flatOptions.location
for (const key of keys) {
this.npm.config.delete(key, where)
}
await this.npm.config.save(where)
}
async edit () {
const ini = require('ini')
const e = this.npm.flatOptions.editor
const where = this.npm.flatOptions.location
const file = this.npm.config.data.get(where).source
// save first, just to make sure it's synced up
// this also removes all the comments from the last time we edited it.
await this.npm.config.save(where)
const data = (
await readFile(file, 'utf8').catch(() => '')
).replace(/\r\n/g, '\n')
const entries = Object.entries(defaults)
const defData = entries.reduce((str, [key, val]) => {
const obj = { [key]: val }
const i = ini.stringify(obj)
.replace(/\r\n/g, '\n') // normalizes output from ini.stringify
.replace(/\n$/m, '')
.replace(/^/g, '; ')
.replace(/\n/g, '\n; ')
.split('\n')
return str + '\n' + i
}, '')
const tmpData = `;;;;
; npm ${where}config file: ${file}
; this is a simple ini-formatted file
; lines that start with semi-colons are comments
; run \`npm help 7 config\` for documentation of the various options
;
; Configs like \`@scope:registry\` map a scope to a given registry url.
;
; Configs like \`//<hostname>/:_authToken\` are auth that is restricted
; to the registry host specified.
${data.split('\n').sort(localeCompare).join('\n').trim()}
;;;;
; all available options shown below with default values
;;;;
${defData}
`.split('\n').join(EOL)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, tmpData, 'utf8')
await input.start(() => new Promise((res, rej) => {
const [bin, ...args] = e.split(/\s+/)
const editor = spawn(bin, [...args, file], { stdio: 'inherit' })
editor.on('exit', (code) => {
if (code) {
return rej(new Error(`editor process exited with code: ${code}`))
}
return res()
})
}))
}
async fix () {
let problems
try {
this.npm.config.validate()
return // if validate doesn't throw we have nothing to do
} catch (err) {
// coverage skipped because we don't need to test rethrowing errors
// istanbul ignore next
if (err.code !== 'ERR_INVALID_AUTH') {
throw err
}
problems = err.problems
}
if (!this.npm.config.isDefault('location')) {
problems = problems.filter((problem) => {
return problem.where === this.npm.config.get('location')
})
}
this.npm.config.repair(problems)
const locations = []
output.standard('The following configuration problems have been repaired:\n')
const summary = problems.map(({ action, from, to, key, where }) => {
// coverage disabled for else branch because it is intentionally omitted
// istanbul ignore else
if (action === 'rename') {
// we keep track of which configs were modified here so we know what to save later
locations.push(where)
return `~ \`${from}\` renamed to \`${to}\` in ${where} config`
} else if (action === 'delete') {
locations.push(where)
return `- \`${key}\` deleted from ${where} config`
}
}).join('\n')
output.standard(summary)
return await Promise.all(locations.map((location) => this.npm.config.save(location)))
}
async list () {
const msg = []
// long does not have a flattener
const long = this.npm.config.get('long')
for (const [where, { data, source }] of this.npm.config.data.entries()) {
if (where === 'default' && !long) {
continue
}
const entries = Object.entries(data).sort(([a], [b]) => localeCompare(a, b))
if (!entries.length) {
continue
}
msg.push(`; "${where}" config from ${source}`, '')
for (const [k, v] of entries) {
const display = displayVar(k, v)
const src = this.npm.config.find(k)
msg.push(src === where ? display : `; ${display} ; overridden by ${src}`)
msg.push()
}
msg.push('')
}
if (!long) {
const envVars = []
const foundEnvVars = new Set()
for (const key of Object.keys(process.env)) {
const lowerKey = key.toLowerCase()
if (proxyEnv.includes(lowerKey) && !foundEnvVars.has(lowerKey)) {
foundEnvVars.add(lowerKey)
envVars.push(`; ${key} = ${JSON.stringify(process.env[key])}`)
}
}
if (envVars.length > 0) {
msg.push('; environment-related config', '')
msg.push(...envVars)
msg.push('')
}
msg.push(
`; node bin location = ${process.execPath}`,
`; node version = ${process.version}`,
`; npm local prefix = ${this.npm.localPrefix}`,
`; npm version = ${this.npm.version}`,
`; cwd = ${process.cwd()}`,
`; HOME = ${process.env.HOME}`,
'; Run `npm config ls -l` to show all defaults.'
)
msg.push('')
}
if (!this.npm.global) {
const { content } = await pkgJson.normalize(this.npm.prefix).catch(() => ({ content: {} }))
if (content.publishConfig) {
for (const key in content.publishConfig) {
this.npm.config.checkUnknown('publishConfig', key)
}
const pkgPath = resolve(this.npm.prefix, 'package.json')
msg.push(`; "publishConfig" from ${pkgPath}`)
msg.push('; This set of config values will be used at publish-time.', '')
const entries = Object.entries(content.publishConfig)
.sort(([a], [b]) => localeCompare(a, b))
for (const [k, value] of entries) {
msg.push(displayVar(k, value))
}
msg.push('')
}
}
output.standard(msg.join('\n').trim())
}
async listJson () {
const publicConf = {}
for (const key in this.npm.config.list[0]) {
const value = this.npm.config.get(key)
if (isPrivate(key, value)) {
continue
}
publicConf[key] = value
}
output.buffer(publicConf)
}
}
module.exports = Config
+54
View File
@@ -0,0 +1,54 @@
const reifyFinish = require('../utils/reify-finish.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
// dedupe duplicated packages, or find them in the tree
class Dedupe extends ArboristWorkspaceCmd {
static description = 'Reduce duplication in the package tree'
static name = 'dedupe'
static params = [
'install-strategy',
'legacy-bundling',
'global-style',
'strict-peer-deps',
'package-lock',
'omit',
'include',
'ignore-scripts',
'allow-directory',
'allow-file',
'allow-git',
'allow-remote',
'audit',
'bin-links',
'fund',
'dry-run',
...super.params,
]
async exec () {
if (this.npm.global) {
const er = new Error('`npm dedupe` does not work in global mode.')
er.code = 'EDEDUPEGLOBAL'
throw er
}
const dryRun = this.npm.config.get('dry-run')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
...this.npm.flatOptions,
path: where,
dryRun,
// Saving during dedupe would only update if one of your direct dependencies was also duplicated somewhere in your tree.
// It would be confusing if running this were to also update your package.json.
// In order to reduce potential confusion we set this to false.
save: false,
workspaces: this.workspaceNames,
}
const arb = new Arborist(opts)
await arb.dedupe(opts)
await reifyFinish(this.npm, arb)
}
}
module.exports = Dedupe
@@ -0,0 +1,10 @@
const AllowScriptsCmd = require('../utils/allow-scripts-cmd.js')
class DenyScripts extends AllowScriptsCmd {
static description = 'Deny install scripts for specific dependencies'
static name = 'deny-scripts'
static usage = ['<pkg> [<pkg> ...]', '--all']
static verb = 'deny'
}
module.exports = DenyScripts
@@ -0,0 +1,86 @@
const npmFetch = require('npm-registry-fetch')
const { otplease } = require('../utils/auth.js')
const npa = require('npm-package-arg')
const { log } = require('proc-log')
const semver = require('semver')
const getIdentity = require('../utils/get-identity.js')
const libaccess = require('libnpmaccess')
const BaseCommand = require('../base-cmd.js')
class Deprecate extends BaseCommand {
static description = 'Deprecate a version of a package'
static name = 'deprecate'
static usage = ['<package-spec> <message>']
static params = [
'registry',
'otp',
'dry-run',
]
static ignoreImplicitWorkspace = true
static async completion (opts, npm) {
if (opts.conf.argv.remain.length > 1) {
return []
}
const username = await getIdentity(npm, npm.flatOptions)
const packages = await libaccess.getPackages(username, npm.flatOptions)
return Object.keys(packages)
.filter((name) =>
packages[name] === 'write' &&
(opts.conf.argv.remain.length === 0 ||
name.startsWith(opts.conf.argv.remain[0])))
}
async exec ([pkg, msg]) {
// msg == null because '' is a valid value, it indicates undeprecate
if (!pkg || msg == null) {
throw this.usageError()
}
// fetch the data and make sure it exists.
const p = npa(pkg)
const spec = p.rawSpec === '*' ? '*' : p.fetchSpec
if (semver.validRange(spec, true) === null) {
throw new Error(`invalid version range: ${spec}`)
}
const uri = '/' + p.escapedName
const packument = await npmFetch.json(uri, {
...this.npm.flatOptions,
spec: p,
query: { write: true },
})
const versions = Object.keys(packument.versions)
.filter(v => semver.satisfies(v, spec, { includePrerelease: true }))
const dryRun = this.npm.config.get('dry-run')
if (versions.length) {
for (const v of versions) {
packument.versions[v].deprecated = msg
if (msg) {
log.notice(`deprecating ${packument.name}@${v} with message "${msg}"`)
} else {
log.notice(`undeprecating ${packument.name}@${v}`)
}
}
if (!dryRun) {
return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
...opts,
spec: p,
method: 'PUT',
body: packument,
ignoreBody: true,
}))
}
} else {
log.warn('deprecate', 'No version found for', p.rawSpec)
}
}
}
module.exports = Deprecate
+280
View File
@@ -0,0 +1,280 @@
const { resolve } = require('node:path')
const semver = require('semver')
const libnpmdiff = require('libnpmdiff')
const npa = require('npm-package-arg')
const pacote = require('pacote')
const pickManifest = require('npm-pick-manifest')
const { log, output } = require('proc-log')
const pkgJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
class Diff extends BaseCommand {
static description = 'The registry diff command'
static name = 'diff'
static usage = [
'[...<paths>]',
]
static params = [
'diff',
'diff-name-only',
'diff-unified',
'diff-ignore-all-space',
'diff-no-prefix',
'diff-src-prefix',
'diff-dst-prefix',
'diff-text',
'global',
'tag',
'workspace',
'workspaces',
'include-workspace-root',
]
static workspaces = true
static ignoreImplicitWorkspace = false
async exec (args) {
const specs = this.npm.config.get('diff').filter(d => d)
if (specs.length > 2) {
throw this.usageError(`Can't use more than two --diff arguments.`)
}
// execWorkspaces may have set this already
if (!this.prefix) {
this.prefix = this.npm.prefix
}
// this is the "top" directory, one up from node_modules in global mode we have to walk one up from globalDir because our node_modules is sometimes under ./lib, and in global mode we're only ever walking through node_modules (because we will have been given a package name already)
if (this.npm.global) {
this.top = resolve(this.npm.globalDir, '..')
} else {
this.top = this.prefix
}
const [a, b] = await this.retrieveSpecs(specs)
log.info('diff', { src: a, dst: b })
const res = await libnpmdiff([a, b], {
...this.npm.flatOptions,
diffFiles: args,
where: this.top,
})
return output.standard(res)
}
async execWorkspaces (args) {
await this.setWorkspaces()
for (const workspacePath of this.workspacePaths) {
this.top = workspacePath
this.prefix = workspacePath
await this.exec(args)
}
}
// get the package name from the packument at `path`
// throws if no packument is present OR if it does not have `name` attribute
async packageName () {
let name
try {
const { content: pkg } = await pkgJson.normalize(this.prefix)
name = pkg.name
} catch {
log.verbose('diff', 'could not read project dir package.json')
}
if (!name) {
throw this.usageError('Needs multiple arguments to compare or run from a project dir.')
}
return name
}
async retrieveSpecs ([a, b]) {
if (a && b) {
const specs = await this.convertVersionsToSpecs([a, b])
return this.findVersionsByPackageName(specs)
}
// no arguments, defaults to comparing cwd to its latest published registry version
if (!a) {
const pkgName = await this.packageName()
return [
`${pkgName}@${this.npm.config.get('tag')}`,
`file:${this.prefix}`,
]
}
// single argument, used to compare wanted versions of an installed dependency or to compare the cwd to a published version
let noPackageJson
let pkgName
try {
const { content: pkg } = await pkgJson.normalize(this.prefix)
pkgName = pkg.name
} catch {
log.verbose('diff', 'could not read project dir package.json')
noPackageJson = true
}
const missingPackageJson =
this.usageError('Needs multiple arguments to compare or run from a project dir.')
// using a valid semver range, that means it should just diff the cwd against a published version to the registry using the same project name and the provided semver range
if (semver.validRange(a)) {
if (!pkgName) {
throw missingPackageJson
}
return [
`${pkgName}@${a}`,
`file:${this.prefix}`,
]
}
// when using a single package name as arg and it's part of the current install tree, then retrieve the current installed version and compare it against the same value `npm outdated` would suggest you to update to
const spec = npa(a)
if (spec.registry) {
let actualTree
let node
const Arborist = require('@npmcli/arborist')
try {
const opts = {
...this.npm.flatOptions,
path: this.top,
}
const arb = new Arborist(opts)
actualTree = await arb.loadActual(opts)
node = actualTree &&
actualTree.inventory.query('name', spec.name)
.values().next().value
} catch {
log.verbose('diff', 'failed to load actual install tree')
}
if (!node || !node.name || !node.package || !node.package.version) {
if (noPackageJson) {
throw missingPackageJson
}
return [
`${spec.name}@${spec.fetchSpec}`,
`file:${this.prefix}`,
]
}
const tryRootNodeSpec = () =>
(actualTree && actualTree.edgesOut.get(spec.name) || {}).spec
const tryAnySpec = () => {
for (const edge of node.edgesIn) {
return edge.spec
}
}
const aSpec = `file:${node.realpath}`
// finds what version of the package to compare against
// if an exact version or tag was passed than it should use that
// otherwise, work from the top of the arborist tree to find the original semver range declared in the package that depends on the package.
let bSpec
if (spec.rawSpec !== '*') {
bSpec = spec.rawSpec
} else {
const bTargetVersion =
tryRootNodeSpec()
|| tryAnySpec()
// figure out what to compare against
// follows same logic to npm outdated "Wanted" results
const packument = await pacote.packument(spec, {
...this.npm.flatOptions,
preferOnline: true,
_isRoot: true,
})
bSpec = pickManifest(
packument,
bTargetVersion,
{ ...this.npm.flatOptions }
).version
}
return [
`${spec.name}@${aSpec}`,
`${spec.name}@${bSpec}`,
]
} else if (spec.type === 'directory') {
return [
`file:${spec.fetchSpec}`,
`file:${this.prefix}`,
]
} else {
throw this.usageError(`Spec type ${spec.type} not supported.`)
}
}
async convertVersionsToSpecs ([a, b]) {
const semverA = semver.validRange(a)
const semverB = semver.validRange(b)
// both specs are semver versions, assume current project dir name
if (semverA && semverB) {
let pkgName
try {
const { content: pkg } = await pkgJson.normalize(this.prefix)
pkgName = pkg.name
} catch {
log.verbose('diff', 'could not read project dir package.json')
}
if (!pkgName) {
throw this.usageError('Needs to be run from a project dir in order to diff two versions.')
}
return [`${pkgName}@${a}`, `${pkgName}@${b}`]
}
// otherwise uses the name from the other arg to figure out the spec.name of what to compare
if (!semverA && semverB) {
return [a, `${npa(a).name}@${b}`]
}
if (semverA && !semverB) {
return [`${npa(b).name}@${a}`, b]
}
// no valid semver ranges used
return [a, b]
}
async findVersionsByPackageName (specs) {
let actualTree
const Arborist = require('@npmcli/arborist')
try {
const opts = {
...this.npm.flatOptions,
path: this.top,
}
const arb = new Arborist(opts)
actualTree = await arb.loadActual(opts)
} catch {
log.verbose('diff', 'failed to load actual install tree')
}
return specs.map(i => {
const spec = npa(i)
if (spec.rawSpec !== '*') {
return i
}
const node = actualTree
&& actualTree.inventory.query('name', spec.name)
.values().next().value
const res = !node || !node.package || !node.package.version
? spec.fetchSpec
: `file:${node.realpath}`
return `${spec.name}@${res}`
})
}
}
module.exports = Diff
+207
View File
@@ -0,0 +1,207 @@
const npa = require('npm-package-arg')
const npmFetch = require('npm-registry-fetch')
const semver = require('semver')
const { log, output } = require('proc-log')
const { otplease } = require('../utils/auth.js')
const pkgJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
class DistTag extends BaseCommand {
static description = 'Modify package distribution tags'
static params = ['workspace', 'workspaces', 'include-workspace-root']
static name = 'dist-tag'
static usage = [
'add <package-spec (with version)> [<tag>]',
'rm <package-spec> <tag>',
'ls [<package-spec>]',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return ['add', 'rm', 'ls']
}
switch (argv[2]) {
default:
return []
}
}
async exec ([cmdName, pkg, tag]) {
const opts = {
...this.npm.flatOptions,
}
if (['add', 'a', 'set', 's'].includes(cmdName)) {
return this.add(pkg, tag, opts)
}
if (['rm', 'r', 'del', 'd', 'remove'].includes(cmdName)) {
return this.remove(pkg, tag, opts)
}
if (['ls', 'l', 'sl', 'list'].includes(cmdName)) {
return this.list(pkg, opts)
}
if (!pkg) {
// when only using the pkg name the default behavior should be listing the existing tags
return this.list(cmdName, opts)
} else {
throw this.usageError()
}
}
async execWorkspaces ([cmdName, pkg, tag]) {
// cmdName is some form of list
// pkg is one of:
// - unset
// - .
// - .@version
if (['ls', 'l', 'sl', 'list'].includes(cmdName) && (!pkg || pkg === '.' || /^\.@/.test(pkg))) {
return this.listWorkspaces()
}
// pkg is unset
// cmdName is one of:
// - unset
// - .
// - .@version
if (!pkg && (!cmdName || cmdName === '.' || /^\.@/.test(cmdName))) {
return this.listWorkspaces()
}
// anything else is just a regular dist-tag command
// so we fall back to the non-workspaces implementation
log.warn('dist-tag', 'Ignoring workspaces for specified package')
return this.exec([cmdName, pkg, tag])
}
async add (spec, tag, opts) {
spec = npa(spec || '')
const version = spec.rawSpec
const defaultTag = tag || this.npm.config.get('tag')
log.verbose('dist-tag add', defaultTag, 'to', spec.name + '@' + version)
// make sure new spec with tag is valid, this will throw if invalid
npa(`${spec.name}@${defaultTag}`)
if (!spec.name || !version || !defaultTag) {
throw this.usageError('must provide a spec with a name and version, and a tag to add')
}
const t = defaultTag.trim()
if (semver.validRange(t)) {
throw new Error('Tag name must not be a valid SemVer range: ' + t)
}
const tags = await this.fetchTags(spec, opts)
if (tags[t] === version) {
log.warn('dist-tag add', t, 'is already set to version', version)
return
}
tags[t] = version
const url =
`/-/package/${spec.escapedName}/dist-tags/${encodeURIComponent(t)}`
const reqOpts = {
...opts,
method: 'PUT',
body: JSON.stringify(version),
headers: {
'content-type': 'application/json',
},
spec,
}
await otplease(this.npm, reqOpts, o => npmFetch(url, o))
output.standard(`+${t}: ${spec.name}@${version}`)
}
async remove (spec, tag, opts) {
spec = npa(spec || '')
log.verbose('dist-tag del', tag, 'from', spec.name)
if (!spec.name) {
throw this.usageError()
}
const tags = await this.fetchTags(spec, opts)
if (!tags[tag]) {
log.info('dist-tag del', tag, 'is not a dist-tag on', spec.name)
throw new Error(tag + ' is not a dist-tag on ' + spec.name)
}
const version = tags[tag]
delete tags[tag]
const url =
`/-/package/${spec.escapedName}/dist-tags/${encodeURIComponent(tag)}`
const reqOpts = {
...opts,
method: 'DELETE',
spec,
}
await otplease(this.npm, reqOpts, o => npmFetch(url, o))
output.standard(`-${tag}: ${spec.name}@${version}`)
}
async list (spec, opts) {
if (!spec) {
if (this.npm.global) {
throw this.usageError()
}
const { content: { name } } = await pkgJson.normalize(this.npm.prefix)
if (!name) {
throw this.usageError()
}
return this.list(name, opts)
}
spec = npa(spec)
try {
const tags = await this.fetchTags(spec, opts)
const msg =
Object.keys(tags).map(k => `${k}: ${tags[k]}`).sort().join('\n')
output.standard(msg)
return tags
} catch (err) {
log.error('dist-tag ls', "Couldn't get dist-tag data for", spec)
throw err
}
}
async listWorkspaces () {
await this.setWorkspaces()
for (const name of this.workspaceNames) {
try {
output.standard(`${name}:`)
await this.list(npa(name), this.npm.flatOptions)
} catch {
// set the exitCode directly, but ignore the error since it will have already been logged by this.list()
process.exitCode = 1
}
}
}
async fetchTags (spec, opts) {
const data = await npmFetch.json(
`/-/package/${spec.escapedName}/dist-tags`,
{ ...opts, 'prefer-online': true, spec }
)
if (data && typeof data === 'object') {
delete data._etag
}
if (!data || !Object.keys(data).length) {
throw new Error('No dist-tags found for ' + spec.name)
}
return data
}
}
module.exports = DistTag
+21
View File
@@ -0,0 +1,21 @@
const PackageUrlCmd = require('../package-url-cmd.js')
class Docs extends PackageUrlCmd {
static description = 'Open documentation for a package in a web browser'
static name = 'docs'
getUrl (spec, mani) {
if (mani.homepage) {
return mani.homepage
}
const info = this.hostedFromMani(mani)
if (info) {
return info.docs()
}
return `https://www.npmjs.com/package/${mani.name}`
}
}
module.exports = Docs
+343
View File
@@ -0,0 +1,343 @@
const cacache = require('cacache')
const { access, lstat, readdir, constants: { R_OK, W_OK, X_OK } } = require('node:fs/promises')
const npmFetch = require('make-fetch-happen')
const which = require('which')
const pacote = require('pacote')
const { resolve } = require('node:path')
const semver = require('semver')
const { log, output } = require('proc-log')
const ping = require('../utils/ping.js')
const { defaults } = require('@npmcli/config/lib/definitions')
const BaseCommand = require('../base-cmd.js')
const maskLabel = mask => {
const label = []
if (mask & R_OK) {
label.push('readable')
}
if (mask & W_OK) {
label.push('writable')
}
if (mask & X_OK) {
label.push('executable')
}
return label.join(', ')
}
const checks = [
{
// Ping is left in as a legacy command but is listed as "connection" to make more sense to more people
groups: ['connection', 'ping', 'registry'],
title: 'Connecting to the registry',
cmd: 'checkPing',
}, {
groups: ['versions'],
title: 'Checking npm version',
cmd: 'getLatestNpmVersion',
}, {
groups: ['versions'],
title: 'Checking node version',
cmd: 'getLatestNodejsVersion',
}, {
groups: ['registry'],
title: 'Checking configured npm registry',
cmd: 'checkNpmRegistry',
}, {
groups: ['environment'],
title: 'Checking for git executable in PATH',
cmd: 'getGitPath',
}, {
groups: ['environment'],
title: 'Checking for global bin folder in PATH',
cmd: 'getBinPath',
}, {
groups: ['permissions', 'cache'],
title: 'Checking permissions on cached files (this may take awhile)',
cmd: 'checkCachePermission',
windows: false,
}, {
groups: ['permissions'],
title: 'Checking permissions on local node_modules (this may take awhile)',
cmd: 'checkLocalModulesPermission',
windows: false,
}, {
groups: ['permissions'],
title: 'Checking permissions on global node_modules (this may take awhile)',
cmd: 'checkGlobalModulesPermission',
windows: false,
}, {
groups: ['permissions'],
title: 'Checking permissions on local bin folder',
cmd: 'checkLocalBinPermission',
windows: false,
}, {
groups: ['permissions'],
title: 'Checking permissions on global bin folder',
cmd: 'checkGlobalBinPermission',
windows: false,
}, {
groups: ['cache'],
title: 'Verifying cache contents (this may take awhile)',
cmd: 'verifyCachedFiles',
windows: false,
},
// TODO:
// group === 'dependencies'?
// - ensure arborist.loadActual() runs without errors and no invalid edges
// - ensure package-lock.json matches loadActual()
// - verify loadActual without hidden lock file matches hidden lockfile
// group === '???'
// - verify all local packages have bins linked
// What is the fix for these?
]
class Doctor extends BaseCommand {
static description = 'Check the health of your npm environment'
static name = 'doctor'
static params = ['registry']
static ignoreImplicitWorkspace = false
static usage = [`[${checks.flatMap(s => s.groups)
.filter((value, index, self) => self.indexOf(value) === index && value !== 'ping')
.join('] [')}]`]
async exec (args) {
log.info('doctor', 'Running checkup')
let allOk = true
const actions = this.actions(args)
const chalk = this.npm.chalk
for (const { title, cmd } of actions) {
this.output(title)
// TODO when we have an in progress indicator that could go here
let result
try {
result = await this[cmd]()
this.output(`${chalk.green('Ok')}${result ? `\n${result}` : ''}\n`)
} catch (err) {
allOk = false
this.output(`${chalk.red('Not ok')}\n${chalk.cyan(err)}\n`)
}
}
if (!allOk) {
if (this.npm.silent) {
throw new Error('Some problems found. Check logs or disable silent mode for recommendations.')
} else {
throw new Error('Some problems found. See above for recommendations.')
}
}
}
async checkPing () {
log.info('doctor', 'Pinging registry')
try {
await ping({ ...this.npm.flatOptions, retry: false })
return ''
} catch (er) {
if (/^E\d{3}$/.test(er.code || '')) {
throw er.code.slice(1) + ' ' + er.message
} else {
throw er.message
}
}
}
async getLatestNpmVersion () {
log.info('doctor', 'Getting npm package information')
const latest = (await pacote.manifest('npm@latest', this.npm.flatOptions)).version
if (semver.gte(this.npm.version, latest)) {
return `current: v${this.npm.version}, latest: v${latest}`
} else {
throw `Use npm v${latest}`
}
}
async getLatestNodejsVersion () {
// XXX get the latest in the current major as well
const current = process.version
const currentRange = `^${current}`
const url = 'https://nodejs.org/dist/index.json'
log.info('doctor', 'Getting Node.js release information')
const res = await npmFetch(url, { method: 'GET', ...this.npm.flatOptions })
const data = await res.json()
let maxCurrent = '0.0.0'
let maxLTS = '0.0.0'
for (const { lts, version } of data) {
if (lts && semver.gt(version, maxLTS)) {
maxLTS = version
}
if (semver.satisfies(version, currentRange) && semver.gt(version, maxCurrent)) {
maxCurrent = version
}
}
const recommended = semver.gt(maxCurrent, maxLTS) ? maxCurrent : maxLTS
if (semver.gte(process.version, recommended)) {
return `current: ${current}, recommended: ${recommended}`
} else {
throw `Use node ${recommended} (current: ${current})`
}
}
async getBinPath () {
log.info('doctor', 'getBinPath', 'Finding npm global bin in your PATH')
if (!process.env.PATH.includes(this.npm.globalBin)) {
throw new Error(`Add ${this.npm.globalBin} to your $PATH`)
}
return this.npm.globalBin
}
async checkCachePermission () {
return this.checkFilesPermission(this.npm.cache, true, R_OK)
}
async checkLocalModulesPermission () {
return this.checkFilesPermission(this.npm.localDir, true, R_OK | W_OK, true)
}
async checkGlobalModulesPermission () {
return this.checkFilesPermission(this.npm.globalDir, false, R_OK)
}
async checkLocalBinPermission () {
return this.checkFilesPermission(this.npm.localBin, false, R_OK | W_OK | X_OK, true)
}
async checkGlobalBinPermission () {
return this.checkFilesPermission(this.npm.globalBin, false, X_OK)
}
async checkFilesPermission (root, shouldOwn, mask, missingOk) {
let ok = true
try {
const uid = process.getuid()
const gid = process.getgid()
const files = new Set([root])
for (const f of files) {
const st = await lstat(f).catch(er => {
// if it can't be missing, or if it can and the error wasn't that it was missing
if (!missingOk || er.code !== 'ENOENT') {
ok = false
log.warn('doctor', 'checkFilesPermission', 'error getting info for ' + f)
}
})
if (!st) {
continue
}
if (shouldOwn && (uid !== st.uid || gid !== st.gid)) {
log.warn('doctor', 'checkFilesPermission', 'should be owner of ' + f)
ok = false
}
if (!st.isDirectory() && !st.isFile()) {
continue
}
try {
await access(f, mask)
} catch {
ok = false
const msg = `Missing permissions on ${f} (expect: ${maskLabel(mask)})`
log.error('doctor', 'checkFilesPermission', msg)
continue
}
if (st.isDirectory()) {
const entries = await readdir(f).catch(() => {
ok = false
log.warn('doctor', 'checkFilesPermission', 'error reading directory ' + f)
return []
})
for (const entry of entries) {
files.add(resolve(f, entry))
}
}
}
} finally {
if (!ok) {
throw (
`Check the permissions of files in ${root}` +
(shouldOwn ? ' (should be owned by current user)' : '')
)
} else {
return ''
}
}
}
async getGitPath () {
log.info('doctor', 'Finding git in your PATH')
return await which('git').catch(er => {
log.warn('doctor', 'getGitPath', er)
throw new Error("Install git and ensure it's in your PATH.")
})
}
async verifyCachedFiles () {
log.info('doctor', 'verifyCachedFiles', 'Verifying the npm cache')
const stats = await cacache.verify(this.npm.flatOptions.cache)
const { badContentCount, reclaimedCount, missingContent, reclaimedSize } = stats
if (badContentCount || reclaimedCount || missingContent) {
if (badContentCount) {
log.warn('doctor', 'verifyCachedFiles', `Corrupted content removed: ${badContentCount}`)
}
if (reclaimedCount) {
log.warn(
'doctor',
'verifyCachedFiles',
`Content garbage-collected: ${reclaimedCount} (${reclaimedSize} bytes)`
)
}
if (missingContent) {
log.warn('doctor', 'verifyCachedFiles', `Missing content: ${missingContent}`)
}
log.warn('doctor', 'verifyCachedFiles', 'Cache issues have been fixed')
}
log.info(
'doctor',
'verifyCachedFiles',
`Verification complete. Stats: ${JSON.stringify(stats, null, 2)}`
)
return `verified ${stats.verifiedContent} tarballs`
}
async checkNpmRegistry () {
if (this.npm.flatOptions.registry !== defaults.registry) {
throw `Try \`npm config set registry=${defaults.registry}\``
} else {
return `using default registry (${defaults.registry})`
}
}
output (...args) {
// TODO display layer should do this
if (!this.npm.silent) {
output.standard(...args)
}
}
actions (params) {
return checks.filter(subcmd => {
if (process.platform === 'win32' && subcmd.windows === false) {
return false
}
if (params.length) {
return params.some(param => subcmd.groups.includes(param))
}
return true
})
}
}
module.exports = Doctor
+64
View File
@@ -0,0 +1,64 @@
const { resolve } = require('node:path')
const { lstat } = require('node:fs/promises')
const cp = require('node:child_process')
const { input } = require('proc-log')
const completion = require('../utils/installed-shallow.js')
const BaseCommand = require('../base-cmd.js')
const splitPackageNames = (path) => path.split('/')
// combine scoped parts
.reduce((parts, part) => {
if (parts.length === 0) {
return [part]
}
const lastPart = parts[parts.length - 1]
// check if previous part is the first part of a scoped package
if (lastPart[0] === '@' && !lastPart.includes('/')) {
parts[parts.length - 1] += '/' + part
} else {
parts.push(part)
}
return parts
}, [])
.join('/node_modules/')
.replace(/(\/node_modules)+/, '/node_modules')
// npm edit <pkg>
// open the package folder in the $EDITOR
class Edit extends BaseCommand {
static description = 'Edit an installed package'
static name = 'edit'
static usage = ['<pkg>[/<subpkg>...]']
static params = ['editor']
static ignoreImplicitWorkspace = false
static async completion (opts, npm) {
return completion(npm, opts)
}
async exec (args) {
if (args.length !== 1) {
throw this.usageError()
}
const path = splitPackageNames(args[0])
const dir = resolve(this.npm.dir, path)
await lstat(dir)
await input.start(() => new Promise((res, rej) => {
const [bin, ...spawnArgs] = this.npm.config.get('editor').split(/\s+/)
const editor = cp.spawn(bin, [...spawnArgs, dir], { stdio: 'inherit' })
editor.on('exit', (code) => {
if (code) {
return rej(new Error(`editor process exited with code: ${code}`))
}
res()
})
}))
await this.npm.exec('rebuild', [dir])
}
}
module.exports = Edit
+119
View File
@@ -0,0 +1,119 @@
const { resolve } = require('node:path')
const libexec = require('libnpmexec')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const BaseCommand = require('../base-cmd.js')
class Exec extends BaseCommand {
static description = 'Run a command from a local or remote npm package'
static params = [
'package',
'call',
'workspace',
'workspaces',
'include-workspace-root',
'allow-scripts',
'strict-allow-scripts',
'dangerously-allow-all-scripts',
]
static name = 'exec'
static usage = [
'-- <pkg>[@<version>] [args...]',
'--package=<pkg>[@<version>] -- <cmd> [args...]',
'-c \'<cmd> [args...]\'',
'--package=foo -c \'<cmd> [args...]\'',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static isShellout = true
async exec (args) {
return this.callExec(args)
}
async execWorkspaces (args) {
await this.setWorkspaces()
for (const [name, path] of this.workspaces) {
const locationMsg =
`in workspace ${this.npm.chalk.green(name)} at location:\n${this.npm.chalk.dim(path)}`
await this.callExec(args, { name, locationMsg, runPath: path })
}
}
async callExec (args, { name, locationMsg, runPath } = {}) {
let localBin = this.npm.localBin
let pkgPath = this.npm.localPrefix
// This is where libnpmexec will actually run the scripts from
if (!runPath) {
runPath = process.cwd()
} else {
// We have to consider if the workspace has its own separate versions libnpmexec will walk up to localDir after looking here
localBin = resolve(this.npm.localDir, name, 'node_modules', '.bin')
// We also need to look for `bin` entries in the workspace package.json
// libnpmexec will NOT look in the project root for the bin entry
pkgPath = runPath
}
const call = this.npm.config.get('call')
let globalPath
const {
flatOptions,
globalBin,
globalDir,
chalk,
} = this.npm
const scriptShell = this.npm.config.get('script-shell') || undefined
const packages = this.npm.config.get('package')
const yes = this.npm.config.get('yes')
// --prefix sets both of these to the same thing, meaning the global prefix is invalid (i.e. no lib/node_modules).
// This is not a trivial thing to untangle and fix so we work around it here.
if (this.npm.localPrefix !== this.npm.globalPrefix) {
globalPath = resolve(globalDir, '..')
}
if (call && args.length) {
throw this.usageError()
}
// Resolve the install-script policy from the user/global .npmrc layer
// only. The RFC requires exec/npx to ignore any project
// package.json#allowScripts; CLI flags still apply.
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm, {
skipProjectConfig: true,
})
return libexec({
...flatOptions,
allowScripts: allowScriptsPolicy,
// we explicitly set packageLockOnly to false because if it's true when we try to install a missing package, we won't actually install it
packageLockOnly: false,
// what the user asked to run args[0] is run by default
args: [...args], // copy args so they don't get mutated
// specify a custom command to be run instead of args[0]
call,
chalk,
// where to look for bins globally, if a file matches call or args[0] it is called
globalBin,
// where to look for packages globally, if a package matches call or args[0] it is called
globalPath,
// where to look for bins locally, if a file matches call or args[0] it is called
localBin,
locationMsg,
// packages that need to be installed
packages,
// path where node_modules is
path: this.npm.localPrefix,
// where to look for package.json#bin entries first
pkgPath,
// cwd to run from
runPath,
scriptShell,
yes,
})
}
}
module.exports = Exec
+128
View File
@@ -0,0 +1,128 @@
const { explainNode } = require('../utils/explain-dep.js')
const npa = require('npm-package-arg')
const semver = require('semver')
const { relative, resolve } = require('node:path')
const validName = require('validate-npm-package-name')
const { output } = require('proc-log')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class Explain extends ArboristWorkspaceCmd {
static description = 'Explain installed packages'
static name = 'explain'
static usage = ['<package-spec>']
static params = [
'json',
'workspace',
]
static ignoreImplicitWorkspace = false
static async completion (opts, npm) {
const completion = require('../utils/installed-deep.js')
return completion(npm, opts)
}
async exec (args) {
if (!args.length) {
throw this.usageError()
}
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({ path: this.npm.prefix, ...this.npm.flatOptions })
const tree = await arb.loadActual()
if (this.npm.flatOptions.workspacesEnabled
&& this.workspaceNames
&& this.workspaceNames.length
) {
this.filterSet = arb.workspaceDependencySet(tree, this.workspaceNames)
} else if (!this.npm.flatOptions.workspacesEnabled) {
this.filterSet =
arb.excludeWorkspacesDependencySet(tree)
}
const nodes = new Set()
for (const arg of args) {
for (const node of this.getNodes(tree, arg)) {
const filteredOut = this.filterSet
&& this.filterSet.size > 0
&& !this.filterSet.has(node)
if (!filteredOut) {
nodes.add(node)
}
}
}
if (nodes.size === 0) {
throw new Error(`No dependencies found matching ${args.join(', ')}`)
}
const expls = []
for (const node of nodes) {
const { extraneous, dev, optional, devOptional, peer, inBundle, overridden } = node
const expl = node.explain()
if (extraneous) {
expl.extraneous = true
} else {
expl.dev = dev
expl.optional = optional
expl.devOptional = devOptional
expl.peer = peer
expl.bundled = inBundle
expl.overridden = overridden
}
expls.push(expl)
}
if (this.npm.flatOptions.json) {
output.buffer(expls)
} else {
output.standard(expls.map(expl => {
return explainNode(expl, Infinity, this.npm.chalk)
}).join('\n\n'))
}
}
getNodes (tree, arg) {
// if it's just a name, return packages by that name
const { validForOldPackages: valid } = validName(arg)
if (valid) {
return tree.inventory.query('packageName', arg)
}
// if it's a location, get that node
const maybeLoc = arg.replace(/\\/g, '/').replace(/(?<!\/)\/+$/, '')
const nodeByLoc = tree.inventory.get(maybeLoc)
if (nodeByLoc) {
return [nodeByLoc]
}
// maybe a path to a node_modules folder
const maybePath = relative(this.npm.prefix, resolve(maybeLoc))
.replace(/\\/g, '/').replace(/(?<!\/)\/+$/, '')
const nodeByPath = tree.inventory.get(maybePath)
if (nodeByPath) {
return [nodeByPath]
}
// otherwise, try to select all matching nodes
try {
return this.getNodesByVersion(tree, arg)
} catch {
return []
}
}
getNodesByVersion (tree, arg) {
const spec = npa(arg, this.npm.prefix)
if (spec.type !== 'version' && spec.type !== 'range') {
return []
}
return tree.inventory.filter(node => {
return node.package.name === spec.name &&
semver.satisfies(node.package.version, spec.rawSpec)
})
}
}
module.exports = Explain
+70
View File
@@ -0,0 +1,70 @@
const pkgJson = require('@npmcli/package-json')
const runScript = require('@npmcli/run-script')
const { join, relative } = require('node:path')
const { log, output } = require('proc-log')
const completion = require('../utils/installed-shallow.js')
const BaseCommand = require('../base-cmd.js')
// npm explore <pkg>[@<version>]
// open a subshell to the package folder.
class Explore extends BaseCommand {
static description = 'Browse an installed package'
static name = 'explore'
static usage = ['<pkg> [ -- <command>]']
static params = ['shell']
static ignoreImplicitWorkspace = false
static async completion (opts, npm) {
return completion(npm, opts)
}
async exec (args) {
if (args.length < 1 || !args[0]) {
throw this.usageError()
}
const pkgname = args.shift()
// detect and prevent any .. shenanigans
const path = join(this.npm.dir, join('/', pkgname))
if (relative(path, this.npm.dir) === '') {
throw this.usageError()
}
// run as if running a script named '_explore', which we set to either the set of arguments, or the shell config, and let @npmcli/run-script handle all the escaping and PATH setup stuff.
const { content: pkg } = await pkgJson.normalize(path).catch(er => {
log.error('explore', `It doesn't look like ${pkgname} is installed.`)
throw er
})
const { shell } = this.npm.flatOptions
pkg.scripts = {
...(pkg.scripts || {}),
_explore: args.join(' ').trim() || shell,
}
if (!args.length) {
output.standard(`\nExploring ${path}\nType 'exit' or ^D when finished\n`)
}
return runScript({
...this.npm.flatOptions,
pkg,
path,
event: '_explore',
stdio: 'inherit',
}).catch(er => {
process.exitCode = typeof er.code === 'number' && er.code !== 0 ? er.code
: 1
// if it's not an exit error, or non-interactive, throw it
const isProcExit = er.message === 'command failed' &&
(typeof er.code === 'number' || /^SIG/.test(er.signal || ''))
if (args.length || !isProcExit) {
throw er
}
})
}
}
module.exports = Explore
@@ -0,0 +1,27 @@
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class FindDupes extends ArboristWorkspaceCmd {
static description = 'Find duplication in the package tree'
static name = 'find-dupes'
static params = [
'install-strategy',
'legacy-bundling',
'global-style',
'strict-peer-deps',
'package-lock',
'omit',
'include',
'ignore-scripts',
'audit',
'bin-links',
'fund',
...super.params,
]
async exec () {
this.npm.config.set('dry-run', true)
return this.npm.exec('dedupe', [])
}
}
module.exports = FindDupes
+215
View File
@@ -0,0 +1,215 @@
const archy = require('archy')
const pacote = require('pacote')
const semver = require('semver')
const { output } = require('proc-log')
const npa = require('npm-package-arg')
const { depth } = require('treeverse')
const { readTree: getFundingInfo, normalizeFunding, isValidFunding } = require('libnpmfund')
const { openUrl } = require('../utils/open-url.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const getPrintableName = ({ name, version }) => {
const printableVersion = version ? `@${version}` : ''
return `${name}${printableVersion}`
}
const errCode = (msg, code) => Object.assign(new Error(msg), { code })
class Fund extends ArboristWorkspaceCmd {
static description = 'Retrieve funding information'
static name = 'fund'
static params = ['json', 'browser', 'unicode', 'workspace', 'which']
static usage = ['[<package-spec>]']
// XXX: maybe worth making this generic for all commands?
usageMessage (paramsObj = {}) {
let msg = `\`npm ${this.constructor.name}`
const params = Object.entries(paramsObj)
if (params.length) {
msg += ` ${this.constructor.usage}`
}
for (const [key, value] of params) {
msg += ` --${key}=${value}`
}
return `${msg}\``
}
static async completion (opts, npm) {
const completion = require('../utils/installed-deep.js')
return completion(npm, opts)
}
async exec (args) {
const spec = args[0]
let fundingSourceNumber = this.npm.config.get('which')
if (fundingSourceNumber != null) {
fundingSourceNumber = parseInt(fundingSourceNumber, 10)
if (isNaN(fundingSourceNumber) || fundingSourceNumber < 1) {
throw errCode(
`${this.usageMessage({ which: 'fundingSourceNumber' })} must be given a positive integer`,
'EFUNDNUMBER'
)
}
}
if (this.npm.global) {
throw errCode(
`${this.usageMessage()} does not support global packages`,
'EFUNDGLOBAL'
)
}
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({ ...this.npm.flatOptions, path: where })
const tree = await arb.loadActual()
if (spec) {
await this.openFundingUrl({
path: where,
tree,
spec,
fundingSourceNumber,
})
return
}
// TODO: add !workspacesEnabled option handling to libnpmfund
const fundingInfo = getFundingInfo(tree, {
...this.flatOptions,
Arborist,
workspaces: this.workspaceNames,
})
if (this.npm.config.get('json')) {
output.buffer(fundingInfo)
} else {
output.standard(this.printHuman(fundingInfo))
}
}
printHuman (fundingInfo) {
const unicode = this.npm.config.get('unicode')
const seenUrls = new Map()
const tree = obj => archy(obj, '', { unicode })
const result = depth({
tree: fundingInfo,
// composes human readable package name and creates a new archy item for readable output
visit: ({ name, version, funding }) => {
const [fundingSource] = [].concat(normalizeFunding(funding)).filter(isValidFunding)
const { url } = fundingSource || {}
const pkgRef = getPrintableName({ name, version })
if (!url) {
return { label: pkgRef }
}
let item
if (seenUrls.has(url)) {
item = seenUrls.get(url)
item.label += `${this.npm.chalk.dim(',')} ${pkgRef}`
return null
}
item = {
label: tree({
label: this.npm.chalk.blue(url),
nodes: [pkgRef],
}).trim(),
}
// stacks all packages together under the same item
seenUrls.set(url, item)
return item
},
// puts child nodes back into returned archy output while also filtering out missing items
leave: (item, children) => {
if (item) {
item.nodes = children.filter(Boolean)
}
return item
},
// turns tree-like object return by libnpmfund into children to be properly read by treeverse
getChildren: node =>
Object.keys(node.dependencies || {}).map(key => ({
name: key,
...node.dependencies[key],
})),
})
const res = tree(result)
return res
}
async openFundingUrl ({ path, tree, spec, fundingSourceNumber }) {
const arg = npa(spec, path)
const retrievePackageMetadata = () => {
if (arg.type === 'directory') {
if (tree.path === arg.fetchSpec) {
// matches cwd, e.g: npm fund .
return tree.package
} else {
// matches any file path within current arborist inventory
for (const item of tree.inventory.values()) {
if (item.path === arg.fetchSpec) {
return item.package
}
}
}
} else {
// tries to retrieve a package from arborist inventory by matching resulted package name from the provided spec
const [item] = [...tree.inventory.query('name', arg.name)]
.filter(i => semver.valid(i.package.version))
.sort((a, b) => semver.rcompare(a.package.version, b.package.version))
if (item) {
return item.package
}
}
}
const { funding } =
retrievePackageMetadata() ||
(await pacote.manifest(arg, this.npm.flatOptions).catch(() => ({})))
const validSources = [].concat(normalizeFunding(funding)).filter(isValidFunding)
if (!validSources.length) {
throw errCode(`No valid funding method available for: ${spec}`, 'ENOFUND')
}
const fundSource = fundingSourceNumber
? validSources[fundingSourceNumber - 1]
: validSources.length === 1 ? validSources[0]
: null
if (fundSource) {
return openUrl(this.npm, ...this.urlMessage(fundSource))
}
const ambiguousUrlMsg = [
...validSources.map((s, i) => `${i + 1}: ${this.urlMessage(s).reverse().join(': ')}`),
`Run ${this.usageMessage({ which: '1' })}` +
', for example, to open the first funding URL listed in that package',
]
if (fundingSourceNumber) {
ambiguousUrlMsg.unshift(`--which=${fundingSourceNumber} is not a valid index`)
}
output.standard(ambiguousUrlMsg.join('\n'))
}
urlMessage (source) {
const { type, url } = source
const typePrefix = type ? `${type} funding` : 'Funding'
const message = `${typePrefix} available at the following URL`
return [url, message]
}
}
module.exports = Fund
+21
View File
@@ -0,0 +1,21 @@
const Npm = require('../npm.js')
const BaseCommand = require('../base-cmd.js')
class Get extends BaseCommand {
static description = 'Get a value from the npm configuration'
static name = 'get'
static usage = ['[<key> ...] (See `npm config`)']
static params = ['long']
static ignoreImplicitWorkspace = false
static async completion (opts) {
const Config = Npm.cmd('config')
return Config.completion(opts)
}
async exec (args) {
return this.npm.exec('config', ['get'].concat(args))
}
}
module.exports = Get
@@ -0,0 +1,194 @@
const { readFile } = require('node:fs/promises')
const path = require('node:path')
const { glob } = require('glob')
const { output } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
const globify = pattern => pattern.split('\\').join('/')
class HelpSearch extends BaseCommand {
static description = 'Search npm help documentation'
static name = 'help-search'
static usage = ['<text>']
static params = ['long']
async exec (args) {
if (!args.length) {
throw this.usageError()
}
const docPath = path.resolve(this.npm.npmRoot, 'docs/content')
let files = await glob(`${globify(docPath)}/*/*.md`)
// preserve glob@8 behavior
files = files.sort((a, b) => a.localeCompare(b, 'en'))
const data = await this.readFiles(files)
const results = await this.searchFiles(args, data)
const formatted = this.formatResults(args, results)
if (!formatted.trim()) {
output.standard(`No matches in help for: ${args.join(' ')}\n`)
} else {
output.standard(formatted)
}
}
async readFiles (files) {
const res = {}
await Promise.all(files.map(async file => {
res[file] = (await readFile(file, 'utf8'))
.replace(/^---\n(.*\n)*?---\n/, '').trim()
}))
return res
}
async searchFiles (args, data) {
const results = []
for (const [file, content] of Object.entries(data)) {
const lowerCase = content.toLowerCase()
// skip if no matches at all
if (!args.some(a => lowerCase.includes(a.toLowerCase()))) {
continue
}
const lines = content.split(/\n+/)
// if a line has a search term, then skip it and the next line.
// if the next line has a search term, then skip all 3
// otherwise, set the line to null
// finally, remove the nulls
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const nextLine = lines[i + 1]
let match = false
if (nextLine) {
match = args.some(a =>
nextLine.toLowerCase().includes(a.toLowerCase()))
if (match) {
// skip over the next line, and the line after it.
i += 2
continue
}
}
match = args.some(a => line.toLowerCase().includes(a.toLowerCase()))
if (match) {
// skip over the next line
i++
continue
}
lines[i] = null
}
// now squish any string of nulls into a single null
const pruned = lines.reduce((l, r) => {
if (!(r === null && l[l.length - 1] === null)) {
l.push(r)
}
return l
}, [])
if (pruned[pruned.length - 1] === null) {
pruned.pop()
}
if (pruned[0] === null) {
pruned.shift()
}
// now count how many args were found
const found = {}
let totalHits = 0
for (const line of pruned) {
for (const arg of args) {
const hit = (line || '').toLowerCase()
.split(arg.toLowerCase()).length - 1
if (hit > 0) {
found[arg] = (found[arg] || 0) + hit
totalHits += hit
}
}
}
const cmd = 'npm help ' +
path.basename(file, '.md').replace(/^npm-/, '')
results.push({
file,
cmd,
lines: pruned,
found: Object.keys(found),
hits: found,
totalHits,
})
}
// sort results by number of results found, then by number of hits then by number of matching lines
// coverage is ignored here because the contents of results are nondeterministic due to either glob or readFiles or Object.entries
return results.sort(/* istanbul ignore next */ (a, b) =>
a.found.length > b.found.length ? -1
: a.found.length < b.found.length ? 1
: a.totalHits > b.totalHits ? -1
: a.totalHits < b.totalHits ? 1
: a.lines.length > b.lines.length ? -1
: a.lines.length < b.lines.length ? 1
: 0).slice(0, 10)
}
formatResults (args, results) {
const cols = Math.min(process.stdout.columns || Infinity, 80) + 1
const formattedOutput = results.map(res => {
const out = [res.cmd]
const r = Object.keys(res.hits)
.map(k => `${k}:${res.hits[k]}`)
.sort((a, b) => a > b ? 1 : -1)
.join(' ')
out.push(' '.repeat((Math.max(1, cols - out.join(' ').length - r.length - 1))))
out.push(r)
if (!this.npm.config.get('long')) {
return out.join('')
}
out.unshift('\n\n')
out.push('\n')
out.push('-'.repeat(cols - 1) + '\n')
res.lines.forEach((line, i) => {
if (line === null || i > 3) {
return
}
const highlightLine = []
for (const arg of args) {
const finder = line.toLowerCase().split(arg.toLowerCase())
let p = 0
for (const f of finder) {
highlightLine.push(line.slice(p, p + f.length))
const word = line.slice(p + f.length, p + f.length + arg.length)
highlightLine.push(this.npm.chalk.blue(word))
p += f.length + arg.length
}
}
out.push(highlightLine.join('') + '\n')
})
return out.join('')
}).join('\n')
const finalOut = results.length && !this.npm.config.get('long')
? 'Top hits for ' + (args.map(JSON.stringify).join(' ')) + '\n' +
'—'.repeat(cols - 1) + '\n' +
formattedOutput + '\n' +
'—'.repeat(cols - 1) + '\n' +
'(run with -l or --long to see more context)'
: formattedOutput
return finalOut.trim()
}
}
module.exports = HelpSearch
+113
View File
@@ -0,0 +1,113 @@
const spawn = require('@npmcli/promise-spawn')
const path = require('node:path')
const { openUrl } = require('../utils/open-url.js')
const { glob } = require('glob')
const { output, input } = require('proc-log')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const { deref } = require('../utils/cmd-list.js')
const BaseCommand = require('../base-cmd.js')
const globify = pattern => pattern.split('\\').join('/')
// Strips out the number from foo.7 or foo.7. or foo.7.tgz
// We don't currently compress our man pages but if we ever did this would seamlessly continue supporting it
const manNumberRegex = /\.(\d+)(\.[^/\\]*)?$/
// hardcoded names for man sections
// XXX: these are used in the docs workspace and should be exported from npm so section names can changed more easily
const manSectionNames = {
1: 'commands',
5: 'configuring-npm',
7: 'using-npm',
}
class Help extends BaseCommand {
static description = 'Get help on npm'
static name = 'help'
static usage = ['<term> [<terms..>]']
static params = ['viewer']
static async completion (opts, npm) {
if (opts.conf.argv.remain.length > 2) {
return []
}
const g = path.resolve(npm.npmRoot, 'man/man[0-9]/*.[0-9]')
let files = await glob(globify(g))
// preserve glob@8 behavior
files = files.sort((a, b) => a.localeCompare(b, 'en'))
return Object.keys(files.reduce(function (acc, file) {
file = path.basename(file).replace(/\.[0-9]+$/, '')
file = file.replace(/^npm-/, '')
acc[file] = true
return acc
}, { help: true }))
}
async exec (args) {
// By default we search all of our man subdirectories, but if the user has asked for a specific one we limit the search to just there
const manSearch = /^\d+$/.test(args[0]) ? `man${args.shift()}` : 'man*'
if (!args.length) {
return output.standard(this.npm.usage)
}
// npm help foo bar baz: search topics
if (args.length > 1) {
return this.helpSearch(args)
}
// `npm help package.json`
const arg = (deref(args[0]) || args[0]).replace('.json', '-json')
// find either section.n or npm-section.n
const f = globify(path.resolve(this.npm.npmRoot, `man/${manSearch}/?(npm-)${arg}.[0-9]*`))
const [man] = await glob(f).then(r => r.sort((a, b) => {
// Because the glob is (subtly) different from manNumberRegex, we can't rely on it passing.
const aManNumberMatch = a.match(manNumberRegex)?.[1] || 999
const bManNumberMatch = b.match(manNumberRegex)?.[1] || 999
if (aManNumberMatch !== bManNumberMatch) {
return aManNumberMatch - bManNumberMatch
}
return localeCompare(a, b)
}))
return man ? this.viewMan(man) : this.helpSearch(args)
}
helpSearch (args) {
return this.npm.exec('help-search', args)
}
async viewMan (man) {
const viewer = this.npm.config.get('viewer')
if (viewer === 'browser') {
return openUrl(this.npm, this.htmlMan(man), 'help available at the following URL', true)
}
let args = ['man', [man]]
if (viewer === 'woman') {
args = ['emacsclient', ['-e', `(woman-find-file '${man}')`]]
}
try {
await input.start(() => spawn(...args, { stdio: 'inherit' }))
} catch (err) {
if (err.code) {
throw new Error(`help process exited with code: ${err.code}`)
} else {
throw err
}
}
}
// Returns the path to the html version of the man page
htmlMan (man) {
const sect = manSectionNames[man.match(manNumberRegex)[1]]
const f = path.basename(man).replace(manNumberRegex, '')
return 'file:///' + path.resolve(this.npm.npmRoot, `docs/output/${sect}/${f}.html`)
}
}
module.exports = Help
+240
View File
@@ -0,0 +1,240 @@
const { statSync } = require('node:fs')
const { relative, resolve } = require('node:path')
const { mkdir } = require('node:fs/promises')
const initJson = require('init-package-json')
const npa = require('npm-package-arg')
const libexec = require('libnpmexec')
const mapWorkspaces = require('@npmcli/map-workspaces')
const PackageJson = require('@npmcli/package-json')
const { log, output, input } = require('proc-log')
const updateWorkspaces = require('../utils/update-workspaces.js')
const BaseCommand = require('../base-cmd.js')
const posixPath = p => p.split('\\').join('/')
class Init extends BaseCommand {
static description = 'Create a package.json file'
static params = [
'init-author-name',
'init-author-url',
'init-license',
'init-module',
'init-type',
'init-version',
'init-private',
'yes',
'force',
'scope',
'workspace',
'workspaces',
'workspaces-update',
'include-workspace-root',
]
static name = 'init'
static usage = [
'<package-spec> (same as `npx create-<package-spec>`)',
'<@scope> (same as `npx <@scope>/create`)',
]
static workspaces = true
static ignoreImplicitWorkspace = false
async exec (args) {
// npm exec style
if (args.length) {
return await this.execCreate(args)
}
// no args, uses classic init-package-json boilerplate
await this.template()
}
async execWorkspaces (args) {
// if the root package is uninitiated, take care of it first
if (this.npm.flatOptions.includeWorkspaceRoot) {
await this.exec(args)
}
// reads package.json for the top-level folder first
// by doing this we ensure the command throw if no package.json is found before trying to create a workspace package.json file or its folders
const { content: pkg } = await PackageJson.normalize(this.npm.localPrefix).catch(err => {
if (err.code === 'ENOENT') {
log.warn('init', 'Missing package.json. Try with `--include-workspace-root`.')
}
throw err
})
// these are workspaces that are being created, so we can't use this.setWorkspaces()
const filters = this.npm.config.get('workspace')
const wPath = filterArg => resolve(this.npm.localPrefix, filterArg)
const workspacesPaths = []
// npm-exec style, runs in the context of each workspace filter
if (args.length) {
for (const filterArg of filters) {
const path = wPath(filterArg)
await mkdir(path, { recursive: true })
workspacesPaths.push(path)
await this.execCreate(args, path)
await this.setWorkspace(pkg, path)
}
return
}
// no args, uses classic init-package-json boilerplate
for (const filterArg of filters) {
const path = wPath(filterArg)
await mkdir(path, { recursive: true })
workspacesPaths.push(path)
await this.template(path)
await this.setWorkspace(pkg, path)
}
// reify packages once all workspaces have been initialized
await this.update(workspacesPaths)
}
async execCreate (args, runPath = process.cwd()) {
const [initerName, ...otherArgs] = args
let packageName = initerName
// Only a scope, possibly with a version
if (/^@[^/]+$/.test(initerName)) {
const [, scope, version] = initerName.split('@')
packageName = `@${scope}/create`
if (version) {
packageName = `${packageName}@${version}`
}
} else {
const req = npa(initerName)
if (req.type === 'git' && req.hosted) {
const { user, project } = req.hosted
packageName = initerName.replace(`${user}/${project}`, `${user}/create-${project}`)
} else if (req.registry) {
packageName = `${req.name.replace(/^(@[^/]+\/)?/, '$1create-')}@${req.rawSpec}`
} else {
throw Object.assign(new Error(
'Unrecognized initializer: ' + initerName +
'\nFor more package binary executing power check out `npx`:' +
'\nhttps://docs.npmjs.com/cli/commands/npx'
), { code: 'EUNSUPPORTED' })
}
}
const newArgs = [packageName, ...otherArgs]
const {
flatOptions,
localBin,
globalBin,
chalk,
} = this.npm
const scriptShell = this.npm.config.get('script-shell') || undefined
const yes = this.npm.config.get('yes')
// only send the init-private flag if it is set
const opts = { ...flatOptions }
if (this.npm.config.isDefault('init-private')) {
delete opts.initPrivate
}
await libexec({
...opts,
args: newArgs,
localBin,
globalBin,
output,
chalk,
path: this.npm.localPrefix,
runPath,
scriptShell,
yes,
})
}
async template (path = process.cwd()) {
const initFile = this.npm.config.get('init-module')
if (!this.npm.config.get('yes') && !this.npm.config.get('force')) {
output.standard([
'This utility will walk you through creating a package.json file.',
'It only covers the most common items, and tries to guess sensible defaults.',
'',
'See `npm help init` for definitive documentation on these fields and exactly what they do.',
'',
'Use `npm install <pkg>` afterwards to install a package and save it as a dependency in the package.json file.',
'',
'Press ^C at any time to quit.',
].join('\n'))
}
try {
const data = await input.read(() => initJson(path, initFile, this.npm.config))
log.silly('package data', data)
return data
} catch (er) {
if (er.message === 'canceled') {
output.flush()
log.warn('init', 'canceled')
} else {
throw er
}
}
}
async setWorkspace (pkg, workspacePath) {
const workspaces = await mapWorkspaces({ cwd: this.npm.localPrefix, pkg })
// skip setting workspace if current package.json glob already satisfies it
for (const wPath of workspaces.values()) {
if (wPath === workspacePath) {
return
}
}
// if a create-pkg didn't generate a package.json at the workspace folder level, it might not be recognized as a workspace by mapWorkspaces, so we're just going to avoid touching the top-level package.json
try {
statSync(resolve(workspacePath, 'package.json'))
} catch {
return
}
const pkgJson = await PackageJson.load(this.npm.localPrefix)
pkgJson.update({
workspaces: [
...(pkgJson.content.workspaces || []),
posixPath(relative(this.npm.localPrefix, workspacePath)),
],
})
await pkgJson.save()
}
async update (workspacesPaths) {
// translate workspaces paths into an array containing workspaces names
const workspaces = []
for (const path of workspacesPaths) {
const { content: { name } } = await PackageJson.normalize(path).catch(() => ({ content: {} }))
if (name) {
workspaces.push(name)
}
}
const {
config,
flatOptions,
localPrefix,
} = this.npm
await updateWorkspaces({
config,
flatOptions,
localPrefix,
npm: this.npm,
workspaces,
})
}
}
module.exports = Init
@@ -0,0 +1,13 @@
const CI = require('./ci.js')
class InstallCITest extends CI {
static description = 'Install a project with a clean slate and run tests'
static name = 'install-ci-test'
async exec (args) {
await this.npm.exec('ci', args)
return this.npm.exec('test', [])
}
}
module.exports = InstallCITest
@@ -0,0 +1,13 @@
const Install = require('./install.js')
class InstallTest extends Install {
static description = 'Install package(s) and run tests'
static name = 'install-test'
async exec (args) {
await this.npm.exec('install', args)
return this.npm.exec('test', [])
}
}
module.exports = InstallTest
+183
View File
@@ -0,0 +1,183 @@
const { readdir } = require('node:fs/promises')
const { resolve, join } = require('node:path')
const { log } = require('proc-log')
const runScript = require('@npmcli/run-script')
const pacote = require('pacote')
const checks = require('npm-install-checks')
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class Install extends ArboristWorkspaceCmd {
static description = 'Install a package'
static name = 'install'
// These are in the order they will show up in when running "-h" If adding to this list, consider adding also to ci.js
static params = [
'save',
'save-exact',
'global',
'install-strategy',
'legacy-bundling',
'global-style',
'omit',
'include',
'strict-peer-deps',
'prefer-dedupe',
'package-lock',
'package-lock-only',
'foreground-scripts',
'ignore-scripts',
'allow-directory',
'allow-file',
'allow-git',
'allow-remote',
'allow-scripts',
'strict-allow-scripts',
'dangerously-allow-all-scripts',
'audit',
'before',
'min-release-age',
'bin-links',
'fund',
'dry-run',
'cpu',
'os',
'libc',
...super.params,
]
static usage = ['[<package-spec> ...]']
static async completion (opts) {
const { partialWord } = opts
// install can complete to a folder with a package.json, or any package.
// if it has a slash, then it's gotta be a folder
// if it starts with https?://, then just give up, because it's a url
if (/^https?:\/\//.test(partialWord)) {
// do not complete to URLs
return []
}
if (/\//.test(partialWord)) {
// Complete fully to folder if there is exactly one match and it is a folder containing a package.json file.
// If that is not the case we return 0 matches, which will trigger the default bash complete.
const lastSlashIdx = partialWord.lastIndexOf('/')
const partialName = partialWord.slice(lastSlashIdx + 1)
const partialPath = partialWord.slice(0, lastSlashIdx) || '/'
const isDirMatch = async sibling => {
if (sibling.slice(0, partialName.length) !== partialName) {
return false
}
try {
const contents = await readdir(join(partialPath, sibling))
const result = (contents.indexOf('package.json') !== -1)
return result
} catch {
return false
}
}
try {
const siblings = await readdir(partialPath)
const matches = []
for (const sibling of siblings) {
if (await isDirMatch(sibling)) {
matches.push(sibling)
}
}
if (matches.length === 1) {
return [join(partialPath, matches[0])]
}
// no matches
return []
} catch {
return [] // invalid dir: no matching
}
}
// Note: there used to be registry completion here, but it stopped making sense somewhere around 50,000 packages on the registry
}
async exec (args) {
// the /path/to/node_modules/..
const globalTop = resolve(this.npm.globalDir, '..')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const isGlobalInstall = this.npm.global
const where = isGlobalInstall ? globalTop : this.npm.prefix
const forced = this.npm.config.get('force')
const scriptShell = this.npm.config.get('script-shell') || undefined
// be very strict about engines when trying to update npm itself
const npmInstall = args.find(arg => arg.startsWith('npm@') || arg === 'npm')
if (isGlobalInstall && npmInstall) {
const npmOptions = this.npm.flatOptions
const npmManifest = await pacote.manifest(npmInstall, npmOptions)
try {
checks.checkEngine(npmManifest, npmManifest.version, process.version)
} catch (e) {
if (forced) {
log.warn(
'install',
`Forcing global npm install with incompatible version ${npmManifest.version} into node ${process.version}`
)
} else {
throw e
}
}
}
// don't try to install the prefix into itself
args = args.filter(a => resolve(a) !== this.npm.prefix)
// `npm i -g` => "install this package globally"
if (isGlobalInstall && !args.length) {
args = ['.']
}
// throw usage error if trying to install empty package name to global space, e.g: `npm i -g ""`
if (where === globalTop && !args.every(Boolean)) {
throw this.usageError()
}
const Arborist = require('@npmcli/arborist')
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
const opts = {
...this.npm.flatOptions,
auditLevel: null,
path: where,
add: args,
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
}
const arb = new Arborist(opts)
await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts })
await arb.reify(opts)
if (!args.length && !isGlobalInstall && !ignoreScripts) {
const scripts = [
'preinstall',
'install',
'postinstall',
'prepublish', // XXX(npm9) should we remove this finally??
'preprepare',
'prepare',
'postprepare',
]
for (const event of scripts) {
await runScript({
path: where,
args: [],
scriptShell,
stdio: 'inherit',
event,
})
}
}
await reifyFinish(this.npm, arb)
}
}
module.exports = Install
+188
View File
@@ -0,0 +1,188 @@
const { readdir } = require('node:fs/promises')
const { resolve } = require('node:path')
const npa = require('npm-package-arg')
const pkgJson = require('@npmcli/package-json')
const semver = require('semver')
const reifyFinish = require('../utils/reify-finish.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class Link extends ArboristWorkspaceCmd {
static description = 'Symlink a package folder'
static name = 'link'
static usage = [
'[<package-spec>]',
]
static params = [
'save',
'save-exact',
'global',
'install-strategy',
'legacy-bundling',
'global-style',
'strict-peer-deps',
'package-lock',
'omit',
'include',
'ignore-scripts',
'allow-directory',
'allow-file',
'allow-git',
'allow-remote',
'audit',
'bin-links',
'fund',
'dry-run',
...super.params,
]
static async completion (opts, npm) {
const dir = npm.globalDir
const files = await readdir(dir)
return files.filter(f => !/^[._-]/.test(f))
}
async exec (args) {
if (this.npm.global) {
throw Object.assign(
new Error(
'link should never be --global.\n' +
'Please re-run this command with --local'
),
{ code: 'ELINKGLOBAL' }
)
}
// install-links is implicitly false when running `npm link`
this.npm.config.set('install-links', false)
// link with no args: symlink the folder to the global location
// link with package arg: symlink the global to the local
args = args.filter(a => resolve(a) !== this.npm.prefix)
return args.length
? this.linkInstall(args)
: this.linkPkg()
}
async linkInstall (args) {
// load current packages from the global space, and then add symlinks installs locally
const globalTop = resolve(this.npm.globalDir, '..')
const Arborist = require('@npmcli/arborist')
const globalOpts = {
...this.npm.flatOptions,
Arborist,
path: globalTop,
global: true,
prune: false,
}
const globalArb = new Arborist(globalOpts)
// get only current top-level packages from the global space
const globals = await globalArb.loadActual({
filter: (node, kid) =>
!node.isRoot || args.some(a => npa(a).name === kid),
})
// any extra arg that is missing from the current global space should be reified there first
const missing = this.missingArgsFromTree(globals, args)
if (missing.length) {
await globalArb.reify({
...globalOpts,
add: missing,
})
}
// get a list of module names that should be linked in the local prefix
const names = []
for (const a of args) {
const arg = npa(a)
if (arg.type === 'directory') {
const { content } = await pkgJson.normalize(arg.fetchSpec)
names.push(content.name)
} else {
names.push(arg.name)
}
}
// npm link should not save=true by default unless you're using any of --save-dev or other types
const save =
Boolean(
(this.npm.config.find('save') !== 'default' &&
this.npm.config.get('save')) ||
this.npm.config.get('save-optional') ||
this.npm.config.get('save-peer') ||
this.npm.config.get('save-dev') ||
this.npm.config.get('save-prod')
)
// create a new arborist instance for the local prefix and
// reify all the pending names as symlinks there
const localArb = new Arborist({
...this.npm.flatOptions,
prune: false,
path: this.npm.prefix,
save,
})
await localArb.reify({
...this.npm.flatOptions,
prune: false,
path: this.npm.prefix,
add: names.map(l => `file:${resolve(globalTop, 'node_modules', l)}`),
save,
workspaces: this.workspaceNames,
})
await reifyFinish(this.npm, localArb)
}
async linkPkg () {
const wsp = this.workspacePaths
const paths = wsp && wsp.length ? wsp : [this.npm.prefix]
const add = paths.map(path => `file:${path}`)
const globalTop = resolve(this.npm.globalDir, '..')
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
...this.npm.flatOptions,
Arborist,
path: globalTop,
global: true,
})
await arb.reify({
add,
})
await reifyFinish(this.npm, arb)
}
// Returns a list of items that can't be fulfilled by things found in the current arborist inventory
missingArgsFromTree (tree, args) {
if (tree.isLink) {
return this.missingArgsFromTree(tree.target, args)
}
const foundNodes = []
const missing = args.filter(a => {
const arg = npa(a)
const nodes = tree.children.values()
const argFound = [...nodes].every(node => {
// TODO: write tests for unmatching version specs
// this is hard to test atm but should be simple once we have a mocked registry again
if (arg.name !== node.name /* istanbul ignore next */ || (
arg.version &&
/* istanbul ignore next */
!semver.satisfies(node.version, arg.version)
)) {
foundNodes.push(node)
return true
}
})
return argFound
})
// remote nodes from the loaded tree in order to avoid dropping them later when reifying
for (const node of foundNodes) {
node.parent = null
}
return missing
}
}
module.exports = Link
+13
View File
@@ -0,0 +1,13 @@
const LS = require('./ls.js')
class LL extends LS {
static name = 'll'
static usage = ['[[<@scope>/]<pkg> ...]']
async exec (args) {
this.npm.config.set('long', true)
return super.exec(args)
}
}
module.exports = LL
+50
View File
@@ -0,0 +1,50 @@
const { log, output } = require('proc-log')
const { redactLog: replaceInfo } = require('@npmcli/redact')
const auth = require('../utils/auth.js')
const BaseCommand = require('../base-cmd.js')
class Login extends BaseCommand {
static description = 'Login to a registry user account'
static name = 'login'
static params = [
'registry',
'scope',
'auth-type',
]
async exec () {
const scope = this.npm.config.get('scope')
let registry = this.npm.config.get('registry')
if (scope) {
const scopedRegistry = this.npm.config.get(`${scope}:registry`)
const cliRegistry = this.npm.config.get('registry', 'cli')
if (scopedRegistry && !cliRegistry) {
registry = scopedRegistry
}
}
const creds = this.npm.config.getCredentialsByURI(registry)
log.notice('', `Log in on ${replaceInfo(registry)}`)
const { message, newCreds } = await auth.login(this.npm, {
...this.npm.flatOptions,
creds,
registry,
})
this.npm.config.delete('_token', 'user') // prevent legacy pollution
this.npm.config.setCredentialsByURI(registry, newCreds)
if (scope) {
this.npm.config.set(scope + ':registry', registry, 'user')
}
await this.npm.config.save('user')
output.standard(message)
}
}
module.exports = Login
+50
View File
@@ -0,0 +1,50 @@
const npmFetch = require('npm-registry-fetch')
const { getAuth } = npmFetch
const { log } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
class Logout extends BaseCommand {
static description = 'Log out of the registry'
static name = 'logout'
static params = [
'registry',
'scope',
]
async exec () {
const registry = this.npm.config.get('registry')
const scope = this.npm.config.get('scope')
const regRef = scope ? `${scope}:registry` : 'registry'
const reg = this.npm.config.get(regRef) || registry
const auth = getAuth(reg, this.npm.flatOptions)
const level = this.npm.config.find(`${auth.regKey}:${auth.authKey}`)
// find the config level and only delete from there
if (auth.token) {
log.verbose('logout', `clearing token for ${reg}`)
await npmFetch(`/-/user/token/${encodeURIComponent(auth.token)}`, {
...this.npm.flatOptions,
registry: reg,
method: 'DELETE',
ignoreBody: true,
})
} else if (auth.isBasicAuth) {
log.verbose('logout', `clearing user credentials for ${reg}`)
} else {
const msg = `not logged in to ${reg}, so can't log out!`
throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' })
}
if (scope) {
this.npm.config.delete(regRef, level)
}
this.npm.config.clearCredentialsByURI(reg, level)
await this.npm.config.save(level)
}
}
module.exports = Logout
+588
View File
@@ -0,0 +1,588 @@
const { resolve, relative, sep } = require('node:path')
const archy = require('archy')
const { breadth } = require('treeverse')
const npa = require('npm-package-arg')
const { output } = require('proc-log')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const relativePrefix = `.${sep}`
const _depth = Symbol('depth')
const _dedupe = Symbol('dedupe')
const _filteredBy = Symbol('filteredBy')
const _include = Symbol('include')
const _invalid = Symbol('invalid')
const _name = Symbol('name')
const _missing = Symbol('missing')
const _parent = Symbol('parent')
const _problems = Symbol('problems')
const _required = Symbol('required')
const _type = Symbol('type')
class LS extends ArboristWorkspaceCmd {
static description = 'List installed packages'
static name = 'ls'
static usage = ['<package-spec>']
static params = [
'all',
'json',
'long',
'parseable',
'global',
'depth',
'omit',
'include',
'link',
'package-lock-only',
'unicode',
...super.params,
]
static async completion (opts, npm) {
const completion = require('../utils/installed-deep.js')
return completion(npm, opts)
}
async exec (args) {
const all = this.npm.config.get('all')
const chalk = this.npm.chalk
const depth = this.npm.config.get('depth')
const global = this.npm.global
const json = this.npm.config.get('json')
const link = this.npm.config.get('link')
const long = this.npm.config.get('long')
const omit = this.npm.flatOptions.omit
const parseable = this.npm.config.get('parseable')
const unicode = this.npm.config.get('unicode')
const packageLockOnly = this.npm.config.get('package-lock-only')
const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
const installStrategy = this.npm.flatOptions.installStrategy
const path = global ? resolve(this.npm.globalDir, '..') : this.npm.prefix
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
global,
...this.npm.flatOptions,
legacyPeerDeps: false,
path,
})
const tree = await this.initTree({ arb, args, packageLockOnly })
// filters by workspaces nodes when using -w <workspace-name>
// We only have to filter the first layer of edges, so we don't explore anything that isn't part of the selected workspace set.
let wsNodes
if (this.workspaceNames && this.workspaceNames.length) {
wsNodes = arb.workspaceNodes(tree, this.workspaceNames)
}
const filterBySelectedWorkspaces = edge => {
if (!workspacesEnabled
&& edge.from.isProjectRoot
&& edge.to.isWorkspace
) {
return false
}
if (!wsNodes || !wsNodes.length) {
return true
}
if (this.npm.flatOptions.includeWorkspaceRoot
&& edge.to && !edge.to.isWorkspace) {
return true
}
if (edge.from.isProjectRoot) {
return (edge.to
&& edge.to.isWorkspace
&& wsNodes.includes(edge.to.target))
}
return true
}
const seenItems = new Set()
const seenNodes = new Map()
const problems = new Set()
// defines special handling of printed depth when filtering with args
const filterDefaultDepth = depth === null ? Infinity : depth
const depthToPrint = (all || args.length)
? filterDefaultDepth
: (depth || 0)
// add root node of tree to list of seenNodes
seenNodes.set(tree.path, tree)
// tree traversal happens here, using treeverse.breadth
const result = await breadth({
tree,
// recursive method, `node` is going to be the current elem (starting from the `tree` obj) that was just visited in the `visit` method below `nodeResult` is going to be the returned `item` from `visit`
getChildren (node, nodeResult) {
const seenPaths = new Set()
const workspace = node.isWorkspace
const currentDepth = workspace ? 0 : node[_depth]
const shouldSkipChildren =
!(node instanceof Arborist.Node) || (currentDepth > depthToPrint)
return (shouldSkipChildren)
? []
: [...(node.target).edgesOut.values()]
.filter(filterBySelectedWorkspaces)
.filter(currentDepth === 0 ? filterByEdgesTypes({
link,
omit,
}) : () => true)
.filter(installStrategy === 'linked'
? filterLinkedStrategyEdges({ node, currentDepth })
: () => true)
.map(mapEdgesToNodes({ seenPaths }))
.concat(appendExtraneousChildren({ node, seenPaths }))
.sort(sortAlphabetically)
.map(augmentNodesWithMetadata({
args,
currentDepth,
nodeResult,
seenNodes,
}))
},
// visit each `node` of the `tree`, returning an `item` - these are the elements that will be used to build the final output
visit (node) {
node[_problems] = getProblems(node, { global })
const item = json
? getJsonOutputItem(node, { global, long })
: parseable
? null
: getHumanOutputItem(node, { args, chalk, global, long })
// loop through list of node problems to add them to global list
if (node[_include]) {
for (const problem of node[_problems]) {
problems.add(problem)
}
}
seenItems.add(item)
// return a promise so we don't blow the stack
return Promise.resolve(item)
},
})
// handle the special case of a broken package.json in the root folder
const [rootError] = tree.errors.filter(e =>
e.code === 'EJSONPARSE' && e.path === resolve(path, 'package.json'))
if (json) {
output.buffer(jsonOutput({ path, problems, result, rootError, seenItems }))
} else {
output.standard(parseable
? parseableOutput({ seenNodes, global, long })
: humanOutput({ chalk, result, seenItems, unicode })
)
}
// if filtering items, should exit with error code on no results
if (result && !result[_include] && args.length) {
process.exitCode = 1
}
if (rootError) {
throw Object.assign(
new Error('Failed to parse root package.json'),
{ code: 'EJSONPARSE' }
)
}
const shouldThrow = problems.size &&
![...problems].every(problem => problem.startsWith('extraneous:'))
if (shouldThrow) {
throw Object.assign(
new Error([...problems].join('\n')),
{ code: 'ELSPROBLEMS' }
)
}
}
async initTree ({ arb, args, packageLockOnly }) {
const tree = await (
packageLockOnly
? arb.loadVirtual()
: arb.loadActual()
)
tree[_include] = args.length === 0
tree[_depth] = 0
return tree
}
}
module.exports = LS
const isGitNode = (node) => {
if (!node.resolved) {
return
}
try {
const { type } = npa(node.resolved)
return type === 'git' || type === 'hosted'
} catch {
return false
}
}
const isOptional = (node) =>
node[_type] === 'optional' || node[_type] === 'peerOptional'
const isExtraneous = (node, { global }) =>
node.extraneous && !global
const getProblems = (node, { global }) => {
const problems = new Set()
if (node[_missing] && !isOptional(node)) {
problems.add(`missing: ${node.pkgid}, required by ${node[_missing]}`)
}
if (node[_invalid]) {
problems.add(`invalid: ${node.pkgid} ${node.path}`)
}
if (isExtraneous(node, { global })) {
problems.add(`extraneous: ${node.pkgid} ${node.path}`)
}
return problems
}
// annotates _parent and _include metadata into the resulting item obj allowing for filtering out results during output
const augmentItemWithIncludeMetadata = (node, item) => {
item[_parent] = node[_parent]
item[_include] = node[_include]
// append current item to its parent.nodes which is the structure expected by archy in order to print tree
if (node[_include]) {
// includes all ancestors of included node
let p = node[_parent]
while (p) {
p[_include] = true
p = p[_parent]
}
}
return item
}
const getHumanOutputItem = (node, { args, chalk, global, long }) => {
const { pkgid, path } = node
const workspacePkgId = chalk.blueBright(pkgid)
let printable = node.isWorkspace ? workspacePkgId : pkgid
// special formatting for top-level package name
if (node.isRoot) {
const hasNoPackageJson = !Object.keys(node.package).length
if (hasNoPackageJson || global) {
printable = path
} else {
printable += `${long ? '\n' : ' '}${path}`
}
}
// TODO there is a LOT of overlap with lib/utils/explain-dep.js here
const highlightDepName = args.length && node[_filteredBy]
const missingColor = isOptional(node)
? chalk.yellow
: chalk.red
const missingMsg = `UNMET ${isOptional(node) ? 'OPTIONAL ' : ''}DEPENDENCY`
const targetLocation = node.root
? relative(node.root.realpath, node.realpath)
: node.targetLocation
const invalid = node[_invalid]
? `invalid: ${node[_invalid]}`
: ''
const label =
(
node[_missing]
? missingColor(missingMsg) + ' '
: ''
) +
`${highlightDepName ? chalk.yellow(printable) : printable}` +
(
node[_dedupe]
? ' ' + chalk.dim('deduped')
: ''
) +
(
invalid
? ' ' + chalk.red(invalid)
: ''
) +
(
isExtraneous(node, { global })
? ' ' + chalk.red('extraneous')
: ''
) +
(
node.overridden
? ' ' + chalk.dim('overridden')
: ''
) +
(isGitNode(node) ? ` (${node.resolved})` : '') +
(node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
(long ? `\n${node.package.description || ''}` : '')
return augmentItemWithIncludeMetadata(node, { label, nodes: [] })
}
const getJsonOutputItem = (node, { global, long }) => {
const item = {}
if (node.version) {
item.version = node.version
}
if (node.resolved) {
item.resolved = node.resolved
}
// if the node is the project root, do not add the overridden flag.
// the project root can't be overridden anyway, and if we add the flag it causes undesirable behavior when `npm ls --json` is ran in an empty directory since we end up printing an object with only an overridden prop
if (!node.isProjectRoot) {
item.overridden = node.overridden
}
item[_name] = node.name
// special formatting for top-level package name
const hasPackageJson =
node && node.package && Object.keys(node.package).length
if (node.isRoot && hasPackageJson) {
item.name = node.package.name || node.name
}
if (long && !node[_missing]) {
item.name = item[_name]
const { dependencies, ...packageInfo } = node.package
Object.assign(item, packageInfo)
item.extraneous = false
item.path = node.path
item._dependencies = {
...node.package.dependencies,
...node.package.optionalDependencies,
}
item.devDependencies = node.package.devDependencies || {}
item.peerDependencies = node.package.peerDependencies || {}
}
// augment json output items with extra metadata
if (isExtraneous(node, { global })) {
item.extraneous = true
}
if (node[_invalid]) {
item.invalid = node[_invalid]
}
if (node[_missing] && !isOptional(node)) {
item.required = node[_required]
item.missing = true
}
if (node[_include] && node[_problems] && node[_problems].size) {
item.problems = [...node[_problems]]
}
return augmentItemWithIncludeMetadata(node, item)
}
// In linked strategy, two types of edges produce false UNMET DEPENDENCYs:
// 1. Workspace edges for undeclared workspaces: the lockfile records edges from root to ALL workspaces, but only declared workspaces are hoisted to root/node_modules in linked mode. Undeclared ones are intentionally absent.
// 2. Dev edges on non-root packages: store package link targets have no parent in the node tree, so they are treated as "top" nodes and their devDependencies are loaded as edges. Those devDeps are never installed.
const filterLinkedStrategyEdges = ({ node, currentDepth }) => {
const declaredDeps = new Set(Object.keys(Object.assign({},
node.target.package.dependencies,
node.target.package.devDependencies,
node.target.package.optionalDependencies,
node.target.package.peerDependencies
)))
return (edge) => {
// Skip workspace edges for undeclared workspaces at root level
if (currentDepth === 0 && edge.type === 'workspace' && edge.missing) {
if (!declaredDeps.has(edge.name)) {
return false
}
}
// Skip dev edges for non-root packages (store packages)
if (currentDepth > 0 && edge.dev) {
return false
}
return true
}
}
const filterByEdgesTypes = ({ link, omit }) => (edge) => {
for (const omitType of omit) {
if (edge[omitType]) {
return false
}
}
return link ? edge.to && edge.to.isLink : true
}
const appendExtraneousChildren = ({ node, seenPaths }) =>
// extraneous children are not represented
// in edges out, so here we add them to the list:
[...node.children.values()]
.filter(i => !seenPaths.has(i.path) && i.extraneous)
const mapEdgesToNodes = ({ seenPaths }) => (edge) => {
let node = edge.to
// if the edge is linking to a missing node, we go ahead and create a new obj that will represent the missing node
if (edge.missing || (edge.optional && !node)) {
const { name, spec } = edge
const pkgid = `${name}@${spec}`
node = { name, pkgid, [_missing]: edge.from.pkgid }
}
// keeps track of a set of seen paths to avoid the edge case in which a tree item would appear twice given that it's a children of an extraneous item
// so it's marked extraneous but it will ALSO show up in edgesOuts of its parent so it ends up as two diff nodes if we don't track it
if (node.path) {
seenPaths.add(node.path)
}
node[_required] = edge.spec || '*'
node[_type] = edge.type
if (edge.invalid) {
const spec = JSON.stringify(node[_required])
const from = edge.from.location || 'the root project'
node[_invalid] = (node[_invalid] ? node[_invalid] + ', ' : '') +
(`${spec} from ${from}`)
}
return node
}
const filterByPositionalArgs = (args, { node }) =>
args.length > 0 ? args.some(
(spec) => (node.satisfies && node.satisfies(spec))
) : true
const augmentNodesWithMetadata = ({
args,
currentDepth,
nodeResult,
seenNodes,
}) => (node) => {
// if the original edge was a deduped dep, treeverse will fail to revisit that node in tree traversal logic, so we make it so that we have a diff obj for deduped nodes:
if (seenNodes.has(node.path)) {
const { realpath, root } = node
const targetLocation = root ? relative(root.realpath, realpath)
: node.targetLocation
node = {
name: node.name,
version: node.version,
pkgid: node.pkgid,
package: node.package,
path: node.path,
isLink: node.isLink,
realpath: node.realpath,
targetLocation,
[_type]: node[_type],
[_invalid]: node[_invalid],
[_missing]: node[_missing],
// if it's missing, it's not deduped, it's just missing
[_dedupe]: !node[_missing],
}
} else {
// keeps track of already seen nodes in order to check for dedupes
seenNodes.set(node.path, node)
}
// _parent is going to be a ref to a treeverse-visited node (returned from getHumanOutputItem, getJsonOutputItem, etc) so that we have an easy shortcut to place new nodes in their right place during tree traversal
node[_parent] = nodeResult
// _include is the property that allow us to filter based on position args
// e.g: `npm ls foo`, `npm ls simple-output@2`
// _filteredBy is used to apply extra color info to the item that was used in args in order to filter
node[_filteredBy] = node[_include] =
filterByPositionalArgs(args, { node: seenNodes.get(node.path) })
// _depth keeps track of how many levels deep tree traversal currently is so that we can `npm ls --depth=1`
node[_depth] = currentDepth + 1
return node
}
const sortAlphabetically = ({ pkgid: a }, { pkgid: b }) => localeCompare(a, b)
const humanOutput = ({ chalk, result, seenItems, unicode }) => {
// we need to traverse the entire tree in order to determine which items should be included (since a nested transitive included dep will make it so that all its ancestors should be displayed)
// here is where we put items in their expected place for archy output
for (const item of seenItems) {
if (item[_include] && item[_parent]) {
item[_parent].nodes.push(item)
}
}
if (!result.nodes.length) {
result.nodes = ['(empty)']
}
const archyOutput = archy(result, '', { unicode })
return chalk.reset(archyOutput)
}
const jsonOutput = ({ path, problems, result, rootError, seenItems }) => {
if (problems.size) {
result.problems = [...problems]
}
if (rootError) {
result.problems = [
...(result.problems || []),
...[`error in ${path}: Failed to parse root package.json`],
]
result.invalid = true
}
// we need to traverse the entire tree in order to determine which items should be included (since a nested transitive included dep will make it so that all its ancestors should be displayed)
// here is where we put items in their expected place for json output
for (const item of seenItems) {
// append current item to its parent item.dependencies obj in order to provide a json object structure that represents the installed tree
if (item[_include] && item[_parent]) {
if (!item[_parent].dependencies) {
item[_parent].dependencies = {}
}
item[_parent].dependencies[item[_name]] = item
}
}
return result
}
const parseableOutput = ({ global, long, seenNodes }) => {
let out = ''
for (const node of seenNodes.values()) {
if (node.path && node[_include]) {
out += node.path
if (long) {
out += `:${node.pkgid}`
out += node.path !== node.realpath ? `:${node.realpath}` : ''
out += isExtraneous(node, { global }) ? ':EXTRANEOUS' : ''
out += node[_invalid] ? ':INVALID' : ''
out += node.overridden ? ':OVERRIDDEN' : ''
}
out += '\n'
}
}
return out.trim()
}
+148
View File
@@ -0,0 +1,148 @@
const liborg = require('libnpmorg')
const { otplease } = require('../utils/auth.js')
const BaseCommand = require('../base-cmd.js')
const { output } = require('proc-log')
class Org extends BaseCommand {
static description = 'Manage orgs'
static name = 'org'
static usage = [
'set orgname username [developer | admin | owner]',
'rm orgname username',
'ls orgname [<username>]',
]
static params = ['registry', 'otp', 'json', 'parseable']
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return ['set', 'rm', 'ls']
}
switch (argv[2]) {
case 'ls':
case 'add':
case 'rm':
case 'set':
return []
default:
throw new Error(argv[2] + ' not recognized')
}
}
async exec ([cmd, orgname, username, role]) {
return otplease(this.npm, {
...this.npm.flatOptions,
}, opts => {
switch (cmd) {
case 'add':
case 'set':
return this.set(orgname, username, role, opts)
case 'rm':
return this.rm(orgname, username, opts)
case 'ls':
return this.ls(orgname, username, opts)
default:
throw this.usageError()
}
})
}
async set (org, user, role, opts) {
role = role || 'developer'
if (!org) {
throw new Error('First argument `orgname` is required.')
}
if (!user) {
throw new Error('Second argument `username` is required.')
}
if (!['owner', 'admin', 'developer'].find(x => x === role)) {
throw new Error(
'Third argument `role` must be one of `owner`, `admin`, or `developer`, with `developer` being the default value if omitted.'
)
}
const memDeets = await liborg.set(org, user, role, opts)
if (opts.json) {
output.standard(JSON.stringify(memDeets, null, 2))
} else if (opts.parseable) {
output.standard(['org', 'orgsize', 'user', 'role'].join('\t'))
output.standard(
[memDeets.org.name, memDeets.org.size, memDeets.user, memDeets.role].join('\t')
)
} else if (!this.npm.silent) {
output.standard(
`Added ${memDeets.user} as ${memDeets.role} to ${memDeets.org.name}. You now have ${
memDeets.org.size
} member${memDeets.org.size === 1 ? '' : 's'} in this org.`
)
}
return memDeets
}
async rm (org, user, opts) {
if (!org) {
throw new Error('First argument `orgname` is required.')
}
if (!user) {
throw new Error('Second argument `username` is required.')
}
await liborg.rm(org, user, opts)
const roster = await liborg.ls(org, opts)
user = user.replace(/^[~@]?/, '')
org = org.replace(/^[~@]?/, '')
const userCount = Object.keys(roster).length
if (opts.json) {
output.buffer({
user,
org,
userCount,
deleted: true,
})
} else if (opts.parseable) {
output.standard(['user', 'org', 'userCount', 'deleted'].join('\t'))
output.standard([user, org, userCount, true].join('\t'))
} else if (!this.npm.silent) {
output.standard(
`Successfully removed ${user} from ${org}. You now have ${userCount} member${userCount === 1 ? '' : 's'} in this org.`
)
}
}
async ls (org, user, opts) {
if (!org) {
throw new Error('First argument `orgname` is required.')
}
let roster = await liborg.ls(org, opts)
if (user) {
const newRoster = {}
if (roster[user]) {
newRoster[user] = roster[user]
}
roster = newRoster
}
if (opts.json) {
output.buffer(roster)
} else if (opts.parseable) {
output.standard(['user', 'role'].join('\t'))
Object.keys(roster).forEach(u => {
output.standard([u, roster[u]].join('\t'))
})
} else if (!this.npm.silent) {
const chalk = this.npm.chalk
for (const u of Object.keys(roster).sort()) {
output.standard(`${u} - ${chalk.cyan(roster[u])}`)
}
}
}
}
module.exports = Org
+286
View File
@@ -0,0 +1,286 @@
const { resolve } = require('node:path')
const { stripVTControlCharacters } = require('node:util')
const pacote = require('pacote')
const table = require('text-table')
const npa = require('npm-package-arg')
const pickManifest = require('npm-pick-manifest')
const { output } = require('proc-log')
const localeCompare = require('@isaacs/string-locale-compare')('en')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const safeNpa = (spec) => {
try {
return npa(spec)
} catch {
return null
}
}
// This string is load bearing and is shared with Arborist
const MISSING = 'MISSING'
class Outdated extends ArboristWorkspaceCmd {
static description = 'Check for outdated packages'
static name = 'outdated'
static usage = ['[<package-spec> ...]']
static params = [
'all',
'json',
'long',
'parseable',
'global',
'workspace',
'before',
'min-release-age',
]
#tree
#list = []
#edges = new Set()
#filterSet
async exec (args) {
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
...this.npm.flatOptions,
path: this.npm.global ? resolve(this.npm.globalDir, '..') : this.npm.prefix,
})
this.#tree = await arb.loadActual()
if (this.workspaceNames?.length) {
this.#filterSet = arb.workspaceDependencySet(
this.#tree,
this.workspaceNames,
this.npm.flatOptions.includeWorkspaceRoot
)
} else if (!this.npm.flatOptions.workspacesEnabled) {
this.#filterSet = arb.excludeWorkspacesDependencySet(this.#tree)
}
if (args.length) {
for (const arg of args) {
// specific deps
this.#getEdges(this.#tree.inventory.query('name', arg), 'edgesIn')
}
} else {
if (this.npm.config.get('all')) {
// all deps in tree
this.#getEdges(this.#tree.inventory.values(), 'edgesOut')
}
// top-level deps
this.#getEdges()
}
await Promise.all([...this.#edges].map((e) => this.#getOutdatedInfo(e)))
// sorts list alphabetically by name and then dependent
const outdated = this.#list
.sort((a, b) => localeCompare(a.name, b.name) || localeCompare(a.dependent, b.dependent))
if (outdated.length) {
process.exitCode = 1
}
if (this.npm.config.get('json')) {
output.buffer(this.#json(outdated))
return
}
const res = this.npm.config.get('parseable')
? this.#parseable(outdated)
: this.#pretty(outdated)
if (res) {
output.standard(res)
}
}
#getEdges (nodes, type) {
// when no nodes are provided then it should only read direct deps from the root node and its workspaces direct dependencies
if (!nodes) {
this.#getEdgesOut(this.#tree)
this.#getWorkspacesEdges()
return
}
for (const node of nodes) {
if (type === 'edgesOut') {
this.#getEdgesOut(node)
} else {
this.#getEdgesIn(node)
}
}
}
#getEdgesIn (node) {
for (const edge of node.edgesIn) {
this.#trackEdge(edge)
}
}
#getEdgesOut (node) {
// TODO: normalize usage of edges and avoid looping through nodes here
const edges = this.npm.global ? node.children.values() : node.edgesOut.values()
for (const edge of edges) {
this.#trackEdge(edge)
}
}
#trackEdge (edge) {
if (edge.from && this.#filterSet?.size > 0 && !this.#filterSet.has(edge.from.target)) {
return
}
this.#edges.add(edge)
}
#getWorkspacesEdges () {
if (this.npm.global) {
return
}
for (const edge of this.#tree.edgesOut.values()) {
if (edge?.to?.target?.isWorkspace) {
this.#getEdgesOut(edge.to.target)
}
}
}
async #getPackument (spec) {
return pacote.packument(spec, {
...this.npm.flatOptions,
fullMetadata: this.npm.config.get('long'),
preferOnline: true,
})
}
async #getOutdatedInfo (edge) {
const alias = safeNpa(edge.spec)?.subSpec
const spec = npa(alias ? alias.name : edge.name)
const node = edge.to || edge
const { path, location, package: { version: current } = {} } = node
const type = edge.optional ? 'optionalDependencies'
: edge.peer ? 'peerDependencies'
: edge.dev ? 'devDependencies'
: 'dependencies'
for (const omitType of this.npm.flatOptions.omit) {
if (node[omitType]) {
return
}
}
// deps different from prod not currently on disk are not included in the output
if (edge.error === MISSING && type !== 'dependencies') {
return
}
// if it's not a range, version, or tag, skip it
if (!safeNpa(`${edge.name}@${edge.spec}`)?.registry) {
return null
}
try {
const packument = await this.#getPackument(spec)
const expected = alias ? alias.fetchSpec : edge.spec
const wanted = pickManifest(packument, expected, this.npm.flatOptions)
const latest = pickManifest(packument, '*', this.npm.flatOptions)
if (!current || current !== wanted.version || wanted.version !== latest.version) {
this.#list.push({
name: alias ? edge.spec.replace('npm', edge.name) : edge.name,
path,
type,
current,
location,
wanted: wanted.version,
latest: latest.version,
workspaceDependent: edge.from?.isWorkspace ? edge.from.pkgid : null,
dependedByLocation: edge.from?.name
? edge.from?.location
: 'global',
dependent: edge.from?.name ?? 'global',
homepage: packument.homepage,
})
}
} catch (err) {
// silently catch and ignore ETARGET, E403 & E404 errors
// deps are just skipped
if (!['ETARGET', 'E403', 'E404'].includes(err.code)) {
throw err
}
}
}
// formatting functions
#pretty (list) {
if (!list.length) {
return
}
const long = this.npm.config.get('long')
const { bold, yellow, red, cyan, blue } = this.npm.chalk
return table([
[
'Package',
'Current',
'Wanted',
'Latest',
'Location',
'Depended by',
...long ? ['Package Type', 'Homepage', 'Depended By Location'] : [],
].map(h => bold.underline(h)),
...list.map((d) => [
d.current === d.wanted ? yellow(d.name) : red(d.name),
d.current ?? 'MISSING',
cyan(d.wanted),
blue(d.latest),
d.location ?? '-',
d.workspaceDependent ? blue(d.workspaceDependent) : d.dependent,
...long ? [d.type, blue(d.homepage ?? ''), d.dependedByLocation] : [],
]),
], {
align: ['l', 'r', 'r', 'r', 'l'],
stringLength: s => stripVTControlCharacters(s).length,
})
}
// --parseable creates output like this:
// <fullpath>:<name@wanted>:<name@installed>:<name@latest>:<dependedby>
#parseable (list) {
return list.map(d => [
d.path,
`${d.name}@${d.wanted}`,
d.current ? `${d.name}@${d.current}` : 'MISSING',
`${d.name}@${d.latest}`,
d.dependent,
...this.npm.config.get('long') ? [d.type, d.homepage, d.dependedByLocation] : [],
].join(':')).join('\n')
}
#json (list) {
// TODO(BREAKING_CHANGE): this should just return an array.
// It's a list and turning it into an object with keys is lossy since multiple items in the list could have the same key. For now we hack that by only changing top level values into arrays if they have multiple outdated items
return list.reduce((acc, d) => {
const dep = {
current: d.current,
wanted: d.wanted,
latest: d.latest,
dependent: d.dependent,
location: d.path,
...this.npm.config.get('long') ? {
type: d.type,
homepage: d.homepage,
dependedByLocation: d.dependedByLocation } : {},
}
acc[d.name] = acc[d.name]
// If this item already has an outdated dep then we turn it into an array
? (Array.isArray(acc[d.name]) ? acc[d.name] : [acc[d.name]]).concat(dep)
: dep
return acc
}, {})
}
}
module.exports = Outdated
+241
View File
@@ -0,0 +1,241 @@
const npa = require('npm-package-arg')
const npmFetch = require('npm-registry-fetch')
const pacote = require('pacote')
const { log, output } = require('proc-log')
const { otplease } = require('../utils/auth.js')
const pkgJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
const { redact } = require('@npmcli/redact')
const readJson = async (path) => {
try {
const { content } = await pkgJson.normalize(path)
return content
} catch {
return {}
}
}
class Owner extends BaseCommand {
static description = 'Manage package owners'
static name = 'owner'
static params = [
'registry',
'otp',
'workspace',
'workspaces',
]
static usage = [
'add <user> <package-spec>',
'rm <user> <package-spec>',
'ls <package-spec>',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static async completion (opts, npm) {
const argv = opts.conf.argv.remain
if (argv.length > 3) {
return []
}
if (argv[1] !== 'owner') {
argv.unshift('owner')
}
if (argv.length === 2) {
return ['add', 'rm', 'ls']
}
// reaches registry in order to autocomplete rm
if (argv[2] === 'rm') {
if (npm.global) {
return []
}
const { name } = await readJson(npm.prefix)
if (!name) {
return []
}
const spec = npa(name)
const data = await pacote.packument(spec, {
...npm.flatOptions,
fullMetadata: true,
_isRoot: true,
})
if (data && data.maintainers && data.maintainers.length) {
return data.maintainers.map(m => m.name)
}
}
return []
}
async exec ([action, ...args]) {
if (action === 'ls' || action === 'list') {
await this.ls(args[0])
} else if (action === 'add') {
await this.changeOwners(args[0], args[1], 'add')
} else if (action === 'rm' || action === 'remove') {
await this.changeOwners(args[0], args[1], 'rm')
} else {
throw this.usageError()
}
}
async execWorkspaces ([action, ...args]) {
await this.setWorkspaces()
// ls pkg or owner add/rm package
if ((action === 'ls' && args.length > 0) || args.length > 1) {
const implicitWorkspaces = this.npm.config.get('workspace', 'default')
if (implicitWorkspaces.length === 0) {
log.warn(`Ignoring specified workspace(s)`)
}
return this.exec([action, ...args])
}
for (const [name] of this.workspaces) {
if (action === 'ls' || action === 'list') {
await this.ls(name)
} else if (action === 'add') {
await this.changeOwners(args[0], name, 'add')
} else if (action === 'rm' || action === 'remove') {
await this.changeOwners(args[0], name, 'rm')
} else {
throw this.usageError()
}
}
}
async ls (pkg) {
pkg = await this.getPkg(this.npm.prefix, pkg)
const spec = npa(pkg)
try {
const packumentOpts = {
...this.npm.flatOptions,
fullMetadata:
true,
preferOnline: true,
_isRoot: true,
}
const { maintainers } = await pacote.packument(spec, packumentOpts)
if (!maintainers || !maintainers.length) {
output.standard('no admin found')
} else {
output.standard(maintainers.map(m => `${m.name} <${m.email}>`).join('\n'))
}
} catch (err) {
log.error('owner ls', "Couldn't get owner data", redact(pkg))
throw err
}
}
async getPkg (prefix, pkg) {
if (!pkg) {
if (this.npm.global) {
throw this.usageError()
}
const { name } = await readJson(prefix)
if (!name) {
throw this.usageError()
}
return name
}
return pkg
}
async changeOwners (user, pkg, addOrRm) {
if (!user) {
throw this.usageError()
}
pkg = await this.getPkg(this.npm.prefix, pkg)
log.verbose(`owner ${addOrRm}`, '%s to %s', user, pkg)
const spec = npa(pkg)
const uri = `/-/user/org.couchdb.user:${encodeURIComponent(user)}`
let u
try {
u = await npmFetch.json(uri, this.npm.flatOptions)
} catch (err) {
log.error('owner mutate', `Error getting user data for ${user}`)
throw err
}
// normalize user data
u = { name: u.name, email: u.email }
const data = await pacote.packument(spec, {
...this.npm.flatOptions,
fullMetadata: true,
preferOnline: true,
_isRoot: true,
})
const owners = data.maintainers || []
let maintainers
if (addOrRm === 'add') {
const existing = owners.find(o => o.name === u.name)
if (existing) {
log.info(
'owner add',
`Already a package owner: ${existing.name} <${existing.email}>`
)
return
}
maintainers = [
...owners,
u,
]
} else {
maintainers = owners.filter(o => o.name !== u.name)
if (maintainers.length === owners.length) {
log.info('owner rm', 'Not a package owner: ' + u.name)
return false
}
if (!maintainers.length) {
throw Object.assign(
new Error(
'Cannot remove all owners of a package. Add someone else first.'
),
{ code: 'EOWNERRM' }
)
}
}
const dataPath = `/${spec.escapedName}/-rev/${encodeURIComponent(data._rev)}`
try {
const res = await otplease(this.npm, this.npm.flatOptions, opts => {
return npmFetch.json(dataPath, {
...opts,
method: 'PUT',
body: {
_id: data._id,
_rev: data._rev,
maintainers,
},
spec,
})
})
if (addOrRm === 'add') {
output.standard(`+ ${user} (${spec.name})`)
} else {
output.standard(`- ${user} (${spec.name})`)
}
return res
} catch (err) {
throw Object.assign(
new Error('Failed to update package: ' + JSON.stringify(err.message)),
{ code: 'EOWNERMUTATE' }
)
}
}
}
module.exports = Owner
+89
View File
@@ -0,0 +1,89 @@
const pacote = require('pacote')
const libpack = require('libnpmpack')
const npa = require('npm-package-arg')
const { log, output } = require('proc-log')
const { getContents, logTar } = require('../utils/tar.js')
const BaseCommand = require('../base-cmd.js')
class Pack extends BaseCommand {
static description = 'Create a tarball from a package'
static name = 'pack'
static params = [
'dry-run',
'json',
'pack-destination',
'workspace',
'workspaces',
'include-workspace-root',
'ignore-scripts',
]
static usage = ['<package-spec>']
static workspaces = true
static ignoreImplicitWorkspace = false
async exec (args) {
if (args.length === 0) {
args = ['.']
}
const unicode = this.npm.config.get('unicode')
const json = this.npm.config.get('json')
const Arborist = require('@npmcli/arborist')
// Get the manifests and filenames first so we can bail early on manifest errors before making any tarballs
const manifests = []
for (const arg of args) {
const spec = npa(arg)
const manifest = await pacote.manifest(spec, {
...this.npm.flatOptions,
Arborist,
preferOnline: true,
_isRoot: true,
})
if (!manifest._id) {
throw new Error('Invalid package, must have name and version')
}
manifests.push({ arg, manifest })
}
// Load tarball names up for printing afterward to isolate from the noise generated during packing
const tarballs = []
for (const { arg, manifest } of manifests) {
const tarballData = await libpack(arg, {
...this.npm.flatOptions,
foregroundScripts: this.npm.config.isDefault('foreground-scripts')
? true
: this.npm.config.get('foreground-scripts'),
preferOnline: true,
prefix: this.npm.localPrefix,
workspaces: this.workspacePaths,
})
tarballs.push(await getContents(manifest, tarballData))
}
for (const [index, tar] of Object.entries(tarballs)) {
// XXX(BREAKING_CHANGE): publish outputs a json object with package names as keys.
// Pack should do the same here instead of an array
logTar(tar, { unicode, json, key: index })
if (!json) {
output.standard(tar.filename.replace(/^@/, '').replace(/\//, '-'))
}
}
}
async execWorkspaces (args) {
// If they either ask for nothing, or explicitly include '.' in the args, we effectively translate that into each workspace requested
const useWorkspaces = args.length === 0 || args.includes('.')
if (!useWorkspaces) {
log.warn('Ignoring workspaces for specified package(s)')
return this.exec(args)
}
await this.setWorkspaces()
return this.exec([...this.workspacePaths, ...args.filter(a => a !== '.')])
}
}
module.exports = Pack
+30
View File
@@ -0,0 +1,30 @@
const { redact } = require('@npmcli/redact')
const { log, output } = require('proc-log')
const pingUtil = require('../utils/ping.js')
const BaseCommand = require('../base-cmd.js')
class Ping extends BaseCommand {
static description = 'Ping npm registry'
static params = ['registry']
static name = 'ping'
async exec () {
const cleanRegistry = redact(this.npm.config.get('registry'))
log.notice('PING', cleanRegistry)
const start = Date.now()
const details = await pingUtil({ ...this.npm.flatOptions })
const time = Date.now() - start
log.notice('PONG', `${time}ms`)
if (this.npm.config.get('json')) {
output.buffer({
registry: cleanRegistry,
time,
details,
})
} else if (Object.keys(details).length) {
log.notice('PONG', JSON.stringify(details, null, 2))
}
}
}
module.exports = Ping
+127
View File
@@ -0,0 +1,127 @@
const { output } = require('proc-log')
const PackageJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
const Queryable = require('../utils/queryable.js')
class Pkg extends BaseCommand {
static description = 'Manages your package.json'
static name = 'pkg'
static usage = [
'set <key>=<value> [<key>=<value> ...]',
'get [<key> [<key> ...]]',
'delete <key> [<key> ...]',
'set [<array>[<index>].<key>=<value> ...]',
'set [<array>[].<key>=<value> ...]',
'fix',
]
static params = [
'force',
'json',
'workspace',
'workspaces',
]
static workspaces = true
static ignoreImplicitWorkspace = false
async exec (args, { path = this.npm.localPrefix, workspace } = {}) {
if (this.npm.global) {
throw Object.assign(
new Error(`There's no package.json file to manage on global mode`),
{ code: 'EPKGGLOBAL' }
)
}
const [cmd, ..._args] = args
switch (cmd) {
case 'get':
return this.get(_args, { path, workspace })
case 'set':
return this.set(_args, { path, workspace }).then(p => p.save())
case 'delete':
return this.delete(_args, { path, workspace }).then(p => p.save())
case 'fix':
return PackageJson.fix(path).then(p => p.save())
default:
throw this.usageError()
}
}
async execWorkspaces (args) {
await this.setWorkspaces()
for (const [workspace, path] of this.workspaces.entries()) {
await this.exec(args, { path, workspace })
}
}
async get (args, { path, workspace }) {
this.npm.config.set('json', true)
const pkgJson = await PackageJson.load(path)
let result = pkgJson.content
if (args.length) {
result = new Queryable(result).query(args)
// in case there's only a single argument and a single result from the query just prints that one element to stdout.
// TODO(BREAKING_CHANGE): much like other places where we unwrap single item arrays this should go away.
// it makes the behavior unknown for users who don't already know the shape of the data.
if (Object.keys(result).length === 1 && args.length === 1) {
result = result[args]
}
}
// The display layer is responsible for calling JSON.stringify on the result
// TODO: https://github.com/npm/cli/issues/5508 a raw mode has been requested similar to jq -r.
// If that was added then this method should no longer set `json:true` all the time
output.buffer(workspace ? { [workspace]: result } : result)
}
async set (args, { path }) {
const setError = () =>
this.usageError('npm pkg set expects a key=value pair of args.')
if (!args.length) {
throw setError()
}
const force = this.npm.config.get('force')
const json = this.npm.config.get('json')
const pkgJson = await PackageJson.load(path)
const q = new Queryable(pkgJson.content)
for (const arg of args) {
const [key, ...rest] = arg.split('=')
const value = rest.join('=')
if (!key || !value) {
throw setError()
}
q.set(key, json ? JSON.parse(value) : value, { force })
}
return pkgJson.update(q.toJSON())
}
async delete (args, { path }) {
const setError = () =>
this.usageError('npm pkg delete expects key args.')
if (!args.length) {
throw setError()
}
const pkgJson = await PackageJson.load(path)
const q = new Queryable(pkgJson.content)
for (const key of args) {
if (!key) {
throw setError()
}
q.delete(key)
}
return pkgJson.update(q.toJSON())
}
}
module.exports = Pkg
+14
View File
@@ -0,0 +1,14 @@
const { output } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
class Prefix extends BaseCommand {
static description = 'Display prefix'
static name = 'prefix'
static params = ['global']
async exec () {
return output.standard(this.npm.prefix)
}
}
module.exports = Prefix
+366
View File
@@ -0,0 +1,366 @@
const { inspect } = require('node:util')
const { URL } = require('node:url')
const { log, output } = require('proc-log')
const { get, set, createToken } = require('npm-profile')
const qrcodeTerminal = require('qrcode-terminal')
const { otplease } = require('../utils/auth.js')
const readUserInfo = require('../utils/read-user-info.js')
const BaseCommand = require('../base-cmd.js')
const qrcode = url =>
new Promise((resolve) => qrcodeTerminal.generate(url, resolve))
const knownProfileKeys = [
'name',
'email',
'two-factor auth',
'fullname',
'homepage',
'freenode',
'twitter',
'github',
'created',
'updated',
]
const writableProfileKeys = [
'email',
'password',
'fullname',
'homepage',
'freenode',
'twitter',
'github',
]
class Profile extends BaseCommand {
static description = 'Change settings on your registry profile'
static name = 'profile'
static usage = [
'enable-2fa [auth-only|auth-and-writes]',
'disable-2fa',
'get [<key>]',
'set <key> <value>',
]
static params = [
'registry',
'json',
'parseable',
'otp',
]
static async completion (opts) {
var argv = opts.conf.argv.remain
if (!argv[2]) {
return ['enable-2fa', 'disable-2fa', 'get', 'set']
}
switch (argv[2]) {
case 'enable-2fa':
case 'enable-tfa':
return ['auth-and-writes', 'auth-only']
case 'disable-2fa':
case 'disable-tfa':
case 'get':
case 'set':
return []
default:
throw new Error(argv[2] + ' not recognized')
}
}
async exec (args) {
if (args.length === 0) {
throw this.usageError()
}
const [subcmd, ...opts] = args
switch (subcmd) {
case 'enable-2fa':
case 'enable-tfa':
case 'enable2fa':
case 'enabletfa':
return this.enable2fa(opts)
case 'disable-2fa':
case 'disable-tfa':
case 'disable2fa':
case 'disabletfa':
return this.disable2fa()
case 'get':
return this.get(opts)
case 'set':
return this.set(opts)
default:
throw new Error('Unknown profile command: ' + subcmd)
}
}
async get (args) {
const tfa = 'two-factor auth'
const info = await get({ ...this.npm.flatOptions })
if (!info.cidr_whitelist) {
delete info.cidr_whitelist
}
if (this.npm.config.get('json')) {
output.buffer(info)
return
}
// clean up and format key/values for output
const cleaned = {}
for (const key of knownProfileKeys) {
cleaned[key] = info[key] || ''
}
const unknownProfileKeys = Object.keys(info).filter((k) => !(k in cleaned))
for (const key of unknownProfileKeys) {
cleaned[key] = info[key] || ''
}
delete cleaned.tfa
delete cleaned.email_verified
cleaned.email += info.email_verified ? ' (verified)' : '(unverified)'
if (info.tfa && !info.tfa.pending) {
cleaned[tfa] = info.tfa.mode
} else {
cleaned[tfa] = 'disabled'
}
if (args.length) {
const values = args // comma or space separated
.join(',')
.split(/,/)
.filter((arg) => arg.trim() !== '')
.map((arg) => cleaned[arg])
.join('\t')
output.standard(values)
} else {
if (this.npm.config.get('parseable')) {
for (const key of Object.keys(info)) {
if (key === 'tfa') {
output.standard(`${key}\t${cleaned[tfa]}`)
} else {
output.standard(`${key}\t${info[key]}`)
}
}
} else {
for (const [key, value] of Object.entries(cleaned)) {
output.standard(`${key}: ${value}`)
}
}
}
}
async set (args) {
const conf = { ...this.npm.flatOptions }
const prop = (args[0] || '').toLowerCase().trim()
let value = args.length > 1 ? args.slice(1).join(' ') : null
const readPasswords = async () => {
const newpassword = await readUserInfo.password('New password: ')
const confirmedpassword = await readUserInfo.password(' Again: ')
if (newpassword !== confirmedpassword) {
log.warn('profile', 'Passwords do not match, please try again.')
return readPasswords()
}
return newpassword
}
if (prop !== 'password' && value === null) {
throw new Error('npm profile set <prop> <value>')
}
if (prop === 'password' && value !== null) {
throw new Error(
'npm profile set password\n' +
'Do not include your current or new passwords on the command line.')
}
if (writableProfileKeys.indexOf(prop) === -1) {
throw new Error(`"${prop}" is not a property we can set. ` +
`Valid properties are: ` + writableProfileKeys.join(', '))
}
if (prop === 'password') {
const current = await readUserInfo.password('Current password: ')
const newpassword = await readPasswords()
value = { old: current, new: newpassword }
}
// FIXME: Work around to not clear everything other than what we're setting
const user = await get(conf)
const newUser = {}
for (const key of writableProfileKeys) {
newUser[key] = user[key]
}
newUser[prop] = value
const result = await otplease(this.npm, conf, c => set(newUser, c))
if (this.npm.config.get('json')) {
output.buffer({ [prop]: result[prop] })
} else if (this.npm.config.get('parseable')) {
output.standard(prop + '\t' + result[prop])
} else if (result[prop] != null) {
output.standard('Set', prop, 'to', result[prop])
} else {
output.standard('Set', prop)
}
}
async enable2fa (args) {
const conf = { ...this.npm.flatOptions }
if (args.length > 1) {
throw new Error('npm profile enable-2fa [auth-and-writes|auth-only]')
}
const mode = args[0] || 'auth-and-writes'
if (mode !== 'auth-only' && mode !== 'auth-and-writes') {
throw new Error(
`Invalid two-factor authentication mode "${mode}".\n` +
'Valid modes are:\n' +
' auth-only - Require two-factor authentication only when logging in\n' +
' auth-and-writes - Require two-factor authentication when logging in ' +
'AND when publishing'
)
}
if (this.npm.config.get('json') || this.npm.config.get('parseable')) {
throw new Error(
'Enabling two-factor authentication is an interactive operation and ' +
(this.npm.config.get('json') ? 'JSON' : 'parseable') + ' output mode is not available'
)
}
const userInfo = await get(conf)
if (!userInfo?.tfa?.pending && userInfo?.tfa?.mode === mode) {
output.standard('Two factor authentication is already enabled and set to ' + mode)
return
}
const info = {
tfa: {
mode,
},
}
// if they're using legacy auth currently then we have to update them to a bearer token before continuing.
const creds = this.npm.config.getCredentialsByURI(this.npm.config.get('registry'))
const auth = {}
if (creds.token) {
auth.token = creds.token
} else if (creds.username) {
auth.basic = { username: creds.username, password: creds.password }
} else if (creds.auth) {
const basic = Buffer.from(creds.auth, 'base64').toString().split(':', 2)
auth.basic = { username: basic[0], password: basic[1] }
}
if (!auth.basic && !auth.token) {
throw new Error(
'You need to be logged in to registry ' +
`${this.npm.config.get('registry')} in order to enable 2fa`
)
}
if (auth.basic) {
log.info('profile', 'Updating authentication to bearer token')
const result = await createToken(
auth.basic.password, false, [], { ...this.npm.flatOptions }
)
if (!result.token) {
throw new Error(`Your registry ${this.npm.config.get('registry')} does not seem to support bearer tokens. Bearer tokens are required for two-factor authentication.`)
}
this.npm.config.setCredentialsByURI(
this.npm.config.get('registry'),
{ token: result.token }
)
await this.npm.config.save('user')
}
log.notice('profile', 'Enabling two factor authentication for ' + mode)
const password = await readUserInfo.password()
info.tfa.password = password
if (userInfo && userInfo.tfa && userInfo.tfa.pending) {
log.info('profile', 'Resetting two-factor authentication')
await set({ tfa: { password, mode: 'disable' } }, conf)
}
log.info('profile', 'Setting two-factor authentication to ' + mode)
const challenge = await otplease(this.npm, conf, o => set(info, o))
if (challenge.tfa && challenge.tfa.mode) {
output.standard('Two factor authentication mode changed to: ' + mode)
return
}
const badResponse = typeof challenge.tfa !== 'string'
|| !/^otpauth:[/][/]/.test(challenge.tfa)
if (badResponse) {
throw new Error(`Unknown error enabling two-factor authentication. Expected otpauth URL, got: ${inspect(challenge.tfa)}`)
}
const otpauth = new URL(challenge.tfa)
const secret = otpauth.searchParams.get('secret')
const code = await qrcode(challenge.tfa)
output.standard('Scan into your authenticator app:\n' + code + '\n Or enter code:', secret)
const interactiveOTP = await readUserInfo.otp('And an OTP code from your authenticator: ')
log.info('profile', 'Finalizing two-factor authentication')
const result = await set({ tfa: [interactiveOTP] }, conf)
output.standard('2FA successfully enabled. Below are your recovery codes, please print these out.')
output.standard('You will need these to recover access to your account if you lose your authentication device.')
for (const tfaCode of result.tfa) {
output.standard('\t' + tfaCode)
}
}
async disable2fa () {
const opts = { ...this.npm.flatOptions }
const info = await get(opts)
if (!info.tfa || info.tfa.pending) {
output.standard('Two factor authentication not enabled.')
return
}
const password = await readUserInfo.password()
log.info('profile', 'disabling tfa')
await otplease(this.npm, opts, o => set({ tfa: { password: password, mode: 'disable' } }, o))
if (this.npm.config.get('json')) {
output.buffer({ tfa: false })
} else if (this.npm.config.get('parseable')) {
output.standard('tfa\tfalse')
} else {
output.standard('Two factor authentication disabled.')
}
}
}
module.exports = Profile
+33
View File
@@ -0,0 +1,33 @@
const reifyFinish = require('../utils/reify-finish.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class Prune extends ArboristWorkspaceCmd {
static description = 'Remove extraneous packages'
static name = 'prune'
static params = [
'omit',
'include',
'dry-run',
'json',
'foreground-scripts',
'ignore-scripts',
...super.params,
]
static usage = ['[[<@scope>/]<pkg>...]']
async exec () {
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
...this.npm.flatOptions,
path: where,
workspaces: this.workspaceNames,
}
const arb = new Arborist(opts)
await arb.prune(opts)
await reifyFinish(this.npm, arb)
}
}
module.exports = Prune
+310
View File
@@ -0,0 +1,310 @@
const { log, output, META } = require('proc-log')
const semver = require('semver')
const pack = require('libnpmpack')
const libpub = require('libnpmpublish').publish
const runScript = require('@npmcli/run-script')
const pacote = require('pacote')
const npa = require('npm-package-arg')
const npmFetch = require('npm-registry-fetch')
const { redactLog: replaceInfo } = require('@npmcli/redact')
const { otplease } = require('../utils/auth.js')
const { getContents, logTar } = require('../utils/tar.js')
// for historical reasons, publishConfig in package.json can contain ANY config keys that npm supports in .npmrc files and elsewhere.
// We *may* want to revisit this at some point, and have a minimal set that's a SemVer-major change that ought to get a RFC written on it.
const { flatten } = require('@npmcli/config/lib/definitions')
const pkgJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
const { oidc } = require('../utils/oidc.js')
class Publish extends BaseCommand {
static description = 'Publish a package'
static name = 'publish'
static stage = false
get isStage () {
return this.constructor.stage
}
static params = [
'tag',
'access',
'dry-run',
'otp',
'workspace',
'workspaces',
'include-workspace-root',
'provenance',
]
static usage = ['<package-spec>']
static workspaces = true
static ignoreImplicitWorkspace = false
async exec (args) {
if (args.length === 0) {
args = ['.']
}
if (args.length !== 1) {
throw this.usageError()
}
await this.#publish(args)
}
async execWorkspaces (args) {
const useWorkspaces = args.length === 0 || args.includes('.')
if (!useWorkspaces) {
log.warn('Ignoring workspaces for specified package(s)')
return this.exec(args)
}
await this.setWorkspaces()
for (const [name, workspace] of this.workspaces.entries()) {
try {
await this.#publish([workspace], { workspace: name })
} catch (err) {
if (err.code !== 'EPRIVATE') {
throw err
}
log.warn(this.#command, `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`)
}
}
}
get #command () {
return this.isStage ? 'stage' : 'publish'
}
async #publish (args, { workspace } = {}) {
log.verbose(this.#command, replaceInfo(args))
const unicode = this.npm.config.get('unicode')
const dryRun = this.npm.config.get('dry-run')
const json = this.npm.config.get('json')
const defaultTag = this.npm.config.get('tag')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const { silent } = this.npm
if (semver.validRange(defaultTag)) {
throw new Error('Tag name must not be a valid SemVer range: ' + defaultTag.trim())
}
const opts = { ...this.npm.flatOptions, progress: false }
// you can publish name@version, ./foo.tgz, etc even though the default is the 'file:.' cwd.
const spec = npa(args[0])
let manifest = await this.#getManifest(spec, opts)
// only run scripts for directory type publishes
if (spec.type === 'directory' && !ignoreScripts) {
await runScript({
event: 'prepublishOnly',
path: spec.fetchSpec,
stdio: 'inherit',
pkg: manifest,
})
}
// we pass dryRun: true to libnpmpack so it doesn't write the file to disk
const tarballData = await pack(spec, {
...opts,
foregroundScripts: this.npm.config.isDefault('foreground-scripts')
? true
: this.npm.config.get('foreground-scripts'),
dryRun: true,
prefix: this.npm.localPrefix,
workspaces: this.workspacePaths,
})
const pkgContents = await getContents(manifest, tarballData)
const logPkg = () => logTar(pkgContents, { unicode, json, key: workspace })
// The purpose of re-reading the manifest is in case it changed, so that we send the latest and greatest thing to the registry note that publishConfig might have changed as well!
manifest = await this.#getManifest(spec, opts, true)
const force = this.npm.config.get('force')
const isDefaultTag = this.npm.config.isDefault('tag') && !manifest.publishConfig?.tag
if (!force) {
const isPreRelease = Boolean(semver.parse(manifest.version).prerelease.length)
if (isPreRelease && isDefaultTag) {
throw new Error('You must specify a tag using --tag when publishing a prerelease version.')
}
}
// If we are not in JSON mode then we show the user the contents of the tarball before it is published so they can see it while their otp is pending
if (!json) {
logPkg()
}
const resolved = npa.resolve(manifest.name, manifest.version)
// make sure tag is valid, this will throw if invalid
npa(`${manifest.name}@${defaultTag}`)
const registry = npmFetch.pickRegistry(resolved, opts)
await oidc({ packageName: manifest.name, registry, opts, config: this.npm.config })
const creds = this.npm.config.getCredentialsByURI(registry)
const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile)
const outputRegistry = replaceInfo(registry)
if (workspace && manifest.private) {
throw Object.assign(
new Error(`This package has been marked as private
Remove the 'private' field from the package.json to publish it.`),
{ code: 'EPRIVATE' }
)
}
if (noCreds) {
const msg = `This command requires you to be logged in to ${outputRegistry}`
if (dryRun) {
log.warn(this.#command, `${msg} (dry-run)`)
} else {
throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' })
}
}
if (!force) {
const { highestVersion, versions } = await this.#registryVersions(resolved, registry)
/* eslint-disable-next-line max-len */
const highestVersionIsGreater = !!highestVersion && semver.gte(highestVersion, manifest.version)
if (versions.includes(manifest.version)) {
throw new Error(`You cannot publish over the previously published versions: ${manifest.version}.`)
}
if (highestVersionIsGreater && isDefaultTag) {
throw new Error(`Cannot implicitly apply the "latest" tag because previously published version ${highestVersion} is higher than the new version ${manifest.version}. You must specify a tag using --tag.`)
}
}
const access = opts.access === null ? 'default' : opts.access
const verb = this.isStage ? 'Staging' : 'Publishing'
let msg = `${verb} to ${outputRegistry} with tag ${defaultTag} and ${access} access`
if (dryRun) {
msg = `${msg} (dry-run)`
}
log.notice('', msg)
let stageId
if (!dryRun) {
if (this.isStage) {
// Stage intentionally bypasses otplease — 2FA is deferred to approve/reject
const res = await libpub(manifest, tarballData, {
...opts,
command: this.#command,
stage: true,
})
stageId = res.stageId
} else {
await otplease(this.npm, opts, o => libpub(manifest, tarballData, o))
}
}
// In json mode we don't log until the publish has completed as this will add it to the output only if completes successfully
if (json) {
if (stageId) {
pkgContents.stageId = stageId
}
logTar(pkgContents, {
unicode, json, key: pkgContents.name, redact: stageId ? false : undefined })
}
if (spec.type === 'directory' && !ignoreScripts) {
await runScript({
event: 'publish',
path: spec.fetchSpec,
stdio: 'inherit',
pkg: manifest,
})
await runScript({
event: 'postpublish',
path: spec.fetchSpec,
stdio: 'inherit',
pkg: manifest,
})
}
if (!json && !silent) {
if (this.isStage) {
const stagedMsg = stageId
? `+ ${pkgContents.id} (staged with id ${stageId})`
: `+ ${pkgContents.id} (staged)`
output.standard(stagedMsg, { [META]: true, redact: false })
log.notice(this.#command, `package ${pkgContents.id} has been staged with tag ${defaultTag}`)
} else {
output.standard(`+ ${pkgContents.id}`)
}
}
}
async #registryVersions (spec, registry) {
try {
const packument = await pacote.packument(spec, {
...this.npm.flatOptions,
preferOnline: true,
registry,
_isRoot: true,
})
if (typeof packument?.versions === 'undefined') {
return { versions: [], highestVersion: null }
}
const ordered = Object.keys(packument?.versions)
.flatMap(v => {
const s = new semver.SemVer(v)
if ((s.prerelease.length > 0) || packument.versions[v].deprecated) {
return []
}
return s
})
.sort((a, b) => b.compare(a))
const highestVersion = ordered.length >= 1 ? ordered[0].version : null
const versions = ordered.map(v => v.version)
return { versions, highestVersion }
} catch (e) {
return { versions: [], highestVersion: null }
}
}
// if it's a directory, read it from the file system
// otherwise, get the full metadata from whatever it is
// XXX can't pacote read the manifest from a directory?
async #getManifest (spec, opts, logWarnings = false) {
let manifest
if (spec.type === 'directory') {
const changes = []
const pkg = await pkgJson.fix(spec.fetchSpec, { changes })
if (changes.length && logWarnings) {
log.warn(this.#command, 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.')
log.warn(this.#command, `errors corrected:\n${changes.join('\n')}`)
}
// Prepare is the special function for publishing, different than normalize
const { content } = await pkg.prepare()
manifest = content
} else {
manifest = await pacote.manifest(spec, {
...opts,
fullMetadata: true,
fullReadJson: true,
})
}
if (manifest.publishConfig) {
const cliFlags = this.npm.config.data.get('cli').raw
// Filter out properties set in CLI flags to prioritize them over corresponding `publishConfig` settings
const filteredPublishConfig = Object.fromEntries(
Object.entries(manifest.publishConfig).filter(([key]) => !(key in cliFlags)))
if (logWarnings) {
for (const key in filteredPublishConfig) {
this.npm.config.checkUnknown('publishConfig', key)
}
}
flatten(filteredPublishConfig, opts)
}
return manifest
}
}
module.exports = Publish
+139
View File
@@ -0,0 +1,139 @@
const { resolve } = require('node:path')
const BaseCommand = require('../base-cmd.js')
const { log, output } = require('proc-log')
class QuerySelectorItem {
constructor (node) {
// all enumerable properties from the target
Object.assign(this, node.target.package)
// append extra info
this.pkgid = node.target.pkgid
this.location = node.target.location
this.path = node.target.path
this.realpath = node.target.realpath
this.resolved = node.target.resolved
this.from = []
this.to = []
this.dev = node.target.dev
this.inBundle = node.target.inBundle
this.deduped = this.from.length > 1
this.overridden = node.overridden
this.queryContext = node.queryContext
for (const edge of node.target.edgesIn) {
this.from.push(edge.from.location)
}
for (const [, edge] of node.target.edgesOut) {
if (edge.to) {
this.to.push(edge.to.location)
}
}
}
}
class Query extends BaseCommand {
#response = [] // response is the query response
#seen = new Set() // paths we've seen so we can keep response deduped
static description = 'Retrieve a filtered list of packages'
static name = 'query'
static usage = ['<selector>']
static workspaces = true
static ignoreImplicitWorkspace = false
static params = [
'global',
'workspace',
'workspaces',
'include-workspace-root',
'package-lock-only',
'expect-results',
]
constructor (...args) {
super(...args)
this.npm.config.set('json', true)
}
async exec (args) {
const packageLockOnly = this.npm.config.get('package-lock-only')
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
...this.npm.flatOptions,
// one dir up from wherever node_modules lives
path: resolve(this.npm.dir, '..'),
forceActual: !packageLockOnly,
})
let tree
if (packageLockOnly) {
try {
tree = await arb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
throw this.usageError(
'A package lock or shrinkwrap file is required in package-lock-only mode'
)
}
} else {
tree = await arb.loadActual()
}
await this.#queryTree(tree, args[0])
this.#output()
}
async execWorkspaces (args) {
await this.setWorkspaces()
const packageLockOnly = this.npm.config.get('package-lock-only')
const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
...this.npm.flatOptions,
path: this.npm.prefix,
forceActual: !packageLockOnly,
})
let tree
if (packageLockOnly) {
try {
tree = await arb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
throw this.usageError(
'A package lock or shrinkwrap file is required in package-lock-only mode'
)
}
} else {
tree = await arb.loadActual()
}
for (const path of this.workspacePaths) {
const wsTree = path === tree.root.path
? tree // --includes-workspace-root
: await tree.querySelectorAll(`.workspace:path(${path})`).then(r => r[0]?.target)
if (wsTree) {
await this.#queryTree(wsTree, args[0])
}
}
this.#output()
}
#output () {
this.checkExpected(this.#response.length)
output.buffer(this.#response)
}
// builds a normalized inventory
async #queryTree (tree, arg) {
const items = await tree.querySelectorAll(arg, this.npm.flatOptions)
for (const node of items) {
const { location } = node.target
if (!location || !this.#seen.has(location)) {
const item = new QuerySelectorItem(node)
this.#response.push(item)
if (location) {
this.#seen.add(item.location)
}
}
}
}
}
module.exports = Query
+107
View File
@@ -0,0 +1,107 @@
const { resolve } = require('node:path')
const { log, output } = require('proc-log')
const npa = require('npm-package-arg')
const semver = require('semver')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
const checkAllowScripts = require('../utils/check-allow-scripts.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
class Rebuild extends ArboristWorkspaceCmd {
static description = 'Rebuild a package'
static name = 'rebuild'
static params = [
'global',
'bin-links',
'foreground-scripts',
'ignore-scripts',
'allow-scripts',
'strict-allow-scripts',
'dangerously-allow-all-scripts',
...super.params,
]
static usage = ['[<package-spec>] ...]']
static async completion (opts, npm) {
const completion = require('../utils/installed-deep.js')
return completion(npm, opts)
}
async exec (args) {
const globalTop = resolve(this.npm.globalDir, '..')
const where = this.npm.global ? globalTop : this.npm.prefix
const Arborist = require('@npmcli/arborist')
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
const arb = new Arborist({
...this.npm.flatOptions,
path: where,
allowScripts: allowScriptsPolicy,
// TODO when extending ReifyCmd
// workspaces: this.workspaceNames,
})
if (args.length) {
// get the set of nodes matching the name that we want rebuilt
const tree = await arb.loadActual()
const specs = args.map(arg => {
const spec = npa(arg)
if (spec.rawSpec === '*') {
return spec
}
if (spec.type !== 'range' && spec.type !== 'version' && spec.type !== 'directory') {
throw new Error('`npm rebuild` only supports SemVer version/range specifiers')
}
return spec
})
const nodes = tree.inventory.filter(node => this.isNode(specs, node))
await strictAllowScriptsPreflight({ arb, npm: this.npm })
await arb.rebuild({ nodes })
} else {
await arb.loadActual()
await strictAllowScriptsPreflight({ arb, npm: this.npm })
await arb.rebuild()
}
// Phase 1 advisory: list any packages whose install scripts ran (or
// would have run) and are not yet covered by allowScripts. Rebuild
// doesn't go through reifyFinish, so the walker is invoked here.
const unreviewed = await checkAllowScripts({ arb, npm: this.npm })
if (unreviewed.length > 0) {
const count = unreviewed.length
const noun = count === 1 ? 'package has' : 'packages have'
log.warn(
'rebuild',
`${count} ${noun} install scripts not yet covered by allowScripts. ` +
'Run `npm approve-scripts --allow-scripts-pending` to review.'
)
}
output.standard('rebuilt dependencies successfully')
}
isNode (specs, node) {
return specs.some(spec => {
if (spec.type === 'directory') {
return node.path === spec.fetchSpec
}
if (spec.name !== node.name) {
return false
}
if (spec.rawSpec === '' || spec.rawSpec === '*') {
return true
}
const { version } = node.package
// TODO: add tests for a package with missing version
return semver.satisfies(version, spec.fetchSpec)
})
}
}
module.exports = Rebuild
+55
View File
@@ -0,0 +1,55 @@
const { URL } = require('node:url')
const PackageUrlCmd = require('../package-url-cmd.js')
class Repo extends PackageUrlCmd {
static description = 'Open package repository page in the browser'
static name = 'repo'
getUrl (spec, mani) {
const r = mani.repository
const rurl = !r ? null
: typeof r === 'string' ? r
: typeof r === 'object' && typeof r.url === 'string' ? r.url
: null
if (!rurl) {
throw Object.assign(new Error('no repository'), {
pkgid: spec,
})
}
const info = this.hostedFromMani(mani)
const url = info ?
info.browse(mani.repository.directory) : unknownHostedUrl(rurl)
if (!url) {
throw Object.assign(new Error('no repository: could not get url'), {
pkgid: spec,
})
}
return url
}
}
module.exports = Repo
const unknownHostedUrl = url => {
try {
const {
protocol,
hostname,
pathname,
} = new URL(url)
/* istanbul ignore next - URL ctor should prevent this */
if (!protocol || !hostname) {
return null
}
const proto = /(git\+)http:$/.test(protocol) ? 'http:' : 'https:'
const path = pathname.replace(/\.git$/, '')
return `${proto}//${hostname}${path}`
} catch {
return null
}
}
+13
View File
@@ -0,0 +1,13 @@
const LifecycleCmd = require('../lifecycle-cmd.js')
// This ends up calling run(['restart', ...args])
class Restart extends LifecycleCmd {
static description = 'Restart a package'
static name = 'restart'
static params = [
'ignore-scripts',
'script-shell',
]
}
module.exports = Restart
+14
View File
@@ -0,0 +1,14 @@
const { output } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
class Root extends BaseCommand {
static description = 'Display npm root'
static name = 'root'
static params = ['global']
async exec () {
output.standard(this.npm.dir)
}
}
module.exports = Root
+222
View File
@@ -0,0 +1,222 @@
const { output } = require('proc-log')
const pkgJson = require('@npmcli/package-json')
const BaseCommand = require('../base-cmd.js')
const { getError } = require('../utils/error-message.js')
const { outputError } = require('../utils/output-error.js')
class RunScript extends BaseCommand {
static description = 'Run arbitrary package scripts'
static params = [
'workspace',
'workspaces',
'include-workspace-root',
'if-present',
'ignore-scripts',
'foreground-scripts',
'script-shell',
]
static name = 'run'
static usage = ['<command> [-- <args>]']
static workspaces = true
static ignoreImplicitWorkspace = false
static isShellout = true
static checkDevEngines = true
static async completion (opts, npm) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
const workspacePrefixes = npm.config.get('workspace', 'default')
const localPrefix = workspacePrefixes.length
? workspacePrefixes[0]
: npm.localPrefix
const { content: { scripts = {} } } = await pkgJson.normalize(localPrefix)
.catch(() => ({ content: {} }))
if (opts.isFish) {
return Object.keys(scripts).map(s => `${s}\t${scripts[s].slice(0, 30)}`)
}
return Object.keys(scripts)
}
}
async exec (args) {
if (args.length) {
await this.#run(args, { path: this.npm.localPrefix })
} else {
await this.#list(this.npm.localPrefix)
}
}
async execWorkspaces (args) {
await this.setWorkspaces()
const ws = [...this.workspaces.entries()]
for (const [workspace, path] of ws) {
const last = path === ws.at(-1)[1]
if (!args.length) {
const newline = await this.#list(path, { workspace })
if (newline && !last) {
output.standard()
}
continue
}
const pkg = await pkgJson.normalize(path).then(p => p.content)
try {
await this.#run(args, { path, pkg, workspace })
} catch (e) {
const err = getError(e, { npm: this.npm, command: null })
outputError({
...err,
error: [
['', `Lifecycle script \`${args[0]}\` failed with error:`],
...err.error,
['workspace', pkg._id || pkg.name],
['location', path],
],
})
process.exitCode = err.exitCode
if (!last) {
output.error('')
}
}
}
}
async #run ([event, ...args], { path, pkg, workspace }) {
const runScript = require('@npmcli/run-script')
pkg ??= await pkgJson.normalize(path).then(p => p.content)
const { scripts = {} } = pkg
if (event === 'restart' && !scripts.restart) {
scripts.restart = 'npm stop --if-present && npm start'
} else if (event === 'env' && !scripts.env) {
const { isWindowsShell } = require('../utils/is-windows.js')
scripts.env = isWindowsShell ? 'SET' : 'env'
}
pkg.scripts = scripts
if (
!Object.prototype.hasOwnProperty.call(scripts, event) &&
!(event === 'start' && (await runScript.isServerPackage(path)))
) {
if (this.npm.config.get('if-present')) {
return
}
const suggestions = require('../utils/did-you-mean.js')(pkg, event)
const wsArg = workspace && path !== this.npm.localPrefix
? ` --workspace=${pkg._id || pkg.name}`
: ''
throw new Error([
`Missing script: "${event}"${suggestions}`,
'',
'To see a list of scripts, run:',
` npm run${wsArg}`,
].join('\n'))
}
// positional args only added to the main event, not pre/post
const events = [[event, args]]
if (!this.npm.config.get('ignore-scripts')) {
if (scripts[`pre${event}`]) {
events.unshift([`pre${event}`, []])
}
if (scripts[`post${event}`]) {
events.push([`post${event}`, []])
}
}
for (const [ev, evArgs] of events) {
await runScript({
args: evArgs,
event: ev,
nodeGyp: this.npm.config.get('node-gyp'),
path,
pkg,
// || undefined is because runScript will be unhappy with the default null value
scriptShell: this.npm.config.get('script-shell') || undefined,
stdio: 'inherit',
})
}
}
async #list (path, { workspace } = {}) {
const { scripts = {}, name, _id } = await pkgJson.normalize(path).then(p => p.content)
const scriptEntries = Object.entries(scripts)
if (this.npm.silent) {
return
}
if (this.npm.config.get('json')) {
output.buffer(workspace ? { [workspace]: scripts } : scripts)
return
}
if (!scriptEntries.length) {
return
}
if (this.npm.config.get('parseable')) {
output.standard(scriptEntries
.map((s) => (workspace ? [workspace, ...s] : s).join(':'))
.join('\n')
.trim())
return
}
const cmdList = [
'prepare', 'prepublishOnly',
'prepack', 'postpack',
'dependencies',
'preinstall', 'install', 'postinstall',
'prepublish', 'publish', 'postpublish',
'prerestart', 'restart', 'postrestart',
'prestart', 'start', 'poststart',
'prestop', 'stop', 'poststop',
'pretest', 'test', 'posttest',
'preuninstall', 'uninstall', 'postuninstall',
'preversion', 'version', 'postversion',
]
const [cmds, runScripts] = scriptEntries.reduce((acc, s) => {
acc[cmdList.includes(s[0]) ? 0 : 1].push(s)
return acc
}, [[], []])
const { reset, bold, cyan, dim, blue } = this.npm.chalk
const pkgId = `in ${cyan(_id || name)}`
const title = (t) => reset(bold(t))
if (cmds.length) {
output.standard(`${title('Lifecycle scripts')} included ${pkgId}:`)
for (const [k, v] of cmds) {
output.standard(` ${k}`)
output.standard(` ${dim(v)}`)
}
}
if (runScripts.length) {
const via = `via \`${blue('npm run')}\`:`
if (!cmds.length) {
output.standard(`${title('Scripts')} available ${pkgId} ${via}`)
} else {
output.standard(`available ${via}`)
}
for (const [k, v] of runScripts) {
output.standard(` ${k}`)
output.standard(` ${dim(v)}`)
}
}
// Return true to indicate that something was output for this path that should be separated from others
return true
}
}
module.exports = RunScript
+130
View File
@@ -0,0 +1,130 @@
const localeCompare = require('@isaacs/string-locale-compare')('en')
const BaseCommand = require('../base-cmd.js')
const { log, output, META } = require('proc-log')
const { cyclonedxOutput } = require('../utils/sbom-cyclonedx.js')
const { spdxOutput } = require('../utils/sbom-spdx.js')
const SBOM_FORMATS = ['cyclonedx', 'spdx']
class SBOM extends BaseCommand {
#response = {} // response is the sbom response
static description = 'Generate a Software Bill of Materials (SBOM)'
static name = 'sbom'
static workspaces = true
static params = [
'omit',
'package-lock-only',
'sbom-format',
'sbom-type',
'workspace',
'workspaces',
]
async exec () {
const sbomFormat = this.npm.config.get('sbom-format')
const packageLockOnly = this.npm.config.get('package-lock-only')
if (!sbomFormat) {
throw this.usageError(`Must specify --sbom-format flag with one of: ${SBOM_FORMATS.join(', ')}.`)
}
const opts = {
...this.npm.flatOptions,
path: this.npm.prefix,
forceActual: true,
}
const Arborist = require('@npmcli/arborist')
const arb = new Arborist(opts)
const tree = packageLockOnly ? await arb.loadVirtual(opts).catch(() => {
throw this.usageError('A package lock or shrinkwrap file is required in package-lock-only mode')
}) : await arb.loadActual(opts)
// Collect the list of selected workspaces in the project
const wsNodes = this.workspaceNames?.length
? arb.workspaceNodes(tree, this.workspaceNames)
: null
// Build the selector and query the tree for the list of nodes
const selector = this.#buildSelector({ wsNodes })
log.info('sbom', `Using dependency selector: ${selector}`)
const items = await tree.querySelectorAll(selector)
const errors = items.flatMap(node => detectErrors(node))
if (errors.length) {
throw Object.assign(new Error([...new Set(errors)].join('\n')), {
code: 'ESBOMPROBLEMS',
})
}
// Populate the response with the list of unique nodes (sorted by location)
this.#buildResponse(items.sort((a, b) => localeCompare(a.location, b.location)))
// TODO(BREAKING_CHANGE): all sbom output is in json mode but setting it before any of the errors will cause those to be thrown in json mode.
this.npm.config.set('json', true)
output.standard(JSON.stringify(this.#response, null, 2), { [META]: true, redact: false })
}
async execWorkspaces (args) {
await this.setWorkspaces()
return this.exec(args)
}
// Build the selector from all of the specified filter options
#buildSelector ({ wsNodes }) {
let selector
const omit = this.npm.flatOptions.omit
const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
// If omit is specified, omit all nodes and their children which match the specified selectors
const omits = omit.reduce((acc, o) => `${acc}:not(.${o})`, '')
if (!workspacesEnabled) {
// If workspaces are disabled, omit all workspace nodes and their children
selector = `:root > :not(.workspace)${omits},:root > :not(.workspace) *${omits},:extraneous`
} else if (wsNodes && wsNodes.length > 0) {
// If one or more workspaces are selected, select only those workspaces and their children
selector = wsNodes.map(ws => `#${ws.name},#${ws.name} *${omits}`).join(',')
} else {
selector = `:root *${omits},:extraneous`
}
// Always include the root node
return `:root,${selector}`
}
// builds a normalized inventory
#buildResponse (items) {
const sbomFormat = this.npm.config.get('sbom-format')
const packageType = this.npm.config.get('sbom-type')
const packageLockOnly = this.npm.config.get('package-lock-only')
this.#response = sbomFormat === 'cyclonedx'
? cyclonedxOutput({ npm: this.npm, nodes: items, packageType, packageLockOnly })
: spdxOutput({ npm: this.npm, nodes: items, packageType })
}
}
const detectErrors = (node) => {
const errors = []
// Look for missing dependencies (that are NOT optional), or invalid dependencies
for (const edge of node.edgesOut.values()) {
if (edge.missing && !(edge.type === 'optional' || edge.type === 'peerOptional')) {
errors.push(`missing: ${edge.name}@${edge.spec}, required by ${edge.from.pkgid}`)
}
if (edge.invalid) {
/* istanbul ignore next */
const spec = edge.spec || '*'
const from = edge.from.pkgid
errors.push(`invalid: ${edge.to.pkgid}, ${spec} required by ${from}`)
}
}
return errors
}
module.exports = SBOM
+70
View File
@@ -0,0 +1,70 @@
const Pipeline = require('minipass-pipeline')
const libSearch = require('libnpmsearch')
const { log, output } = require('proc-log')
const formatSearchStream = require('../utils/format-search-stream.js')
const BaseCommand = require('../base-cmd.js')
class Search extends BaseCommand {
static description = 'Search for packages'
static name = 'search'
static params = [
'json',
'color',
'parseable',
'description',
'searchlimit',
'searchopts',
'searchexclude',
'registry',
'prefer-online',
'prefer-offline',
'offline',
]
static usage = ['<search term> [<search term> ...]']
async exec (args) {
const opts = {
...this.npm.flatOptions,
...this.npm.flatOptions.search,
include: args.map(s => s.toLowerCase()).filter(Boolean),
exclude: this.npm.flatOptions.search.exclude.split(/\s+/),
}
if (opts.include.length === 0) {
throw new Error('search must be called with arguments')
}
// Used later to figure out whether we had any packages go out
let anyOutput = false
// Grab a configured output stream that will spit out packages in the desired format.
const outputStream = formatSearchStream({
args, // --searchinclude options are not highlighted
...opts,
npm: this.npm,
})
log.silly('search', 'searching packages')
const p = new Pipeline(
libSearch.stream(opts.include, opts),
outputStream
)
p.on('data', chunk => {
if (!anyOutput) {
anyOutput = true
}
output.standard(chunk.toString('utf8'))
})
await p.promise()
if (!anyOutput && !this.npm.config.get('json') && !this.npm.config.get('parseable')) {
output.standard('No matches found for ' + (args.map(JSON.stringify).join(' ')))
}
log.silly('search', 'search completed')
}
}
module.exports = Search
+24
View File
@@ -0,0 +1,24 @@
const Npm = require('../npm.js')
const BaseCommand = require('../base-cmd.js')
class Set extends BaseCommand {
static description = 'Set a value in the npm configuration'
static name = 'set'
static usage = ['<key>=<value> [<key>=<value> ...] (See `npm config`)']
static params = ['global', 'location']
static ignoreImplicitWorkspace = false
static async completion (opts) {
const Config = Npm.cmd('config')
return Config.completion(opts)
}
async exec (args) {
if (!args.length) {
throw this.usageError()
}
return this.npm.exec('config', ['set'].concat(args))
}
}
module.exports = Set
@@ -0,0 +1,70 @@
const { resolve, basename } = require('node:path')
const { unlink } = require('node:fs/promises')
const { log } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
class Shrinkwrap extends BaseCommand {
static description = 'Lock down dependency versions for publication'
static name = 'shrinkwrap'
static ignoreImplicitWorkspace = false
async exec () {
// if has a npm-shrinkwrap.json, nothing to do
// if has a package-lock.json, rename to npm-shrinkwrap.json
// if has neither, load the actual tree and save that as npm-shrinkwrap.json
//
// loadVirtual, fall back to loadActual
// rename shrinkwrap file type, and tree.meta.save()
if (this.npm.global) {
const er = new Error('`npm shrinkwrap` does not work for global packages')
er.code = 'ESHRINKWRAPGLOBAL'
throw er
}
const Arborist = require('@npmcli/arborist')
const path = this.npm.prefix
const sw = resolve(path, 'npm-shrinkwrap.json')
const arb = new Arborist({ ...this.npm.flatOptions, path })
const tree = await arb.loadVirtual().catch(() => arb.loadActual())
const { meta } = tree
const newFile = meta.hiddenLockfile || !meta.loadedFromDisk
const oldFilename = meta.filename
const notSW = !newFile && basename(oldFilename) !== 'npm-shrinkwrap.json'
// The computed lockfile version of a hidden lockfile is always 3 even if the actual value of the property is a different.
// When shrinkwrap is run with only a hidden lockfile we want to set the shrinkwrap lockfile version as whatever was explicitly requested with a fallback to the actual value from the hidden
// lockfile.
if (meta.hiddenLockfile) {
meta.lockfileVersion = arb.options.lockfileVersion ||
meta.originalLockfileVersion
}
meta.hiddenLockfile = false
meta.filename = sw
await meta.save()
const updatedVersion = meta.originalLockfileVersion !== meta.lockfileVersion
? meta.lockfileVersion
: null
if (newFile) {
let message = 'created a lockfile as npm-shrinkwrap.json'
if (updatedVersion) {
message += ` with version ${updatedVersion}`
}
log.notice('', message)
} else if (notSW) {
await unlink(oldFilename)
let message = 'package-lock.json has been renamed to npm-shrinkwrap.json'
if (updatedVersion) {
message += ` and updated to version ${updatedVersion}`
}
log.notice('', message)
} else if (updatedVersion) {
log.notice('', `npm-shrinkwrap.json updated to version ${updatedVersion}`)
} else {
log.notice('', 'npm-shrinkwrap.json up to date')
}
}
}
module.exports = Shrinkwrap
@@ -0,0 +1,35 @@
const { log, output, META } = require('proc-log')
const npmFetch = require('npm-registry-fetch')
const { otplease } = require('../../utils/auth.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
const BaseCommand = require('../../base-cmd.js')
class StageApprove extends BaseCommand {
static description = 'Approve a staged package, publishing it to the npm registry'
static name = 'approve'
static usage = ['<stage-id>']
static params = ['otp', 'registry']
static positionals = 1
async exec (args) {
if (!args[0]) {
throw this.usageError('Missing required <stage-id>')
}
const stageId = args[0]
validateUUID(stageId, 'stage-id')
const opts = { ...this.npm.flatOptions }
log.notice('', `Approving staged package ${stageId}`)
await otplease(this.npm, opts, o =>
npmFetch.json(`/-/stage/${stageId}/approve`, {
...o,
method: 'POST',
})
)
output.standard(`Staged package ${stageId} approved and published successfully.`, { [META]: true, redact: false })
}
}
module.exports = StageApprove
@@ -0,0 +1,70 @@
const { log, output, META } = require('proc-log')
const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')
const tar = require('tar')
const npmFetch = require('npm-registry-fetch')
const { getContents, logTar } = require('../../utils/tar.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
const BaseCommand = require('../../base-cmd.js')
class StageDownload extends BaseCommand {
static description = 'Download the tarball of a staged package for inspection'
static name = 'download'
static usage = ['<stage-id>']
static params = ['json', 'registry']
static positionals = 1
async exec (args) {
if (!args[0]) {
throw this.usageError('Missing required <stage-id>')
}
const stageId = args[0]
validateUUID(stageId, 'stage-id')
const opts = { ...this.npm.flatOptions }
const unicode = this.npm.config.get('unicode')
const json = this.npm.config.get('json')
log.notice('', `Downloading staged package ${stageId}`)
const res = await npmFetch(`/-/stage/${stageId}/tarball`, opts)
const data = Buffer.from(await res.arrayBuffer())
const manifest = await this.#readManifestFromTarball(data)
const pkgContents = await getContents(manifest, data)
logTar(pkgContents, { unicode, json, key: pkgContents.name })
const safeName = pkgContents.name.replace('@', '').replace('/', '-')
const filename = `${safeName}-${pkgContents.version}-${stageId}.tgz`
const dest = resolve(process.cwd(), filename)
await writeFile(dest, data)
if (!json) {
output.standard(filename, { [META]: true, redact: false })
}
}
async #readManifestFromTarball (tarballData) {
let manifestJson
const stream = tar.t({
onentry (entry) {
if (entry.path === 'package/package.json') {
const chunks = []
entry.on('data', c => chunks.push(c))
entry.on('end', () => {
manifestJson = JSON.parse(Buffer.concat(chunks).toString())
})
} else {
entry.resume()
}
},
})
// node-tar uses Minipass which processes synchronously on .end()
stream.end(tarballData)
if (!manifestJson) {
throw new Error('Could not read package.json from tarball')
}
return manifestJson
}
}
module.exports = StageDownload
@@ -0,0 +1,25 @@
const BaseCommand = require('../../base-cmd.js')
class Stage extends BaseCommand {
static description = 'Stage packages for publishing, deferring proof-of-presence (2FA) to a later point in time'
static name = 'stage'
static subcommands = {
publish: require('./publish.js'),
list: require('./list.js'),
view: require('./view.js'),
approve: require('./approve.js'),
reject: require('./reject.js'),
download: require('./download.js'),
}
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return Object.keys(Stage.subcommands)
}
return []
}
}
module.exports = Stage
@@ -0,0 +1,72 @@
const { output, META } = require('proc-log')
const npa = require('npm-package-arg')
const npmFetch = require('npm-registry-fetch')
const { logStageItem } = require('../../utils/key-values.js')
const BaseCommand = require('../../base-cmd.js')
class StageList extends BaseCommand {
static description = 'List all staged package versions'
static name = 'list'
static usage = ['[<package-spec>]']
static params = ['json', 'registry']
async exec (args) {
let packageFilter = null
if (args[0]) {
const spec = npa(args[0])
if (spec.rawSpec !== '*') {
throw this.usageError('Version specifiers are not supported for listing staged packages')
}
packageFilter = spec.name
}
const opts = { ...this.npm.flatOptions }
const json = this.npm.config.get('json')
const allItems = await this.#fetchAllPages(opts, packageFilter)
if (json) {
output.standard(JSON.stringify(allItems, null, 2), { [META]: true, redact: false })
return
}
if (allItems.length === 0) {
if (packageFilter) {
output.standard(`No staged versions of package name "${packageFilter}".`)
} else {
output.standard('No staged packages found.')
}
return
}
for (let i = 0; i < allItems.length; i++) {
if (i > 0) {
output.standard('')
}
logStageItem(allItems[i], { chalk: this.npm.chalk })
}
}
async #fetchAllPages (opts, packageFilter) {
const items = []
let page = 0
const perPage = 100
while (true) {
const query = { page, perPage }
if (packageFilter) {
query.package = packageFilter
}
const res = await npmFetch.json('/-/stage', {
...opts,
query,
})
items.push(...res.items)
if (items.length >= res.total || res.items.length < perPage) {
break
}
page++
}
return items
}
}
module.exports = StageList
@@ -0,0 +1,13 @@
const Publish = require('../publish.js')
class StagePublish extends Publish {
static description = 'Stage a package for publishing, deferring proof-of-presence (2FA) to a later point in time'
static name = 'publish'
static stage = true
static params = Publish.params
static usage = Publish.usage
static workspaces = true
static ignoreImplicitWorkspace = false
}
module.exports = StagePublish
@@ -0,0 +1,37 @@
const { log, output, META } = require('proc-log')
const npmFetch = require('npm-registry-fetch')
const { otplease } = require('../../utils/auth.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
const BaseCommand = require('../../base-cmd.js')
class StageReject extends BaseCommand {
static description = 'Reject a staged package, removing it from the registry'
static name = 'reject'
static usage = ['<stage-id>']
static params = ['otp', 'registry']
static positionals = 1
async exec (args) {
if (!args[0]) {
throw this.usageError('Missing required <stage-id>')
}
const stageId = args[0]
validateUUID(stageId, 'stage-id')
const opts = { ...this.npm.flatOptions }
log.notice('', `Rejecting staged package ${stageId}`)
log.warn('', 'Rejecting will permanently delete this staged publish record and tarball from the registry.')
await otplease(this.npm, opts, o =>
npmFetch(`/-/stage/${stageId}`, {
...o,
method: 'DELETE',
ignoreBody: true,
})
)
output.standard(`Staged package ${stageId} has been rejected.`, { [META]: true, redact: false })
}
}
module.exports = StageReject
@@ -0,0 +1,34 @@
const { output, META } = require('proc-log')
const npmFetch = require('npm-registry-fetch')
const { logStageItem } = require('../../utils/key-values.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
const BaseCommand = require('../../base-cmd.js')
class StageView extends BaseCommand {
static description = 'View details of a specific staged package'
static name = 'view'
static usage = ['<stage-id>']
static params = ['json', 'registry']
static positionals = 1
async exec (args) {
if (!args[0]) {
throw this.usageError('Missing required <stage-id>')
}
const stageId = args[0]
validateUUID(stageId, 'stage-id')
const opts = { ...this.npm.flatOptions }
const json = this.npm.config.get('json')
const item = await npmFetch.json(`/-/stage/${stageId}`, opts)
if (json) {
output.standard(JSON.stringify(item, null, 2), { [META]: true, redact: false })
return
}
logStageItem(item, { chalk: this.npm.chalk })
}
}
module.exports = StageView
+72
View File
@@ -0,0 +1,72 @@
const npmFetch = require('npm-registry-fetch')
const npa = require('npm-package-arg')
const { log, output } = require('proc-log')
const getIdentity = require('../utils/get-identity')
const BaseCommand = require('../base-cmd.js')
class Star extends BaseCommand {
static description = 'Mark your favorite packages'
static name = 'star'
static usage = ['[<package-spec>...]']
static params = [
'registry',
'unicode',
'otp',
]
static ignoreImplicitWorkspace = false
async exec (args) {
if (!args.length) {
throw this.usageError()
}
// if we're unstarring, then show an empty star image
// otherwise, show the full star image
const unicode = this.npm.config.get('unicode')
const full = unicode ? '\u2605 ' : '(*)'
const empty = unicode ? '\u2606 ' : '( )'
const show = this.name === 'star' ? full : empty
const pkgs = args.map(npa)
const username = await getIdentity(this.npm, this.npm.flatOptions)
for (const pkg of pkgs) {
const fullData = await npmFetch.json(pkg.escapedName, {
...this.npm.flatOptions,
spec: pkg,
query: { write: true },
preferOnline: true,
})
const body = {
_id: fullData._id,
_rev: fullData._rev,
users: fullData.users || {},
}
if (this.name === 'star') {
log.info('star', 'starring', body._id)
body.users[username] = true
log.verbose('star', 'starring', body)
} else {
delete body.users[username]
log.info('unstar', 'unstarring', body._id)
log.verbose('unstar', 'unstarring', body)
}
const data = await npmFetch.json(pkg.escapedName, {
...this.npm.flatOptions,
spec: pkg,
method: 'PUT',
body,
})
output.standard(show + ' ' + pkg.name)
log.verbose('star', data)
return data
}
}
}
module.exports = Star
+39
View File
@@ -0,0 +1,39 @@
const npmFetch = require('npm-registry-fetch')
const { log, output } = require('proc-log')
const getIdentity = require('../utils/get-identity.js')
const BaseCommand = require('../base-cmd.js')
class Stars extends BaseCommand {
static description = 'View packages marked as favorites'
static name = 'stars'
static usage = ['[<user>]']
static params = ['registry']
static ignoreImplicitWorkspace = false
async exec ([user]) {
try {
if (!user) {
user = await getIdentity(this.npm, this.npm.flatOptions)
}
const { rows } = await npmFetch.json('/-/_view/starredByUser', {
...this.npm.flatOptions,
query: { key: `"${user}"` },
})
if (rows.length === 0) {
log.warn('stars', 'user has not starred any packages')
}
for (const row of rows) {
output.standard(row.value)
}
} catch (err) {
if (err.code === 'ENEEDAUTH') {
log.warn('stars', 'auth is required to look up your username')
}
throw err
}
}
}
module.exports = Stars
+13
View File
@@ -0,0 +1,13 @@
const LifecycleCmd = require('../lifecycle-cmd.js')
// This ends up calling run(['start', ...args])
class Start extends LifecycleCmd {
static description = 'Start a package'
static name = 'start'
static params = [
'ignore-scripts',
'script-shell',
]
}
module.exports = Start
+13
View File
@@ -0,0 +1,13 @@
const LifecycleCmd = require('../lifecycle-cmd.js')
// This ends up calling run(['stop', ...args])
class Stop extends LifecycleCmd {
static description = 'Stop a package'
static name = 'stop'
static params = [
'ignore-scripts',
'script-shell',
]
}
module.exports = Stop
+158
View File
@@ -0,0 +1,158 @@
const libteam = require('libnpmteam')
const { output } = require('proc-log')
const { otplease } = require('../utils/auth.js')
const BaseCommand = require('../base-cmd.js')
class Team extends BaseCommand {
static description = 'Manage organization teams and team memberships'
static name = 'team'
static usage = [
'create <scope:team> [--otp <otpcode>]',
'destroy <scope:team> [--otp <otpcode>]',
'add <scope:team> <user> [--otp <otpcode>]',
'rm <scope:team> <user> [--otp <otpcode>]',
'ls <scope>|<scope:team>',
]
static params = [
'registry',
'otp',
'parseable',
'json',
]
static ignoreImplicitWorkspace = false
static async completion (opts) {
const { conf: { argv: { remain: argv } } } = opts
const subcommands = ['create', 'destroy', 'add', 'rm', 'ls']
if (argv.length === 2) {
return subcommands
}
if (subcommands.includes(argv[2])) {
return []
}
throw new Error(argv[2] + ' not recognized')
}
async exec ([cmd, entity = '', user = '']) {
// Entities are in the format <scope>:<team>
// XXX: "description" option to libnpmteam is used as a description of the team, but in npm's options
// this is a boolean meaning "show the description in npm search output".
// Hence its being set to null here.
await otplease(this.npm, { ...this.npm.flatOptions }, opts => {
entity = entity.replace(/^@/, '')
switch (cmd) {
case 'create': return this.create(entity, opts)
case 'destroy': return this.destroy(entity, opts)
case 'add': return this.add(entity, user, opts)
case 'rm': return this.rm(entity, user, opts)
case 'ls': {
const match = entity.match(/[^:]+:.+/)
if (match) {
return this.listUsers(entity, opts)
} else {
return this.listTeams(entity, opts)
}
}
default:
throw this.usageError()
}
})
}
async create (entity, opts) {
await libteam.create(entity, opts)
if (opts.json) {
output.buffer({
created: true,
team: entity,
})
} else if (opts.parseable) {
output.standard(`${entity}\tcreated`)
} else if (!this.npm.silent) {
output.standard(`+@${entity}`)
}
}
async destroy (entity, opts) {
await libteam.destroy(entity, opts)
if (opts.json) {
output.buffer({
deleted: true,
team: entity,
})
} else if (opts.parseable) {
output.standard(`${entity}\tdeleted`)
} else if (!this.npm.silent) {
output.standard(`-@${entity}`)
}
}
async add (entity, user, opts) {
await libteam.add(user, entity, opts)
if (opts.json) {
output.buffer({
added: true,
team: entity,
user,
})
} else if (opts.parseable) {
output.standard(`${user}\t${entity}\tadded`)
} else if (!this.npm.silent) {
output.standard(`${user} added to @${entity}`)
}
}
async rm (entity, user, opts) {
await libteam.rm(user, entity, opts)
if (opts.json) {
output.buffer({
removed: true,
team: entity,
user,
})
} else if (opts.parseable) {
output.standard(`${user}\t${entity}\tremoved`)
} else if (!this.npm.silent) {
output.standard(`${user} removed from @${entity}`)
}
}
async listUsers (entity, opts) {
const users = (await libteam.lsUsers(entity, opts)).sort()
if (opts.json) {
output.buffer(users)
} else if (opts.parseable) {
output.standard(users.join('\n'))
} else if (!this.npm.silent) {
const plural = users.length === 1 ? '' : 's'
const more = users.length === 0 ? '' : ':'
output.standard(`@${entity} has ${users.length} user${plural}${more}`)
for (const user of users) {
output.standard(user)
}
}
}
async listTeams (entity, opts) {
const teams = (await libteam.lsTeams(entity, opts)).sort()
if (opts.json) {
output.buffer(teams)
} else if (opts.parseable) {
output.standard(teams.join('\n'))
} else if (!this.npm.silent) {
const plural = teams.length === 1 ? '' : 's'
const more = teams.length === 0 ? '' : ':'
output.standard(`@${entity} has ${teams.length} team${plural}${more}`)
for (const team of teams) {
output.standard(`@${team}`)
}
}
}
}
module.exports = Team
+13
View File
@@ -0,0 +1,13 @@
const LifecycleCmd = require('../lifecycle-cmd.js')
// This ends up calling run(['test', ...args])
class Test extends LifecycleCmd {
static description = 'Test a package'
static name = 'test'
static params = [
'ignore-scripts',
'script-shell',
]
}
module.exports = Test
+281
View File
@@ -0,0 +1,281 @@
const { log, output, META } = require('proc-log')
const fetch = require('npm-registry-fetch')
const { otplease } = require('../utils/auth.js')
const readUserInfo = require('../utils/read-user-info.js')
const BaseCommand = require('../base-cmd.js')
async function paginate (href, opts, items = []) {
while (href) {
const result = await fetch.json(href, opts)
items = items.concat(result.objects)
href = result.urls.next
}
return items
}
class Token extends BaseCommand {
static description = 'Manage your authentication tokens'
static name = 'token'
static usage = ['list', 'revoke <id|token>', 'create']
static params = ['name',
'token-description',
'expires',
'packages',
'packages-all',
'scopes',
'orgs',
'packages-and-scopes-permission',
'orgs-permission',
'cidr',
'bypass-2fa',
'password',
'registry',
'otp',
'read-only',
]
static async completion (opts) {
const argv = opts.conf.argv.remain
const subcommands = ['list', 'revoke', 'create']
if (argv.length === 2) {
return subcommands
}
if (subcommands.includes(argv[2])) {
return []
}
throw new Error(argv[2] + ' not recognized')
}
async exec (args) {
if (args.length === 0) {
return this.list()
}
switch (args[0]) {
case 'list':
case 'ls':
return this.list()
case 'rm':
case 'delete':
case 'revoke':
case 'remove':
return this.rm(args.slice(1))
case 'create':
return this.create(args.slice(1))
default:
throw this.usageError(`${args[0]} is not a recognized subcommand.`)
}
}
async list () {
const json = this.npm.config.get('json')
const parseable = this.npm.config.get('parseable')
log.info('token', 'getting list')
const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
if (json) {
output.buffer(tokens)
return
}
if (parseable) {
output.standard(['key', 'token', 'created', 'readonly', 'CIDR whitelist'].join('\t'))
tokens.forEach(token => {
output.standard(
[
token.key,
token.token,
token.created,
token.readonly ? 'true' : 'false',
token.cidr_whitelist ? token.cidr_whitelist.join(',') : '',
].join('\t')
)
})
return
}
this.generateTokenIds(tokens, 6)
const chalk = this.npm.chalk
for (const token of tokens) {
const created = String(token.created).slice(0, 10)
output.standard(`${chalk.blue('Token')} ${token.token}… with id ${chalk.cyan(token.id)} created ${created}`)
if (token.cidr_whitelist) {
output.standard(`with IP whitelist: ${chalk.green(token.cidr_whitelist.join(','))}`)
}
output.standard()
}
}
async rm (args) {
if (args.length === 0) {
throw this.usageError('`<tokenKey>` argument is required.')
}
const json = this.npm.config.get('json')
const parseable = this.npm.config.get('parseable')
const toRemove = []
log.info('token', `removing ${toRemove.length} tokens`)
const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
for (const id of args) {
const matches = tokens.filter(token => token.key.indexOf(id) === 0)
if (matches.length === 1) {
toRemove.push(matches[0].key)
} else if (matches.length > 1) {
throw new Error(
`Token ID "${id}" was ambiguous, a new token may have been created since you last ran \`npm token list\`.`
)
} else {
const tokenMatches = tokens.some(t => id.indexOf(t.token) === 0)
if (!tokenMatches) {
throw new Error(`Unknown token id or value "${id}".`)
}
toRemove.push(id)
}
}
for (const tokenKey of toRemove) {
await otplease(this.npm, this.npm.flatOptions, opts =>
fetch(`/-/npm/v1/tokens/token/${tokenKey}`, {
...opts,
method: 'DELETE',
ignoreBody: true,
})
)
}
if (json) {
output.buffer(toRemove)
} else if (parseable) {
output.standard(toRemove.join('\t'))
} else {
output.standard('Removed ' + toRemove.length + ' token' + (toRemove.length !== 1 ? 's' : ''))
}
}
async create () {
const json = this.npm.config.get('json')
const parseable = this.npm.config.get('parseable')
const cidr = this.npm.config.get('cidr')
const name = this.npm.config.get('name')
const tokenDescription = this.npm.config.get('token-description')
const expires = this.npm.config.get('expires')
const packages = this.npm.config.get('packages')
const packagesAll = this.npm.config.get('packages-all')
const scopes = this.npm.config.get('scopes')
const orgs = this.npm.config.get('orgs')
const packagesAndScopesPermission = this.npm.config.get('packages-and-scopes-permission')
const orgsPermission = this.npm.config.get('orgs-permission')
const bypassTwoFactor = this.npm.config.get('bypass-2fa')
let password = this.npm.config.get('password')
const validCIDR = await this.validateCIDRList(cidr)
/* istanbul ignore if - skip testing read input */
if (!password) {
password = await readUserInfo.password()
}
const tokenData = {
name: name,
password: password,
}
if (tokenDescription) {
tokenData.description = tokenDescription
}
if (packages?.length > 0) {
tokenData.packages = packages
}
if (packagesAll) {
tokenData.packages_all = true
}
if (scopes?.length > 0) {
tokenData.scopes = scopes
}
if (orgs?.length > 0) {
tokenData.orgs = orgs
}
if (packagesAndScopesPermission) {
tokenData.packages_and_scopes_permission = packagesAndScopesPermission
}
if (orgsPermission) {
tokenData.orgs_permission = orgsPermission
}
// Add expiration in days
if (expires) {
tokenData.expires = parseInt(expires, 10)
}
// Add optional fields
if (validCIDR?.length > 0) {
tokenData.cidr_whitelist = validCIDR
}
if (bypassTwoFactor) {
tokenData.bypass_2fa = true
}
log.info('token', 'creating')
const result = await otplease(this.npm, this.npm.flatOptions, opts =>
fetch.json('/-/npm/v1/tokens', {
...opts,
method: 'POST',
body: tokenData,
})
)
delete result.key
delete result.updated
if (json) {
output.buffer(result)
} else if (parseable) {
Object.keys(result).forEach(k => output.standard(k + '\t' + result[k]))
} else {
const chalk = this.npm.chalk
output.standard(`Created token ${result.token}`, { [META]: true, redact: false })
if (result.cidr_whitelist?.length) {
output.standard(`with IP whitelist: ${chalk.green(result.cidr_whitelist.join(','))}`)
}
if (result.expires) {
output.standard(`expires: ${result.expires}`)
}
}
}
invalidCIDRError (msg) {
return Object.assign(new Error(msg), { code: 'EINVALIDCIDR' })
}
generateTokenIds (tokens, minLength) {
for (const token of tokens) {
token.id = token.key
for (let ii = minLength; ii < token.key.length; ++ii) {
const match = tokens.some(
ot => ot !== token && ot.key.slice(0, ii) === token.key.slice(0, ii)
)
if (!match) {
token.id = token.key.slice(0, ii)
break
}
}
}
}
async validateCIDRList (cidrs) {
const { v4: isCidrV4, v6: isCidrV6 } = await import('is-cidr')
const maybeList = [].concat(cidrs).filter(Boolean)
const list = maybeList.length === 1 ? maybeList[0].split(/,\s*/) : maybeList
for (const cidr of list) {
if (isCidrV6(cidr)) {
throw this.invalidCIDRError(
`CIDR whitelist can only contain IPv4 addresses, ${cidr} is IPv6`
)
}
if (!isCidrV4(cidr)) {
throw this.invalidCIDRError(`CIDR whitelist contains invalid CIDR entry: ${cidr}`)
}
}
return list
}
}
module.exports = Token
@@ -0,0 +1,178 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = require('../../trust-cmd.js')
const { validateUUID } = require('../../utils/validate-uuid.js')
class TrustCircleCI extends TrustCommand {
static description = 'Create a trusted relationship between a package and CircleCI'
static name = 'circleci'
static positionals = 1 // expects at most 1 positional (package name)
static providerName = 'CircleCI'
static providerEntity = 'CircleCI pipeline'
static usage = [
'[package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> [--context-id <uuid>...] [--allow-publish] [--allow-stage-publish] [-y|--yes]',
]
static definitions = [
new Definition('org-id', {
default: null,
type: String,
required: true,
description: 'CircleCI organization UUID',
}),
new Definition('project-id', {
default: null,
type: String,
required: true,
description: 'CircleCI project UUID',
}),
new Definition('pipeline-definition-id', {
default: null,
type: String,
required: true,
description: 'CircleCI pipeline definition UUID',
}),
new Definition('vcs-origin', {
default: null,
type: String,
required: true,
description: "CircleCI repository origin in format 'provider/owner/repo'",
}),
new Definition('context-id', {
default: null,
type: [null, String, Array],
description: 'CircleCI context UUID to match',
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
globalDefinitions.registry,
globalDefinitions.yes,
]
validateUuid (value, fieldName) {
validateUUID(value, fieldName)
}
validateVcsOrigin (value) {
// Expected format: provider/owner/repo (e.g., github.com/owner/repo, bitbucket.org/owner/repo)
if (value.includes('://')) {
throw new Error("vcs-origin must not include a scheme (e.g., use 'github.com/owner/repo' not 'https://github.com/owner/repo')")
}
const parts = value.split('/')
if (parts.length < 3) {
throw new Error("vcs-origin must be in format 'provider/owner/repo'")
}
}
// Generate a URL from vcs-origin (e.g., github.com/npm/repo -> https://github.com/npm/repo)
getVcsOriginUrl (vcsOrigin) {
if (!vcsOrigin) {
return null
}
// vcs-origin format: github.com/owner/repo or bitbucket.org/owner/repo
return `https://${vcsOrigin}`
}
static optionsToBody (options) {
const { orgId, projectId, pipelineDefinitionId, vcsOrigin, contextIds } = options
const trustConfig = {
type: 'circleci',
claims: {
'oidc.circleci.com/org-id': orgId,
'oidc.circleci.com/project-id': projectId,
'oidc.circleci.com/pipeline-definition-id': pipelineDefinitionId,
'oidc.circleci.com/vcs-origin': vcsOrigin,
},
}
if (contextIds && contextIds.length > 0) {
trustConfig.claims['oidc.circleci.com/context-ids'] = contextIds
}
return trustConfig
}
static bodyToOptions (body) {
return {
...(body.id) && { id: body.id },
...(body.type) && { type: body.type },
...(body.claims?.['oidc.circleci.com/org-id']) && { orgId: body.claims['oidc.circleci.com/org-id'] },
...(body.claims?.['oidc.circleci.com/project-id']) && { projectId: body.claims['oidc.circleci.com/project-id'] },
...(body.claims?.['oidc.circleci.com/pipeline-definition-id']) && {
pipelineDefinitionId: body.claims['oidc.circleci.com/pipeline-definition-id'],
},
...(body.claims?.['oidc.circleci.com/vcs-origin']) && { vcsOrigin: body.claims['oidc.circleci.com/vcs-origin'] },
...(body.claims?.['oidc.circleci.com/context-ids']) && { contextIds: body.claims['oidc.circleci.com/context-ids'] },
}
}
// Override flagsToOptions since CircleCI doesn't use file/entity pattern
async flagsToOptions ({ positionalArgs, flags }) {
const content = await this.optionalPkgJson()
const pkgName = positionalArgs[0] || content.name
if (!pkgName) {
throw new Error('Package name must be specified either as an argument or in package.json file')
}
const orgId = flags['org-id']
const projectId = flags['project-id']
const pipelineDefinitionId = flags['pipeline-definition-id']
const vcsOrigin = flags['vcs-origin']
const contextIds = flags['context-id']
// Validate required flags
if (!orgId) {
throw new Error('org-id is required')
}
if (!projectId) {
throw new Error('project-id is required')
}
if (!pipelineDefinitionId) {
throw new Error('pipeline-definition-id is required')
}
if (!vcsOrigin) {
throw new Error('vcs-origin is required')
}
// Validate formats
this.validateUuid(orgId, 'org-id')
this.validateUuid(projectId, 'project-id')
this.validateUuid(pipelineDefinitionId, 'pipeline-definition-id')
this.validateVcsOrigin(vcsOrigin)
if (contextIds?.length > 0) {
for (const contextId of contextIds) {
this.validateUuid(contextId, 'context-id')
}
}
return {
values: {
package: pkgName,
orgId,
projectId,
pipelineDefinitionId,
vcsOrigin,
...(contextIds?.length > 0 && { contextIds }),
},
fromPackageJson: {},
warnings: [],
urls: {
package: this.getFrontendUrl({ pkgName }),
vcsOrigin: this.getVcsOriginUrl(vcsOrigin),
},
}
}
async exec (positionalArgs, flags) {
await this.createConfigCommand({
positionalArgs,
flags,
})
}
}
module.exports = TrustCircleCI
@@ -0,0 +1,107 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = require('../../trust-cmd.js')
const path = require('node:path')
class TrustGitHub extends TrustCommand {
static description = 'Create a trusted relationship between a package and GitHub Actions'
static name = 'github'
static positionals = 1 // expects at most 1 positional (package name)
static providerName = 'GitHub Actions'
static providerEntity = 'GitHub repository'
static providerFile = 'GitHub Actions Workflow'
static providerHostname = 'https://github.com'
// entity means project / repository
static entityKey = 'repository'
static usage = [
'[package] --file [--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]',
]
static definitions = [
new Definition('file', {
default: null,
type: String,
required: true,
description: 'Name of workflow file within a repositories .GitHub folder (must end in yaml, yml)',
}),
new Definition('repository', {
default: null,
type: String,
description: 'Name of the repository in the format owner/repo',
alias: ['repo'],
}),
new Definition('environment', {
default: null,
type: String,
description: 'CI environment name',
alias: ['env'],
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
globalDefinitions.registry,
globalDefinitions.yes,
]
getEntityUrl ({ providerHostname, file, entity }) {
if (file) {
return new URL(`${entity}/blob/HEAD/.github/workflows/${file}`, providerHostname).toString()
}
return new URL(entity, providerHostname).toString()
}
validateEntity (entity) {
if (entity.split('/').length !== 2) {
throw new Error(`${this.constructor.providerEntity} must be specified in the format owner/repository`)
}
}
validateFile (file) {
if (file !== path.basename(file)) {
throw new Error('GitHub Actions workflow must be just a file not a path')
}
}
static optionsToBody (options) {
const { file, repository, environment } = options
const trustConfig = {
type: 'github',
claims: {
repository,
workflow_ref: {
file,
},
...(environment) && { environment },
},
}
return trustConfig
}
// Convert API response body to options
static bodyToOptions (body) {
const file = body.claims?.workflow_ref?.file
const repository = body.claims?.repository
const environment = body.claims?.environment
return {
...(body.id) && { id: body.id },
...(body.type) && { type: body.type },
...(file) && { file },
...(repository) && { repository },
...(environment) && { environment },
}
}
async exec (positionalArgs, flags) {
await this.createConfigCommand({
positionalArgs,
flags,
})
}
}
module.exports = TrustGitHub
@@ -0,0 +1,108 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = require('../../trust-cmd.js')
const path = require('node:path')
class TrustGitLab extends TrustCommand {
static description = 'Create a trusted relationship between a package and GitLab CI/CD'
static name = 'gitlab'
static positionals = 1 // expects at most 1 positional (package name)
static providerName = 'GitLab CI/CD'
static providerEntity = 'GitLab project'
static providerFile = 'GitLab CI/CD Pipeline'
static providerHostname = 'https://gitlab.com'
// entity means project / repository
static entityKey = 'project'
static usage = [
'[package] --file [--project|--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]',
]
static definitions = [
new Definition('file', {
default: null,
type: String,
required: true,
description: 'Name of pipeline file (e.g., .gitlab-ci.yml)',
}),
new Definition('project', {
default: null,
type: String,
description: 'Name of the project in the format group/project or group/subgroup/project',
}),
new Definition('environment', {
default: null,
type: String,
description: 'CI environment name',
alias: ['env'],
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
globalDefinitions.registry,
globalDefinitions.yes,
]
getEntityUrl ({ providerHostname, file, entity }) {
if (file) {
return new URL(`${entity}/-/blob/HEAD/${file}`, providerHostname).toString()
}
return new URL(entity, providerHostname).toString()
}
validateEntity (entity) {
if (entity.split('/').length < 2) {
throw new Error(`${this.constructor.providerEntity} must be specified in the format group/project or group/subgroup/project`)
}
}
validateFile (file) {
if (file !== path.basename(file)) {
throw new Error('GitLab CI/CD pipeline file must be just a file not a path')
}
}
static optionsToBody (options) {
const { file, project, environment } = options
const trustConfig = {
type: 'gitlab',
claims: {
project_path: project,
// this looks off, but this is correct
/** The ref path to the top-level pipeline definition, for example, gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main. Introduced in GitLab 16.2. This claim is null unless the pipeline definition is located in the same project. */
ci_config_ref_uri: {
file,
},
...(environment) && { environment },
},
}
return trustConfig
}
// Convert API response body to options
static bodyToOptions (body) {
const file = body.claims?.ci_config_ref_uri?.file
const project = body.claims?.project_path
const environment = body.claims?.environment
return {
...(body.id) && { id: body.id },
...(body.type) && { type: body.type },
...(file) && { file },
...(project) && { project },
...(environment) && { environment },
}
}
async exec (positionalArgs, flags) {
await this.createConfigCommand({
positionalArgs,
flags,
})
}
}
module.exports = TrustGitLab
@@ -0,0 +1,25 @@
const BaseCommand = require('../../base-cmd.js')
class Trust extends BaseCommand {
static description = 'Create a trusted relationship between a package and a OIDC provider'
static name = 'trust'
static usage = null
static subcommands = {
github: require('./github.js'),
gitlab: require('./gitlab.js'),
circleci: require('./circleci.js'),
list: require('./list.js'),
revoke: require('./revoke.js'),
}
static async completion (opts) {
const argv = opts.conf.argv.remain
if (argv.length === 2) {
return Object.keys(Trust.subcommands)
}
return []
}
}
module.exports = Trust
@@ -0,0 +1,50 @@
const { otplease } = require('../../utils/auth.js')
const npmFetch = require('npm-registry-fetch')
const npa = require('npm-package-arg')
const TrustCircleCI = require('./circleci.js')
const TrustGithub = require('./github.js')
const TrustGitlab = require('./gitlab.js')
const TrustCommand = require('../../trust-cmd.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
class TrustList extends TrustCommand {
static description = 'List trusted relationships for a package'
static name = 'list'
static positionals = 1 // expects at most 1 positional (package name)
static usage = [
'[package]',
]
static definitions = [
globalDefinitions.json,
globalDefinitions.registry,
]
static bodyToOptions (body) {
if (body.type === 'circleci') {
return TrustCircleCI.bodyToOptions(body)
} else if (body.type === 'github') {
return TrustGithub.bodyToOptions(body)
} else if (body.type === 'gitlab') {
return TrustGitlab.bodyToOptions(body)
}
return TrustCommand.bodyToOptions(body)
}
async exec (positionalArgs) {
const packageName = positionalArgs[0] || (await this.optionalPkgJson()).name
if (!packageName) {
throw new Error('Package name must be specified either as an argument or in the package.json file')
}
const spec = npa(packageName)
const uri = `/-/package/${spec.escapedName}/trust`
const body = await otplease(this.npm, this.npm.flatOptions, opts => npmFetch.json(uri, {
...opts,
method: 'GET',
}))
this.displayResponseBody({ body, packageName })
}
}
module.exports = TrustList
@@ -0,0 +1,52 @@
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const { otplease } = require('../../utils/auth.js')
const npmFetch = require('npm-registry-fetch')
const npa = require('npm-package-arg')
const TrustCommand = require('../../trust-cmd.js')
class TrustRevoke extends TrustCommand {
static description = 'Revoke a trusted relationship for a package'
static name = 'revoke'
static positionals = 1 // expects at most 1 positional (package name)
static usage = [
'[package] --id=<trust-id>',
]
static definitions = [
new Definition('id', {
default: null,
type: String,
description: 'ID of the trusted relationship to revoke',
required: true,
}),
globalDefinitions['dry-run'],
globalDefinitions.registry,
]
async exec (positionalArgs, flags) {
const dryRun = this.config.get('dry-run')
const pkgName = positionalArgs[0] || (await this.optionalPkgJson()).name
if (!pkgName) {
throw new Error('Package name must be specified either as an argument or in the package.json file')
}
const { id } = flags
if (!id) {
throw new Error('ID of the trusted relationship to revoke must be specified with the --id option')
}
this.dialogue`Attempting to revoke trusted configuration for package ${pkgName} with id ${id}`
if (dryRun) {
return
}
const spec = npa(pkgName)
const uri = `/-/package/${spec.escapedName}/trust/${encodeURIComponent(id)}`
await otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
...opts,
method: 'DELETE',
}))
this.dialogue`Revoked trusted configuration for package ${pkgName} with id ${id}`
}
}
module.exports = TrustRevoke
@@ -0,0 +1,13 @@
const Deprecate = require('./deprecate.js')
class Undeprecate extends Deprecate {
static description = 'Undeprecate a version of a package'
static name = 'undeprecate'
static usage = ['<package-spec>']
async exec ([pkg]) {
return super.exec([pkg, ''])
}
}
module.exports = Undeprecate
@@ -0,0 +1,54 @@
const { resolve } = require('node:path')
const pkgJson = require('@npmcli/package-json')
const reifyFinish = require('../utils/reify-finish.js')
const completion = require('../utils/installed-shallow.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class Uninstall extends ArboristWorkspaceCmd {
static description = 'Remove a package'
static name = 'uninstall'
static params = ['save', 'global', ...super.params]
static usage = ['[<@scope>/]<pkg>...']
static ignoreImplicitWorkspace = false
static async completion (opts, npm) {
return completion(npm, opts)
}
async exec (args) {
if (!args.length) {
if (!this.npm.global) {
throw new Error('Must provide a package name to remove')
} else {
try {
const { content: pkg } = await pkgJson.normalize(this.npm.localPrefix)
args.push(pkg.name)
} catch (er) {
if (er.code !== 'ENOENT' && er.code !== 'ENOTDIR') {
throw er
} else {
throw this.usageError()
}
}
}
}
// the /path/to/node_modules/..
const path = this.npm.global
? resolve(this.npm.globalDir, '..')
: this.npm.localPrefix
const Arborist = require('@npmcli/arborist')
const opts = {
...this.npm.flatOptions,
path,
rm: args,
workspaces: this.workspaceNames,
}
const arb = new Arborist(opts)
await arb.reify(opts)
await reifyFinish(this.npm, arb)
}
}
module.exports = Uninstall
+163
View File
@@ -0,0 +1,163 @@
const libaccess = require('libnpmaccess')
const libunpub = require('libnpmpublish').unpublish
const npa = require('npm-package-arg')
const pacote = require('pacote')
const { output, log } = require('proc-log')
const pkgJson = require('@npmcli/package-json')
const { flatten } = require('@npmcli/config/lib/definitions')
const getIdentity = require('../utils/get-identity.js')
const { otplease } = require('../utils/auth.js')
const BaseCommand = require('../base-cmd.js')
const LAST_REMAINING_VERSION_ERROR = 'Refusing to delete the last version of the package. It will block from republishing a new version for 24 hours.\nRun with --force to do this.'
class Unpublish extends BaseCommand {
static description = 'Remove a package from the registry'
static name = 'unpublish'
static params = ['dry-run', 'force', 'workspace', 'workspaces']
static usage = ['[<package-spec>]']
static workspaces = true
static ignoreImplicitWorkspace = false
static async getKeysOfVersions (name, opts) {
const packument = await pacote.packument(name, {
...opts,
spec: name,
query: { write: true },
_isRoot: true,
})
return Object.keys(packument.versions)
}
static async completion (args, npm) {
const { partialWord, conf } = args
if (conf.argv.remain.length >= 3) {
return []
}
const opts = { ...npm.flatOptions }
const username = await getIdentity(npm, { ...opts }).catch(() => null)
if (!username) {
return []
}
const access = await libaccess.getPackages(username, opts)
// do a bit of filtering at this point, so that we don't need to fetch versions for more than one thing, but also don't accidentally unpublish a whole project
let pkgs = Object.keys(access)
if (!partialWord || !pkgs.length) {
return pkgs
}
const pp = npa(partialWord).name
pkgs = pkgs.filter(p => !p.indexOf(pp))
if (pkgs.length > 1) {
return pkgs
}
const versions = await Unpublish.getKeysOfVersions(pkgs[0], opts)
if (!versions.length) {
return pkgs
} else {
return versions.map(v => `${pkgs[0]}@${v}`)
}
}
async exec (args, { localPrefix } = {}) {
if (args.length > 1) {
throw this.usageError()
}
// workspace mode
if (!localPrefix) {
localPrefix = this.npm.localPrefix
}
const force = this.npm.config.get('force')
const { silent } = this.npm
const dryRun = this.npm.config.get('dry-run')
let spec
if (args.length) {
spec = npa(args[0])
if (spec.type !== 'version' && spec.rawSpec !== '*') {
throw this.usageError('Can only unpublish a single version, or the entire project.\nTags and ranges are not supported.')
}
}
log.silly('unpublish', 'args[0]', args[0])
log.silly('unpublish', 'spec', spec)
if (spec?.rawSpec === '*' && !force) {
throw this.usageError('Refusing to delete entire project.\nRun with --force to do this.')
}
const opts = { ...this.npm.flatOptions }
let manifest
try {
const { content } = await pkgJson.prepare(localPrefix)
manifest = content
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
if (!spec) {
// We needed a local package.json to figure out what package to unpublish
throw this.usageError()
}
} else {
// folks should know if ANY local package.json had a parsing error.
// They may be relying on `publishConfig` to be loading and we don't want to ignore errors in that case.
throw err
}
}
let pkgVersion // for cli output
if (spec) {
pkgVersion = spec.type === 'version' ? `@${spec.rawSpec}` : ''
} else {
spec = npa.resolve(manifest.name, manifest.version)
log.verbose('unpublish', manifest)
pkgVersion = manifest.version ? `@${manifest.version}` : ''
if (!manifest.version && !force) {
throw this.usageError('Refusing to delete entire project.\nRun with --force to do this.')
}
}
// If localPrefix has a package.json with a name that matches the package being unpublished, load up the publishConfig
if (manifest?.name === spec.name && manifest.publishConfig) {
const cliFlags = this.npm.config.data.get('cli').raw
// Filter out properties set in CLI flags to prioritize them over corresponding `publishConfig` settings
const filteredPublishConfig = Object.fromEntries(
Object.entries(manifest.publishConfig).filter(([key]) => !(key in cliFlags)))
for (const key in filteredPublishConfig) {
this.npm.config.checkUnknown('publishConfig', key)
}
flatten(filteredPublishConfig, opts)
}
const versions = await Unpublish.getKeysOfVersions(spec.name, opts)
if (versions.length === 1 && spec.rawSpec === versions[0] && !force) {
throw this.usageError(LAST_REMAINING_VERSION_ERROR)
}
if (versions.length === 1) {
pkgVersion = ''
}
if (!dryRun) {
await otplease(this.npm, opts, o => libunpub(spec, o))
}
if (!silent) {
output.standard(`- ${spec.name}${pkgVersion}`)
}
}
async execWorkspaces (args) {
await this.setWorkspaces()
for (const path of this.workspacePaths) {
await this.exec(args, { localPrefix: path })
}
}
}
module.exports = Unpublish
+8
View File
@@ -0,0 +1,8 @@
const Star = require('./star.js')
class Unstar extends Star {
static description = 'Remove an item from your favorite packages'
static name = 'unstar'
}
module.exports = Unstar
+76
View File
@@ -0,0 +1,76 @@
const path = require('node:path')
const { log } = require('proc-log')
const reifyFinish = require('../utils/reify-finish.js')
const resolveAllowScripts = require('../utils/resolve-allow-scripts.js')
const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js')
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
class Update extends ArboristWorkspaceCmd {
static description = 'Update packages'
static name = 'update'
static params = [
'save',
'global',
'install-strategy',
'legacy-bundling',
'global-style',
'omit',
'include',
'strict-peer-deps',
'package-lock',
'foreground-scripts',
'ignore-scripts',
'allow-scripts',
'strict-allow-scripts',
'dangerously-allow-all-scripts',
'audit',
'before',
'min-release-age',
'bin-links',
'fund',
'dry-run',
...super.params,
]
static usage = ['[<pkg>...]']
static async completion (opts, npm) {
const completion = require('../utils/installed-deep.js')
return completion(npm, opts)
}
async exec (args) {
const update = args.length === 0 ? true : args
const global = path.resolve(this.npm.globalDir, '..')
const where = this.npm.global ? global : this.npm.prefix
// In the context of `npm update` the save config value should default to `false`
const save = this.npm.config.isDefault('save')
? false
: this.npm.config.get('save')
if (this.npm.config.get('depth')) {
log.warn('update', 'The --depth option no longer has any effect. See RFC0019.\n' +
'https://github.com/npm/rfcs/blob/latest/implemented/0019-remove-update-depth-option.md')
}
const Arborist = require('@npmcli/arborist')
const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm)
const opts = {
...this.npm.flatOptions,
path: where,
save,
workspaces: this.workspaceNames,
allowScripts: allowScriptsPolicy,
}
const arb = new Arborist(opts)
const reifyOpts = { ...opts, update }
await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: reifyOpts })
await arb.reify(reifyOpts)
await reifyFinish(this.npm, arb)
}
}
module.exports = Update
+152
View File
@@ -0,0 +1,152 @@
const { resolve } = require('node:path')
const { readFile } = require('node:fs/promises')
const { output } = require('proc-log')
const BaseCommand = require('../base-cmd.js')
class Version extends BaseCommand {
static description = 'Bump a package version'
static name = 'version'
static params = [
'allow-same-version',
'commit-hooks',
'git-tag-version',
'json',
'preid',
'sign-git-tag',
'save',
'workspace',
'workspaces',
'workspaces-update',
'include-workspace-root',
'ignore-scripts',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static usage = ['[<newversion> | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git]']
static async completion (opts) {
const {
conf: {
argv: { remain },
},
} = opts
if (remain.length > 2) {
return []
}
return [
'major',
'minor',
'patch',
'premajor',
'preminor',
'prepatch',
'prerelease',
'from-git',
]
}
async exec (args) {
switch (args.length) {
case 0:
return this.list()
case 1:
return this.change(args)
default:
throw this.usageError()
}
}
async execWorkspaces (args) {
switch (args.length) {
case 0:
return this.listWorkspaces()
case 1:
return this.changeWorkspaces(args)
default:
throw this.usageError()
}
}
async change (args) {
const libnpmversion = require('libnpmversion')
const prefix = this.npm.config.get('tag-version-prefix')
const version = await libnpmversion(args[0], {
...this.npm.flatOptions,
path: this.npm.prefix,
})
return output.standard(`${prefix}${version}`)
}
async changeWorkspaces (args) {
const updateWorkspaces = require('../utils/update-workspaces.js')
const libnpmversion = require('libnpmversion')
const prefix = this.npm.config.get('tag-version-prefix')
const {
config,
flatOptions,
localPrefix,
} = this.npm
await this.setWorkspaces()
const updatedWorkspaces = []
for (const [name, path] of this.workspaces) {
output.standard(name)
const version = await libnpmversion(args[0], {
...flatOptions,
'git-tag-version': false,
path,
})
updatedWorkspaces.push(name)
output.standard(`${prefix}${version}`)
}
return updateWorkspaces({
config,
flatOptions,
localPrefix,
npm: this.npm,
workspaces: updatedWorkspaces,
})
}
async list (results = {}) {
const pj = resolve(this.npm.prefix, 'package.json')
const pkg = await readFile(pj, 'utf8')
.then(data => JSON.parse(data))
.catch(() => ({}))
if (pkg.name && pkg.version) {
results[pkg.name] = pkg.version
}
results.npm = this.npm.version
for (const [key, version] of Object.entries(process.versions)) {
results[key] = version
}
if (this.npm.config.get('json')) {
output.buffer(results)
} else {
output.standard(results)
}
}
async listWorkspaces () {
const results = {}
await this.setWorkspaces()
for (const path of this.workspacePaths) {
const pj = resolve(path, 'package.json')
// setWorkspaces has already parsed package.json so we know it won't error
const pkg = await readFile(pj, 'utf8').then(data => JSON.parse(data))
if (pkg.name && pkg.version) {
results[pkg.name] = pkg.version
}
}
return this.list(results)
}
}
module.exports = Version
+488
View File
@@ -0,0 +1,488 @@
const { readFile } = require('node:fs/promises')
const jsonParse = require('json-parse-even-better-errors')
const { log, output, META } = require('proc-log')
const npa = require('npm-package-arg')
const { resolve } = require('node:path')
const formatBytes = require('../utils/format-bytes.js')
const relativeDate = require('tiny-relative-date')
const semver = require('semver')
const { inspect } = require('node:util')
const { packument } = require('pacote')
const Queryable = require('../utils/queryable.js')
const BaseCommand = require('../base-cmd.js')
const { getError } = require('../utils/error-message.js')
const { jsonError, outputError } = require('../utils/output-error.js')
const readJson = file => readFile(file, 'utf8').then(jsonParse)
class View extends BaseCommand {
static description = 'View registry info'
static name = 'view'
static params = [
'json',
'workspace',
'workspaces',
'include-workspace-root',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static usage = ['[<package-spec>] [<field>[.subfield]...]']
static async completion (opts, npm) {
if (opts.conf.argv.remain.length <= 2) {
// There used to be registry completion here, but it stopped making sense somewhere around 50,000 packages on the registry
return
}
// have the package, get the fields
const config = {
...npm.flatOptions,
fullMetadata: true,
preferOnline: true,
_isRoot: true,
}
const spec = npa(opts.conf.argv.remain[2])
const pckmnt = await packument(spec, config)
const defaultTag = npm.config.get('tag')
const dv = pckmnt.versions[pckmnt['dist-tags'][defaultTag]]
pckmnt.versions = Object.keys(pckmnt.versions).sort(semver.compareLoose)
return getCompletionFields(pckmnt).concat(getCompletionFields(dv))
}
async exec (args) {
let { pkg, local, rest } = parseArgs(args)
if (local) {
if (this.npm.global) {
throw new Error('Cannot use view command in global mode.')
}
const dir = this.npm.prefix
const manifest = await readJson(resolve(dir, 'package.json'))
if (!manifest.name) {
throw new Error('Invalid package.json, no "name" field')
}
// put the version back if it existed
pkg = `${manifest.name}${pkg.slice(1)}`
}
await this.#viewPackage(pkg, rest)
}
async execWorkspaces (args) {
const { pkg, local, rest } = parseArgs(args)
if (!local) {
log.warn('Ignoring workspaces for specified package(s)')
return this.exec([pkg, ...rest])
}
const json = this.npm.config.get('json')
await this.setWorkspaces()
for (const name of this.workspaceNames) {
try {
await this.#viewPackage(`${name}${pkg.slice(1)}`, rest, { workspace: true })
} catch (e) {
const err = getError(e, { npm: this.npm, command: this })
if (err.code !== 'E404') {
throw e
}
if (json) {
output.buffer({ [META]: true, jsonError: { [name]: jsonError(err, this.npm) } })
} else {
outputError(err)
}
process.exitCode = err.exitCode
}
}
}
async #viewPackage (name, args, { workspace } = {}) {
const wholePackument = !args.length
const json = this.npm.config.get('json')
// If we are viewing many packages and outputting individual fields then output the name before doing any async activity
if (!json && !wholePackument && workspace) {
output.standard(`${name}:`)
}
const [pckmnt, data] = await this.#getData(name, args, wholePackument)
if (!json && wholePackument) {
// pretty view (entire packument)
for (const v of data) {
output.standard(this.#prettyView(pckmnt, Object.values(v)[0][Queryable.ALL]))
}
return
}
const res = this.#packageOutput(cleanData(data, wholePackument), pckmnt._id)
if (res) {
if (json) {
output.buffer(workspace ? { [name]: res } : res)
} else {
output.standard(res)
}
}
}
async #getData (pkg, args) {
const spec = npa(pkg)
const pckmnt = await packument(spec, {
...this.npm.flatOptions,
preferOnline: true,
fullMetadata: true,
_isRoot: true,
})
// get the data about this package
let version = this.npm.config.get('tag')
// rawSpec is the git url if this is from git
if (spec.type !== 'git' && spec.type !== 'directory' && spec.rawSpec !== '*') {
version = spec.rawSpec
}
if (pckmnt['dist-tags']?.[version]) {
version = pckmnt['dist-tags'][version]
}
if (pckmnt.time?.unpublished) {
const u = pckmnt.time.unpublished
throw Object.assign(new Error(`Unpublished on ${u.time}`), {
statusCode: 404,
code: 'E404',
pkgid: pckmnt._id,
})
}
const versions = pckmnt.versions || {}
pckmnt.versions = Object.keys(versions).filter(v => {
if (semver.valid(v)) {
return true
}
log.info('view', `Ignoring invalid version: ${v}`)
return false
}).sort(semver.compareLoose)
// remove readme unless we asked for it
if (args.indexOf('readme') === -1) {
delete pckmnt.readme
}
const data = Object.entries(versions)
.filter(([v]) => semver.satisfies(v, version, true))
.flatMap(([, v]) => {
// remove readme unless we asked for it
if (args.indexOf('readme') !== -1) {
delete v.readme
}
return showFields({
data: pckmnt,
version: v,
fields: args,
json: this.npm.config.get('json'),
})
})
// No data has been pushed because no data is matching the specified version
if (!data.length && version !== 'latest') {
throw Object.assign(new Error(`No match found for version ${version}`), {
statusCode: 404,
code: 'E404',
pkgid: `${pckmnt._id}@${version}`,
})
}
return [pckmnt, data]
}
#packageOutput (data, name) {
const json = this.npm.config.get('json')
const versions = Object.keys(data)
const includeVersions = versions.length > 1
let includeFields
const res = versions.flatMap((v) => {
const fields = Object.entries(data[v])
includeFields ||= (fields.length > 1)
const msg = json ? {} : []
for (let [f, d] of fields) {
d = cleanup(d)
if (json) {
msg[f] = d
continue
}
if (includeVersions || includeFields || typeof d !== 'string') {
d = inspect(d, {
showHidden: false,
depth: 5,
colors: this.npm.color,
maxArrayLength: null,
})
}
if (f && includeFields) {
f += ' = '
}
msg.push(`${includeVersions ? `${name}@${v} ` : ''}${includeFields ? f : ''}${d}`)
}
return msg
})
if (json) {
// TODO(BREAKING_CHANGE): all unwrapping should be removed.
// Users should know based on their arguments if they can expect an array or an object.
// And this unwrapping can break that assumption.
// e.g. `npm view abbrev@^2` should always return an array, but currently since there is only one version matching `^2` this will return a single object instead.
const first = Object.keys(res[0] || {})
const jsonRes = first.length === 1 ? res.map(m => m[first[0]]) : res
if (jsonRes.length === 0) {
return
}
if (jsonRes.length === 1) {
return jsonRes[0]
}
return jsonRes
}
return res.join('\n').trim()
}
#prettyView (packu, manifest) {
// More modern, pretty printing of default view
const unicode = this.npm.config.get('unicode')
const chalk = this.npm.chalk
const deps = Object.entries(manifest.dependencies || {}).map(([k, dep]) =>
`${chalk.blue(k)}: ${dep}`
)
// Sort dist-tags by publish time when available, then by tag name, keeping `latest` at the top of the list.
const distTags = Object.entries(packu['dist-tags'])
.sort(([aTag, aVer], [bTag, bVer]) => {
const timeMap = packu.time || {}
const aTime = aTag === 'latest' ? Infinity : Date.parse(timeMap[aVer] || 0)
const bTime = bTag === 'latest' ? Infinity : Date.parse(timeMap[bVer] || 0)
if (aTime === bTime) {
return aTag > bTag ? -1 : 1
}
return aTime > bTime ? -1 : 1
})
.map(([k, t]) => `${chalk.blue(k)}: ${t}`)
const site = manifest.homepage?.url || manifest.homepage
const bins = Object.keys(manifest.bin || {})
const licenseField = manifest.license || 'Proprietary'
const license = typeof licenseField === 'string'
? licenseField
: (licenseField.type || 'Proprietary')
const res = []
res.push('')
res.push([
chalk.underline.cyan(`${manifest.name}@${manifest.version}`),
license.toLowerCase().trim() === 'proprietary'
? chalk.red(license)
: chalk.green(license),
`deps: ${deps.length ? chalk.cyan(deps.length) : chalk.cyan('none')}`,
`versions: ${chalk.cyan(packu.versions.length + '')}`,
].join(' | '))
manifest.description && res.push(manifest.description)
if (site) {
res.push(chalk.blue(site))
}
manifest.deprecated && res.push(
`\n${chalk.redBright('DEPRECATED')}${unicode ? ' ⚠️ ' : '!!'} - ${manifest.deprecated}`
)
if (packu.keywords?.length) {
res.push(`\nkeywords: ${
packu.keywords.map(k => chalk.cyan(k)).join(', ')
}`)
}
if (bins.length) {
res.push(`\nbin: ${chalk.cyan(bins.join(', '))}`)
}
res.push('\ndist')
res.push(`.tarball: ${chalk.blue(manifest.dist.tarball)}`)
res.push(`.shasum: ${chalk.green(manifest.dist.shasum)}`)
if (manifest.dist.integrity) {
res.push(`.integrity: ${chalk.green(manifest.dist.integrity)}`)
}
if (manifest.dist.unpackedSize) {
res.push(`.unpackedSize: ${chalk.blue(formatBytes(manifest.dist.unpackedSize, true))}`)
}
if (deps.length) {
const maxDeps = 24
res.push('\ndependencies:')
res.push(deps.slice(0, maxDeps).join(', '))
if (deps.length > maxDeps) {
res.push(chalk.dim(`(...and ${deps.length - maxDeps} more.)`))
}
}
if (packu.maintainers?.length) {
res.push('\nmaintainers:')
packu.maintainers.forEach(u =>
res.push(`- ${unparsePerson({
name: chalk.blue(u.name),
email: chalk.dim(u.email) })}`)
)
}
res.push('\ndist-tags:')
const maxTags = 5
res.push(distTags.slice(0, maxTags).join('\n'))
if (distTags.length > maxTags) {
res.push(chalk.dim(`(...and ${distTags.length - maxTags} more.)`))
}
const publisher = manifest._npmUser && unparsePerson({
name: chalk.blue(manifest._npmUser.name),
email: chalk.dim(manifest._npmUser.email),
})
if (publisher || packu.time) {
let publishInfo = 'published'
if (packu.time?.[manifest.version]) {
publishInfo += ` ${chalk.cyan(relativeDate(packu.time[manifest.version]))}`
}
if (publisher) {
publishInfo += ` by ${publisher}`
}
res.push('')
res.push(publishInfo)
}
return res.join('\n')
}
}
module.exports = View
function parseArgs (args) {
if (!args.length) {
args = ['.']
}
const pkg = args.shift()
return {
pkg,
local: /^\.@/.test(pkg) || pkg === '.',
rest: args,
}
}
function cleanData (obj, wholePackument) {
// JSON formatted output (JSON or specific attributes from packument)
const data = obj.reduce((acc, cur) => {
if (cur) {
Object.entries(cur).forEach(([k, v]) => {
acc[k] ||= {}
Object.keys(v).forEach((t) => {
acc[k][t] = cur[k][t]
})
})
}
return acc
}, {})
if (wholePackument) {
const cleaned = Object.entries(data).reduce((acc, [k, v]) => {
acc[k] = v[Queryable.ALL]
return acc
}, {})
log.silly('view', cleaned)
return cleaned
}
return data
}
// return whatever was printed
function showFields ({ data, version, fields, json }) {
const o = [data, version].reduce((acc, s) => {
Object.entries(s).forEach(([k, v]) => {
acc[k] = v
})
return acc
}, {})
const queryable = new Queryable(o)
if (!fields.length) {
return { [version.version]: queryable.query(Queryable.ALL) }
}
return fields.map((field) => {
const s = queryable.query(field, { unwrapSingleItemArrays: !json })
if (s) {
return { [version.version]: s }
}
})
}
function cleanup (data) {
if (Array.isArray(data)) {
return data.map(cleanup)
}
if (!data || typeof data !== 'object') {
return data
}
const keys = Object.keys(data)
if (keys.length <= 3 && data.name && (
(keys.length === 1) ||
(keys.length === 3 && data.email && data.url) ||
(keys.length === 2 && (data.email || data.url)) ||
data.trustedPublisher
)) {
data = unparsePerson(data)
}
return data
}
const unparsePerson = (d) =>
`${d.name}${d.email ? ` <${d.email}>` : ''}${d.url ? ` (${d.url})` : ''}`
function getCompletionFields (d, f = [], pref = []) {
Object.entries(d).forEach(([k, v]) => {
if (k.charAt(0) === '_' || k.indexOf('.') !== -1) {
return
}
const p = pref.concat(k).join('.')
f.push(p)
if (Array.isArray(v)) {
v.forEach((val, i) => {
const pi = p + '[' + i + ']'
if (val && typeof val === 'object') {
getCompletionFields(val, f, [p])
} else {
f.push(pi)
}
})
return
}
if (typeof v === 'object') {
getCompletionFields(v, f, [p])
}
})
return f
}
+20
View File
@@ -0,0 +1,20 @@
const { output } = require('proc-log')
const getIdentity = require('../utils/get-identity.js')
const BaseCommand = require('../base-cmd.js')
class Whoami extends BaseCommand {
static description = 'Display npm username'
static name = 'whoami'
static params = ['registry']
async exec () {
const username = await getIdentity(this.npm, { ...this.npm.flatOptions })
if (this.npm.config.get('json')) {
output.buffer(username)
} else {
output.standard(username)
}
}
}
module.exports = Whoami
+20
View File
@@ -0,0 +1,20 @@
const BaseCommand = require('./base-cmd.js')
// The implementation of commands that are just "run a script"
// e.g. restart, start, stop, test
class LifecycleCmd extends BaseCommand {
static usage = ['[-- <args>]']
static isShellout = true
static workspaces = true
static ignoreImplicitWorkspace = false
async exec (args) {
return this.npm.exec('run', [this.constructor.name, ...args])
}
async execWorkspaces (args) {
return this.npm.exec('run', [this.constructor.name, ...args])
}
}
module.exports = LifecycleCmd
+525
View File
@@ -0,0 +1,525 @@
const { resolve, dirname, join } = require('node:path')
const Config = require('@npmcli/config')
const which = require('which')
const fs = require('node:fs/promises')
const { definitions, flatten, nerfDarts, shorthands } = require('@npmcli/config/lib/definitions')
const usage = require('./utils/npm-usage.js')
const LogFile = require('./utils/log-file.js')
const Timers = require('./utils/timers.js')
const Display = require('./utils/display.js')
const { log, time, output, META } = require('proc-log')
const { redactLog: replaceInfo } = require('@npmcli/redact')
const pkg = require('../package.json')
const { deref } = require('./utils/cmd-list.js')
const { jsonError, outputError } = require('./utils/output-error.js')
class Npm {
static get version () {
return pkg.version
}
static cmd (c) {
const command = deref(c)
if (!command) {
throw Object.assign(new Error(`Unknown command ${c}`), {
code: 'EUNKNOWNCOMMAND',
command: c,
})
}
return require(`./commands/${command}`)
}
unrefPromises = []
updateNotification = null
argv = []
#command = null
#runId = new Date().toISOString().replace(/[.:]/g, '_')
#title = 'npm'
#argvClean = []
#npmRoot = null
#display = null
#logFile = new LogFile()
#timers = new Timers()
// All these options are only used by tests in order to make testing more closely resemble real world usage.
// For now, npm has no programmatic API so it is ok to add stuff here, but we should not rely on it more than necessary.
// XXX: make these options not necessary by refactoring @npmcli/config
// - npmRoot: this is where npm looks for docs files and the builtin config
// - argv: this allows tests to extend argv in the same way the argv would be passed in via a CLI arg.
// - excludeNpmCwd: this is a hack to get @npmcli/config to stop walking up dirs to set a local prefix when it encounters the `npmRoot`.
// this allows tests created by tap inside this repo to not set the local prefix to `npmRoot` since that is the first dir it would encounter when doing implicit detection
constructor ({
stdout = process.stdout,
stderr = process.stderr,
npmRoot = dirname(__dirname),
argv = [],
excludeNpmCwd = false,
} = {}) {
this.#display = new Display({ stdout, stderr })
this.#npmRoot = npmRoot
this.config = new Config({
npmPath: this.#npmRoot,
definitions,
flatten,
nerfDarts,
shorthands,
argv: [...process.argv, ...argv],
excludeNpmCwd,
warn: false,
})
}
async load () {
let err
try {
return await time.start('npm:load', () => this.#load())
} catch (e) {
err = e
}
return this.#handleError(err)
}
async #load () {
await time.start('npm:load:whichnode', async () => {
// TODO should we throw here?
const node = await which(process.argv[0]).catch(() => {})
if (node && node.toUpperCase() !== process.execPath.toUpperCase()) {
log.verbose('node symlink', node)
process.execPath = node
this.config.execPath = node
}
})
await time.start('npm:load:configload', () => this.config.load())
// npm --versions
if (this.config.get('versions', 'cli')) {
this.argv = ['version']
this.config.set('usage', false, 'cli')
} else {
this.argv = [...this.config.parsedArgv.remain]
}
// Remove first argv since that is our command as typed
// Note that this might not be the actual name of the command due to aliases, etc.
// But we use the raw form of it later in user output so it must be preserved as is.
const commandArg = this.argv.shift()
// This is the actual name of the command that will be run or undefined if deref could not find a match
const command = deref(commandArg)
await this.#display.load({
command,
loglevel: this.config.get('loglevel'),
stdoutColor: this.color,
stderrColor: this.logColor,
timing: this.config.get('timing'),
unicode: this.config.get('unicode'),
progress: this.flatOptions.progress,
json: this.config.get('json'),
heading: this.config.get('heading'),
})
process.env.COLOR = this.color ? '1' : '0'
// npm -v
// return from here early so we don't create any caches/logfiles/timers etc
if (this.config.get('version', 'cli')) {
output.standard(this.version)
return { exec: false }
}
// mkdir this separately since the logs dir can be set to a different location.
// if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
await time.start('npm:load:mkdirpcache', () =>
fs.mkdir(this.cache, { recursive: true })
.catch((e) => log.verbose('cache', `could not create cache: ${e}`)))
// it's ok if this fails. user might have specified an invalid dir which we will tell them about at the end
if (this.config.get('logs-max') > 0) {
await time.start('npm:load:mkdirplogs', () =>
fs.mkdir(this.#logsDir, { recursive: true })
.catch((e) => log.verbose('logfile', `could not create logs-dir: ${e}`)))
}
// note: this MUST be shorter than the actual argv length, because it uses the same memory, so node will truncate it if it's too long.
// We time this because setting process.title is slow sometimes but we have to do it for security reasons. But still helpful to know how slow it is.
time.start('npm:load:setTitle', () => {
const { parsedArgv: { cooked, remain } } = this.config
// Secrets are mostly in configs, so title is set using only the positional args to keep those from being leaked.
// We still do a best effort replaceInfo.
this.#title = ['npm'].concat(replaceInfo(remain)).join(' ').trim()
process.title = this.#title
// The cooked argv is also logged separately for debugging purposes.
// It is cleaned as a best effort by replacing known secrets like basic auth password and strings that look like npm tokens.
// XXX: for this to be safer the config should create a sanitized version of the argv as it has the full context of what each option contains.
this.#argvClean = replaceInfo(cooked)
log.verbose('title', this.title)
log.verbose('argv', this.#argvClean.map(JSON.stringify).join(' '))
})
// logFile.load returns a promise that resolves when old logs are done being cleaned.
// We save this promise to an array so that we can await it in tests to ensure more deterministic logging behavior.
// The process will also hang open if this were to take a long time to resolve, but that is why process.exit is called explicitly in the exit-handler.
this.unrefPromises.push(this.#logFile.load({
command,
path: this.logPath,
logsMax: this.config.get('logs-max'),
timing: this.config.get('timing'),
}))
this.#timers.load({
path: this.logPath,
timing: this.config.get('timing'),
})
const configScope = this.config.get('scope')
if (configScope && !/^@/.test(configScope)) {
this.config.set('scope', `@${configScope}`, this.config.find('scope'))
}
if (this.config.get('force')) {
log.warn('using --force', 'Recommended protections disabled.')
}
return { exec: true, command: commandArg, args: this.argv }
}
async exec (cmd, args = this.argv) {
if (!this.#command) {
let err
try {
await this.#exec(cmd, args)
} catch (e) {
err = e
}
return this.#handleError(err)
} else {
return this.#exec(cmd, args)
}
}
// Call an npm command
async #exec (cmd, args) {
const Command = this.constructor.cmd(cmd)
const command = new Command(this)
// since 'test', 'start', 'stop', etc. commands re-enter this function to call the run command, we need to only set it one time.
if (!this.#command) {
this.#command = command
process.env.npm_command = this.command
}
// Only log warnings for legacy commands without definitions or subcommands
// Commands with definitions will handle warnings in base-cmd flags()
// Commands with subcommands will delegate to the subcommand to handle warnings
if (!Command.definitions && !Command.subcommands) {
this.config.logWarnings()
}
// this needs to be rest after because some commands run this.npm.config.checkUnknown('publishConfig', key)
this.config.warn = true
return this.execCommandClass(command, args, [cmd])
}
// Unified command execution for both top-level commands and subcommands
// Supports n-depth subcommands, workspaces, and definitions
async execCommandClass (commandInstance, args, commandPath = []) {
const Command = commandInstance.constructor
const commandName = commandPath.join(':')
// Handle subcommands if present
if (Command.subcommands) {
const subcommandName = args[0]
// If help is requested without a subcommand, show main command help
if (this.config.get('usage') && !subcommandName) {
return output.standard(commandInstance.usage)
}
// If no subcommand provided, show usage error
if (!subcommandName) {
throw commandInstance.usageError()
}
// Check if the subcommand exists
const SubCommand = Command.subcommands[subcommandName]
if (!SubCommand) {
throw commandInstance.usageError(`Unknown subcommand: ${subcommandName}`)
}
// Check if help is requested for the subcommand
if (this.config.get('usage')) {
const parentName = commandPath[0]
return output.standard(SubCommand.getUsage(parentName))
}
// Create subcommand instance and recurse
const subcommandInstance = new SubCommand(this)
const subcommandArgs = args.slice(1) // Remove subcommand name from args
const subcommandPath = [...commandPath, subcommandName]
return time.start(`command:${subcommandPath.join(':')}`, () =>
this.execCommandClass(subcommandInstance, subcommandArgs, subcommandPath))
}
// No subcommands - execute this command
if (this.config.get('usage')) {
return output.standard(commandInstance.usage)
}
let execWorkspaces = false
const hasWsConfig = this.config.get('workspaces') || this.config.get('workspace').length
// if cwd is a workspace, the default is set to [that workspace]
const implicitWs = this.config.get('workspace', 'default').length
// (-ws || -w foo) && (cwd is not a workspace || command is not ignoring implicit workspaces)
if (hasWsConfig && (!implicitWs || !Command.ignoreImplicitWorkspace)) {
if (this.global) {
throw new Error('Workspaces not supported for global packages')
}
if (!Command.workspaces) {
throw Object.assign(new Error('This command does not support workspaces.'), {
code: 'ENOWORKSPACES',
})
}
execWorkspaces = true
}
// Check dev engines if needed
if (commandInstance.checkDevEngines && !this.global) {
await commandInstance.checkDevEngines()
}
// Execute command with or without definitions
if (Command.definitions) {
// config.argv contains the full argv with flags (set by Config in production, by MockNpm in tests)
// Pass depth so flags() knows how many command names to skip
const [flags, positionalArgs] = commandInstance.flags(commandPath.length)
return time.start(`command:${commandName}`, () =>
execWorkspaces
? commandInstance.execWorkspaces(positionalArgs, flags)
: commandInstance.exec(positionalArgs, flags))
} else {
// Legacy commands without definitions
this.config.logWarnings()
return time.start(`command:${commandName}`, () =>
execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args))
}
}
// This gets called at the end of the exit handler and during any tests to cleanup all of our listeners
// Everything in here should be synchronous
unload () {
this.#timers.off()
this.#display.off()
this.#logFile.off()
}
finish (err) {
// Finish all our timer work, this will write the file if requested, end timers, etc
this.#timers.finish({
id: this.#runId,
command: this.#argvClean,
logfiles: this.logFiles,
version: this.version,
})
output.flush({
[META]: true,
// json can be set during a command so we send the final value of it to the display layer here
json: this.loaded && this.config.get('json'),
jsonError: jsonError(err, this),
})
}
exitErrorMessage () {
if (this.logFiles.length) {
return `A complete log of this run can be found in: ${this.logFiles}`
}
const logsMax = this.config.get('logs-max')
if (logsMax <= 0) {
// user specified no log file
return `Log files were not written due to the config logs-max=${logsMax}`
}
// could be an error writing to the directory
return `Log files were not written due to an error writing to the directory: ${this.#logsDir}` +
'\nYou can rerun the command with `--loglevel=verbose` to see the logs in your terminal'
}
async #handleError (err) {
if (err) {
// Get the local package if it exists for a more helpful error message
const localPkg = await require('@npmcli/package-json')
.normalize(this.localPrefix)
.then(p => p.content)
.catch(() => null)
Object.assign(err, this.#getError(err, { pkg: localPkg }))
}
this.finish(err)
if (err) {
throw err
}
}
#getError (rawErr, opts) {
const { files = [], ...error } = require('./utils/error-message.js').getError(rawErr, {
npm: this,
command: this.#command,
...opts,
})
const { writeFileSync } = require('node:fs')
for (const [file, content] of files) {
const filePath = `${this.logPath}${file}`
const fileContent = `'Log files:\n${this.logFiles.join('\n')}\n\n${content.trim()}\n`
try {
writeFileSync(filePath, fileContent)
error.detail.push(['', `\n\nFor a full report see:\n${filePath}`])
} catch (fileErr) {
log.warn('', `Could not write error message to ${file} due to ${fileErr}`)
}
}
outputError(error)
return error
}
get title () {
return this.#title
}
get loaded () {
return this.config.loaded
}
get version () {
return this.constructor.version
}
get command () {
return this.#command?.name
}
get flatOptions () {
const { flat } = this.config
flat.nodeVersion = process.version
flat.npmVersion = pkg.version
if (this.command) {
flat.npmCommand = this.command
}
return flat
}
// color and logColor are a special derived values that takes into consideration not only the config, but whether or not we are operating in a tty with the associated output (stdout/stderr)
get color () {
return this.flatOptions.color
}
get logColor () {
return this.flatOptions.logColor
}
get noColorChalk () {
return this.#display.chalk.noColor
}
get chalk () {
return this.#display.chalk.stdout
}
get logChalk () {
return this.#display.chalk.stderr
}
get global () {
return this.config.get('global') || this.config.get('location') === 'global'
}
get silent () {
return this.flatOptions.silent
}
get lockfileVersion () {
return 2
}
get started () {
return this.#timers.started
}
get logFiles () {
return this.#logFile.files
}
get #logsDir () {
return this.config.get('logs-dir') || join(this.cache, '_logs')
}
get logPath () {
return resolve(this.#logsDir, `${this.#runId}-`)
}
get npmRoot () {
return this.#npmRoot
}
get cache () {
return this.config.get('cache')
}
get globalPrefix () {
return this.config.globalPrefix
}
get localPrefix () {
return this.config.localPrefix
}
get localPackage () {
return this.config.localPackage
}
get globalDir () {
return process.platform !== 'win32'
? resolve(this.globalPrefix, 'lib', 'node_modules')
: resolve(this.globalPrefix, 'node_modules')
}
get localDir () {
return resolve(this.localPrefix, 'node_modules')
}
get dir () {
return this.global ? this.globalDir : this.localDir
}
get globalBin () {
const b = this.globalPrefix
return process.platform !== 'win32' ? resolve(b, 'bin') : b
}
get localBin () {
return resolve(this.dir, '.bin')
}
get bin () {
return this.global ? this.globalBin : this.localBin
}
get prefix () {
return this.global ? this.globalPrefix : this.localPrefix
}
get usage () {
return usage(this)
}
}
module.exports = Npm
+63
View File
@@ -0,0 +1,63 @@
const pacote = require('pacote')
const { openUrl } = require('./utils/open-url.js')
const { log } = require('proc-log')
const BaseCommand = require('./base-cmd.js')
// Base command for opening urls from a package manifest (bugs, docs, repo)
class PackageUrlCommand extends BaseCommand {
static params = [
'browser',
'registry',
'workspace',
'workspaces',
'include-workspace-root',
]
static workspaces = true
static ignoreImplicitWorkspace = false
static usage = ['[<pkgname> [<pkgname> ...]]']
async exec (args) {
if (!args || !args.length) {
args = ['.']
}
for (const arg of args) {
// XXX It is very odd that `where` is how pacote knows to look anywhere other than the cwd.
const opts = {
...this.npm.flatOptions,
where: this.npm.localPrefix,
fullMetadata: true,
_isRoot: true,
}
const mani = await pacote.manifest(arg, opts)
const url = this.getUrl(arg, mani)
log.silly(this.name, 'url', url)
await openUrl(this.npm, url, `${mani.name} ${this.name} available at the following URL`)
}
}
async execWorkspaces (args) {
if (args && args.length) {
return this.exec(args)
}
await this.setWorkspaces()
return this.exec(this.workspacePaths)
}
// given a manifest, try to get the hosted git info from it based on repository (if a string) or repository.url (if an object)
// returns null if it's not a valid repo, or not a known hosted repo
hostedFromMani (mani) {
const hostedGitInfo = require('hosted-git-info')
const r = mani.repository
const rurl = !r ? null
: typeof r === 'string' ? r
: typeof r === 'object' && typeof r.url === 'string' ? r.url
: null
// hgi returns undefined sometimes, but let's always return null here
return (rurl && hostedGitInfo.fromUrl(rurl.replace(/^git\+/, ''))) || null
}
}
module.exports = PackageUrlCommand
+345
View File
@@ -0,0 +1,345 @@
const BaseCommand = require('./base-cmd.js')
const { otplease } = require('./utils/auth.js')
const npmFetch = require('npm-registry-fetch')
const npa = require('npm-package-arg')
const { read: _read } = require('read')
const { input, output, log, META } = require('proc-log')
const gitinfo = require('hosted-git-info')
const pkgJson = require('@npmcli/package-json')
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const NPM_FRONTEND = 'https://www.npmjs.com'
const PERMISSIONS = {
CREATE_PACKAGE: 'createPackage',
CREATE_STAGED_PACKAGE: 'createStagedPackage',
}
const trustDefinitions = {
'allow-publish': new Definition('allow-publish', {
default: false,
type: Boolean,
description: 'Allow npm publish for this trusted publisher configuration',
}),
'allow-stage-publish': new Definition('allow-stage-publish', {
default: false,
type: Boolean,
description: 'Allow npm stage publish for this trusted publisher configuration',
alias: ['allow-staged-publish'],
}),
}
class TrustCommand extends BaseCommand {
// Helper to format template strings with color
// Blue text with reset color for interpolated values
warnString (strings, ...values) {
const chalk = this.npm.chalk
const message = strings.reduce((result, str, i) => {
return result + chalk.blue(str) + (values[i] ? chalk.reset(values[i]) : '')
}, '')
return message
}
// Log a warning message with blue formatting
warn (strings, ...values) {
log.warn('trust', this.warnString(strings, ...values))
}
// dialogue is non-log text that is different from our usual npm prefix logging
// it should always show to the user unless --json is specified
// it's not controled by log levels
dialogue (strings, ...values) {
const json = this.config.get('json')
if (!json) {
output.standard(this.warnString(strings, ...values))
}
}
createConfig (pkg, body) {
const spec = npa(pkg)
const uri = `/-/package/${spec.escapedName}/trust`
return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
...opts,
method: 'POST',
body: body,
}))
}
static permissionLabels = {
[PERMISSIONS.CREATE_PACKAGE]: 'publish',
[PERMISSIONS.CREATE_STAGED_PACKAGE]: 'stage publish',
}
static formatPermissions (permissions) {
if (!Array.isArray(permissions) || permissions.length === 0) {
return null
}
return permissions
.map(p => TrustCommand.permissionLabels[p] || p)
.join(', ')
}
logOptions (options, pad = true) {
const { values, warnings, fromPackageJson, urls, permissions } = { warnings: [], ...options }
if (warnings && warnings.length > 0) {
for (const warningMsg of warnings) {
log.warn('trust', warningMsg)
}
}
const json = this.config.get('json')
if (json) {
const jsonValues = { ...options.values }
if (permissions) {
jsonValues.permissions = permissions
}
// Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets
output.standard(JSON.stringify(jsonValues, null, 2), { [META]: true, redact: false })
return
}
const chalk = this.npm.chalk
const { type, id, ...rest } = values || {}
if (values) {
const lines = []
if (type) {
lines.push(`type: ${chalk.green(type)}`)
}
if (id) {
lines.push(`id: ${chalk.green(id)}`)
}
for (const [key, value] of Object.entries(rest)) {
if (value !== null && value !== undefined) {
const parts = [
`${chalk.reset(key)}: ${chalk.green(value)}`,
]
if (fromPackageJson && fromPackageJson[key]) {
parts.push(`(${chalk.yellow(`from package.json`)})`)
}
lines.push(parts.join(' '))
}
}
const formattedPermissions = TrustCommand.formatPermissions(permissions)
if (formattedPermissions) {
lines.push(`${chalk.reset('permissions')}: ${chalk.green(formattedPermissions)}`)
}
if (pad) {
output.standard()
}
output.standard(lines.join('\n'), { [META]: true, redact: false })
// Print URLs on their own lines after config, following the same order as rest keys
if (urls) {
const urlLines = []
for (const key of Object.keys(rest)) {
if (urls[key]) {
urlLines.push(chalk.blue(urls[key]))
}
}
if (urlLines.length > 0) {
output.standard()
output.standard(urlLines.join('\n'), { [META]: true, redact: false })
}
}
if (pad) {
output.standard()
}
}
}
async confirmOperation (yes) {
// Ask for confirmation unless --yes flag is set
if (yes === true) {
return
}
if (yes === false) {
throw new Error('User cancelled operation')
}
const confirm = await input.read(
() => _read({ prompt: 'Do you want to proceed? (y/N) ', default: 'n' })
)
const normalized = confirm.toLowerCase()
if (['y', 'yes'].includes(normalized)) {
return
}
throw new Error('User cancelled operation')
}
getFrontendUrl ({ pkgName }) {
if (this.registryIsDefault) {
return new URL(`/package/${pkgName}`, NPM_FRONTEND).toString()
}
return null
}
getRepositoryFromPackageJson (pkg) {
const info = gitinfo.fromUrl(pkg.repository?.url || pkg?.repository)
if (!info) {
return null
}
const repository = info.user + '/' + info.project
const type = info.type
return { repository, type }
}
async optionalPkgJson () {
try {
const { content } = await pkgJson.normalize(this.npm.prefix)
return content
} catch (err) {
return {}
}
}
get registryIsDefault () {
return this.npm.config.defaults.registry === this.npm.config.get('registry')
}
// generic
static bodyToOptions (body) {
return {
...(body.id) && { id: body.id },
...(body.type) && { type: body.type },
}
}
async createConfigCommand ({ positionalArgs, flags }) {
const { providerName, providerEntity, providerHostname } = this.constructor
const dryRun = this.config.get('dry-run')
const yes = this.config.get('yes') // deep-lore this allows for --no-yes
const allowPublish = flags['allow-publish']
const allowStagePublish = flags['allow-stage-publish']
if (!allowPublish && !allowStagePublish) {
throw new Error('At least one permission flag is required (--allow-publish, --allow-stage-publish)')
}
const permissions = []
if (allowPublish) {
permissions.push(PERMISSIONS.CREATE_PACKAGE)
}
if (allowStagePublish) {
permissions.push(PERMISSIONS.CREATE_STAGED_PACKAGE)
}
const options = await this.flagsToOptions({ positionalArgs, flags, providerHostname })
this.dialogue`Establishing trust between ${options.values.package} package and ${providerName}`
this.dialogue`Anyone with ${providerEntity} write access can publish to ${options.values.package}`
this.dialogue`Two-factor authentication is required for this operation`
if (!this.registryIsDefault) {
this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing`
}
this.logOptions({ ...options, permissions })
if (dryRun) {
return
}
await this.confirmOperation(yes)
const trustConfig = this.constructor.optionsToBody(options.values)
trustConfig.permissions = permissions
const response = await this.createConfig(options.values.package, [trustConfig])
const body = await response.json()
this.dialogue`Trust configuration created successfully for ${options.values.package} with the following settings:`
this.displayResponseBody({ body, packageName: options.values.package })
}
async flagsToOptions ({ positionalArgs, flags, providerHostname }) {
const { entityKey, name, providerEntity, providerFile } = this.constructor
const content = await this.optionalPkgJson()
const pkgPositional = positionalArgs[0]
const pkgJsonName = content.name
const git = this.getRepositoryFromPackageJson(content)
// the provided positional matches package.json name or no positional provided
const matchPkg = (!pkgPositional || pkgPositional === pkgJsonName)
const pkgName = pkgPositional || pkgJsonName
const usedPkgNameFromPkgJson = !pkgPositional && Boolean(pkgJsonName)
const invalidPkgJsonProviderType = matchPkg && git && git?.type !== name
let entity
let entitySource
if (flags[entityKey]) {
entity = flags[entityKey]
entitySource = 'flag'
} else if (!invalidPkgJsonProviderType && git?.repository) {
entity = git.repository
entitySource = 'package.json'
}
const mismatchPkgJsonRepository = matchPkg && git && entity !== git.repository
const usedRepositoryInPkgJson = entitySource === 'package.json'
const warnings = []
if (!pkgName) {
throw new Error('Package name must be specified either as an argument or in package.json file')
}
if (!flags.file) {
throw new Error(`${providerFile} must be specified with the file option`)
}
if (!flags.file.endsWith('.yml') && !flags.file.endsWith('.yaml')) {
throw new Error(`${providerFile} must end in .yml or .yaml`)
}
this.validateFile?.(flags.file)
if (invalidPkgJsonProviderType) {
const message = this.warnString`Repository in package.json is not a ${providerEntity}`
if (!flags[entityKey]) {
throw new Error(message)
} else {
warnings.push(message)
}
} else {
if (mismatchPkgJsonRepository) {
warnings.push(this.warnString`Repository in package.json (${git.repository}) differs from provided ${providerEntity} (${entity})`)
}
}
if (!entity && matchPkg) {
throw new Error(`${providerEntity} must be specified with ${entityKey} option or inferred from the package.json repository field`)
}
if (!entity) {
throw new Error(`${providerEntity} must be specified with ${entityKey} option`)
}
this.validateEntity(entity)
return {
values: {
package: pkgName,
file: flags.file,
[entityKey]: entity,
...(flags.environment && { environment: flags.environment }),
},
fromPackageJson: {
[entityKey]: usedRepositoryInPkgJson,
package: usedPkgNameFromPkgJson,
},
warnings: warnings,
urls: {
package: this.getFrontendUrl({ pkgName }),
[entityKey]: this.getEntityUrl({ providerHostname, entity }),
file: this.getEntityUrl({ providerHostname, entity, file: flags.file }),
},
}
}
displayResponseBody ({ body, packageName }) {
if (!body || body.length === 0) {
this.dialogue`No trust configurations found for package (${packageName})`
return
}
const items = Array.isArray(body) ? body : [body]
for (const config of items) {
const values = this.constructor.bodyToOptions(config)
const permissions = config.permissions
output.standard()
this.logOptions({ values, permissions }, false)
}
output.standard()
}
}
module.exports = TrustCommand
module.exports.NPM_FRONTEND = NPM_FRONTEND
module.exports.trustDefinitions = trustDefinitions
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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,
}
@@ -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
View File
@@ -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,
}
@@ -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
Loaded 100 of 2001 files, more files were not shown because too many files have changed in this diff. Show more