@qooxdoo/framework
Version:
The JS Framework for Coders
714 lines (637 loc) • 21.7 kB
JavaScript
/* ************************************************************************
qooxdoo - the new era of web development
http://qooxdoo.org
Copyright:
2018 Zenesis Ltd
License:
MIT: https://opensource.org/licenses/MIT
See the LICENSE file in the project's top-level directory for details.
Authors:
* John Spackman (john.spackman@zenesis.com, @johnspackman)
************************************************************************ */
const path = require("upath");
const fs = qx.tool.utils.Promisify.fs;
const semver = require("semver");
/**
* Entry point for the CLI
*/
qx.Class.define("qx.tool.cli.Cli", {
extend: qx.core.Object,
properties: {
command: {
apply: "__applyCommand"
}
},
construct() {
super();
if (qx.tool.cli.Cli.__instance) {
throw new Error("qx.tool.cli.Cli has already been initialized!");
}
this.__compileJsExists = false;
qx.tool.cli.Cli.__instance = this;
// include & register log appender
qx.log.appender.NodeConsole;
},
members: {
/** @type {typeof import("yargs")} the current yargs instance */
yargs: null,
/** @type {Object} the current argv */
argv: null,
/** @type {qx.tool.cli.api.CompilerApi} the CompilerApi instance */
_compilerApi: null,
/** @type {String} the compile.js filename, if there is one */
_compileJsFilename: null,
/** @type {String} the compile.json filename, if there is one */
_compileJsonFilename: null,
/** @type {Object} Parsed arguments */
__parsedArgs: null,
/** @type {Boolean} Whether libraries have had their `.load()` method called yet */
__librariesNotified: false,
__applyCommand(command) {
command.setCompilerApi(this._compilerApi);
this._compilerApi.setCommand(command);
},
/**
* Creates an instance of yargs, with minimal options
*
* @return {import("yargs")}
*/
__createYargs() {
return (this.yargs = require("yargs")
.locale("en")
.version()
.strict(false)
.showHelpOnFail()
.help(false)
.option("force", {
describe: "Override warnings",
type: "boolean",
default: false,
alias: "F"
})
.option("config-file", {
describe: "Specify the config file to use",
type: "string",
alias: "c"
})
.option("verbose", {
alias: "v",
describe: "enables additional progress output to console",
default: false,
type: "boolean"
})
.option("debug", {
describe: "enables debug output",
default: false,
type: "boolean"
})
.option("quiet", {
alias: "q",
describe: "suppresses normal progress output to console",
type: "boolean"
})
.option("colorize", {
describe: "colorize log output to the console using ANSI color codes",
default: true,
type: "boolean"
}));
},
/**
* Initialises this.argv with the bare minimum required to load the config files and begin
* processing
*/
async __bootstrapArgv() {
var title = "qooxdoo command line interface";
title = "\n" + title + "\n" + "=".repeat(title.length);
title += `
Version: v${await qx.tool.config.Utils.getQxVersion()}
`;
title += "\n";
title += `Typical usage:
qx <commands> [options]
Type qx <command> --help for options and subcommands.`;
let yargs = this.__createYargs().usage(title);
this.argv = yargs.argv;
// Logging - needs to be unified..
if (this.argv.debug) {
qx.log.Logger.setLevel("debug");
} else if (this.argv.quiet) {
qx.log.Logger.setLevel("error");
} else {
qx.log.Logger.setLevel("info");
}
// use node console log appender with colors
qx.log.appender.NodeConsole.setUseColors(this.argv.colorize);
},
/**
* Reloads this.argv with the full set of arguments
*/
async __fullArgv() {
let yargs = this.__createYargs()
.help(true)
.option("set", {
describe: "sets an environment value for the compiler",
nargs: 1,
requiresArg: true,
type: "string",
array: true
})
.option("set-env", {
describe: "sets an environment value for the application",
nargs: 1,
requiresArg: true,
type: "string",
array: true
})
.check(argv => {
// validate that "set-env" is not set or if it is
// set it's items are strings in the form of key=value
const regexp = /^[^=\s]+=.+$/;
const setEnv = argv["set-env"];
if (
!(setEnv === undefined || !setEnv.some(item => !regexp.test(item)))
) {
throw new Error(
"Argument check failed: --set-env must be a key=value pair."
);
}
return true;
});
qx.tool.cli.Cli.addYargsCommands(
yargs,
[
"Add",
"Clean",
"Compile",
"Config",
"Deploy",
"Es6ify",
"ExportGlyphs",
"Package",
"Pkg", // alias for Package
"Create",
"Lint",
"Run",
"Test",
"Typescript",
"Serve",
"Migrate"
],
"qx.tool.cli.commands"
);
this.argv = await yargs.demandCommand().strict().argv;
await this.__notifyLibraries();
},
/**
* This is to notify the commands after loading the full args.
* The commands can overload special arg arguments here.
* e.g. Deploy will will overload the target.
*/
__notifyCommand() {
let cmd = this._compilerApi.getCommand();
if (cmd) {
this._compilerApi.getCommand().processArgs(this.argv);
}
},
/**
* Calls the `.load()` method of each library, safe to call multiple times. This is
* to delay the calling of `load()` until after we know that the command has been selected
* by Yargs
*/
async __notifyLibraries() {
if (this.__librariesNotified) {
return;
}
this.__librariesNotified = true;
for (
let i = 0, arr = this._compilerApi.getLibraryApis();
i < arr.length;
i++
) {
let libraryApi = arr[i];
await libraryApi.load();
}
await this._compilerApi.afterLibrariesLoaded();
},
/**
* Processes a command. All commands should use this method when invoked by Yargs, because it
* provides a standard error control and makes sure that the libraries know what command has
* been selected.
*
* @param command {qx.tool.cli.commands.Command} the command being run
*/
async processCommand(command) {
qx.tool.compiler.Console.getInstance().setVerbose(this.argv.verbose);
await this.__notifyLibraries();
const res = await command.process();
await this._compilerApi.afterProcessFinished(command, res);
return res;
},
/**
* Returns the parsed command line and configuration data
*
* @return {Object}
*/
getParsedArgs() {
return this.__parsedArgs;
},
/**
* Parses the command line and loads configuration data from a .js or .json file;
* if you provide a .js file the file must be a module which returns an object which
* has any of these properties:
*
* CompilerApi - the class (derived from qx.tool.cli.api.CompilerApi)
* for configuring the compiler
*
* Each library can also have a compile.js, and that is also a module which can
* return an object with any of these properties:
*
* LibraryApi - the class (derived from qx.tool.cli.api.LibraryApi)
* for configuring the library
*
*/
async run() {
var args = qx.lang.Array.clone(process.argv);
args.shift();
process.title = args.join(" ");
await this.__parseArgsImpl();
return this.processCommand(this.getCommand());
},
/**
* Does the work of parsing command line arguments and loading `compile.js[on]`
*/
async __parseArgsImpl() {
await this.__bootstrapArgv();
/*
* Detect and load compile.json and compile.js
*/
let defaultConfigFilename = qx.tool.config.Compile.config.fileName;
if (this.argv.configFile) {
process.chdir(path.dirname(this.argv.configFile));
this.argv.configFile = path.basename(this.argv.configFile);
defaultConfigFilename = this.argv.configFile;
}
var lockfileContent = {
version: qx.tool.config.Lockfile.getInstance().getVersion()
};
let compileJsFilename = qx.tool.cli.Cli.compileJsFilename;
let compileJsonFilename = qx.tool.config.Compile.config.fileName;
if (defaultConfigFilename) {
if (defaultConfigFilename.match(/\.js$/)) {
compileJsFilename = defaultConfigFilename;
} else {
compileJsonFilename = defaultConfigFilename;
}
}
if (await fs.existsAsync(compileJsonFilename)) {
this._compileJsonFilename = compileJsonFilename;
}
/*
* Create a CompilerAPI
*/
let CompilerApi = qx.tool.cli.api.CompilerApi;
if (await fs.existsAsync(compileJsFilename)) {
this.__compileJsExists = true;
let compileJs = await this.__loadJs(compileJsFilename);
this._compileJsFilename = compileJsFilename;
if (compileJs.CompilerApi) {
CompilerApi = compileJs.CompilerApi;
}
}
let compilerApi = (this._compilerApi = new CompilerApi(this).set({
rootDir: ".",
configFilename: compileJsonFilename
}));
// Boot the compiler API, load the compile.json and create configuration data
await compilerApi.load();
let config = compilerApi.getConfiguration();
// Validate configuration data against the schema
await qx.tool.config.Compile.getInstance().load(config);
/*
* Open the lockfile and check versions
*/
if (defaultConfigFilename) {
let lockfile = qx.tool.config.Lockfile.config.fileName;
try {
var name = path.join(path.dirname(defaultConfigFilename), lockfile);
lockfileContent =
(await qx.tool.utils.Json.loadJsonAsync(name)) || lockfileContent;
} catch (ex) {
// Nothing
} // check semver-type compatibility (i.e. compatible as long as major version stays the same)
let schemaVersion = semver.coerce(
qx.tool.config.Lockfile.getInstance().getVersion(),
true
).raw;
let fileVersion =
lockfileContent && lockfileContent.version
? semver.coerce(lockfileContent.version, true).raw
: "1.0.0";
if (semver.major(schemaVersion) > semver.major(fileVersion)) {
if (this.argv.force) {
let config = {
verbose: this.argv.verbose,
quiet: this.argv.quiet,
save: false
};
const installer = new qx.tool.cli.commands.package.Install(config);
let filepath = installer.getLockfilePath();
let backup = filepath + ".old";
await fs.copyFileAsync(filepath, backup);
if (!this.argv.quiet) {
qx.tool.compiler.Console.warn(
`*** A backup of ${lockfile} has been saved to ${backup}, in case you need to revert to it. ***`
);
}
await installer.deleteLockfile();
for (let lib of lockfileContent.libraries) {
if (!(await installer.isInstalled(lib.uri, lib.repo_tag))) {
if (lib.repo_tag) {
await installer.install(lib.uri, lib.repo_tag);
} else if (lib.path && fs.existsSync(lib.path)) {
await installer.installFromLocaPath(lib.path, lib.uri);
}
} else if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> ${lib.uri}@${lib.repo_tag} is already installed.`
);
}
}
lockfileContent = await installer.getLockfileData();
} else {
throw new qx.tool.utils.Utils.UserError(
`*** Warning ***\n` +
`The schema of '${lockfile}' has changed. Execute 'qx clean && qx compile --force' to delete and regenerate it.\n` +
`You might have to re-apply manual modifications to '${lockfile}'.`
);
}
}
}
/*
* Locate and load libraries
*/
if (!config.libraries) {
if (fs.existsSync("Manifest.json")) {
config.libraries = ["."];
}
}
if (lockfileContent.libraries) {
config.packages = {};
lockfileContent.libraries.forEach(function (library) {
if (library.uri == "qooxdoo/qxl.apiviewer") {
let m = library.repo_tag.match(/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/);
if (m) {
m.shift();
m = m.map(v => parseInt(v, 10));
if (m[0] <= 1 && m[1] == 0 && m[2] < 15) {
qx.tool.compiler.Console.warn(
"***********\n*********** API Viewer is out of date and must be upgraded - please run 'qx package update' and then 'qx package upgrade'\n***********"
);
}
}
}
config.libraries.push(library.path);
config.packages[library.uri] = library.path;
});
}
// check if we need to load libraries, needs more robust test
let needLibraries =
qx.lang.Type.isArray(this.argv._) && this.argv._[0] !== "clean";
// check if libraries are loaded
if (config.libraries && needLibraries) {
let neededLibraries = config.libraries.filter(
libData => !fs.existsSync(libData + "/Manifest.json")
);
if (neededLibraries.length) {
if (!fs.existsSync(qx.tool.config.Manifest.config.fileName)) {
qx.tool.compiler.Console.error(
"Libraries are missing and there is no Manifest.json in the current directory so we cannot attempt to install them; the missing libraries are: \n " +
neededLibraries.join("\n ")
);
process.exit(1);
}
qx.tool.compiler.Console.info(
"One or more libraries not found - trying to install them from library repository..."
);
const installer = new qx.tool.cli.commands.package.Install({
quiet: true,
save: false
});
await installer.process();
}
for (const aPath of config.libraries) {
let libCompileJsFilename = path.join(
aPath,
qx.tool.cli.Cli.compileJsFilename
);
let LibraryApi = qx.tool.cli.api.LibraryApi;
if (await fs.existsAsync(libCompileJsFilename)) {
let compileJs = await this.__loadJs(libCompileJsFilename);
if (compileJs.LibraryApi) {
LibraryApi = compileJs.LibraryApi;
}
}
let libraryApi = new LibraryApi().set({
rootDir: aPath,
compilerApi: compilerApi
});
compilerApi.addLibraryApi(libraryApi);
await libraryApi.initialize();
}
}
/*
* Now everything is loaded, we can process the command line properly
*/
await this.__fullArgv();
this.__notifyCommand();
let parsedArgs = {
target: this.argv.target,
outputPath: null,
locales: null,
writeAllTranslations: this.argv.writeAllTranslations,
environment: {},
verbose: this.argv.verbose
};
if (this.argv.locale && this.argv.locale.length) {
parsedArgs.locales = this.argv.locale;
}
if (this.argv["set-env"]) {
this.argv["set-env"].forEach(function (kv) {
var m = kv.match(/^([^=\s]+)(=(.+))?$/);
var key = m[1];
var value = m[3];
parsedArgs.environment[key] = value;
});
}
let targetType = this._compilerApi.getCommand().getTargetType();
if (!config.locales) {
config.locales = [];
}
if (typeof parsedArgs.writeAllTranslations == "boolean") {
config.writeAllTranslations = parsedArgs.writeAllTranslations;
}
if (!config.environment) {
config.environment = {};
}
// Set the environment variables coming from command line arguments
// in target's environment object. If that object doesn't exist create
// one and assign it to the target.
if (config.targets) {
const target = config.targets.find(
target => target.type === targetType
);
target.environment = target.environment || {};
qx.lang.Object.mergeWith(
target.environment,
parsedArgs.environment,
true
);
}
if (config.sass && config.sass.compiler !== undefined) {
qx.tool.compiler.resources.ScssConverter.USE_V6_COMPILER =
config.sass.compiler == "latest";
} else {
qx.tool.compiler.resources.ScssConverter.USE_V6_COMPILER = null;
}
if (config.sass && config.sass.copyOriginal) {
qx.tool.compiler.resources.ScssConverter.COPY_ORIGINAL_FILES = true;
}
if (!config.serve) {
config.serve = {};
}
if (this.isExplicitArg("listen-port")) {
config.serve.listenPort = this.argv.listenPort;
} else {
config.serve.listenPort =
config.serve.listenPort || this.argv.listenPort;
}
this.__parsedArgs = await compilerApi.getConfiguration();
return this.__parsedArgs;
},
/**
* Loads a .js file using `require`, handling exceptions as best as possible
*
* @param aPath {String} the file to load
* @return {Object} the module
*/
async __loadJs(aPath) {
try {
let module = require(path.resolve(aPath));
return module;
} catch (e) {
let lines = e.stack.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/^\s+at/)) {
lines.splice(i);
}
}
let lineNumber = lines[0].split("evalmachine.<anonymous>:")[1];
if (lineNumber !== undefined) {
lines.shift();
throw new Error(
"Error while reading " +
aPath +
" at line " +
lineNumber +
"\n" +
lines.join("\n")
);
} else {
throw new Error(
"Error while reading " + aPath + "\n" + lines.join("\n")
);
}
}
},
/**
* Returns if the file compile.js exists
*
* @returns {Boolean}
*/
compileJsExists() {
return this.__compileJsExists;
},
/**
* Returns the CompilerApi instance
*
* @return {qx.tool.cli.api.CompilerApi}
*/
getCompilerApi() {
return this._compilerApi;
},
/**
* Returns the filename of compile.js, if there is one
*
* @return {String?} filename
*/
getCompileJsFilename() {
return this._compileJsFilename;
},
/**
* Returns the filename of compile.json, if there is one
*
* @return {String?} filename
*/
getCompileJsonFilename() {
return this._compileJsonFilename;
},
/**
* Detects whether the command line explicit set an option (as opposed to yargs
* providing a default value). Note that this does not handle aliases, use the
* actual, full option name.
*
* @param option {String} the name of the option, eg "listen-port"
* @return {Boolean}
*/
isExplicitArg(option) {
function searchForOption(option) {
return process.argv.indexOf(option) > -1;
}
return searchForOption(`-${option}`) || searchForOption(`--${option}`);
}
},
statics: {
compileJsFilename: "compile.js",
/** {CompileJs} singleton instance */
__instance: null,
/**
* Returns the singleton instance, throws an error if it has not been created
*
* @return {qx.tool.cli.Cli}
*/
getInstance() {
if (!qx.tool.cli.Cli.__instance) {
throw new Error("CompileJs has not been initialized yet!");
}
return qx.tool.cli.Cli.__instance;
},
/**
* Adds commands to Yargs
*
* @param yargs {typeof import("yargs")} the Yargs instance
* @param classNames {String[]} array of class names, each of which is in the `packageName` package
* @param packageName {String} the name of the package to find each command class
*/
addYargsCommands(yargs, classNames, packageName) {
let pkg = null;
packageName.split(".").forEach(seg => {
if (pkg === null) {
pkg = window[seg];
} else {
pkg = pkg[seg];
}
});
classNames.forEach(cmd => {
let Clazz = pkg[cmd];
let data = Clazz.getYargsCommand();
if (data) {
if (data.handler === undefined) {
data.handler = argv =>
qx.tool.cli.Cli.getInstance().setCommand(new Clazz(argv));
}
yargs.command(data);
}
});
}
}
});