titanium
Version:
Command line interface for building Titanium SDK apps
1,693 lines (1,501 loc) • 50.4 kB
JavaScript
import chalk from 'chalk';
import fs from 'fs-extra';
import { program, Command, Option } from 'commander';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { unique } from './util/unique.js';
import { ticonfig } from './util/ticonfig.js';
import { initSDK, typeLabels } from './util/tisdk.js';
import { expand } from './util/expand.js';
import { arrayify } from './util/arrayify.js';
import * as version from './util/version.js';
import { Logger } from './util/logger.js';
import { capitalize } from './util/capitalize.js';
import wrapAnsi from 'wrap-ansi';
import { TiError } from './util/tierror.js';
import { prompt } from './util/prompt.js';
import { applyCommandConfig } from './util/apply-command-config.js';
import { TiHelp } from './util/tihelp.js';
import semver from 'semver';
const { blue, bold, cyan, gray, green, magenta, red, yellow } = chalk;
/**
* A curated list of built-in CLI commands and CLI commands defined in a
* Titanium SDK. These are hard coded for speed.
*/
const commands = {
config: 'get and set config options',
info: 'display development environment information',
module: 'displays installed Titanium modules',
sdk: 'manages installed Titanium SDKs',
setup: 'sets up the Titanium CLI'
};
const sdkCommands = {
build: 'builds a project',
clean: 'removes previous build directories',
create: 'creates a new project',
project: 'get and set tiapp.xml settings',
serve: 'serves a project through the Titanium Vite runtime',
};
/**
* The Titanium CLI v5 requires the `--sdk <version>` to equal the
* `<sdk-version>` in the `tiapp.xml`. If they don't match, node-titanium-sdk's
* `ti.validateCorrectSDK()` will spawn a new Titanium CLI process with the
* correct `--sdk`. Due to the design of the Titanium CLI, this
* `GracefullyShutdown` error was thrown as an easy way to stop validating and
* skip executing the command.
*
* Since this Titanium CLI shim will ALWAYS match the `<sdk-version>` in the
* `tiapp.xml`, this really isn't used, but just in case, we'll define it and
* set it on the `CLI` instance.
*/
class GracefulShutdown extends Error {}
process.setMaxListeners(666);
export class CLI {
static HOOK_PRIORITY_DEFAULT = 1000;
/**
* Export of the graceful shutdown error. This is for backwards
* compatibility with forking the correct SDK and is no longer relevant.
* See note in `GracefulShutdown` docs above.
* @type {Function}
* @deprecated
*/
GracefulShutdown = GracefulShutdown;
/**
* Parsed command line arguments
* @type {Object}
*/
argv = {
_: [], // parsed arguments (reset each time the context's parse() is called)
$: 'titanium', // resolved node script path
$_: process.argv.slice(), // original arguments
$0: process.argv.slice(0, 2).join(' ') // node process and original node script path
};
/**
* The command module.
* @type {Object|null}
*/
command = null;
/**
* A map of discovered custom commands.
*/
customCommands = {};
/**
* The Titanium CLI config object.
* @type {Object}
*/
config = ticonfig;
/**
* Environment information such as operating system. This logic used to be
* generated by the `environ` module in `node-appc`.
* @type {Object}
*/
env = {
installPath: '',
os: {
name: process.platform === 'darwin' ? 'osx' : process.platform,
sdkPaths: [],
sdks: {}
},
getOSInfo: async (callback) => {
const { detect } = await import('./util/detect.js');
const { data } = await detect(this.debugLogger, ticonfig, this, { nodejs: true, os: true });
const { node, npm, os } = data;
if (typeof callback === 'function') {
callback({
os: os.name,
platform: process.platform.replace('darwin', 'osx'),
osver: os.version,
ostype: os.architecture,
oscpu: os.numcpus,
memory: os.memory,
node: node.version,
npm: npm.version
});
}
}
};
/**
* The hook system state.
* @type {Object}
*/
hooks = {
erroredFilenames: [],
errors: {},
ids: {},
incompatibleFilenames: [],
loadedFilenames: [],
post: {},
pre: {},
scannedPaths: {}
};
/**
* The new, improved slimmed down logger API.
* @type {Object}
*/
debugLogger = new Logger(process.argv.includes('--debug') ? 1 : 10);
/**
* The new, improved slimmed down logger API for commands with --log-level.
* @type {Object}
*/
logger = new Logger(ticonfig.get('cli.logLevel'));
/**
* The time that executing the command starts. This value is set after validation and prompting
* has occurred.
* @type {Number}
*/
startTime = null;
/**
* A set to track loaded custom paths.
* @type {Set}
*/
scannedCustomPaths = new Set();
constructor() {
const pkgJsonFile = join(dirname(fileURLToPath(import.meta.url)), '../package.json');
const { engines, version } = fs.readJsonSync(pkgJsonFile);
this.name = 'Titanium Command-Line Interface';
this.copyright = 'Copyright TiDev, Inc. 4/7/2022-Present. All Rights Reserved.';
this.version = version;
this.nodeVersion = engines?.node;
this.logger.setBanner({
name: this.name,
copyright: this.copyright,
version: this.version
});
this.debugLogger.timestampEnabled(true);
process.on('exit', () => {
this.debugLogger.trace(`Total run time ${process.uptime().toFixed(2)}s`);
});
}
/**
* This method is called by the SDK and we need to keep it.
*
* @access public
* @deprecated
*/
addAnalyticsEvent() {
// noop
}
/**
* Alias for `on()`. This method has been deprecated for years, yet it is
* still used, so we must keep it.
*
* @access public
* @deprecated
*/
addHook(...args) {
return this.on(...args);
}
/**
* Applies commander's argv for current command and all parent command
* contexts into this CLI's argv.
*
* @param {Command} - A Commander.js command context to get parsed options from.
* @access private
*/
applyArgv(cmd) {
if (!Array.isArray(cmd?.options)) {
return;
}
const argv = this.argv;
const cargv = cmd.opts();
this.debugLogger.trace(`Copying ${cmd.options.length} options... (${cmd.name()})`);
for (const opt of cmd.options) {
let name = opt.name();
if (opt.negate) {
name = name.replace(/^no-/, '');
}
this.debugLogger.trace(` Setting ${name} = ${cargv[opt.attributeName()]} (prev: ${argv[name]})`);
argv[name] = cargv[opt.attributeName()];
}
}
/**
* Adds the config flags, options, arguments, and subcommands to a command.
*
* @param {String} cmdName - The name of the command.
* @param {Command} cmd - The commander command.
* @param {Object} conf - The command configuration.
* @access private
*/
applyConfig(cmdName, cmd, conf) {
if (conf.skipBanner) {
this.logger.skipBanner(true);
}
this.command.conf = conf;
applyCommandConfig(this, cmdName, cmd, conf);
if (conf.platforms) {
this.debugLogger.trace(`Detected conf.platforms applying config for "${cmd.name()}", overriding createHelp()`);
this.command.createHelp = () => {
return Object.assign(new TiHelp(this, conf.platforms), this.command.configureHelp());
};
}
}
/**
* Defines a hook function that will emit an event before and after the hooked function is
* invoked.
*
* @param {String} name - The name of hook event.
* @param {Object} [ctx] - The `this` context to bind the callbacks to.
* @param {Function} [fn] - The function being hooked.
* @returns {Function}
* @access public
*/
createHook(name, ctx, fn) {
let dataPayload;
if (typeof ctx === 'function') {
fn = ctx;
ctx = null;
} else if (ctx && typeof ctx === 'object' && !fn) {
dataPayload = ctx;
ctx = null;
}
const hookType = typeof fn === 'function' ? 'function' : 'event';
this.debugLogger.trace(`Creating ${hookType} hook "${name}"`);
return (...args) => {
let data = Object.assign(dataPayload || {}, {
type: name,
args,
fn,
ctx
});
const callback = data.args.pop();
const pres = this.hooks.pre[name] || [];
const posts = this.hooks.post[name] || [];
this.debugLogger.trace(`Firing ${hookType} hook "${name}" pres=${pres.length}, posts=${posts.length}`);
(async () => {
// call all pre filters
await pres
.reduce((promise, pre) => promise.then(async () => {
if (pre.length >= 2) {
await new Promise((resolve, reject) => {
pre.call(ctx, data, (err, newData) => {
if (err) {
return reject(err);
}
if (newData && typeof newData === 'object' && newData.type) {
data = newData;
}
resolve();
});
});
} else {
await pre.call(ctx, data);
}
}), Promise.resolve());
if (data.fn) {
data.result = await new Promise(resolve => {
// call the hooked function
data.fn.call(
data.ctx,
...data.args,
(...args) => resolve(args)
);
});
}
// call all post filters
await posts
.reduce((promise, post) => promise.then(async () => {
if (post.length >= 2) {
await new Promise((resolve, reject) => {
post.call(ctx, data, (err, newData) => {
if (err) {
return reject(err);
}
if (newData && typeof newData === 'object' && newData.type) {
data = newData;
}
resolve();
});
});
} else {
await post.call(ctx, data);
}
}), Promise.resolve());
if (typeof callback === 'function') {
callback.apply(data, data.result);
}
})().catch(err => {
// this is the primary error handler
if (typeof callback === 'function') {
callback(err);
} else {
this.debugLogger.error('Hook completion callback threw unhandled error:');
this.debugLogger.error(err.stack);
process.exit(1);
}
});
};
}
/**
* Emits an event along with a data payload.
*
* @param {String|Array.<String>} name - One or more events to emit.
* @param {Object} [data] - An optional data payload.
* @param {Function} [callback] A function to call once the emitting has
* finished. If no callback is specified, this function will return a
* promise instead.
* @returns {CLI|Promise}
* @access public
*/
emit(name, data, callback) {
if (typeof data === 'function') {
callback = data;
data = null;
}
// create each hook and immediately fire them
const events = unique(arrayify(name, true));
this.debugLogger.trace(`Emitting "${name}" (${events.length} listener${events.length !== 1 ? 's' : ''})`);
const promise = events
.reduce((promise, name) => promise.then(() => new Promise((resolve, reject) => {
const hook = this.createHook(name, data);
hook((err, result) => {
err ? reject(err) : resolve(result);
});
})), Promise.resolve(this));
if (typeof callback !== 'function') {
return promise;
}
promise.then(result => callback(null, result), callback);
return this;
}
/**
* Executes the command's `run()` method. Because Commander.js parses the
* CLI args twice, this function is called twice, but only runs the actual
* command after the args have been parsed once and processed.
*
* @param {Array} args - An array arguments where the last argument is the
* Commander.js command context.
* @returns {Promise}
* @access private
*/
async executeCommand(args) {
const cmd = args.pop();
args.pop(); // discard argv
// `args` are Commander's action-handler arguments, one entry per declared
// positional in order. Variadic positionals (e.g. `sdk uninstall
// [versions...]`) arrive as a nested array, which consumers like
// `sdk.js` rely on (`cli.argv._[0]` is the versions array). Flattening to
// `cmd.args` here would collapse that array into a string and break them.
// The `serve`/`build` positional platform is handled separately in
// `initBuildPlatform()` via `command.processedArgs`, not `argv._`.
while (args.length && args[args.length - 1] === undefined) {
args.pop();
}
this.argv._ = args;
this.applyArgv(cmd);
if (!this.ready) {
return;
}
this.command = cmd;
this.command.skipRun = false;
this.logger.banner();
const commandName = this.command.name();
if (sdkCommands[commandName] || commandName === 'info') {
// the SDK still uses the `colors` package, so we need to add the
// colors to the string prototype
const assignColors = proto => Object.defineProperties(proto, {
blue: { get() { return blue(`${this}`); }, configurable: true },
bold: { get() { return bold(`${this}`); }, configurable: true },
cyan: { get() { return cyan(`${this}`); }, configurable: true },
gray: { get() { return gray(`${this}`); }, configurable: true },
green: { get() { return green(`${this}`); }, configurable: true },
grey: { get() { return gray(`${this}`); }, configurable: true },
magenta: { get() { return magenta(`${this}`); }, configurable: true },
red: { get() { return red(`${this}`); }, configurable: true },
yellow: { get() { return yellow(`${this}`); }, configurable: true }
});
assignColors(String.prototype);
assignColors(Number.prototype);
assignColors(Boolean.prototype);
}
await this.validate();
await this.emit('cli:pre-execute', { cli: this, command: this.command });
this.startTime = Date.now();
const run = this.command.module?.run;
if (typeof run !== 'function') {
return;
}
this.debugLogger.trace(`Executing command's run: ${this.command.name()}`);
this.debugLogger.trace('Final argv:', this.argv);
if (this.command.skipRun || this.argv['debug-no-run']) {
this.debugLogger.trace('Skipping run()');
return;
}
const result = await new Promise((resolve, reject) => {
const r = run(this.logger || this.debugLogger, this.config, this, async (err, result) => {
// we need to wrap the post-execute emit in a try/catch so that any exceptions
// it throws aren't confused with command errors
try {
await this.emit('cli:post-execute', { cli: this, command: this.command, err, result });
} catch (ex) {
return reject(ex);
}
if (err) {
return reject(err);
}
resolve();
});
if (r instanceof Promise) {
r.then(() => this.emit('cli:post-execute', { cli: this, command: this.command }))
.then(() => resolve())
.catch(reject);
} else if (run.length < 4) {
// run doesn't expect a `callback()`
resolve();
}
});
if (result instanceof Promise) {
await result;
}
}
/**
* Alias for `emit()`. This method has been deprecated for years, yet it is
* still used, so we must keep it.
*
* @access public
* @deprecated
*/
fireHook(...args) {
return this.emit(...args);
}
/**
* The main pipeline for running the CLI.
*
* @returns {Promise}
* @access public
*/
async go() {
if (this.nodeVersion && !semver.satisfies(process.version, this.nodeVersion)) {
throw new TiError(`Node.js version ${this.nodeVersion} required, current version is ${process.version}`);
}
Command.prototype.createHelp = () => {
return Object.assign(new TiHelp(this), this.command.configureHelp());
};
this.initGlobals();
// parse the CLI args round 1
this.debugLogger.trace('Parsing arguments first pass...');
await program.parseAsync();
this.debugLogger.trace('Finished parsing arguments first pass');
// check for unknown command
if (this.command === program && program.args.length && !program.args[0].startsWith('-')) {
throw new TiError(`Unknown command "${program.args[0]}"`, { showHelp: true });
}
this.applyArgv(this.command);
this.resetCommander(program);
this.initKnownOptionBranches();
await this.initBuildPlatform();
this.validateHooks();
// wire up the event listeners
program
.on('option:no-banner', () => this.logger.bannerEnabled(false))
.on('option:no-color', () => chalk.level = 0)
.on('option:no-colors', () => chalk.level = 0)
.on('option:quiet', () => this.debugLogger.silence())
.on('option:version', () => {
this.logger.log(this.version);
process.exit(0);
});
// this hook is fired during the second parse when a command is found
// and allows us to add options/flags to the current command while the
// parsing still occurring
program.hook('preSubcommand', async (_, cmd) => {
const { conf, optionBranches } = cmd;
const cmdName = cmd.name();
// this is a hack... `-d` now conflicts between `--workspace-dir` and
// the now global `--project-dir` option causing `--project-dir` to
// snipe `--workspace-dir`, so we treat them the same for the `create`
// command
if (cmdName === 'create' && !this.argv['workspace-dir'] && this.argv['project-dir']) {
cmd.setOptionValue('workspaceDir', expand(this.argv['project-dir']));
this.argv['project-dir'] = undefined;
}
if (optionBranches?.length) {
this.debugLogger.trace(`Processing missing option branches: ${optionBranches.join(', ')}`);
for (const name of optionBranches) {
const option = conf.options[name];
option.name ||= name;
if (!this.promptingEnabled || !option.prompt || !option.values) {
throw new TiError(`Missing required option "--${name}"`, {
after: option.values && `Allowed values:\n${option.values.map(v => ` ${cyan(v)}`)}`
});
}
// we need to prompt
this.logger.banner();
const value = await this.prompt(option);
const src = conf[name][value];
Object.assign(conf.flags, src?.flags);
Object.assign(conf.options, src?.options);
applyCommandConfig(this, cmdName, this.command, {
flags: src?.flags,
options: src?.options
});
}
}
// apply missing option defaults
if (conf.options) {
for (const name of Object.keys(conf.options)) {
if (!Object.hasOwn(this.argv, name) && conf.options[name].default !== undefined) {
cmd.setOptionValue(name, this.argv[name] = conf.options[name].default);
}
}
}
});
// enable command execution
this.ready = true;
// parse the CLI args round 2
this.debugLogger.trace('Parsing arguments second pass...');
await program.parseAsync();
this.debugLogger.trace('Finished parsing arguments second pass');
}
/**
* If the current command supports platform-specific config, this function
* checks for the `--platform` option and prompts when missing.
*
* Finally, the platform specific options and flags are added to the command
* context so that the second parse picks up the newly defined options/flags.
*
* @returns {Promise}
* @access private
*/
async initBuildPlatform() {
const cmdName = this.command.name();
// Commands with build-style platform branches.
if (cmdName !== 'build' && cmdName !== 'serve') {
return;
}
const platformOption = this.command.conf?.options?.platform;
if (!platformOption) {
return;
}
// Support shorthand positional platform syntax, e.g. `ti serve ios`.
// Commander parses this into processedArgs via the [platform] argument
// declared in loadCommand().
const positionalPlatform = this.command.processedArgs?.[0];
if (!this.argv.platform && positionalPlatform && platformOption.values.includes(positionalPlatform)) {
this.debugLogger.trace(`Converting positional platform argument "${positionalPlatform}" to --platform`);
this.argv.platform = positionalPlatform;
}
// when specifying `--platform ios`, the SDK's option callback converts
// it to `iphone`, however the platform config uses `ios` and we must
// convert it back
if (this.argv.platform === 'iphone') {
this.argv.platform = 'ios';
}
this.debugLogger.trace(`Processing --platform option: ${this.argv.platform || 'not specified'}`);
try {
if (!this.argv.platform) {
throw new TiError('Missing required option: --platform <name>', {
after: `Available Platforms:\n${
platformOption.values
.map(v => ` ${cyan(v)}`)
.join('\n')
}`
});
} else if (!platformOption.values.includes(this.argv.platform)) {
throw new TiError(`Invalid platform "${this.argv.$originalPlatform || this.argv.platform}"`, {
code: 'INVALID_PLATFORM'
});
}
} catch (e) {
if (!this.promptingEnabled) {
throw e;
}
if (platformOption.values.length > 1 || e.code === 'INVALID_PLATFORM') {
this.logger.banner();
if (e.code === 'INVALID_PLATFORM') {
this.logger.error(`${e.message}\n`);
}
this.argv.platform = await prompt({
type: 'select',
message: 'Please select a valid platform:',
choices: platformOption.values.map(v => ({ label: v, value: v }))
});
this.debugLogger.trace(`Selecting platform "${this.argv.platform}"`);
} else {
this.argv.platform = platformOption.values[0];
this.debugLogger.trace(`Auto-selecting platform "${this.argv.platform}"`);
}
}
const platformConf = this.command.conf.platforms[this.argv.platform];
this.argv.$platform = this.argv.platform;
// set the platform in Commander so we don't lose it when we re-parse the args
this.command.setOptionValue('platform', this.argv.platform);
// set platform context
this.command.platform = {
conf: platformConf
};
this.debugLogger.trace('Applying platform config...');
applyCommandConfig(this, cmdName, this.command, platformConf);
await this.scanHooks(expand(this.sdk.path, this.argv.platform, 'cli', 'hooks'));
if (this.argv.platform === 'ios') {
await this.scanHooks(expand(this.sdk.path, 'iphone', 'cli', 'hooks'));
}
}
/**
* Initialize the global Commander.js context and add the commands.
*
* @access private
*/
initGlobals() {
program
.name('titanium')
.allowUnknownOption()
.allowExcessArguments()
.addHelpText('beforeAll', () => {
this.logger.bannerEnabled(true);
this.logger.skipBanner(false);
this.logger.banner();
})
.configureHelp({
helpWidth: ticonfig.get('cli.width', 80),
showGlobalOptions: true,
sortSubcommands: true
})
.configureOutput({
outputError(msg) {
throw new TiError(msg.replace(/^error:\s*/, ''));
}
})
.option('--no-banner', 'disable Titanium version banner')
.addOption(
// completely ignored, we just need the parser to not choke
new Option('--color')
.hideHelp()
)
.addOption(
new Option('--colors')
.hideHelp()
)
.option('--no-color', 'disable colors')
.addOption(
new Option('--no-colors')
.hideHelp()
)
.option('--no-progress-bars', 'disable progress bars')
.option('--no-prompt', 'disable interactive prompting')
.option('--config [json]', 'serialized JSON string to mix into the CLI config')
.option('--config-file [file]', 'path to CLI config file')
.option('--debug', 'display CLI debug log messages')
.addOption(
new Option('--debug-no-run')
.hideHelp()
)
.option('-d, --project-dir <path>', 'the directory containing the project')
.option('-q, --quiet', 'suppress all output')
.option('-v, --version', 'displays the current version')
.option('-s, --sdk [version]', `Titanium SDK version to use ${gray('(default: "latest")')}`)
.on('option:config', cfg => {
try {
const json = (0, eval)(`(${cfg})`);
this.debugLogger.trace('Applying --config:', json);
this.config.apply(json);
if (!this.config.cli?.colors) {
chalk.level = 0;
}
this.loadCustomCommands();
} catch (e) {
throw new Error(`Failed to parse --config: ${e.message}`);
}
})
.on('option:config-file', file => {
this.config.load(file);
this.loadCustomCommands();
})
.on('option:project-dir', dir => program.setOptionValue('projectDir', expand(dir)));
const allCommands = [
...Object.entries(commands),
...Object.entries(sdkCommands)
];
for (const [name, summary] of allCommands) {
program
.command(name)
.summary(summary)
.allowUnknownOption()
.action((...args) => this.executeCommand(args));
}
// load custom commands in `paths.commands` from the config file
this.loadCustomCommands();
program.title = 'Global';
// this hook is fired during the first parse when a known command is
// found, then load the hooks in user-defined paths, load the Titanium
// SDK, load the SDK hooks, and load the actual command module
program.hook('preSubcommand', async (_, cmd) => {
const cmdName = cmd.name();
this.debugLogger.trace(`Initializing command: ${cmdName}`);
this.command = cmd;
this.applyArgv(program);
this.promptingEnabled = this.argv.prompt && !this.argv.$_.includes('-h') && !this.argv.$_.includes('--help');
// if `--project-dir` was not set, default to the current working directory
const cwd = expand(this.argv['project-dir'] || '.');
if (cmdName !== 'create') {
// create command doesn't have a --project-dir option
this.argv['project-dir'] = cwd;
}
try {
await this.loadSDK({ cmdName, cwd });
} catch (err) {
if (sdkCommands[cmdName]) {
throw err;
}
// if it's not a `sdk` command, then it's ok if the SDK failed to load
}
// load hooks
const hooks = ticonfig.paths?.hooks;
if (hooks) {
const paths = arrayify(hooks, true);
await Promise.all(paths.map(p => this.scanHooks(p)));
}
await this.loadCommand(cmd);
});
// wire up the default global action handler when no command is present
program.action(() => {
if (this.ready) {
program.help();
}
});
this.command = program;
}
/**
* Detects any "option branches" where a `--option` value determines other
* options that need to be defined. For each option branch, it checks if
* the value was passed in via CLI args, then immediately adds the new
* options/flags to the current command.
*
* @access private
*/
initKnownOptionBranches() {
const { conf } = this.command;
if (!conf) {
return;
}
// any keys in the conf object that aren't explicitly 'flags',
// 'options', 'args', or 'subcommands' is probably a option branch
// that changes the available flags/options
const skipRegExp = /^(flags|options|args|subcommands)$/;
const optionBranches = Object.keys(conf)
.filter(name => conf.options?.[name] && !skipRegExp.test(name))
.sort((a, b) => {
// if we have multiple option groups, then try to process them in order
if (!conf.options[a]?.order) {
return 1;
}
if (!conf.options[b]?.order) {
return -1;
}
return conf.options[b].order - conf.options[a].order;
});
if (optionBranches.length) {
this.debugLogger.trace(`Processing known option branches: ${optionBranches.join(', ')}`);
this.command.optionBranches = optionBranches;
for (let i = 0; i < optionBranches.length; i++) {
// if --<option> was passed in, then mix in the option branch's flags/options
const name = optionBranches[i];
const cmdName = this.command.name();
const value = this.argv[name];
if (value !== undefined) {
const src = conf[name][value];
Object.assign(conf.flags, src?.flags);
Object.assign(conf.options, src?.options);
applyCommandConfig(this, cmdName, this.command, {
flags: src?.flags,
options: src?.options
});
optionBranches.splice(i--, 1);
}
}
}
}
/**
* Load the command's JavaScript implementation, then get the command's
* CLI config.
*
* @param {Command} cmd - The current
* @returns {Promise}
* @access private
*/
async loadCommand(cmd) {
const cmdName = cmd.name();
this.debugLogger.trace(`loadCommand('${cmdName}')`);
let commandFile;
let desc = '';
if (commands[cmdName]) {
desc = commands[cmdName];
commandFile = pathToFileURL(join(fileURLToPath(import.meta.url), `../commands/${cmdName}.js`));
} else if (sdkCommands[cmdName]) {
desc = sdkCommands[cmdName];
commandFile = pathToFileURL(join(this.sdk.path, `cli/commands/${cmdName}.js`));
} else if (this.customCommands[cmdName]) {
commandFile = pathToFileURL(this.customCommands[cmdName]);
} else {
this.debugLogger.warn(`Unknown command "${cmdName}"`);
return;
}
// load the command
this.debugLogger.trace(`Importing: ${commandFile}`);
cmd.module = (await import(commandFile)) || {};
// check if this command is compatible with this version of the CLI
if (cmd.module.cliVersion && !version.satisfies(this.version, cmd.module.cliVersion)) {
throw new TiError(`Command "${cmdName}" incompatible with this version of the CLI`, {
after: `Requires version ${cmd.module.cliVersion}, currently ${this.version}`
});
}
if (typeof cmd.module.extendedDesc === 'string') {
desc = cmd.module.extendedDesc;
} else if (desc) {
desc = capitalize(desc) + (/[.!]$/.test(desc) ? '' : '.');
}
desc = desc.replace(/__(.+?)__/gs, (s, m) => cyan(m));
cmd.description(wrapAnsi(desc, ticonfig.get('cli.width', 80), { hard: true, trim: false }));
// load the command's config
if (typeof cmd.module.config === 'function') {
const fn = await cmd.module.config(this.logger, this.config, this);
const conf = (typeof fn === 'function'
? await new Promise(resolve => fn(resolve))
: fn) || {};
cmd.conf = conf;
if (conf.skipBanner) {
this.logger.skipBanner(true);
}
// `ti create` hack
if (cmdName === 'create') {
// if `--alloy` is not defined, define it
if (!conf.flags.alloy) {
conf.flags.alloy = {
desc: 'initialize new project as an Alloy project'
};
}
}
// if we have a `--platforms` option branch, override the help
if (conf.platforms) {
// the build command's config `platforms` uses `iphone`, but
// the `ti.targetPlatforms` uses `ios`, so rename the key
if (!conf.platforms.ios && conf.platforms.iphone) {
conf.platforms.ios = conf.platforms.iphone;
delete conf.platforms.iphone;
}
this.debugLogger.trace(`Detected conf.platforms loading "${cmdName}", overriding createHelp()`);
this.command.createHelp = () => {
return Object.assign(new TiHelp(this, conf.platforms), this.command.configureHelp());
};
cmd.argument('[platform]', 'target platform');
cmd.usage('[platform] [options]');
}
applyCommandConfig(this, cmdName, cmd, conf);
}
await this.emit('cli:command-loaded', { cli: this, command: this.command });
}
/**
* Loads any custom commands found in `paths.commands`.
*/
loadCustomCommands() {
// load any custom commands
const customCommandPaths = ticonfig.get('paths.commands');
if (Array.isArray(customCommandPaths)) {
const jsRE = /\.js$/;
const ignoreRE = /^[._]/;
for (let p of customCommandPaths) {
if (this.scannedCustomPaths.has(p)) {
continue;
}
this.scannedCustomPaths.add(p);
try {
p = expand(p);
const isDir = fs.statSync(p).isDirectory();
const files = isDir ? fs.readdirSync(p) : [p];
for (const filename of files) {
const file = isDir ? join(p, filename) : filename;
if (!ignoreRE.test(filename) && fs.existsSync(file) && (fs.statSync(file)).isFile() && jsRE.test(file)) {
const name = basename(file).replace(jsRE, '');
this.debugLogger.trace(`Found custom command "${name}"`);
this.customCommands[name] = file;
program
.command(name)
.allowUnknownOption()
.action((...args) => this.executeCommand(args));
}
}
} catch {
// squelch
}
}
}
}
/**
* Detect and load the Titanium SDK, prompt if necessary, update the
* development environment info and CLI banner, and load the SDK hooks.
*
* @param {String} cmdName - The name of the current command.
* @param {String} cwd - The current working directory (e.g. project directory)
* @returns {Promise}
* @access private
*/
async loadSDK({ cmdName, cwd }) {
this.debugLogger.trace('Loading SDK...');
// this is a hack... if we know this is the "create" command and there
// are no options set, then assume we're prompting for everything
// including the Titanium SDK version
let showSDKPrompt = false;
const createOpts = ['-p', '--platforms', '-n', '--name', '-t', '--type'];
if (cmdName === 'create' && !this.argv.sdk && !createOpts.some(f => this.argv.$_.includes(f))) {
showSDKPrompt = true;
}
// load the SDK and its hooks
const {
installPath,
sdk,
sdkPaths,
sdks,
tiappSdkVersion
} = await initSDK({
config: this.config,
cwd,
debugLogger: this.debugLogger,
logger: this.logger,
promptingEnabled: this.promptingEnabled,
selectedSdk: this.argv.sdk,
showSDKPrompt
});
this.env.installPath = installPath;
this.env.os.sdkPaths = sdkPaths;
this.env.sdks = sdks;
this.env.getSDK = version => {
if (!version || version === 'latest') {
return sdk;
}
const values = Object.values(sdks);
return values.find(s => s.name === version) ||
values.find(s => s.version === version) ||
null;
};
this.sdk = sdk;
this.argv.sdk = sdk?.name;
if (sdkCommands[cmdName]) {
const hasSDKs = Object.keys(sdks).length > 0;
if (!hasSDKs || !sdk) {
if (hasSDKs && tiappSdkVersion) {
throw new TiError(`The <sdk-version> in the tiapp.xml is set to "${tiappSdkVersion}", but this version is not installed`, {
after: `Available SDKs:\n${Object.values(sdks).map(sdk => ` ${cyan(sdk.name.padEnd(24))} ${gray(typeLabels[sdk.type])}`).join('\n')}`
});
}
throw new TiError('No Titanium SDKs found', {
after: `You can download the latest Titanium SDK by running: ${cyan('titanium sdk install')}`
});
}
try {
// check if the SDK is compatible with our version of node
sdk.packageJson = await fs.readJson(join(sdk.path, 'package.json'));
const current = process.versions.node;
const required = sdk.packageJson.vendorDependencies.node;
const supported = version.satisfies(current, required, true);
if (supported === false) {
throw new TiError(`Titanium SDK v${sdk.name} is incompatible with Node.js v${current}`, {
after: `Please install Node.js ${version.parseMax(required)} in order to use this version of the Titanium SDK.`
});
}
} catch {
// do nothing
}
}
// render the banner
this.logger.setBanner({
name: this.name,
copyright: this.copyright,
version: this.version,
sdkVersion: this.sdk?.name
});
// if we're running a `sdk` command, then scan the SDK for hooks
if (sdkCommands[cmdName]) {
this.debugLogger.trace('Loading SDK hooks...');
await this.scanHooks(expand(this.sdk.path, 'cli', 'hooks'));
}
}
/**
* Registers an event callback.
*
* @param {String} name - The name of the event.
* @param {Function} callback - The listener to register.
* @returns {CLI}
* @access public
*/
on(name, callback) {
let priority = CLI.HOOK_PRIORITY_DEFAULT;
let i;
if (typeof callback === 'function') {
callback = { post: callback };
} else if (callback && typeof callback === 'object') {
priority = parseInt(callback.priority) || priority;
}
if (callback.pre) {
const h = this.hooks.pre[name] || (this.hooks.pre[name] = []);
callback.pre.priority = priority;
for (i = 0; i < h.length && priority >= h[i].priority; i++) {}
h.splice(i, 0, callback.pre);
}
if (callback.post) {
const h = this.hooks.post[name] || (this.hooks.post[name] = []);
callback.post.priority = priority;
for (i = 0; i < h.length && priority >= h[i].priority; i++) {}
h.splice(i, 0, callback.post);
}
return this;
}
/**
* Prompt for a missing option. In some cases, the prompting is actually
* performed by the `fields` prompt library defined in the Titanium SDK
* itself, otherwise the `prompts` library is used.
*
* @param {Object} opt - A CLI option to prompt for.
* @returns {Promise}
* @access private
*/
async prompt(opt) {
let value;
this.debugLogger.trace(`Prompting for --${opt.name}`);
if (typeof opt.prompt === 'function') {
// option has it's own prompt handler which probably uses `fields`
const field = await new Promise(resolve => opt.prompt(resolve));
if (!field) {
return;
}
if (opt._err && field.autoSelectOne) {
field.autoSelectOne = false;
}
value = await new Promise(resolve => {
field.prompt((err, value) => {
if (err) {
process.exit(1);
}
this.logger.log();
resolve(value);
});
});
} else {
// use the generic prompt
const pr = opt.prompt || {};
const p = (pr.label || capitalize(opt.desc || '')).trim().replace(/:$/, '');
let def = pr.default || opt.default || '';
if (typeof def === 'function') {
def = def();
} else if (Array.isArray(def)) {
def = def.join(',');
}
const validate = pr.validate || (value => {
if (pr.validator) {
try {
pr.validator(value);
} catch (ex) {
return ex.toString();
}
} else if (!value.length || (pr.pattern && !pr.pattern.test(value))) {
return pr.error;
}
return true;
});
if (Array.isArray(opt.values)) {
if (opt.values.length > 1) {
const choices = opt.values.map(v => ({ label: v, value: v }));
value = await prompt({
type: 'select',
message: p,
initial: def && choices.find(c => c.value === def) || undefined,
validate,
choices
});
} else {
value = opt.values[0];
}
} else {
value = await prompt({
type: opt.password ? 'password' : 'text',
message: p,
initial: def || undefined,
validate
});
}
if (value === undefined) {
// sigint
process.exit(0);
}
}
this.debugLogger.trace(`Selected value = ${value}`);
this.command.setOptionValue(opt.name, value);
return this.argv[opt.name] = value;
}
/**
* Reset/remove all hooks and option event handlers for all Commander.js
* command contexts. This is needed because the Commander.js parser is
* invoked twice and we don't want the events to be fired twice and cause
* things to be loaded and defined again.
*
* @param {Command} ctx - A Commander.js command context.
* @access private
*/
resetCommander(ctx) {
ctx._lifeCycleHooks = {};
ctx._savedState = null;
const optionEvents = ctx.eventNames().filter(name => name.startsWith('option:'));
for (const name of optionEvents) {
ctx.removeAllListeners(name);
}
for (const cmd of ctx.commands) {
this.resetCommander(cmd);
}
}
/**
* Searches the specified directory for Titanium CLI plugin files.
*
* @param {String} dir - The directory to scan.
* @access public
*/
async scanHooks(dir) {
dir = expand(dir);
this.debugLogger.trace(`Scanning hooks: ${dir}`);
if (this.hooks.scannedPaths[dir]) {
return;
}
try {
const jsfile = /\.js$/;
const ignore = /^[._]/;
const files = fs.statSync(dir).isDirectory() ? fs.readdirSync(dir).map(n => join(dir, n)) : [dir];
let appc;
for (const file of files) {
try {
if (fs.statSync(file).isFile() && jsfile.test(file) && !ignore.test(basename(dirname(file)))) {
const startTime = Date.now();
const mod = await import(pathToFileURL(file));
if (mod.id) {
if (!Array.isArray(this.hooks.ids[mod.id])) {
this.hooks.ids[mod.id] = [];
}
this.hooks.ids[mod.id].push({
file: file,
version: mod.version || null
});
// don't load duplicate ids
if (this.hooks.ids[mod.id].length > 1) {
continue;
}
}
if (this.sdk && (!this.version || !mod.cliVersion || version.satisfies(this.version, mod.cliVersion))) {
if (!appc) {
const nodeAppc = pathToFileURL(join(this.sdk.path, 'node_modules', 'node-appc', 'index.js'));
this.debugLogger.trace(`Importing: ${join(this.sdk.path, 'node_modules', 'node-appc', 'index.js')}`);
appc = (await import(nodeAppc)).default;
}
if (typeof mod.init === 'function') {
await mod.init(this.logger, this.config, this, appc);
}
this.hooks.loadedFilenames.push(file);
this.debugLogger.trace(`Loaded CLI hook: ${file} (${Date.now() - startTime} ms)`);
} else {
this.debugLogger.trace(`Incompatible CLI hook: ${file}`);
this.hooks.incompatibleFilenames.push(file);
}
}
} catch (e) {
this.hooks.erroredFilenames.push(file);
e.stack = e.stack.replace(/\n+/, '\n');
this.hooks.errors[file] = e;
}
}
} catch (err) {
if (err.code !== 'ENOENT') {
this.debugLogger.trace(`Error scanning hooks: ${dir}`);
this.debugLogger.trace(err.stack);
}
}
}
/**
* Validates the arguments. First it checks against the built-in naive
* validation such as required or against a list of values. Next it calls
* each option's validator. After that it calls the command's validator.
* Lastly it calls each option's value callback.
*
* @returns {Promise}
* @access private
*/
async validate() {
await this.emit('cli:pre-validate', { cli: this, command: this.command });
const options = {};
for (const ctx of [this.command, this.command?.platform]) {
if (ctx?.conf?.options) {
Object.assign(options, ctx.conf.options);
}
}
if (Object.keys(options).length) {
const orderedOptionNames = Object.keys(options).sort((a, b) => {
if (options[a].order && options[b].order) {
return options[a].order - options[b].order;
}
if (options[a].order) {
return -1;
}
if (options[b].order) {
return 1;
}
return 0;
});
this.debugLogger.trace('Checking for missing/invalid options:', orderedOptionNames);
// this while loop is essentially a pump that processes missing/invalid
// options one at a time, recalculating them each iteration
while (true) {
const invalid = {};
let invalidCount = 0;
const missing = {};
let missingCount = 0;
for (const name of orderedOptionNames) {
if (this.promptingEnabled && (missingCount || invalidCount)) {
continue;
}
const opt = options[name];
if (opt.validated) {
continue;
}
const obj = Object.assign(opt, { name: name });
// check missing required options and invalid options
if (this.argv[name] === undefined) {
// check if the option is required
if (opt.required || opt.conf?.required) {
// ok, we have a required option, but it's possible that this option
// replaces some legacy option in which case we need to check if the
// legacy options were defined
this.debugLogger.trace(`--${name} required, but undefined`);
if (typeof opt.verifyIfRequired === 'function') {
await new Promise(resolve => {
opt.verifyIfRequired(stillRequired => {
if (stillRequired) {
missing[name] = obj;
missingCount++;
}
resolve();
});
});
continue;
}
missing[name] = obj;
missingCount++;
}
} else if (Array.isArray(opt.values) && !opt.skipValueCheck && opt.values.indexOf(this.argv[name]) === -1) {
invalid[name] = obj;
invalidCount++;
} else if (!opt.validated && typeof opt.validate === 'function') {
try {
await new Promise(resolve => {
opt.validate(this.argv[name], (err, value) => {
if (err) {
obj._err = err;
invalid[name] = obj;
invalidCount++;
} else {
this.argv[name] = value;
opt.validated = true;
if (opt.callback) {
var val = opt.callback(this.argv[name] || '');
val !== undefined && (this.argv[name] = val);
delete opt.callback;
}
}
resolve();
});
});
} catch (ex) {
if (ex instanceof GracefulShutdown) {
this.command.skipRun = true;
return;
}
throw ex;
}
} else if (opt.callback) {
opt.validated = true;
const val = opt.callback(this.argv[name] || '');
if (val !== undefined) {
this.argv[name] = val;
}
delete opt.callback;
}
}
// at this point, we know if we have any invalid or missing options
if (!invalidCount && !missingCount) {
break;
}
// we have an invalid option or missing option
if (!this.promptingEnabled) {
// if we're not prompting, output the invalid/missing options and exit
this.logger.banner();
if (Object.keys(invalid).length) {
for (const name of Object.keys(invalid)) {
const opt = invalid[name];
const msg = `Invalid "${opt.label || `--${name}`}" value "${this.argv[opt.name]}"`;
if (typeof opt.helpNoPrompt === 'function') {
opt.helpNoPrompt(this.logger, msg);
} else {
this.logger.error(`${msg}\n`);
if (opt.values) {
this.logger.log('Accepted values:');
for (const v of opt.values) {
this.logger.log(` ${cyan(v)}`);
}
this.logger.log();
}
}
}
}
if (Object.keys(missing).length) {
// if prompting is disabled, then we just print all the problems we encountered
for (const name of Object.keys(missing)) {
const msg = `Missing required option: --${name} <${missing[name].hint || 'value'}>`;
if (typeof missing[name].helpNoPrompt === 'function') {
missing[name].helpNoPrompt(this.logger, msg);
} else {
this.logger.error(`${msg}\n`);
}
}
}
const cmd = ['titanium'];
if (this.command) {
cmd.push(this.command.name());
}
cmd.push('--help');
this.logger.log(`For help, run: ${cyan(cmd.join(' '))}\n`);
process.exit(1);
}
// we are prompting, so find the first invalid or missing option
let opt;
if (invalidCount) {
const name = Object.keys(invalid).shift();
opt = invalid[name];
if (!opt.prompt) {
// option doesn't have a prompt, so let's make a generic one
opt.prompt = async callback => {
// if the option has values, then display a pretty list
if (Array.isArray(opt.values)) {
return callback({
async prompt(callback) {
const value = await prompt({
type: 'select',
message: `Please select a valid ${cyan(name)} value:`,
choices: opt.values.map(v => ({ label: v, value: v }))
});
callback(null, value);
}
});
}
const pr = opt.prompt || {};
return callback({
async prompt(callback) {
const value = await prompt({
type: opt.password ? 'password' : 'text',
message: `Please enter a valid ${cyan(name)}`,
validate: opt.validate || (value => {
if (pr.validator) {
try {
pr.validator(value);
} catch (ex) {
return ex.toString();
}
} else if (!value.length || (pr.pattern && !pr.pattern.test(value))) {
return pr.error;
}
return true;
})
});
if (value === undefined) {
// sigint
process.exit(0);
}
callback(null, value);
}
});
};
}
} else {
// must be a missing option
opt = missing[Object.keys(missing).shift()];
}
// do the prompting
await this.prompt(opt);
try {
opt._err = null;
opt.validated = true;
if (opt.callback) {
try {
const val = opt.callback(this.argv[opt.name] || '');
if (val !== undefined) {
this.argv[opt.name] = val;
}
delete opt.callback;
} catch (e) {
if (e instanceof GracefulShutdown) {
this.command.skipRun = true;
} else {
throw e;
}
}
}
} catch {
this.argv[opt.name] = undefined;
}
}
}
// fire the command's validate
const fn = this.command.module?.validate;
if (fn && typeof fn === 'function') {
this.debugLogger.trace(`Executing command's validate: ${this.command.name()}`);
const result = fn(this.logger || this.debugLogger, this.config, this);
// fn should always be a function for `build` and `clean` commands
if (typeof result === 'function') {
await new Promise(resolve => result(resolve));
} else if (result === false) {
this.command.skipRun = true;
}
}
await this.emit('cli:post-validate', { cli: this, command: this.command });
// fire all option callbacks for any options we missed above
for (const ctx of [this.command, this.command?.platform]) {
if (ctx?.conf?.options) {
for (const [name, opt] of Object.entries(ctx.conf.options)) {
if (typeof opt.callback === 'function') {
try {
const val = opt.callback(this.argv[name] || '');
if (val !== undefined) {
this.argv[name] = val;
}
} catch (e) {
if (e instanceof GracefulShutdown) {
// the --sdk differs from the tiapp.xml version,
// so gracefully return here and let node-titanium-sdk
// fork the command with the correct SDK version
this.command.skipRun = true;
} else {
throw e;
}
}
}
}
}
}
}
/**
* Display any warnings for incompatible, bad, or conflicting plugins.
*
* @access private
*/
validateHooks() {
if (this.hooks.incompatibleFilenames.length) {
this.logger.warn(`Incompatible plugin hooks:\n${
this.hooks.incompatibleFilenames.map(file => ` ${file}`).join('\n')
}\n`);
}
if (Object.keys(this.hooks.errors).length) {
this.logger.warn(`Bad plugin hooks that failed to load:\n${
Object.values(this.hooks.errors)
.map(e => (e.stack || e.toString())
.trim()
.split('\n')
.map(line => ` ${line}`)
.join('\n')
)
.join('\n')
}\n`);
}
if (Object.keys(this.hooks.ids).some(id => this.hooks.ids[id].length > 1)) {
t