config.js 11 KB

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