Repository: ember-cli/ember-cli
Pull request #8964 (2019-12-16 22:35:23)

Add support for `ember new @scope-here/name-here`.
Closes https://github.com/ember-cli/ember-cli/issues/4372

kellyselden: For future travellers, we can probably use this https://github.com/npm/npm-package-arg/pull/38 for parsing packages with scopes once I finish it.

Patch:
diff --git a/lib/commands/init.js b/lib/commands/init.js
index dc4792249..e0bb1f8e9 100644
--- a/lib/commands/init.js
+++ b/lib/commands/init.js
@@ -6,7 +6,7 @@ const Command = require('../models/command');
 const Promise = require('rsvp').Promise;
 const SilentError = require('silent-error');
 const chalk = require('chalk');
-const validProjectName = require('../utilities/valid-project-name');
+const isValidProjectName = require('../utilities/valid-project-name');
 const normalizeBlueprint = require('../utilities/normalize-blueprint-option');
 const mergeBlueprintOptions = require('../utilities/merge-blueprint-options');
 const isYarnProject = require('../utilities/is-yarn-project');
@@ -81,7 +81,7 @@ module.exports = Command.extend({
       blueprint: normalizeBlueprint(blueprintOpts.blueprint || this._defaultBlueprint()),
     });
 
-    if (!validProjectName(packageName)) {
+    if (!isValidProjectName(packageName)) {
       return Promise.reject(new SilentError(`We currently do not support a name of \`${packageName}\`.`));
     }
 
diff --git a/lib/commands/new.js b/lib/commands/new.js
index 767ef49ea..dfb8085f4 100644
--- a/lib/commands/new.js
+++ b/lib/commands/new.js
@@ -6,7 +6,7 @@ const Command = require('../models/command');
 const RSVP = require('rsvp');
 const Project = require('../models/project');
 const SilentError = require('silent-error');
-const validProjectName = require('../utilities/valid-project-name');
+const isValidProjectName = require('../utilities/valid-project-name');
 const normalizeBlueprint = require('../utilities/normalize-blueprint-option');
 const mergeBlueprintOptions = require('../utilities/merge-blueprint-options');
 
@@ -40,12 +40,12 @@ module.exports = Command.extend({
   beforeRun: mergeBlueprintOptions,
 
   run(commandOptions, rawArgs) {
-    let packageName = rawArgs[0],
+    let projectName = rawArgs[0],
       message;
 
     commandOptions.name = rawArgs.shift();
 
-    if (!packageName) {
+    if (!projectName) {
       message = `The \`ember ${
         this.name
       }\` command requires a name to be specified. For more details, use \`ember help\`.`;
@@ -57,25 +57,21 @@ module.exports = Command.extend({
       commandOptions.skipGit = true;
     }
 
-    if (packageName === '.') {
+    if (projectName === '.') {
       let blueprintName = commandOptions.blueprint === 'app' ? 'application' : commandOptions.blueprint;
       message = `Trying to generate an ${blueprintName} structure in this directory? Use \`ember init\` instead.`;
 
       return Promise.reject(new SilentError(message));
     }
 
-    if (!validProjectName(packageName)) {
-      message = `We currently do not support a name of \`${packageName}\`.`;
+    if (!isValidProjectName(projectName)) {
+      message = `We currently do not support a name of \`${projectName}\`.`;
 
       return Promise.reject(new SilentError(message));
     }
 
     commandOptions.blueprint = normalizeBlueprint(commandOptions.blueprint);
 
-    if (!commandOptions.directory) {
-      commandOptions.directory = packageName;
-    }
-
     let InitCommand = this.commands.Init;
 
     let initCommand = new InitCommand({
@@ -86,16 +82,20 @@ module.exports = Command.extend({
     });
 
     return this.runTask('CreateAndStepIntoDirectory', {
+      projectName,
       directoryName: commandOptions.directory,
       dryRun: commandOptions.dryRun,
     }).then(opts => {
       initCommand.project.root = process.cwd();
 
       return initCommand.run(commandOptions, rawArgs).catch(err => {
-        let dirName = commandOptions.directory;
-        process.chdir(opts.initialDirectory);
-        return rmdir(dirName).then(() => {
-          console.log(chalk.red(`Error creating new application. Removing generated directory \`./${dirName}\``));
+        let { initialDirectory, projectDirectory } = opts;
+
+        process.chdir(initialDirectory);
+        return rmdir(projectDirectory).then(() => {
+          console.log(
+            chalk.red(`Error creating new application. Removing generated directory \`./${projectDirectory}\``)
+          );
           throw err;
         });
       });
diff --git a/lib/tasks/create-and-step-into-directory.js b/lib/tasks/create-and-step-into-directory.js
index ba2b82023..6908360b6 100644
--- a/lib/tasks/create-and-step-into-directory.js
+++ b/lib/tasks/create-and-step-into-directory.js
@@ -4,23 +4,50 @@
 // this directory.
 
 const fs = require('fs-extra');
+const path = require('path');
 const Task = require('../models/task');
 const SilentError = require('silent-error');
 
+// used in order to infer the directory to use, if `--directory` was specified
+// to `ember new`/`ember addon` it will **always** be used directly (without
+// modification)
+function chooseDirectoryName(projectName) {
+  let isScoped = projectName[0] === '@' && projectName.includes('/');
+
+  if (isScoped) {
+    let slashIndex = projectName.indexOf('/');
+    let scopeName = projectName.substring(1, slashIndex);
+    let packageNameWithoutScope = projectName.substring(slashIndex + 1);
+    let pathParts = process.cwd().split(path.sep);
+
+    let parentDirectoryContainsScopeName = pathParts.includes(scopeName);
+
+    if (parentDirectoryContainsScopeName) {
+      return packageNameWithoutScope;
+    } else {
+      return `${scopeName}-${packageNameWithoutScope}`;
+    }
+  } else {
+    return projectName;
+  }
+}
+
 class CreateTask extends Task {
   // Options: String directoryName, Boolean: dryRun
 
-  warnDirectoryAlreadyExists() {
-    let message = `Directory '${this.directoryName}' already exists.`;
+  warnDirectoryAlreadyExists(directoryName) {
+    let message = `Directory '${directoryName}' already exists.`;
     return new SilentError(message);
   }
 
   async run(options) {
-    let directoryName = (this.directoryName = options.directoryName);
+    let directoryName = options.directoryName ? options.directoryName : chooseDirectoryName(options.projectName);
+
     if (options.dryRun) {
       if (fs.existsSync(directoryName) && fs.readdirSync(directoryName).length) {
-        throw this.warnDirectoryAlreadyExists();
+        throw this.warnDirectoryAlreadyExists(directoryName);
       }
+
       return;
     }
 
@@ -30,7 +57,7 @@ class CreateTask extends Task {
       if (err.code === 'EEXIST') {
         // Allow using directory if it is empty.
         if (fs.readdirSync(directoryName).length) {
-          throw this.warnDirectoryAlreadyExists();
+          throw this.warnDirectoryAlreadyExists(directoryName);
         }
       } else {
         throw err;
@@ -38,7 +65,8 @@ class CreateTask extends Task {
     }
     let cwd = process.cwd();
     process.chdir(directoryName);
-    return { initialDirectory: cwd };
+
+    return { initialDirectory: cwd, projectDirectory: directoryName };
   }
 }
 
diff --git a/lib/utilities/valid-project-name.js b/lib/utilities/valid-project-name.js
index 5874b1e63..36ad71a66 100644
--- a/lib/utilities/valid-project-name.js
+++ b/lib/utilities/valid-project-name.js
@@ -1,5 +1,7 @@
 'use strict';
 
+const INVALID_PROJECT_NAMES = ['test', 'ember', 'ember-cli', 'vendor', 'public', 'app', 'addon', 'application'];
+
 /**
  * Checks if the string starts with a number.
  *
@@ -10,6 +12,15 @@ function startsWithNumber(name) {
   return /^\d.*$/.test(name);
 }
 
+function containsInvalidSlash(name) {
+  let indexOfFirstSlash = name.indexOf('/');
+  let isScoped = name[0] === '@' && indexOfFirstSlash !== -1;
+
+  let containsInvalidSlash = isScoped ? name.indexOf('/', indexOfFirstSlash + 1) > -1 : name.includes('/');
+
+  return containsInvalidSlash;
+}
+
 /**
  * Checks if project name is valid.
  *
@@ -21,11 +32,15 @@ function startsWithNumber(name) {
  * @param {String} name The name of Ember CLI project
  * @return {Boolean}
  */
-module.exports = function(name) {
-  let invalidProjectNames = ['test', 'ember', 'ember-cli', 'vendor', 'public', 'app', 'application'];
-  name = name.toLowerCase();
+module.exports = function isValidProjectName(projectName) {
+  let lowerSanitizedName = projectName.toLowerCase();
 
-  if (invalidProjectNames.indexOf(name) !== -1 || name.indexOf('.') !== -1 || startsWithNumber(name)) {
+  if (
+    INVALID_PROJECT_NAMES.includes(lowerSanitizedName) ||
+    lowerSanitizedName.includes('.') ||
+    containsInvalidSlash(lowerSanitizedName) ||
+    startsWithNumber(lowerSanitizedName)
+  ) {
     return false;
   }
 


Test patch:
diff --git a/tests/acceptance/new-test.js b/tests/acceptance/new-test.js
index 1f84254e0..ab70bd640 100644
--- a/tests/acceptance/new-test.js
+++ b/tests/acceptance/new-test.js
@@ -43,8 +43,8 @@ describe('Acceptance: ember new', function() {
     return tmp.teardown(tmpDir);
   });
 
-  function confirmBlueprintedForDir(dir) {
-    let blueprintPath = path.join(root, dir, 'files');
+  function confirmBlueprintedForDir(blueprintDir, expectedAppDir = 'foo') {
+    let blueprintPath = path.join(root, blueprintDir, 'files');
     let expected = walkSync(blueprintPath);
     let actual = walkSync('.').sort();
     let directory = path.basename(process.cwd());
@@ -55,7 +55,7 @@ describe('Acceptance: ember new', function() {
 
     expected.sort();
 
-    expect(directory).to.equal('foo');
+    expect(directory).to.equal(expectedAppDir);
     expect(expected).to.deep.equal(
       actual,
       `${EOL} expected: ${util.inspect(expected)}${EOL} but got: ${util.inspect(actual)}`
@@ -102,6 +102,32 @@ describe('Acceptance: ember new', function() {
     })
   );
 
+  it('ember new @foo/bar, when parent directory does not contain `foo`', async function() {
+    await ember(['new', '@foo/bar', '--skip-npm', '--skip-bower']);
+
+    confirmBlueprintedForDir('blueprints/app', 'foo-bar');
+  });
+
+  it('ember new @foo/bar, when direct parent directory contains `foo`', async function() {
+    let scopedDirectoryPath = path.join(process.cwd(), 'foo');
+    fs.mkdirsSync(scopedDirectoryPath);
+    process.chdir(scopedDirectoryPath);
+
+    await ember(['new', '@foo/bar', '--skip-npm', '--skip-bower']);
+
+    confirmBlueprintedForDir('blueprints/app', 'bar');
+  });
+
+  it('ember new @foo/bar, when parent directory heirarchy contains `foo`', async function() {
+    let scopedDirectoryPath = path.join(process.cwd(), 'foo', 'packages');
+    fs.mkdirsSync(scopedDirectoryPath);
+    process.chdir(scopedDirectoryPath);
+
+    await ember(['new', '@foo/bar', '--skip-npm', '--skip-bower']);
+
+    confirmBlueprintedForDir('blueprints/app', 'bar');
+  });
+
   it(
     'ember new foo, blueprint targets match the default ember-cli targets',
     co.wrap(function*() {
@@ -358,6 +384,32 @@ describe('Acceptance: ember new', function() {
     })
   );
 
+  it('ember addon @foo/bar when parent directory does not contain `foo`', async function() {
+    await ember(['addon', '@foo/bar', '--skip-npm', '--skip-bower', '--skip-git']);
+
+    let directoryName = path.basename(process.cwd());
+
+    expect(directoryName).to.equal('foo-bar');
+
+    let pkgJson = fs.readJsonSync('package.json');
+    expect(pkgJson.name).to.equal('@foo/bar', 'uses addon name for package name');
+  });
+
+  it('ember addon @foo/bar when parent directory contains `foo`', async function() {
+    let scopedDirectoryPath = path.join(process.cwd(), 'foo');
+    fs.mkdirsSync(scopedDirectoryPath);
+    process.chdir(scopedDirectoryPath);
+
+    await ember(['addon', '@foo/bar', '--skip-npm', '--skip-bower', '--skip-git']);
+
+    let directoryName = path.basename(process.cwd());
+
+    expect(directoryName).to.equal('bar');
+
+    let pkgJson = fs.readJsonSync('package.json');
+    expect(pkgJson.name).to.equal('@foo/bar', 'uses addon name for package name');
+  });
+
   describe('verify fixtures', function() {
     function checkEslintConfig(fixturePath) {
       expect(file('.eslintrc.js')).to.equal(file(path.join(__dirname, '../fixtures', fixturePath, '.eslintrc.js')));
diff --git a/tests/unit/utilities/valid-project-name-test.js b/tests/unit/utilities/valid-project-name-test.js
index 7083f735d..b6673c6e4 100644
--- a/tests/unit/utilities/valid-project-name-test.js
+++ b/tests/unit/utilities/valid-project-name-test.js
@@ -1,18 +1,32 @@
 'use strict';
 
-const validProjectName = require('../../../lib/utilities/valid-project-name');
+const isValidProjectName = require('../../../lib/utilities/valid-project-name');
 const expect = require('chai').expect;
 
-describe('validate project name', function() {
-  ['app', 'public', '.', 'ember', 'so-cool.', 'vendor', 'test', '1234test', 'application'].forEach(name => {
-    it(`'${name}' is an invalid name`, function() {
-      expect(validProjectName(name)).to.not.be.ok;
+describe('lib/utilities/valid-project-name', function() {
+  describe('isValidProjectName', function() {
+    [
+      'app',
+      'addon',
+      'public',
+      '.',
+      'ember',
+      'so-cool.',
+      'vendor',
+      'test',
+      '1234test',
+      'application',
+      '@foo/bar/baz',
+    ].forEach(name => {
+      it(`'${name}' is an invalid name`, function() {
+        expect(isValidProjectName(name)).to.not.be.ok;
+      });
     });
-  });
 
-  ['my-app', 'foobar', 'testbaz'].forEach(name => {
-    it(`'${name}' is a valid name`, function() {
-      expect(validProjectName(name)).to.be.ok;
+    ['my-app', 'foobar', 'testbaz', '@foo/bar'].forEach(name => {
+      it(`'${name}' is a valid name`, function() {
+        expect(isValidProjectName(name)).to.be.ok;
+      });
     });
   });
 });