Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/commands/addons/attach.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {color} from '@heroku-cli/color'
import {color} from '@heroku/heroku-cli-util'
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {Args, ux} from '@oclif/core'
Expand Down Expand Up @@ -36,7 +36,7 @@ export default class Attach extends Command {
addon: {name: addon.name}, app: {name: app}, confirm: confirmed, name: as, namespace,
}

ux.action.start(`Attaching ${credential ? color.yellow(credential) + ' of ' : ''}${color.yellow(addon.name || '')}${as ? ' as ' + color.cyan(as) : ''} to ${color.app(app)}`)
ux.action.start(`Attaching ${credential ? color.yellow(credential) + ' of ' : ''}${color.addon(addon.name || '')}${as ? ' as ' + color.cyan(as) : ''} to ${color.app(app)}`)
const {body: attachments} = await this.heroku.post<Heroku.AddOnAttachment>('/addon-attachments', {body})
ux.action.stop()
return attachments
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/addons/destroy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {color} from '@heroku-cli/color'
import {color} from '@heroku/heroku-cli-util'
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {Args} from '@oclif/core'
Expand Down Expand Up @@ -42,7 +42,7 @@ export default class Destroy extends Command {
// prevent deletion of add-on when context.app is set but the addon is attached to a different app
const addonApp = addon.app?.name
if (app && addonApp !== app) {
throw new Error(`${color.yellow(addon.name ?? '')} is on ${color.app(addonApp ?? '')} not ${color.app(app)}`)
throw new Error(`${color.addon(addon.name ?? '')} is on ${color.app(addonApp ?? '')} not ${color.app(app)}`)
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/addons/detach.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {color} from '@heroku-cli/color'
import {color} from '@heroku/heroku-cli-util'
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {Args, ux} from '@oclif/core'
Expand All @@ -22,7 +22,7 @@ export default class Detach extends Command {
const {app} = flags
const {body: attachment} = await this.heroku.get<Heroku.AddOnAttachment>(`/apps/${app}/addon-attachments/${args.attachment_name}`)

ux.action.start(`Detaching ${color.cyan(attachment.name || '')} to ${color.yellow(attachment.addon?.name || '')} from ${color.app(app)}`)
ux.action.start(`Detaching ${color.cyan(attachment.name || '')} to ${color.addon(attachment.addon?.name || '')} from ${color.app(app)}`)

await this.heroku.delete(`/addon-attachments/${attachment.id}`)

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/addons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function displayAll(addons: Heroku.AddOn[]) {
get: ({app}) => newColor.app(app?.name || ''),
},
'Add-on': {
get: ({name}) => color.magenta(name || ''),
get: ({name}) => newColor.addon(name || ''),
},
Plan: {
get({plan}) {
Expand Down Expand Up @@ -170,7 +170,7 @@ function displayForApp(app: string, addons: Heroku.AddOn[]) {

const isForeignApp = (attOrAddon: Heroku.AddOn | Heroku.AddOnAttachment) => attOrAddon.app?.name !== app
function presentAddon(addon: Heroku.AddOn) {
const name = color.magenta(addon.name || '')
const name = newColor.addon(addon.name || '')
let service = addon.addon_service?.name
if (service === undefined) {
service = color.dim('?')
Expand Down
28 changes: 15 additions & 13 deletions packages/cli/src/commands/addons/info.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
import {color} from '@heroku-cli/color'
/* eslint-disable perfectionist/sort-objects */
import {color, hux} from '@heroku/heroku-cli-util'
import {Command, flags} from '@heroku-cli/command'
import {Args} from '@oclif/core'
import {hux} from '@heroku/heroku-cli-util'
import * as Heroku from '@heroku-cli/schema'
import {grandfatheredPrice, formatPrice, formatState} from '../../lib/addons/util.js'
import {Args} from '@oclif/core'

import {resolveAddon} from '../../lib/addons/resolve.js'
import {formatPrice, formatState, grandfatheredPrice} from '../../lib/addons/util.js'

const topic = 'addons'

export default class Info extends Command {
static topic = topic
static args = {
addon: Args.string({description: 'unique identifier or globally unique name of the add-on', required: true}),
}

static description = 'show detailed add-on resource and attachment information'
static flags = {
app: flags.app(),
remote: flags.remote(),
}

static topic = topic
static usage = `${topic}:info ADDON`
static args = {
addon: Args.string({required: true, description: 'unique identifier or globally unique name of the add-on'}),
}

public async run(): Promise<void> {
const {flags, args} = await this.parse(Info)
const {args, flags} = await this.parse(Info)
const {app} = flags

const addon = await resolveAddon(this.heroku, app, args.addon)
const {body: attachments} = await this.heroku.get<Heroku.AddOnAttachment[]>(`/addons/${addon.id}/addon-attachments`)

addon.plan.price = grandfatheredPrice(addon)
addon.attachments = attachments
hux.styledHeader(color.magenta(addon.name ?? ''))
hux.styledHeader(color.addon(addon.name ?? ''))

hux.styledObject({
Plan: addon.plan.name,
Price: formatPrice({price: addon.plan.price, hourly: true}),
'Max Price': formatPrice({price: addon.plan.price, hourly: false}),
Price: formatPrice({hourly: true, price: addon.plan.price}),
'Max Price': formatPrice({hourly: false, price: addon.plan.price}),
Attachments: addon.attachments.map((att: Heroku.AddOnAttachment) => [
color.cyan(att.app?.name || ''), color.green(att.name || ''),
].join('::'))
.sort(), 'Owning app': color.cyan(addon.app?.name ?? ''), 'Installed at': (new Date(addon.created_at ?? ''))
.sort(), 'Owning app': color.app(addon.app?.name ?? ''), 'Installed at': (new Date(addon.created_at ?? ''))
.toString(), State: formatState(addon.state),
})
}
Expand Down
162 changes: 83 additions & 79 deletions packages/cli/src/commands/addons/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import {color} from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import {formatPriceText} from '../../lib/addons/util.js'
import {addonResolver} from '../../lib/addons/resolve.js'
import type {AddOn, Plan} from '@heroku-cli/schema'

import {color} from '@heroku/heroku-cli-util'
import {HTTP} from '@heroku/http-call'
import {Command, flags} from '@heroku-cli/command'
import {HerokuAPIError} from '@heroku-cli/command/lib/api-client.js'
import {Args, ux} from '@oclif/core'

import type {ExtendedAddon} from '../../lib/pg/types.js'

import {addonResolver} from '../../lib/addons/resolve.js'
import {formatPriceText} from '../../lib/addons/util.js'

export default class Upgrade extends Command {
static aliases = ['addons:downgrade']
static topic = 'addons'
static args = {
addon: Args.string({description: 'unique identifier or globally unique name of the add-on', required: true}),
plan: Args.string({description: 'unique identifier or name of the plan'}),
}

static description = `change add-on plan.
See available plans with \`heroku addons:plans SERVICE\`.

Expand All @@ -25,18 +32,81 @@ export default class Upgrade extends Command {
remote: flags.remote(),
}

static args = {
addon: Args.string({required: true, description: 'unique identifier or globally unique name of the add-on'}),
plan: Args.string({description: 'unique identifier or name of the plan'}),
static topic = 'addons'

protected buildApiErrorMessage(errorMessage: string, ctx: any) {
const {args: {addon, plan}, flags: {app}} = ctx
const example = errorMessage.split(', ')[2] || 'redis-triangular-1234'
return `${errorMessage}

Multiple add-ons match ${color.addon(addon)}${app ? ' on ' + color.app(app) : ''}
It is not clear which add-on's plan you are trying to change.

Specify the add-on name instead of the name of the add-on service.
For example, instead of: ${color.blue('heroku addons:upgrade ' + addon + ' ' + (plan || ''))}
Run this: ${color.blue('heroku addons:upgrade ' + example + ' ' + addon + ':' + plan)}
${app ? '' : 'Alternatively, specify an app to filter by with ' + color.blue('--app')}
${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`
}

protected buildNoPlanError(addon: string): string {
return `Error: No plan specified.
You need to specify a plan to move ${color.addon(addon)} to.
For example: ${color.blue('heroku addons:upgrade heroku-redis:premium-0')}
${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`
}

protected getAddonPartsFromArgs(args: { addon: string, plan: string | undefined }): { plan: string, addon: string } {
let {addon, plan} = args

if (!plan && addon.includes(':')) {
([addon, plan] = addon.split(':'))
}

if (!plan) {
throw new Error(this.buildNoPlanError(addon))
}

// ignore the service part of the plan since we can infer the service based on the add-on
if (plan.includes(':')) {
plan = plan.split(':')[1]
}

return {addon, plan}
}

protected async getPlans(addonServiceName: string | undefined): Promise<Plan[]> {
try {
const plansResponse: HTTP<Plan[]> = await this.heroku.get(`/addon-services/${addonServiceName}/plans`)
const {body: plans} = plansResponse
plans.sort((a, b) => {
if (a?.price?.cents === b?.price?.cents) {
return 0
}

if (!a?.price?.cents || !b?.price?.cents || a.price.cents > b.price.cents) {
return 1
}

if (a.price.cents < b.price.cents) {
return -1
}

return 0
})
return plans
} catch {
return []
}
}

public async run(): Promise<void> {
const ctx = await this.parse(Upgrade)
const {flags: {app}, args} = ctx
const {args, flags: {app}} = ctx
// called with just one argument in the form of `heroku addons:upgrade heroku-redis:hobby`
const {addon, plan} = this.getAddonPartsFromArgs(args)

let resolvedAddon: Required<AddOn> | ExtendedAddon
let resolvedAddon: ExtendedAddon | Required<AddOn>
try {
resolvedAddon = await addonResolver(this.heroku, app, addon)
} catch (error) {
Expand All @@ -51,7 +121,7 @@ export default class Upgrade extends Command {
const {name: appName} = resolvedAddon.app
const {name: addonName, plan: resolvedAddonPlan} = resolvedAddon ?? {}
const updatedPlanName = `${addonServiceName}:${plan}`
ux.action.start(`Changing ${color.magenta(addonName ?? '')} on ${color.cyan(appName ?? '')} from ${color.blue(resolvedAddonPlan?.name ?? '')} to ${color.blue(updatedPlanName)}`)
ux.action.start(`Changing ${color.addon(addonName ?? '')} on ${color.app(appName ?? '')} from ${color.blue(resolvedAddonPlan?.name ?? '')} to ${color.blue(updatedPlanName)}`)

try {
const patchResult: HTTP<Required<AddOn>> = await this.heroku.patch(`/apps/${appName}/addons/${addonName}`,
Expand All @@ -72,7 +142,7 @@ export default class Upgrade extends Command {
const plans = await this.getPlans(addonServiceName)
errorToThrow = new Error(`${http.body.message}

Here are the available plans for ${color.yellow(addonServiceName || '')}:
Here are the available plans for ${color.addon(addonServiceName || '')}:
${plans.map(plan => plan.name).join('\n')}\n\nSee more plan information with ${color.blue('heroku addons:plans ' + addonServiceName)}

${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`)
Expand All @@ -88,70 +158,4 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`)
ux.stdout(resolvedAddon.provision_message)
}
}

protected getAddonPartsFromArgs(args: { addon: string, plan: string | undefined }): { plan: string, addon: string } {
let {addon, plan} = args

if (!plan && addon.includes(':')) {
([addon, plan] = addon.split(':'))
}

if (!plan) {
throw new Error(this.buildNoPlanError(addon))
}

// ignore the service part of the plan since we can infer the service based on the add-on
if (plan.includes(':')) {
plan = plan.split(':')[1]
}

return {plan, addon}
}

protected buildNoPlanError(addon: string): string {
return `Error: No plan specified.
You need to specify a plan to move ${color.yellow(addon)} to.
For example: ${color.blue('heroku addons:upgrade heroku-redis:premium-0')}
${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`
}

protected buildApiErrorMessage(errorMessage: string, ctx: any) {
const {flags: {app}, args: {addon, plan}} = ctx
const example = errorMessage.split(', ')[2] || 'redis-triangular-1234'
return `${errorMessage}

Multiple add-ons match ${color.yellow(addon)}${app ? ' on ' + app : ''}
It is not clear which add-on's plan you are trying to change.

Specify the add-on name instead of the name of the add-on service.
For example, instead of: ${color.blue('heroku addons:upgrade ' + addon + ' ' + (plan || ''))}
Run this: ${color.blue('heroku addons:upgrade ' + example + ' ' + addon + ':' + plan)}
${app ? '' : 'Alternatively, specify an app to filter by with ' + color.blue('--app')}
${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`
}

protected async getPlans(addonServiceName: string | undefined): Promise<Plan[]> {
try {
const plansResponse: HTTP<Plan[]> = await this.heroku.get(`/addon-services/${addonServiceName}/plans`)
const {body: plans} = plansResponse
plans.sort((a, b) => {
if (a?.price?.cents === b?.price?.cents) {
return 0
}

if (!a?.price?.cents || !b?.price?.cents || a.price.cents > b.price.cents) {
return 1
}

if (a.price.cents < b.price.cents) {
return -1
}

return 0
})
return plans
} catch {
return []
}
}
}
28 changes: 15 additions & 13 deletions packages/cli/src/commands/addons/wait.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
import {color} from '@heroku-cli/color'
import {color} from '@heroku/heroku-cli-util'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {resolveAddon} from '../../lib/addons/resolve.js'
import {Args, ux} from '@oclif/core'

import {waitForAddonProvisioning, waitForAddonDeprovisioning} from '../../lib/addons/addons_wait.js'
import {resolveAddon} from '../../lib/addons/resolve.js'
import notify from '../../lib/notify.js'
import {ExtendedAddon} from '../../lib/pg/types.js'

export default class Wait extends Command {
static topic = 'addons'
static args = {
addon: Args.string({description: 'unique identifier or globally unique name of the add-on'}),
}

static description = 'show provisioning status of the add-ons on the app'
static flags = {
'wait-interval': flags.string({description: 'how frequently to poll in seconds'}),
app: flags.app(),
remote: flags.remote(),
}

static args = {
addon: Args.string({description: 'unique identifier or globally unique name of the add-on'}),
'wait-interval': flags.string({description: 'how frequently to poll in seconds'}),
}

public static notifier: (subtitle: string, message: string, success?: boolean) => void = notify

static topic = 'addons'

public async run(): Promise<void> {
const {flags, args} = await this.parse(Wait)
const {args, flags} = await this.parse(Wait)
// TODO: remove this type once the schema is fixed
type AddonWithDeprovisioningState = Omit<ExtendedAddon, 'state'> & {state?: ExtendedAddon['state'] | 'deprovisioning'}
type AddonWithDeprovisioningState = {state?: 'deprovisioning' | ExtendedAddon['state']} & Omit<ExtendedAddon, 'state'>
let addonsToWaitFor: AddonWithDeprovisioningState[]
if (args.addon) {
addonsToWaitFor = [await resolveAddon(this.heroku, flags.app, args.addon)]
Expand Down Expand Up @@ -57,9 +59,9 @@ export default class Wait extends Command {

const configVars = (addonResponse.config_vars || [])
if (configVars.length > 0) {
const decoratedConfigVars = configVars.map(c => color.green(c))
const decoratedConfigVars = configVars.map(c => color.name(c))
.join(', ')
ux.stdout(`Created ${color.yellow(addonName)} as ${decoratedConfigVars}`)
ux.stdout(`Created ${color.addon(addonName)} as ${decoratedConfigVars}`)
}

if (Date.now() - startTime.valueOf() >= 1000 * 5) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/apps/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async function addAddons(heroku: APIClient, app: Heroku.App, addons: { as?: stri
plan: addon.plan,
}

ux.action.start(`Adding ${color.green(addon.plan)}`)
ux.action.start(`Adding ${color.addon(addon.plan)}`)
await heroku.post(`/apps/${app.name}/addons`, {body})
ux.action.stop()
}
Expand Down
Loading
Loading