diff --git a/lib/commands/new.js b/lib/commands/new.js index 47cebd45e47..f8a0a3cb8d0 100644 --- a/lib/commands/new.js +++ b/lib/commands/new.js @@ -41,6 +41,13 @@ module.exports = Command.extend({ default: 'github', description: 'Installs the default CI blueprint. Either Travis or Github Actions is supported.', }, + { + name: 'interactive', + type: Boolean, + default: false, + aliases: ['i'], + description: 'Create a new Ember app/addon in an interactive way.', + }, ], anonymousOptions: [''], @@ -53,10 +60,27 @@ module.exports = Command.extend({ commandOptions.name = rawArgs.shift(); - if (!projectName) { - message = `The \`ember ${this.name}\` command requires a name to be specified. For more details, use \`ember help\`.`; + if (!projectName || commandOptions.interactive) { + let answers = await this.runTask('InteractiveNew', commandOptions); - return Promise.reject(new SilentError(message)); + commandOptions.blueprint = answers.blueprint; + + if (answers.name) { + projectName = answers.name; + commandOptions.name = answers.name; + } + + if (answers.lang) { + commandOptions.lang = answers.lang; + } + + if (answers.packageManager) { + commandOptions.yarn = answers.packageManager === 'yarn'; + } + + if (answers.ciProvider) { + commandOptions.ciProvider = answers.ciProvider; + } } if (commandOptions.dryRun) { diff --git a/lib/tasks/interactive-new.js b/lib/tasks/interactive-new.js new file mode 100644 index 00000000000..508c3f4273d --- /dev/null +++ b/lib/tasks/interactive-new.js @@ -0,0 +1,160 @@ +'use strict'; + +const inquirer = require('inquirer'); +const { isLangCode } = require('is-language-code'); +const osLocale = require('os-locale'); + +const Task = require('../models/task'); +const isValidProjectName = require('../utilities/valid-project-name'); + +const DEFAULT_LOCALE = 'en-US'; + +class InteractiveNewTask extends Task { + async run(newCommandOptions, _testAnswers) { + let prompt = inquirer.createPromptModule(); + let questions = await this.getQuestions(newCommandOptions); + let answers = await prompt(questions, _testAnswers); + + answers.lang = answers.langSelection || answers.langDifferent; + + delete answers.langSelection; + delete answers.langDifferent; + + return answers; + } + + async getQuestions(newCommandOptions = {}) { + return [ + { + name: 'blueprint', + type: 'list', + message: 'Is this an app or an addon?', + choices: [ + { + name: 'App', + value: 'app', + }, + { + name: 'Addon', + value: 'addon', + }, + ], + }, + { + name: 'name', + type: 'input', + message: ({ blueprint }) => `Please provide the name of your ${blueprint}:`, + when: !newCommandOptions.name, + validate: (name) => { + if (name) { + if (isValidProjectName(name)) { + return true; + } + + return `We currently do not support \`${name}\` as a name.`; + } + + return 'Please provide a name.'; + }, + }, + { + name: 'langSelection', + type: 'list', + message: ({ blueprint }) => `Please provide the spoken/content language of your ${blueprint}:`, + when: !newCommandOptions.lang, + choices: await this.getLangChoices(), + }, + { + name: 'langDifferent', + type: 'input', + message: 'Please provide the different language:', + when: ({ langSelection } = {}) => !newCommandOptions.lang && !langSelection, + validate: (lang) => { + if (isLangCode(lang).res) { + return true; + } + + return 'Please provide a valid locale code.'; + }, + }, + { + name: 'packageManager', + type: 'list', + message: 'Pick the package manager to use when installing dependencies:', + when: !newCommandOptions.yarn, + choices: [ + { + name: 'NPM', + value: 'npm', + }, + { + name: 'Yarn', + value: 'yarn', + }, + { + name: 'Ignore/Skip', + value: null, + }, + ], + }, + { + name: 'ciProvider', + type: 'list', + message: 'Which CI provider do you want to use?', + // `newCommandOptions.ciProvider` has `github` as a default value, + // so we need to check the presence of the `--ci-provider` flag to know + // if the user provided a CI provider manually. + when: !isCliOptionProvided('--ci-provider'), + choices: [ + { + name: 'GitHub Actions', + value: 'github', + }, + { + name: 'Travis CI', + value: 'travis', + }, + { + name: 'Ignore/Skip', + value: null, + }, + ], + }, + ]; + } + + async getLangChoices() { + let userLocale = await this.getUserLocale(); + let langChoices = [ + { + name: DEFAULT_LOCALE, + value: DEFAULT_LOCALE, + }, + ]; + + if (userLocale !== DEFAULT_LOCALE) { + langChoices.push({ + name: userLocale, + value: userLocale, + }); + } + + langChoices.push({ + name: 'I want to manually provide a different language', + value: null, + }); + + return langChoices; + } + + getUserLocale() { + return osLocale(); + } +} + +function isCliOptionProvided(name) { + return process.argv.some((arg) => arg.includes(name)); +} + +module.exports = InteractiveNewTask; +module.exports.DEFAULT_LOCALE = DEFAULT_LOCALE; diff --git a/package.json b/package.json index badbeb20110..7cd2c0edd8f 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "heimdalljs-logger": "^0.1.10", "http-proxy": "^1.18.1", "inflection": "^1.13.1", + "inquirer": "^8.2.1", "is-git-url": "^1.0.0", "is-language-code": "^3.1.0", "isbinaryfile": "^4.0.8", @@ -106,6 +107,7 @@ "morgan": "^1.10.0", "nopt": "^3.0.6", "npm-package-arg": "^8.1.5", + "os-locale": "^5.0.0", "p-defer": "^3.0.0", "portfinder": "^1.0.28", "promise-map-series": "^0.3.0", diff --git a/tests/unit/commands/new-test.js b/tests/unit/commands/new-test.js index aa703248c08..d025b0d6544 100644 --- a/tests/unit/commands/new-test.js +++ b/tests/unit/commands/new-test.js @@ -7,6 +7,7 @@ const NewCommand = require('../../../lib/commands/new'); const Blueprint = require('../../../lib/models/blueprint'); const Command = require('../../../lib/models/command'); const Task = require('../../../lib/models/task'); +const InteractiveNewTask = require('../../../lib/tasks/interactive-new'); const td = require('testdouble'); describe('new command', function () { @@ -106,4 +107,114 @@ describe('new command', function () { let reason = await command.validateAndRun(['foo', '--custom-option=customValue']); expect(reason).to.equal('Called run'); }); + + describe('interactive', function () { + it('interactive new is entered when no app/addon name is provided', async function () { + class InteractiveNewTaskMock extends InteractiveNewTask { + run(newCommandOptions) { + return super.run(newCommandOptions, { + blueprint: 'addon', + name: 'foo', + langSelection: 'en-US', + packageManager: 'npm', + ciProvider: 'github', + }); + } + } + + class CreateAndStepIntoDirectoryTask extends Task { + run() {} + } + + class InitCommand extends Command { + run(commandOptions) { + expect(commandOptions).to.deep.include({ + blueprint: 'addon', + name: 'foo', + lang: 'en-US', + yarn: false, + ciProvider: 'github', + }); + } + } + + command.tasks.InteractiveNew = InteractiveNewTaskMock; + command.tasks.CreateAndStepIntoDirectory = CreateAndStepIntoDirectoryTask; + command.commands.Init = InitCommand; + + expect(command.validateAndRun([])).to.be.fulfilled; + }); + + it('interactive new is entered when the `--interactive` flag is provided', async function () { + class InteractiveNewTaskMock extends InteractiveNewTask { + run(newCommandOptions) { + return super.run(newCommandOptions, { + blueprint: 'app', + name: newCommandOptions.name, + langSelection: 'nl-BE', + packageManager: 'yarn', + ciProvider: 'travis', + }); + } + } + + class CreateAndStepIntoDirectoryTask extends Task { + run() {} + } + + class InitCommand extends Command { + run(commandOptions) { + expect(commandOptions).to.deep.include({ + blueprint: 'app', + name: 'bar', + lang: 'nl-BE', + yarn: true, + ciProvider: 'travis', + }); + } + } + + command.tasks.InteractiveNew = InteractiveNewTaskMock; + command.tasks.CreateAndStepIntoDirectory = CreateAndStepIntoDirectoryTask; + command.commands.Init = InitCommand; + + expect(command.validateAndRun(['bar', '--interactive'])).to.be.fulfilled; + }); + + it('interactive new is entered when the `-i` flag is provided', async function () { + class InteractiveNewTaskMock extends InteractiveNewTask { + run(newCommandOptions) { + return super.run(newCommandOptions, { + blueprint: 'app', + name: newCommandOptions.name, + langSelection: 'fr-BE', + packageManager: null, + ciProvider: null, + }); + } + } + + class CreateAndStepIntoDirectoryTask extends Task { + run() {} + } + + class InitCommand extends Command { + run(commandOptions) { + expect(commandOptions).does.not.have.key('yarn'); + expect(commandOptions).to.deep.include({ + blueprint: 'app', + name: 'baz', + lang: 'fr-BE', + ciProvider: 'github', + }); + } + } + + command.tasks.InteractiveNew = InteractiveNewTaskMock; + command.tasks.CreateAndStepIntoDirectory = CreateAndStepIntoDirectoryTask; + command.commands.Init = InitCommand; + + expect(command.validateAndRun(['baz', '-i'])).to.be.fulfilled; + }); + }); }); diff --git a/tests/unit/tasks/interactive-new-test.js b/tests/unit/tasks/interactive-new-test.js new file mode 100644 index 00000000000..09761d60d31 --- /dev/null +++ b/tests/unit/tasks/interactive-new-test.js @@ -0,0 +1,136 @@ +'use strict'; + +const { expect } = require('chai'); +const InteractiveNewTask = require('../../../lib/tasks/interactive-new'); + +describe('interactive new task', function () { + let interactiveNewTask; + + beforeEach(function () { + interactiveNewTask = new InteractiveNewTask(); + }); + + afterEach(function () { + interactiveNewTask = null; + }); + + it('it only displays the `name` question when no app/addon name is provided', async function () { + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('name', questions); + + expect(question.when).to.be.true; + + questions = await interactiveNewTask.getQuestions({ name: 'foo' }); + question = getQuestion('name', questions); + + expect(question.when).to.be.false; + }); + + it('it validates the provided app/addon name', async function () { + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('name', questions); + + expect(question.validate('')).to.equal('Please provide a name.'); + expect(question.validate('app')).to.equal(`We currently do not support \`app\` as a name.`); + expect(question.validate('foo')).to.be.true; + }); + + it('it only displays the `langSelection` question when no language is provided', async function () { + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('langSelection', questions); + + expect(question.when).to.be.true; + + questions = await interactiveNewTask.getQuestions({ lang: 'nl-BE' }); + question = getQuestion('langSelection', questions); + + expect(question.when).to.be.false; + }); + + it('it only displays the `langDifferent` question when no language is provided and when the user wants to provide a different language', async function () { + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('langDifferent', questions); + + expect(question.when()).to.be.true; + + questions = await interactiveNewTask.getQuestions({ lang: 'nl-BE' }); + question = getQuestion('langDifferent', questions); + + expect(question.when()).to.be.false; + + questions = await interactiveNewTask.getQuestions(); + question = getQuestion('langDifferent', questions); + + expect(question.when({ langSelection: 'nl-BE' })).to.be.false; + }); + + it('it validates the provided different language', async function () { + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('langDifferent', questions); + + expect(question.validate('')).to.equal('Please provide a valid locale code.'); + expect(question.validate('foo')).to.equal('Please provide a valid locale code.'); + expect(question.validate('nl-BE')).to.be.true; + }); + + it('it only displays the `packageManager` question when the yarn option is not provided', async function () { + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('packageManager', questions); + + expect(question.when).to.be.true; + + questions = await interactiveNewTask.getQuestions({ yarn: true }); + question = getQuestion('packageManager', questions); + + expect(question.when).to.be.false; + }); + + it('it displays the correct language choices', async function () { + let userLocale = InteractiveNewTask.DEFAULT_LOCALE; + + class InteractiveNewTaskMock extends InteractiveNewTask { + getUserLocale() { + return userLocale; + } + } + + interactiveNewTask = new InteractiveNewTaskMock(); + + let questions = await interactiveNewTask.getQuestions(); + let question = getQuestion('langSelection', questions); + + expect(question.choices).to.deep.equal([ + { + name: InteractiveNewTask.DEFAULT_LOCALE, + value: InteractiveNewTask.DEFAULT_LOCALE, + }, + { + name: 'I want to manually provide a different language', + value: null, + }, + ]); + + userLocale = 'nl-BE'; + questions = await interactiveNewTask.getQuestions(); + question = getQuestion('langSelection', questions); + + expect(question.choices).to.deep.equal([ + { + name: InteractiveNewTask.DEFAULT_LOCALE, + value: InteractiveNewTask.DEFAULT_LOCALE, + }, + { + name: 'nl-BE', + value: 'nl-BE', + }, + { + name: 'I want to manually provide a different language', + value: null, + }, + ]); + }); +}); + +function getQuestion(name, questions) { + return questions.find((question) => question.name === name); +} diff --git a/yarn.lock b/yarn.lock index ca7e92a4f04..2e592b92f22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4370,6 +4370,26 @@ inquirer@^6: strip-ansi "^5.1.0" through "^2.3.6" +inquirer@^8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.1.tgz#e00022e3e8930a92662f760f020686530a84671d" + integrity sha512-pxhBaw9cyTFMjwKtkjePWDhvwzvrNGAw7En4hottzlPvz80GZaMZthdDU35aA6/f5FRZf3uhE057q8w1DE3V2g== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -4384,6 +4404,11 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== +invert-kv@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-3.0.1.tgz#a93c7a3d4386a1dc8325b97da9bb1620c0282523" + integrity sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -5064,6 +5089,13 @@ latest-version@^5.1.0: dependencies: package-json "^6.3.0" +lcid@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-3.1.1.tgz#9030ec479a058fc36b5e8243ebaac8b6ac582fd0" + integrity sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg== + dependencies: + invert-kv "^3.0.0" + leek@0.0.24: version "0.0.24" resolved "https://registry.yarnpkg.com/leek/-/leek-0.0.24.tgz#e400e57f0e60d8ef2bd4d068dc428a54345dbcda" @@ -5378,6 +5410,13 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +map-age-cleaner@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" + integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + dependencies: + p-defer "^1.0.0" + map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" @@ -5464,6 +5503,15 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +mem@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/mem/-/mem-5.1.1.tgz#7059b67bf9ac2c924c9f1cff7155a064394adfb3" + integrity sha512-qvwipnozMohxLXG1pOqoLiZKNkC4r4qqRucSoDwXowsNGDSULiqFTRUF05vcZWnwJSG22qTsynQhxbaMtnX9gw== + dependencies: + map-age-cleaner "^0.1.3" + mimic-fn "^2.1.0" + p-is-promise "^2.1.0" + memory-streams@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/memory-streams/-/memory-streams-0.1.3.tgz#d9b0017b4b87f1d92f55f2745c9caacb1dc93ceb" @@ -6088,6 +6136,15 @@ os-homedir@^1.0.0: resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= +os-locale@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-5.0.0.tgz#6d26c1d95b6597c5d5317bf5fba37eccec3672e0" + integrity sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA== + dependencies: + execa "^4.0.0" + lcid "^3.0.0" + mem "^5.0.0" + os-name@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/os-name/-/os-name-4.0.1.tgz#32cee7823de85a8897647ba4d76db46bf845e555" @@ -6119,6 +6176,11 @@ p-cancelable@^2.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= + p-defer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-3.0.0.tgz#d1dceb4ee9b2b604b1d94ffec83760175d4e6f83" @@ -6129,6 +6191,11 @@ p-finally@^1.0.0: resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= +p-is-promise@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" + integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== + p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -6956,6 +7023,13 @@ rxjs@^7.2.0: dependencies: tslib "^2.1.0" +rxjs@^7.5.5: + version "7.5.5" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.5.tgz#2ebad89af0f560f460ad5cc4213219e1f7dd4e9f" + integrity sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"