UNPKG

titanium

Version:

Command line interface for building Titanium SDK apps

1,693 lines (1,501 loc) 50.4 kB
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