const path = require('path') const debug = require('debug') const inquirer = require('inquirer') const EventEmitter = require('events') const Generator = require('./Generator') const cloneDeep = require('lodash.clonedeep') const sortObject = require('./util/sortObject') const getVersions = require('./util/getVersions') const PackageManager = require('./util/ProjectPackageManager') const { clearConsole } = require('./util/clearConsole') const PromptModuleAPI = require('./PromptModuleAPI') const writeFileTree = require('./util/writeFileTree') const { formatFeatures } = require('./util/features') const loadLocalPreset = require('./util/loadLocalPreset') const loadRemotePreset = require('./util/loadRemotePreset') const generateReadme = require('./util/generateReadme') const { resolvePkg } = require('@vue/cli-shared-utils') const { defaults, saveOptions, loadOptions, savePreset, validatePreset } = require('./options') const { chalk, execa, log, warn, error, logWithSpinner, stopSpinner, hasGit, hasProjectGit, hasYarn, hasPnpm3OrLater, hasPnpmVersionOrLater, exit, loadModule } = require('@vue/cli-shared-utils') const isManualMode = answers => answers.preset === '__manual__' module.exports = class Creator extends EventEmitter { constructor (name, context, promptModules) { super() this.name = name this.context = process.env.VUE_CLI_CONTEXT = context const { presetPrompt, featurePrompt } = this.resolveIntroPrompts() this.presetPrompt = presetPrompt this.featurePrompt = featurePrompt this.outroPrompts = this.resolveOutroPrompts() this.injectedPrompts = [] this.promptCompleteCbs = [] this.afterInvokeCbs = [] this.afterAnyInvokeCbs = [] this.run = this.run.bind(this) const promptAPI = new PromptModuleAPI(this) promptModules.forEach(m => m(promptAPI)) } async create (cliOptions = {}, preset = null) { const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this if (!preset) { if (cliOptions.preset) { // vue create foo --preset bar preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone) } else if (cliOptions.default) { // vue create foo --default preset = defaults.presets.default } else if (cliOptions.inlinePreset) { // vue create foo --inlinePreset {...} try { preset = JSON.parse(cliOptions.inlinePreset) } catch (e) { error(`CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`) exit(1) } } else { preset = await this.promptAndResolvePreset() } } // clone before mutating preset = cloneDeep(preset) // inject core service preset.plugins['@vue/cli-service'] = Object.assign({ projectName: name }, preset) if (cliOptions.bare) { preset.plugins['@vue/cli-service'].bare = true } // legacy support for router if (preset.router) { preset.plugins['@vue/cli-plugin-router'] = {} if (preset.routerHistoryMode) { preset.plugins['@vue/cli-plugin-router'].historyMode = true } } // legacy support for vuex if (preset.vuex) { preset.plugins['@vue/cli-plugin-vuex'] = {} } const packageManager = ( cliOptions.packageManager || loadOptions().packageManager || (hasYarn() ? 'yarn' : null) || (hasPnpm3OrLater() ? 'pnpm' : 'npm') ) const pm = new PackageManager({ context, forcePackageManager: packageManager }) await clearConsole() logWithSpinner(`✨`, `Creating project in ${chalk.yellow(context)}.`) this.emit('creation', { event: 'creating' }) // get latest CLI plugin version const { latestMinor } = await getVersions() // generate package.json with plugin dependencies const pkg = { name, version: '0.1.0', private: true, devDependencies: {}, ...resolvePkg(context) } const deps = Object.keys(preset.plugins) deps.forEach(dep => { if (preset.plugins[dep]._isPreset) { return } // Note: the default creator includes no more than `@vue/cli-*` & `@vue/babel-preset-env`, // so it is fine to only test `@vue` prefix. // Other `@vue/*` packages' version may not be in sync with the cli itself. pkg.devDependencies[dep] = ( preset.plugins[dep].version || ((/^@vue/.test(dep)) ? `~${latestMinor}` : `latest`) ) }) // write package.json await writeFileTree(context, { 'package.json': JSON.stringify(pkg, null, 2) }) // intilaize git repository before installing deps // so that vue-cli-service can setup git hooks. const shouldInitGit = this.shouldInitGit(cliOptions) if (shouldInitGit) { logWithSpinner(`🗃`, `Initializing git repository...`) this.emit('creation', { event: 'git-init' }) await run('git init') } // install plugins stopSpinner() log(`⚙\u{fe0f} Installing CLI plugins. This might take a while...`) log() this.emit('creation', { event: 'plugins-install' }) if (isTestOrDebug && !process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) { // in development, avoid installation process await require('./util/setupDevProject')(context) } else { await pm.install() } // run generator log(`🚀 Invoking generators...`) this.emit('creation', { event: 'invoking-generators' }) const plugins = await this.resolvePlugins(preset.plugins) const generator = new Generator(context, { pkg, plugins, afterInvokeCbs, afterAnyInvokeCbs }) await generator.generate({ extractConfigFiles: preset.useConfigFiles }) // install additional deps (injected by generators) log(`📦 Installing additional dependencies...`) this.emit('creation', { event: 'deps-install' }) log() if (!isTestOrDebug) { await pm.install() } // run complete cbs if any (injected by generators) logWithSpinner('⚓', `Running completion hooks...`) this.emit('creation', { event: 'completion-hooks' }) for (const cb of afterInvokeCbs) { await cb() } for (const cb of afterAnyInvokeCbs) { await cb() } // generate README.md stopSpinner() log() logWithSpinner('📄', 'Generating README.md...') await writeFileTree(context, { 'README.md': generateReadme(generator.pkg, packageManager) }) // generate a .npmrc file for pnpm, to persist the `shamefully-flatten` flag if (packageManager === 'pnpm') { const pnpmConfig = hasPnpmVersionOrLater('4.0.0') ? 'shamefully-hoist=true\n' : 'shamefully-flatten=true\n' await writeFileTree(context, { '.npmrc': pnpmConfig }) } // commit initial state let gitCommitFailed = false if (shouldInitGit) { await run('git add -A') if (isTestOrDebug) { await run('git', ['config', 'user.name', 'test']) await run('git', ['config', 'user.email', 'test@test.com']) } const msg = typeof cliOptions.git === 'string' ? cliOptions.git : 'init' try { await run('git', ['commit', '-m', msg]) } catch (e) { gitCommitFailed = true } } // log instructions stopSpinner() log() log(`🎉 Successfully created project ${chalk.yellow(name)}.`) if (!cliOptions.skipGetStarted) { log( `👉 Get started with the following commands:\n\n` + (this.context === process.cwd() ? `` : chalk.cyan(` ${chalk.gray('$')} cd ${name}\n`)) + chalk.cyan(` ${chalk.gray('$')} ${packageManager === 'yarn' ? 'yarn serve' : packageManager === 'pnpm' ? 'pnpm run serve' : 'npm run serve'}`) ) } log() this.emit('creation', { event: 'done' }) if (gitCommitFailed) { warn( `Skipped git commit due to missing username and email in git config.\n` + `You will need to perform the initial commit yourself.\n` ) } generator.printExitLogs() } run (command, args) { if (!args) { [command, ...args] = command.split(/\s+/) } return execa(command, args, { cwd: this.context }) } async promptAndResolvePreset (answers = null) { // prompt if (!answers) { await clearConsole(true) answers = await inquirer.prompt(this.resolveFinalPrompts()) } debug('vue-cli:answers')(answers) if (answers.packageManager) { saveOptions({ packageManager: answers.packageManager }) } let preset if (answers.preset && answers.preset !== '__manual__') { preset = await this.resolvePreset(answers.preset) } else { // manual preset = { useConfigFiles: answers.useConfigFiles === 'files', plugins: {} } answers.features = answers.features || [] // run cb registered by prompt modules to finalize the preset this.promptCompleteCbs.forEach(cb => cb(answers, preset)) } // validate validatePreset(preset) // save preset if (answers.save && answers.saveName) { savePreset(answers.saveName, preset) } debug('vue-cli:preset')(preset) return preset } async resolvePreset (name, clone) { let preset const savedPresets = loadOptions().presets || {} if (name in savedPresets) { preset = savedPresets[name] } else if (name.endsWith('.json') || /^\./.test(name) || path.isAbsolute(name)) { preset = await loadLocalPreset(path.resolve(name)) } else if (name.includes('/')) { logWithSpinner(`Fetching remote preset ${chalk.cyan(name)}...`) this.emit('creation', { event: 'fetch-remote-preset' }) try { preset = await loadRemotePreset(name, clone) stopSpinner() } catch (e) { stopSpinner() error(`Failed fetching remote preset ${chalk.cyan(name)}:`) throw e } } // use default preset if user has not overwritten it if (name === 'default' && !preset) { preset = defaults.presets.default } if (!preset) { error(`preset "${name}" not found.`) const presets = Object.keys(savedPresets) if (presets.length) { log() log(`available presets:\n${presets.join(`\n`)}`) } else { log(`you don't seem to have any saved preset.`) log(`run vue-cli in manual mode to create a preset.`) } exit(1) } return preset } // { id: options } => [{ id, apply, options }] async resolvePlugins (rawPlugins) { // ensure cli-service is invoked first rawPlugins = sortObject(rawPlugins, ['@vue/cli-service'], true) const plugins = [] for (const id of Object.keys(rawPlugins)) { const apply = loadModule(`${id}/generator`, this.context) || (() => {}) let options = rawPlugins[id] || {} if (options.prompts) { const prompts = loadModule(`${id}/prompts`, this.context) if (prompts) { log() log(`${chalk.cyan(options._isPreset ? `Preset options:` : id)}`) options = await inquirer.prompt(prompts) } } plugins.push({ id, apply, options }) } return plugins } getPresets () { const savedOptions = loadOptions() return Object.assign({}, savedOptions.presets, defaults.presets) } resolveIntroPrompts () { const presets = this.getPresets() const presetChoices = Object.keys(presets).map(name => { return { name: `${name} (${formatFeatures(presets[name])})`, value: name } }) const presetPrompt = { name: 'preset', type: 'list', message: `Please pick a preset:`, choices: [ ...presetChoices, { name: 'Manually select features', value: '__manual__' } ] } const featurePrompt = { name: 'features', when: isManualMode, type: 'checkbox', message: 'Check the features needed for your project:', choices: [], pageSize: 10 } return { presetPrompt, featurePrompt } } resolveOutroPrompts () { const outroPrompts = [ { name: 'useConfigFiles', when: isManualMode, type: 'list', message: 'Where do you prefer placing config for Babel, ESLint, etc.?', choices: [ { name: 'In dedicated config files', value: 'files' }, { name: 'In package.json', value: 'pkg' } ] }, { name: 'save', when: isManualMode, type: 'confirm', message: 'Save this as a preset for future projects?', default: false }, { name: 'saveName', when: answers => answers.save, type: 'input', message: 'Save preset as:' } ] // ask for packageManager once const savedOptions = loadOptions() if (!savedOptions.packageManager && (hasYarn() || hasPnpm3OrLater())) { const packageManagerChoices = [] if (hasYarn()) { packageManagerChoices.push({ name: 'Use Yarn', value: 'yarn', short: 'Yarn' }) } if (hasPnpm3OrLater()) { packageManagerChoices.push({ name: 'Use PNPM', value: 'pnpm', short: 'PNPM' }) } packageManagerChoices.push({ name: 'Use NPM', value: 'npm', short: 'NPM' }) outroPrompts.push({ name: 'packageManager', type: 'list', message: 'Pick the package manager to use when installing dependencies:', choices: packageManagerChoices }) } return outroPrompts } resolveFinalPrompts () { // patch generator-injected prompts to only show in manual mode this.injectedPrompts.forEach(prompt => { const originalWhen = prompt.when || (() => true) prompt.when = answers => { return isManualMode(answers) && originalWhen(answers) } }) const prompts = [ this.presetPrompt, this.featurePrompt, ...this.injectedPrompts, ...this.outroPrompts ] debug('vue-cli:prompts')(prompts) return prompts } shouldInitGit (cliOptions) { if (!hasGit()) { return false } // --git if (cliOptions.forceGit) { return true } // --no-git if (cliOptions.git === false || cliOptions.git === 'false') { return false } // default: true unless already in a git repo return !hasProjectGit(this.context) } }