UNPKG

16 kBJavaScriptView Raw
1/*
2 * grunt
3 * https://github.com/cowboy/grunt
4 *
5 * Copyright (c) 2012 "Cowboy" Ben Alman
6 * Licensed under the MIT license.
7 * http://benalman.com/about/license/
8 */
9
10var fs = require('fs');
11var path = require('path');
12
13var semver = require('semver');
14
15var prompt = require('prompt');
16prompt.message = '[' + '?'.green + ']';
17prompt.delimiter = ' ';
18
19// ============================================================================
20// TASKS
21// ============================================================================
22
23// Get user-specified init defaults (if they exist).
24var defaults;
25function getDefaults() {
26 if (defaults) { return defaults; }
27 defaults = {};
28 // Search all available init-specific extras paths for a defaults.json file.
29 var filepaths = file.taskfiles('init/defaults.json');
30 // Load defaults data.
31 if (filepaths.length) {
32 verbose.subhead('Loading defaults');
33 // Since extras path order goes from most-specific to least-specific, only
34 // add-in properties that don't already exist.
35 filepaths.forEach(function(filepath) {
36 underscore.defaults(defaults, file.readJson(filepath));
37 });
38 }
39}
40
41// An array of all available license files.
42function availableLicenses() {
43 return file.taskpaths('init/licenses').reduce(function(arr, filepath) {
44 return arr.concat(fs.readdirSync(filepath).map(function(filename) {
45 return filename.replace(/^LICENSE-/, '');
46 }));
47 }, []);
48}
49
50task.registerInitTask('init', 'Initialize a project from a predefined template.', function() {
51 // Extra arguments will be applied to the template file.
52 var args = util.toArray(arguments);
53 // Template name.
54 var name = args.shift();
55 // Valid init templates (.js files).
56 var templates = {};
57 // Template-related search paths.
58 var searchpaths = [];
59 // Iterate over all available init-specific extras paths, building templates
60 // object and searchpaths index.
61 this.extraspaths().reverse().forEach(function(dirpath) {
62 var obj = {path: dirpath, subdirs: []};
63 searchpaths.unshift(obj);
64 // Iterate over all files inside init-specific extras paths.
65 fs.readdirSync(dirpath).forEach(function(filename) {
66 var filepath = path.join(dirpath, filename);
67 if (fs.statSync(filepath).isDirectory()) {
68 // Push init subdirs into searchpaths subdirs array for later use.
69 obj.subdirs.push(filename);
70 } else if (fs.statSync(filepath).isFile() && path.extname(filepath) === '.js') {
71 // Add template (plus its path) to the templates object.
72 templates[path.basename(filename, '.js')] = path.join(dirpath, filename);
73 }
74 });
75 });
76
77 // Abort if a valid template was not specified.
78 if (!(name && name in templates)) {
79 log.error('A valid template name must be specified. Valid templates are: ' +
80 log.wordlist(Object.keys(templates)) + '.');
81 return false;
82 }
83
84 // Abort if a gruntfile was found (to avoid accidentally nuking it).
85 if (path.existsSync(path.join(process.cwd(), 'grunt.js'))) {
86 fail.warn('Beware, grunt.js file already exists.');
87 }
88
89 // This task is asynchronous.
90 var taskDone = this.async();
91
92 // Useful init sub-task-specific utilities.
93 var init = {
94 // Expose any user-specified default init values.
95 defaults: getDefaults(),
96 // Search init template paths for filename.
97 srcpath: file.taskfile.bind(file, 'init', name),
98 // Determine absolute destination file path.
99 destpath: path.join.bind(path, process.cwd()),
100 // Given some number of licenses, add properly-named license files to the
101 // files array.
102 addLicenseFiles: function(files, licenses) {
103 var available = availableLicenses();
104 licenses.forEach(function(license) {
105 files.push({
106 src: '../licenses/LICENSE-' + license,
107 dest: 'LICENSE-' + license
108 });
109 });
110 },
111 // Given a relative URL, copy a file (optionally processing it through
112 // a passed callback).
113 copy: function(srcpath, destpath, callback) {
114 if (typeof destpath !== 'string') {
115 callback = destpath;
116 destpath = srcpath;
117 }
118 var abssrcpath = init.srcpath(srcpath);
119 var absdestpath = init.destpath(destpath);
120 if (!path.existsSync(abssrcpath)) {
121 abssrcpath = init.srcpath('../misc/placeholder');
122 }
123 verbose.or.write('Writing ' + destpath + '...');
124 try {
125 file.copy(abssrcpath, absdestpath, callback);
126 verbose.or.ok();
127 } catch(e) {
128 verbose.or.error();
129 throw e;
130 }
131 },
132 // Iterate over all files in the passed array, copying the source file to
133 // the destination, processing the contents.
134 copyAndProcess: function(files, props) {
135 files.forEach(function(files) {
136 init.copy(files.src, files.dest || files.src, function(contents) {
137 return template.process(contents, props, 'init');
138 });
139 });
140 },
141 // Save a package.json file in the destination directory. The callback
142 // can be used to post-process properties to add/remove/whatever.
143 writePackage: function(filename, props, callback) {
144 var pkg = {};
145 // Basic values.
146 ['name', 'title', 'description', 'version', 'homepage'].forEach(function(prop) {
147 if (prop in props) { pkg[prop] = props[prop]; }
148 });
149 // Author.
150 ['name', 'email', 'url'].forEach(function(prop) {
151 if (props['author_' + prop]) {
152 if (!pkg.author) { pkg.author = {}; }
153 pkg.author[prop] = props['author_' + prop];
154 }
155 });
156 // Other stuff.
157 if ('repository' in props) { pkg.repository = {type: 'git', url: props.repository}; }
158 if ('bugs' in props) { pkg.bugs = {url: props.bugs}; }
159 pkg.licenses = props.licenses.map(function(license) {
160 return {type: license, url: props.homepage + '/blob/master/LICENSE-' + license};
161 });
162 pkg.dependencies = {};
163 pkg.devDependencies = {};
164 pkg.keywords = [];
165 // Node/npm-specific (?)
166 if (props.node_version) { pkg.engines = {node: props.node_version}; }
167 if (props.node_main) { pkg.main = props.node_main; }
168 if (props.node_test) {
169 pkg.scripts = {test: props.node_test};
170 if (props.node_test.split(' ')[0] === 'grunt') {
171 pkg.devDependencies.grunt = '~' + grunt.version;
172 }
173 }
174
175 // Allow final tweaks to the pkg object.
176 if (callback) { pkg = callback(pkg, props); }
177
178 // Write file.
179 file.write(init.destpath(filename), JSON.stringify(pkg, null, 2));
180 }
181 };
182
183 // Make args available as flags.
184 init.flags = {};
185 args.forEach(function(flag) { init.flags[flag] = true; });
186
187 // Execute template code, passing in the init object, done function, and any
188 // other arguments specified after the init:name:???.
189 require(templates[name]).apply(null, [init, function() {
190 // Fail task if errors were logged.
191 if (task.hadErrors()) { taskDone(false); }
192 // Otherwise, print a success message.
193 log.writeln().writeln('Initialized from template "' + name + '".');
194 // All done!
195 taskDone();
196 }].concat(args));
197});
198
199// ============================================================================
200// HELPERS
201// ============================================================================
202
203// Prompt user to override default values passed in obj.
204task.registerHelper('prompt', function(defaults, options, done) {
205 // If defaults are omitted, shuffle arguments a bit.
206 if (util.kindOf(defaults) === 'array') {
207 done = options;
208 options = defaults;
209 defaults = {};
210 }
211
212 // Keep track of any "sanitize" functions for later use.
213 var sanitize = {};
214 options.forEach(function(option) {
215 if (option.sanitize) {
216 sanitize[option.name] = option.sanitize;
217 }
218 });
219
220 // Add one final "are you sure?" prompt.
221 options.push({
222 message: 'Are these answers correct?'.green,
223 name: 'ANSWERS_VALID',
224 default: 'Y/n'
225 });
226
227 // Ask user for input. This is in an IIFE because it has to execute at least
228 // once, and might be repeated.
229 (function ask() {
230 log.subhead('Please answer the following:');
231 var result = underscore.clone(defaults);
232 // Loop over each prompt option.
233 async.forEachSeries(options, function(option, done) {
234 // Actually get user input.
235 function doPrompt() {
236 prompt.start();
237 prompt.getInput(option, function(err, line) {
238 if (err) { return done(err); }
239 result[option.name] = line;
240 done();
241 });
242 }
243 // If the default value is a function, execute that function, using the
244 // value passed into the return callback as the new default value.
245 if (typeof option.default === 'function') {
246 option.default(result, function(err, value) {
247 // Handle errors (there should never be errors).
248 option.default = err ? '???' : value;
249 doPrompt();
250 });
251 } else {
252 doPrompt();
253 }
254 }, function(err) {
255 // After all prompt questions have been answered...
256 if (/y/i.test(result.ANSWERS_VALID)) {
257 // User accepted all answers. Suspend prompt.
258 prompt.pause();
259 // Clean up.
260 delete result.ANSWERS_VALID;
261 // Iterate over all results.
262 Object.keys(result).forEach(function(name) {
263 // If this value needs to be sanitized, process it now.
264 if (sanitize[name]) {
265 result[name] = sanitize[name](result[name], result);
266 }
267 // If is value is "none" set it to empty string.
268 if (result[name] === 'none') {
269 result[name] = '';
270 }
271 });
272 // Done!
273 log.writeln();
274 done(err, result);
275 } else {
276 // Otherwise update the default value for each user prompt option...
277 options.slice(0, -1).forEach(function(option) {
278 option.default = result[option.name];
279 });
280 // ...and start over again.
281 ask();
282 }
283 });
284 }());
285});
286
287// Built-in prompt options for the prompt_for helper.
288// These generally follow the node "prompt" module convention, except:
289// * The "default" value can be a function which is executed at run-time.
290// * An optional "sanitize" function has been added to post-process data.
291var prompts = {
292 name: {
293 message: 'Project name',
294 default: function(data, done) {
295 var type = data.type || '';
296 // This regexp matches:
297 // leading type- type. type_
298 // trailing -type .type _type and/or -js .js _js
299 var re = new RegExp('^' + type + '[\\-\\._]?|(?:[\\-\\._]?' + type + ')?(?:[\\-\\._]?js)?$', 'ig');
300 // Strip the above stuff from the current dirname.
301 var name = path.basename(process.cwd()).replace(re, '');
302 // Remove anything not a letter, number, dash, dot or underscore.
303 name = name.replace(/[^\w\-\.]/g, '');
304 done(null, name);
305 },
306 validator: /^[\w\-\.]+$/,
307 warning: 'Name must be only letters, numbers, dashes, dots or underscores.',
308 sanitize: function(value, obj) {
309 // An additional value, safe to use as a JavaScript identifier.
310 obj.js_safe_name = value.replace(/[\W_]+/g, '_').replace(/^(\d)/, '_$1');
311 // The original value must be returned so that "name" isn't unset.
312 return value;
313 }
314 },
315 title: {
316 message: 'Project title',
317 default: function(data, done) {
318 var title = data.name || '';
319 title = title.replace(/[\W_]+/g, ' ');
320 title = title.replace(/\w+/g, function(word) {
321 return word[0].toUpperCase() + word.slice(1).toLowerCase();
322 });
323 done(null, title);
324 }
325 },
326 description: {
327 message: 'Description',
328 default: 'The best project ever.'
329 },
330 version: {
331 message: 'Version',
332 default: function(data, done) {
333 // Get a valid semver tag from `git describe --tags` if possible.
334 task.helper('child_process', {
335 cmd: 'git',
336 args: ['describe', '--tags']
337 }, function(err, result) {
338 if (result) {
339 result = result.split('-')[0];
340 }
341 done(null, semver.valid(result) || '0.1.0');
342 });
343 },
344 validator: semver.valid,
345 warning: 'Must be a valid semantic version.'
346 },
347 repository: {
348 message: 'Project git repository',
349 default: function(data, done) {
350 // Change any git@...:... uri to git://.../... format.
351 task.helper('git_origin', function(err, result) {
352 if (!err) {
353 result = result.replace(/^git@([^:]+):/, 'git://$1/');
354 }
355 done(null, result);
356 });
357 }
358 },
359 homepage: {
360 message: 'Project homepage',
361 // If GitHub is the origin, the (potential) homepage is easy to figure out.
362 default: function(data, done) {
363 done(null, task.helper('github_web_url', data.repository) || 'none');
364 }
365 },
366 bugs: {
367 message: 'Project issues tracker',
368 // If GitHub is the origin, the issues tracker is easy to figure out.
369 default: function(data, done) {
370 done(null, task.helper('github_web_url', data.repository, 'issues') || 'none');
371 }
372 },
373 licenses: {
374 message: 'Licenses',
375 default: 'MIT',
376 validator: /^[\w\-]+(?:\s+[\w\-]+)*$/,
377 warning: 'Must be one or more space-separated licenses. (eg. ' +
378 availableLicenses().join(' ') + ')',
379 // Split the string on spaces.
380 sanitize: function(value) { return value.split(/\s+/); }
381 },
382 author_name: {
383 message: 'Author name',
384 default: function(data, done) {
385 // Attempt to pull the data from the user's git config.
386 task.helper('child_process', {
387 cmd: 'git',
388 args: ['config', '--get', 'user.name'],
389 fallback: 'none'
390 }, done);
391 }
392 },
393 author_email: {
394 message: 'Author email',
395 default: function(data, done) {
396 // Attempt to pull the data from the user's git config.
397 task.helper('child_process', {
398 cmd: 'git',
399 args: ['config', '--get', 'user.email'],
400 fallback: 'none'
401 }, done);
402 }
403 },
404 author_url: {
405 message: 'Author url',
406 default: 'none'
407 },
408 node_version: {
409 message: 'What versions of node does it run on?',
410 default: '>= ' + process.versions.node
411 },
412 node_main: {
413 message: 'Main module/entry point',
414 default: function(data, done) {
415 done(null, 'lib/' + data.name);
416 }
417 },
418 node_test: {
419 message: 'Test command',
420 default: 'grunt test'
421 }
422};
423
424// Expose prompts object so that prompt_for prompts can be added or modified.
425task.registerHelper('prompt_for_obj', function() {
426 return prompts;
427});
428
429// Commonly-used prompt options with meaningful default values.
430task.registerHelper('prompt_for', function(name, alternateDefault) {
431 // Clone the option so the original options object doesn't get modified.
432 var option = underscore.clone(prompts[name]);
433 option.name = name;
434
435 if (name in getDefaults()) {
436 // A user default was specified for this option, so use its value.
437 option.default = getDefaults()[name];
438 } else if (arguments.length === 2) {
439 // An alternate default was specified, so use it.
440 option.default = alternateDefault;
441 }
442 return option;
443});
444
445// Get the git origin url from the current repo (if possible).
446task.registerHelper('git_origin', function(done) {
447 task.helper('child_process', {
448 cmd: 'git',
449 args: ['remote', '-v']
450 }, function(err, result) {
451 var re = /^origin/;
452 if (err || !result) {
453 done(true, 'none');
454 } else {
455 result = result.split('\n').filter(re.test.bind(re))[0];
456 done(null, result.split(/\s/)[1]);
457 }
458 });
459});
460
461// Generate a GitHub web URL from a GitHub repo URI.
462var githubWebUrlRe = /^.+(?:@|:\/\/)(github.com)[:\/](.+?)(?:\.git|\/)?$/;
463task.registerHelper('github_web_url', function(uri, suffix) {
464 var matches = githubWebUrlRe.exec(uri);
465 if (!matches) { return null; }
466 var url = 'https://' + matches[1] + '/' + matches[2];
467 if (suffix) {
468 url += '/' + suffix.replace(/^\//, '');
469 }
470 return url;
471});