base-cmd.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. const { log } = require('proc-log')
  2. const { definitions, shorthands } = require('@npmcli/config/lib/definitions')
  3. const nopt = require('nopt')
  4. class BaseCommand {
  5. // these defaults can be overridden by individual commands
  6. static workspaces = false
  7. static ignoreImplicitWorkspace = true
  8. static checkDevEngines = false
  9. // these should always be overridden by individual commands
  10. static name = null
  11. static description = null
  12. static params = null
  13. static definitions = null
  14. static subcommands = null
  15. // Number of expected positional arguments (null = unlimited/unchecked)
  16. static positionals = null
  17. // this is a static so that we can read from it without instantiating a command which would require loading the config
  18. static get describeUsage () {
  19. return this.getUsage()
  20. }
  21. static getUsage (parentName = null, includeDescriptions = true) {
  22. const { aliases: cmdAliases } = require('./utils/cmd-list')
  23. const seenExclusive = new Set()
  24. const wrapWidth = 80
  25. const { description, usage = [''], name } = this
  26. // Resolve to a definitions array: if the command has its own definitions, use those directly; otherwise resolve params from the global definitions pool.
  27. let cmdDefs
  28. if (this.definitions) {
  29. cmdDefs = this.definitions
  30. } else if (this.params) {
  31. cmdDefs = this.params.map(p => definitions[p]).filter(Boolean)
  32. }
  33. // If this is a subcommand, prepend parent name
  34. const fullCommandName = parentName ? `${parentName} ${name}` : name
  35. const fullUsage = [
  36. `${description}`,
  37. '',
  38. 'Usage:',
  39. ]
  40. if (usage) {
  41. fullUsage.push(...usage.map(u => `npm ${fullCommandName} ${u}`.trim()))
  42. }
  43. if (this.subcommands) {
  44. for (const sub in this.subcommands) {
  45. fullUsage.push(`npm ${fullCommandName} ${sub} ${this.subcommands[sub].usage}`)
  46. }
  47. fullUsage.push('')
  48. fullUsage.push('Subcommands:')
  49. const subcommandEntries = Object.entries(this.subcommands)
  50. for (let i = 0; i < subcommandEntries.length; i++) {
  51. const [subName, SubCommand] = subcommandEntries[i]
  52. fullUsage.push(` ${subName}`)
  53. if (SubCommand.description) {
  54. fullUsage.push(` ${SubCommand.description}`)
  55. }
  56. // Add space between subcommands except after the last one
  57. if (i < subcommandEntries.length - 1) {
  58. fullUsage.push('')
  59. }
  60. }
  61. fullUsage.push('')
  62. fullUsage.push(`Run "npm ${name} <subcommand> --help" for more info on a subcommand.`)
  63. }
  64. if (cmdDefs) {
  65. let results = ''
  66. let line = ''
  67. for (const def of cmdDefs) {
  68. /* istanbul ignore next */
  69. if (seenExclusive.has(def.key)) {
  70. continue
  71. }
  72. let paramUsage = def.usage
  73. if (def.exclusive) {
  74. const exclusiveParams = [paramUsage]
  75. for (const e of def.exclusive) {
  76. seenExclusive.add(e)
  77. const eDef = cmdDefs.find(d => d.key === e) || definitions[e]
  78. exclusiveParams.push(eDef?.usage)
  79. }
  80. paramUsage = `${exclusiveParams.join('|')}`
  81. }
  82. paramUsage = `[${paramUsage}]`
  83. if (line.length + paramUsage.length > wrapWidth) {
  84. results = [results, line].filter(Boolean).join('\n')
  85. line = ''
  86. }
  87. line = [line, paramUsage].filter(Boolean).join(' ')
  88. }
  89. fullUsage.push('')
  90. fullUsage.push('Options:')
  91. fullUsage.push([results, line].filter(Boolean).join('\n'))
  92. // Add flag descriptions
  93. if (cmdDefs.length > 0 && includeDescriptions) {
  94. fullUsage.push('')
  95. for (const def of cmdDefs) {
  96. if (def.description) {
  97. const desc = def.description.trim().split('\n')[0]
  98. const shortcuts = def.short ? `-${def.short}` : ''
  99. const aliases = (def.alias || []).map(v => `--${v}`).join('|')
  100. const mainFlag = `--${def.key}`
  101. const flagName = [shortcuts, mainFlag, aliases].filter(Boolean).join('|')
  102. const requiredNote = def.required ? ' (required)' : ''
  103. fullUsage.push(` ${flagName}${requiredNote}`)
  104. fullUsage.push(` ${desc}`)
  105. fullUsage.push('')
  106. }
  107. }
  108. }
  109. }
  110. const aliases = Object.entries(cmdAliases).reduce((p, [k, v]) => {
  111. return p.concat(v === name ? k : [])
  112. }, [])
  113. if (aliases.length) {
  114. const plural = aliases.length === 1 ? '' : 'es'
  115. fullUsage.push('')
  116. fullUsage.push(`alias${plural}: ${aliases.join(', ')}`)
  117. }
  118. fullUsage.push('')
  119. fullUsage.push(`Run "npm help ${name}" for more info`)
  120. return fullUsage.join('\n')
  121. }
  122. constructor (npm) {
  123. this.npm = npm
  124. this.commandArgs = null
  125. const { config } = this
  126. if (!this.constructor.skipConfigValidation) {
  127. config.validate()
  128. }
  129. if (config.get('workspaces') === false && config.get('workspace').length) {
  130. throw new Error('Cannot use --no-workspaces and --workspace at the same time')
  131. }
  132. }
  133. get config () {
  134. // Return command-specific config if it exists, otherwise use npm's config
  135. return this.npm.config
  136. }
  137. get name () {
  138. return this.constructor.name
  139. }
  140. get description () {
  141. return this.constructor.description
  142. }
  143. get params () {
  144. return this.constructor.params
  145. }
  146. get usage () {
  147. return this.constructor.describeUsage
  148. }
  149. usageError (prefix = '') {
  150. if (prefix) {
  151. prefix += '\n\n'
  152. }
  153. return Object.assign(new Error(`\n${prefix}${this.usage}`), {
  154. code: 'EUSAGE',
  155. })
  156. }
  157. // Compare the number of entries with what was expected
  158. checkExpected (entries) {
  159. if (!this.npm.config.isDefault('expect-results')) {
  160. const expected = this.npm.config.get('expect-results')
  161. if (!!entries !== !!expected) {
  162. log.warn(this.name, `Expected ${expected ? '' : 'no '}results, got ${entries}`)
  163. process.exitCode = 1
  164. }
  165. } else if (!this.npm.config.isDefault('expect-result-count')) {
  166. const expected = this.npm.config.get('expect-result-count')
  167. if (expected !== entries) {
  168. log.warn(this.name, `Expected ${expected} result${expected === 1 ? '' : 's'}, got ${entries}`)
  169. process.exitCode = 1
  170. }
  171. }
  172. }
  173. // Checks the devEngines entry in the package.json at this.localPrefix
  174. async checkDevEngines () {
  175. const force = this.npm.flatOptions.force
  176. const { devEngines } = await require('@npmcli/package-json')
  177. .normalize(this.npm.config.localPrefix)
  178. .then(p => p.content)
  179. .catch(() => ({}))
  180. if (typeof devEngines === 'undefined') {
  181. return
  182. }
  183. const { checkDevEngines, currentEnv } = require('npm-install-checks')
  184. const current = currentEnv.devEngines({
  185. nodeVersion: this.npm.nodeVersion,
  186. npmVersion: this.npm.version,
  187. })
  188. const failures = checkDevEngines(devEngines, current)
  189. const warnings = failures.filter(f => f.isWarn)
  190. const errors = failures.filter(f => f.isError)
  191. const genMsg = (failure, i = 0) => {
  192. return [...new Set([
  193. // eslint-disable-next-line
  194. i === 0 ? 'The developer of this package has specified the following through devEngines' : '',
  195. `${failure.message}`,
  196. `${failure.errors.map(e => e.message).join('\n')}`,
  197. ])].filter(v => v).join('\n')
  198. }
  199. [...warnings, ...(force ? errors : [])].forEach((failure, i) => {
  200. const message = genMsg(failure, i)
  201. log.warn('EBADDEVENGINES', message)
  202. log.warn('EBADDEVENGINES', {
  203. current: failure.current,
  204. required: failure.required,
  205. })
  206. })
  207. if (force) {
  208. return
  209. }
  210. if (errors.length) {
  211. const failure = errors[0]
  212. const message = genMsg(failure)
  213. throw Object.assign(new Error(message), {
  214. engine: failure.engine,
  215. code: 'EBADDEVENGINES',
  216. current: failure.current,
  217. required: failure.required,
  218. })
  219. }
  220. }
  221. async setWorkspaces () {
  222. const { relative } = require('node:path')
  223. const includeWorkspaceRoot = this.isArboristCmd
  224. ? false
  225. : this.npm.config.get('include-workspace-root')
  226. const prefixInsideCwd = relative(this.npm.localPrefix, process.cwd()).startsWith('..')
  227. const relativeFrom = prefixInsideCwd ? this.npm.localPrefix : process.cwd()
  228. const filters = this.npm.config.get('workspace')
  229. const getWorkspaces = require('./utils/get-workspaces.js')
  230. const ws = await getWorkspaces(filters, {
  231. path: this.npm.localPrefix,
  232. includeWorkspaceRoot,
  233. relativeFrom,
  234. })
  235. this.workspaces = ws
  236. this.workspaceNames = [...ws.keys()]
  237. this.workspacePaths = [...ws.values()]
  238. }
  239. flags (depth = 1) {
  240. const commandDefinitions = this.constructor.definitions || []
  241. // Build types, shorthands, and defaults from definitions
  242. const types = {}
  243. const defaults = {}
  244. const cmdShorthands = {}
  245. const aliasMap = {} // Track which aliases map to which main keys
  246. for (const def of commandDefinitions) {
  247. defaults[def.key] = def.default
  248. types[def.key] = def.type
  249. // Handle aliases defined in the definition
  250. if (def.alias && Array.isArray(def.alias)) {
  251. for (const aliasKey of def.alias) {
  252. types[aliasKey] = def.type // Needed for nopt to parse aliases
  253. if (!aliasMap[def.key]) {
  254. aliasMap[def.key] = []
  255. }
  256. aliasMap[def.key].push(aliasKey)
  257. }
  258. }
  259. // Handle short options
  260. if (def.short) {
  261. const shorts = Array.isArray(def.short) ? def.short : [def.short]
  262. for (const short of shorts) {
  263. cmdShorthands[short] = [`--${def.key}`]
  264. }
  265. }
  266. }
  267. // Parse args
  268. let parsed = {}
  269. let remains = []
  270. const argv = this.config.argv
  271. if (argv && argv.length > 0) {
  272. // config.argv contains the full command line including node, npm, and command names
  273. // Format: ['node', 'npm', 'command', 'subcommand', 'positional', '--flags']
  274. // depth tells us how many command names to skip (1 for top-level, 2 for subcommand, etc.)
  275. const offset = 2 + depth // Skip 'node', 'npm', and all command/subcommand names
  276. parsed = nopt(types, cmdShorthands, argv, offset)
  277. remains = parsed.argv.remain
  278. delete parsed.argv
  279. }
  280. // Validate flags - only if command has definitions (new system)
  281. if (this.constructor.definitions && this.constructor.definitions.length > 0) {
  282. this.#validateFlags(parsed, commandDefinitions, remains)
  283. }
  284. // Check for conflicts between main flags and their aliases
  285. // Also map aliases back to their main keys
  286. for (const [mainKey, aliases] of Object.entries(aliasMap)) {
  287. const providedKeys = []
  288. if (mainKey in parsed) {
  289. providedKeys.push(mainKey)
  290. }
  291. for (const alias of aliases) {
  292. if (alias in parsed) {
  293. providedKeys.push(alias)
  294. }
  295. }
  296. if (providedKeys.length > 1) {
  297. const flagList = providedKeys.map(k => `--${k}`).join(' or ')
  298. throw new Error(`Please provide only one of ${flagList}`)
  299. }
  300. // If an alias was provided, map it to the main key
  301. if (providedKeys.length === 1 && providedKeys[0] !== mainKey) {
  302. const aliasKey = providedKeys[0]
  303. parsed[mainKey] = parsed[aliasKey]
  304. delete parsed[aliasKey]
  305. }
  306. }
  307. // Only include keys that are defined in commandDefinitions (main keys only)
  308. const filtered = {}
  309. for (const def of commandDefinitions) {
  310. if (def.key in parsed) {
  311. filtered[def.key] = parsed[def.key]
  312. }
  313. }
  314. return [{ ...defaults, ...filtered }, remains]
  315. }
  316. // Validate flags and throw errors for unknown flags or unexpected positionals
  317. #validateFlags (parsed, commandDefinitions, remains) {
  318. // Build a set of all valid flag names (global + command-specific + shorthands)
  319. const validFlags = new Set([
  320. ...Object.keys(definitions),
  321. ...commandDefinitions.map(d => d.key),
  322. ...Object.keys(shorthands), // Add global shorthands like 'verbose', 'dd', etc.
  323. ])
  324. // Add aliases to valid flags
  325. for (const def of commandDefinitions) {
  326. if (def.alias && Array.isArray(def.alias)) {
  327. for (const alias of def.alias) {
  328. validFlags.add(alias)
  329. }
  330. }
  331. }
  332. // Check parsed flags against valid flags
  333. const unknownFlags = []
  334. for (const key of Object.keys(parsed)) {
  335. if (!validFlags.has(key)) {
  336. unknownFlags.push(key)
  337. }
  338. }
  339. // Throw error if unknown flags were found
  340. if (unknownFlags.length > 0) {
  341. const flagList = unknownFlags.map(f => `--${f}`).join(', ')
  342. throw this.usageError(`Unknown flag${unknownFlags.length > 1 ? 's' : ''}: ${flagList}`)
  343. }
  344. // Remove warnings for command-specific definitions that npm's global config doesn't know about (these were queued as "unknown" during config.load())
  345. for (const def of commandDefinitions) {
  346. this.npm.config.removeWarning(def.key)
  347. if (def.alias && Array.isArray(def.alias)) {
  348. for (const alias of def.alias) {
  349. this.npm.config.removeWarning(alias)
  350. }
  351. }
  352. }
  353. // 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)
  354. const remainsSet = new Set(remains)
  355. for (const unknownPos of this.npm.config.getUnknownPositionals()) {
  356. if (!remainsSet.has(unknownPos)) {
  357. // This value was consumed as a flag value, not truly a positional
  358. this.npm.config.removeUnknownPositional(unknownPos)
  359. }
  360. }
  361. // Warn about extra positional arguments beyond what the command expects
  362. const expectedPositionals = this.constructor.positionals
  363. if (expectedPositionals !== null && remains.length > expectedPositionals) {
  364. const extraPositionals = remains.slice(expectedPositionals)
  365. for (const extra of extraPositionals) {
  366. throw new Error(`Unknown positional argument: ${extra}`)
  367. }
  368. }
  369. this.npm.config.logWarnings()
  370. }
  371. async exec () {
  372. // This method should be overridden by commands
  373. // Subcommand routing is handled in npm.js #exec
  374. }
  375. }
  376. module.exports = BaseCommand