codekingpro/portable-devtools
114k
1// 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.
2// 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.
3// These are:
4// COMP_CWORD: the index of the "word" in the command line being completed
5// COMP_LINE: the full command line thus far as a string
6// COMP_POINT: the cursor index at the point of triggering completion
7//
8// We parse the command line with nopt, like npm does, and then create an options object containing:
9// words: array of words in the command line
10// w: the index of the word being completed (ie, COMP_CWORD)
11// word: the word being completed
12// line: the COMP_LINE
13// lineLength
14// 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
15// partialLine: the line up to the point
16// partialWord: the word being completed (which might be ''), up to the point
17// conf: a nopt parse of the command line
18//
19// 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.
20//
21// 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.
22
23const fs = require('node:fs/promises')
24const nopt = require('nopt')
25const { resolve } = require('node:path')
26const { output } = require('proc-log')
27const Npm = require('../npm.js')
28const { definitions, shorthands } = require('@npmcli/config/lib/definitions')
29const { commands, aliases, deref } = require('../utils/cmd-list.js')
30const { isWindowsShell } = require('../utils/is-windows.js')
31const BaseCommand = require('../base-cmd.js')
32
33const fileExists = (file) => fs.stat(file).then(s => s.isFile()).catch(() => false)
34
35class Completion extends BaseCommand {
36 static description = 'Tab Completion for npm'
37 static name = 'completion'
38 // 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
39 static definitions = []
40
41 // completion for the completion command
42 static async completion (opts) {
43 if (opts.w > 2) {
44 return
45 }
46
47 const [bashExists, zshExists] = await Promise.all([
48 fileExists(resolve(process.env.HOME, '.bashrc')),
49 fileExists(resolve(process.env.HOME, '.zshrc')),
50 ])
51 const out = []
52 if (zshExists) {
53 out.push(['>>', '~/.zshrc'])
54 }
55
56 if (bashExists) {
57 out.push(['>>', '~/.bashrc'])
58 }
59
60 return out
61 }
62
63 async exec (args) {
64 if (isWindowsShell) {
65 const msg = 'npm completion supported only in MINGW / Git bash on Windows'
66 throw Object.assign(new Error(msg), {
67 code: 'ENOTSUP',
68 })
69 }
70
71 const { COMP_CWORD, COMP_LINE, COMP_POINT, COMP_FISH } = process.env
72
73 // if the COMP_* isn't in the env, then just dump the script.
74 if (COMP_CWORD === undefined || COMP_LINE === undefined || COMP_POINT === undefined) {
75 return dumpScript(resolve(this.npm.npmRoot, 'lib', 'utils', 'completion.sh'))
76 }
77
78 // 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.
79 // ie, tabbing at: npm foo b|ar
80 const w = +COMP_CWORD
81 const line = COMP_LINE
82 // Use COMP_LINE to get words if args doesn't include flags (e.g., in tests)
83 const hasFlags = line.includes(' -') && !args.some(arg => arg.startsWith('-'))
84 const words = (hasFlags ? line.split(/\s+/) : args).map(unescape)
85 const word = words[w] || ''
86 const point = +COMP_POINT
87 const partialLine = line.slice(0, point)
88 const partialWords = words.slice(0, w)
89
90 // figure out where in that last word the point is.
91 const partialWordRaw = args[w] || ''
92 let i = partialWordRaw.length
93 while (partialWordRaw.slice(0, i) !== partialLine.slice(-1 * i) && i > 0) {
94 i--
95 }
96
97 const partialWord = unescape(partialWordRaw.slice(0, i))
98 partialWords.push(partialWord)
99
100 const opts = {
101 isFish: COMP_FISH === 'true',
102 words,
103 w,
104 word,
105 line,
106 lineLength: line.length,
107 point,
108 partialLine,
109 partialWords,
110 partialWord,
111 raw: args,
112 }
113
114 // try to find the npm command and subcommand early for flag completion this helps with custom command definitions from subcommands
115 const types = Object.entries(definitions).reduce((acc, [key, def]) => {
116 acc[key] = def.type
117 return acc
118 }, {})
119 const parsed = opts.conf =
120 nopt(types, shorthands, partialWords.slice(0, -1), 0)
121 const cmd = parsed.argv.remain[1]
122 const subCmd = parsed.argv.remain[2]
123
124 if (partialWords.slice(0, -1).indexOf('--') === -1) {
125 if (word && word.charAt(0) === '-') {
126 return this.wrap(opts, configCompl(opts, cmd, subCmd, this.npm))
127 }
128
129 if (words[w - 1] &&
130 words[w - 1].charAt(0) === '-' &&
131 !isFlag(words[w - 1], cmd, subCmd, this.npm)) {
132 // awaiting a value for a non-bool config.
133 // don't even try to do this for now
134 return this.wrap(opts, configValueCompl(opts))
135 }
136 }
137
138 // check if there's a command already.
139 if (!cmd) {
140 return this.wrap(opts, cmdCompl(opts, this.npm))
141 }
142
143 Object.keys(parsed).forEach(k => this.npm.config.set(k, parsed[k]))
144
145 // at this point, if words[1] is some kind of npm command, then complete on it.
146 // otherwise, do nothing
147 try {
148 const { completion } = Npm.cmd(cmd)
149 if (completion) {
150 const comps = await completion(opts, this.npm)
151 return this.wrap(opts, comps)
152 }
153 } catch {
154 // it wasn't a valid command, so do nothing
155 }
156 }
157
158 // The command should respond with an array.
159 // Loop over that, wrapping quotes around any that have spaces, and writing them to stdout.
160 // If any of the items are arrays, then join them with a space.
161 // e.g. returning ['a', 'b c', ['d', 'e']] would allow it to expand to: 'a', 'b c', or 'd' 'e'
162 wrap (opts, compls) {
163 if (opts.partialWord) {
164 compls = compls.filter(c => c.startsWith(opts.partialWord))
165 }
166
167 if (compls.length > 0) {
168 output.standard(compls.join('\n'))
169 }
170 }
171}
172
173const dumpScript = async (p) => {
174 const d = (await fs.readFile(p, 'utf8')).replace(/^#!.*?\n/, '')
175 await new Promise((res, rej) => {
176 let done = false
177 process.stdout.on('error', er => {
178 if (done) {
179 return
180 }
181
182 done = true
183
184 // Darwin is a pain sometimes.
185 //
186 // 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.
187 //
188 // Really, one should not be tossing away EPIPE errors, or any errors, so casually.
189 // But, without this, `. <(npm completion)` can never ever work on OS X.
190 // TODO Ignoring coverage, see 'non EPIPE errors cause failures' test.
191 /* istanbul ignore next */
192 if (er.errno === 'EPIPE') {
193 res()
194 } else {
195 rej(er)
196 }
197 })
198
199 process.stdout.write(d, () => {
200 if (done) {
201 return
202 }
203
204 done = true
205 res()
206 })
207 })
208}
209
210const unescape = w => w.charAt(0) === '\'' ? w.replace(/^'|'$/g, '')
211 : w.replace(/\\ /g, ' ')
212
213// Helper to get custom definitions from a command/subcommand
214const getCustomDefinitions = (cmd, subCmd) => {
215 if (!cmd) {
216 return []
217 }
218
219 try {
220 const command = Npm.cmd(cmd)
221
222 // Check if the command has subcommands
223 if (subCmd && command.subcommands && command.subcommands[subCmd]) {
224 const subcommand = command.subcommands[subCmd]
225 // All subcommands have definitions
226 return subcommand.definitions
227 }
228
229 // Check if the command itself has definitions
230 if (command.definitions) {
231 return command.definitions
232 }
233 } catch {
234 // Command not found or no definitions
235 }
236
237 return []
238}
239
240// Helper to get all config names including aliases from custom definitions
241const getCustomConfigNames = (customDefs) => {
242 const names = new Set()
243 for (const def of customDefs) {
244 names.add(def.key)
245 if (def.alias && Array.isArray(def.alias)) {
246 def.alias.forEach(a => names.add(a))
247 }
248 }
249 return [...names]
250}
251
252// the current word has a dash.
253// Return the config names with the same number of dashes as the current word has.
254const configCompl = (opts, cmd, subCmd, npm) => {
255 const word = opts.word
256 const split = word.match(/^(-+)((?:no-)*)(.*)$/)
257 const dashes = split[1]
258 const no = split[2]
259
260 // Get custom definitions from the command/subcommand
261 const customDefs = getCustomDefinitions(cmd, subCmd, npm)
262 const customNames = getCustomConfigNames(customDefs)
263
264 // If there are custom definitions, return only those (new feature)
265 // Otherwise, return empty array (historical behavior - no global flag completion)
266 if (customNames.length > 0) {
267 const flags = customNames.filter(name => isFlag(name, cmd, subCmd, npm))
268 return customNames.map(c => dashes + c)
269 .concat(flags.map(f => dashes + (no || 'no-') + f))
270 }
271
272 return []
273}
274
275// expand with the valid values of various config values.
276// not yet implemented.
277const configValueCompl = () => []
278
279// check if the thing is a flag or not.
280const isFlag = (word, cmd, subCmd, npm) => {
281 // shorthands never take args.
282 const split = word.match(/^(-*)((?:no-)+)?(.*)$/)
283 const no = split[2]
284 const conf = split[3]
285
286 // Check custom definitions first
287 const customDefs = getCustomDefinitions(cmd, subCmd, npm)
288
289 // Check if conf is in custom definitions or is an alias
290 let customDef = customDefs.find(d => d.key === conf)
291 if (!customDef) {
292 // Check if conf is an alias for any of the custom definitions
293 for (const def of customDefs) {
294 if (def.alias && Array.isArray(def.alias) && def.alias.includes(conf)) {
295 customDef = def
296 break
297 }
298 }
299 }
300
301 if (customDef) {
302 const { type } = customDef
303 return no ||
304 type === Boolean ||
305 (Array.isArray(type) && type.includes(Boolean))
306 }
307
308 // No custom definitions found, should not reach here in normal flow since configCompl returns empty array when no custom defs exist
309 return false
310}
311
312// complete against the npm commands
313// if they all resolve to the same thing, just return the thing it already is
314const cmdCompl = (opts) => {
315 const allCommands = commands.concat(Object.keys(aliases))
316 const matches = allCommands.filter(c => c.startsWith(opts.partialWord))
317 if (!matches.length) {
318 return matches
319 }
320
321 const derefs = new Set([...matches.map(c => deref(c))])
322 if (derefs.size === 1) {
323 return [...derefs]
324 }
325
326 return allCommands
327}
328
329module.exports = Completion
330 