lib/opter.js
var commander = require('commander'),
objPath = require('object-path'),
path = require('path'),
fs = require('fs'),
yaml = require('js-yaml'),
ZSchema = require('z-schema'),
ZSchemaErrors = require('z-schema-errors'),
validator = new ZSchema(),
reporter = ZSchemaErrors.init();
/*
example options object:
{
option1: {
charater: 'o',
argument: 'value',
defaultValue: '',
required: true,
schema: {
type: 'boolean',
description: 'an option'
}
},
options2: {
charater: 'O',
argument: 'value',
defaultValue: 10,
schema: {
type: 'integer',
description: 'another option'
}
}
}
*/
module.exports = function (options, appVersion, opterFileLocation) {
if (!options || !appVersion) {
throw new Error('Missing arguments');
}
commander
.version(appVersion)
.usage('[options]');
var config = {},
option,
optName,
requiredText,
description,
longOptionStr,
configFile = {},
character,
allCharacters = 'abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUWXYZ',
usedCharacters = { h: true, V: true },
isValidCharacter = function(char) {
return (allCharacters.indexOf(char) !== -1);
},
getCharacterForOption = function (optName) {
var i, moreChars, ch,
firstCharLC = optName[0].toLowerCase(),
firstCharUC = optName[0].toUpperCase();
// try the first character in the option name
if (!usedCharacters[firstCharLC] && isValidCharacter(firstCharLC)) {
ch = firstCharLC;
}
// try the first character in the option name (upper case)
else if (!usedCharacters[firstCharUC] && isValidCharacter(firstCharUC)) {
ch = firstCharUC;
}
else {
// try the any of the upper case letters in the option name
moreChars = optName.match(/[A-Z]/g);
if (moreChars) {
for (i = 0; i < moreChars.length; i++) {
if (!usedCharacters[moreChars[i].toLowerCase()]) {
ch = moreChars[i].toLowerCase();
break;
}
else if (!usedCharacters[moreChars[i]]) {
ch = moreChars[i];
break;
}
}
}
if (!ch) {
var testCharLC, testCharUC;
// try the any character in the option name
for (i = 1; i < optName.length; i++) {
testCharLC = optName[i].toLowerCase();
testCharUC = optName[i].toUpperCase();
if (!usedCharacters[testCharLC] && isValidCharacter(testCharLC)) {
ch = testCharLC;
break;
}
else if (!usedCharacters[testCharUC] && isValidCharacter(testCharUC)) {
ch = testCharUC;
break;
}
}
// pick any character from the alphabet that hasn't been used
if (!ch) {
for (i = 1; i < allCharacters.length; i++) {
if (!usedCharacters[allCharacters[i]]) {
ch = allCharacters[i];
break;
}
}
// uh oh
if (!ch) {
throw new Error('There are no valid characters left. Consider reducing the number of options you have.');
}
}
}
}
usedCharacters[ch] = true;
return ch;
};
for (optName in options) {
if (options.hasOwnProperty(optName)) {
option = options[optName];
if (option.hasOwnProperty('character')) {
if (usedCharacters[option.character]) {
throw new Error('More than one option is attempting to use the same character ("' + option.character + '"). Please choose unique characters for your options.');
}
usedCharacters[option.character] = true;
}
}
}
for (optName in options) {
if (options.hasOwnProperty(optName)) {
option = options[optName];
if (option.hasOwnProperty('schema') && option.schema.type && option.schema.type !== 'boolean') {
option.argument = option.schema.type;
}
requiredText = (option.required) ? '(Required) ' : '(Optional) ';
description = (option.hasOwnProperty('schema') && option.schema.description) ? option.schema.description : 'No Description.';
description = requiredText + description;
if (option.hasOwnProperty('defaultValue') && option.defaultValue !== null) {
description += ' Defaults to: ';
description += (typeof(option.defaultValue) === 'string') ? '"' + option.defaultValue + '"' : option.defaultValue;
}
longOptionStr = optName.replace(/([A-Z])/g, function (match) { return '-' + match.toLowerCase(); });
var argOpenChar = (option.required) ? '<' : '[',
argCloseChar = (option.required) ? '>' : ']';
longOptionStr = (option.argument) ? longOptionStr + ' ' + argOpenChar + option.argument + argCloseChar : longOptionStr;
// if no argument was specified, then this is a boolean flag, and therefore should default to false, if not specified otherwise
if (!option.argument) {
option.defaultValue = option.defaultValue || false;
}
// if no character was supplied, let's try to pick one
if (!option.hasOwnProperty('character')) {
character = getCharacterForOption(optName);
}
else {
character = option.character;
}
commander.option('-' + character + ', --' + longOptionStr, description);
}
}
// parse options form arguments
commander.parse(process.argv);
// look for opter.json as a sibling to the file currently being executed
if (process.argv[1]) {
var basePath = process.argv[1].substr(0, process.argv[1].lastIndexOf('/') + 1);
if (opterFileLocation) {
opterFileLocation = (opterFileLocation.indexOf(path.sep) === 0) ? opterFileLocation : basePath + opterFileLocation;
}
configFile = opterFileLocation || basePath + 'opter.json';
try {
fs.statSync(configFile);
if (path.extname(configFile) === '.yml' || path.extname(configFile) === '.yaml') {
configFile = yaml.safeLoad(fs.readFileSync(configFile, 'utf8'));
}
else {
configFile = require(configFile);
}
}
catch (ex) {
configFile = {};
}
}
// save options to config obj (from command line first, env vars second, opter.json third, and defaults last)
for (optName in options) {
if (options.hasOwnProperty(optName)) {
option = options[optName];
var value = commander[optName];
if (value === undefined) {
value = process.env[optName.replace(/\./g, '_')] || process.env[optName.replace(/\./g, '_').toUpperCase()];
if (value === undefined) {
value = objPath.get(configFile, optName);
if (value === undefined) {
value = option.defaultValue;
}
}
}
if (option.hasOwnProperty('schema')) {
// if type is defined, try to convert the value to the specified type
if (option.schema.hasOwnProperty('type')) {
switch (option.schema.type) {
case 'boolean':
value = (value === 'true') || (value === true);
break;
case 'number':
case 'integer':
// fastest, most reliable way to convert a string to a valid number
value = 1 * value;
break;
case 'object':
case 'array':
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
throw new Error('Option "' + optName + '" has a value that cannot be converted to an Object/Array: ' + value);
}
} else {
if (typeof value !== 'object') {
throw new Error('Option "' + optName + '" has a value is not an Object/Array: ' + value);
}
}
break;
default:
value = (value !== null && value !== undefined) ? value.toString() : value;
}
}
// validate the value against the schema
var isValid = validator.validate(value, option.schema);
if (!isValid) {
var errorMsg = reporter.extractMessage(validator.lastReport);
throw new Error(errorMsg);
}
}
if (value === undefined && option.required) {
throw new Error('Option "' + optName + '" is not set and is required.');
}
objPath.set(config, optName, value);
}
}
return config;
};