UNPKG

titanium

Version:

Command line interface for building Titanium SDK apps

925 lines (821 loc) 24 kB
import chalk from 'chalk'; import { TiError } from '../util/tierror.js'; import { expand } from '../util/expand.js'; import * as version from '../util/version.js'; import { request } from '../util/request.js'; import { BusyIndicator } from '../util/busyindicator.js'; import fs from 'fs-extra'; import { mkdir } from 'node:fs/promises'; import { suggest } from '../util/suggest.js'; import { columns } from '../util/columns.js'; import { basename, dirname, join } from 'node:path'; import os from 'node:os'; import { ProgressBar } from '../util/progress.js'; import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { extractZip } from '../util/extract-zip.js'; import { prompt } from '../util/prompt.js'; import { getReleases } from '../util/tisdk.js'; import prettyBytes from 'pretty-bytes'; import wrapAnsi from 'wrap-ansi'; const { cyan, gray, green, magenta, red, yellow } = chalk; const SdkSubcommands = {}; /** * Returns the configuration for the SDK command. * @param {Object} logger - The logger instance * @param {Object} config - The CLI config object * @param {CLI} cli - The CLI instance * @returns {Object} SDK command configuration */ export function config(logger, config, cli) { const subcommands = {}; for (const [name, subcmd] of Object.entries(SdkSubcommands)) { subcommands[name] = subcmd.conf(logger, config, cli); if (subcmd.alias) { subcommands[name].alias = subcmd.alias; } } return { title: 'SDK', defaultSubcommand: 'list', skipBanner: true, subcommands }; } /** * Displays all installed Titanium SDKs or installs a new SDK. * @param {Object} logger - The logger instance * @param {Object} config - The CLI config object * @param {CLI} cli - The CLI instance */ export async function run(logger, config, cli) { let action = cli.command.name(); if (action === 'list' && cli.command.args.length) { action = cli.command.args[0]; if (cli.argv.$_.includes('list')) { throw new TiError(`Invalid argument "${action}"`, { showHelp: true }); } cli.command = cli.command.parent; } for (const [name, subcommand] of Object.entries(SdkSubcommands)) { if (action === name || action === subcommand.alias) { await SdkSubcommands[name].fn(logger, config, cli); return; } } throw new TiError(`Invalid subcommand "${action}"`, { showHelp: true }); } SdkSubcommands.select = { conf() { return { hidden: true }; }, fn(logger, config, _cli) { logger.skipBanner(false); logger.banner(); logger.log( yellow( wrapAnsi( `Good news! The "select" subcommand is no longer required. If the current working directory is a Titanium app, the Titanium CLI will automatically use the <sdk-version> from the "tiapp.xml", otherwise use the default to the latest installed SDK.`, config.get('cli.width', 80), { hard: true, trim: false } ) ) ); } }; /** * Displays a list of all installed Titanium SDKs. * @memberof SdkSubcommands * @param {Object} logger - The logger instance * @param {Object} config - The CLI config object * @param {CLI} cli - The CLI instance */ SdkSubcommands.list = { alias: 'ls', conf(_logger, _config, _cli) { return { desc: 'print a list of installed SDK versions', flags: { branches: { abbr: 'b', desc: 'retrieve and print all branches' }, json: { desc: 'display installed modules as JSON' }, releases: { abbr: 'r', desc: 'retrieve and print all releases' }, unstable: { abbr: 'u', desc: 'retrieve and print all unstable release candidate (rc) and beta releases' } }, options: { branch: { desc: 'branch to fetch CI builds' }, output: { abbr: 'o', default: 'report', hidden: true, values: ['report', 'json'] } } }; }, async fn(logger, config, cli) { const os = cli.env.os.name; const [releases, branches, branchBuilds] = (await Promise.allSettled([ (cli.argv.releases || cli.argv.unstable) && getReleases(cli.argv.unstable), cli.argv.branches && getBranches(), cli.argv.branch && getBranchBuilds(cli.argv.branch, os) ])).map(r => { return r.status === 'fulfilled' ? r.value : new TiError(r.reason); }); const { sdks } = cli.env; const vers = version.sort(Object.keys(sdks)).reverse(); const defaultInstallLocation = cli.env.installPath; const locations = Array.from( new Set([ cli.env.os.sdkPaths, defaultInstallLocation, config.get('paths.sdks') ].flat().filter(Boolean).map(p => p && expand(p))) ).sort(); if (cli.argv.json || cli.argv.output === 'json') { for (const ver of vers) { delete sdks[ver].commands; delete sdks[ver].packageJson; delete sdks[ver].platforms; } const obj = { branch: branchBuilds?.length ? { [cli.argv.branch]: branchBuilds } : {}, branches: { defaultBranch: 'main', branches: branches || [] }, defaultInstallLocation, installLocations: locations, installed: vers.reduce((obj, v) => { obj[v] = sdks[v].path; return obj; }, {}), releases: releases && releases.reduce((obj, { name, assets }) => { obj[name] = assets.find(a => a.os === os).url; return obj; }, {}) || {}, sdks }; logger.log(JSON.stringify(obj, null, '\t')); return; } logger.skipBanner(false); logger.banner(); logger.log('SDK Install Locations:'); for (const p of locations) { logger.log(` ${cyan(p)}${p === defaultInstallLocation ? gray(' [default]') : ''}`); } logger.log(); if (vers.length) { const maxVersionLen = vers.reduce((len, b) => Math.max(len, sdks[b].version.length), 0); const maxNameLen = vers.reduce((len, b) => { return Math.max(len, sdks[b].manifest && sdks[b].manifest.name ? sdks[b].manifest.name.length : 0); }, 0); logger.log('Installed SDKs:'); for (const v of vers) { const ver = sdks[v].version; let name = sdks[v].manifest && (sdks[v].manifest.name || sdks[v].manifest.version); if (!name) { try { name = version.format(v, 3, 3); } catch { name = ''; } } logger.log(` ${ cyan(name.padEnd(maxNameLen)) } ${ magenta(ver.padEnd(maxVersionLen) )} ${sdks[v].path}`); } } else { logger.log(red('No Titanium SDKs found\n')); logger.log(`You can download the latest Titanium SDK by running: ${cyan('titanium sdk install')}\n`); } if (releases) { logger.log(); logger.log('Releases:'); if (releases instanceof Error) { logger.log(` ${red(releases.message)}`); } else if (!releases.length) { logger.log(' No releases found'); } else { let i = 0; for (const r of releases) { logger.log(` ${cyan(r.name.padEnd(12))}\ ${Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).format(new Date(r.date)).padStart(8)}\ ${prettyBytes(r.assets.find(a => a.os === os).size).toUpperCase().padStart(11)}\ ${Object.prototype.hasOwnProperty.call(sdks, r) ? ' [installed]' : ''}\ ${r.type !== 'ga' ? gray(' [unstable]') : i++ === 0 ? green(' [latest stable]') : ''}`); } } } if (branches) { logger.log(); logger.log('Branches:'); if (branches instanceof Error) { logger.log(` ${branches.message.red}`); } else { for (const b of branches) { logger.log(` ${cyan(b)}${b === 'main' ? gray(' [default]') : ''}`); } } } if (cli.argv.branch) { logger.log(); if (branchBuilds instanceof Error) { logger.error(`Invalid branch "${cli.argv.branch}"\n`); logger.log(`Run '${cyan('titanium sdk --branches')}' for a list of available branches.\n`); } else { logger.log(`'${cli.argv.branch}' Branch Builds:`); if (branchBuilds?.length) { for (const b of branchBuilds) { const dt = Intl.DateTimeFormat('en-US', { dateStyle: 'short' }).format(new Date(b.date)); logger.log(` ${cyan(b.name)}\ ${dt.padStart(11)}\ ${prettyBytes(b.assets.find(a => a.os === os).size).toUpperCase().padStart(11)} ${gray('[unstable]')}`); } logger.log(gray('** NOTE: these builds not recommended for production use **')); } else { logger.log(' No builds found'); } } } } }; /** * Installs the specified Titanium SDK. * @memberof SdkSubcommands * @param {Object} logger - The logger instance * @param {Object} config - The CLI config object * @param {CLI} cli - The CLI instance * @param {Function} finished - Callback when the command finishes */ SdkSubcommands.install = { alias: 'i', conf(_logger, _config, _cli) { return { // command examples: // ti sdk install // ti sdk install --default // ti sdk install latest // ti sdk install latest-rc // ti sdk install latest-beta // ti sdk install something.zip // ti sdk install something.zip --default // ti sdk install https://github.com/tidev/titanium-sdk/releases/download/12_1_2_GA/mobilesdk-12.1.2.GA-osx.zip // ti sdk install 3.1.0.GA // ti sdk install 3.1.0.GA --default desc: 'download the latest Titanium SDK or a specific version', args: [ { desc: 'the version to install, "latest", URL, zip file, or <branch>:<build_name>', name: 'version' } ], flags: { default: { abbr: 'd', hidden: true }, force: { abbr: 'f', desc: 'force re-install' }, 'keep-files': { abbr: 'k', desc: 'keep downloaded files after install' } }, options: { branch: { abbr: 'b', desc: 'the branch to install from or "latest" (stable)', hint: 'branch name' } } }; }, async fn(logger, config, cli) { const titaniumDir = expand(cli.env.installPath); const showProgress = !cli.argv.quiet && !!cli.argv['progress-bars']; const osName = cli.env.os.name; const subject = cli.argv._.shift() || 'latest'; const { trace } = cli.debugLogger; logger.skipBanner(false); logger.banner(); // step 0: make sure the install location exists await mkdir(titaniumDir, { recursive: true }); // step 1: determine what the URI is const { downloadedFile, file } = await getInstallFile({ branch: cli.argv.branch, config, logger, osName, showProgress, subject }); // step 2: extract the SDK zip file let { forceModules, name, renameTo, tempDir } = await extractSDK({ debugLogger: cli.debugLogger, file, force: cli.argv.force, logger, noPrompt: !cli.argv.prompt, osName, showProgress, subject, titaniumDir }); // step 3: validate the manifest.json let src = name && join(tempDir, 'mobilesdk', osName, name); if (name) { try { const manifestFile = join(src, 'manifest.json'); const manifest = await fs.readJson(manifestFile); if (renameTo) { manifest.name = renameTo; await fs.writeJson(manifestFile, manifest); } } catch { name = null; } } if (!name) { throw new TiError('Zip file does not contain a valid Titanium SDK'); } // step 4: move the SDK files to the dest const dest = join(titaniumDir, 'mobilesdk', osName, renameTo || name); if (showProgress) { logger.log(); } logger.log(`\nInstalling SDK files to ${cyan(dest)}`); await fs.mkdirs(dest); await fs.move(src, dest, { overwrite: true }); // step 5: install the modules const modules = {}; src = join(tempDir, 'modules'); if (fs.statSync(src).isDirectory()) { const modulesDest = join(titaniumDir, 'modules'); for (const platform of fs.readdirSync(src)) { const srcPlatformDir = join(src, platform); if (!fs.statSync(srcPlatformDir).isDirectory()) { continue; } for (const moduleName of fs.readdirSync(srcPlatformDir)) { const srcModuleDir = join(srcPlatformDir, moduleName); if (!fs.statSync(srcModuleDir).isDirectory()) { continue; } for (const ver of fs.readdirSync(srcModuleDir)) { const srcVersionDir = join(srcModuleDir, ver); if (!fs.statSync(srcVersionDir).isDirectory()) { continue; } const destDir = join(modulesDest, platform, moduleName, ver); if (!forceModules && fs.existsSync(destDir)) { trace(`Module ${cyan(`${moduleName}@${ver}`)} already installed`); continue; } modules[`${moduleName}@${ver}@${platform}`] = { src: srcVersionDir, dest: destDir }; } } } } if (Object.keys(modules).length) { trace(`Installing ${cyan(Object.keys(modules).length)} modules:`); for (const [name, { src, dest }] of Object.entries(modules)) { trace(` ${cyan(name)}`); await fs.move(src, dest, { overwrite: true }); } } else { trace('SDK has new modules to install'); } // step 6: cleanup if (downloadedFile && !cli.argv['keep-files']) { await fs.remove(downloadedFile); } await fs.remove(tempDir); logger.log(`\nTitanium SDK ${cyan(name)} successfully installed!`); } }; async function getInstallFile({ branch, config, logger, osName, showProgress, subject }) { const uriMatch = subject?.match(/^(https?:\/\/.+)|(?:file:\/\/(.+))$/); let file; if (uriMatch && uriMatch[2]) { file = uriMatch[2]; } else if (subject && fs.existsSync(subject)) { file = subject; } if (file) { file = expand(file); if (!fs.existsSync(file)) { throw new TiError('Specified file does not exist'); } if (!file.endsWith('.zip')) { throw new TiError('Specified file is not a zip file'); } return { file }; } // we are downloading an SDK let url; let uri = subject.toLowerCase(); if (uriMatch && uriMatch[1]) { // we have a http URL url = uriMatch[1]; } else if (branch) { // we have a CI build const branches = await getBranches(); if (!branches.includes(branch)) { throw new TiError(`Branch "${branch}" does not exist`, { after: `${ suggest(branch, branches, 2) }Available Branches:\n${ columns(branches, ' ', config.get('cli.width', 80)) }` }); } const builds = await getBranchBuilds(branch, osName); const build = uri === 'latest' ? builds[0] : builds.find(b => b.name.toLowerCase() === uri); if (!build) { throw new TiError(`CI build ${subject} does not exist`); } const asset = build.assets.find(a => a.os === osName); if (!asset) { throw new TiError(`CI build ${subject} does not support ${osName}`); } url = asset.url; } else { // try to find the release by name let release = null; const releases = await getReleases(true); if (uri === 'latest') { release = releases.find(r => r.type === 'ga'); } else if (uri === 'latest-rc') { release = releases.find(r => r.type === 'rc'); } else if (uri === 'latest-beta') { release = releases.find(r => r.type === 'beta'); } else { release = releases.find(r => r.name.toLowerCase() === uri); if (!release) { const name = `${uri}.ga`; release = releases.find(r => r.name.toLowerCase() === name); } } if (release) { const asset = release.assets.find(a => a.os === osName); if (!asset) { throw new TiError(`SDK release ${subject} does not support ${osName}`); } url = asset.url; } } if (!url) { throw new TiError(`Unable to find any Titanium SDK releases or CI builds that match "${subject}"`); } // step 1.5: download the file let downloadedFile = expand('~', '.titanium', 'downloads', `titanium-sdk-${Math.floor(Math.random(1e6))}.zip`); const downloadDir = dirname(downloadedFile); await mkdir(downloadDir, { recursive: true }); logger.log(`Downloading ${cyan(url)}`); let bar; let busy; let filename; let total; const out = fs.createWriteStream(downloadedFile); let response = await request(url); if ([301, 302].includes(response.statusCode)) { response = await request(response.headers.location); } const cd = response.headers['content-disposition']; let m = cd && cd.match(/filename\*?=(?:[^']*'[^']*'([^;]+)|["']([^"']+)["']|([^;\s]+))/i); filename = m && (m[1] || m[2] || m[3]); // try to determine the file extension by the filename in the URL if (!filename && (m = url.match(/.*\/(.+\.zip)$/))) { filename = m[1]; } total = parseInt(response.headers['content-length']); if (showProgress) { if (total) { bar = new ProgressBar(' :paddedPercent [:bar]', { complete: cyan('='), incomplete: gray('.'), width: 40, total }); } else { busy = new BusyIndicator(); busy.start(); } } const progressStream = new Transform({ transform(chunk, _encoding, callback) { bar?.tick(chunk.length); this.push(chunk); callback(); } }); await pipeline(response.body, progressStream, out); out.close(); busy?.stop(); bar?.tick(total); if (bar) { logger.log('\n'); } else if (busy) { logger.log(); } if (filename) { file = join(downloadDir, filename); await fs.move(downloadedFile, file, { overwrite: true }); downloadedFile = file; } else { file = downloadedFile; } return { downloadedFile, file }; } async function extractSDK({ debugLogger, file, force, logger, noPrompt, osName, showProgress, subject, titaniumDir }) { const sdkDestRegExp = new RegExp(`^mobilesdk[/\\\\]${osName}[/\\\\]([^/\\\\]+)`); const tempDir = join(os.tmpdir(), `titanium-cli-${Math.floor(Math.random() * 1e6)}`); let artifact; let bar; let name; let renameTo; let forceModules = force; const onEntry = async (filename, _idx, total) => { if (total > 1) { const m = !name && filename.match(sdkDestRegExp); if (m) { name = m[1]; const result = await checkSDKFile({ force, logger, filename, name, noPrompt, osName, sdkDir: join(titaniumDir, 'mobilesdk', osName, name), subject }); forceModules = result?.forceModules ?? force; renameTo = result?.renameTo; logger.log('Extracting SDK...'); if (showProgress && !bar) { bar = new ProgressBar(' :paddedPercent [:bar]', { complete: cyan('='), incomplete: gray('.'), width: 40, total }); } } bar?.tick(); } else { artifact = filename; } }; debugLogger.trace(`Extracting ${file} -> ${tempDir}`); await extractZip({ dest: tempDir, file, onEntry }); if (!artifact) { return { forceModules, name, renameTo, tempDir }; } debugLogger.trace(`Detected artifact: ${artifact}`); const tempDir2 = join(os.tmpdir(), `titanium-cli-${Math.floor(Math.random() * 1e6)}`); file = join(tempDir, artifact); debugLogger.trace(`Extracting ${file} -> ${tempDir2}`); await extractZip({ dest: tempDir2, file, onEntry }); await fs.remove(tempDir); return { forceModules, name, renameTo, tempDir: tempDir2 }; } async function checkSDKFile({ force, logger, _filename, name, noPrompt, _osName, sdkDir, subject }) { try { if (force || !fs.statSync(sdkDir).isDirectory()) { return; } } catch { return; } // already installed const releases = await getReleases(false); const latest = releases[0]; const tip = `Run '${cyan(`titanium sdk install ${latest.name} --force`)}' to re-install`; if (noPrompt) { if (subject === 'latest' && name === latest.name) { throw new TiError(`Titanium SDK ${name} is already installed`, { after: `You're up-to-date. Version ${cyan(latest.name)} is currently the newest version available.\n${tip}` }); } throw new TiError(`Titanium SDK ${name} is already installed`, { after: tip }); } let renameTo; // eslint-disable no-constant-condition for (let i = 2; true; i++) { try { renameTo = `${name}-${i}`; if (!fs.statSync(`${sdkDir}-${i}`).isDirectory()) { break; } } catch { break; } } const action = await prompt({ type: 'select', name: 'action', message: `Titanium SDK ${name} is already installed`, instructions: false, hint: 'Use arrows to select and return to submit', choices: [ { title: 'Overwrite', value: 'overwrite' }, { title: `Rename as ${basename(renameTo)}`, value: 'rename' }, { title: 'Abort', value: 'abort' } ] }); if (!action || action === 'abort') { process.exit(0); } logger.log(); const result = { action }; if (action === 'rename') { result.renameTo = renameTo; } else if (action === 'overwrite') { result.forceModules = true; } return result; } /** * Uninstalls the specified Titanium SDK. * @memberof SdkSubcommands * @param {Object} logger - The logger instance * @param {Object} config - The CLI config object * @param {CLI} cli - The CLI instance */ SdkSubcommands.uninstall = { alias: 'rm', conf(_logger, _config, _cli) { return { desc: 'uninstall a specific Titanium SDK versions', args: [ { desc: 'one or more SDK names to uninstall', name: 'versions', variadic: true } ], flags: { force: { abbr: 'f', desc: 'force uninstall without confirmation' } } }; }, async fn(logger, _config, cli) { const vers = version.sort(Object.keys(cli.env.sdks)).reverse(); const { force } = cli.argv; let versions = cli.argv._[0] || []; logger.skipBanner(false); logger.banner(); if (!cli.argv.prompt) { if (!versions.length) { throw new TiError('Missing <version...> argument'); } if (!force) { throw new TiError('To uninstall a Titanium SDK in non-interactive mode, you must use --force'); } } if (!versions.length) { versions = await prompt({ type: 'multiselect', name: 'versions', message: 'Which SDKs to uninstall?', instructions: false, hint: 'Space to select. Return to submit', choices: vers.map(v => ({ title: v, value: v })) }); if (!versions) { return; } logger.log(); } const found = versions.filter(v => vers.includes(v)); const maxlen = versions.reduce((a, b) => Math.max(a, b.length), 0); if (!found.length) { for (const v of versions) { logger.log(` • ${cyan(v.padEnd(maxlen))} ${cli.env.sdks[v]?.path || yellow('not found')}`); } return; } if (!force) { // prompt for confirmation logger.log(`${yellow('WARNING!')} This will permanently remove the following Titanium SDKs:\n`); for (const v of versions) { logger.log(` • ${cyan(v.padEnd(maxlen))} ${cli.env.sdks[v]?.path || yellow('not found')}`); } logger.log(); const confirm = await prompt({ type: 'toggle', name: 'confirm', message: 'Proceed?', initial: false, active: 'yes', inactive: 'no' }); if (!confirm) { return; } logger.log(); } let busy; if (!cli.argv.quiet && !!cli.argv['progress-bars']) { busy = new BusyIndicator(); busy.start(); } let results; try { results = await Promise.allSettled(found.map(async (ver) => { const dir = cli.env.sdks[ver].path; try { await fs.remove(dir); return dir; } catch (e) { throw new TiError(`Failed to remove ${dir}`, { after: e.message }); } })); } finally { busy?.stop(); } for (const r of results) { if (r.status === 'fulfilled') { logger.log(` ${green('√')} ${cyan(r.value)} removed`); } else { logger.log(` ${red(${r.reason}`)}`); if (r.reason.after) { logger.log(` ${red(r.reason.after)}`); } } } } }; /** * Retrieves the list of branches. * @returns {Promise<Branches>} */ async function getBranches() { const res = await request('https://downloads.titaniumsdk.com/registry/branches.json', { responseType: 'json' }); return Object .entries(await res.body.json()) .filter(([, count]) => count) .map(([name]) => name); } /** * Retrieves the list of builds for a given branch. * @param {String} branch - The name of the branch * @param {String} os - The name of the current OS (osx, linux, win32) * @returns {Promise<BranchBuild[]>} */ async function getBranchBuilds(branch, os) { const res = await request(`https://downloads.titaniumsdk.com/registry/${branch}.json`, { responseType: 'json' }); const now = Date.now(); const results = await res.body.json(); return results.filter(b => { return (!b.expires || Date.parse(b.expires) > now) && b.assets.some(a => a.os === os); }); }