heroku/heroku-cli-addons

View on GitHub
commands/addons/index.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict'

const cli = require('heroku-cli-util')
const co = require('co')

function * run (ctx, api) {
  const util = require('../../lib/util')
  const table = util.table
  const style = util.style
  const formatPrice = util.formatPrice
  const formatState = util.formatState
  const grandfatheredPrice = util.grandfatheredPrice
  const printf = require('printf')

  const {groupBy, some, sortBy, values} = require('lodash')

  // Gets *all* attachments and add-ons and filters locally because the API
  // returns *owned* items not associated items.
  function * addonGetter (api, app) {
    let attachments, addons

    if (app) { // don't disploy attachments globally
      addons = api.get(`/apps/${app}/addons`, {headers: {
        'Accept-Expansion': 'addon_service,plan'
      }})

      let sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}')
      if (sudoHeaders['X-Heroku-Sudo'] && !sudoHeaders['X-Heroku-Sudo-User']) {
        // because the root /addon-attachments endpoint won't include relevant
        // attachments when sudo-ing for another app, we will use the more
        // specific API call and sacrifice listing foreign attachments.
        attachments = api.request({
          method: 'GET',
          path: `/apps/${app}/addon-attachments`
        })
      } else {
        // In order to display all foreign attachments, we'll get out entire
        // attachment list
        attachments = api.get('/addon-attachments')
      }
    } else {
      addons = api.request({
        method: 'GET',
        path: '/addons',
        headers: {
          'Accept-Expansion': 'addon_service,plan'
        }
      })
    }

    // Get addons and attachments in parallel
    let items = yield [addons, attachments]

    function isRelevantToApp (addon) {
      return !app ||
      addon.app.name === app ||
      some(addon.attachments, (att) => att.app.name === app)
    }

    attachments = groupBy(items[1], 'addon.id')

    addons = []
    items[0].forEach(function (addon) {
      addon.attachments = attachments[addon.id] || []

      delete attachments[addon.id]

      if (isRelevantToApp(addon)) {
        addons.push(addon)
      }

      addon.plan.price = grandfatheredPrice(addon)
    })

    // Any attachments left didn't have a corresponding add-on record in API.
    // This is probably normal (because we are asking API for all attachments)
    // but it could also be due to certain types of permissions issues, so check
    // if the attachment looks relevant to the app, and then render whatever
    // information we can.
    values(attachments).forEach(function (atts) {
      let inaccessibleAddon = {
        app: atts[0].addon.app,
        name: atts[0].addon.name,
        addon_service: {},
        plan: {},
        attachments: atts
      }

      if (isRelevantToApp(inaccessibleAddon)) {
        addons.push(inaccessibleAddon)
      }
    })

    return addons
  }

  function displayAll (addons) {
    addons = sortBy(addons, 'app.name', 'plan.name', 'addon.name')

    if (addons.length === 0) {
      cli.log('No add-ons.')
      return
    }

    table(addons, {
      headerAnsi: cli.color.bold,
      columns: [{
        key: 'app.name',
        label: 'Owning App',
        format: style('app')
      }, {
        key: 'name',
        label: 'Add-on',
        format: style('addon')
      }, {
        key: 'plan.name',
        label: 'Plan',
        format: function (plan) {
          if (typeof plan === 'undefined') return style('dim', '?')
          return plan
        }
      }, {
        key: 'plan.price',
        label: 'Price',
        format: function (price) {
          if (typeof price === 'undefined') return style('dim', '?')
          return formatPrice(price)
        }
      },
      {
        key: 'state',
        label: 'State',
        format: function (state) {
          switch (state) {
            case 'provisioned':
              state = 'created'
              break
            case 'provisioning':
              state = 'creating'
              break
            case 'deprovisioned':
              state = 'errored'
          }
          return state
        }
      }]
    })
  }

  function formatAttachment (attachment, showApp) {
    if (showApp === undefined) showApp = true

    let attName = style('attachment', attachment.name)

    let output = [style('dim', 'as'), attName]
    if (showApp) {
      let appInfo = `on ${style('app', attachment.app.name)} app`
      output.push(style('dim', appInfo))
    }

    return output.join(' ')
  }

  function renderAttachment (attachment, app, isFirst) {
    let line = isFirst ? '└─' : '├─'
    let attName = formatAttachment(attachment, attachment.app.name !== app)
    return printf(' %s %s', style('dim', line), attName)
  }

  function displayForApp (app, addons) {
    if (addons.length === 0) {
      cli.log(`No add-ons for app ${app}.`)
      return
    }

    let isForeignApp = (attOrAddon) => attOrAddon.app.name !== app

    function presentAddon (addon) {
      let name = style('addon', addon.name)
      let service = addon.addon_service.name

      if (service === undefined) {
        service = style('dim', '?')
      }

      let addonLine = `${service} (${name})`

      let atts = sortBy(addon.attachments,
        isForeignApp,
        'app.name',
        'name')

      // render each attachment under the add-on
      let attLines = atts.map(function (attachment, idx) {
        let isFirst = (idx === addon.attachments.length - 1)
        return renderAttachment(attachment, app, isFirst)
      })

      return [addonLine].concat(attLines).join('\n')
    }

    addons = sortBy(addons,
      isForeignApp,
      'plan.name',
      'name')

    cli.log()
    table(addons, {
      headerAnsi: cli.color.bold,
      columns: [{
        label: 'Add-on',
        format: presentAddon
      }, {
        label: 'Plan',
        key: 'plan.name',
        format: function (name) {
          if (name === undefined) return style('dim', '?')
          return name.replace(/^[^:]+:/, '')
        }
      }, {
        label: 'Price',
        format: function (addon) {
          if (addon.app.name === app) {
            return formatPrice(addon.plan.price)
          } else {
            return style('dim', printf('(billed to %s app)', style('app', addon.app.name)))
          }
        }
      }, {
        label: 'State',
        key: 'state',
        format: formatState
      }],

      // Separate each add-on row by a blank line
      after: () => cli.log('')
    })

    cli.log(`The table above shows ${style('addon', 'add-ons')} and the ` +
      `${style('attachment', 'attachments')} to the current app (${app}) ` +
      `or other ${style('app', 'apps')}.
  `)
  }

  function displayJSON (addons) {
    cli.log(JSON.stringify(addons, null, 2))
  }

  if (!ctx.flags.all && ctx.app) {
    let addons = yield co(addonGetter(api, ctx.app))
    if (ctx.flags.json) displayJSON(addons)
    else displayForApp(ctx.app, addons)
  } else {
    let addons = yield co(addonGetter(api))
    if (ctx.flags.json) displayJSON(addons)
    else displayAll(addons)
  }
}

let topic = 'addons'
module.exports = {
  topic: topic,
  needsAuth: true,
  wantsApp: true,
  flags: [
    {
      name: 'all',
      char: 'A',
      hasValue: false,
      description: 'show add-ons and attachments for all accessible apps'
    },
    {
      name: 'json',
      hasValue: false,
      description: 'return add-ons in json format'
    }
  ],

  run: cli.command({preauth: true}, co.wrap(run)),
  usage: `${topic} [--all|--app APP]`,
  description: 'lists your add-ons and attachments',
  help: `The default filter applied depends on whether you are in a Heroku app
directory. If so, the --app flag is implied. If not, the default of --all
is implied. Explicitly providing either flag overrides the default
behavior.`,
  examples: [
    `$ heroku ${topic} --all`,
    `$ heroku ${topic} --app acme-inc-www`
  ]
}