UNPKG

15.8 kBJavaScriptView Raw
1const path = require('path')
2const debug = require('debug')
3const inquirer = require('inquirer')
4const EventEmitter = require('events')
5const Generator = require('./Generator')
6const cloneDeep = require('lodash.clonedeep')
7const sortObject = require('./util/sortObject')
8const getVersions = require('./util/getVersions')
9const PackageManager = require('./util/ProjectPackageManager')
10const { clearConsole } = require('./util/clearConsole')
11const PromptModuleAPI = require('./PromptModuleAPI')
12const writeFileTree = require('./util/writeFileTree')
13const { formatFeatures } = require('./util/features')
14const loadLocalPreset = require('./util/loadLocalPreset')
15const loadRemotePreset = require('./util/loadRemotePreset')
16const generateReadme = require('./util/generateReadme')
17const { resolvePkg, isOfficialPlugin } = require('@vue/cli-shared-utils')
18
19const {
20 defaults,
21 saveOptions,
22 loadOptions,
23 savePreset,
24 validatePreset,
25 rcPath
26} = require('./options')
27
28const {
29 chalk,
30 execa,
31
32 log,
33 warn,
34 error,
35
36 hasGit,
37 hasProjectGit,
38 hasYarn,
39 hasPnpm3OrLater,
40 hasPnpmVersionOrLater,
41
42 exit,
43 loadModule
44} = require('@vue/cli-shared-utils')
45
46const isManualMode = answers => answers.preset === '__manual__'
47
48module.exports = class Creator extends EventEmitter {
49 constructor (name, context, promptModules) {
50 super()
51
52 this.name = name
53 this.context = process.env.VUE_CLI_CONTEXT = context
54 const { presetPrompt, featurePrompt } = this.resolveIntroPrompts()
55
56 this.presetPrompt = presetPrompt
57 this.featurePrompt = featurePrompt
58 this.outroPrompts = this.resolveOutroPrompts()
59 this.injectedPrompts = []
60 this.promptCompleteCbs = []
61 this.afterInvokeCbs = []
62 this.afterAnyInvokeCbs = []
63
64 this.run = this.run.bind(this)
65
66 const promptAPI = new PromptModuleAPI(this)
67 promptModules.forEach(m => m(promptAPI))
68 }
69
70 async create (cliOptions = {}, preset = null) {
71 const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG
72 const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this
73
74 if (!preset) {
75 if (cliOptions.preset) {
76 // vue create foo --preset bar
77 preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone)
78 } else if (cliOptions.default) {
79 // vue create foo --default
80 preset = defaults.presets.default
81 } else if (cliOptions.inlinePreset) {
82 // vue create foo --inlinePreset {...}
83 try {
84 preset = JSON.parse(cliOptions.inlinePreset)
85 } catch (e) {
86 error(`CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`)
87 exit(1)
88 }
89 } else {
90 preset = await this.promptAndResolvePreset()
91 }
92 }
93
94 // clone before mutating
95 preset = cloneDeep(preset)
96 // inject core service
97 preset.plugins['@vue/cli-service'] = Object.assign({
98 projectName: name
99 }, preset)
100
101 if (cliOptions.bare) {
102 preset.plugins['@vue/cli-service'].bare = true
103 }
104
105 // legacy support for router
106 if (preset.router) {
107 preset.plugins['@vue/cli-plugin-router'] = {}
108
109 if (preset.routerHistoryMode) {
110 preset.plugins['@vue/cli-plugin-router'].historyMode = true
111 }
112 }
113
114 // Introducing this hack because typescript plugin must be invoked after router.
115 // Currently we rely on the `plugins` object enumeration order,
116 // which depends on the order of the field initialization.
117 // FIXME: Remove this ugly hack after the plugin ordering API settled down
118 if (preset.plugins['@vue/cli-plugin-router'] && preset.plugins['@vue/cli-plugin-typescript']) {
119 const tmp = preset.plugins['@vue/cli-plugin-typescript']
120 delete preset.plugins['@vue/cli-plugin-typescript']
121 preset.plugins['@vue/cli-plugin-typescript'] = tmp
122 }
123
124 // legacy support for vuex
125 if (preset.vuex) {
126 preset.plugins['@vue/cli-plugin-vuex'] = {}
127 }
128
129 const packageManager = (
130 cliOptions.packageManager ||
131 loadOptions().packageManager ||
132 (hasYarn() ? 'yarn' : null) ||
133 (hasPnpm3OrLater() ? 'pnpm' : 'npm')
134 )
135 const pm = new PackageManager({ context, forcePackageManager: packageManager })
136
137 await clearConsole()
138 log(`✨ Creating project in ${chalk.yellow(context)}.`)
139 this.emit('creation', { event: 'creating' })
140
141 // get latest CLI plugin version
142 const { latestMinor } = await getVersions()
143
144 // generate package.json with plugin dependencies
145 const pkg = {
146 name,
147 version: '0.1.0',
148 private: true,
149 devDependencies: {},
150 ...resolvePkg(context)
151 }
152 const deps = Object.keys(preset.plugins)
153 deps.forEach(dep => {
154 if (preset.plugins[dep]._isPreset) {
155 return
156 }
157
158 let { version } = preset.plugins[dep]
159
160 if (!version) {
161 if (isOfficialPlugin(dep) || dep === '@vue/cli-service' || dep === '@vue/babel-preset-env') {
162 version = isTestOrDebug ? `file:${path.resolve(__dirname, '../../../', dep)}` : `~${latestMinor}`
163 } else {
164 version = 'latest'
165 }
166 }
167
168 pkg.devDependencies[dep] = version
169 })
170
171 // write package.json
172 await writeFileTree(context, {
173 'package.json': JSON.stringify(pkg, null, 2)
174 })
175
176 // intilaize git repository before installing deps
177 // so that vue-cli-service can setup git hooks.
178 const shouldInitGit = this.shouldInitGit(cliOptions)
179 if (shouldInitGit) {
180 log(`🗃 Initializing git repository...`)
181 this.emit('creation', { event: 'git-init' })
182 await run('git init')
183 }
184
185 // install plugins
186 log(`⚙\u{fe0f} Installing CLI plugins. This might take a while...`)
187 log()
188 this.emit('creation', { event: 'plugins-install' })
189
190 if (isTestOrDebug && !process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
191 // in development, avoid installation process
192 await require('./util/setupDevProject')(context)
193 } else {
194 await pm.install()
195 }
196
197 // run generator
198 log(`🚀 Invoking generators...`)
199 this.emit('creation', { event: 'invoking-generators' })
200 const plugins = await this.resolvePlugins(preset.plugins, pkg)
201 const generator = new Generator(context, {
202 pkg,
203 plugins,
204 afterInvokeCbs,
205 afterAnyInvokeCbs
206 })
207 await generator.generate({
208 extractConfigFiles: preset.useConfigFiles
209 })
210
211 // install additional deps (injected by generators)
212 log(`📦 Installing additional dependencies...`)
213 this.emit('creation', { event: 'deps-install' })
214 log()
215 if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
216 await pm.install()
217 }
218
219 // run complete cbs if any (injected by generators)
220 log(`⚓ Running completion hooks...`)
221 this.emit('creation', { event: 'completion-hooks' })
222 for (const cb of afterInvokeCbs) {
223 await cb()
224 }
225 for (const cb of afterAnyInvokeCbs) {
226 await cb()
227 }
228
229 if (!generator.files['README.md']) {
230 // generate README.md
231 log()
232 log('📄 Generating README.md...')
233 await writeFileTree(context, {
234 'README.md': generateReadme(generator.pkg, packageManager)
235 })
236 }
237
238 // generate a .npmrc file for pnpm, to persist the `shamefully-flatten` flag
239 if (packageManager === 'pnpm') {
240 const pnpmConfig = hasPnpmVersionOrLater('4.0.0')
241 ? 'shamefully-hoist=true\n'
242 : 'shamefully-flatten=true\n'
243
244 await writeFileTree(context, {
245 '.npmrc': pnpmConfig
246 })
247 }
248
249 // commit initial state
250 let gitCommitFailed = false
251 if (shouldInitGit) {
252 await run('git add -A')
253 if (isTestOrDebug) {
254 await run('git', ['config', 'user.name', 'test'])
255 await run('git', ['config', 'user.email', 'test@test.com'])
256 }
257 const msg = typeof cliOptions.git === 'string' ? cliOptions.git : 'init'
258 try {
259 await run('git', ['commit', '-m', msg, '--no-verify'])
260 } catch (e) {
261 gitCommitFailed = true
262 }
263 }
264
265 // log instructions
266 log()
267 log(`🎉 Successfully created project ${chalk.yellow(name)}.`)
268 if (!cliOptions.skipGetStarted) {
269 log(
270 `👉 Get started with the following commands:\n\n` +
271 (this.context === process.cwd() ? `` : chalk.cyan(` ${chalk.gray('$')} cd ${name}\n`)) +
272 chalk.cyan(` ${chalk.gray('$')} ${packageManager === 'yarn' ? 'yarn serve' : packageManager === 'pnpm' ? 'pnpm run serve' : 'npm run serve'}`)
273 )
274 }
275 log()
276 this.emit('creation', { event: 'done' })
277
278 if (gitCommitFailed) {
279 warn(
280 `Skipped git commit due to missing username and email in git config.\n` +
281 `You will need to perform the initial commit yourself.\n`
282 )
283 }
284
285 generator.printExitLogs()
286 }
287
288 run (command, args) {
289 if (!args) { [command, ...args] = command.split(/\s+/) }
290 return execa(command, args, { cwd: this.context })
291 }
292
293 async promptAndResolvePreset (answers = null) {
294 // prompt
295 if (!answers) {
296 await clearConsole(true)
297 answers = await inquirer.prompt(this.resolveFinalPrompts())
298 }
299 debug('vue-cli:answers')(answers)
300
301 if (answers.packageManager) {
302 saveOptions({
303 packageManager: answers.packageManager
304 })
305 }
306
307 let preset
308 if (answers.preset && answers.preset !== '__manual__') {
309 preset = await this.resolvePreset(answers.preset)
310 } else {
311 // manual
312 preset = {
313 useConfigFiles: answers.useConfigFiles === 'files',
314 plugins: {}
315 }
316 answers.features = answers.features || []
317 // run cb registered by prompt modules to finalize the preset
318 this.promptCompleteCbs.forEach(cb => cb(answers, preset))
319 }
320
321 // validate
322 validatePreset(preset)
323
324 // save preset
325 if (answers.save && answers.saveName && savePreset(answers.saveName, preset)) {
326 log()
327 log(`🎉 Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(rcPath)}`)
328 }
329
330 debug('vue-cli:preset')(preset)
331 return preset
332 }
333
334 async resolvePreset (name, clone) {
335 let preset
336 const savedPresets = this.getPresets()
337
338 if (name in savedPresets) {
339 preset = savedPresets[name]
340 } else if (name.endsWith('.json') || /^\./.test(name) || path.isAbsolute(name)) {
341 preset = await loadLocalPreset(path.resolve(name))
342 } else if (name.includes('/')) {
343 log(`Fetching remote preset ${chalk.cyan(name)}...`)
344 this.emit('creation', { event: 'fetch-remote-preset' })
345 try {
346 preset = await loadRemotePreset(name, clone)
347 } catch (e) {
348 error(`Failed fetching remote preset ${chalk.cyan(name)}:`)
349 throw e
350 }
351 }
352
353 if (!preset) {
354 error(`preset "${name}" not found.`)
355 const presets = Object.keys(savedPresets)
356 if (presets.length) {
357 log()
358 log(`available presets:\n${presets.join(`\n`)}`)
359 } else {
360 log(`you don't seem to have any saved preset.`)
361 log(`run vue-cli in manual mode to create a preset.`)
362 }
363 exit(1)
364 }
365 return preset
366 }
367
368 // { id: options } => [{ id, apply, options }]
369 async resolvePlugins (rawPlugins, pkg) {
370 // ensure cli-service is invoked first
371 rawPlugins = sortObject(rawPlugins, ['@vue/cli-service'], true)
372 const plugins = []
373 for (const id of Object.keys(rawPlugins)) {
374 const apply = loadModule(`${id}/generator`, this.context) || (() => {})
375 let options = rawPlugins[id] || {}
376
377 if (options.prompts) {
378 let pluginPrompts = loadModule(`${id}/prompts`, this.context)
379
380 if (pluginPrompts) {
381 const prompt = inquirer.createPromptModule()
382
383 if (typeof pluginPrompts === 'function') {
384 pluginPrompts = pluginPrompts(pkg, prompt)
385 }
386 if (typeof pluginPrompts.getPrompts === 'function') {
387 pluginPrompts = pluginPrompts.getPrompts(pkg, prompt)
388 }
389
390 log()
391 log(`${chalk.cyan(options._isPreset ? `Preset options:` : id)}`)
392 options = await prompt(pluginPrompts)
393 }
394 }
395
396 plugins.push({ id, apply, options })
397 }
398 return plugins
399 }
400
401 getPresets () {
402 const savedOptions = loadOptions()
403 return Object.assign({}, savedOptions.presets, defaults.presets)
404 }
405
406 resolveIntroPrompts () {
407 const presets = this.getPresets()
408 const presetChoices = Object.entries(presets).map(([name, preset]) => {
409 let displayName = name
410 if (name === 'default') {
411 displayName = 'Default'
412 } else if (name === '__default_vue_3__') {
413 displayName = 'Default (Vue 3 Preview)'
414 }
415
416 return {
417 name: `${displayName} (${formatFeatures(preset)})`,
418 value: name
419 }
420 })
421 const presetPrompt = {
422 name: 'preset',
423 type: 'list',
424 message: `Please pick a preset:`,
425 choices: [
426 ...presetChoices,
427 {
428 name: 'Manually select features',
429 value: '__manual__'
430 }
431 ]
432 }
433 const featurePrompt = {
434 name: 'features',
435 when: isManualMode,
436 type: 'checkbox',
437 message: 'Check the features needed for your project:',
438 choices: [],
439 pageSize: 10
440 }
441 return {
442 presetPrompt,
443 featurePrompt
444 }
445 }
446
447 resolveOutroPrompts () {
448 const outroPrompts = [
449 {
450 name: 'useConfigFiles',
451 when: isManualMode,
452 type: 'list',
453 message: 'Where do you prefer placing config for Babel, ESLint, etc.?',
454 choices: [
455 {
456 name: 'In dedicated config files',
457 value: 'files'
458 },
459 {
460 name: 'In package.json',
461 value: 'pkg'
462 }
463 ]
464 },
465 {
466 name: 'save',
467 when: isManualMode,
468 type: 'confirm',
469 message: 'Save this as a preset for future projects?',
470 default: false
471 },
472 {
473 name: 'saveName',
474 when: answers => answers.save,
475 type: 'input',
476 message: 'Save preset as:'
477 }
478 ]
479
480 // ask for packageManager once
481 const savedOptions = loadOptions()
482 if (!savedOptions.packageManager && (hasYarn() || hasPnpm3OrLater())) {
483 const packageManagerChoices = []
484
485 if (hasYarn()) {
486 packageManagerChoices.push({
487 name: 'Use Yarn',
488 value: 'yarn',
489 short: 'Yarn'
490 })
491 }
492
493 if (hasPnpm3OrLater()) {
494 packageManagerChoices.push({
495 name: 'Use PNPM',
496 value: 'pnpm',
497 short: 'PNPM'
498 })
499 }
500
501 packageManagerChoices.push({
502 name: 'Use NPM',
503 value: 'npm',
504 short: 'NPM'
505 })
506
507 outroPrompts.push({
508 name: 'packageManager',
509 type: 'list',
510 message: 'Pick the package manager to use when installing dependencies:',
511 choices: packageManagerChoices
512 })
513 }
514
515 return outroPrompts
516 }
517
518 resolveFinalPrompts () {
519 // patch generator-injected prompts to only show in manual mode
520 this.injectedPrompts.forEach(prompt => {
521 const originalWhen = prompt.when || (() => true)
522 prompt.when = answers => {
523 return isManualMode(answers) && originalWhen(answers)
524 }
525 })
526
527 const prompts = [
528 this.presetPrompt,
529 this.featurePrompt,
530 ...this.injectedPrompts,
531 ...this.outroPrompts
532 ]
533 debug('vue-cli:prompts')(prompts)
534 return prompts
535 }
536
537 shouldInitGit (cliOptions) {
538 if (!hasGit()) {
539 return false
540 }
541 // --git
542 if (cliOptions.forceGit) {
543 return true
544 }
545 // --no-git
546 if (cliOptions.git === false || cliOptions.git === 'false') {
547 return false
548 }
549 // default: true unless already in a git repo
550 return !hasProjectGit(this.context)
551 }
552}