@ionic/cli-utils
Version:
Ionic CLI Utils
532 lines (531 loc) • 25.4 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const cli_framework_1 = require("@ionic/cli-framework");
const process_1 = require("@ionic/cli-framework/utils/process");
const string_1 = require("@ionic/cli-framework/utils/string");
const utils_fs_1 = require("@ionic/utils-fs");
const utils_network_1 = require("@ionic/utils-network");
const chalk_1 = require("chalk");
const Debug = require("debug");
const events_1 = require("events");
const lodash = require("lodash");
const os = require("os");
const path = require("path");
const split2 = require("split2");
const through2 = require("through2");
const constants_1 = require("../constants");
const guards_1 = require("../guards");
const errors_1 = require("./errors");
const events_2 = require("./events");
const hooks_1 = require("./hooks");
const logger_1 = require("./utils/logger");
const debug = Debug('ionic:cli-utils:lib:serve');
exports.DEFAULT_DEV_LOGGER_PORT = 53703;
exports.DEFAULT_LIVERELOAD_PORT = 35729;
exports.DEFAULT_SERVER_PORT = 8100;
exports.DEFAULT_LAB_PORT = 8200;
exports.DEFAULT_DEVAPP_COMM_PORT = 53233;
exports.BIND_ALL_ADDRESS = '0.0.0.0';
exports.LOCAL_ADDRESSES = ['localhost', '127.0.0.1'];
exports.BROWSERS = ['safari', 'firefox', process.platform === 'win32' ? 'chrome' : (process.platform === 'darwin' ? 'google chrome' : 'google-chrome')];
// npm script name
exports.SERVE_SCRIPT = 'ionic:serve';
exports.COMMON_SERVE_COMMAND_OPTIONS = [
{
name: 'address',
summary: 'Use specific address for the dev server',
default: exports.BIND_ALL_ADDRESS,
groups: [cli_framework_1.OptionGroup.Advanced],
},
{
name: 'port',
summary: 'Use specific port for HTTP',
default: exports.DEFAULT_SERVER_PORT.toString(),
aliases: ['p'],
groups: [cli_framework_1.OptionGroup.Advanced],
},
{
name: 'livereload',
summary: 'Do not spin up dev server--just serve files',
type: Boolean,
default: true,
},
{
name: 'engine',
summary: `Target engine (e.g. ${['browser', 'cordova'].map(e => chalk_1.default.green(e)).join(', ')})`,
groups: [cli_framework_1.OptionGroup.Hidden, cli_framework_1.OptionGroup.Advanced],
},
{
name: 'platform',
summary: `Target platform on chosen engine (e.g. ${['ios', 'android'].map(e => chalk_1.default.green(e)).join(', ')})`,
groups: [cli_framework_1.OptionGroup.Hidden, cli_framework_1.OptionGroup.Advanced],
},
];
class ServeRunner {
constructor() {
this.devAppConnectionMade = false;
}
createOptionsFromCommandLine(inputs, options) {
const separatedArgs = options['--'];
if (options['local']) {
options['address'] = 'localhost';
options['devapp'] = false;
}
const engine = this.determineEngineFromCommandLine(options);
const address = options['address'] ? String(options['address']) : exports.BIND_ALL_ADDRESS;
const labPort = string_1.str2num(options['lab-port'], exports.DEFAULT_LAB_PORT);
const port = string_1.str2num(options['port'], exports.DEFAULT_SERVER_PORT);
return {
'--': separatedArgs ? separatedArgs : [],
address,
browser: options['browser'] ? String(options['browser']) : undefined,
browserOption: options['browseroption'] ? String(options['browseroption']) : undefined,
devapp: engine === 'browser' && (typeof options['devapp'] === 'undefined' || options['devapp']) ? true : false,
engine,
externalAddressRequired: options['externalAddressRequired'] ? true : false,
lab: options['lab'] ? true : false,
labHost: options['lab-host'] ? String(options['lab-host']) : 'localhost',
labPort,
livereload: typeof options['livereload'] === 'boolean' ? Boolean(options['livereload']) : true,
open: options['open'] ? true : false,
platform: options['platform'] ? String(options['platform']) : undefined,
port,
proxy: typeof options['proxy'] === 'boolean' ? Boolean(options['proxy']) : true,
ssl: false,
project: options['project'] ? String(options['project']) : undefined,
};
}
determineEngineFromCommandLine(options) {
if (options['engine']) {
return String(options['engine']);
}
if (options['cordova']) {
return 'cordova';
}
return 'browser';
}
displayDevAppMessage(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const pkg = yield this.e.project.requirePackageJson();
// If this is regular `ionic serve`, we warn the dev about unsupported
// plugins in the devapp.
if (options.devapp && guards_1.isCordovaPackageJson(pkg)) {
const plugins = yield this.getSupportedDevAppPlugins();
const packageCordovaPlugins = Object.keys(pkg.cordova.plugins);
const packageCordovaPluginsDiff = packageCordovaPlugins.filter(p => !plugins.has(p));
if (packageCordovaPluginsDiff.length > 0) {
this.e.log.warn('Detected unsupported Cordova plugins with Ionic DevApp:\n' +
`${packageCordovaPluginsDiff.map(p => `- ${chalk_1.default.bold(p)}`).join('\n')}\n\n` +
`App may not function as expected in Ionic DevApp.`);
}
}
});
}
beforeServe(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const hook = new ServeBeforeHook(this.e);
try {
yield hook.run({ name: hook.name, serve: options });
}
catch (e) {
if (e instanceof cli_framework_1.BaseError) {
throw new errors_1.FatalException(e.message);
}
throw e;
}
});
}
run(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
yield this.beforeServe(options);
const details = yield this.serveProject(options);
const devAppDetails = yield this.gatherDevAppDetails(options, details);
const labDetails = options.lab ? yield this.runLab(options, details) : undefined;
if (devAppDetails) {
const devAppName = yield this.publishDevApp(options, devAppDetails);
devAppDetails.channel = devAppName;
}
const localAddress = `${details.protocol}://localhost:${details.port}`;
const fmtExternalAddress = (address) => `${details.protocol}://${address}:${details.port}`;
const labAddress = labDetails ? `${labDetails.protocol}://${labDetails.address}:${labDetails.port}` : undefined;
this.e.log.nl();
this.e.log.info(`Development server running!` +
(labAddress ? `\nLab: ${chalk_1.default.bold(labAddress)}` : '') +
`\nLocal: ${chalk_1.default.bold(localAddress)}` +
(details.externalNetworkInterfaces.length > 0 ? `\nExternal: ${details.externalNetworkInterfaces.map(v => chalk_1.default.bold(fmtExternalAddress(v.address))).join(', ')}` : '') +
(devAppDetails && devAppDetails.channel ? `\nDevApp: ${chalk_1.default.bold(devAppDetails.channel)} on ${chalk_1.default.bold(os.hostname())}` : '') +
`\n\n${chalk_1.default.yellow('Use Ctrl+C to quit this process')}`);
this.e.log.nl();
if (options.open) {
const openAddress = labAddress ? labAddress : localAddress;
const openURL = this.modifyOpenURL(openAddress, options);
const opn = yield Promise.resolve().then(() => require('opn'));
yield opn(openURL, { app: options.browser, wait: false });
this.e.log.info(`Browser window opened to ${chalk_1.default.bold(openURL)}!`);
this.e.log.nl();
}
events_2.emit('serve:ready', details);
this.scheduleAfterServe(options, details);
return details;
});
}
afterServe(options, details) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const hook = new ServeAfterHook(this.e);
try {
yield hook.run({ name: hook.name, serve: lodash.assign({}, options, details) });
}
catch (e) {
if (e instanceof cli_framework_1.BaseError) {
throw new errors_1.FatalException(e.message);
}
throw e;
}
});
}
scheduleAfterServe(options, details) {
process_1.onBeforeExit(() => tslib_1.__awaiter(this, void 0, void 0, function* () { return this.afterServe(options, details); }));
}
gatherDevAppDetails(options, details) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (options.devapp) {
const { computeBroadcastAddress } = yield Promise.resolve().then(() => require('./devapp'));
// TODO: There is no accurate/reliable/realistic way to identify a WiFi
// network uniquely in NodeJS. But this is where we could detect new
// networks and prompt the dev if they want to "trust" it (allow binding to
// 0.0.0.0 and broadcasting).
const interfaces = utils_network_1.getExternalIPv4Interfaces()
.map(i => (Object.assign({}, i, { broadcast: computeBroadcastAddress(i.address, i.netmask) })));
const { port } = details;
// the comm server always binds to 0.0.0.0 to target every possible interface
const commPort = yield utils_network_1.findClosestOpenPort(exports.DEFAULT_DEVAPP_COMM_PORT);
return { port, commPort, interfaces };
}
});
}
publishDevApp(options, details) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (options.devapp) {
const { createCommServer, createPublisher } = yield Promise.resolve().then(() => require('./devapp'));
const publisher = yield createPublisher(this.e.project.config.get('name'), details.port, details.commPort);
const comm = yield createCommServer(publisher.id, details.commPort);
publisher.interfaces = details.interfaces;
comm.on('error', (err) => {
debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`);
});
comm.on('connect', (data) => tslib_1.__awaiter(this, void 0, void 0, function* () {
if (!this.devAppConnectionMade) {
this.devAppConnectionMade = true;
yield this.displayDevAppMessage(options);
}
this.e.log.info(`DevApp connection established from ${chalk_1.default.bold(data.email)}`);
}));
publisher.on('error', (err) => {
debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`);
});
try {
yield comm.start();
}
catch (e) {
this.e.log.error(`Could not create DevApp comm server: ${String(e.stack ? e.stack : e)}`);
}
try {
yield publisher.start();
}
catch (e) {
this.e.log.error(`Could not publish DevApp service: ${String(e.stack ? e.stack : e)}`);
}
return publisher.name;
}
});
}
getSupportedDevAppPlugins() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const p = path.resolve(constants_1.ASSETS_DIRECTORY, 'devapp', 'plugins.json');
const plugins = yield utils_fs_1.readJsonFile(p);
if (!Array.isArray(plugins)) {
throw new Error(`Cannot read ${p} file of supported plugins.`);
}
// This one is common, and hopefully obvious enough that the devapp doesn't
// use any splash screen but its own, so we mark it as "supported".
plugins.push('cordova-plugin-splashscreen');
return new Set(plugins);
});
}
runLab(options, serveDetails) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const labDetails = {
protocol: options.ssl ? 'https' : 'http',
address: options.labHost,
port: yield utils_network_1.findClosestOpenPort(options.labPort),
};
if (options.ssl) {
const sslConfig = this.e.project.config.get('ssl');
if (sslConfig && sslConfig.key && sslConfig.cert) {
labDetails.ssl = { key: sslConfig.key, cert: sslConfig.cert };
}
else {
throw new errors_1.FatalException(`Both ${chalk_1.default.green('ssl.key')} and ${chalk_1.default.green('ssl.cert')} config entries must be set.\n` +
`See ${chalk_1.default.green('ionic serve --help')} for details on using your own SSL key and certificate for Ionic Lab and the dev server.`);
}
}
const lab = new IonicLabServeCLI(this.e);
yield lab.serve(Object.assign({ serveDetails }, labDetails));
return labDetails;
});
}
selectExternalIP(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
let availableInterfaces = [];
let chosenIP = options.address;
if (options.address === exports.BIND_ALL_ADDRESS) {
availableInterfaces = utils_network_1.getExternalIPv4Interfaces();
if (availableInterfaces.length === 0) {
if (options.externalAddressRequired) {
throw new errors_1.FatalException(`No external network interfaces detected. In order to use the dev server externally you will need one.\n` +
`Are you connected to a local network?\n`);
}
}
else if (availableInterfaces.length === 1) {
chosenIP = availableInterfaces[0].address;
}
else if (availableInterfaces.length > 1) {
if (options.externalAddressRequired) {
if (this.e.flags.interactive) {
this.e.log.warn('Multiple network interfaces detected!\n' +
'You will be prompted to select an external-facing IP for the dev server that your device or emulator has access to.\n\n' +
`You may also use the ${chalk_1.default.green('--address')} option to skip this prompt.`);
const promptedIp = yield this.e.prompt({
type: 'list',
name: 'promptedIp',
message: 'Please select which IP to use:',
choices: availableInterfaces.map(i => ({
name: `${i.address} ${chalk_1.default.dim(`(${i.device})`)}`,
value: i.address,
})),
});
chosenIP = promptedIp;
}
else {
throw new errors_1.FatalException(`Multiple network interfaces detected!\n` +
`You must select an external-facing IP for the dev server that your device or emulator has access to with the ${chalk_1.default.green('--address')} option.`);
}
}
}
}
return [chosenIP, availableInterfaces];
});
}
}
exports.ServeRunner = ServeRunner;
class ServeBeforeHook extends hooks_1.Hook {
constructor() {
super(...arguments);
this.name = 'serve:before';
}
}
class ServeAfterHook extends hooks_1.Hook {
constructor() {
super(...arguments);
this.name = 'serve:after';
}
}
class ServeCLI extends events_1.EventEmitter {
constructor(e) {
super();
this.e = e;
this.resolvedProgram = this.program;
}
/**
* Called whenever a line of stdout is received.
*
* If `false` is returned, the line is not emitted to the log.
*
* By default, the CLI is considered ready whenever stdout is emitted. This
* method should be overridden to more accurately portray readiness.
*
* @param line A line of stdout.
*/
stdoutFilter(line) {
this.emit('ready');
return true;
}
/**
* Called whenever a line of stderr is received.
*
* If `false` is returned, the line is not emitted to the log.
*/
stderrFilter(line) {
return true;
}
serve(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
this.resolvedProgram = yield this.resolveProgram();
yield this.spawnWrapper(options);
const interval = setInterval(() => {
this.e.log.info(`Waiting for connectivity with ${chalk_1.default.green(this.resolvedProgram)}...`);
}, 5000);
debug('awaiting TCP connection to %s:%d', options.address, options.port);
yield utils_network_1.isHostConnectable(options.address, options.port);
clearInterval(interval);
});
}
spawnWrapper(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
try {
return yield this.spawn(options);
}
catch (e) {
if (!(e instanceof errors_1.ServeCLIProgramNotFoundException)) {
throw e;
}
this.e.log.nl();
this.e.log.info(`Looks like ${chalk_1.default.green(this.pkg)} isn't installed in this project.\n` +
`This package is required for this command to work properly.`);
const installed = yield this.promptToInstall();
if (!installed) {
this.e.log.nl();
throw new errors_1.FatalException(`${chalk_1.default.green(this.pkg)} is required for this command to work properly.`);
}
return this.spawn(options);
}
});
}
spawn(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const args = yield this.buildArgs(options);
const p = this.e.shell.spawn(this.resolvedProgram, args, { stdio: 'pipe', cwd: this.e.project.directory });
return new Promise((resolve, reject) => {
const errorHandler = (err) => {
debug('received error for %s: %o', this.resolvedProgram, err);
if (this.resolvedProgram === this.program && err.code === 'ENOENT') {
p.removeListener('close', closeHandler); // do not exit Ionic CLI, we can gracefully ask to install this CLI
reject(new errors_1.ServeCLIProgramNotFoundException(`${chalk_1.default.bold(this.resolvedProgram)} command not found.`));
}
else {
reject(err);
}
};
const closeHandler = (code, signal) => {
debug('received unexpected close for %s (code: %d, signal: %s)', this.resolvedProgram, code, signal);
this.e.log.nl();
this.e.log.error(`A utility CLI has unexpectedly closed (exit code ${code}).\n` +
'The Ionic CLI will exit. Please check any output above for error details.');
process_1.processExit(1); // tslint:disable-line:no-floating-promises
};
p.on('error', errorHandler);
p.on('close', closeHandler);
process_1.onBeforeExit(() => tslib_1.__awaiter(this, void 0, void 0, function* () {
p.removeListener('close', closeHandler);
if (p.pid) {
yield process_1.killProcessTree(p.pid);
}
}));
const ws = this.createLoggerStream();
p.stdout.pipe(split2()).pipe(this.createStreamFilter(line => this.stdoutFilter(line))).pipe(ws);
p.stderr.pipe(split2()).pipe(this.createStreamFilter(line => this.stderrFilter(line))).pipe(ws);
this.once('ready', () => {
resolve();
});
});
});
}
createLoggerStream() {
const log = this.e.log.clone();
log.handlers = logger_1.createDefaultLoggerHandlers(cli_framework_1.createPrefixedFormatter(chalk_1.default.dim(`[${this.resolvedProgram === this.program ? this.prefix : this.resolvedProgram}]`)));
return log.createWriteStream(cli_framework_1.LOGGER_LEVELS.INFO);
}
resolveProgram() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (typeof this.script !== 'undefined') {
debug(`Looking for ${chalk_1.default.cyan(this.script)} npm script.`);
const pkg = yield this.e.project.requirePackageJson();
if (pkg.scripts && pkg.scripts[this.script]) {
debug(`Using ${chalk_1.default.cyan(this.script)} npm script.`);
return this.e.config.get('npmClient');
}
}
return this.program;
});
}
createStreamFilter(filter) {
return through2(function (chunk, enc, callback) {
const str = chunk.toString();
if (filter(str)) {
this.push(chunk);
}
callback();
});
}
promptToInstall() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const { pkgManagerArgs } = yield Promise.resolve().then(() => require('./utils/npm'));
const [manager, ...managerArgs] = yield pkgManagerArgs(this.e.config.get('npmClient'), { command: 'install', pkg: this.pkg, saveDev: true, saveExact: true });
this.e.log.nl();
const confirm = yield this.e.prompt({
name: 'confirm',
message: `Install ${chalk_1.default.green(this.pkg)}?`,
type: 'confirm',
});
if (!confirm) {
this.e.log.warn(`Not installing--here's how to install manually: ${chalk_1.default.green(`${manager} ${managerArgs.join(' ')}`)}`);
return false;
}
yield this.e.shell.run(manager, managerArgs, { cwd: this.e.project.directory });
return true;
});
}
}
exports.ServeCLI = ServeCLI;
class IonicLabServeCLI extends ServeCLI {
constructor() {
super(...arguments);
this.name = 'Ionic Lab';
this.pkg = '@ionic/lab';
this.program = 'ionic-lab';
this.prefix = 'lab';
this.script = undefined;
}
stdoutFilter(line) {
if (line.includes('running')) {
this.emit('ready');
}
return false; // no stdout
}
buildArgs(options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const { serveDetails } = options, labDetails = tslib_1.__rest(options, ["serveDetails"]);
const pkg = yield this.e.project.requirePackageJson();
const url = `${serveDetails.protocol}://localhost:${serveDetails.port}`;
const appName = this.e.project.config.get('name');
const labArgs = [url, '--host', labDetails.address, '--port', String(labDetails.port)];
const nameArgs = appName ? ['--app-name', appName] : [];
const versionArgs = pkg.version ? ['--app-version', pkg.version] : [];
if (labDetails.ssl) {
labArgs.push('--ssl', '--ssl-key', labDetails.ssl.key, '--ssl-cert', labDetails.ssl.cert);
}
return [...labArgs, ...nameArgs, ...versionArgs];
});
}
}
function serve(deps, inputs, options) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
try {
const runner = yield deps.project.requireServeRunner();
if (deps.project.name) {
options['project'] = deps.project.name;
}
const opts = runner.createOptionsFromCommandLine(inputs, options);
const details = yield runner.run(opts);
return details;
}
catch (e) {
if (e instanceof errors_1.RunnerException) {
throw new errors_1.FatalException(e.message);
}
throw e;
}
});
}
exports.serve = serve;
;