From 8da3d1a6c1333af444018be1e640ce955538d10e Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Sun, 30 Sep 2018 07:27:17 -0400 Subject: [PATCH] feat(setup): refactor setup command closes #594, #588 - cleanup setup command code & improve testability - rework how extensions register setup steps - make it so an errored setup step doesn't break the entire flow --- extensions/mysql/index.js | 44 +- extensions/mysql/test/extension-spec.js | 167 ++-- extensions/nginx/index.js | 175 +++-- extensions/nginx/test/extension-spec.js | 552 ++++++------- extensions/systemd/index.js | 29 +- extensions/systemd/test/extension-spec.js | 115 ++- lib/commands/setup.js | 272 +++---- lib/tasks/linux.js | 8 +- test/unit/commands/setup-spec.js | 892 +++++++++------------- 9 files changed, 991 insertions(+), 1263 deletions(-) diff --git a/extensions/mysql/index.js b/extensions/mysql/index.js index 7274fa2f6..090445591 100644 --- a/extensions/mysql/index.js +++ b/extensions/mysql/index.js @@ -3,31 +3,29 @@ const Promise = require('bluebird'); const mysql = require('mysql'); const omit = require('lodash/omit'); -const cli = require('../../lib'); const generator = require('generate-password'); +const {Extension, errors} = require('../../lib'); const localhostAliases = ['localhost', '127.0.0.1']; - -class MySQLExtension extends cli.Extension { - setup(cmd, argv) { - // Case 1: ghost install local OR ghost setup --local - // Case 2: ghost install --db sqlite3 - // Skip in both cases - if (argv.local || argv.db === 'sqlite3') { - return; - } - - cmd.addStage('mysql', this.setupMySQL.bind(this), [], '"ghost" mysql user'); +const {ConfigError, CliError} = errors; + +class MySQLExtension extends Extension { + setup() { + return [{ + id: 'mysql', + name: '"ghost" mysql user', + task: (...args) => this.setupMySQL(...args), + // Case 1: ghost install local OR ghost setup --local + // Case 2: ghost install --db sqlite3 + // Skip in both cases + enabled: ({argv}) => !(argv.local || argv.db === 'sqlite3'), + skip: ({instance}) => instance.config.get('database.connection.user') !== 'root' + }]; } - setupMySQL(argv, ctx, task) { + setupMySQL(ctx) { const dbconfig = ctx.instance.config.get('database.connection'); - if (dbconfig.user !== 'root') { - this.ui.log('MySQL user is not "root", skipping additional user setup', 'yellow'); - return task.skip(); - } - return this.ui.listr([{ title: 'Connecting to database', task: () => this.canConnect(ctx, dbconfig) @@ -53,7 +51,7 @@ class MySQLExtension extends cli.Extension { return Promise.fromCallback(cb => this.connection.connect(cb)).catch((error) => { if (error.code === 'ECONNREFUSED') { - return Promise.reject(new cli.errors.ConfigError({ + return Promise.reject(new ConfigError({ message: error.message, config: { 'database.connection.host': dbconfig.host, @@ -63,7 +61,7 @@ class MySQLExtension extends cli.Extension { help: 'Ensure that MySQL is installed and reachable. You can always re-run `ghost setup` to try again.' })); } else if (error.code === 'ER_ACCESS_DENIED_ERROR') { - return Promise.reject(new cli.errors.ConfigError({ + return Promise.reject(new ConfigError({ message: error.message, config: { 'database.connection.user': dbconfig.user, @@ -74,7 +72,7 @@ class MySQLExtension extends cli.Extension { })); } - return Promise.reject(new cli.errors.CliError({ + return Promise.reject(new CliError({ message: 'Error trying to connect to the MySQL database.', help: 'You can run `ghost config` to re-enter the correct credentials. Alternatively you can run `ghost setup` again.', err: error @@ -168,11 +166,11 @@ class MySQLExtension extends cli.Extension { this.ui.logVerbose(`MySQL: running query > ${queryString}`, 'gray'); return Promise.fromCallback(cb => this.connection.query(queryString, cb)) .catch((error) => { - if (error instanceof (cli.errors.CliError)) { + if (error instanceof CliError) { return Promise.reject(error); } - return Promise.reject(new cli.errors.CliError({ + return Promise.reject(new CliError({ message: error.message, context: queryString, err: error diff --git a/extensions/mysql/test/extension-spec.js b/extensions/mysql/test/extension-spec.js index d958740b0..856e3e009 100644 --- a/extensions/mysql/test/extension-spec.js +++ b/extensions/mysql/test/extension-spec.js @@ -2,100 +2,97 @@ const expect = require('chai').expect; const sinon = require('sinon'); const proxyquire = require('proxyquire'); +const configStub = require('../../../test/utils/config-stub'); const modulePath = '../index'; const errors = require('../../../lib/errors'); describe('Unit: Mysql extension', function () { - describe('setup hook', function () { + it('setup hook works', function () { const MysqlExtension = require(modulePath); - - it('does not add stage if --local is true', function () { - const instance = new MysqlExtension({}, {}, {}, '/some/dir'); - const addStageStub = sinon.stub(); - - instance.setup({addStage: addStageStub}, {local: true}); - expect(addStageStub.called).to.be.false; - }); - - it('does not add stage if db is sqlite3', function () { - const instance = new MysqlExtension({}, {}, {}, '/some/dir'); - const addStageStub = sinon.stub(); - - instance.setup({addStage: addStageStub}, {local: false, db: 'sqlite3'}); - expect(addStageStub.called).to.be.false; - }); - - it('adds stage if not local and db is not sqlite3', function () { - const instance = new MysqlExtension({}, {}, {}, '/some/dir'); - const addStageStub = sinon.stub(); - - instance.setup({addStage: addStageStub}, {local: false, db: 'mysql'}); - expect(addStageStub.calledOnce).to.be.true; - expect(addStageStub.calledWith('mysql')).to.be.true; - }); + const inst = new MysqlExtension({}, {}, {}, '/some/dir'); + const result = inst.setup(); + + expect(result).to.have.length(1); + const [task] = result; + + // Check static properties + expect(task.id).to.equal('mysql'); + expect(task.name).to.equal('"ghost" mysql user'); + + // Check task functions + expect(task.task).to.be.a('function'); + expect(task.enabled).to.be.a('function'); + expect(task.skip).to.be.a('function'); + + // Check task.task + const stub = sinon.stub(inst, 'setupMySQL'); + task.task('a', 'set', 'of', 'args', true); + expect(stub.calledOnce).to.be.true; + expect(stub.calledWithExactly('a', 'set', 'of', 'args', true)).to.be.true; + + // Check task.enabled + expect(task.enabled({argv: {local: true}})).to.be.false; + expect(task.enabled({argv: {local: false, db: 'sqlite3'}})).to.be.false; + expect(task.enabled({argv: {local: false}})).to.be.true; + + // Check task.skip + const get = sinon.stub(); + get.onFirstCall().returns('not-root'); + get.onSecondCall().returns('root'); + + expect(task.skip({instance: {config: {get}}})).to.be.true; + expect(get.calledOnce).to.be.true; + expect(task.skip({instance: {config: {get}}})).to.be.false; + expect(get.calledTwice).to.be.true; }); - describe('setupMySQL', function () { + it('setupMySQL works', function () { const MysqlExtension = require(modulePath); - - it('skips if db user is not root', function () { - const logStub = sinon.stub(); - const instance = new MysqlExtension({log: logStub}, {}, {}, '/some/dir'); - const getStub = sinon.stub().returns({user: 'notroot'}); - const skipStub = sinon.stub().resolves(); - - return instance.setupMySQL({}, {instance: {config: {get: getStub}}}, {skip: skipStub}).then(() => { - expect(getStub.calledOnce).to.be.true; - expect(getStub.calledWithExactly('database.connection')).to.be.true; - expect(logStub.calledOnce).to.be.true; - expect(logStub.args[0][0]).to.match(/user is not "root"/); - expect(skipStub.calledOnce).to.be.true; - }); - }); - - it('returns tasks that call the helpers and cleanup', function () { - const listrStub = sinon.stub().callsFake(function (tasks) { - expect(tasks).to.be.an.instanceof(Array); - expect(tasks).to.have.length(4); - - // Run each task step - tasks.forEach(task => task.task()); - return Promise.resolve(); - }); - const instance = new MysqlExtension({listr: listrStub}, {}, {}, '/some/dir'); - const getStub = sinon.stub().returns({user: 'root'}); - const saveStub = sinon.stub(); - const setStub = sinon.stub(); - setStub.returns({set: setStub, save: saveStub}); - const endStub = sinon.stub(); - const skipStub = sinon.stub().resolves(); - const canConnectStub = sinon.stub(instance, 'canConnect'); - const createUserStub = sinon.stub(instance, 'createUser').callsFake((ctx) => { - ctx.mysql = {username: 'testuser', password: 'testpass'}; - }); - const grantPermissionsStub = sinon.stub(instance, 'grantPermissions'); - - instance.connection = {end: endStub}; - - return instance.setupMySQL({}, { - instance: {config: {get: getStub, set: setStub, save: saveStub}} - }, {skip: skipStub}).then(() => { - expect(skipStub.called).to.be.false; - expect(listrStub.calledOnce).to.be.true; - expect(listrStub.args[0][1]).to.be.false; - - expect(getStub.calledOnce).to.be.true; - expect(canConnectStub.calledOnce).to.be.true; - expect(createUserStub.calledOnce).to.be.true; - expect(grantPermissionsStub.calledOnce).to.be.true; - expect(setStub.calledTwice).to.be.true; - expect(setStub.calledWithExactly('database.connection.user', 'testuser')).to.be.true; - expect(setStub.calledWithExactly('database.connection.password', 'testpass')).to.be.true; - expect(saveStub.calledOnce).to.be.true; - expect(endStub.calledOnce).to.be.true; - }); - }); + const listr = sinon.stub(); + const config = configStub(); + const dbConfig = {host: 'localhost', user: 'root', password: 'password'}; + config.get.returns(dbConfig); + + const inst = new MysqlExtension({listr}, {}, {}, '/some/dir'); + const context = {instance: {config}}; + inst.setupMySQL(context); + + expect(config.get.calledOnce).to.be.true; + expect(config.get.calledWithExactly('database.connection')).to.be.true; + expect(listr.calledOnce).to.be.true; + const [tasks, ctx] = listr.args[0]; + expect(tasks).to.have.length(4); + expect(ctx).to.be.false; + + const canConnect = sinon.stub(inst, 'canConnect'); + expect(tasks[0].title).to.equal('Connecting to database'); + tasks[0].task(); + expect(canConnect.calledOnce).to.be.true; + expect(canConnect.calledWithExactly(context, dbConfig)).to.be.true; + + const createUser = sinon.stub(inst, 'createUser'); + expect(tasks[1].title).to.equal('Creating new MySQL user'); + tasks[1].task(); + expect(createUser.calledOnce).to.be.true; + expect(createUser.calledWithExactly(context, dbConfig)).to.be.true; + + const grantPermissions = sinon.stub(inst, 'grantPermissions'); + expect(tasks[2].title).to.equal('Granting new user permissions'); + tasks[2].task(); + expect(grantPermissions.calledOnce).to.be.true; + expect(grantPermissions.calledWithExactly(context, dbConfig)).to.be.true; + + const end = sinon.stub(); + inst.connection = {end}; + context.mysql = {username: 'new', password: 'new'}; + expect(tasks[3].title).to.equal('Saving new config'); + tasks[3].task(); + expect(config.set.calledTwice).to.be.true; + expect(config.set.calledWithExactly('database.connection.user', 'new')).to.be.true; + expect(config.set.calledWithExactly('database.connection.password', 'new')).to.be.true; + expect(config.save.calledOnce).to.be.true; + expect(end.calledOnce).to.be.true; }); describe('canConnect', function () { diff --git a/extensions/nginx/index.js b/extensions/nginx/index.js index 20fd9d58a..30ec98c7b 100644 --- a/extensions/nginx/index.js +++ b/extensions/nginx/index.js @@ -20,110 +20,119 @@ class NginxExtension extends Extension { return [{ before: '1.2.0', title: 'Migrating SSL certs', - skip: () => os.platform() !== 'linux' || !fs.existsSync(path.join(os.homedir(), '.acme.sh')), + skip: () => !this.system.platform.linux || !fs.existsSync(path.join(os.homedir(), '.acme.sh')), task: migrations.migrateSSL.bind(this) }]; } - setup(cmd, argv) { + setup() { // ghost setup --local, skip - if (argv.local) { - return; - } + const enabled = ({argv}) => !argv.local; - cmd.addStage('nginx', this.setupNginx.bind(this), null, 'Nginx'); - cmd.addStage('ssl', this.setupSSL.bind(this), 'nginx', 'SSL'); - } + return [{ + id: 'nginx', + name: 'Nginx', + task: (...args) => this.setupNginx(...args), + enabled, + skip: ({instance}) => { + if (!this.isSupported()) { + return 'Nginx is not installed. Skipping Nginx setup.'; + } - setupNginx(argv, ctx, task) { - if (!this.isSupported()) { - this.ui.log('Nginx is not installed. Skipping Nginx setup.', 'yellow'); - return task.skip(); - } + const {port, hostname} = url.parse(instance.config.get('url')); - const parsedUrl = url.parse(ctx.instance.config.get('url')); + if (port) { + return 'Your url contains a port. Skipping Nginx setup.'; + } - if (parsedUrl.port) { - this.ui.log('Your url contains a port. Skipping Nginx setup.', 'yellow'); - return task.skip(); - } + const confFile = `${hostname}.conf`; - const confFile = `${parsedUrl.hostname}.conf`; + if (fs.existsSync(`/etc/nginx/sites-available/${confFile}`)) { + return 'Nginx configuration already found for this url. Skipping Nginx setup.'; + } - if (fs.existsSync(`/etc/nginx/sites-available/${confFile}`)) { - this.ui.log('Nginx configuration already found for this url. Skipping Nginx setup.', 'yellow'); - return task.skip(); - } + return false; + } + }, { + id: 'ssl', + name: 'SSL', + enabled, + task: (...args) => this.setupSSL(...args), + skip: ({tasks, instance, argv, single}) => { + if (tasks.nginx.isSkipped()) { + return 'Nginx setup task was skipped, skipping SSL setup'; + } + + if (tasks.nginx.hasFailed()) { + return 'Nginx setup task failed, skipping SSL setup'; + } + + const {hostname} = url.parse(instance.config.get('url')); + const confFile = `${hostname}-ssl.conf`; + + if (isIP(hostname)) { + return 'SSL certs cannot be generated for IP addresses, skipping'; + } + + if (fs.existsSync(`/etc/nginx/sites-available/${confFile}`)) { + return 'SSL has already been set up, skipping'; + } + + if (!argv.prompt && !argv.sslemail) { + return 'SSL email must be provided via the --sslemail option, skipping SSL setup'; + } + if (!fs.existsSync(`/etc/nginx/sites-available/${hostname}.conf`)) { + return single ? 'Nginx config file does not exist, skipping SSL setup' : true; + } + + return false; + } + }]; + } + + setupNginx({instance}) { + const {hostname, pathname} = url.parse(instance.config.get('url')); const conf = template(fs.readFileSync(path.join(__dirname, 'templates', 'nginx.conf'), 'utf8')); - const rootPath = path.resolve(ctx.instance.dir, 'system', 'nginx-root'); + const rootPath = path.resolve(instance.dir, 'system', 'nginx-root'); + const confFile = `${hostname}.conf`; const generatedConfig = conf({ - url: parsedUrl.hostname, + url: hostname, webroot: rootPath, - location: parsedUrl.pathname !== '/' ? `^~ ${parsedUrl.pathname}` : '/', - port: ctx.instance.config.get('server.port') + location: pathname !== '/' ? `^~ ${pathname}` : '/', + port: instance.config.get('server.port') }); - return this.template( - ctx.instance, - generatedConfig, - 'nginx config', - confFile, - '/etc/nginx/sites-available' - ).then( + return this.template(instance, generatedConfig, 'nginx config', confFile, '/etc/nginx/sites-available').then( () => this.ui.sudo(`ln -sf /etc/nginx/sites-available/${confFile} /etc/nginx/sites-enabled/${confFile}`) ).then( () => this.restartNginx() - ).catch( - (error) => { - // CASE: error is already a cli error, just pass it along - if (error instanceof CliError) { - return Promise.reject(error); - } + ).catch((error) => { + // CASE: error is already a cli error, just pass it along + if (error instanceof CliError) { + return Promise.reject(error); + } - return Promise.reject(new ProcessError(error)); - }); + return Promise.reject(new ProcessError(error)); + }); } - setupSSL(argv, ctx, task) { - const parsedUrl = url.parse(ctx.instance.config.get('url')); + setupSSL({instance, argv}) { + const parsedUrl = url.parse(instance.config.get('url')); const confFile = `${parsedUrl.hostname}-ssl.conf`; - if (isIP(parsedUrl.hostname)) { - this.ui.log('SSL certs cannot be generated for IP addresses, skipping', 'yellow'); - return task.skip(); - } - - if (fs.existsSync(`/etc/nginx/sites-available/${confFile}`)) { - this.ui.log('SSL has already been set up, skipping', 'yellow'); - return task.skip(); - } - - if (!argv.prompt && !argv.sslemail) { - this.ui.log('SSL email must be provided via the --sslemail option, skipping SSL setup', 'yellow'); - return task.skip(); - } - - if (!fs.existsSync(`/etc/nginx/sites-available/${parsedUrl.hostname}.conf`)) { - if (ctx.single) { - this.ui.log('Nginx config file does not exist, skipping SSL setup', 'yellow'); - } - - return task.skip(); - } - const acme = require('./acme'); - const rootPath = path.resolve(ctx.instance.dir, 'system', 'nginx-root'); + const rootPath = path.resolve(instance.dir, 'system', 'nginx-root'); const dhparamFile = '/etc/nginx/snippets/dhparam.pem'; const sslParamsFile = '/etc/nginx/snippets/ssl-params.conf'; const sslParamsConf = template(fs.readFileSync(path.join(__dirname, 'templates', 'ssl-params.conf'), 'utf8')); return this.ui.listr([{ title: 'Checking DNS resolution', - task: ctx => Promise.fromNode(cb => dns.lookup(parsedUrl.hostname, {family: 4}, cb)).catch((error) => { + task: () => Promise.fromNode(cb => dns.lookup(parsedUrl.hostname, {family: 4}, cb)).catch((error) => { if (error.code !== 'ENOTFOUND') { // Some other error return Promise.reject(new CliError({ @@ -139,13 +148,13 @@ class NginxExtension extends Extension { 'Because of this, SSL setup won\'t work correctly. Once you\'ve set up your domain', 'and pointed it at this server\'s IP, try running `ghost setup ssl` again.' ]; - - this.ui.log(text.join(' '), 'yellow'); - ctx.dnsfail = true; + return Promise.reject(new CliError({ + message: text.join('\n'), + task: 'Setting up SSL' + })); }) }, { title: 'Getting additional configuration', - skip: ({dnsfail}) => dnsfail, task: () => { let promise; @@ -166,20 +175,18 @@ class NginxExtension extends Extension { } }, { title: 'Installing acme.sh', - skip: ({dnsfail}) => dnsfail, task: (ctx, task) => acme.install(this.ui, task) }, { title: 'Getting SSL Certificate from Let\'s Encrypt', - skip: ({dnsfail}) => dnsfail, task: () => acme.generate(this.ui, parsedUrl.hostname, rootPath, argv.sslemail, argv.sslstaging) }, { title: 'Generating Encryption Key (may take a few minutes)', - skip: ({dnsfail}) => dnsfail || fs.existsSync(dhparamFile), + skip: () => fs.existsSync(dhparamFile), task: () => this.ui.sudo(`openssl dhparam -out ${dhparamFile} 2048`) .catch(error => Promise.reject(new ProcessError(error))) }, { title: 'Generating SSL security headers', - skip: ({dnsfail}) => dnsfail || fs.existsSync(sslParamsFile), + skip: () => fs.existsSync(sslParamsFile), task: () => { const tmpfile = path.join(os.tmpdir(), 'ssl-params.conf'); @@ -190,8 +197,7 @@ class NginxExtension extends Extension { } }, { title: 'Generating SSL configuration', - skip: ({dnsfail}) => dnsfail, - task: (ctx) => { + task: () => { const acmeFolder = path.join('/etc/letsencrypt', parsedUrl.hostname); const sslConf = template(fs.readFileSync(path.join(__dirname, 'templates', 'nginx-ssl.conf'), 'utf8')); const generatedSslConfig = sslConf({ @@ -201,22 +207,15 @@ class NginxExtension extends Extension { privkey: path.join(acmeFolder, `${parsedUrl.hostname}.key`), sslparams: sslParamsFile, location: parsedUrl.pathname !== '/' ? `^~ ${parsedUrl.pathname}` : '/', - port: ctx.instance.config.get('server.port') + port: instance.config.get('server.port') }); - return this.template( - ctx.instance, - generatedSslConfig, - 'ssl config', - confFile, - '/etc/nginx/sites-available' - ).then( + return this.template(instance, generatedSslConfig, 'ssl config', confFile, '/etc/nginx/sites-available').then( () => this.ui.sudo(`ln -sf /etc/nginx/sites-available/${confFile} /etc/nginx/sites-enabled/${confFile}`) ).catch(error => Promise.reject(new ProcessError(error))); } }, { title: 'Restarting Nginx', - skip: ({dnsfail}) => dnsfail, task: () => this.restartNginx() }], false); } diff --git a/extensions/nginx/test/extension-spec.js b/extensions/nginx/test/extension-spec.js index e878c710a..bdae99927 100644 --- a/extensions/nginx/test/extension-spec.js +++ b/extensions/nginx/test/extension-spec.js @@ -5,11 +5,17 @@ const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); const modulePath = '../index'; const Promise = require('bluebird'); -const errors = require('../../../lib/errors'); +const {errors, Extension} = require('../../../lib'); +const configStub = require('../../../test/utils/config-stub'); -const NGINX = require(modulePath); +// Proxied things +const fs = require('fs-extra'); +const execa = require('execa'); +const migrations = require('../migrations'); + +const Nginx = require(modulePath); const testURL = 'http://ghost.dev'; -const nginxBase = '/etc/nginx/sites-'; +// const nginxBase = '/etc/nginx/sites-'; const dir = '/var/www/ghost'; function addStubs(instance) { @@ -40,142 +46,173 @@ function _get(key) { } describe('Unit: Extensions > Nginx', function () { - it('inherits from extension', function () { - const ext = require('../../../lib/extension.js'); + afterEach(() => { + sinon.restore(); + }); - expect(NGINX.prototype instanceof ext).to.be.true; + it('inherits from extension', function () { + expect(Nginx.prototype instanceof Extension).to.be.true; }); - describe('migrations hook', function () { - // Describe is used here for future-proofing - describe('[before 1.2.0]', function () { - it('Skips if not linux', function () { - const osStub = sinon.stub().returns('win32'); - const ext = proxyNginx({os: {platform: osStub}}); - const migrations = ext.migrations(); + it('migrations hook', function () { + const inst = new Nginx({}, {}, {}, '/some/dir'); + const migrateStub = sinon.stub(migrations, 'migrateSSL'); + const result = inst.migrations(); - expect(migrations[0].before).to.equal('1.2.0'); - expect(migrations[0].skip()).to.be.true; - }); + expect(result).to.have.length(1); + const [task] = result; - it('Skips if acme.sh doesn\'t exist', function () { - const ext = proxyNginx({ - 'fs-extra': {existsSync: sinon.stub().returns(false)}, - os: { - platform: () => 'linux', - homedir: () => '/home/me' - } - }); - const migrations = ext.migrations(); + expect(task.before).to.equal('1.2.0'); + expect(task.title).to.equal('Migrating SSL certs'); - expect(migrations[0].before).to.equal('1.2.0'); - expect(migrations[0].skip()).to.be.true; - }); + task.task(); + expect(migrateStub.calledOnce).to.be.true; - it('Doesn\'t skip if acme.sh exists', function () { - const ext = proxyNginx({ - 'fs-extra': {existsSync: sinon.stub().returns(true)}, - os: { - platform: () => 'linux', - homedir: () => '/home/me' - } - }); - const migrations = ext.migrations(); + inst.system.platform = {linux: false}; + const exists = sinon.stub(fs, 'existsSync').returns(false); + expect(task.skip()).to.be.true; + expect(exists.called).to.be.false; - expect(migrations[0].before).to.equal('1.2.0'); - expect(migrations[0].skip()).to.be.false; - }); - }); + inst.system.platform = {linux: true}; + expect(task.skip()).to.be.true; + expect(exists.calledOnce).to.be.true; + + inst.system.platform = {linux: true}; + exists.returns(true); + expect(task.skip()).to.be.false; + expect(exists.calledTwice).to.be.true; }); describe('setup hook', function () { - let ext; - - before(function () { - ext = new NGINX(); - }); - - it('Doesn\'t run on local installs', function () { - const asStub = sinon.stub(); - const cmd = {addStage: asStub}; - ext.setup(cmd, {local: true}); - - expect(asStub.called).to.be.false; - }); - - it('Adds nginx and ssl substages', function () { - const asStub = sinon.stub(); - const cmd = {addStage: asStub}; - ext.setup(cmd, {}); + function tasks(i) { + const inst = new Nginx({}, {}, {}, '/some/dir'); + const result = inst.setup(); + expect(result).to.have.length(2); + return {inst, task: result[i]}; + } - expect(asStub.calledTwice).to.be.true; - expect(asStub.args[0][0]).to.equal('nginx'); - expect(asStub.args[1][0]).to.equal('ssl'); - expect(asStub.args[1][2]).to.equal('nginx'); + it('nginx', function () { + const {task, inst} = tasks(0); + const nginxStub = sinon.stub(inst, 'setupNginx'); + + // Check nginx + expect(task.id).to.equal('nginx'); + expect(task.name).to.equal('Nginx'); + expect(task.enabled({argv: {local: false}})).to.be.true; + expect(task.enabled({argv: {local: true}})).to.be.false; + + task.task('some', 'args'); + expect(nginxStub.calledOnce).to.be.true; + expect(nginxStub.calledWithExactly('some', 'args')).to.be.true; + + const supportedStub = sinon.stub(inst, 'isSupported').returns(false); + const exists = sinon.stub(fs, 'existsSync').returns(true); + const config = configStub(); + const ctx = {instance: {config}}; + + function reset() { + exists.resetHistory(); + config.get.reset(); + } + + expect(task.skip(ctx)).to.contain('Nginx is not installed.'); + expect(config.get.called).to.be.false; + expect(supportedStub.calledOnce).to.be.true; + expect(exists.called).to.be.false; + + reset(); + supportedStub.returns(true); + config.get.returns('http://localhost:2368'); + expect(task.skip(ctx)).to.contain('Your url contains a port.'); + expect(config.get.called).to.be.true; + expect(exists.called).to.be.false; + + reset(); + config.get.returns('https://ghost.dev'); + expect(task.skip(ctx)).to.contain('Nginx configuration already found for this url.'); + expect(config.get.called).to.be.true; + expect(exists.calledOnce).to.be.true; + + reset(); + config.get.returns('https://ghost.dev'); + exists.returns(false); + expect(task.skip(ctx)).to.be.false; + expect(config.get.called).to.be.true; + expect(exists.calledOnce).to.be.true; + }); + + it('ssl', function () { + const ip = sinon.stub(); + const {task, inst} = tasks(1); + const stub = sinon.stub(inst, 'setupSSL'); + + // Check SSL + expect(task.id).to.equal('ssl'); + expect(task.name).to.equal('SSL'); + expect(task.enabled({argv: {local: false}})).to.be.true; + expect(task.enabled({argv: {local: true}})).to.be.false; + + task.task('some', 'args'); + expect(stub.calledOnce).to.be.true; + expect(stub.calledWithExactly('some', 'args')).to.be.true; + + const isSkipped = sinon.stub().returns(false); + const hasFailed = sinon.stub().returns(false); + const exists = sinon.stub(fs, 'existsSync'); + const config = configStub(); + + const nginx = {isSkipped, hasFailed}; + const context = {tasks: {nginx}, instance: {config}}; + + isSkipped.returns(true); + expect(task.skip(context)).to.contain('Nginx setup task was skipped'); + + isSkipped.returns(false); + hasFailed.returns(true); + expect(task.skip(context)).to.contain('Nginx setup task failed'); + + hasFailed.returns(false); + ip.returns(true); + config.get.returns('http://127.0.0.1'); + expect(task.skip(context)).to.contain('SSL certs cannot be generated for IP addresses'); + expect(exists.called).to.be.false; + + ip.returns(false); + exists.returns(true); + config.get.returns('http://ghost.dev'); + expect(task.skip(context)).to.contain('SSL has already been set up'); + expect(exists.calledOnce).to.be.true; + + exists.reset(); + exists.returns(false); + expect(task.skip(Object.assign({argv: {prompt: false}}, context))).to.contain('SSL email must be provided'); + expect(exists.calledOnce).to.be.true; + + const argv = {prompt: true, sslemail: 'test@ghost.org'}; + + exists.resetHistory(); + expect(task.skip(Object.assign({argv, single: true}, context))).to.contain('Nginx config file does not exist'); + expect(exists.calledTwice).to.be.true; + + exists.resetHistory(); + expect(task.skip(Object.assign({argv, single: false}, context))).to.be.true; + expect(exists.calledTwice).to.be.true; + + exists.reset(); + exists.onFirstCall().returns(false); + exists.onSecondCall().returns(true); + expect(task.skip(Object.assign({argv}, context))).to.be.false; + expect(exists.calledTwice).to.be.true; }); }); describe('setupNginx', function () { - let ext; - let ctx; - const task = {skip: sinon.stub()}; - - beforeEach(function () { - ext = new NGINX(); - ext = addStubs(ext); - ctx = { - instance: { - config: {get: sinon.stub().callsFake(_get)} - } - }; - }); - - afterEach(function () { - task.skip.reset(); - }); - - it('Checks if nginx is installed & breaks if not', function () { - ext.isSupported = sinon.stub().returns(false); - ext.setupNginx(null, null, task); - - expect(ext.isSupported.calledOnce).to.be.true; - expect(ext.ui.log.calledOnce).to.be.true; - expect(ext.ui.log.args[0][0]).to.match(/not installed/); - expect(task.skip.calledOnce).to.be.true; - }); - - it('Notifies if URL contains a port & breaks', function () { - const get = sinon.stub().returns(`${testURL}:3000`); - const log = ext.ui.log; - ctx.instance.config.get = get; - ext.setupNginx(null, ctx, task); - - expect(get.calledOnce).to.be.true; - expect(log.calledOnce).to.be.true; - expect(log.args[0][0]).to.match(/contains a port/); - expect(task.skip.calledOnce).to.be.true; - }); - - // This is 2 tests in one, because checking the proper file name via a test requires - // very similar logic to nginx not running setup if the config file already exists - it('Generates correct filename & breaks if already configured', function () { - const expectedFile = `${nginxBase}available/ghost.dev.conf`; - const existsStub = sinon.stub().returns(true); - const ext = proxyNginx({'fs-extra': {existsSync: existsStub}}); - ext.setupNginx(null, ctx, task); - const log = ext.ui.log; - - expect(existsStub.calledOnce).to.be.true; - expect(existsStub.args[0][0]).to.equal(expectedFile); - expect(log.calledOnce).to.be.true; - expect(log.args[0][0]).to.match(/configuration already found/); - expect(task.skip.calledOnce).to.be.true; - }); + const config = configStub(); + config.get.callsFake(_get); it('Generates the proper config', function () { const name = 'ghost.dev.conf'; const lnExp = new RegExp(`(?=^ln -sf)(?=.*available/${name})(?=.*enabled/${name}$)`); - ctx.instance.dir = dir; const expectedConfig = { url: 'ghost.dev', webroot: `${dir}/system/nginx-root`, @@ -195,7 +232,7 @@ describe('Unit: Extensions > Nginx', function () { const sudo = sinon.stub().resolves(); ext.ui.sudo = sudo; - return ext.setupNginx(null, ctx, task).then(() => { + return ext.setupNginx({instance: {config, dir}}).then(() => { expect(templateStub.calledOnce).to.be.true; expect(loadStub.calledOnce).to.be.true; expect(loadStub.args[0][0]).to.deep.equal(expectedConfig); @@ -206,16 +243,10 @@ describe('Unit: Extensions > Nginx', function () { // Testing handling of subdirectory installations loadStub.reset(); - ctx.instance.config.get = (key) => { - if (key === 'url') { - return `${testURL}/a/b/c`; - } else { - return _get(key); - } - }; + config.get.withArgs('url').returns(`${testURL}/a/b/c`); expectedConfig.location = '^~ /a/b/c'; - return ext.setupNginx(null, ctx, task).then(() => { + return ext.setupNginx({instance: {config, dir}}).then(() => { expect(loadStub.calledOnce).to.be.true; expect(loadStub.args[0][0]).to.deep.equal(expectedConfig); }); @@ -225,7 +256,6 @@ describe('Unit: Extensions > Nginx', function () { it('passes the error if it\'s already a CliError', function () { const name = 'ghost.dev.conf'; const lnExp = new RegExp(`(?=^ln -sf)(?=.*available/${name})(?=.*enabled/${name}$)`); - ctx.instance.dir = dir; const loadStub = sinon.stub().returns('nginx config file'); const templateStub = sinon.stub().returns(loadStub); const ext = proxyNginx({ @@ -240,7 +270,7 @@ describe('Unit: Extensions > Nginx', function () { ext.ui.sudo = sudo; ext.restartNginx = sinon.stub().rejects(new errors.CliError('Did not restart')); - return ext.setupNginx(null, ctx, task).then(() => { + return ext.setupNginx({instance: {config, dir}}).then(() => { expect(false, 'Promise should have rejected').to.be.true; }).catch((error) => { expect(error).to.exist; @@ -257,7 +287,6 @@ describe('Unit: Extensions > Nginx', function () { it('returns a ProcessError when symlink command fails', function () { const name = 'ghost.dev.conf'; const lnExp = new RegExp(`(?=^ln -sf)(?=.*available/${name})(?=.*enabled/${name}$)`); - ctx.instance.dir = dir; const loadStub = sinon.stub().returns('nginx config file'); const templateStub = sinon.stub().returns(loadStub); const ext = proxyNginx({ @@ -271,7 +300,7 @@ describe('Unit: Extensions > Nginx', function () { ext.template = sinon.stub().resolves(); ext.ui.sudo = sudo; - return ext.setupNginx(null, ctx, task).then(() => { + return ext.setupNginx({instance: {config, dir}}).then(() => { expect(false, 'Promise should have rejected').to.be.true; }).catch((error) => { expect(error).to.exist; @@ -287,87 +316,13 @@ describe('Unit: Extensions > Nginx', function () { describe('setupSSL', function () { let stubs; - let task; - let ctx; - let ext; - - beforeEach(function () { - stubs = { - es: sinon.stub().returns(false), - skip: sinon.stub() - }; - task = {skip: stubs.skip}; - ctx = {instance: {config: {get: _get}}}; - ext = proxyNginx({'fs-extra': {existsSync: stubs.es}}); - }); - - it('skips if the url is an IP address', function () { - ctx = {instance: {config: {get: () => 'http://10.0.0.1'}}}; - ext.setupSSL({}, ctx, task); - - expect(stubs.es.calledOnce).to.be.false; - expect(ext.ui.log.calledOnce).to.be.true; - expect(ext.ui.log.args[0][0]).to.match(/SSL certs cannot be generated for IP addresses/); - expect(stubs.skip.calledOnce).to.be.true; - }); - - it('Breaks if ssl config already exists', function () { - const sslFile = '/etc/nginx/sites-available/ghost.dev-ssl.conf'; - const existsStub = sinon.stub().returns(true); - ext = proxyNginx({'fs-extra': {existsSync: existsStub}}); - ext.setupSSL(null, ctx, task); - const log = ext.ui.log; - - expect(existsStub.calledOnce).to.be.true; - expect(existsStub.args[0][0]).to.equal(sslFile); - expect(log.calledOnce).to.be.true; - expect(log.args[0][0]).to.match(/SSL has /); - expect(stubs.skip.calledOnce).to.be.true; - }); - - it('Errors when email cannot be retrieved', function () { - ext.setupSSL({}, ctx, task); - - expect(stubs.es.calledOnce).to.be.true; - expect(ext.ui.log.calledOnce).to.be.true; - expect(ext.ui.log.args[0][0]).to.match(/SSL email must be provided/); - expect(stubs.skip.calledOnce).to.be.true; - }); - - it('Breaks if http config doesn\'t exist (single & multiple)', function () { - ext.setupSSL({prompt: true}, ctx, task); - - expect(stubs.es.calledTwice).to.be.true; - expect(ext.ui.log.called).to.be.false; - expect(stubs.skip.calledOnce).to.be.true; - - // Test w/ singular context - stubs.es.reset(); - stubs.skip.reset(); - ext.ui.log.reset(); - ctx.single = true; - ext.setupSSL({prompt: true}, ctx, task); - - expect(stubs.es.calledTwice, '1').to.be.true; - expect(ext.ui.log.calledOnce, '2').to.be.true; - expect(ext.ui.log.args[0][0]).to.match(/Nginx config file/); - expect(stubs.skip.calledOnce, '4').to.be.true; - }); - }); - - describe('setupSSL > Subtasks', function () { - let stubs; - let task; let ctx; let proxy; const fsExp = new RegExp(/-ssl/); - function getTasks(ext, args) { - args = args || {}; - args.prompt = true; - ext.setupSSL(args, ctx, task); + function getTasks(ext, argv = {}) { + ext.setupSSL(Object.assign({argv}, ctx)); - expect(task.skip.called, 'getTasks: task.skip').to.be.false; expect(ext.ui.log.called, 'getTasks: ui.log').to.be.false; expect(ext.ui.listr.calledOnce, 'getTasks: ui.listr').to.be.true; return ext.ui.listr.args[0][0]; @@ -375,10 +330,8 @@ describe('Unit: Extensions > Nginx', function () { beforeEach(function () { stubs = { - es: sinon.stub().callsFake(value => !(fsExp).test(value)), - skip: sinon.stub() + es: sinon.stub().callsFake(value => !(fsExp).test(value)) }; - task = {skip: stubs.skip}; ctx = { instance: { config: {get: _get}, @@ -412,14 +365,13 @@ describe('Unit: Extensions > Nginx', function () { const log = ext.ui.log; let firstSet = false; - return tasks[0].task(ctx).then(() => { - expect(log.called).to.be.true; - expect(log.args[0][0]).to.match(/domain isn't set up correctly/); - expect(ctx.dnsfail).to.be.true; + return tasks[0].task().then(() => { + expect(true, 'task should have errored').to.be.false; + }).catch((error) => { + expect(error).to.be.an.instanceof(errors.CliError); + expect(error.message).to.contain('your domain isn\'t set up correctly'); DNS.code = 'PEACHESARETASTY'; - log.reset(); - ctx = {}; firstSet = true; return tasks[0].task(ctx); }).then(() => { @@ -433,27 +385,6 @@ describe('Unit: Extensions > Nginx', function () { expect(ctx.dnsfail).to.not.exist; }); }); - - it('Everything skips when DNS fails', function () { - stubs.es.callsFake(val => (val.indexOf('-ssl') < 0 || val.indexOf('acme') >= 0)); - - const ctx = {dnsfail: true}; - const ext = proxyNginx(proxy); - const tasks = getTasks(ext); - - expect(tasks[1].skip(ctx)).to.be.true; - expect(tasks[2].skip(ctx)).to.be.true; - expect(tasks[2].skip(ctx)).to.be.true; - expect(tasks[3].skip(ctx)).to.be.true; - expect(tasks[4].skip(ctx)).to.be.true; - expect(tasks[5].skip(ctx)).to.be.true; - expect(tasks[6].skip(ctx)).to.be.true; - expect(tasks[7].skip(ctx)).to.be.true; - ctx.dnsfail = false; - // These are FS related validators - expect(tasks[4].skip(ctx)).to.be.true; - expect(tasks[5].skip(ctx)).to.be.true; - }); }); describe('Email', function () { @@ -527,9 +458,12 @@ describe('Unit: Extensions > Nginx', function () { }); it('Uses OpenSSL (and skips if already exists)', function () { + proxy['fs-extra'].existsSync = () => true; const ext = proxyNginx(proxy); const tasks = getTasks(ext); + expect(tasks[4].skip()).to.be.true; + return tasks[4].task().then(() => { expect(ext.ui.sudo.calledOnce).to.be.true; expect(ext.ui.sudo.args[0][0]).to.match(/openssl dhparam/); @@ -537,10 +471,12 @@ describe('Unit: Extensions > Nginx', function () { }); it('Rejects when command fails', function () { + proxy['fs-extra'].existsSync = () => false; const ext = proxyNginx(proxy); ext.ui.sudo.rejects(new Error('Go ask George')); const tasks = getTasks(ext); + expect(tasks[4].skip()).to.be.false; return tasks[4].task().then(() => { expect(false, 'Promise should have rejected').to.be.true; }).catch((err) => { @@ -557,10 +493,12 @@ describe('Unit: Extensions > Nginx', function () { }); it('Writes & moves file to proper location', function () { + proxy['fs-extra'].existsSync = () => true; const ext = proxyNginx(proxy); const tasks = getTasks(ext); const expectedSudo = new RegExp(/(?=^mv)(?=.*snippets\/ssl-params\.conf)/); + expect(tasks[5].skip()).to.be.true; return tasks[5].task().then(() => { expect(ext.ui.sudo.calledOnce).to.be.true; expect(ext.ui.sudo.args[0][0]).to.match(expectedSudo); @@ -568,10 +506,12 @@ describe('Unit: Extensions > Nginx', function () { }); it('Throws an error when moving fails', function () { + proxy['fs-extra'].existsSync = () => false; const ext = proxyNginx(proxy); ext.ui.sudo.rejects(new Error('Potato')); const tasks = getTasks(ext); + expect(tasks[5].skip()).to.be.false; return tasks[5].task().then(() => { expect(false, 'Promise should have been rejected').to.be.true; }).catch((err) => { @@ -663,125 +603,117 @@ describe('Unit: Extensions > Nginx', function () { const instance = {config: {get: () => 'http://ghost.dev'}}; const testEs = val => (new RegExp(/-ssl/)).test(val); + function stub() { + const ui = {sudo: sinon.stub(), log: sinon.stub()}; + const exists = sinon.stub(fs, 'existsSync'); + const inst = new Nginx(ui, {}, {}, '/some/dir'); + const restartNginx = sinon.stub(inst, 'restartNginx'); + + return {inst, exists, restartNginx, ui}; + } + it('returns if no url exists in config', function () { const config = {get: () => undefined}; - const existsSync = sinon.stub().returns(false); - const ext = proxyNginx({'fs-extra': {existsSync}}); + const {exists, inst, restartNginx} = stub(); - return ext.uninstall({config}).then(() => { - expect(existsSync.called).to.be.false; - expect(ext.restartNginx.called).to.be.false; + return inst.uninstall({config}).then(() => { + expect(exists.called).to.be.false; + expect(restartNginx.called).to.be.false; }); }); it('Leaves nginx alone when no config file exists', function () { - const esStub = sinon.stub().returns(false); - const ext = proxyNginx({'fs-extra': {existsSync: esStub}}); + const {exists, inst, restartNginx} = stub(); + exists.returns(false); - return ext.uninstall(instance).then(() => { - expect(esStub.calledTwice).to.be.true; - expect(ext.restartNginx.called).to.be.false; + return inst.uninstall(instance).then(() => { + expect(exists.calledTwice).to.be.true; + expect(restartNginx.called).to.be.false; }); }); it('Removes http config', function () { const sudoExp = new RegExp(/(available|enabled)\/ghost\.dev\.conf/); - const esStub = sinon.stub().callsFake(val => !testEs(val)); - const ext = proxyNginx({'fs-extra': {existsSync: esStub}}); + const {exists, inst, restartNginx, ui} = stub(); + exists.callsFake(val => !testEs(val)); - return ext.uninstall(instance).then(() => { - expect(ext.ui.sudo.calledTwice).to.be.true; - expect(ext.ui.sudo.args[0][0]).to.match(sudoExp); - expect(ext.ui.sudo.args[1][0]).to.match(sudoExp); - expect(ext.restartNginx.calledOnce).to.be.true; + return inst.uninstall(instance).then(() => { + expect(ui.sudo.calledTwice).to.be.true; + expect(ui.sudo.args[0][0]).to.match(sudoExp); + expect(ui.sudo.args[1][0]).to.match(sudoExp); + expect(restartNginx.calledOnce).to.be.true; }); }); it('Removes https config', function () { const sudoExp = new RegExp(/(available|enabled)\/ghost\.dev-ssl\.conf/); - const esStub = sinon.stub().callsFake(testEs); - const ext = proxyNginx({'fs-extra': {existsSync: esStub}}); + const {exists, inst, restartNginx, ui} = stub(); + exists.callsFake(testEs); - return ext.uninstall(instance).then(() => { - expect(ext.ui.sudo.calledTwice).to.be.true; - expect(ext.ui.sudo.args[0][0]).to.match(sudoExp); - expect(ext.ui.sudo.args[1][0]).to.match(sudoExp); - expect(ext.restartNginx.calledOnce).to.be.true; + return inst.uninstall(instance).then(() => { + expect(ui.sudo.calledTwice).to.be.true; + expect(ui.sudo.args[0][0]).to.match(sudoExp); + expect(ui.sudo.args[1][0]).to.match(sudoExp); + expect(restartNginx.calledOnce).to.be.true; }); }); it('Handles symlink removal fails smoothly', function () { - const urlStub = sinon.stub().returns('http://ghost.dev'); - const instance = {config: {get: urlStub}}; - const esStub = sinon.stub().returns(true); - const NGINX = proxyquire(modulePath, {'fs-extra': {existsSync: esStub}}); - const ext = new NGINX(); - ext.ui = {sudo: sinon.stub().rejects(), log: sinon.stub()}; - ext.restartNginx = sinon.stub(); - - return ext.uninstall(instance).then(() => { + const {exists, inst, restartNginx, ui} = stub(); + exists.returns(true); + ui.sudo.rejects(); + + return inst.uninstall(instance).then(() => { expect(false, 'A rejection should have happened').to.be.true; }).catch((error) => { - sinon.assert.callCount(ext.ui.sudo, 4); + expect(ui.sudo.callCount).to.equal(4); expect(error).to.be.an.instanceof(errors.CliError); expect(error.message).to.match(/Nginx config file/); - expect(ext.restartNginx.calledOnce).to.be.false; + expect(restartNginx.calledOnce).to.be.false; }); }); }); describe('restartNginx', function () { - let ext; + const sudo = sinon.stub(); + const inst = new Nginx({sudo}, {}, {}, '/some/dir'); - beforeEach(function () { - ext = new NGINX(); - ext.ui = {sudo: sinon.stub().resolves()}; - }); + afterEach(() => sudo.reset()); it('Soft reloads nginx', function () { - const sudo = ext.ui.sudo; - ext.restartNginx(); + sudo.resolves(); - expect(sudo.calledOnce).to.be.true; - expect(sudo.args[0][0]).to.match(/nginx -s reload/); + return inst.restartNginx().then(() => { + expect(sudo.calledOnce).to.be.true; + expect(sudo.calledWithExactly('nginx -s reload')).to.be.true; + }); }); - it('Throws an Error when nginx does', function () { - ext.ui.sudo.rejects('ssl error or something'); - const sudo = ext.ui.sudo; + it('throws an error when nginx does', function () { + const err = new Error('ssl error'); + sudo.rejects(err); - return ext.restartNginx().then(function () { + return inst.restartNginx().then(() => { expect(false, 'An error should have been thrown').to.be.true; - }).catch(function (err) { + }).catch((error) => { expect(sudo.calledOnce).to.be.true; - expect(sudo.args[0][0]).to.match(/nginx -s reload/); - expect(err).to.be.ok; - expect(err).to.be.instanceof(errors.CliError); + expect(sudo.calledWithExactly('nginx -s reload')).to.be.true; + expect(error).to.be.an.instanceof(errors.CliError); + expect(error.message).to.equal('Failed to restart Nginx.'); }); }); }); - describe('isSupported', function () { - it('Calls dpkg', function () { - const shellStub = sinon.stub().resolves(); - const NGINX = proxyquire(modulePath,{execa: {shellSync: shellStub}}); - const ext = new NGINX(); + it('isSupported fn', function () { + const shell = sinon.stub(execa, 'shellSync'); + const inst = new Nginx({}, {}, {}, '/some/dir'); - ext.isSupported(); + expect(inst.isSupported()).to.be.true; + expect(shell.calledOnce).to.be.true; - expect(shellStub.calledOnce).to.be.true; - expect(shellStub.args[0][0]).to.match(/dpkg -l \| grep nginx/); - }); - - it('Returns false when dpkg fails', function () { - const shellStub = sinon.stub().throws('uh oh'); - const NGINX = proxyquire(modulePath,{execa: {shellSync: shellStub}}); - const ext = new NGINX(); - - const isSupported = ext.isSupported(); - - expect(shellStub.calledOnce).to.be.true; - expect(isSupported).to.be.false; - }); + shell.reset(); + shell.throws(new Error()); + expect(inst.isSupported()).to.be.false; + expect(shell.calledOnce).to.be.true; }); }); diff --git a/extensions/systemd/index.js b/extensions/systemd/index.js index 593211b04..15ef53090 100644 --- a/extensions/systemd/index.js +++ b/extensions/systemd/index.js @@ -10,30 +10,31 @@ const {Extension, errors} = require('../../lib'); const {ProcessError, SystemError} = errors; class SystemdExtension extends Extension { - setup(cmd, argv) { - const instance = this.system.getInstance(); + setup() { + return [{ + id: 'systemd', + name: 'Systemd', + enabled: ({instance, argv}) => !argv.local && instance.config.get('process') === 'systemd', + task: (...args) => this._setup(...args), + skip: ({instance}) => { + if (fs.existsSync(`/lib/systemd/system/ghost_${instance.name}.service`)) { + return 'Systemd service has already been set up. Skipping Systemd setup'; + } - if (!argv.local && instance.config.get('process') === 'systemd') { - cmd.addStage('systemd', this._setup.bind(this), [], 'Systemd'); - } + return false; + } + }]; } - _setup(argv, {instance}, task) { + _setup({instance}, task) { const uid = getUid(instance.dir); // getUid returns either the uid or null if (!uid) { - this.ui.log('The "ghost" user has not been created, try running `ghost setup linux-user` first', 'yellow'); - return task.skip(); + return task.skip('The "ghost" user has not been created, try running `ghost setup linux-user` first'); } const serviceFilename = `ghost_${instance.name}.service`; - - if (fs.existsSync(path.join('/lib/systemd/system', serviceFilename))) { - this.ui.log('Systemd service has already been set up. Skipping Systemd setup'); - return task.skip(); - } - const service = template(fs.readFileSync(path.join(__dirname, 'ghost.service.template'), 'utf8')); const contents = service({ name: instance.name, diff --git a/extensions/systemd/test/extension-spec.js b/extensions/systemd/test/extension-spec.js index 5723c86f8..b3c89529e 100644 --- a/extensions/systemd/test/extension-spec.js +++ b/extensions/systemd/test/extension-spec.js @@ -3,53 +3,58 @@ const expect = require('chai').expect; const sinon = require('sinon'); const path = require('path'); const proxyquire = require('proxyquire').noCallThru(); +const configStub = require('../../../test/utils/config-stub'); + +const fs = require('fs-extra'); const modulePath = '../index'; const errors = require('../../../lib/errors'); +const SystemdExtension = require('../index'); describe('Unit: Systemd > Extension', function () { - describe('setup hook', function () { - const SystemdExtension = require(modulePath); + afterEach(function () { + sinon.restore(); + }); - it('skips adding stage if argv.local is true', function () { - const configStub = sinon.stub().returns('systemd'); - const instanceStub = sinon.stub().returns({config: {get: configStub}}); - const addStageStub = sinon.stub(); + it('setup hook', function () { + const inst = new SystemdExtension({}, {}, {}, '/some/dir'); + const tasks = inst.setup(); - const testInstance = new SystemdExtension({}, {getInstance: instanceStub}, {}, path.join(__dirname, '..')); + expect(tasks).to.have.length(1); + expect(tasks[0].id).to.equal('systemd'); + expect(tasks[0].name).to.equal('Systemd'); - testInstance.setup({addStage: addStageStub}, {local: true}); - expect(instanceStub.calledOnce).to.be.true; - expect(addStageStub.calledOnce).to.be.false; - expect(configStub.calledOnce).to.be.false; - }); + const [{enabled, task, skip}] = tasks; + const config = configStub(); + const instance = {config, name: 'test_instance'}; - it('skips adding stage if process is not systemd', function () { - const configStub = sinon.stub().returns('local'); - const instanceStub = sinon.stub().returns({config: {get: configStub}}); - const addStageStub = sinon.stub(); + config.get.withArgs('process').returns('notsystemd'); + expect(enabled({argv: {local: true}, instance})).to.be.false; + expect(config.get.called).to.be.false; - const testInstance = new SystemdExtension({}, {getInstance: instanceStub}, {}, path.join(__dirname, '..')); + expect(enabled({argv: {local: false}, instance})).to.be.false; + expect(config.get.calledOnce).to.be.true; - testInstance.setup({addStage: addStageStub}, {local: false}); - expect(instanceStub.calledOnce).to.be.true; - expect(configStub.calledOnce).to.be.true; - expect(addStageStub.calledOnce).to.be.false; - }); + config.get.withArgs('process').returns('systemd'); + config.get.resetHistory(); + expect(enabled({argv: {local: false}, instance})).to.be.true; + expect(config.get.calledOnce).to.be.true; - it('adds stage if local is not true and process is systemd', function () { - const configStub = sinon.stub().returns('systemd'); - const instanceStub = sinon.stub().returns({config: {get: configStub}}); - const addStageStub = sinon.stub(); + const stub = sinon.stub(inst, '_setup').returns({stubCalled: true}); + expect(task('some', 'args')).to.deep.equal({stubCalled: true}); + expect(stub.calledOnceWithExactly('some', 'args')).to.be.true; - const testInstance = new SystemdExtension({}, {getInstance: instanceStub}, {}, path.join(__dirname, '..')); + const exists = sinon.stub(fs, 'existsSync'); + exists.returns(true); - testInstance.setup({addStage: addStageStub}, {local: false}); - expect(instanceStub.calledOnce).to.be.true; - expect(configStub.calledOnce).to.be.true; - expect(addStageStub.calledOnce).to.be.true; - expect(addStageStub.calledWith('systemd')).to.be.true; - }); + expect(skip({instance})).to.contain('Systemd service has already been set up'); + expect(exists.calledOnceWithExactly('/lib/systemd/system/ghost_test_instance.service')).to.be.true; + + exists.returns(false); + exists.resetHistory(); + + expect(skip({instance})).to.be.false; + expect(exists.calledOnce).to.be.true; }); describe('setup stage', function () { @@ -60,50 +65,23 @@ describe('Unit: Systemd > Extension', function () { './get-uid': uidStub }); - const logStub = sinon.stub(); - const skipStub = sinon.stub(); - const testInstance = new SystemdExtension({log: logStub}, {}, {}, path.join(__dirname, '..')); - - testInstance._setup({}, {instance: {dir: '/some/dir'}}, {skip: skipStub}); - expect(uidStub.calledOnce).to.be.true; - expect(uidStub.calledWithExactly('/some/dir')).to.be.true; - expect(logStub.calledOnce).to.be.true; - expect(logStub.args[0][0]).to.match(/"ghost" user has not been created/); - expect(skipStub.calledOnce).to.be.true; - }); - - it('skips stage if systemd file already exists', function () { - const uidStub = sinon.stub().returns(true); - const existsStub = sinon.stub().returns(true); - - const SystemdExtension = proxyquire(modulePath, { - './get-uid': uidStub, - 'fs-extra': {existsSync: existsStub} - }); - - const logStub = sinon.stub(); const skipStub = sinon.stub(); - const testInstance = new SystemdExtension({log: logStub}, {}, {}, path.join(__dirname, '..')); - const instance = {dir: '/some/dir', name: 'test'}; + const testInstance = new SystemdExtension({}, {}, {}, path.join(__dirname, '..')); - testInstance._setup({}, {instance: instance}, {skip: skipStub}); + testInstance._setup({instance: {dir: '/some/dir'}}, {skip: skipStub}); expect(uidStub.calledOnce).to.be.true; expect(uidStub.calledWithExactly('/some/dir')).to.be.true; - expect(existsStub.calledOnce).to.be.true; - expect(existsStub.calledWithExactly('/lib/systemd/system/ghost_test.service')).to.be.true; - expect(logStub.calledOnce).to.be.true; - expect(logStub.args[0][0]).to.match(/Systemd service has already been set up/); expect(skipStub.calledOnce).to.be.true; + expect(skipStub.args[0][0]).to.contain('"ghost" user has not been created'); }); it('runs through template method and reloads daemon', function () { const uidStub = sinon.stub().returns(true); - const existsStub = sinon.stub().returns(false); const readFileSyncStub = sinon.stub().returns('SOME TEMPLATE CONTENTS'); const SystemdExtension = proxyquire(modulePath, { './get-uid': uidStub, - 'fs-extra': {existsSync: existsStub, readFileSync: readFileSyncStub} + 'fs-extra': {readFileSync: readFileSyncStub} }); const logStub = sinon.stub(); @@ -113,10 +91,9 @@ describe('Unit: Systemd > Extension', function () { const instance = {dir: '/some/dir', name: 'test'}; const templateStub = sinon.stub(testInstance, 'template').resolves(); - return testInstance._setup({}, {instance: instance}, {skip: skipStub}).then(() => { + return testInstance._setup({instance: instance}, {skip: skipStub}).then(() => { expect(uidStub.calledOnce).to.be.true; expect(uidStub.calledWithExactly('/some/dir')).to.be.true; - expect(existsStub.calledOnce).to.be.true; expect(readFileSyncStub.calledOnce).to.be.true; expect(templateStub.calledOnce).to.be.true; expect(templateStub.calledWith(instance, 'SOME TEMPLATE CONTENTS')).to.be.true; @@ -129,12 +106,11 @@ describe('Unit: Systemd > Extension', function () { it('can handle error when template method fails', function () { const uidStub = sinon.stub().returns(true); - const existsStub = sinon.stub().returns(false); const readFileSyncStub = sinon.stub().returns('SOME TEMPLATE CONTENTS'); const SystemdExtension = proxyquire(modulePath, { './get-uid': uidStub, - 'fs-extra': {existsSync: existsStub, readFileSync: readFileSyncStub} + 'fs-extra': {readFileSync: readFileSyncStub} }); const logStub = sinon.stub(); @@ -144,7 +120,7 @@ describe('Unit: Systemd > Extension', function () { const templateStub = sinon.stub(testInstance, 'template').resolves(); const instance = {dir: '/some/dir', name: 'test'}; - return testInstance._setup({}, {instance: instance}, {skip: skipStub}).then(() => { + return testInstance._setup({instance: instance}, {skip: skipStub}).then(() => { expect(false, 'Promise should have rejected').to.be.true; }).catch((error) => { expect(error).to.exist; @@ -152,7 +128,6 @@ describe('Unit: Systemd > Extension', function () { expect(error.options.stderr).to.be.equal('something went wrong'); expect(uidStub.calledOnce).to.be.true; expect(uidStub.calledWithExactly('/some/dir')).to.be.true; - expect(existsStub.calledOnce).to.be.true; expect(readFileSyncStub.calledOnce).to.be.true; expect(templateStub.calledOnce).to.be.true; expect(templateStub.calledWith(instance, 'SOME TEMPLATE CONTENTS')).to.be.true; diff --git a/lib/commands/setup.js b/lib/commands/setup.js index b44bdc930..b7c55404d 100644 --- a/lib/commands/setup.js +++ b/lib/commands/setup.js @@ -1,4 +1,5 @@ 'use strict'; +const path = require('path'); const Command = require('../command'); const StartCommand = require('./start'); const options = require('../tasks/configure/options'); @@ -21,180 +22,161 @@ class SetupCommand extends Command { yargs = StartCommand.configureOptions('start', yargs, extensions, true); } - constructor(ui, system) { - super(ui, system); - - this.stages = []; - } + localArgs(argv) { + const dbpath = argv.db ? null : path.join(process.cwd(), 'content/data/ghost-local.db'); + const args = Object.assign({ + url: 'http://localhost:2368', + pname: 'ghost-local', + process: 'local', + stack: false, + db: 'sqlite3', + start: true + }, argv); + + if (dbpath) { + args.dbpath = dbpath; + } - addStage(name, fn, dependencies, description) { - this.stages.push({ - name: name, - description: description || name, - dependencies: dependencies && (Array.isArray(dependencies) ? dependencies : [dependencies]), - fn: fn - }); + return args; } - run(argv) { - const os = require('os'); + tasks(steps) { const url = require('url'); - const path = require('path'); const semver = require('semver'); + const flatten = require('lodash/flatten'); const linux = require('../tasks/linux'); const migrator = require('../tasks/migrator'); const configure = require('../tasks/configure'); - if (argv.local) { - argv.url = argv.url || 'http://localhost:2368/'; - argv.pname = argv.pname || 'ghost-local'; - argv.process = 'local'; - argv.stack = false; + // This is used so we can denote built-in setup steps + // and disable the "do you wish to set up x?" prompt + // We use symbols so extension-defined tasks can't + // mark themselves as internal and skip the prompt + const internal = Symbol('internal setup step'); + const extSteps = flatten(steps.filter(Boolean)).filter(step => step.id && step.task); + + return [{ + id: 'config', + title: 'Configuring Ghost', + [internal]: true, + task: ({instance, argv, single}) => configure(this.ui, instance.config, argv, this.system.environment, !single) + }, { + id: 'instance', + [internal]: true, + task: ({instance, argv}) => { + instance.name = (argv.pname || url.parse(instance.config.get('url')).hostname).replace(/\./g, '-'); + this.system.addInstance(instance); - // If the user's already specified a db client, then we won't override it. - if (!argv.db) { - argv.db = argv.db || 'sqlite3'; - argv.dbpath = path.join(process.cwd(), 'content/data/ghost-local.db'); - } + // Ensure we set the content path when we set up the instance + if (!instance.config.has('paths.contentPath')) { + instance.config.set('paths.contentPath', path.join(instance.dir, 'content')).save(); + } + }, + enabled: ({instance}) => !instance.isSetup + }, { + id: 'linux-user', + name: '"ghost" system user', + [internal]: true, + task: linux, + enabled: ({argv}) => !argv.local && this.system.platform.linux && argv.process !== 'local' + }, ...extSteps, { + id: 'migrate', + title: 'Running database migrations', + [internal]: true, + task: migrator.migrate, + // Check argv.migrate for backwards compatibility + // CASE: Ghost > 2.0 runs migrations itself + enabled: ({instance, argv}) => argv.migrate !== false && semver.major(instance.version) < 2 + }].map((step) => { + const name = step.name || step.id; + const title = step.title || `Setting up ${name}`; + const origEnabled = step.enabled || (() => true); + const enabled = (ctx, ...args) => { + const {argv} = ctx; + + if (argv.stages.length) { + return argv.stages.includes(step.id) && origEnabled(ctx, ...args); + } + + if (argv[`setup-${step.id}`] === false) { + return false; + } - argv.start = (typeof argv.start === 'undefined') ? true : argv.start; + return origEnabled(ctx, ...args); + }; + const task = (ctx, task) => { + if (ctx.single || step[internal]) { + return step.task(ctx, task); + } + + return this.ui.confirm(`Do you wish to set up ${name}?`, true).then((confirmed) => { + if (!confirmed) { + return task.skip(); + } + + return step.task(ctx, task); + }); + }; + + return Object.assign({}, step, {title, enabled, task}); + }); + } + + run(argv) { + // Ensure stages is an array + argv.stages = argv.stages || []; + + const {local = false, stages} = argv; + + if (local) { + argv = this.localArgs(argv); // In the case that the user runs `ghost setup --local`, we want to make // sure we're set up in development mode this.system.setEnvironment(true, true); } - if (argv.stages && argv.stages.length) { - const instance = this.system.getInstance(); + const instance = this.system.getInstance(); + if (stages.length) { // If a user is running a specific setup stage (or stages), we want to run the env check here. // That way, if they haven't run setup at all for the particular environment that they're running // this stage for, then it will use the other one instance.checkEnvironment(); - - return this.system.hook('setup', this, argv).then(() => { - const tasks = this.stages.filter(stage => argv.stages.includes(stage.name)).map(stage => ({ - title: `Setting up ${stage.description}`, - task: (ctx, task) => stage.fn(argv, ctx, task) - })); - - // Special-case migrations - if (argv.stages.includes('migrate')) { - tasks.push({title: 'Running database migrations', task: migrator.migrate}); - } - - if (argv.stages.includes('linux-user')) { - if (os.platform() !== 'linux') { - this.ui.log('Operating system is not Linux, skipping Linux setup', 'yellow'); - } else { - // we want this to run first so we use unshift rather than push - tasks.unshift({ - title: 'Setting up "ghost" system user', - task: linux.bind(this) - }); - } - } - - return this.ui.listr(tasks, {single: true, instance: instance}); - }); } - const initialStages = [{ - title: 'Setting up instance', - task: (ctx) => { - ctx.instance = this.system.getInstance(); - ctx.instance.name = (argv.pname || url.parse(ctx.instance.config.get('url')).hostname).replace(/\./g, '-'); - this.system.addInstance(ctx.instance); - - // Ensure we set the content path when we set up the instance - if (!ctx.instance.config.has('paths.contentPath')) { - ctx.instance.config.set('paths.contentPath', path.join(ctx.instance.dir, 'content')).save(); - } - } - }]; - - if (!argv.local && argv['setup-linux-user'] !== false && os.platform() === 'linux' && argv.process !== 'local') { - initialStages.push({ - title: 'Setting up "ghost" system user', - task: linux.bind(this) + return this.system.hook('setup').then((steps) => { + const tasks = this.tasks(steps); + const listr = this.ui.listr(tasks, false, {exitOnError: false}); + const taskMap = listr.tasks.reduce( + (map, task, index) => Object.assign({[tasks[index].id]: task}, map), + {} + ); + + return listr.run({ + ui: this.ui, + system: this.system, + instance, + tasks: taskMap, + listr, + argv, + single: Boolean(stages.length) }); - } - - const instance = this.system.getInstance(); - - return this.ui.run( - () => configure(this.ui, instance.config, argv, this.system.environment), - 'Configuring Ghost' - ).then( - () => this.system.hook('setup', this, argv) - ).then(() => { - const taskMap = {}; - - const tasks = initialStages.concat(this.stages.map(stage => ({ - title: `Setting up ${stage.description}`, - task: (ctx, task) => { - taskMap[stage.name] = task; - - if (stage.dependencies) { - // TODO: this depends on Listr private API, probably should find a better way - const skipped = stage.dependencies.filter( - dep => !taskMap[dep] || taskMap[dep]._task.isSkipped() - ); - - if (skipped && skipped.length) { - const plural = skipped.length > 1; - this.ui.log(`Task ${stage.name} depends on the '${skipped.join('\', \'')}' ${plural ? 'stages' : 'stage'}, which ${plural ? 'were' : 'was'} skipped.`, 'gray'); - return task.skip(); - } - } - - if (argv[`setup-${stage.name}`] === false) { - return task.skip(); - } - - if (!argv.prompt) { - // Prompt has been disabled and there has not been a `--no-setup-` - // flag passed, so we will automatically run things - return stage.fn(argv, ctx, task); - } - - return this.ui.confirm(`Do you wish to set up ${stage.description}?`, true).then((confirmed) => { - if (!confirmed) { - return task.skip(); - } - - return stage.fn(argv, ctx, task); - }); - } - }))); - - if (argv.migrate !== false) { - // Tack on db migration task to the end - tasks.push({ - title: 'Running database migrations', - task: migrator.migrate, - // CASE: We are about to install Ghost 2.0. We moved the execution of knex-migrator into Ghost. - enabled: () => semver.major(instance.version) < 2 - }); + }).then(() => { + if (stages.length) { + // Don't start Ghost if we're running individual setup stages + return false; } - return this.ui.listr(tasks, {setup: true}).then(() => { - // If we are not allowed to prompt, set the default value, which should be true - if (!argv.prompt && typeof argv.start === 'undefined') { - argv.start = true; - } - - // If argv.start has a value, this means either --start or --no-start were explicitly provided - // (or --no-prompt was provided, and we already defaulted to true) - // In this case, we don't prompt, we use the value of argv.start - if (argv.start || argv.start === false) { - return Promise.resolve(argv.start); - } + // If argv.start has a value, this means either --start or --no-start were explicitly provided + // In this case, we don't prompt, we use the value of argv.start + if (argv.start || argv.start === false) { + return Promise.resolve(argv.start); + } - return this.ui.confirm('Do you want to start Ghost?', true); - }).then(confirmed => confirmed && this.runCommand(StartCommand, argv)); - }); + return this.ui.confirm('Do you want to start Ghost?', true); + }).then(confirmed => confirmed && this.runCommand(StartCommand, argv)); } } diff --git a/lib/tasks/linux.js b/lib/tasks/linux.js index 65e98e9a0..24de45a9d 100644 --- a/lib/tasks/linux.js +++ b/lib/tasks/linux.js @@ -3,7 +3,7 @@ const execa = require('execa'); const path = require('path'); -module.exports = function linuxSetupTask(ctx) { +module.exports = function linuxSetupTask({ui, instance}) { let userExists = false; try { @@ -14,13 +14,13 @@ module.exports = function linuxSetupTask(ctx) { // so we don't need to do any additional checking really } - return ctx.ui.listr([{ + return ui.listr([{ title: 'Creating "ghost" system user', skip: () => userExists, - task: () => ctx.ui.sudo('useradd --system --user-group ghost') + task: () => ui.sudo('useradd --system --user-group ghost') }, { title: 'Giving "ghost" user ownership of the /content/ directory', - task: () => ctx.ui.sudo(`chown -R ghost:ghost ${path.join(ctx.instance.dir, 'content')}`) + task: () => ui.sudo(`chown -R ghost:ghost ${path.join(instance.dir, 'content')}`) }], false); }; diff --git a/test/unit/commands/setup-spec.js b/test/unit/commands/setup-spec.js index 38692b8ad..34967e447 100644 --- a/test/unit/commands/setup-spec.js +++ b/test/unit/commands/setup-spec.js @@ -2,578 +2,422 @@ const {expect} = require('chai'); const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); -const Promise = require('bluebird'); const path = require('path'); const configStub = require('../../utils/config-stub'); +const UI = require('../../../lib/ui/index'); +const System = require('../../../lib/system'); + const modulePath = '../../../lib/commands/setup'; -const SetupCommand = proxyquire(modulePath, {os: {platform: () => 'linux'}}); +const SetupCommand = require(modulePath); describe('Unit: Commands > Setup', function () { - it('Constructor initializes stages', function () { - const setup = new SetupCommand({},{}); - expect(setup.stages).to.be.an('array'); - expect(setup.stages.length).to.equal(0); - }); - - it('addStage pushes to stages', function () { - const setup = new SetupCommand({},{}); - const expectedA = { - name: 'Eat', - description: 'Consume food', - dependencies: ['food', 'water'], - fn: () => true - }; - const expectedB = { - name: 'Sleep', - dependencies: 'bed', - fn: () => false + it('localArgs', function () { + const setup = new SetupCommand({}, {}); + + const argvA = { + local: true, + url: 'http://localhost:2369', + pname: 'local-ghost', + process: 'local', + db: 'mysql', + stack: false, + start: true }; - const expectedC = { - name: 'Code', - dependencies: [], - fn: () => false + const argvB = { + local: true, + url: 'http://localhost:2368', + pname: 'ghost-local', + process: 'local', + stack: false, + start: true, + db: 'sqlite3', + dbpath: path.join(process.cwd(), 'content/data/ghost-local.db') }; - const expectedD = { - name: 'Repeat', - fn: () => 'Foundation', - dependencies: null - }; - setup.addStage(expectedA.name, expectedA.fn, expectedA.dependencies, expectedA.description); - setup.addStage(expectedB.name, expectedB.fn, expectedB.dependencies, expectedB.description); - setup.addStage(expectedC.name, expectedC.fn, expectedC.dependencies, expectedC.description); - setup.addStage(expectedD.name, expectedD.fn, expectedD.dependencies, expectedD.description); - - expectedB.dependencies = ['bed']; - expectedB.description = 'Sleep'; - expectedC.description = 'Code'; - expectedD.description = 'Repeat'; - - expect(setup.stages.length).to.equal(4); - expect(setup.stages[0]).to.deep.equal(expectedA); - expect(setup.stages[1]).to.deep.equal(expectedB); - expect(setup.stages[2]).to.deep.equal(expectedC); - expect(setup.stages[3]).to.deep.equal(expectedD); - }); - - describe('run', function () { - it('Handles local setup properly', function () { - const argvA = { - local: true, - url: 'http://localhost:2369', - pname: 'local-ghost', - process: 'local', - db: 'mysql', - stack: false, - start: true - }; - const argvB = { - local: true, - url: 'http://localhost:2368/', - pname: 'ghost-local', - process: 'local', - stack: false, - start: true, - db: 'sqlite3', - dbpath: path.join(process.cwd(), 'content/data/ghost-local.db') - }; - let argVSpy = { - local: true, - url: 'http://localhost:2369', - pname: 'local-ghost', - process: 'local', - db: 'mysql' - }; - - const setup = new SetupCommand({}, {setEnvironment: () => { - throw new Error('Take a break'); - }}); - try { - setup.run(Object.assign(argVSpy, argvA)); - expect(false, 'An error should have been thrown').to.be.true; - } catch (error) { - expect(error.message).to.equal('Take a break'); - expect(argVSpy).to.deep.equal(argvA); - argVSpy = {local: true}; - } - - try { - setup.run(argVSpy); - expect(false, 'An error should have been thrown').to.be.true; - } catch (error) { - expect(error.message).to.equal('Take a break'); - expect(argVSpy).to.deep.equal(argvB); - argVSpy = {local: true, start: false}; - } + expect(setup.localArgs(argvA)).to.deep.equal(argvA); + expect(setup.localArgs({local: true})).to.deep.equal(argvB); + expect(setup.localArgs({local: true, start: false})).to.deep.equal(Object.assign(argvB, {start: false})); + }); - try { - setup.run(argVSpy); - expect(false, 'An error should have been thrown').to.be.true; - } catch (error) { - expect(error.message).to.equal('Take a break'); - expect(argVSpy).to.deep.equal(Object.assign(argvB, {start: false})); - } + describe('tasks', function () { + function getTasks(stubs = {}, steps = []) { + const Command = proxyquire(modulePath, stubs); + const ui = sinon.createStubInstance(UI); + const system = sinon.createStubInstance(System); + const setup = new Command(ui, system); + ui.confirm.resolves(false); + + const tasks = setup.tasks(steps); + return {tasks, ui, system, setup}; + } + + it('returns default tasks correctly', function () { + const {tasks} = getTasks(); + expect(tasks).to.have.length(4); + tasks.forEach((task) => { + expect(task).to.include.all.keys('id', 'task', 'enabled', 'title'); + }); }); - it('Hooks when stages are passed through', function () { - let stages = []; - const stubs = { - noCall: sinon.stub().resolves(), - important: sinon.stub().resolves(), - hook: sinon.stub().callsFake((n, ths) => { - ths.stages = stages; return Promise.resolve(); - }), - listr: sinon.stub().callsFake((tasks) => { - tasks.forEach(task => task.task()); - }) - }; - stages = [{name: 'nocall', fn: stubs.noCall}, {name: 'important', fn: stubs.important}]; - const system = { - getInstance: () => ({checkEnvironment: () => true}), - hook: stubs.hook - }; - const ui = {listr: stubs.listr}; - const setup = new SetupCommand(ui, system); - return setup.run({stages: ['important']}).then(() => { - expect(stubs.hook.calledOnce).to.be.true; - expect(stubs.noCall.called).to.be.false; - expect(stubs.important.calledOnce).to.be.true; - expect(stubs.listr.calledOnce).to.be.true; + it('wraps tasks correctly', function () { + const task1stub = sinon.stub().resolves(); + const task2stub = sinon.stub().resolves(); + const steps = [ + [{ + id: 'testing', + name: 'Testing', + task: task1stub + }, { + id: 'testing-2', + title: 'Custom Title', + enabled: () => false, + task: task2stub + }], null, [{ + notarealtask: true + }] + ]; + const {tasks, ui} = getTasks({}, steps); + + // there should only be 2 tasks added + expect(tasks).to.have.length(6); + + const task1 = tasks[3]; + const task2 = tasks[4]; + + expect(task1.id).to.equal('testing'); + expect(task1.name).to.equal('Testing'); + expect(task1.title).to.equal('Setting up Testing'); + + expect(task2.id).to.equal('testing-2'); + expect(task2.title).to.equal('Custom Title'); + + expect(task1.enabled({ + argv: {stages: ['testing']} + })).to.be.true; + expect(task1.enabled({ + argv: {'setup-testing': false, stages: []} + })).to.be.false; + expect(task1.enabled({argv: {stages: []}})); + + expect(task2.enabled({ + argv: {stages: ['testing-2']} + })).to.be.false; + expect(task2.enabled({ + argv: {'setup-testing-2': false, stages: []} + })).to.be.false; + expect(task2.enabled({ + argv: {stages: []} + })).to.be.false; + + const skip = sinon.stub().resolves(); + + return task1.task({single: true}).then(() => { + expect(ui.confirm.called).to.be.false; + expect(task1stub.calledOnce).to.be.true; + + task1stub.resetHistory(); + return task1.task({single: false}, {skip}); + }).then(() => { + expect(ui.confirm.calledOnceWithExactly('Do you wish to set up Testing?', true)).to.be.true; + expect(task1stub.called).to.be.false; + expect(skip.calledOnce).to.be.true; + + skip.resetHistory(); + ui.confirm.resetHistory(); + ui.confirm.resolves(true); + return task1.task({single: false}, {skip}); + }).then(() => { + expect(ui.confirm.calledOnceWithExactly('Do you wish to set up Testing?', true)).to.be.true; + expect(task1stub.calledOnce).to.be.true; + expect(skip.called).to.be.false; + + ui.confirm.reset(); + ui.confirm.resolves(false); + skip.resetHistory(); + + return task2.task({single: true}, {skip}); + }).then(() => { + expect(ui.confirm.called).to.be.false; + expect(task2stub.calledOnce).to.be.true; + + task2stub.resetHistory(); + return task2.task({single: false}, {skip}); + }).then(() => { + expect(ui.confirm.calledOnceWithExactly('Do you wish to set up testing-2?', true)).to.be.true; + expect(task2stub.called).to.be.false; + expect(skip.calledOnce).to.be.true; + + skip.resetHistory(); + ui.confirm.resetHistory(); + ui.confirm.resolves(true); + return task2.task({single: false}, {skip}); + }).then(() => { + expect(ui.confirm.calledOnceWithExactly('Do you wish to set up testing-2?', true)).to.be.true; + expect(task2stub.calledOnce).to.be.true; + expect(skip.called).to.be.false; }); }); - describe('special case migrations', function () { - it('db migrations', function () { - const system = { - getInstance: () => ({checkEnvironment: () => true}), - hook: () => Promise.resolve() - }; - const ui = { - listr: sinon.stub().callsFake((tasks) => { - expect(tasks[0].title).to.equal('Running database migrations'); - expect(tasks[0].task.name).to.equal('runMigrations'); - return Promise.resolve(); - }) - }; - const setup = new SetupCommand(ui, system); - - return setup.run({stages: ['migrate']}).then(() => { - expect(ui.listr.calledOnce).to.be.true; + describe('internal (default) tasks', function () { + it('config', function () { + const stub = sinon.stub(); + const instance = {config: {config: true}}; + const {tasks, ui, system} = getTasks({ + '../tasks/configure': stub }); + const [configTask] = tasks; + + expect(configTask.id).to.equal('config'); + expect(configTask.title).to.equal('Configuring Ghost'); + expect(stub.called).to.be.false; + + system.environment = 'testing'; + configTask.task({instance, argv: {thisisargs: true}, single: false}); + expect(stub.calledOnceWithExactly( + ui, + {config: true}, + {thisisargs: true}, + 'testing', + true + )).to.be.true; }); - it('linux-user', function () { - const system = { - getInstance: () => ({checkEnvironment: () => true}), - hook: () => Promise.resolve() - }; - const ui = { - listr: sinon.stub().callsFake((tasks) => { - expect(tasks[0].title).to.equal('Setting up "ghost" system user'); - expect(tasks[0].task.name).to.equal('bound linuxSetupTask'); - return Promise.resolve(); - }) - }; - const setup = new SetupCommand(ui, system); - - return setup.run({stages: ['linux-user']}).then(() => { - expect(ui.listr.calledOnce).to.be.true; - }); - }); + it('instance', function () { + const config = configStub(); + const instance = {config, dir: '/var/www/ghosttest'}; + const {tasks, system} = getTasks(); + const [,instanceTask] = tasks; + + expect(instanceTask.id).to.equal('instance'); + expect(instanceTask.title).to.equal('Setting up instance'); + + const argv = {stages: []}; + + // Check enabled fn + instance.isSetup = true; + expect(instanceTask.enabled({instance, argv})).to.be.false; + instance.isSetup = false; + expect(instanceTask.enabled({instance, argv})).to.be.true; + + // Test task + config.has.returns(false); + instanceTask.task({instance, argv: {pname: 'test-ghost'}, stages: []}); + expect(instance.name).to.equal('test-ghost'); + expect(config.get.called).to.be.false; + expect(system.addInstance.calledOnceWithExactly(instance)).to.be.true; + expect(config.has.calledOnceWithExactly('paths.contentPath')).to.be.true; + expect(config.set.calledOnceWithExactly('paths.contentPath', '/var/www/ghosttest/content')).to.be.true; + expect(config.save.calledOnce).to.be.true; - it('linux-user (on windows)', function () { - const system = { - getInstance: () => ({checkEnvironment: () => true}), - hook: () => Promise.resolve() - }; - const osStub = {platform: () => 'win32'}; - const ui = { - listr: sinon.stub().callsFake((tasks) => { - expect(tasks.length).to.equal(0); - return Promise.resolve(); - }), - log: sinon.stub() - }; - const SetupCommand = proxyquire(modulePath, {os: osStub}); - const setup = new SetupCommand(ui, system); - - return setup.run({stages: ['linux-user']}).then(() => { - expect(ui.log.calledOnce).to.be.true; - expect(ui.log.args[0][0]).to.match(/is not Linux/); - }); + config.has.returns(true); + config.has.resetHistory(); + config.set.resetHistory(); + config.save.resetHistory(); + system.addInstance.resetHistory(); + config.get.returns('https://example.com'); + instanceTask.task({instance, argv}); + expect(instance.name).to.equal('example-com'); + expect(config.get.calledOnceWithExactly('url')).to.be.true; + expect(system.addInstance.calledOnceWithExactly(instance)).to.be.true; + expect(config.has.calledOnceWithExactly('paths.contentPath')).to.be.true; + expect(config.set.called).to.be.false; + expect(config.save.called).to.be.false; }); - it('linux-user (on macos)', function () { - const system = { - getInstance: () => ({checkEnvironment: () => true}), - hook: () => Promise.resolve() - }; - const osStub = {platform: () => 'darwin'}; - const ui = { - listr: sinon.stub().callsFake((tasks) => { - expect(tasks.length).to.equal(0); - return Promise.resolve(); - }), - log: sinon.stub() - }; - const SetupCommand = proxyquire(modulePath, {os: osStub}); - const setup = new SetupCommand(ui, system); - - return setup.run({stages: ['linux-user']}).then(() => { - expect(ui.log.calledOnce).to.be.true; - expect(ui.log.args[0][0]).to.match(/is not Linux/); - }); + it('linux-user', function () { + const stub = sinon.stub(); + const {tasks, system} = getTasks({'../tasks/linux': stub}); + const [,,linuxTask] = tasks; + + expect(linuxTask.id).to.equal('linux-user'); + expect(linuxTask.name).to.equal('"ghost" system user'); + expect(linuxTask.title).to.equal('Setting up "ghost" system user'); + + linuxTask.task({}); + expect(stub.calledOnce).to.be.true; + + expect(linuxTask.enabled({argv: {local: true, stages: []}})).to.be.false; + system._platform = {linux: false}; + expect(linuxTask.enabled({argv: {local: false, stages: []}})).to.be.false; + system._platform = {linux: true}; + expect(linuxTask.enabled({argv: {process: 'local', stages: []}})).to.be.false; + expect(linuxTask.enabled({argv: {process: 'systemd', stages: []}})).to.be.true; }); - }); - it('Initial stage is setup properly, but skips db migrations', function () { - const migratorStub = { - migrate: sinon.stub().resolves(), - rollback: sinon.stub().resolves() - }; + it('migrate', function () { + const migrate = sinon.stub(); + const {tasks} = getTasks({'../tasks/migrator': {migrate}}); + const [,,,migrateTask] = tasks; - const SetupCommand = proxyquire(modulePath, { - '../tasks/migrator': migratorStub - }); + expect(migrateTask.id).to.equal('migrate'); + expect(migrateTask.title).to.equal('Running database migrations'); - const listr = sinon.stub(); - const aIstub = sinon.stub(); - const config = configStub(); - - config.get.withArgs('url').returns('https://ghost.org'); - config.has.returns(false); - - const system = { - getInstance: () => ({ - checkEnvironment: () => true, - apples: true, - config, - dir: '/var/www/ghost', - version: '2.0.0' - }), - addInstance: aIstub, - hook: () => Promise.resolve() - }; - const ui = { - run: () => Promise.resolve(), - listr: listr, - confirm: () => Promise.resolve(false) - }; - const argv = { - prompt: true, - 'setup-linux-user': false - }; - - ui.listr.callsFake((tasks, ctx) => Promise.each(tasks, (task) => { - if ((task.skip && task.skip(ctx)) || (task.enabled && !task.enabled(ctx))) { - return; - } - - return task.task(ctx); - })); + migrateTask.task({}); + expect(migrate.calledOnce).to.be.true; - const setup = new SetupCommand(ui, system); - return setup.run(argv).then(() => { - expect(listr.calledOnce).to.be.true; - expect(migratorStub.migrate.called).to.be.false; - expect(migratorStub.rollback.called).to.be.false; + const instance = {version: '1.0.0'}; + expect(migrateTask.enabled({argv: {migrate: false, stages: []}, instance})).to.be.false; + expect(migrateTask.enabled({argv: {stages: []}, instance})).to.be.true; + instance.version = '2.0.0'; + expect(migrateTask.enabled({argv: {stages: []}, instance})).to.be.false; }); }); + }); - it('Initial stage is setup properly', function () { - const listr = sinon.stub().resolves(); - const aIstub = sinon.stub(); - const config = configStub(); - - config.get.withArgs('url').returns('https://ghost.org'); - config.has.returns(false); - - const system = { - getInstance: () => ({ - checkEnvironment: () => true, - apples: true, - config, - dir: '/var/www/ghost', - version: '1.25.0' - }), - addInstance: aIstub, - hook: () => Promise.resolve() - }; - const ui = { - run: () => Promise.resolve(), - listr: listr, - confirm: () => Promise.resolve(false) - }; - const argv = { - prompt: true, - 'setup-linux-user': false, - migrate: false - }; - - const setup = new SetupCommand(ui, system); - return setup.run(argv).then(() => { - expect(listr.calledOnce).to.be.true; - const tasks = listr.args[0][0]; - expect(tasks[0].title).to.equal('Setting up instance'); - tasks.forEach(function (task) { - expect(task.title).to.not.match(/database migrations/); - }); - - const ctx = {}; - tasks[0].task(ctx); + describe('run', function () { + it('Handles local setup properly', function () { + const setup = new SetupCommand({}, {setEnvironment: () => { + throw new Error('Take a break'); + }}); - expect(ctx.instance).to.be.ok; - expect(ctx.instance.apples).to.be.true; - expect(ctx.instance.name).to.equal('ghost-org'); - expect(aIstub.calledOnce).to.be.true; - expect(aIstub.args[0][0]).to.deep.equal(ctx.instance); + const localArgs = sinon.stub(setup, 'localArgs'); - expect(config.has.calledOnce).to.be.true; - expect(config.set.calledOnce).to.be.true; - expect(config.set.calledWithExactly('paths.contentPath', '/var/www/ghost/content')).to.be.true; - expect(config.save.calledOnce).to.be.true; - }); + try { + setup.run({local: true}); + expect(false, 'An error should have been thrown').to.be.true; + } catch (error) { + expect(error.message).to.equal('Take a break'); + expect(localArgs.calledOnce).to.be.true; + } }); - it('Initial stage is setup properly, sanitizes pname argument', function () { - const listr = sinon.stub().resolves(); - const aIstub = sinon.stub(); - const config = configStub(); - config.get.withArgs('url').returns('https://ghost.org'); - config.has.returns(true); - - const system = { - getInstance: () => ({ - checkEnvironment: () => true, - apples: true, - config, - dir: '/var/www/ghost' - }), - addInstance: aIstub, - hook: () => Promise.resolve() - }; - const ui = { - run: () => Promise.resolve(), - listr: listr, - confirm: () => Promise.resolve(false) - }; - const argv = { - prompt: true, - 'setup-linux-user': false, - migrate: false, - pname: 'example.com' - }; - + it('calls correct methods when stages are passed in', function () { + const ui = sinon.createStubInstance(UI); + const system = sinon.createStubInstance(System); const setup = new SetupCommand(ui, system); - return setup.run(argv).then(() => { - expect(listr.calledOnce).to.be.true; - const tasks = listr.args[0][0]; - expect(tasks[0].title).to.equal('Setting up instance'); - tasks.forEach(function (task) { - expect(task.title).to.not.match(/database migrations/); + const runCommand = sinon.stub(setup, 'runCommand').resolves(); + const run = sinon.stub().resolves([]); + const checkEnvironment = sinon.stub(); + const instance = {checkEnvironment}; + const tasks = [{id: 'test', task1: true}, {id: 'test2', task2: true}]; + const taskStub = sinon.stub(setup, 'tasks').returns(tasks); + const listr = {tasks, run}; + + system.getInstance.returns(instance); + system.hook.resolves([{step1: true}, {step2: true}]); + ui.listr.returns(listr); + ui.confirm.resolves(true); + + return setup.run({local: false, stages: ['test1', 'test2']}).then(() => { + expect(system.getInstance.calledOnce).to.be.true; + expect(checkEnvironment.calledOnce).to.be.true; + expect(system.hook.calledOnceWithExactly('setup')).to.be.true; + expect(taskStub.calledOnce).to.be.true; + expect(taskStub.args[0]).to.deep.equal([ + [{step1: true}, {step2: true}] + ]); + expect(ui.listr.calledOnce).to.be.true; + expect(ui.listr.args[0]).to.deep.equal([ + tasks, + false, + {exitOnError: false} + ]); + expect(run.calledOnce).to.be.true; + const [[runArgs]] = run.args; + expect(Object.keys(runArgs)).to.deep.equal(['ui', 'system', 'instance', 'tasks', 'listr', 'argv', 'single']); + expect(runArgs.ui).to.equal(ui); + expect(runArgs.system).to.equal(system); + expect(runArgs.instance).to.equal(instance); + expect(runArgs.tasks).to.deep.equal({ + test: tasks[0], + test2: tasks[1] }); - - const ctx = {}; - tasks[0].task(ctx); - - expect(ctx.instance).to.be.ok; - expect(ctx.instance.apples).to.be.true; - expect(ctx.instance.name).to.equal('example-com'); - expect(aIstub.calledOnce).to.be.true; - expect(aIstub.args[0][0]).to.deep.equal(ctx.instance); - expect(config.has.calledOnce).to.be.true; - expect(config.set.called).to.be.false; - expect(config.save.called).to.be.false; + expect(runArgs.listr).to.equal(listr); + expect(runArgs.argv).to.deep.equal({local: false, stages: ['test1', 'test2']}); + expect(runArgs.single).to.be.true; + expect(ui.confirm.called).to.be.false; + expect(runCommand.called).to.be.false; }); }); - describe('task dependency checks', function () { - let stubs, ui, system; - - beforeEach(function () { - stubs = { - test: sinon.stub(), - zest: sinon.stub(), - listr: sinon.stub().resolves(), - skipped: sinon.stub(), - skip: sinon.stub(), - log: sinon.stub() - }; - ui = { - run: () => Promise.resolve(), - listr: stubs.listr, - log: stubs.log - }; - system = { - hook: () => Promise.resolve(), - getInstance: sinon.stub() - }; - }); - - it('everything is fine', function () { - stubs.skipped.returns(false); - const setup = new SetupCommand(ui, system); - setup.runCommand = () => Promise.resolve(); - - setup.addStage('test', stubs.test, [], 'Test'); - setup.addStage('zest', stubs.zest, ['test'], 'Zesty'); - - return setup.run({prompt: false}).then(() => { - expect(stubs.listr.calledOnce).to.be.true; - - const tasks = stubs.listr.args[0][0]; - - expect(tasks[2].title).to.match(/Test/); - expect(tasks[3].title).to.match(/Zesty/); - tasks[2].task({}, {_task: {isSkipped: stubs.skipped}}); - expect(stubs.test.calledOnce).to.be.true; - - tasks[3].task({}, {}); - expect(stubs.skipped.calledOnce).to.be.true; - expect(stubs.zest.calledOnce).to.be.true; - }); - }); - - it('unknown dependency', function () { - const expectLog = new RegExp(/(?=.*test)(?=.*stage, which was)/); - const setup = new SetupCommand(ui, system); - setup.runCommand = () => Promise.resolve(); - setup.addStage('zest', stubs.zest, ['test'], 'Zesty'); - - return setup.run({prompt: false}).then(() => { - expect(stubs.listr.calledOnce).to.be.true; - const tasks = stubs.listr.args[0][0]; - - expect(tasks[2].title).to.match(/Zesty/); - tasks[2].task({}, {skip: stubs.skip}); - - expect(stubs.zest.calledOnce).to.be.false; - expect(stubs.skip.calledOnce).to.be.true; - expect(stubs.log.calledOnce).to.be.true; - expect(stubs.log.args[0][0]).to.match(expectLog); - }); - }); - - it('dependency was skipped', function () { - stubs.skipped.returns(true); - const expectLog = new RegExp(/(?=.*test)(?=.*stage, which was)/); - const setup = new SetupCommand(ui, system); - setup.runCommand = () => Promise.resolve(); - - setup.addStage('test', stubs.test, [], 'Test'); - setup.addStage('zest', stubs.zest, ['test'], 'Zesty'); - - return setup.run({prompt: false}).then(() => { - expect(stubs.listr.calledOnce).to.be.true; - const tasks = stubs.listr.args[0][0]; - - expect(tasks[2].title).to.match(/Test/); - expect(tasks[3].title).to.match(/Zesty/); - - tasks[2].task({}, {_task: {isSkipped: stubs.skipped}}); - expect(stubs.test.calledOnce).to.be.true; - tasks[3].task({}, {skip: stubs.skip}); - expect(stubs.skipped.calledOnce).to.be.true; - - expect(stubs.zest.calledOnce).to.be.false; - expect(stubs.skip.calledOnce).to.be.true; - expect(stubs.log.calledOnce).to.be.true; - expect(stubs.log.args[0][0]).to.match(expectLog); - }); - }); - - it('multiple dependencies did not run', function () { - const expectLog = new RegExp(/(?=.*'test', 'rest')(?=.*stages, which were)/); - const setup = new SetupCommand(ui, system); - setup.runCommand = () => Promise.resolve(); - setup.addStage('zest', stubs.zest, ['test', 'rest'], 'Zesty'); - - return setup.run({prompt: false}).then(() => { - expect(stubs.listr.calledOnce).to.be.true; - const tasks = stubs.listr.args[0][0]; - - expect(tasks[2].title).to.match(/Zesty/); - tasks[2].task({}, {skip: stubs.skip}); - expect(stubs.zest.calledOnce).to.be.false; - - expect(stubs.skip.calledOnce).to.be.true; - expect(stubs.log.calledOnce).to.be.true; - expect(stubs.log.args[0][0]).to.match(expectLog); - }); + it('doesn\'t prompt and doesn\'t start when argv.start is false', function () { + const ui = sinon.createStubInstance(UI); + const system = sinon.createStubInstance(System); + const setup = new SetupCommand(ui, system); + const runCommand = sinon.stub(setup, 'runCommand').resolves(); + const taskStub = sinon.stub(setup, 'tasks').returns([]); + const run = sinon.stub().resolves([]); + const checkEnvironment = sinon.stub(); + const instance = {checkEnvironment}; + + system.getInstance.returns(instance); + system.hook.resolves([]); + ui.listr.returns({run, tasks: []}); + ui.confirm.resolves(true); + + return setup.run({start: false}).then(() => { + expect(system.getInstance.calledOnce).to.be.true; + expect(checkEnvironment.calledOnce).to.be.false; + expect(system.hook.calledOnceWithExactly('setup')).to.be.true; + expect(taskStub.calledOnce).to.be.true; + expect(ui.listr.calledOnce).to.be.true; + expect(run.calledOnce).to.be.true; + const [[runArgs]] = run.args; + expect(Object.keys(runArgs)).to.deep.equal(['ui', 'system', 'instance', 'tasks', 'listr', 'argv', 'single']); + expect(runArgs.single).to.be.false; + expect(ui.confirm.called).to.be.false; + expect(runCommand.called).to.be.false; }); }); - it('honors stage skipping via arguments', function () { - const SetupCommand = proxyquire(modulePath, { - os: {platform: () => 'linux'}, - '../tasks/configure': () => Promise.resolve() - }); - const ui = { - run: a => a(), - listr: sinon.stub().resolves() - }; - const skipStub = sinon.stub(); - const config = configStub(); - const system = { - hook: () => Promise.resolve(), - getInstance: sinon.stub().returns({config}) - }; + it('doesn\'t prompt and starts when argv.start is true', function () { + const ui = sinon.createStubInstance(UI); + const system = sinon.createStubInstance(System); const setup = new SetupCommand(ui, system); - setup.runCommand = () => Promise.resolve(); - setup.addStage('zest', () => true, null, 'Zesty'); - - return setup.run({prompt: false, 'setup-zest': false}).then(() => { + const runCommand = sinon.stub(setup, 'runCommand').resolves(); + const taskStub = sinon.stub(setup, 'tasks').returns(['some', 'tasks']); + const run = sinon.stub().resolves([]); + const checkEnvironment = sinon.stub(); + const instance = {checkEnvironment}; + + system.getInstance.returns(instance); + system.hook.resolves([]); + ui.listr.returns({run, tasks: []}); + ui.confirm.resolves(true); + + return setup.run({start: true}).then(() => { + expect(system.getInstance.calledOnce).to.be.true; + expect(checkEnvironment.calledOnce).to.be.false; + expect(system.hook.calledOnceWithExactly('setup')).to.be.true; + expect(taskStub.calledOnce).to.be.true; expect(ui.listr.calledOnce).to.be.true; - const tasks = ui.listr.args[0][0]; - - expect(tasks[2].title).to.match(/Zesty/); - tasks[2].task({}, {skip: skipStub}); - expect(skipStub.calledOnce).to.be.true; + expect(run.calledOnce).to.be.true; + const [[runArgs]] = run.args; + expect(Object.keys(runArgs)).to.deep.equal(['ui', 'system', 'instance', 'tasks', 'listr', 'argv', 'single']); + expect(runArgs.single).to.be.false; + expect(ui.confirm.called).to.be.false; + expect(runCommand.called).to.be.true; }); }); - it('normally prompts to run a stage', function () { - const configureStub = sinon.stub().resolves(); - const SetupCommand = proxyquire(modulePath, { - os: {platform: () => 'linux'}, - '../tasks/configure': configureStub - }); - function confirm(a) { - return Promise.resolve(a.indexOf('Z') < 0); - } - const ui = { - run: a => a(), - log: () => true, - listr: sinon.stub().resolves(), - confirm: sinon.stub().callsFake(confirm) - }; - const skipStub = sinon.stub(); - const config = configStub(); - const system = { - hook: () => Promise.resolve(), - getInstance: sinon.stub().returns({config}) - }; + it('prompts and follows start prompt', function () { + const ui = sinon.createStubInstance(UI); + const system = sinon.createStubInstance(System); const setup = new SetupCommand(ui, system); - let tasks; - - setup.addStage('zest', () => true, null, 'Zesty'); - setup.addStage('test', () => true, null, 'Test'); - - return setup.run({prompt: true, start: false}).then(() => { + const runCommand = sinon.stub(setup, 'runCommand').resolves(); + const taskStub = sinon.stub(setup, 'tasks').returns(['some', 'tasks']); + const run = sinon.stub().resolves([]); + const checkEnvironment = sinon.stub(); + const instance = {checkEnvironment}; + + system.getInstance.returns(instance); + system.hook.resolves([]); + ui.listr.returns({run, tasks: []}); + ui.confirm.resolves(false); + + return setup.run({}).then(() => { + expect(system.getInstance.calledOnce).to.be.true; + expect(checkEnvironment.calledOnce).to.be.false; + expect(system.hook.calledOnceWithExactly('setup')).to.be.true; + expect(taskStub.calledOnce).to.be.true; expect(ui.listr.calledOnce).to.be.true; - tasks = ui.listr.args[0][0]; - - expect(tasks[2].title).to.match(/Zesty/); - expect(tasks[3].title).to.match(/Test/); - - return tasks[2].task({}, {skip: skipStub}); - }).then(() => tasks[3].task({}, {})).then(() => { - expect(skipStub.calledOnce).to.be.true; - expect(ui.confirm.calledTwice).to.be.true; - expect(ui.confirm.args[0][0]).to.match(/Zesty/); - expect(ui.confirm.args[1][0]).to.match(/Test/); - expect(configureStub.calledOnce).to.be.true; + expect(run.calledOnce).to.be.true; + const [[runArgs]] = run.args; + expect(Object.keys(runArgs)).to.deep.equal(['ui', 'system', 'instance', 'tasks', 'listr', 'argv', 'single']); + expect(runArgs.single).to.be.false; + expect(ui.confirm.calledOnce).to.be.true; + expect(runCommand.called).to.be.false; }); }); });