• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const { mkdir, readFile, writeFile } = require('fs/promises')
2const { dirname, resolve } = require('path')
3const { spawn } = require('child_process')
4const { EOL } = require('os')
5const ini = require('ini')
6const localeCompare = require('@isaacs/string-locale-compare')('en')
7const pkgJson = require('@npmcli/package-json')
8const { defaults, definitions } = require('@npmcli/config/lib/definitions')
9const log = require('../utils/log-shim.js')
10
11// These are the configs that we can nerf-dart. Not all of them currently even
12// *have* config definitions so we have to explicitly validate them here
13const nerfDarts = [
14  '_auth',
15  '_authToken',
16  'username',
17  '_password',
18  'email',
19  'certfile',
20  'keyfile',
21]
22
23// take an array of `[key, value, k2=v2, k3, v3, ...]` and turn into
24// { key: value, k2: v2, k3: v3 }
25const keyValues = args => {
26  const kv = {}
27  for (let i = 0; i < args.length; i++) {
28    const arg = args[i].split('=')
29    const key = arg.shift()
30    const val = arg.length ? arg.join('=')
31      : i < args.length - 1 ? args[++i]
32      : ''
33    kv[key.trim()] = val.trim()
34  }
35  return kv
36}
37
38const publicVar = k => {
39  // _password
40  if (k.startsWith('_')) {
41    return false
42  }
43  // //localhost:8080/:_password
44  if (k.startsWith('//') && k.includes(':_')) {
45    return false
46  }
47  return true
48}
49
50const BaseCommand = require('../base-command.js')
51class Config extends BaseCommand {
52  static description = 'Manage the npm configuration files'
53  static name = 'config'
54  static usage = [
55    'set <key>=<value> [<key>=<value> ...]',
56    'get [<key> [<key> ...]]',
57    'delete <key> [<key> ...]',
58    'list [--json]',
59    'edit',
60    'fix',
61  ]
62
63  static params = [
64    'json',
65    'global',
66    'editor',
67    'location',
68    'long',
69  ]
70
71  static ignoreImplicitWorkspace = false
72
73  static skipConfigValidation = true
74
75  static async completion (opts) {
76    const argv = opts.conf.argv.remain
77    if (argv[1] !== 'config') {
78      argv.unshift('config')
79    }
80
81    if (argv.length === 2) {
82      const cmds = ['get', 'set', 'delete', 'ls', 'rm', 'edit', 'fix']
83      if (opts.partialWord !== 'l') {
84        cmds.push('list')
85      }
86
87      return cmds
88    }
89
90    const action = argv[2]
91    switch (action) {
92      case 'set':
93        // todo: complete with valid values, if possible.
94        if (argv.length > 3) {
95          return []
96        }
97
98        // fallthrough
99        /* eslint no-fallthrough:0 */
100      case 'get':
101      case 'delete':
102      case 'rm':
103        return Object.keys(definitions)
104      case 'edit':
105      case 'list':
106      case 'ls':
107      case 'fix':
108      default:
109        return []
110    }
111  }
112
113  async exec ([action, ...args]) {
114    log.disableProgress()
115    try {
116      switch (action) {
117        case 'set':
118          await this.set(args)
119          break
120        case 'get':
121          await this.get(args)
122          break
123        case 'delete':
124        case 'rm':
125        case 'del':
126          await this.del(args)
127          break
128        case 'list':
129        case 'ls':
130          await (this.npm.flatOptions.json ? this.listJson() : this.list())
131          break
132        case 'edit':
133          await this.edit()
134          break
135        case 'fix':
136          await this.fix()
137          break
138        default:
139          throw this.usageError()
140      }
141    } finally {
142      log.enableProgress()
143    }
144  }
145
146  async set (args) {
147    if (!args.length) {
148      throw this.usageError()
149    }
150
151    const where = this.npm.flatOptions.location
152    for (const [key, val] of Object.entries(keyValues(args))) {
153      log.info('config', 'set %j %j', key, val)
154      const baseKey = key.split(':').pop()
155      if (!this.npm.config.definitions[baseKey] && !nerfDarts.includes(baseKey)) {
156        throw new Error(`\`${baseKey}\` is not a valid npm option`)
157      }
158      const deprecated = this.npm.config.definitions[baseKey]?.deprecated
159      if (deprecated) {
160        throw new Error(
161          `The \`${baseKey}\` option is deprecated, and can not be set in this way${deprecated}`
162        )
163      }
164
165      if (val === '') {
166        this.npm.config.delete(key, where)
167      } else {
168        this.npm.config.set(key, val, where)
169      }
170
171      if (!this.npm.config.validate(where)) {
172        log.warn('config', 'omitting invalid config values')
173      }
174    }
175
176    await this.npm.config.save(where)
177  }
178
179  async get (keys) {
180    if (!keys.length) {
181      return this.list()
182    }
183
184    const out = []
185    for (const key of keys) {
186      if (!publicVar(key)) {
187        throw new Error(`The ${key} option is protected, and can not be retrieved in this way`)
188      }
189
190      const pref = keys.length > 1 ? `${key}=` : ''
191      out.push(pref + this.npm.config.get(key))
192    }
193    this.npm.output(out.join('\n'))
194  }
195
196  async del (keys) {
197    if (!keys.length) {
198      throw this.usageError()
199    }
200
201    const where = this.npm.flatOptions.location
202    for (const key of keys) {
203      this.npm.config.delete(key, where)
204    }
205    await this.npm.config.save(where)
206  }
207
208  async edit () {
209    const e = this.npm.flatOptions.editor
210    const where = this.npm.flatOptions.location
211    const file = this.npm.config.data.get(where).source
212
213    // save first, just to make sure it's synced up
214    // this also removes all the comments from the last time we edited it.
215    await this.npm.config.save(where)
216
217    const data = (
218      await readFile(file, 'utf8').catch(() => '')
219    ).replace(/\r\n/g, '\n')
220    const entries = Object.entries(defaults)
221    const defData = entries.reduce((str, [key, val]) => {
222      const obj = { [key]: val }
223      const i = ini.stringify(obj)
224        .replace(/\r\n/g, '\n') // normalizes output from ini.stringify
225        .replace(/\n$/m, '')
226        .replace(/^/g, '; ')
227        .replace(/\n/g, '\n; ')
228        .split('\n')
229      return str + '\n' + i
230    }, '')
231
232    const tmpData = `;;;;
233; npm ${where}config file: ${file}
234; this is a simple ini-formatted file
235; lines that start with semi-colons are comments
236; run \`npm help 7 config\` for documentation of the various options
237;
238; Configs like \`@scope:registry\` map a scope to a given registry url.
239;
240; Configs like \`//<hostname>/:_authToken\` are auth that is restricted
241; to the registry host specified.
242
243${data.split('\n').sort(localeCompare).join('\n').trim()}
244
245;;;;
246; all available options shown below with default values
247;;;;
248
249${defData}
250`.split('\n').join(EOL)
251    await mkdir(dirname(file), { recursive: true })
252    await writeFile(file, tmpData, 'utf8')
253    await new Promise((res, rej) => {
254      const [bin, ...args] = e.split(/\s+/)
255      const editor = spawn(bin, [...args, file], { stdio: 'inherit' })
256      editor.on('exit', (code) => {
257        if (code) {
258          return rej(new Error(`editor process exited with code: ${code}`))
259        }
260        return res()
261      })
262    })
263  }
264
265  async fix () {
266    let problems
267
268    try {
269      this.npm.config.validate()
270      return // if validate doesn't throw we have nothing to do
271    } catch (err) {
272      // coverage skipped because we don't need to test rethrowing errors
273      // istanbul ignore next
274      if (err.code !== 'ERR_INVALID_AUTH') {
275        throw err
276      }
277
278      problems = err.problems
279    }
280
281    if (!this.npm.config.isDefault('location')) {
282      problems = problems.filter((problem) => {
283        return problem.where === this.npm.config.get('location')
284      })
285    }
286
287    this.npm.config.repair(problems)
288    const locations = []
289
290    this.npm.output('The following configuration problems have been repaired:\n')
291    const summary = problems.map(({ action, from, to, key, where }) => {
292      // coverage disabled for else branch because it is intentionally omitted
293      // istanbul ignore else
294      if (action === 'rename') {
295        // we keep track of which configs were modified here so we know what to save later
296        locations.push(where)
297        return `~ \`${from}\` renamed to \`${to}\` in ${where} config`
298      } else if (action === 'delete') {
299        locations.push(where)
300        return `- \`${key}\` deleted from ${where} config`
301      }
302    }).join('\n')
303    this.npm.output(summary)
304
305    return await Promise.all(locations.map((location) => this.npm.config.save(location)))
306  }
307
308  async list () {
309    const msg = []
310    // long does not have a flattener
311    const long = this.npm.config.get('long')
312    for (const [where, { data, source }] of this.npm.config.data.entries()) {
313      if (where === 'default' && !long) {
314        continue
315      }
316
317      const keys = Object.keys(data).sort(localeCompare)
318      if (!keys.length) {
319        continue
320      }
321
322      msg.push(`; "${where}" config from ${source}`, '')
323      for (const k of keys) {
324        const v = publicVar(k) ? JSON.stringify(data[k]) : '(protected)'
325        const src = this.npm.config.find(k)
326        const overridden = src !== where
327        msg.push((overridden ? '; ' : '') +
328          `${k} = ${v} ${overridden ? `; overridden by ${src}` : ''}`)
329      }
330      msg.push('')
331    }
332
333    if (!long) {
334      msg.push(
335        `; node bin location = ${process.execPath}`,
336        `; node version = ${process.version}`,
337        `; npm local prefix = ${this.npm.localPrefix}`,
338        `; npm version = ${this.npm.version}`,
339        `; cwd = ${process.cwd()}`,
340        `; HOME = ${process.env.HOME}`,
341        '; Run `npm config ls -l` to show all defaults.'
342      )
343      msg.push('')
344    }
345
346    if (!this.npm.global) {
347      const { content } = await pkgJson.normalize(this.npm.prefix).catch(() => ({ content: {} }))
348
349      if (content.publishConfig) {
350        const pkgPath = resolve(this.npm.prefix, 'package.json')
351        msg.push(`; "publishConfig" from ${pkgPath}`)
352        msg.push('; This set of config values will be used at publish-time.', '')
353        const pkgKeys = Object.keys(content.publishConfig).sort(localeCompare)
354        for (const k of pkgKeys) {
355          const v = publicVar(k) ? JSON.stringify(content.publishConfig[k]) : '(protected)'
356          msg.push(`${k} = ${v}`)
357        }
358        msg.push('')
359      }
360    }
361
362    this.npm.output(msg.join('\n').trim())
363  }
364
365  async listJson () {
366    const publicConf = {}
367    for (const key in this.npm.config.list[0]) {
368      if (!publicVar(key)) {
369        continue
370      }
371
372      publicConf[key] = this.npm.config.get(key)
373    }
374    this.npm.output(JSON.stringify(publicConf, null, 2))
375  }
376}
377
378module.exports = Config
379