hzw-init
Version:
Init react project helper tools.
416 lines (380 loc) • 12.9 kB
JavaScript
'use strict';
const os = require('os');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const urllib = require('urllib');
const updater = require('npm-updater');
const mkdirp = require('mkdirp');
const inquirer = require('inquirer');
const yargs = require('yargs');
const memFs = require('mem-fs');
const editor = require('mem-fs-editor');
const glob = require('glob');
const is = require('is-type-of');
const homedir = require('node-homedir');
const groupBy = require('group-object');
const compressing = require('compressing');
const rimraf = require('mz-modules/rimraf');
const isTextPath = require('is-text-path');
const ProxyAgent = require('proxy-agent');
require('colors');
module.exports = class Command {
constructor(options) {
options = options || {};
this.name = options.name || 'hzw-init';
this.configName = options.configName || 'hzw-init-config';
this.pkgInfo = options.pkgInfo || require('../package.json');
this.needUpdate = options.needUpdate !== false;
this.httpClient = urllib.create();
this.inquirer = inquirer;
this.fileMapping = {
gitignore: '.gitignore',
_gitignore: '.gitignore',
'_.gitignore': '.gitignore',
'_package.json': 'package.json',
'_.eslintignore': '.eslintignore',
'_.npmignore': '.npmignore',
};
}
* run(cwd, args) {
const argv = this.argv = this.getParser().parse(args || []);
this.cwd = cwd;
// console.log('%j', argv);
const proxyHost = process.env.http_proxy || process.env.HTTP_PROXY;
if (proxyHost) {
const proxyAgent = new ProxyAgent(proxyHost);
this.httpClient.agent = proxyAgent;
this.httpClient.httpsAgent = proxyAgent;
this.log(`use http_proxy: ${proxyHost}`);
}
// detect registry url
this.registryUrl = this.getRegistryByType(argv.registry);
this.log(`use registry: ${this.registryUrl}`);
if (this.needUpdate) {
// check update
yield updater({
package: this.pkgInfo,
registry: this.registryUrl,
level: 'major',
});
}
// ask for target dir
this.targetDir = yield this.getTargetDirectory();
// use local template
let templateDir = yield this.getTemplateDir();
if (!templateDir) {
// support --package=<npm name>
let pkgName = 'hzw-boilerplate';
// pkgName = 'egg-boilerplate-simple'
templateDir = yield this.downloadBoilerplate(pkgName);
}
// copy template
yield this.processFiles(this.targetDir, templateDir);
// done
this.printUsage(this.targetDir);
}
/**
* ask user to provide variables which is defined at boilerplate
* @param {String} targetDir - target dir
* @param {String} templateDir - template dir
* @return {Object} variable scope
*/
* askForVariable(targetDir, templateDir) {
let questions;
try {
questions = require(templateDir);
// support function
if (is.function(questions)) {
questions = questions(this);
}
// use target dir name as `name` default
if (questions.name && !questions.name.default) {
questions.name.default = path.basename(targetDir).replace(/^hzw-/, '');
}
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') {
this.log(`load boilerplate config got trouble, skip and use defaults, ${err.message}`.yellow);
}
return {};
}
this.log('collecting boilerplate config...');
const keys = Object.keys(questions);
if (this.argv.silent) {
const result = keys.reduce((result, key) => {
const defaultFn = questions[key].default;
const filterFn = questions[key].filter;
if (typeof defaultFn === 'function') {
result[key] = defaultFn(result) || '';
} else {
result[key] = questions[key].default || '';
}
if (typeof filterFn === 'function') {
result[key] = filterFn(result[key]) || '';
}
return result;
}, {});
this.log('use default due to --silent, %j', result);
return result;
} else {
return yield inquirer.prompt(keys.map(key => {
const question = questions[key];
return {
type: 'input',
name: key,
message: question.description || question.desc,
default: question.default,
filter: question.filter,
};
}));
}
}
/**
* copy boilerplate to target dir with template scope replace
* @param {String} targetDir - target dir
* @param {String} templateDir - template dir, must contain a folder which named `boilerplate`
* @return {Array} file names
*/
* processFiles(targetDir, templateDir) {
const src = path.join(templateDir, 'boilerplate');
const locals = yield this.askForVariable(targetDir, templateDir);
const fsEditor = editor.create(memFs.create());
const files = glob.sync('**/*', { cwd: src, dot: true, nodir: true });
files.forEach(file => {
const from = path.join(src, file);
const to = path.join(targetDir, this.replaceTemplate(this.fileMapping[file] || file, locals));
fsEditor.copy(from, to, {
process: (content, filePath) => {
this.log('write to %s', to);
if (isTextPath(filePath)) {
return this.replaceTemplate(content, locals);
}
return content;
},
});
});
// write file to disk
// yield new Promise(resolve => fsEditor.commit(resolve));
fsEditor.commit(() => true)
// this.log('finished!');
return files;
}
/**
* get argv parser
* @return {Object} yargs instance
*/
getParser() {
return yargs
.usage('init hzw project from boilerplate.\nUsage: $0 [dir]')
.options(this.getParserOptions())
.alias('h', 'help')
.alias('v', 'version')
.version()
.help();
}
/**
* get yargs options
* @return {Object} opts
*/
getParserOptions() {
return {
dir: {
type: 'string',
description: 'target directory',
},
};
}
/**
* get registryUrl by short name
* @param {String} key - short name, support `china / npm / npmrc`, default to read from .npmrc
* @return {String} registryUrl
*/
getRegistryByType(key) {
switch (key) {
case 'china':
return 'https://registry.npm.taobao.org';
case 'npm':
return 'https://registry.npmjs.org';
default: {
if (/^https?:/.test(key)) {
return key.replace(/\/$/, '');
} else {
// support .npmrc
const home = homedir();
let url = process.env.npm_registry || process.env.npm_config_registry || 'https://registry.npmjs.org';
if (fs.existsSync(path.join(home, '.cnpmrc')) || fs.existsSync(path.join(home, '.tnpmrc'))) {
url = 'https://registry.npm.taobao.org';
}
url = url.replace(/\/$/, '');
return url;
}
}
}
}
/**
* ask for target directory, will check if dir is valid.
* @return {String} Full path of target directory
*/
* getTargetDirectory() {
const dir = this.argv._[0] || this.argv.dir || '';
let targetDir = path.resolve(this.cwd, dir);
const force = this.argv.force;
const validate = dir => {
// create dir if not exist
if (!fs.existsSync(dir)) {
mkdirp.sync(dir);
return true;
}
// not a directory
if (!fs.statSync(dir).isDirectory()) {
return `${dir} already exists as a file`.red;
}
// check if directory empty
const files = fs.readdirSync(dir).filter(name => name[0] !== '.');
if (files.length > 0) {
if (force) {
this.log(`${dir} already exists and will be override due to --force`.red);
return true;
}
return `${dir} already exists and not empty: ${JSON.stringify(files)}`.red;
}
return true;
};
// if argv dir is invalid, then ask user
const isValid = validate(targetDir);
if (isValid !== true) {
this.log(isValid);
const answer = yield this.inquirer.prompt({
name: 'dir',
message: 'Please enter target dir: ',
default: dir || '.',
filter: dir => path.resolve(this.cwd, dir),
validate,
});
targetDir = answer.dir;
}
this.log(`target dir is ${targetDir}`);
return targetDir;
}
/**
* find template dir from support `--template=`
* @return {String} template files dir
*/
* getTemplateDir() {
let templateDir;
// when use `hzw-init --template=PATH`
if (this.argv.template) {
this.log('has privite template')
templateDir = path.resolve(this.cwd, this.argv.template);
if (!fs.existsSync(templateDir)) {
this.log(`${templateDir} is not exists`.red);
} else if (!fs.existsSync(path.join(templateDir, 'boilerplate'))) {
this.log(`${templateDir} should contain boilerplate folder`.red);
} else {
this.log(`local template dir is ${templateDir.green}`);
return templateDir;
}
}
}
/**
* fetch boilerplate mapping from `hzw-init-config`
* @param {String} [pkgName] - config package name, default to `this.configName`
* @return {Object} boilerplate config mapping, `{ simple: { "name": "simple", "package": "hzw-boilerplate-simple", "description": "Simple hzw app boilerplate" } }`
*/
// * fetchBoilerplateMapping(pkgName) {
// this.configName = 'hzw-init-config';
// const pkgInfo = yield this.getPackageInfo(pkgName || this.configName, true);
// const mapping = pkgInfo.config.boilerplate;
// Object.keys(mapping).forEach(key => {
// this.log('key %j',key);
// const item = mapping[key];
// item.name = item.name || key;
// item.from = pkgInfo;
// });
// return mapping;
// }
/**
* print usage guide
*/
printUsage() {
this.log(`usage:
- cd ${this.targetDir}
- npm install
- npm start / npm run build
`);
}
/**
* replace content with template scope,
* - `{{ test }}` will replace
* - `\{{ test }}` will skip
*
* @param {String} content - template content
* @param {Object} scope - variable scope
* @return {String} new content
*/
replaceTemplate(content, scope) {
return content.toString().replace(/(\\)?__ *(\w+) *__/g, (block, skip, key) => {
if (skip) {
return block.substring(skip.length);
}
return scope.hasOwnProperty(key) ? scope[key] : block;
});
}
/**
* download boilerplate by pkgName then extract it
* @param {String} pkgName - boilerplate package name
* @return {String} extract directory
*/
* downloadBoilerplate(pkgName) {
const result = yield this.getPackageInfo(pkgName, false);
const tgzUrl = result.dist.tarball;
const saveDir = path.join(os.tmpdir(), 'hzw-init-boilerplate');
yield rimraf(saveDir);
const response = yield this.curl(tgzUrl, { streaming: true, followRedirect: true });
yield compressing.tgz.uncompress(response.res, saveDir);
return path.join(saveDir, '/package');
}
/**
* send curl to remote server
* @param {String} url - target url
* @param {Object} [options] - request options
* @return {Object} response data
*/
* curl(url, options) {
return yield this.httpClient.request(url, options);
}
/**
* get package info from registry
*
* @param {String} pkgName - package name
* @param {Boolean} [withFallback] - when http request fail, whethe to require local
* @return {Object} pkgInfo
*/
* getPackageInfo(pkgName, withFallback) {
this.log(`fetching npm info of ${pkgName}`);
this.log(`curl ${this.registryUrl}`);
try {
const result = yield this.curl(`${this.registryUrl}/${pkgName}/latest`, {
dataType: 'json',
followRedirect: true,
});
assert(result.status === 200, `npm info ${pkgName} got error: ${result.status}, ${result.data.reason}`);
return result.data;
} catch (err) {
if (withFallback) {
this.log(`use fallback from ${pkgName}`);
return require(`${pkgName}/package.json`);
} else {
throw err;
}
}
}
/**
* log with prefix
*/
log() {
const args = Array.prototype.slice.call(arguments);
args[0] = `[${this.name}] `.blue + args[0];
console.log.apply(console, args);
}
};