diff --git a/packages/apps/package.json b/packages/apps/package.json index ec32e3a6d4..ccbd2ac0c7 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -10,6 +10,7 @@ "@oclif/command": "^1", "@oclif/config": "^1", "cli-ux": "^5.3.2", + "inquirer": "^7.0.1", "shell-escape": "^0.2.0", "tslib": "^1", "urijs": "^1.19.1" diff --git a/packages/apps/src/commands/domains/add.ts b/packages/apps/src/commands/domains/add.ts index ae03d90894..6558606ab5 100644 --- a/packages/apps/src/commands/domains/add.ts +++ b/packages/apps/src/commands/domains/add.ts @@ -2,10 +2,18 @@ import {color} from '@heroku-cli/color' import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import cli from 'cli-ux' +import {prompt} from 'inquirer' import * as shellescape from 'shell-escape' import waitForDomain from '../../lib/wait-for-domain' +interface DomainCreatePayload { + hostname: string + sni_endpoint?: string +} + +const MULTIPLE_SNI_ENDPOINT_FLAG = 'allow-multiple-sni-endpoints' + export default class DomainsAdd extends Command { static description = 'add a domain to an app' @@ -14,35 +22,114 @@ export default class DomainsAdd extends Command { static flags = { help: flags.help({char: 'h'}), app: flags.app({required: true}), + cert: flags.string({description: 'the name of the SSL cert you want to use for this domain', char: 'c'}), json: flags.boolean({description: 'output in json format', char: 'j'}), wait: flags.boolean() } static args = [{name: 'hostname'}] + createDomain = async (appName: string, payload: DomainCreatePayload): Promise => { + cli.action.start(`Adding ${color.green(payload.hostname)} to ${color.app(appName)}`) + try { + const response = await this.heroku.post(`/apps/${appName}/domains`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.allow_multiple_sni_endpoints'}, + body: payload + }) + return response.body + } catch (err) { + // If the error indicates that the app has multiple certs needs the user to specify which one + // to use, we ask them which cert to use, otherwise we rethrow the error and handle it like usual + if (err.body.id === 'invalid_params' && err.body.message.includes('sni_endpoint')) { + cli.action.stop('resolving SNI endpoint') + const {body: certs} = await this.heroku.get(`/apps/${appName}/sni-endpoints`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.allow_multiple_sni_endpoints'} + }) + + const certChoices = certs.map((cert: Heroku.SniEndpoint) => { + const certName = cert.displayName || cert.name + const domainsLength = cert.ssl_cert.cert_domains.length + + if (domainsLength) { + let domainsList = cert.ssl_cert.cert_domains.slice(0, 4).join(', ') + + if (domainsLength > 5) { + domainsList = `${domainsList} (...and ${domainsLength - 4} more)` + } + + domainsList = `${certName} -> ${domainsList}` + + return { + name: domainsList, + value: cert.name + } + } + + return { + name: certName, + value: cert.name + } + }) + + const selection = await prompt<{cert: string}>([ + { + type: 'list', + name: 'cert', + message: 'Choose an SNI endpoint to associate with this domain', + choices: certChoices + } + ]) + + payload.sni_endpoint = selection.cert + + return this.createDomain(appName, payload) + } else { + throw err + } + } + } + async run() { const {args, flags} = this.parse(DomainsAdd) const {hostname} = args - cli.action.start(`Adding ${color.green(args.hostname)} to ${color.app(flags.app)}`) - const {body: domain} = await this.heroku.post(`/apps/${flags.app}/domains`, { - body: {hostname} - }) - cli.action.stop() - if (flags.json) { - cli.styledJSON(domain) - } else { - cli.log(`Configure your app's DNS provider to point to the DNS Target ${color.green(domain.cname || '')}. -For help, see https://devcenter.heroku.com/articles/custom-domains`) - if (domain.status !== 'none') { - if (flags.wait) { - await waitForDomain(flags.app, this.heroku, domain) - } else { - cli.log('') - cli.log(`The domain ${color.green(hostname)} has been enqueued for addition`) - let command = `heroku domains:wait ${shellescape([hostname])}` - cli.log(`Run ${color.cmd(command)} to wait for completion`) + + const {body: featureFlag} = await this.heroku.get( + `/apps/${flags.app}/features/${MULTIPLE_SNI_ENDPOINT_FLAG}` + ) + + const domainCreatePayload: DomainCreatePayload = { + hostname + } + + if (featureFlag.enabled) { + // multiple SNI endpoints is enabled + if (flags.cert) { + domainCreatePayload.sni_endpoint = flags.cert + } + } + + try { + const domain = await this.createDomain(flags.app, domainCreatePayload) + if (flags.json) { + cli.styledJSON(domain) + } else { + cli.log(`Configure your app's DNS provider to point to the DNS Target ${color.green(domain.cname || '')}. + For help, see https://devcenter.heroku.com/articles/custom-domains`) + if (domain.status !== 'none') { + if (flags.wait) { + await waitForDomain(flags.app, this.heroku, domain) + } else { + cli.log('') + cli.log(`The domain ${color.green(hostname)} has been enqueued for addition`) + let command = `heroku domains:wait ${shellescape([hostname])}` + cli.log(`Run ${color.cmd(command)} to wait for completion`) + } } } + } catch (err) { + cli.error(err) + } finally { + cli.action.stop() } } } diff --git a/packages/apps/test/commands/domains/add.test.ts b/packages/apps/test/commands/domains/add.test.ts index fd86def20c..33a9041545 100644 --- a/packages/apps/test/commands/domains/add.test.ts +++ b/packages/apps/test/commands/domains/add.test.ts @@ -1,3 +1,5 @@ +import * as inquirer from 'inquirer' + import {expect, test} from '../../test' describe('domains:add', () => { @@ -17,14 +19,96 @@ describe('domains:add', () => { status: 'pending' } - test - .stderr() - .nock('https://api.heroku.com', api => api - .post('/apps/myapp/domains', {hostname: 'example.com'}) - .reply(200, domainsResponse) - ) - .command(['domains:add', 'example.com', '--app', 'myapp']) - .it('adds the domain to the app', ctx => { - expect(ctx.stderr).to.contain('Adding example.com to myapp... done') + describe('adding a domain without the feature flag on (the old way)', () => { + test + .stderr() + .nock('https://api.heroku.com', api => api + .get('/apps/myapp/features/allow-multiple-sni-endpoints') + .reply(200, { + enabled: false + }) + .post('/apps/myapp/domains', {hostname: 'example.com'}) + .reply(200, domainsResponse) + ) + .command(['domains:add', 'example.com', '--app', 'myapp']) + .it('adds the domain to the app', ctx => { + expect(ctx.stderr).to.contain('Adding example.com to myapp... done') + }) + }) + + describe('adding a domain to an app with multiple certs', () => { + describe('using the --cert flag', () => { + test + .stderr() + .nock('https://api.heroku.com', api => api + .get('/apps/myapp/features/allow-multiple-sni-endpoints') + .reply(200, { + enabled: true + }) + .post('/apps/myapp/domains', { + hostname: 'example.com', + sni_endpoint: 'my-cert' + }) + .reply(200, domainsResponse) + ) + .command(['domains:add', 'example.com', '--app', 'myapp', '--cert', 'my-cert']) + .it('adds the domain to the app', ctx => { + expect(ctx.stderr).to.contain('Adding example.com to myapp... done') + }) + }) + + describe('without passing a cert', () => { + const certsResponse = [ + { + app: { + name: 'myapp', + }, + name: 'cert1', + displayName: 'Best Cert Ever', + ssl_cert: { + cert_domains: ['foo.com', 'bar.com', 'baz.com', 'baq.com', 'blah.com', 'rejairieja.com'], + }, + }, + { + app: { + name: 'myapp', + }, + name: 'cert2', + ssl_cert: { + cert_domains: ['foo.com', 'bar.com', 'baz.com', 'baq.com', 'blah.com', 'rejairieja.com'], + }, + }, + ] + + test + .stderr() + .stub(inquirer, 'prompt', () => { + return Promise.resolve({cert: 'my-cert'}) + }) + .nock('https://api.heroku.com', api => api + .get('/apps/myapp/features/allow-multiple-sni-endpoints') + .reply(200, { + enabled: true + }) + .post('/apps/myapp/domains', { + hostname: 'example.com', + }) + .reply(422, { + id: 'invalid_params', + message: '\'sni_endpoint\' param is required when adding a domain to an app with multiple SSL certs.' + }) + .post('/apps/myapp/domains', { + hostname: 'example.com', + sni_endpoint: 'my-cert' + }) + .reply(200, domainsResponse) + .get('/apps/myapp/sni-endpoints') + .reply(200, certsResponse) + ) + .command(['domains:add', 'example.com', '--app', 'myapp']) + .it('adds the domain to the app', ctx => { + expect(ctx.stderr).to.contain('Adding example.com to myapp... done') + }) }) + }) }) diff --git a/yarn.lock b/yarn.lock index 04f0cd4e32..94144384af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5237,6 +5237,25 @@ inquirer@^7.0.0: strip-ansi "^5.1.0" through "^2.3.6" +inquirer@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.1.tgz#13f7980eedc73c689feff3994b109c4e799c6ebb" + integrity sha512-V1FFQ3TIO15det8PijPLFR9M9baSlnRs9nL7zWu1MNVA2T9YVl9ZbrHJhYs7e9X8jeMZ3lr2JH/rdHFgNCBdYw== + dependencies: + ansi-escapes "^4.2.1" + chalk "^2.4.2" + cli-cursor "^3.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.15" + mute-stream "0.0.8" + run-async "^2.2.0" + rxjs "^6.5.3" + string-width "^4.1.0" + strip-ansi "^5.1.0" + through "^2.3.6" + into-stream@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6" @@ -8145,7 +8164,7 @@ rxjs@^5.5.2: dependencies: symbol-observable "1.0.1" -rxjs@^6.1.0: +rxjs@^6.1.0, rxjs@^6.5.3: version "6.5.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.3.tgz#510e26317f4db91a7eb1de77d9dd9ba0a4899a3a" integrity sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==