diff --git a/lib/command.js b/lib/command.js index 72a258bc1..4b7c50736 100644 --- a/lib/command.js +++ b/lib/command.js @@ -115,8 +115,6 @@ class Command { let context = { ui: ui, service: service, - renderer: verbose ? 'verbose' : 'update', - verbose: verbose, cliVersion: pkg.version }; @@ -160,8 +158,6 @@ class Command { // Will refactor this out with the service refactors this.ui = context.ui; this.service = context.service; - this.renderer = context.renderer; - this.verbose = context.verbose; this.cliVersion = context.cliVersion; this.development = context.development; this.environment = context.environment; diff --git a/lib/commands/doctor/checks/setup.js b/lib/commands/doctor/checks/setup.js index c2c0792db..6314377b3 100644 --- a/lib/commands/doctor/checks/setup.js +++ b/lib/commands/doctor/checks/setup.js @@ -2,62 +2,72 @@ const os = require('os'); const chalk = require('chalk'); const execa = require('execa'); -const Listr = require('listr'); const errors = require('../../../errors'); module.exports = [{ title: 'System Stack', task: (context) => { + let promise; + if (os.platform() !== 'linux') { context.linux = false; - return Promise.reject(new errors.SystemError(chalk.yellow('Platform is not Linux'))); - } + promise = Promise.reject(new errors.SystemError(chalk.yellow('Platform is not Linux'))); + } else { + context.linux = true; - context.linux = true; + promise = execa.shell('lsb_release -a').then((result) => { + if (!result.stdout || !result.stdout.match(/Ubuntu 16/)) { + context.ubuntu = false; + return Promise.reject(new errors.SystemError(chalk.yellow('Linux version is not Ubuntu 16'))); + } - return execa.shell('lsb_release -a').then((result) => { - if (!result.stdout || !result.stdout.match(/Ubuntu 16/)) { - context.ubuntu = false; - return Promise.reject(new errors.SystemError(chalk.yellow('Linux version is not Ubuntu 16'))); - } + context.ubuntu = true; + + return context.ui.listr([{ + title: 'Systemd', + task: (ctx) => execa.shell('dpkg -l | grep systemd').then(() => { + ctx.systemd = true; + }) + }, { + title: 'Nginx', + task: (ctx) => execa.shell('dpkg -l | grep nginx').then(() => { + ctx.nginx = true; + }) + }], context, {concurrent: true, renderer: context.ui.verbose ? 'verbose' : 'silent', exitOnError: false}) + }); + } - context.ubuntu = true; - - return new Listr([{ - title: 'Systemd', - task: (ctx) => execa.shell('dpkg -l | grep systemd').then(() => { - ctx.systemd = true; - }) - }, { - title: 'Nginx', - task: (ctx) => execa.shell('dpkg -l | grep nginx').then(() => { - ctx.nginx = true; - }) - }], {concurrent: true, renderer: context.verbose ? context.renderer : 'silent', exitOnError: false}) - .run(context).catch(() => { - let missing = []; - - if (!context.systemd) { - missing.push('systemd'); - } - - if (!context.nginx) { - missing.push('nginx'); - } - - if (missing.length) { - return Promise.reject(new errors.SystemError(chalk.yellow(`Missing package(s): ${missing.join(', ')}`))); - } - }); - }).catch((error) => { + return promise.then(() => { return {continue: true}; }).catch((error) => { // If the error caught is not a SystemError, something went wrong with execa, // so throw a ProcessError instead if (!(error instanceof errors.SystemError)) { - error = new errors.ProcessError(error); + return Promise.reject(new errors.ProcessError(error)); + } + + // This is a check so that when running as part of `ghost setup`, we can do things more cleanly + // As part of `ghost doctor`, none of the below should run + if (!context.setup) { + return Promise.reject(error); } - return Promise.reject(error); - }); + context.ui.log( + `System Stack checks failed with message: '${error.message}'.${os.EOL}` + + 'Some features of Ghost-CLI may not work without additional configuration.', + 'yellow' + ); + + return context.ui.prompt({ + type: 'confirm', + name: 'continue', + message: chalk.blue('Continue anyways?'), + default: true + }); + }).then( + (answers) => answers.continue || Promise.reject(new errors.SystemError( + `Setup was halted. Ghost is installed but not fully setup.${os.EOL}` + + 'Fix any errors shown and re-run `ghost setup`, or run `ghost setup --no-stack`.' + )) + ); } }]; diff --git a/lib/commands/doctor/index.js b/lib/commands/doctor/index.js index 6fed28cdd..808afaee3 100644 --- a/lib/commands/doctor/index.js +++ b/lib/commands/doctor/index.js @@ -1,5 +1,4 @@ 'use strict'; -const Listr = require('listr'); const Command = require('../../command'); const errors = require('../../errors'); @@ -19,9 +18,7 @@ class DoctorCommand extends Command { return Promise.reject(e); } - let tasks = new Listr(checks, {concurrent: true, renderer: this.renderer}); - - return tasks.run(this).then(() => { + return this.ui.listr(checks, {ui: this.ui, system: this.system}, {concurrent: true}).then(() => { this.ui.success(`All ${category} checks passed`); }).catch((error) => { if (error instanceof errors.SystemError) { @@ -36,7 +33,7 @@ class DoctorCommand extends Command { } DoctorCommand.description = 'Check the system for any potential hiccups when installing/updating Ghost'; -DoctorCommand.params = '[name]'; +DoctorCommand.params = '[category]'; DoctorCommand.global = true; module.exports = DoctorCommand; diff --git a/lib/commands/install.js b/lib/commands/install.js index 68da38bcf..ac26e1b7c 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -2,7 +2,6 @@ const fs = require('fs-extra'); const path = require('path'); const every = require('lodash/every'); -const Listr = require('listr'); const Promise = require('bluebird'); const Command = require('../command'); const symlinkSync = require('symlink-or-copy').sync; @@ -50,12 +49,12 @@ class InstallCommand extends Command { this.environment = 'development'; } - return new Listr([{ + return this.ui.listr([{ title: 'Checking for latest Ghost version', task: this.constructor.tasks.version }, { title: 'Running system checks', - task: (ctx) => new Listr(installChecks, {concurrent: true, renderer: ctx.renderer}) + task: () => this.ui.listr(installChecks, false, {concurrent: true}) }, { title: 'Setting up install directory', task: ensureStructure @@ -63,25 +62,25 @@ class InstallCommand extends Command { title: 'Downloading and installing Ghost', task: (ctx, task) => { task.title = `Downloading and installing Ghost v${ctx.version}`; - return yarnInstall(ctx.renderer); + return yarnInstall(ctx.ui); } }, { title: 'Moving files', - task: () => new Listr([{ + task: () => this.ui.listr([{ title: 'Summoning Casper', task: this.constructor.tasks.casper }, { title: 'Linking things', task: this.constructor.tasks.link - }], {concurrent: true}) - }], {renderer: this.renderer}).run({ + }], false, {concurrent: true}) + }], { version: version, - cliVersion: this.cliVersion, - renderer: this.renderer + cliVersion: this.cliVersion }).then(() => { - if (argv.noSetup) { + if (!argv.setup) { return; } + argv.local = local; let setup = new SetupCommand(this); @@ -128,10 +127,10 @@ InstallCommand.options = { description: 'Folder to install Ghost in', type: 'string' }, - noSetup: { - alias: 'N', - description: 'Don\'t automatically run the setup command', - type: 'boolean' + setup: { + description: 'Automatically run the setup command', + type: 'boolean', + default: true } }; diff --git a/lib/commands/setup.js b/lib/commands/setup.js index ac0c78dbc..0613af979 100644 --- a/lib/commands/setup.js +++ b/lib/commands/setup.js @@ -1,11 +1,7 @@ 'use strict'; -const eol = require('os').EOL; const path = require('path'); -const chalk = require('chalk'); -const Listr = require('listr'); const Config = require('../utils/config'); -const errors = require('../errors'); const setupChecks = require('./doctor/checks/setup'); const StartCommand = require('./start'); const ConfigCommand = require('./config'); @@ -19,15 +15,11 @@ class SetupCommand extends Command { } run(argv) { - let context = { - renderer: this.renderer, - verbose: this.verbose - }; - if (argv.local) { argv.url = argv.url || 'http://localhost:2368/'; argv.pname = argv.pname || 'ghost-local'; argv.process = 'local'; + argv.stack = false; // If the user's already specified a db client, then we won't override it. if (!argv.db) { @@ -35,89 +27,64 @@ class SetupCommand extends Command { argv.dbpath = path.join(process.cwd(), 'content/data/ghost-local.db'); } - context.start = true; + argv.start = true; // In the case that the user runs `ghost setup --local`, we want to make // sure we're set up in development mode this.development = true; process.env.NODE_ENV = this.environment = 'development'; - } else { - context.start = argv.start || false; } - let configCommand = new ConfigCommand(this); - return configCommand.run(argv).then((config) => { - context.config = config; - - if (!argv.local && argv.stack) { - return new Listr(setupChecks, {concurrent: true, renderer: this.renderer}).run(context) - .then((context) => {context.continue = true;}) - .catch((error) => { - if (!(error instanceof errors.SystemError)) { - return Promise.reject(error); - } - - this.ui.log( - `System Stack checks failed with message: '${error.message}'.${eol}` + - 'Some features of Ghost-CLI may not work without additional configuration.', - 'yellow' - ); - - return this.ui.prompt({ - type: 'confirm', - name: 'continue', - message: chalk.blue('Continue anyways?'), - default: true - }).then((answers) => { - if (!answers.continue) { - return Promise.reject(new Error( - `Setup was halted. Ghost is installed but not fully setup.${eol}` + - 'Fix any errors shown and re-run `ghost setup`, or run `ghost setup --no-stack`.' - )); - } - }); - }); + return this.ui.listr([{ + title: 'Configuring Ghost', + task: (ctx) => { + let configCommand = new ConfigCommand(this); + return configCommand.run(argv).then((config) => ctx.config = config); } - }).then(() => { - // De-duplicate process name before setting up the process manager - dedupeProcessName(context.config); - - this.service.setConfig(context.config); - - return this.ui.run(this.service.callHook('setup', context), 'Finishing setup'); - }).then(() => { - if (context.start) { - return; + }, { + title: 'Running setup checks', + skip: () => !argv.stack, + task: () => this.ui.listr(setupChecks, false) + }, { + title: 'Finishing setup', + task: (ctx) => { + // De-duplicate process name before setting up the process manager + dedupeProcessName(ctx.config); + this.service.setConfig(ctx.config); + return this.service.callHook('setup', ctx); } - - return this.ui.prompt({ + }], {setup: true}).then((context) => { + let promise = argv.start ? Promise.resolve({start: true}) : this.ui.prompt({ type: 'confirm', name: 'start', message: 'Do you want to start Ghost?', default: true }); - }).then((answer) => { - // Add config to system blog list - let systemConfig = Config.load('system'); - let instances = systemConfig.get('instances', {}); - instances[context.config.get('pname')] = { - cwd: process.cwd() - }; - systemConfig.set('instances', instances).save(); - if (context.start || answer.start) { - let startCommand = new StartCommand(this); - return startCommand.run(argv); - } + return promise.then((answer) => { + // Add config to system blog list + let systemConfig = Config.load('system'); + let instances = systemConfig.get('instances', {}); + instances[context.config.get('pname')] = { + cwd: process.cwd() + }; + systemConfig.set('instances', instances).save(); + + if (answer.start) { + let startCommand = new StartCommand(this); + return startCommand.run(argv); + } + }); }); } } SetupCommand.description = 'Setup an installation of Ghost (after it is installed)'; SetupCommand.options = { - noStack: { - description: 'Don\'t check the system stack on setup', - type: 'boolean' + stack: { + description: 'Check the system stack on setup', + type: 'boolean', + default: true }, local: { alias: 'l', @@ -127,7 +94,7 @@ SetupCommand.options = { start: { name: 'start', description: 'Automatically start Ghost without prompting', - flag: true + type: 'boolean' } }; diff --git a/lib/commands/start.js b/lib/commands/start.js index 54052ab5b..97355a572 100644 --- a/lib/commands/start.js +++ b/lib/commands/start.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); const KnexMigrator = require('knex-migrator'); -const Listr = require('listr'); const Config = require('../utils/config'); const Command = require('../command'); @@ -22,7 +21,7 @@ class StartCommand extends Command { process.env.NODE_ENV = this.environment = 'development'; } - return new Listr(startupChecks, {renderer: this.renderer}).run(this).then(() => { + return this.ui.listr(startupChecks, {environment: this.environment}).then(() => { config = Config.load(this.environment); cliConfig = Config.load('.ghost-cli'); diff --git a/lib/commands/update.js b/lib/commands/update.js index 659b24a62..860bd9162 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -1,7 +1,6 @@ 'use strict'; const fs = require('fs-extra'); const path = require('path'); -const Listr = require('listr'); const symlinkSync = require('symlink-or-copy').sync; // Utils @@ -48,7 +47,7 @@ class UpdateCommand extends Command { this.service.setConfig(Config.load(context.environment)); - return new Listr([{ + return this.ui.listr([{ title: 'Checking for latest Ghost version', skip: (ctx) => ctx.rollback, task: this.constructor.tasks.version @@ -67,7 +66,7 @@ class UpdateCommand extends Command { } task.title = `Downloading and updating Ghost to v${ctx.version}`; - return yarnInstall(ctx.renderer); + return yarnInstall(ctx.ui); } }, { title: 'Stopping Ghost', @@ -102,7 +101,7 @@ class UpdateCommand extends Command { let startCommand = new StartCommand(this); return startCommand.run({quiet: true}); } - }], {renderer: this.renderer}).run(context); + }], context); } } diff --git a/lib/tasks/yarn-install.js b/lib/tasks/yarn-install.js index c3582698e..d1e00cf57 100644 --- a/lib/tasks/yarn-install.js +++ b/lib/tasks/yarn-install.js @@ -1,6 +1,5 @@ 'use strict'; const fs = require('fs-extra'); -const Listr = require('listr'); const shasum = require('shasum'); const download = require('download'); const decompress = require('decompress'); @@ -41,8 +40,8 @@ const subTasks = { }; module.exports.subTasks = subTasks; -module.exports = function yarnInstall(renderer) { - return new Listr([{ +module.exports = function yarnInstall(ui) { + return ui.listr([{ title: 'Getting download information', task: subTasks.dist }, { @@ -55,5 +54,5 @@ module.exports = function yarnInstall(renderer) { env: {NODE_ENV: 'production'}, observe: true }) - }], {renderer: renderer}); + }], false); }; diff --git a/lib/ui.js b/lib/ui/index.js similarity index 91% rename from lib/ui.js rename to lib/ui/index.js index 13375b1e5..2083c9510 100644 --- a/lib/ui.js +++ b/lib/ui/index.js @@ -4,7 +4,7 @@ const path = require('path'); const ora = require('ora'); const chalk = require('chalk'); const execa = require('execa'); -const errors = require('./errors'); +const Listr = require('listr'); const Table = require('cli-table2'); const assign = require('lodash/assign'); const Promise = require('bluebird'); @@ -13,6 +13,9 @@ const stripAnsi = require('strip-ansi'); const isFunction = require('lodash/isFunction'); const isObject = require('lodash/isObject'); +const errors = require('../errors'); +const CLIRenderer = require('./renderer'); + const defaultOptions = { stdin: process.stdin, stdout: process.stdout, @@ -38,12 +41,14 @@ class UI { input: this.stdin, output: this.stdout }); + + CLIRenderer.ui = this; } run(promiseOrFunction, name, options) { options = options || {}; options.text = options.text || name; - options.spinner = options.spinner || 'dots'; + options.spinner = options.spinner || 'hamburger'; options.stream = this.stdout; this.spinner = ora(options).start(); @@ -77,6 +82,15 @@ class UI { return this.noSpin(() => this.inquirer(prompts)); } + listr(tasks, context, options) { + let listrOpts = Object.assign({ + renderer: this.verbose ? 'verbose' : CLIRenderer + }, options); + + let listr = new Listr(tasks, listrOpts); + return context === false ? listr : listr.run(Object.assign(context || {}, { ui: this })); + } + sudo(command, options) { this.log(`Running sudo command: ${command}`, 'gray'); @@ -95,11 +109,13 @@ class UI { noSpin(promiseOrFunc) { if (this.spinner) { this.spinner.stop(); + this.spinner.paused = true; } return Promise.resolve(isFunction(promiseOrFunc) ? promiseOrFunc() : promiseOrFunc).then((result) => { if (this.spinner) { this.spinner.start(); + this.spinner.paused = false; } return result; diff --git a/lib/ui/renderer.js b/lib/ui/renderer.js new file mode 100644 index 000000000..d881ca8ce --- /dev/null +++ b/lib/ui/renderer.js @@ -0,0 +1,92 @@ +'use strict'; +const UI = require('./index'); +const ora = require('ora'); +const chalk = require('chalk'); + +const defaultOptions = { + refreshRate: 100 +}; + +/** + * Renderer class used for Listr lists. Adds some integration with the UI + * class so that prompt and noSpin calls still work + */ +class CLIRenderer { + constructor(tasks, options) { + this.tasks = tasks; + this.options = Object.assign({}, defaultOptions, options); + + this.ui = this.constructor.ui || new UI(); + this.previousFrame = null; + } + + render() { + if (this.id) { + return; + } + + this.spinner = this.ui.spinner = ora({ + stream: this.ui.stdout, + spinner: this.options.spinner || 'hamburger' + }); + + this.subscribeToEvents(); + + this.id = setInterval(() => { + this.frame(); + }, this.options.refreshRate); + } + + subscribeToEvents() { + this.tasks.forEach((task) => { + task.subscribe((event) => { + if (event.type === 'STATE' && (task.isCompleted() || task.isSkipped() || task.hasFailed())) { + let spinnerMethod = task.isCompleted() ? 'succeed' : (task.isSkipped() ? 'info' : 'fail'); + this.spinner[spinnerMethod](task.isSkipped() ? `${task.title} ${chalk.gray('[skipped]')}` : task.title); + } + }); + }); + } + + frame() { + let text = this.tasks + .filter((task) => task.isPending()) + .map(this.buildText.bind(this)).join(' | '); + + if (text && text !== this.previousFrame && !this.spinner.paused) { + this.spinner.start(text); + this.previousFrame = text; + } + } + + buildText(task) { + if (!task.hasSubtasks()) { + if (task.output && typeof task.output === 'string') { + let data = task.output.trim().split('\n').filter(Boolean).pop(); + return `${task.title} ${this.constructor.separator} ${chalk.gray(data)}`; + } + + return task.title; + } + + let subtaskText = task.subtasks + .filter((subtask) => subtask.isPending()) + .map((subtask) => this.buildText(subtask)) + .join('/'); + + return `${task.title} ${this.constructor.separator} ${subtaskText}`; + } + + end() { + if (this.id) { + clearInterval(this.id); + this.id = undefined; + } + + this.frame(); // Ensure last spinners are cleared up + } +} + +CLIRenderer.separator = chalk.cyan('>'); + +module.exports = CLIRenderer;