ember-cli
Version:
Command line tool for developing ambitious ember.js apps
834 lines (684 loc) • 20.5 kB
JavaScript
'use strict';
var FileInfo = require('./file-info');
var Promise = require('../ext/promise');
var any = require('lodash-node/compat/collections/some');
var chalk = require('chalk');
var fs = require('fs-extra');
var glob = require('glob');
var merge = require('lodash-node/compat/objects/merge');
var minimatch = require('minimatch');
var path = require('path');
var sequence = require('../utilities/sequence');
var stat = Promise.denodeify(fs.stat);
var stringUtils = require('../utilities/string');
var uniq = require('lodash-node/compat/arrays/uniq');
var walkSync = require('walk-sync');
var writeFile = Promise.denodeify(fs.outputFile);
var removeFile = Promise.denodeify(fs.remove);
var SilentError = require('../errors/silent');
var CoreObject = require('core-object');
module.exports = Blueprint;
/*
@class Blueprint
@extends CoreObject
@param {String} [blueprintPath]
A `Blueprint` is a bundle of template files with optional
install logic. Ember CLI uses blueprints to generate new
projects via `ember new` and `ember init`, and project
resources via `ember generate`.
Blueprints follow a simple structure. Let's take the built-in
`controller` blueprint as an example:
```
blueprints/controller
├── files
│ ├── app
│ │ └── controllers
│ │ └── __name__.js
│ └── tests
│ └── unit
│ └── controllers
│ └── __name__-test.js
└── index.js
```
## Files
`files` contains templates for the all the files to be
installed into the target directory.
The `__name__` placeholder is subtituted with the dasherized
entity name at install time. For example, when the user
invokes `ember generate controller foo` then `__name__` becomes
`foo`.
## Template Variables (AKA Locals)
Variables can be inserted into templates with
`<%= someVariableName %>`.
For example, in the built-in `util` blueprint
`files/app/utils/__name__.js` looks like this:
```js
export default function <%= camelizedModuleName %>() {
return true;
}
```
`<%= camelizedModuleName %>` is replaced with the real
value at install time.
The following template variables provided by default:
- `dasherizedPackageName`
- `classifiedPackageName`
- `dasherizedModuleName`
- `classifiedModuleName`
- `camelizedModuleName`
`packageName` is the project name as found in the project's
`package.json`.
`moduleName` is the name of the entity being generated.
The mechanism for providing custom template variables is
described below.
## Index.js
`index.js` contains a subclass of `Blueprint`. Use this
to customize installation behaviour.
```js
var Blueprint = require('ember-cli/lib/models/blueprint');
module.exports = Blueprint.extend({
locals: function(options) {
// Return custom template variables here.
return {};
},
afterInstall: function(options) {
// Perform extra work here.
}
});
```
As shown above, there are two hooks available:
`locals` and `afterInstall`.
## Locals
Use `locals` to add custom tempate variables. The method
recieves one argument: `options`. Options is an object
containing general and entity-specific install options.
When the following is called on the command line:
```sh
$ ember generate controller foo type:array --dry-run
```
The object passed to `locals` looks like this:
```js
{
entity: {
name: 'foo',
options: {
type: 'array'
}
},
dryRun: true
}
```
This hook must return an object. It will be merged with the
aforementioned default locals.
## afterInstall
The `afterInstall` hook receives the same options as `locals`.
Use it to perform any custom work after the files are
installed. For example, the built-in `route` blueprint uses
the `afterInstall` hook to add relevant route declarations
to `app/router.js`.
## Overriding Install
If you don't want your blueprint to install the contents of
`files` you can override the `install` method. It receives the
same `options` object described above and must return a promise.
See the built-in `resource` blueprint for an example of this.
*/
function Blueprint(blueprintPath) {
this.path = blueprintPath;
this.name = path.basename(blueprintPath);
}
Blueprint.__proto__ = CoreObject;
Blueprint.prototype.constructor = Blueprint;
/*
@method files
@return {Array} Contents of the blueprint's files directory
*/
Blueprint.prototype.files = function() {
if (this._files) { return this._files; }
var filesPath = path.join(this.path, 'files');
if (fs.existsSync(filesPath)) {
this._files = walkSync(path.join(this.path, 'files'));
} else {
this._files = [];
}
return this._files;
};
/*
@method srcPath
@param {String} file
@return {String} Resolved path to the file
*/
Blueprint.prototype.srcPath = function(file) {
return path.resolve(this.path, 'files', file);
};
/*
Hook for normalizing entity name
@method normalizeEntityName
@param {String} entityName
@return {null}
*/
Blueprint.prototype.normalizeEntityName = function(entityName) {
if (!entityName) {
throw new SilentError('The `ember generate` command requires an ' +
'entity name to be specified. ' +
'For more details, use `ember help`.');
}
var trailingSlash = /(\/$|\\$)/;
if(trailingSlash.test(entityName)) {
throw new SilentError('You specified "' + entityName + '", but you can\'t use a ' +
'trailing slash as an entity name with generators. Please ' +
're-run the command with "' + entityName.replace(trailingSlash, '') + '".');
}
return entityName;
};
/*
@method install
@param {Object} options
@return {Promise}
*/
Blueprint.prototype.install = function(options) {
var ui = this.ui = options.ui;
var intoDir = options.target;
var dryRun = options.dryRun;
this.project = options.project;
this.testing = options.testing;
var actions = {
write: function(info) {
ui.writeLine(' ' + chalk.green('create') + ' ' + info.displayPath);
if (!dryRun) {
return writeFile(info.outputPath, info.render());
}
},
skip: function(info) {
var label = 'skip';
if (info.resolution === 'identical') {
label = 'identical';
}
ui.writeLine(' ' + chalk.yellow(label) + ' ' + info.displayPath);
},
overwrite: function(info) {
ui.writeLine(' ' + chalk.yellow('overwrite') + ' ' + info.displayPath);
if (!dryRun) {
return writeFile(info.outputPath, info.render());
}
},
edit: function(info) {
ui.writeLine(' ' + chalk.green('edited') + ' ' + info.displayPath);
}
};
function commit(result) {
var action = actions[result.action];
if (action) {
return action(result);
} else {
throw new Error('Tried to call action \"' + result.action + '\" but it does not exist');
}
}
ui.writeLine('installing');
if (dryRun) {
ui.writeLine(chalk.yellow('You specified the dry-run flag, so no changes will be written.'));
}
if(options.entity) {
options.entity.name = this.normalizeEntityName(options.entity.name);
}
var locals = this._locals(options);
return Promise.resolve()
.then(this.beforeInstall.bind(this, options))
.then(this.processFiles.bind(this, intoDir, locals)).map(commit)
.then(this.afterInstall.bind(this, options));
};
/*
@method uninstall
@param {Object} options
@return {Promise}
*/
Blueprint.prototype.uninstall = function(options) {
var ui = this.ui = options.ui;
var intoDir = options.target;
var dryRun = options.dryRun;
var packageName = options.project.name();
var moduleName = options.entity && options.entity.name || packageName;
var locals = { dasherizedModuleName: stringUtils.dasherize(moduleName) };
this.project = options.project;
var actions = {
remove: function(info) {
ui.writeLine(' ' + chalk.red('remove') + ' ' + info.displayPath);
if (!dryRun) {
return removeFile(info.outputPath);
}
}
};
function commit(result) {
var action = actions[result.action];
if (action) {
return action(result);
} else {
throw new Error('Tried to call action \"' + result.action + '\" but it does not exist');
}
}
ui.writeLine('uninstalling');
if (dryRun) {
ui.writeLine(chalk.yellow('You specified the dry-run flag, so no files will be deleted.'));
}
if(options.entity) {
options.entity.name = this.normalizeEntityName(options.entity.name);
}
return Promise.resolve()
.then(this.beforeUninstall.bind(this, options))
.then(this.processFilesForUninstall.bind(this, intoDir, locals)).map(commit)
.then(this.afterUninstall.bind(this, options));
};
/*
Hook for running operations before install.
@method beforeInstall
@return {Promise|null}
*/
Blueprint.prototype.beforeInstall = function() {};
/*
Hook for running operations after install.
@method afterInstall
@return {Promise|null}
*/
Blueprint.prototype.afterInstall = function() {};
/*
Hook for running operations before uninstall.
@method beforeUninstall
@return {Promise|null}
*/
Blueprint.prototype.beforeUninstall = function() {};
/*
Hook for running operations after uninstall.
@method afterUninstall
@return {Promise|null}
*/
Blueprint.prototype.afterUninstall = function() {};
/*
Hook for adding additional locals
@method locals
@return {Object|null}
*/
Blueprint.prototype.locals = function() {};
/*
@method buildFileInfo
@param {Function} destPath
@param {Object} templateVariables
@param {String} file
@return {FileInfo}
*/
Blueprint.prototype.buildFileInfo = function(destPath, templateVariables, file) {
var mappedPath = this.mapFile(file, templateVariables);
return new FileInfo({
action: 'write',
outputPath: destPath(mappedPath),
displayPath: mappedPath,
inputPath: this.srcPath(file),
templateVariables: templateVariables,
ui: this.ui
});
};
/*
@method isUpdate
@return {Boolean}
*/
Blueprint.prototype.isUpdate = function() {
if (this.project && this.project.isEmberCLIProject) {
return this.project.isEmberCLIProject();
}
};
/*
@method processFiles
@param {String} intoDir
@param {Object} templateVariables
*/
Blueprint.prototype.processFiles = function(intoDir, templateVariables) {
function destPath(file) {
return path.join(intoDir, file);
}
var fileInfos = this.files().
map(this.buildFileInfo.bind(this, destPath, templateVariables));
if (this.isUpdate()) {
Blueprint.ignoredFiles = Blueprint.ignoredFiles.concat(Blueprint.ignoredUpdateFiles);
}
function isValidFile(fileInfo) {
if (isIgnored(fileInfo)) {
return Promise.resolve(false);
} else {
return isFile(fileInfo);
}
}
return Promise.filter(fileInfos, isValidFile).
map(prepareConfirm).
then(function(infos) {
infos.forEach(markIdenticalToBeSkipped);
var infosNeedingConfirmation = infos.reduce(gatherConfirmationMessages, []);
return sequence(infosNeedingConfirmation).returns(infos);
});
};
/*
@method processFilesForUninstall
@param {String} intoDir
@param {Object} templateVariables
*/
Blueprint.prototype.processFilesForUninstall = function(intoDir, templateVariables) {
function destPath(file) {
return path.join(intoDir, file);
}
var fileInfos = this.files().
map(this.buildFileInfo.bind(this, destPath, templateVariables));
if (this.isUpdate()) {
Blueprint.ignoredFiles = Blueprint.ignoredFiles.concat(Blueprint.ignoredUpdateFiles);
}
function isValidFile(fileInfo) {
if (isIgnored(fileInfo)) {
return Promise.resolve(false);
} else {
return isFile(fileInfo);
}
}
return Promise.filter(fileInfos, isValidFile).then(function(infos) {
infos.forEach(function(info) {
info.action = 'remove';
});
return infos;
});
};
/*
@method mapFile
@param {String} file
@return {String}
*/
Blueprint.prototype.mapFile = function(file, locals) {
file = Blueprint.renamedFiles[file] || file;
return file.replace(/__name__/g, locals.dasherizedModuleName);
};
/*
@private
@method _locals
@param {Object} options
@return {Object}
*/
Blueprint.prototype._locals = function(options) {
var packageName = options.project.name();
var moduleName = options.entity && options.entity.name || packageName;
var sanitizedModuleName = moduleName.replace(/\//g, '-');
var standardLocals = {
dasherizedPackageName: stringUtils.dasherize(packageName),
classifiedPackageName: stringUtils.classify(packageName),
dasherizedModuleName: stringUtils.dasherize(moduleName),
classifiedModuleName: stringUtils.classify(sanitizedModuleName),
camelizedModuleName: stringUtils.camelize(sanitizedModuleName)
};
var customLocals = this.locals(options);
return merge({}, standardLocals, customLocals);
};
/*
Used to add a package to the projects `package.json`.
Generally, this would be done from the `afterInstall` hook, to
ensure that a package that is required by a given blueprint is
available.
@method addPackageToProject
@param {String} packageName
@param {String} version
@return {Promise}
*/
Blueprint.prototype.addPackageToProject = function(packageName, version) {
var command = 'npm install --save-dev ' + packageName;
var ui = this.ui;
if (version) {
command += '@' + version;
}
if (ui) {
ui.writeLine(' ' + chalk.green('install package') + ' ' + packageName);
}
return this._exec(command);
};
/*
Used to add a package to the projects `bower.json`.
Generally, this would be done from the `afterInstall` hook, to
ensure that a package that is required by a given blueprint is
available.
@method addBowerPackageToProject
@param {String} packageName
@param {String} target
@return {Promise}
*/
Blueprint.prototype.addBowerPackageToProject = function(packageName, target) {
var task = this.taskFor('bower-install');
var packageNameAndVersion = packageName;
if (target) {
packageNameAndVersion += '#' + target;
}
return task.run({
verbose: true,
packages: [packageNameAndVersion]
});
};
/*
Used to retrieve a task with the given name. Passes the new task
the standard information available (like `ui`, `analytics`, `project`, etc).
@method taskFor
@param dasherizedName
@public
*/
Blueprint.prototype.taskFor = function(dasherizedName) {
var Task = require('../tasks/' + dasherizedName);
return new Task({
ui: this.ui,
project: this.project,
analytics: this.analytics
});
};
/*
Inserts the given content into a file. If the `contentsToInsert` string is already
present in the current contents, the file will not be changed unless `force` option
is passed.
This method currently, only inserts the new contents at the end of the file.
@method insertIntoFile
@param {String} pathRelativeToProjectRoot
@param {String} contentsToInsert
@param {Object} options
@return {Promise}
*/
Blueprint.prototype.insertIntoFile = function(pathRelativeToProjectRoot, contentsToInsert, providedOptions) {
var fullPath = path.join(this.project.root, pathRelativeToProjectRoot);
var originalContents = '';
if (fs.existsSync(fullPath)) {
originalContents = fs.readFileSync(fullPath, { encoding: 'utf8' });
}
var contentsToWrite = originalContents;
var options = providedOptions || {};
var alreadyPresent = originalContents.indexOf(contentsToInsert) > -1;
var insert = !alreadyPresent;
if (options.force) { insert = true; }
if (insert) {
contentsToWrite += contentsToInsert;
}
var returnValue = {
path: fullPath,
originalContents: originalContents,
contents: contentsToWrite,
inserted: false
};
if (contentsToWrite !== originalContents) {
returnValue.inserted = true;
return writeFile(fullPath, contentsToWrite)
.then(function() {
return returnValue;
});
} else {
return Promise.resolve(returnValue);
}
};
/*
@private
@method _exec
@param {String} command
@return {Promise}
*/
Blueprint.prototype._exec = function(command) {
var exec = Promise.denodeify(require('child_process').exec);
if (this.testing) {
return Promise.resolve();
} else {
return exec(command);
}
};
/*
@static
@method lookup
@namespace Blueprint
@param {String} [name]
@param {Object} [options]
@param {Array} [options.paths] Extra paths to search for blueprints
@param {Object} [options.properties] Properties
@return {Blueprint}
*/
Blueprint.lookup = function(name, options) {
options = options || {};
var lookupPaths = generateLookupPaths(options.paths);
var lookupPath;
var blueprintPath;
var constructorPath;
var blueprintModule;
var Constructor;
for (var i = 0; lookupPath = lookupPaths[i]; i++) {
blueprintPath = path.resolve(lookupPath, name);
if (!fs.existsSync(blueprintPath)) {
continue;
}
constructorPath = path.resolve(blueprintPath, 'index.js');
if (fs.existsSync(constructorPath)) {
blueprintModule = require(constructorPath);
if (typeof blueprintModule === 'function') {
Constructor = blueprintModule;
} else {
Constructor = Blueprint.extend(blueprintModule);
}
} else {
Constructor = Blueprint;
}
return new Constructor(blueprintPath);
}
throw new SilentError('Unknown blueprint: ' + name);
};
/*
@static
@method list
@namespace Blueprint
@param {Object} [options]
@param {Array} [options.paths] Extra paths to search for blueprints
@return {Blueprint}
*/
Blueprint.list = function(options) {
options = options || {};
var lookupPaths = generateLookupPaths(options.paths);
return lookupPaths.map(function(lookupPath) {
var source = path.basename(path.join(lookupPath, '..'));
var blueprints = glob.sync(path.join(lookupPath, '*'));
blueprints = blueprints.map(function(blueprint) {
return path.basename(blueprint);
});
return {
source: source,
blueprints: blueprints
};
});
};
/*
@static
@property renameFiles
*/
Blueprint.renamedFiles = {
'gitignore': '.gitignore'
};
/*
@static
@property ignoredFiles
*/
Blueprint.ignoredFiles = [
'.DS_Store'
];
/*
@static
@property ignoredUpdateFiles
*/
Blueprint.ignoredUpdateFiles = [
'.gitkeep',
'app.css'
];
/*
@static
@property defaultLookupPaths
*/
Blueprint.defaultLookupPaths = function() {
return [
path.resolve(__dirname, '..', '..', 'blueprints')
];
};
/*
@private
@method prepareConfirm
@param {FileInfo} info
@return {Promise}
*/
function prepareConfirm(info) {
return info.checkForConflict().then(function(resolution) {
info.resolution = resolution;
return info;
});
}
/*
@private
@method markIdenticalToBeSkipped
@param {FileInfo} info
*/
function markIdenticalToBeSkipped(info) {
if (info.resolution === 'identical') {
info.action = 'skip';
}
}
/*
@private
@method gatherConfirmationMessages
@param {Array} collection
@param {FileInfo} info
@return {Array}
*/
function gatherConfirmationMessages(collection, info) {
if (info.resolution === 'confirm') {
collection.push(info.confirmOverwriteTask());
}
return collection;
}
/*
@private
@method isFile
@param {FileInfo} info
@return {Boolean}
*/
function isFile(info) {
return stat(info.inputPath).invoke('isFile');
}
/*
@private
@method isIgnored
@param {FileInfo} info
@return {Boolean}
*/
function isIgnored(info) {
var fn = info.inputPath;
return any(Blueprint.ignoredFiles, function(ignoredFile) {
return minimatch(fn, ignoredFile, { matchBase: true });
});
}
/*
Combines provided lookup paths with defaults and removes
duplicates.
@private
@method generateLookupPaths
@param {Array} lookupPaths
@return {Array}
*/
function generateLookupPaths(lookupPaths) {
lookupPaths = lookupPaths || [];
lookupPaths = lookupPaths.concat(Blueprint.defaultLookupPaths());
return uniq(lookupPaths);
}