1// class that describes a config key we know about 2// this keeps us from defining a config key and not 3// providing a default, description, etc. 4// 5// TODO: some kind of categorization system, so we can 6// say "these are for registry access", "these are for 7// version resolution" etc. 8 9const required = ['type', 'description', 'default', 'key'] 10 11const allowed = [ 12 'default', 13 'defaultDescription', 14 'deprecated', 15 'description', 16 'exclusive', 17 'flatten', 18 'hint', 19 'key', 20 'short', 21 'type', 22 'typeDescription', 23 'usage', 24 'envExport', 25] 26 27const { 28 semver: { type: semver }, 29 Umask: { type: Umask }, 30 url: { type: url }, 31 path: { type: path }, 32} = require('../type-defs.js') 33 34class Definition { 35 constructor (key, def) { 36 this.key = key 37 // if it's set falsey, don't export it, otherwise we do by default 38 this.envExport = true 39 Object.assign(this, def) 40 this.validate() 41 if (!this.defaultDescription) { 42 this.defaultDescription = describeValue(this.default) 43 } 44 if (!this.typeDescription) { 45 this.typeDescription = describeType(this.type) 46 } 47 // hint is only used for non-boolean values 48 if (!this.hint) { 49 if (this.type === Number) { 50 this.hint = '<number>' 51 } else { 52 this.hint = `<${this.key}>` 53 } 54 } 55 if (!this.usage) { 56 this.usage = describeUsage(this) 57 } 58 } 59 60 validate () { 61 for (const req of required) { 62 if (!Object.prototype.hasOwnProperty.call(this, req)) { 63 throw new Error(`config lacks ${req}: ${this.key}`) 64 } 65 } 66 if (!this.key) { 67 throw new Error(`config lacks key: ${this.key}`) 68 } 69 for (const field of Object.keys(this)) { 70 if (!allowed.includes(field)) { 71 throw new Error(`config defines unknown field ${field}: ${this.key}`) 72 } 73 } 74 } 75 76 // a textual description of this config, suitable for help output 77 describe () { 78 const description = unindent(this.description) 79 const noEnvExport = this.envExport 80 ? '' 81 : ` 82This value is not exported to the environment for child processes. 83` 84 const deprecated = !this.deprecated ? '' : `* DEPRECATED: ${unindent(this.deprecated)}\n` 85 /* eslint-disable-next-line max-len */ 86 const exclusive = !this.exclusive ? '' : `\nThis config can not be used with: \`${this.exclusive.join('`, `')}\`` 87 return wrapAll(`#### \`${this.key}\` 88 89* Default: ${unindent(this.defaultDescription)} 90* Type: ${unindent(this.typeDescription)} 91${deprecated} 92${description} 93${exclusive} 94${noEnvExport}`) 95 } 96} 97 98const describeUsage = def => { 99 let key = '' 100 101 // Single type 102 if (!Array.isArray(def.type)) { 103 if (def.short) { 104 key = `-${def.short}|` 105 } 106 107 if (def.type === Boolean && def.default !== false) { 108 key = `${key}--no-${def.key}` 109 } else { 110 key = `${key}--${def.key}` 111 } 112 113 if (def.type !== Boolean) { 114 key = `${key} ${def.hint}` 115 } 116 117 return key 118 } 119 120 key = `--${def.key}` 121 if (def.short) { 122 key = `-${def.short}|--${def.key}` 123 } 124 125 // Multiple types 126 let types = def.type 127 const multiple = types.includes(Array) 128 const bool = types.includes(Boolean) 129 130 // null type means optional and doesn't currently affect usage output since 131 // all non-optional params have defaults so we render everything as optional 132 types = types.filter(t => t !== null && t !== Array && t !== Boolean) 133 134 if (!types.length) { 135 return key 136 } 137 138 let description 139 if (!types.some(t => typeof t !== 'string')) { 140 // Specific values, use specifics given 141 description = `<${types.filter(d => d).join('|')}>` 142 } else { 143 // Generic values, use hint 144 description = def.hint 145 } 146 147 if (bool) { 148 // Currently none of our multi-type configs with boolean values default to 149 // false so all their hints should show `--no-`, if we ever add ones that 150 // default to false we can branch the logic here 151 key = `--no-${def.key}|${key}` 152 } 153 154 const usage = `${key} ${description}` 155 if (multiple) { 156 return `${usage} [${usage} ...]` 157 } else { 158 return usage 159 } 160} 161 162const describeType = type => { 163 if (Array.isArray(type)) { 164 const descriptions = type.filter(t => t !== Array).map(t => describeType(t)) 165 166 // [a] => "a" 167 // [a, b] => "a or b" 168 // [a, b, c] => "a, b, or c" 169 // [a, Array] => "a (can be set multiple times)" 170 // [a, Array, b] => "a or b (can be set multiple times)" 171 const last = descriptions.length > 1 ? [descriptions.pop()] : [] 172 const oxford = descriptions.length > 1 ? ', or ' : ' or ' 173 const words = [descriptions.join(', ')].concat(last).join(oxford) 174 const multiple = type.includes(Array) ? ' (can be set multiple times)' : '' 175 return `${words}${multiple}` 176 } 177 178 // Note: these are not quite the same as the description printed 179 // when validation fails. In that case, we want to give the user 180 // a bit more information to help them figure out what's wrong. 181 switch (type) { 182 case String: 183 return 'String' 184 case Number: 185 return 'Number' 186 case Umask: 187 return 'Octal numeric string in range 0000..0777 (0..511)' 188 case Boolean: 189 return 'Boolean' 190 case Date: 191 return 'Date' 192 case path: 193 return 'Path' 194 case semver: 195 return 'SemVer string' 196 case url: 197 return 'URL' 198 default: 199 return describeValue(type) 200 } 201} 202 203// if it's a string, quote it. otherwise, just cast to string. 204const describeValue = val => (typeof val === 'string' ? JSON.stringify(val) : String(val)) 205 206const unindent = s => { 207 // get the first \n followed by a bunch of spaces, and pluck off 208 // that many spaces from the start of every line. 209 const match = s.match(/\n +/) 210 return !match ? s.trim() : s.split(match[0]).join('\n').trim() 211} 212 213const wrap = s => { 214 const cols = Math.min(Math.max(20, process.stdout.columns) || 80, 80) - 5 215 return unindent(s) 216 .split(/[ \n]+/) 217 .reduce((left, right) => { 218 const last = left.split('\n').pop() 219 const join = last.length && last.length + right.length > cols ? '\n' : ' ' 220 return left + join + right 221 }) 222} 223 224const wrapAll = s => { 225 let inCodeBlock = false 226 return s 227 .split('\n\n') 228 .map(block => { 229 if (inCodeBlock || block.startsWith('```')) { 230 inCodeBlock = !block.endsWith('```') 231 return block 232 } 233 234 if (block.charAt(0) === '*') { 235 return ( 236 '* ' + 237 block 238 .slice(1) 239 .trim() 240 .split('\n* ') 241 .map(li => { 242 return wrap(li).replace(/\n/g, '\n ') 243 }) 244 .join('\n* ') 245 ) 246 } else { 247 return wrap(block) 248 } 249 }) 250 .join('\n\n') 251} 252 253module.exports = Definition 254