1 | 'use strict'
|
2 |
|
3 | const cli = require('heroku-cli-util')
|
4 | const co = require('co')
|
5 |
|
6 | function * run (ctx, api) {
|
7 | const util = require('../../lib/util')
|
8 | const table = util.table
|
9 | const style = util.style
|
10 | const formatPrice = util.formatPrice
|
11 | const formatState = util.formatState
|
12 | const grandfatheredPrice = util.grandfatheredPrice
|
13 | const printf = require('printf')
|
14 |
|
15 | const groupBy = require('lodash.groupby')
|
16 | const some = require('lodash.some')
|
17 | const sortBy = require('lodash.sortby')
|
18 | const values = require('lodash.values')
|
19 |
|
20 |
|
21 |
|
22 | function * addonGetter (api, app) {
|
23 | let attachments, addons
|
24 |
|
25 | if (app) {
|
26 | addons = api.get(`/apps/${app}/addons`, {headers: {
|
27 | 'Accept-Expansion': 'addon_service,plan',
|
28 | 'Accept': 'application/vnd.heroku+json; version=3.with-addon-billing-info'
|
29 | }})
|
30 |
|
31 | let sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}')
|
32 | if (sudoHeaders['X-Heroku-Sudo'] && !sudoHeaders['X-Heroku-Sudo-User']) {
|
33 |
|
34 |
|
35 |
|
36 | attachments = api.request({
|
37 | method: 'GET',
|
38 | path: `/apps/${app}/addon-attachments`
|
39 | })
|
40 | } else {
|
41 |
|
42 |
|
43 | attachments = api.get('/addon-attachments')
|
44 | }
|
45 | } else {
|
46 | addons = api.request({
|
47 | method: 'GET',
|
48 | path: '/addons',
|
49 | headers: {
|
50 | 'Accept-Expansion': 'addon_service,plan',
|
51 | 'Accept': 'application/vnd.heroku+json; version=3.with-addon-billing-info'
|
52 | }
|
53 | })
|
54 | }
|
55 |
|
56 |
|
57 | let items = yield [addons, attachments]
|
58 |
|
59 | function isRelevantToApp (addon) {
|
60 | return !app ||
|
61 | addon.app.name === app ||
|
62 | some(addon.attachments, (att) => att.app.name === app)
|
63 | }
|
64 |
|
65 | attachments = groupBy(items[1], 'addon.id')
|
66 |
|
67 | addons = []
|
68 | items[0].forEach(function (addon) {
|
69 | addon.attachments = attachments[addon.id] || []
|
70 |
|
71 | delete attachments[addon.id]
|
72 |
|
73 | if (isRelevantToApp(addon)) {
|
74 | addons.push(addon)
|
75 | }
|
76 |
|
77 | addon.plan.price = grandfatheredPrice(addon)
|
78 | })
|
79 |
|
80 |
|
81 |
|
82 |
|
83 |
|
84 |
|
85 | values(attachments).forEach(function (atts) {
|
86 | let inaccessibleAddon = {
|
87 | app: atts[0].addon.app,
|
88 | name: atts[0].addon.name,
|
89 | addon_service: {},
|
90 | plan: {},
|
91 | attachments: atts
|
92 | }
|
93 |
|
94 | if (isRelevantToApp(inaccessibleAddon)) {
|
95 | addons.push(inaccessibleAddon)
|
96 | }
|
97 | })
|
98 |
|
99 | return addons
|
100 | }
|
101 |
|
102 | function displayAll (addons) {
|
103 | addons = sortBy(addons, 'app.name', 'plan.name', 'addon.name')
|
104 |
|
105 | if (addons.length === 0) {
|
106 | cli.log('No add-ons.')
|
107 | return
|
108 | }
|
109 |
|
110 | table(addons, {
|
111 | headerAnsi: cli.color.bold,
|
112 | columns: [{
|
113 | key: 'app.name',
|
114 | label: 'Owning App',
|
115 | format: style('app')
|
116 | }, {
|
117 | key: 'name',
|
118 | label: 'Add-on',
|
119 | format: style('addon')
|
120 | }, {
|
121 | key: 'plan.name',
|
122 | label: 'Plan',
|
123 | format: function (plan) {
|
124 | if (typeof plan === 'undefined') return style('dim', '?')
|
125 | return plan
|
126 | }
|
127 | }, {
|
128 | key: 'plan.price',
|
129 | label: 'Price',
|
130 | format: function (price) {
|
131 | if (typeof price === 'undefined') return style('dim', '?')
|
132 | return formatPrice(price)
|
133 | }
|
134 | },
|
135 | {
|
136 | key: 'state',
|
137 | label: 'State',
|
138 | format: function (state) {
|
139 | switch (state) {
|
140 | case 'provisioned':
|
141 | state = 'created'
|
142 | break
|
143 | case 'provisioning':
|
144 | state = 'creating'
|
145 | break
|
146 | case 'deprovisioned':
|
147 | state = 'errored'
|
148 | }
|
149 | return state
|
150 | }
|
151 | }]
|
152 | })
|
153 | }
|
154 |
|
155 | function formatAttachment (attachment, showApp) {
|
156 | if (showApp === undefined) showApp = true
|
157 |
|
158 | let attName = style('attachment', attachment.name)
|
159 |
|
160 | let output = [style('dim', 'as'), attName]
|
161 | if (showApp) {
|
162 | let appInfo = `on ${style('app', attachment.app.name)} app`
|
163 | output.push(style('dim', appInfo))
|
164 | }
|
165 |
|
166 | return output.join(' ')
|
167 | }
|
168 |
|
169 | function renderAttachment (attachment, app, isFirst) {
|
170 | let line = isFirst ? '└─' : '├─'
|
171 | let attName = formatAttachment(attachment, attachment.app.name !== app)
|
172 | return printf(' %s %s', style('dim', line), attName)
|
173 | }
|
174 |
|
175 | function displayForApp (app, addons) {
|
176 | if (addons.length === 0) {
|
177 | cli.log(`No add-ons for app ${app}.`)
|
178 | return
|
179 | }
|
180 |
|
181 | let isForeignApp = (attOrAddon) => attOrAddon.app.name !== app
|
182 |
|
183 | function presentAddon (addon) {
|
184 | let name = style('addon', addon.name)
|
185 | let service = addon.addon_service.name
|
186 |
|
187 | if (service === undefined) {
|
188 | service = style('dim', '?')
|
189 | }
|
190 |
|
191 | let addonLine = `${service} (${name})`
|
192 |
|
193 | let atts = sortBy(addon.attachments,
|
194 | isForeignApp,
|
195 | 'app.name',
|
196 | 'name')
|
197 |
|
198 |
|
199 | let attLines = atts.map(function (attachment, idx) {
|
200 | let isFirst = (idx === addon.attachments.length - 1)
|
201 | return renderAttachment(attachment, app, isFirst)
|
202 | })
|
203 |
|
204 | return [addonLine].concat(attLines).join('\n')
|
205 | }
|
206 |
|
207 | addons = sortBy(addons,
|
208 | isForeignApp,
|
209 | 'plan.name',
|
210 | 'name')
|
211 |
|
212 | cli.log()
|
213 | table(addons, {
|
214 | headerAnsi: cli.color.bold,
|
215 | columns: [{
|
216 | label: 'Add-on',
|
217 | format: presentAddon
|
218 | }, {
|
219 | label: 'Plan',
|
220 | key: 'plan.name',
|
221 | format: function (name) {
|
222 | if (name === undefined) return style('dim', '?')
|
223 | return name.replace(/^[^:]+:/, '')
|
224 | }
|
225 | }, {
|
226 | label: 'Price',
|
227 | format: function (addon) {
|
228 | if (addon.app.name === app) {
|
229 | return formatPrice(addon.plan.price)
|
230 | } else {
|
231 | return style('dim', printf('(billed to %s app)', style('app', addon.app.name)))
|
232 | }
|
233 | }
|
234 | }, {
|
235 | label: 'State',
|
236 | key: 'state',
|
237 | format: formatState
|
238 | }],
|
239 |
|
240 |
|
241 | after: () => cli.log('')
|
242 | })
|
243 |
|
244 | cli.log(`The table above shows ${style('addon', 'add-ons')} and the ` +
|
245 | `${style('attachment', 'attachments')} to the current app (${app}) ` +
|
246 | `or other ${style('app', 'apps')}.
|
247 | `)
|
248 | }
|
249 |
|
250 | function displayJSON (addons) {
|
251 | cli.log(JSON.stringify(addons, null, 2))
|
252 | }
|
253 |
|
254 | if (!ctx.flags.all && ctx.app) {
|
255 | let addons = yield co(addonGetter(api, ctx.app))
|
256 | if (ctx.flags.json) displayJSON(addons)
|
257 | else displayForApp(ctx.app, addons)
|
258 | } else {
|
259 | let addons = yield co(addonGetter(api))
|
260 | if (ctx.flags.json) displayJSON(addons)
|
261 | else displayAll(addons)
|
262 | }
|
263 | }
|
264 |
|
265 | let topic = 'addons'
|
266 | module.exports = {
|
267 | topic: topic,
|
268 | default: true,
|
269 | needsAuth: true,
|
270 | wantsApp: true,
|
271 | flags: [
|
272 | {
|
273 | name: 'all',
|
274 | char: 'A',
|
275 | hasValue: false,
|
276 | description: 'show add-ons and attachments for all accessible apps'
|
277 | },
|
278 | {
|
279 | name: 'json',
|
280 | hasValue: false,
|
281 | description: 'return add-ons in json format'
|
282 | }
|
283 | ],
|
284 |
|
285 | run: cli.command({preauth: true}, co.wrap(run)),
|
286 | usage: `${topic} [--all|--app APP]`,
|
287 | description: 'lists your add-ons and attachments',
|
288 | help: `The default filter applied depends on whether you are in a Heroku app
|
289 | directory. If so, the --app flag is implied. If not, the default of --all
|
290 | is implied. Explicitly providing either flag overrides the default
|
291 | behavior.
|
292 |
|
293 | Examples:
|
294 |
|
295 | $ heroku ${topic} --all
|
296 | $ heroku ${topic} --app acme-inc-www
|
297 |
|
298 | Overview of Add-ons:
|
299 |
|
300 | Add-ons are created with the \`addons:create\` command, providing a reference
|
301 | to an add-on service (such as \`heroku-postgresql\`) or a service and plan
|
302 | (such as \`heroku-postgresql:hobby-dev\`).
|
303 |
|
304 | At creation, each add-on is given a globally unique name. In addition, each
|
305 | add-on has at least one attachment alias to each application which uses the
|
306 | add-on. In all cases, the owning application will be attached to the add-on.
|
307 | An attachment alias is unique to its application, and is used as a prefix to
|
308 | any environment variables it exports to the application.
|
309 |
|
310 | In this example, a \`heroku-postgresql\` add-on is created and its given name
|
311 | is \`postgresql-deep-6913\` with a default attachment alias of \`DATABASE\`:
|
312 |
|
313 | $ heroku addons:create heroku-postgresql --app my-app
|
314 | Creating postgresql-deep-6913... done, (free)
|
315 | Adding postgresql-deep-6913 to my-app... done
|
316 | Setting DATABASE_URL and restarting my-app... done, v5
|
317 | Database has been created and is available
|
318 |
|
319 | $ heroku addons --app my-app
|
320 | Add-on Plan Price
|
321 | ───────────────────────────────────────── ───────── ─────
|
322 | heroku-postgresql (postgresql-deep-6913) hobby-dev free
|
323 | └─ as DATABASE
|
324 |
|
325 | The add-on name and, in some cases, the attachment alias can be specified by
|
326 | the user. For instance, we can add a second database to the app, specifying
|
327 | both these identifiers:
|
328 |
|
329 | $ heroku addons:create heroku-postgresql --app my-app --name main-db --as PRIMARY_DB
|
330 | Creating main-db... done, (free)
|
331 | Adding main-db to my-app... done
|
332 | Setting PRIMARY_DB_URL and restarting my-app... done, v6
|
333 | Database has been created and is available
|
334 |
|
335 | $ heroku addons --app my-app
|
336 | Add-on Plan Price
|
337 | ───────────────────────────────────────── ───────── ─────
|
338 | heroku-postgresql (main-db) hobby-dev free
|
339 | └─ as PRIMARY_DB
|
340 |
|
341 | heroku-postgresql (postgresql-deep-6913) hobby-dev free
|
342 | └─ as DATABASE
|
343 |
|
344 | Attachment aliases can also be specified when making attachments:
|
345 |
|
346 | $ heroku addons:attach main-db --app my-app --as ANOTHER_NAME
|
347 | Attaching main-db as ANOTHER_NAME to my-app... done
|
348 | Setting ANOTHER_NAME vars and restarting my-app... done, v7
|
349 |
|
350 | $ heroku addons --app my-app
|
351 | Add-on Plan Price
|
352 | ───────────────────────────────────────── ───────── ─────
|
353 | heroku-postgresql (main-db) hobby-dev free
|
354 | ├─ as PRIMARY_DB
|
355 | └─ as ANOTHER_NAME
|
356 |
|
357 | heroku-postgresql (postgresql-deep-6913) hobby-dev free
|
358 | └─ as DATABASE
|
359 |
|
360 | For more information, read https://devcenter.heroku.com/articles/add-ons.`
|
361 | }
|