UNPKG

titanium

Version:

Command line interface for building Titanium SDK apps

934 lines (846 loc) 26.3 kB
import { detect as proxyDetect } from './proxy.js'; import { prompt } from './prompt.js'; import chalk from 'chalk'; import { expand } from './expand.js'; import { existsSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { BusyIndicator } from './busyindicator.js'; import { detect } from './detect.js'; import { request } from './request.js'; import * as version from './version.js'; import { detectTitaniumSDKs, getReleases } from './tisdk.js'; import dns from 'node:dns/promises'; import os from 'node:os'; import { join } from 'node:path'; const { bold, cyan, gray, green, magenta, red, yellow } = chalk; export class SetupScreens { proxy = []; screens = { quick: { label: '__q__uick', desc: 'Quick Setup' }, check: { label: 'chec__k__', desc: 'Check Environment' }, user: { label: '__u__ser', desc: 'User Information' }, app: { label: 'a__p__p', desc: 'New App Defaults' }, network: { label: '__n__etwork', desc: 'Network Settings' }, cli: { label: '__c__li', desc: 'Titanium CLI Settings' }, android: { label: '__a__ndroid', desc: 'Android Settings' }, ios: { label: '__i__os', desc: 'iOS Settings' } }; constructor(logger, config, cli) { this.logger = logger; this.config = config; this.cli = cli; } async run() { const p = await proxyDetect(); if (p) { this.proxy.push(p); } let next = this.cli.argv._[0] || 'mainmenu'; let screen = this[`${next}Screen`]; while (screen) { next = (await screen.call(this)) || 'mainmenu'; this.cli.debugLogger.trace(`Next screen: ${next}`); screen = this[`${next}Screen`]; } } async mainmenuScreen() { const screens = Object.keys(this.screens).filter(name => name !== 'ios' || process.platform === 'darwin'); const lookup = { [screens.length + 1]: 'exit', exit: 'exit', x: 'exit' }; this.logger.log( screenTitle('Main Menu') + '\n' + screens .map((name, i) => { const { label, desc } = this.screens[name]; const padding = 7 - (label.length - 4); const title = cyan( label.replace(/__(.+)__/, (_s, char) => { lookup[char] = name; return bold(char); }) + (padding > 0 ? ' '.repeat(padding) : '') ); lookup[name] = lookup[i + 1] = name; return `${String(i + 1).padStart(4)}) ${title} ${desc}`; }) .join('\n') + `\n${String(screens.length + 1).padStart(4)}) ${cyan( 'e__x__it'.replace(/__(.+)__/, (_s, char) => bold(char))) } Exit` ); const value = await prompt({ type: 'text', message: 'Where do you want to go?' }); const next = lookup[value]; if (!next || next === 'exit') { process.exit(0); } return next; } async quickScreen() { this.logger.log(screenTitle('Quick Setup')); let data; const busy = new BusyIndicator(); busy.start(); try { ({ data } = await detect(this.cli.debugLogger, this.config, this.cli, { all: true })); } finally { busy.stop(); } const values = await prompt([ { type: 'text', message: 'What do you want as your "author" name?', initial: this.config.get('user.name', ''), name: 'name' }, { type: 'text', message: 'Path to your workspace where your projects should be created:', initial: this.config.get('app.workspace', ''), name: 'workspace', validate: value => { if (!value) { return 'Please specify a workspace directory'; } value = expand(value); if (!existsSync(value)) { return 'Specified workspace directory does not exist'; } return true; } }, { type: 'toggle', message: 'Do you plan to build your app for Android?', initial: true, name: 'usingAndroid', active: 'yes', inactive: 'no' }, { type: prev => prev ? 'text' : null, message: 'Path to the Android SDK', initial: this.config.get('android.sdkPath', data?.android?.sdk?.path), name: 'androidSdkPath', validate: value => { if (!value) { return 'Please specify the Android SDK directory'; } value = expand(value); if (!existsSync(value)) { return 'Specified Android SDK directory does not exist'; } if (process.platform === 'win32' && value.includes('&')) { return 'The Android SDK path must not contain ampersands (&) on Windows'; } const adbExecutable = join(value, 'platform-tools', 'adb' + (process.platform === 'win32' ? '.exe' : '')); if (!existsSync(adbExecutable)) { return 'Invalid Android SDK path: adb not found'; } return true; } } ]); this.config.set('user.name', values.name); this.config.set('app.workspace', values.workspace); if (values.androidSdkPath !== undefined) { this.config.set('android.sdkPath', values.androidSdkPath); } this.config.save(); this.logger.log('\nConfiguration saved!'); } async checkScreen() { this.logger.log(screenTitle('Check Environment')); let data; const busy = new BusyIndicator(); busy.start(); let online = true; try { await dns.resolve('github.com'); } catch { online = false; } try { ({ data } = await detect(this.cli.debugLogger, this.config, this.cli, { all: true })); data.titaniumCLI.latest = await request('https://registry.npmjs.org/-/package/titanium/dist-tags') .then(res => res.body.json()) .then(r => r.latest); data.titaniumSDK = { installed: await detectTitaniumSDKs(this.config), latest: online && (await getReleases())?.[0] || null }; data.network = { online, proxy: this.config.get('cli.httpProxyServer'), test: !!data.titaniumSDK.latest?.name }; } finally { busy.stop(); } const log = (...args) => this.logger.log(...args); let labelPadding = 18; const checkmark = '✓'; const starmark = '\u2605'; const xmark = '\u2715'; const ok = (label, status, extra) => { log(` ${green(checkmark)} ${label.padEnd(labelPadding)} ${status ? green(status) : ''}${extra ? gray(` ${extra}`) : ''}`); }; const warn = (label, status, extra) => { log(` ${bold(yellow('!'))} ${label.padEnd(labelPadding)} ${status ? yellow(status) : ''}${extra ? gray(` ${extra}`) : ''}`); }; const bad = (label, status, extra) => { log(` ${red(xmark)} ${label.padEnd(labelPadding)} ${status ? red(status) : ''}${extra ? gray(` ${extra}`) : ''}`); }; const update = (label, status, extra) => { log(` ${magenta(starmark)} ${label.padEnd(labelPadding)} ${status ? magenta(status) : ''}${extra ? gray(` ${extra}`) : ''}`); }; const note = (label, status, extra) => { log(` ${bold(gray('-'))} ${label.padEnd(labelPadding)} ${status ? gray(status) : ''}${extra ? gray(` ${extra}`) : ''}`); }; log('Node.js'); ok('node', 'installed', '(v' + data.node.version + ')'); ok('npm', 'installed', '(v' + data.npm.version + ')'); log(); log('Titanium CLI'); if (data.titaniumCLI.latest === null) { note('cli', `(v${data.titaniumCLI.version})`); } else if (data.titaniumCLI.latest === data.titaniumCLI.version) { ok('cli', 'up-to-date', `(v${data.titaniumCLI.version})`); } else if (version.gt(data.titaniumCLI.version, data.titaniumCLI.latest)) { ok('cli', 'bleeding edge', `(v${data.titaniumCLI.version})`); } else { update('cli', `new version v${data.titaniumCLI.latest} available`, `(currently v${data.titaniumCLI.version})`); } log(); log('Titanium SDK'); if (data.titaniumSDK.latest === null) { note('latest sdk', 'unknown (offline)'); } else if (!data.titaniumSDK.installed.sdks.length) { bad('latest sdk', 'no Titanium SDKs found'); } else if (data.titaniumSDK.installed.sdks.find(s => s.name === data.titaniumSDK.latest.name)) { ok('latest sdk', 'installed', `(v${data.titaniumSDK.latest.name})`); } else { update('latest sdk', `new version v${data.titaniumSDK.latest.name} available!`); } log(); if (process.platform === 'darwin') { log('iOS Environment'); let data; const busy = new BusyIndicator(); busy.start(); try { ({ data } = await detect(this.cli.debugLogger, this.config, this.cli, { all: true })); } finally { busy.stop(); } if (data.ios) { const distPPLabel = 'dist provisioning'; const len = distPPLabel.length; if (Object.keys(data.ios.xcode).length) { ok('Xcode'.padEnd(len), 'installed', `(${ Object .keys(data.ios.xcode) .filter(ver => ver !== '__selected__') .map(ver => data.ios.xcode[ver].version) .sort() .join(', ') })`); const iosSdks = {}; for (const ver of Object.keys(data.ios.xcode)) { if (ver !== '__selected__') { for (const v of data.ios.xcode[ver].sdks) { iosSdks[v] = 1; } } } if (Object.keys(iosSdks).length) { ok('iOS SDK'.padEnd(len), 'installed', `(${Object.keys(iosSdks).sort().join(', ')})`); } else { warn('iOS SDK'.padEnd(len), 'no iOS SDKs found'); } } else { warn('Xcode'.padEnd(len), 'no Xcode installations found'); warn('iOS SDK'.padEnd(len), 'no Xcode installations found'); } if (data.ios.certs.wwdr) { ok('WWDR cert'.padEnd(len), 'installed'); } else { warn('WWDR cert'.padEnd(len), 'not found'); } let devCerts = 0; let distCerts = 0; for (const keychain of Object.keys(data.ios.certs.keychains)) { if (data.ios.certs.keychains[keychain].developer) { for (const i of data.ios.certs.keychains[keychain].developer) { if (!Object.hasOwn(i, 'invalid') || i.invalid === false) { devCerts++; } } } if (data.ios.certs.keychains[keychain].distribution) { for (const i of data.ios.certs.keychains[keychain].distribution) { if (!Object.hasOwn(i, 'invalid') || i.invalid === false) { distCerts++; } } } } if (devCerts) { ok('developer cert'.padEnd(len), 'installed', `(${devCerts} found)`); } else { warn('developer cert'.padEnd(len), 'not found'); } if (distCerts) { ok('distribution cert'.padEnd(len), 'installed', `(${distCerts} found)`); } else { warn('distribution cert'.padEnd(len), 'not found'); } const devPP = data.ios.provisioning.development.filter(i => { return !Object.hasOwn(i, 'expired') || i.expired === false; }).length; if (devPP) { ok('dev provisioning'.padEnd(len), 'installed', `(${devPP} found)`); } else { warn('dev provisioning'.padEnd(len), 'not found'); } const distPP = data.ios.provisioning.distribution.filter(i => { return !Object.hasOwn(i, 'expired') || i.expired === false; }).length + data.ios.provisioning.adhoc.filter(i => { return !Object.hasOwn(i, 'expired') || i.expired === false; }).length + data.ios.provisioning.enterprise.filter(i => { return !Object.hasOwn(i, 'expired') || i.expired === false; }).length; if (distPP) { ok(distPPLabel, 'installed', `(${distPP} found)`); } else { warn(distPPLabel, 'not found'); } } else { log(yellow(' A Titanium SDK must be installed to detect Android environment')); log(` To install the latest SDK, run: ${cyan('titanium sdk install')}`); } log(); } log('Android Environment'); if (data.android) { if (data.android.sdk?.path) { ok('sdk', 'installed', `(${data.android.sdk.path})`); if (data.android.sdk.platformTools && data.android.sdk.platformTools.path) { if (data.android.sdk.platformTools.supported === 'maybe') { warn('platform tools', `untested version ${data.android.sdk.platformTools.version}; may or may not work`); } else if (data.android.sdk.platformTools.supported) { ok('platform tools', 'installed', `(v${data.android.sdk.platformTools.version})`); } else { bad('platform tools', `unsupported version ${data.android.sdk.platformTools.version}`); } } if (data.android.sdk.buildTools && data.android.sdk.buildTools.path) { if (data.android.sdk.buildTools.supported === 'maybe') { warn('build tools', `untested version ${data.android.sdk.buildTools.version}; may or may not work`); } else if (data.android.sdk.buildTools.supported) { ok('build tools', 'installed', `(v${data.android.sdk.buildTools.version})`); } else { bad('build tools', `unsupported version ${data.android.sdk.buildTools.version}`); } } if (data.android.sdk.executables) { if (data.android.sdk.executables.adb) { ok('adb', 'installed', data.android.sdk.executables.adb); } else { bad('adb', '"adb" executable not found; please reinstall Android SDK'); } if (data.android.sdk.executables.emulator) { ok('emulator', 'installed', data.android.sdk.executables.emulator); } else { bad('emulator', '"emulator" executable not found; please reinstall Android SDK'); } } } else { warn('sdk', 'Android SDK not found'); } if (data.android.targets && Object.keys(data.android.targets).length) { ok('targets', 'installed', `(${Object.keys(data.android.targets).length} found)`); } else { warn('targets', 'no targets found'); } if (data.android.emulators?.length) { ok('emulators', 'installed', `(${data.android.emulators.length} found)`); } else { warn('emulators', 'no emulators found'); } if (data.android.ndk) { ok('ndk', 'installed', `(${data.android.ndk.version})`); if (data.android.ndk.executables) { ok('ndk-build', 'installed', `(${data.android.ndk.executables.ndkbuild})`); } } else { warn('ndk', 'Android NDK not found'); } } else { log(yellow(' A Titanium SDK must be installed to detect Android environment')); log(` To install the latest SDK, run: ${cyan('titanium sdk install')}`); } log(); // end android log('Java Development Kit'); if (data.jdk.version == null) { bad('jdk', 'JDK not found!'); } else { ok('jdk', 'installed', `(v${data.jdk.version})`); if (data.jdk.executables.java) { ok('java', 'installed', data.jdk.executables.java); } else { bad('java', '"java" executable not found; please reinstall JDK 1.6'); } if (data.jdk.executables.javac) { ok('javac', 'installed', data.jdk.executables.javac); } else { bad('javac', '"javac" executable not found; please reinstall JDK 1.6'); } if (data.jdk.executables.keytool) { ok('keytool', 'installed', data.jdk.executables.keytool); } else { bad('keytool', '"keytool" executable not found; please reinstall JDK 1.6'); } } log(); log('Network'); if (data.network.online) { ok('online'); } else { warn('offline'); } if (data.network.proxy) { ok('proxy server enabled', data.network.proxy); } else { note('no proxy server configured'); } if (data.network.online) { if (data.network.test) { ok('Network connection test'); } else { bad('github.com is unreachable'); } } else { note('Network connection test'); } log(); log('Directory Permissions'); labelPadding = 31; const dirs = [ ['~', 'home directory'], ['~/.titanium', 'titanium config directory'], [this.cli.env.installPath, 'titanium sdk install directory'], [this.config.get('app.workspace'), 'workspace directory'], [os.tmpdir(), 'temp directory'] ]; for (let [dir, desc] of dirs) { if (dir) { dir = isDirWritable(dir); if (dir) { if (isDirWritable(dir)) { ok(desc, dir); } else { bad(desc, `"${dir}" not writable, check permissions and owner`); } } else { warn(desc, `"${dir}" does not exist`); } } } } async userScreen() { this.logger.log(screenTitle('User')); const name = await prompt({ type: 'text', message: 'What do you want as your "author" name?', initial: this.config.get('user.name', ''), name: 'name' }); if (name) { this.config.set('user.name', name); this.config.save(); this.logger.log('\nConfiguration saved!'); } } async appScreen() { this.logger.log(screenTitle('New App Defaults')); const values = await prompt([ { type: 'text', message: 'Path to your workspace where your projects should be created:', initial: this.config.get('app.workspace', ''), name: 'workspace', validate: value => { if (!value) { return 'Please specify a workspace directory'; } value = expand(value); if (!existsSync(value)) { return 'Specified workspace directory does not exist'; } return true; } }, { type: 'text', message: 'What is your prefix for application IDs? (example: com.mycompany)', initial: this.config.get('app.idprefix'), name: 'idprefix' }, { type: 'text', message: 'What is the name of your organization to use as the "publisher"?', initial: this.config.get('app.publisher'), name: 'publisher' }, { type: 'text', message: 'What is the URL of your organization?', initial: this.config.get('app.url'), name: 'url' } ]); this.config.set('app.workspace', values.workspace); this.config.set('app.idprefix', values.idprefix); this.config.set('app.publisher', values.publisher); this.config.set('app.url', values.url); this.config.save(); this.logger.log('\nConfiguration saved!'); } async networkScreen() { this.logger.log(screenTitle('Network Settings')); let defaultProxy = this.config.get('cli.httpProxyServer', undefined); if (!defaultProxy) { for (const proxy of this.proxy) { if (proxy.valid) { defaultProxy = proxy.fullAddress; break; } } } const values = await prompt([ { type: 'toggle', message: 'Are you behind a proxy server?', initial: !!this.config.get('cli.httpProxyServer'), name: 'hasProxy', active: 'yes', inactive: 'no' }, { type: prev => prev ? 'text' : null, message: 'Proxy server URL', initial: defaultProxy, name: 'httpProxyServer', validate: value => { try { const u = new URL(value); if (!/^https?:$/.test(u.protocol)) { return 'HTTP proxy URL protocol must be either "http" or "https" (e.g.: http://user:pass@example.com)'; } if (!(u.host || '')) { return 'HTTP proxy URL must contain a host name (e.e.: http://user:pass@example.com)'; } return true; } catch (e) { return e.message; } } }, { type: 'toggle', message: 'Verify server (SSL) certificates against known certificate authorities?', initial: !!this.config.get('cli.rejectUnauthorized'), name: 'rejectUnauthorized', active: 'yes', inactive: 'no' } ]); this.config.set('cli.httpProxyServer', values.hasProxy ? values.httpProxyServer : ''); this.config.set('cli.rejectUnauthorized', values.rejectUnauthorized); this.config.save(); this.logger.log('\nConfiguration saved!'); } async cliScreen() { this.logger.log(screenTitle('Titanium CLI Settings')); const logLevels = this.logger.getLevels().reverse(); const values = await prompt([ { type: 'toggle', message: 'Enable colors?', initial: this.config.get('cli.colors', true), name: 'colors', active: 'yes', inactive: 'no' }, { type: 'toggle', message: 'Enable interactive prompting for missing options and arguments?', initial: this.config.get('cli.prompt', true), name: 'prompt', active: 'yes', inactive: 'no' }, { type: 'toggle', message: 'Display progress bars when downloading or installing?', initial: this.config.get('cli.progressBars', true), name: 'progressBars', active: 'yes', inactive: 'no' }, { type: 'select', message: 'Output log level', initial: logLevels.indexOf(this.config.get('cli.logLevel', 'info')), name: 'logLevel', choices: this.logger.getLevels().reverse().map(level => { return { title: level, value: level }; }) }, { type: 'number', message: 'What is the width of the Titanium CLI output?', initial: this.config.get('cli.width', 80), name: 'width', validate: value => { return value !== '' && value < 1 ? 'Please enter a positive number' : true; } } ]); this.logger.setLevel(values.logLevel); this.config.set('cli.colors', values.colors); this.config.set('cli.prompt', values.prompt); this.config.set('cli.progressBars', values.progressBars); this.config.set('cli.logLevel', values.logLevel); this.config.set('cli.width', values.width); this.config.save(); this.logger.log('\nConfiguration saved!'); } async androidScreen() { this.logger.log(screenTitle('Android Settings')); let data; const busy = new BusyIndicator(); busy.start(); try { ({ data } = await detect(this.cli.debugLogger, this.config, this.cli, { all: true })); } finally { busy.stop(); } const values = await prompt([ { type: 'text', message: 'Path to the Android SDK', initial: this.config.get('android.sdkPath', data?.android?.sdk?.path), name: 'androidSdkPath', validate: value => { if (!value) { return 'Please specify the Android SDK directory'; } value = expand(value); if (!existsSync(value)) { return 'Specified Android SDK directory does not exist'; } if (process.platform === 'win32' && value.includes('&')) { return 'The Android SDK path must not contain ampersands (&) on Windows'; } const adbExecutable = join(value, 'platform-tools', `adb${process.platform === 'win32' ? '.exe' : ''}`); if (!existsSync(adbExecutable)) { return 'Invalid Android SDK path: adb not found'; } return true; } }, { type: 'toggle', message: 'Do you plan to build native Titanium Modules?', initial: false, name: 'modules', active: 'yes', inactive: 'no' }, { type: prev => prev ? 'text' : null, message: 'Path to the Android NDK', initial: this.config.get('android.ndkPath', data?.android?.ndk?.path), name: 'androidNdkPath', validate: value => { if (!value) { return 'Please specify the Android NDK directory'; } value = expand(value); if (!existsSync(value)) { return 'Specified Android NDK directory does not exist'; } const ndkbuildExecutable = join(value, `ndk-build${process.platform === 'win32' ? '.cmd' : ''}`); if (!existsSync(ndkbuildExecutable)) { return 'Invalid Android NDK path: ndk-build not found'; } return true; } } ]); if (values.androidSdkPath !== undefined) { this.config.set('android.sdkPath', values.androidSdkPath); } if (values.androidNdkPath !== undefined) { this.config.set('android.ndkPath', values.androidNdkPath); } if (values.androidSdkPath !== undefined || values.androidNdkPath !== undefined) { this.config.save(); this.logger.log('\nConfiguration saved!'); } } async iosScreen() { if (process.platform !== 'darwin') { return; } this.logger.log(screenTitle('iOS Settings')); let data; const busy = new BusyIndicator(); busy.start(); try { ({ data } = await detect(this.cli.debugLogger, this.config, this.cli, { all: true })); } finally { busy.stop(); } const devList = []; const devNames = {}; const currentDevName = this.config.get('ios.developerName'); const distList = []; const distNames = {}; const currentDistName = this.config.get('ios.distributionName'); const questions = []; for (const keychain of Object.keys(data.ios.certs.keychains)) { if (data.ios.certs.keychains[keychain].developer) { for (const dev of data.ios.certs.keychains[keychain].developer) { const { name, invalid } = dev; if ((name === currentDevName || !invalid) && !devNames[name]) { devList.push(dev); devNames[name] = 1; } } } if (data.ios.certs.keychains[keychain].distribution) { for (const dist of data.ios.certs.keychains[keychain].distribution) { const { name, invalid } = dist; if ((name === currentDistName || !invalid) && !distNames[name]) { distList.push(dist); distNames[name] = 1; } } } } if (devList.length) { questions.push({ type: 'select', message: 'What do you want to be your default iOS developer cert for device builds?', name: 'developerName', initial: currentDevName, choices: devList .sort((a, b) => a.name.localeCompare(b.name)) .map(dev => ({ title: `${dev.name}${ dev.expired ? ` ${red('**EXPIRED**')}` : '' }${ dev.invalid ? ` ${red('**NOT VALID**')}` : '' }`, value: dev.name })) }); } if (distList.length) { questions.push({ type: 'select', message: 'What do you want to be your default iOS distribution cert for App Store and Ad Hoc builds?', name: 'distributionName', initial: currentDistName, choices: devList .sort((a, b) => a.name.localeCompare(b.name)) .map(dev => ({ title: `${dev.name}${ dev.expired ? ` ${red('**EXPIRED**')}` : '' }${ dev.invalid ? ` ${red('**NOT VALID**')}` : '' }`, value: dev.name })) }); } if (questions.length) { const values = await prompt(questions); if (devList.length) { this.config.set('ios.developerName', values.developerName); } if (distList.length) { this.config.set('ios.distributionName', values.distributionName); } this.config.save(); this.logger.log('\nConfiguration saved!'); } else { this.logger.log('No developer or distribution certs found, skipping'); } } } function isDirWritable(dir) { dir = expand(dir); if (!existsSync(dir)) { return; } const tmpFile = join(dir, `tmp${Math.round(Math.random() * 1e12)}`); try { if (existsSync(tmpFile)) { utimesSync(tmpFile, new Date(), new Date()); } else { writeFileSync(tmpFile, '', 'utf-8'); } if (existsSync(tmpFile)) { return dir; } } finally { unlinkSync(tmpFile); } } function screenTitle(title) { const width = 50; const margin = width - title.length + 4; const pad = Math.floor(margin / 2); return `\n${ gray('┤ '.padStart(pad + 1, '─')) }${ bold(title) }${ gray(' ├'.padEnd(margin - pad + 1, '─')) }\n`; }