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