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