grunt
Version:
A task-based command line build tool for JavaScript projects.
490 lines (463 loc) • 16.7 kB
JavaScript
/*
* grunt
* https://github.com/cowboy/grunt
*
* Copyright (c) 2012 "Cowboy" Ben Alman
* Licensed under the MIT license.
* http://benalman.com/about/license/
*/
var fs = require('fs');
var path = require('path');
var semver = require('semver');
var prompt = require('prompt');
prompt.message = '[' + '?'.green + ']';
prompt.delimiter = ' ';
// ============================================================================
// TASKS
// ============================================================================
// Get user-specified init defaults (if they exist).
var defaults;
function getDefaults() {
if (defaults) { return defaults; }
defaults = {};
// Search all available init-specific extras paths for a defaults.json file.
var filepaths = file.taskfiles('init/defaults.json');
// Load defaults data.
if (filepaths.length) {
verbose.subhead('Loading defaults');
// Since extras path order goes from most-specific to least-specific, only
// add-in properties that don't already exist.
filepaths.forEach(function(filepath) {
underscore.defaults(defaults, file.readJson(filepath));
});
}
}
// An array of all available license files.
function availableLicenses() {
return file.taskpaths('init/licenses').reduce(function(arr, filepath) {
return arr.concat(fs.readdirSync(filepath).map(function(filename) {
return filename.replace(/^LICENSE-/, '');
}));
}, []);
}
task.registerInitTask('init', 'Generate project scaffolding from a predefined template.', function() {
// Extra arguments will be applied to the template file.
var args = util.toArray(arguments);
// Template name.
var name = args.shift();
// Valid init templates (.js files).
var templates = {};
// Template-related search paths.
var searchpaths = [];
// Iterate over all available init-specific extras paths, building templates
// object and searchpaths index.
this.extraspaths().reverse().forEach(function(dirpath) {
var obj = {path: dirpath, subdirs: []};
searchpaths.unshift(obj);
// Iterate over all files inside init-specific extras paths.
fs.readdirSync(dirpath).forEach(function(filename) {
var filepath = path.join(dirpath, filename);
if (fs.statSync(filepath).isDirectory()) {
// Push init subdirs into searchpaths subdirs array for later use.
obj.subdirs.push(filename);
} else if (fs.statSync(filepath).isFile() && path.extname(filepath) === '.js') {
// Add template (plus its path) to the templates object.
templates[path.basename(filename, '.js')] = path.join(dirpath, filename);
}
});
});
// Abort if a valid template was not specified.
if (!(name && name in templates)) {
log.error('A valid template name must be specified. Valid templates are: ' +
log.wordlist(Object.keys(templates)) + '.');
return false;
}
// Abort if a gruntfile was found (to avoid accidentally nuking it).
if (path.existsSync(path.join(process.cwd(), 'grunt.js'))) {
fail.warn('Beware, grunt.js file already exists.');
}
// This task is asynchronous.
var taskDone = this.async();
// Useful init sub-task-specific utilities.
var init = {
// Expose any user-specified default init values.
defaults: getDefaults(),
// Search init template paths for filename.
srcpath: file.taskfile.bind(file, 'init', name),
// Determine absolute destination file path.
destpath: path.join.bind(path, process.cwd()),
// Given some number of licenses, add properly-named license files to the
// files array.
addLicenseFiles: function(files, licenses) {
var available = availableLicenses();
licenses.forEach(function(license) {
files.push({
src: '../licenses/LICENSE-' + license,
dest: 'LICENSE-' + license
});
});
},
// Given a relative URL, copy a file (optionally processing it through
// a passed callback).
copy: function(srcpath, destpath, callback) {
if (typeof destpath !== 'string') {
callback = destpath;
destpath = srcpath;
}
var abssrcpath = init.srcpath(srcpath);
var absdestpath = init.destpath(destpath);
if (!path.existsSync(abssrcpath)) {
abssrcpath = init.srcpath('../misc/placeholder');
}
verbose.or.write('Writing ' + destpath + '...');
try {
file.copy(abssrcpath, absdestpath, callback);
verbose.or.ok();
} catch(e) {
verbose.or.error();
throw e;
}
},
// Iterate over all files in the passed array, copying the source file to
// the destination, processing the contents.
copyAndProcess: function(files, props) {
files.forEach(function(files) {
init.copy(files.src, files.dest || files.src, function(contents) {
return template.process(contents, props, 'init');
});
});
},
// Save a package.json file in the destination directory. The callback
// can be used to post-process properties to add/remove/whatever.
writePackage: function(filename, props, callback) {
var pkg = {};
// Basic values.
['name', 'title', 'description', 'version', 'homepage'].forEach(function(prop) {
if (prop in props) { pkg[prop] = props[prop]; }
});
// Author.
['name', 'email', 'url'].forEach(function(prop) {
if (props['author_' + prop]) {
if (!pkg.author) { pkg.author = {}; }
pkg.author[prop] = props['author_' + prop];
}
});
// Other stuff.
if ('repository' in props) { pkg.repository = {type: 'git', url: props.repository}; }
if ('bugs' in props) { pkg.bugs = {url: props.bugs}; }
pkg.licenses = props.licenses.map(function(license) {
return {type: license, url: props.homepage + '/blob/master/LICENSE-' + license};
});
pkg.dependencies = {};
pkg.devDependencies = {};
pkg.keywords = [];
// Node/npm-specific (?)
if (props.node_version) { pkg.engines = {node: props.node_version}; }
if (props.node_main) { pkg.main = props.node_main; }
if (props.node_test) {
pkg.scripts = {test: props.node_test};
if (props.node_test.split(' ')[0] === 'grunt') {
pkg.devDependencies.grunt = '~' + grunt.version;
}
}
// Allow final tweaks to the pkg object.
if (callback) { pkg = callback(pkg, props); }
// Write file.
file.write(init.destpath(filename), JSON.stringify(pkg, null, 2));
}
};
// Make args available as flags.
init.flags = {};
args.forEach(function(flag) { init.flags[flag] = true; });
// Execute template code, passing in the init object, done function, and any
// other arguments specified after the init:name:???.
require(templates[name]).apply(null, [init, function() {
// Fail task if errors were logged.
if (task.hadErrors()) { taskDone(false); }
// Otherwise, print a success message.
log.writeln().writeln('Initialized from template "' + name + '".');
// All done!
taskDone();
}].concat(args));
});
// ============================================================================
// HELPERS
// ============================================================================
// Prompt user to override default values passed in obj.
task.registerHelper('prompt', function(defaults, options, done) {
// If defaults are omitted, shuffle arguments a bit.
if (util.kindOf(defaults) === 'array') {
done = options;
options = defaults;
defaults = {};
}
// Keep track of any "sanitize" functions for later use.
var sanitize = {};
options.forEach(function(option) {
if (option.sanitize) {
sanitize[option.name] = option.sanitize;
}
});
// Add one final "are you sure?" prompt.
options.push({
message: 'Are these answers correct?'.green,
name: 'ANSWERS_VALID',
default: 'Y/n'
});
// Ask user for input. This is in an IIFE because it has to execute at least
// once, and might be repeated.
(function ask() {
log.subhead('Please answer the following:');
var result = underscore.clone(defaults);
// Loop over each prompt option.
async.forEachSeries(options, function(option, done) {
// Actually get user input.
function doPrompt() {
prompt.start();
prompt.getInput(option, function(err, line) {
if (err) { return done(err); }
result[option.name] = line;
done();
});
}
// If the default value is a function, execute that function, using the
// value passed into the return callback as the new default value.
if (typeof option.default === 'function') {
option.default(result, function(err, value) {
// Handle errors (there should never be errors).
option.default = err ? '???' : value;
doPrompt();
});
} else {
doPrompt();
}
}, function(err) {
// After all prompt questions have been answered...
if (/y/i.test(result.ANSWERS_VALID)) {
// User accepted all answers. Suspend prompt.
prompt.pause();
// Clean up.
delete result.ANSWERS_VALID;
// Iterate over all results.
Object.keys(result).forEach(function(name) {
// If this value needs to be sanitized, process it now.
if (sanitize[name]) {
result[name] = sanitize[name](result[name], result);
}
// If is value is "none" set it to empty string.
if (result[name] === 'none') {
result[name] = '';
}
});
// Done!
log.writeln();
done(err, result);
} else {
// Otherwise update the default value for each user prompt option...
options.slice(0, -1).forEach(function(option) {
option.default = result[option.name];
});
// ...and start over again.
ask();
}
});
}());
});
// Built-in prompt options for the prompt_for helper.
// These generally follow the node "prompt" module convention, except:
// * The "default" value can be a function which is executed at run-time.
// * An optional "sanitize" function has been added to post-process data.
var prompts = {
name: {
message: 'Project name',
default: function(data, done) {
var type = data.type || '';
// This regexp matches:
// leading type- type. type_
// trailing -type .type _type and/or -js .js _js
var re = new RegExp('^' + type + '[\\-\\._]?|(?:[\\-\\._]?' + type + ')?(?:[\\-\\._]?js)?$', 'ig');
// Strip the above stuff from the current dirname.
var name = path.basename(process.cwd()).replace(re, '');
// Remove anything not a letter, number, dash, dot or underscore.
name = name.replace(/[^\w\-\.]/g, '');
done(null, name);
},
validator: /^[\w\-\.]+$/,
warning: 'Name must be only letters, numbers, dashes, dots or underscores.',
sanitize: function(value, data) {
// An additional value, safe to use as a JavaScript identifier.
data.js_safe_name = value.replace(/[\W_]+/g, '_').replace(/^(\d)/, '_$1');
// The original value must be returned so that "name" isn't unset.
return value;
}
},
title: {
message: 'Project title',
default: function(data, done) {
var title = data.name || '';
title = title.replace(/[\W_]+/g, ' ');
title = title.replace(/\w+/g, function(word) {
return word[0].toUpperCase() + word.slice(1).toLowerCase();
});
title = title.replace(/jquery/i, 'jQuery');
done(null, title);
}
},
description: {
message: 'Description',
default: 'The best project ever.'
},
version: {
message: 'Version',
default: function(data, done) {
// Get a valid semver tag from `git describe --tags` if possible.
task.helper('child_process', {
cmd: 'git',
args: ['describe', '--tags']
}, function(err, result) {
if (result) {
result = result.split('-')[0];
}
done(null, semver.valid(result) || '0.1.0');
});
},
validator: semver.valid,
warning: 'Must be a valid semantic version.'
},
repository: {
message: 'Project git repository',
default: function(data, done) {
// Change any git@...:... uri to git://.../... format.
task.helper('git_origin', function(err, result) {
if (err) {
// Attempt to guess at the repo name. Maybe we'll get lucky!
result = 'git://github.com/' + (process.env.USER || '???') + '/' +
data.name + '.git';
} else {
result = result.replace(/^git@([^:]+):/, 'git://$1/');
}
done(null, result);
});
},
sanitize: function(value, data) {
// An additional computed "git_user" property.
var repo = task.helper('github_web_url', data.repository);
var parts;
if (repo != null) {
parts = repo.split('/');
data.git_user = parts[parts.length - 2];
} else {
data.git_user = process.env.USER || '???';
}
// The original value must be returned so that "repository" isn't unset.
return value;
}
},
homepage: {
message: 'Project homepage',
// If GitHub is the origin, the (potential) homepage is easy to figure out.
default: function(data, done) {
done(null, task.helper('github_web_url', data.repository) || 'none');
}
},
bugs: {
message: 'Project issues tracker',
// If GitHub is the origin, the issues tracker is easy to figure out.
default: function(data, done) {
done(null, task.helper('github_web_url', data.repository, 'issues') || 'none');
}
},
licenses: {
message: 'Licenses',
default: 'MIT',
validator: /^[\w\-]+(?:\s+[\w\-]+)*$/,
warning: 'Must be one or more space-separated licenses. (eg. ' +
availableLicenses().join(' ') + ')',
// Split the string on spaces.
sanitize: function(value) { return value.split(/\s+/); }
},
author_name: {
message: 'Author name',
default: function(data, done) {
// Attempt to pull the data from the user's git config.
task.helper('child_process', {
cmd: 'git',
args: ['config', '--get', 'user.name'],
fallback: 'none'
}, done);
}
},
author_email: {
message: 'Author email',
default: function(data, done) {
// Attempt to pull the data from the user's git config.
task.helper('child_process', {
cmd: 'git',
args: ['config', '--get', 'user.email'],
fallback: 'none'
}, done);
}
},
author_url: {
message: 'Author url',
default: 'none'
},
node_version: {
message: 'What versions of node does it run on?',
default: '>= ' + process.versions.node
},
node_main: {
message: 'Main module/entry point',
default: function(data, done) {
done(null, 'lib/' + data.name);
}
},
node_test: {
message: 'Test command',
default: 'grunt test'
}
};
// Expose prompts object so that prompt_for prompts can be added or modified.
task.registerHelper('prompt_for_obj', function() {
return prompts;
});
// Commonly-used prompt options with meaningful default values.
task.registerHelper('prompt_for', function(name, alternateDefault) {
// Clone the option so the original options object doesn't get modified.
var option = underscore.clone(prompts[name]);
option.name = name;
if (name in getDefaults()) {
// A user default was specified for this option, so use its value.
option.default = getDefaults()[name];
} else if (arguments.length === 2) {
// An alternate default was specified, so use it.
option.default = alternateDefault;
}
return option;
});
// Get the git origin url from the current repo (if possible).
task.registerHelper('git_origin', function(done) {
task.helper('child_process', {
cmd: 'git',
args: ['remote', '-v']
}, function(err, result) {
var re = /^origin/;
if (err || !result) {
done(true, 'none');
} else {
result = result.split('\n').filter(re.test.bind(re))[0];
done(null, result.split(/\s/)[1]);
}
});
});
// Generate a GitHub web URL from a GitHub repo URI.
var githubWebUrlRe = /^.+(?:@|:\/\/)(github.com)[:\/](.+?)(?:\.git|\/)?$/;
task.registerHelper('github_web_url', function(uri, suffix) {
var matches = githubWebUrlRe.exec(uri);
if (!matches) { return null; }
var url = 'https://' + matches[1] + '/' + matches[2];
if (suffix) {
url += '/' + suffix.replace(/^\//, '');
}
return url;
});