'use strict';
/**
* The `kss/lib/cli` module is a wrapper around the code used by the
* `bin/kss` command line utility.
*
* ```
* const cli = require('kss/lib/cli');
* ```
*
* @module kss/lib/cli
*/
const Promise = require('bluebird'),
kss = require('./kss'),
KssBuilderBase = require('../builder/base'),
path = require('path'),
version = require('../package.json').version,
yargs = require('yargs');
const fs = Promise.promisifyAll(require('fs-extra'));
/**
* Parses command line arguments in `opts.argv` and outputs messages and errors
* on `opts.stdout` and `opts.stderr`, respectively.
*
* @param {Object} opts The `stdout`, `stderr` and `argv` options to use.
* @returns {Promise.<KssStyleGuide|null>} A `Promise` object resolving to a
* `KssStyleGuide` object, or to `null` if the clone option is used.
*/
const cli = function(opts) {
// First 2 args are "node" and path to kss script; we don't need them.
const args = opts.argv.slice(2) || /* istanbul ignore next */ [];
const supportedBuilders = [
'builder/twig',
'builder/nunjucks'
];
// Set up a logging function for any messages we need to send to stdout.
// We will set up the error function after we know what options to use.
const reportMessage = function() {
let message = '';
for (let i = 0; i < arguments.length; i++) {
message += arguments[i];
}
opts.stdout.write(message + '\n');
};
// If the demo is requested, load the settings from its config file.
if (args.indexOf('--demo') !== -1) {
// Add the configuration file to the raw arguments list.
args.push('--config', path.join(__dirname, '../demo/kss-config.json'));
if (args.indexOf('--json') === -1) {
args.push('--verbose');
reportMessage('WELCOME to the kss demo! We\'ve turned on the --verbose flag so you can see what kss is doing.');
}
}
const cliOptionDefinitions = {
'config': {
group: 'File locations:',
alias: 'c',
config: true,
multiple: false,
describe: 'Load the kss options from a json file'
},
'demo': {
multiple: false,
boolean: true,
describe: 'Builds a KSS demo.',
default: false
},
// Prevent yargs from complaining about JSON comments in the config file.
'//': {
describe: 'Comments in JSON files will be ignored'
}
};
// We need to know which builder to use, so we do a quick first parse of the
// arguments using yargs.
let options = yargs(args).options(
// We merge the CLI option definitions with the default KssBuilderBase
// option definitions.
(new KssBuilderBase()).addOptionDefinitions(cliOptionDefinitions).getOptionDefinitions()
).argv;
// Check if there are settings coming from a JSON config file. We need to note
// the config file's values and the directory where it is located.
let configFileOptions = {},
configFileDirectory = '',
checkBuilderPath = Promise.resolve();
if (options.config) {
let configFilePath = path.resolve(options.config);
configFileOptions = require(configFilePath);
configFileDirectory = path.dirname(configFilePath);
// First, ensure the path to the builder is relative to the config file's
// location. Later, we will do the same for other paths in the config file.
if (configFileOptions.builder) {
options.builder = path.resolve(configFileDirectory, configFileOptions.builder);
checkBuilderPath = fs.statAsync(options.builder).then(stats => {
if (!stats.isDirectory()) {
throw new Error();
}
return Promise.resolve();
}).catch(() => {
// If the resolved builder path does not work, check if the a supported
// builder was desired.
if (supportedBuilders.indexOf(configFileOptions.builder) > -1) {
options.builder = path.resolve(__dirname, '..', configFileOptions.builder);
return Promise.resolve();
} else {
throw new Error('The builder path, "' + options.builder + '", is not a directory.');
}
});
}
}
// Set up an error handler for Promised tasks in this module; kss() will
// handle its own errors, so we don't want to double-catch/report its errors.
const reportError = function(error) {
// Make sure the standard error handler is writable.
// istanbul ignore else
if (opts && opts.stderr && opts.stderr.write) {
// Show the full error stack if the verbose option is used twice or more.
opts.stderr.write(((error.stack && options.verbose > 1) ? error.stack : error) + '\n');
} else {
// If the standard output for errors is not there, use console.error().
console.error(error);
}
};
// Confirm this is a compatible builder.
return checkBuilderPath.then(() => {
return KssBuilderBase.loadBuilder(KssBuilderBase.builderResolve(options.builder));
}).catch(error => {
return Promise.reject(error).catch(error => {
reportError(error);
throw error;
});
}).then(builder => {
builder.addOptionDefinitions(cliOptionDefinitions);
// After the builder is loaded, we finally know all the option definitions.
// So we re-run yargs one last time with all the yarg definitions we need.
options = yargs(args)
.options(builder.getOptionDefinitions())
// Make a --help option available.
.usage('Usage: kss [options]')
.help('help')
.alias('help', 'h')
.alias('help', '?')
.wrap(yargs.terminalWidth())
// Make a --version option available.
.version('version', version)
// Complain if the user tries to configure a non-existent option.
.strict()
.argv;
// If no arguments given, display help and exit.
if (args.length === 0) {
yargs.showHelp(reportMessage);
return Promise.resolve();
}
// All paths from the config file are relative to the file.
for (let key in configFileOptions) {
if (configFileOptions.hasOwnProperty(key) && builder.getOptionDefinitions()[key] && builder.getOptionDefinitions()[key].path) {
if (options[key] instanceof Array) {
/* eslint-disable no-loop-func */
options[key] = options[key].map(value => {
return path.resolve(configFileDirectory, value);
});
/* eslint-enable no-loop-func */
} else if (key !== 'builder' || supportedBuilders.indexOf(options[key]) === -1) {
options[key] = path.resolve(configFileDirectory, options[key]);
}
}
}
// Check for source and destination set as unnamed parameters.
if (options._.length > 0) {
let positionalParams = options._;
// Check for a second unnamed parameter, the destination.
if (positionalParams.length > 1) {
options.destination = positionalParams[1];
}
// The source directory is the first unnamed parameter.
if (!(options.source instanceof Array)) {
options.source = (typeof options.source === 'undefined') ? [] : [options.source];
}
options.source.unshift(positionalParams[0]);
}
// If we are building the demo, copy the styles.css file to the destination.
let demo = true;
if (options.demo && !options.json) {
// We save the Promise for the end of this function.
demo = fs.copyAsync(
path.resolve(__dirname, '../demo/styles.css'),
path.resolve(options.destination, 'styles.css'),
{clobber: true}
).catch(/* istanbul ignore next @TODO change back to an arrow function when istanbul adds support */ function(error) {
reportError(error);
return Promise.reject(error);
});
}
// Clean up the settings by removing object properties that yargs adds, but
// that we don't need for kss().
['config', '_', '//', 'help', 'h', '?', 'version', '$0'].forEach(key => delete options[key]);
let optionDefinitions = builder.getOptionDefinitions();
for (let key in optionDefinitions) {
if (optionDefinitions[key].alias) {
delete options[optionDefinitions[key].alias];
}
}
for (let key in options) {
if (typeof options[key] === 'undefined') {
delete options[key];
}
}
// Pass on cli()'s stdout/stderr reporters to kss().
options.logFunction = reportMessage;
options.logErrorFunction = reportError;
return Promise.all([
demo,
kss(options).then(styleGuide => {
if (options.json) {
reportMessage(JSON.stringify(styleGuide));
}
return styleGuide;
})
]);
});
};
module.exports = cli;