From 4512c30289ca70afa1ed899a0021b023fa78a2d9 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Fri, 16 Jan 2026 16:56:16 -0800 Subject: [PATCH] feat: migrate to color.addon from @heroku/heroku-cli-util Migrates all add-on name and plan references to use the new semantic color.addon() function from @heroku/heroku-cli-util instead of generic colors like color.yellow() or color.green(). Changes: - Updated 8 files: 5 commands in addons/, 1 in apps/, 1 in drains/, 1 lib file - Changed all imports from @heroku-cli/color to @heroku/heroku-cli-util - Updated color.cmd() to color.command() where affected by import changes - Migrated addon names, addon plans, and addon service names to color.addon() - Kept config var colors as color.green() (out of scope for this PR) The new color.addon() function provides consistent bold yellow formatting for add-on names and plans throughout the CLI. Part of the color system migration plan (PR 3 of 17). --- packages/cli/src/commands/addons/attach.ts | 4 +- packages/cli/src/commands/addons/destroy.ts | 4 +- packages/cli/src/commands/addons/detach.ts | 4 +- packages/cli/src/commands/addons/index.ts | 4 +- packages/cli/src/commands/addons/info.ts | 28 +-- packages/cli/src/commands/addons/upgrade.ts | 162 +++++++++--------- packages/cli/src/commands/addons/wait.ts | 28 +-- packages/cli/src/commands/apps/create.ts | 2 +- packages/cli/src/commands/drains/index.ts | 11 +- packages/cli/src/lib/addons/addons_wait.ts | 2 +- packages/cli/src/lib/addons/create_addon.ts | 17 +- packages/cli/src/lib/addons/destroy_addon.ts | 11 +- .../unit/commands/addons/info.unit.test.ts | 156 +++++++++-------- .../unit/commands/addons/upgrade.unit.test.ts | 51 +++--- 14 files changed, 258 insertions(+), 226 deletions(-) diff --git a/packages/cli/src/commands/addons/attach.ts b/packages/cli/src/commands/addons/attach.ts index 07465be040..82bd269f5d 100644 --- a/packages/cli/src/commands/addons/attach.ts +++ b/packages/cli/src/commands/addons/attach.ts @@ -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' @@ -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('/addon-attachments', {body}) ux.action.stop() return attachments diff --git a/packages/cli/src/commands/addons/destroy.ts b/packages/cli/src/commands/addons/destroy.ts index 50edc9a1f9..00e2ca015d 100644 --- a/packages/cli/src/commands/addons/destroy.ts +++ b/packages/cli/src/commands/addons/destroy.ts @@ -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' @@ -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)}`) } } diff --git a/packages/cli/src/commands/addons/detach.ts b/packages/cli/src/commands/addons/detach.ts index 0c290349f6..ee6f75cea2 100644 --- a/packages/cli/src/commands/addons/detach.ts +++ b/packages/cli/src/commands/addons/detach.ts @@ -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' @@ -22,7 +22,7 @@ export default class Detach extends Command { const {app} = flags const {body: attachment} = await this.heroku.get(`/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}`) diff --git a/packages/cli/src/commands/addons/index.ts b/packages/cli/src/commands/addons/index.ts index c7101eb812..4b248745f2 100644 --- a/packages/cli/src/commands/addons/index.ts +++ b/packages/cli/src/commands/addons/index.ts @@ -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}) { @@ -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('?') diff --git a/packages/cli/src/commands/addons/info.ts b/packages/cli/src/commands/addons/info.ts index 72928ff884..8d064d3aed 100644 --- a/packages/cli/src/commands/addons/info.ts +++ b/packages/cli/src/commands/addons/info.ts @@ -1,28 +1,30 @@ -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 { - 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) @@ -30,16 +32,16 @@ export default class Info extends Command { 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), }) } diff --git a/packages/cli/src/commands/addons/upgrade.ts b/packages/cli/src/commands/addons/upgrade.ts index cbade77cfe..2d17753408 100644 --- a/packages/cli/src/commands/addons/upgrade.ts +++ b/packages/cli/src/commands/addons/upgrade.ts @@ -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\`. @@ -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 { + try { + const plansResponse: HTTP = 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 { 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 | ExtendedAddon + let resolvedAddon: ExtendedAddon | Required try { resolvedAddon = await addonResolver(this.heroku, app, addon) } catch (error) { @@ -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> = await this.heroku.patch(`/apps/${appName}/addons/${addonName}`, @@ -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')}`) @@ -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 { - try { - const plansResponse: HTTP = 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 [] - } - } } diff --git a/packages/cli/src/commands/addons/wait.ts b/packages/cli/src/commands/addons/wait.ts index 525749993e..c80948eb8e 100644 --- a/packages/cli/src/commands/addons/wait.ts +++ b/packages/cli/src/commands/addons/wait.ts @@ -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 { - 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 & {state?: ExtendedAddon['state'] | 'deprovisioning'} + type AddonWithDeprovisioningState = {state?: 'deprovisioning' | ExtendedAddon['state']} & Omit let addonsToWaitFor: AddonWithDeprovisioningState[] if (args.addon) { addonsToWaitFor = [await resolveAddon(this.heroku, flags.app, args.addon)] @@ -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) { diff --git a/packages/cli/src/commands/apps/create.ts b/packages/cli/src/commands/apps/create.ts index 46a9ed28f1..6173f9e365 100644 --- a/packages/cli/src/commands/apps/create.ts +++ b/packages/cli/src/commands/apps/create.ts @@ -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() } diff --git a/packages/cli/src/commands/drains/index.ts b/packages/cli/src/commands/drains/index.ts index 92cc4cce3d..7c9966235b 100644 --- a/packages/cli/src/commands/drains/index.ts +++ b/packages/cli/src/commands/drains/index.ts @@ -1,8 +1,7 @@ -import {ux} from '@oclif/core' -import {hux} from '@heroku/heroku-cli-util' -import {color} from '@heroku-cli/color' +import {color, hux} from '@heroku/heroku-cli-util' +import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' -import {flags, Command} from '@heroku-cli/command' +import {ux} from '@oclif/core' function styledDrain(id: string, name: string, drain: Heroku.LogDrain) { let output = `${id} (${name})` @@ -15,9 +14,9 @@ export default class Drains extends Command { static flags = { app: flags.app({required: true}), - remote: flags.remote(), extended: flags.boolean({char: 'x', hidden: true}), json: flags.boolean({description: 'output in json format'}), + remote: flags.remote(), } async run() { @@ -58,7 +57,7 @@ export default class Drains extends Command { ) hux.styledHeader('Add-on Drains') addons.forEach(({body: addon}, i) => { - styledDrain(color.yellow(addon.plan?.name || ''), color.green(addon.name || ''), drainsWithAddons[i]) + styledDrain(color.addon(addon.plan?.name || ''), color.addon(addon.name || ''), drainsWithAddons[i]) }) } } diff --git a/packages/cli/src/lib/addons/addons_wait.ts b/packages/cli/src/lib/addons/addons_wait.ts index 1d3a0242c5..598eb00801 100644 --- a/packages/cli/src/lib/addons/addons_wait.ts +++ b/packages/cli/src/lib/addons/addons_wait.ts @@ -1,4 +1,4 @@ -import {color} from '@heroku-cli/color' +import {color} from '@heroku/heroku-cli-util' import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {ux} from '@oclif/core' diff --git a/packages/cli/src/lib/addons/create_addon.ts b/packages/cli/src/lib/addons/create_addon.ts index f8caf57168..133281aa2b 100644 --- a/packages/cli/src/lib/addons/create_addon.ts +++ b/packages/cli/src/lib/addons/create_addon.ts @@ -1,15 +1,16 @@ -import {ux} from '@oclif/core' -import {color} from '@heroku-cli/color' -import * as Heroku from '@heroku-cli/schema' +import {color} from '@heroku/heroku-cli-util' import {APIClient} from '@heroku-cli/command' -import * as util from './util.js' +import * as Heroku from '@heroku-cli/schema' +import {ux} from '@oclif/core' + import {waitForAddonProvisioning} from './addons_wait.js' +import * as util from './util.js' function formatConfigVarsMessage(addon: Heroku.AddOn) { const configVars = addon.config_vars || [] if (configVars.length > 0) { - return `Created ${color.addon(addon.name || '')} as ${configVars.map((c: string) => color.configVar(c)).join(', ')}` + return `Created ${color.addon(addon.name || '')} as ${configVars.map((c: string) => color.name(c)).join(', ')}` } return `Created ${color.addon(addon.name || '')}` @@ -26,11 +27,11 @@ export default async function ( ) { async function createAddonRequest(confirmed?: string) { const body = { + attachment: {name: options.as}, + config: options.config, confirm: confirmed, name: options.name, - config: options.config, plan: {name: plan}, - attachment: {name: options.as}, } ux.action.start(`Creating ${plan} on ${color.app(app)}`) @@ -60,7 +61,7 @@ export default async function ( ux.stdout(formatConfigVarsMessage(addon)) } else { ux.stdout(`${color.addon(addon.name || '')} is being created in the background. The app will restart when complete...`) - ux.stdout(`Use ${color.cmd('heroku addons:info ' + addon.name)} to check creation progress`) + ux.stdout(`Use ${color.command('heroku addons:info ' + addon.name)} to check creation progress`) } } else if (addon.state === 'deprovisioned') { throw new Error(`The add-on was unable to be created, with status ${addon.state}`) diff --git a/packages/cli/src/lib/addons/destroy_addon.ts b/packages/cli/src/lib/addons/destroy_addon.ts index 574fa5c0c9..3febfda538 100644 --- a/packages/cli/src/lib/addons/destroy_addon.ts +++ b/packages/cli/src/lib/addons/destroy_addon.ts @@ -1,8 +1,9 @@ -import {waitForAddonDeprovisioning} from './addons_wait.js' -import {color} from '@heroku-cli/color' -import {ux} from '@oclif/core' +import {color} from '@heroku/heroku-cli-util' import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {ux} from '@oclif/core' + +import {waitForAddonDeprovisioning} from './addons_wait.js' export default async function (heroku: APIClient, addon: Heroku.AddOn, force = false, wait = false) { const addonName = addon.name || '' @@ -11,8 +12,8 @@ export default async function (heroku: APIClient, addon: Heroku.AddOn, force = f ux.action.start(`Destroying ${color.addon(addonName)} on ${color.app(addon.app?.name || '')}`) const {body: addonDelete} = await heroku.delete(`/apps/${addon.app?.id}/addons/${addon.id}`, { - headers: {'Accept-Expansion': 'plan'}, body: {force}, + headers: {'Accept-Expansion': 'plan'}, }).catch(error => { if (error.body && error.body.message) { throw new Error(`The add-on was unable to be destroyed: ${error.body.message}.`) @@ -40,7 +41,7 @@ export default async function (heroku: APIClient, addon: Heroku.AddOn, force = f addonResponse = await waitForAddonDeprovisioning(heroku, addonResponse, 5) } else { ux.stdout(`${color.addon(addonName)} is being destroyed in the background. The app will restart when complete...`) - ux.stdout(`Use ${color.cmd('heroku addons:info ' + addonName)} to check destruction progress`) + ux.stdout(`Use ${color.command('heroku addons:info ' + addonName)} to check destruction progress`) } } else if (addonResponse.state !== 'deprovisioned') { throw new Error(`The add-on was unable to be destroyed, with status ${addonResponse.state}.`) diff --git a/packages/cli/test/unit/commands/addons/info.unit.test.ts b/packages/cli/test/unit/commands/addons/info.unit.test.ts index e9bee973c5..a616e31a75 100644 --- a/packages/cli/test/unit/commands/addons/info.unit.test.ts +++ b/packages/cli/test/unit/commands/addons/info.unit.test.ts @@ -1,33 +1,37 @@ +import nock from 'nock' import {stdout} from 'stdout-stderr' + import Cmd from '../../../../src/commands/addons/info.js' -import runCommand from '../../../helpers/runCommand.js' +import {resolveAddon} from '../../../../src/lib/addons/resolve.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' +import runCommand from '../../../helpers/runCommand.js' import expectOutput from '../../../helpers/utils/expectOutput.js' -import nock from 'nock' -import {resolveAddon} from '../../../../src/lib/addons/resolve.js' const {cache} = resolveAddon describe('addons:info', function () { + let api: nock.Scope + beforeEach(function () { - nock.cleanAll() + api = nock('https://api.heroku.com') cache.clear() }) + afterEach(function () { + api.done() + nock.cleanAll() + }) context('with add-ons', function () { beforeEach(function () { - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: null, addon: 'www-db'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'www-db', app: null}) .reply(200, [fixtures.addons['www-db']]) - nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) - .get(`/addons/${fixtures.addons['www-db'].id}`) - .reply(200, fixtures.addons['www-db']) - nock('https://api.heroku.com') - .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) - .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) + api.get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`).reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) it('prints add-ons in a table', async function () { await runCommand(Cmd, [ @@ -40,7 +44,7 @@ Plan: heroku-postgresql:mini Price: ~$0.007/hour Max Price: $5/month Attachments: acme-inc-www::DATABASE -Owning app: acme-inc-www +Owning app: ⬢ acme-inc-www Installed at: Invalid Date State: created\n `) @@ -49,18 +53,22 @@ State: created\n context('with app add-ons', function () { beforeEach(function () { - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: 'example', addon: 'www-db'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'www-db', app: 'example'}) .reply(200, [fixtures.addons['www-db']]) - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - }}) + nock('https://api.heroku.com', { + reqheaders: { + 'Accept-Expansion': 'addon_service,plan', + }, + }) .get(`/addons/${fixtures.addons['www-db'].id}`) .reply(200, fixtures.addons['www-db']) - nock('https://api.heroku.com') + api .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) @@ -78,7 +86,7 @@ Plan: heroku-postgresql:mini Price: ~$0.007/hour Max Price: $5/month Attachments: acme-inc-www::DATABASE -Owning app: acme-inc-www +Owning app: ⬢ acme-inc-www Installed at: Invalid Date State: created\n `) @@ -86,11 +94,13 @@ State: created\n }) context('with app but not an app add-on', function () { beforeEach(function () { - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: 'example', addon: 'www-db'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'www-db', app: 'example'}) .reply(200, [fixtures.addons['www-db']]) nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) .get('/apps/example/addons/www-db') @@ -101,7 +111,7 @@ State: created\n nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) .get(`/addons/${fixtures.addons['www-db'].id}`) .reply(200, fixtures.addons['www-db']) - nock('https://api.heroku.com') + api .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) @@ -118,7 +128,7 @@ Plan: heroku-postgresql:mini Price: ~$0.007/hour Max Price: $5/month Attachments: acme-inc-www::DATABASE -Owning app: acme-inc-www +Owning app: ⬢ acme-inc-www Installed at: Invalid Date State: created\n `) @@ -129,18 +139,22 @@ State: created\n beforeEach(function () { const addon = fixtures.addons['dwh-db'] addon.billed_price = {cents: 10000} - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: null, addon: 'dwh-db'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'dwh-db', app: null}) .reply(200, [addon]) - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - }}) + nock('https://api.heroku.com', { + reqheaders: { + 'Accept-Expansion': 'addon_service,plan', + }, + }) .get(`/addons/${addon.id}`) .reply(200, addon) - nock('https://api.heroku.com') + api .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) }) @@ -155,7 +169,7 @@ Plan: heroku-postgresql:standard-2 Price: ~$0.139/hour Max Price: $100/month Attachments: acme-inc-dwh::DATABASE -Owning app: acme-inc-dwh +Owning app: ⬢ acme-inc-dwh Installed at: Invalid Date State: created\n `) @@ -166,18 +180,22 @@ State: created\n beforeEach(function () { const addon = fixtures.addons['dwh-db'] addon.billed_price = {cents: 0, contract: true} - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: null, addon: 'dwh-db'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'dwh-db', app: null}) .reply(200, [addon]) - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - }}) + nock('https://api.heroku.com', { + reqheaders: { + 'Accept-Expansion': 'addon_service,plan', + }, + }) .get(`/addons/${addon.id}`) .reply(200, addon) - nock('https://api.heroku.com') + api .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) }) @@ -192,7 +210,7 @@ Plan: heroku-postgresql:standard-2 Price: contract Max Price: contract Attachments: acme-inc-dwh::DATABASE -Owning app: acme-inc-dwh +Owning app: ⬢ acme-inc-dwh Installed at: Invalid Date State: created\n `) @@ -202,16 +220,18 @@ State: created\n context('provisioning add-on', function () { beforeEach(function () { const provisioningAddon = fixtures.addons['www-redis'] - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: null, addon: 'www-redis'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) .reply(200, [provisioningAddon]) nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) .get(`/addons/${provisioningAddon.id}`) .reply(200, provisioningAddon) - nock('https://api.heroku.com') + api .get(`/addons/${provisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) }) @@ -226,7 +246,7 @@ Plan: heroku-redis:premium-2 Price: ~$0.083/hour Max Price: $60/month Attachments: acme-inc-www::REDIS -Owning app: acme-inc-www +Owning app: ⬢ acme-inc-www Installed at: Invalid Date State: creating\n `) @@ -236,16 +256,18 @@ State: creating\n context('deprovisioning add-on', function () { beforeEach(function () { const deprovisioningAddon = fixtures.addons['www-redis-2'] - nock('https://api.heroku.com', {reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - Accept: 'application/vnd.heroku+json; version=3.sdk', - }}) - .post('/actions/addons/resolve', {app: null, addon: 'www-redis-2'}) + nock('https://api.heroku.com', { + reqheaders: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', + }, + }) + .post('/actions/addons/resolve', {addon: 'www-redis-2', app: null}) .reply(200, [deprovisioningAddon]) nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) .get(`/addons/${deprovisioningAddon.id}`) .reply(200, deprovisioningAddon) - nock('https://api.heroku.com') + api .get(`/addons/${deprovisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) }) @@ -260,7 +282,7 @@ Plan: heroku-redis:premium-2 Price: ~$0.083/hour Max Price: $60/month Attachments: acme-inc-www::REDIS -Owning app: acme-inc-www +Owning app: ⬢ acme-inc-www Installed at: Invalid Date State: destroying\n `) diff --git a/packages/cli/test/unit/commands/addons/upgrade.unit.test.ts b/packages/cli/test/unit/commands/addons/upgrade.unit.test.ts index e72bb8aa4f..9ce9e056ae 100644 --- a/packages/cli/test/unit/commands/addons/upgrade.unit.test.ts +++ b/packages/cli/test/unit/commands/addons/upgrade.unit.test.ts @@ -1,16 +1,17 @@ -import {stdout, stderr} from 'stdout-stderr' -import Cmd from '../../../../src/commands/addons/upgrade.js' -import runCommand from '../../../helpers/runCommand.js' -import nock from 'nock' import {AddOn} from '@heroku-cli/schema' import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' import stripAnsi from 'strip-ansi' +import Cmd from '../../../../src/commands/addons/upgrade.js' +import runCommand from '../../../helpers/runCommand.js' + describe('addons:upgrade', function () { let api: ReturnType beforeEach(function () { - api = nock('https://api.heroku.com:443') + api = nock('https://api.heroku.com') }) afterEach(function () { @@ -20,13 +21,13 @@ describe('addons:upgrade', function () { it('upgrades an add-on', async function () { const addon: AddOn = { - name: 'kafka-swiftly-123', addon_service: {name: 'heroku-kafka'}, app: {name: 'myapp'}, + name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } api - .post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-kafka'}) + .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) .reply(200, [addon]) .patch('/apps/myapp/addons/kafka-swiftly-123', {plan: {name: 'heroku-kafka:hobby'}}) .reply(200, {plan: {price: {cents: 0}}, provision_message: 'provision msg'}) @@ -38,19 +39,19 @@ describe('addons:upgrade', function () { 'heroku-kafka:hobby', ]) expect(stdout.output).to.equal('provision msg\n') - expect(stderr.output).to.contain('Changing kafka-swiftly-123 on myapp from premium-0 to heroku-kafka:hobby... done, free') + expect(stderr.output).to.contain('Changing kafka-swiftly-123 on ⬢ myapp from premium-0 to heroku-kafka:hobby... done, free') }) it('displays hourly and monthly price when upgrading an add-on', async function () { const addon: AddOn = { - name: 'kafka-swiftly-123', addon_service: {name: 'heroku-kafka'}, app: {name: 'myapp'}, + name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } api - .post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-kafka'}) + .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) .reply(200, [addon]) .patch('/apps/myapp/addons/kafka-swiftly-123', {plan: {name: 'heroku-kafka:standard'}}) .reply(200, {plan: {price: {cents: 2500, unit: 'month'}}, provision_message: 'provision msg'}) @@ -62,19 +63,19 @@ describe('addons:upgrade', function () { 'heroku-kafka:standard', ]) expect(stdout.output).to.equal('provision msg\n') - expect(stderr.output).to.contain('Changing kafka-swiftly-123 on myapp from premium-0 to heroku-kafka:standard... done, ~$0.035/hour (max $25/month)') + expect(stderr.output).to.contain('Changing kafka-swiftly-123 on ⬢ myapp from premium-0 to heroku-kafka:standard... done, ~$0.035/hour (max $25/month)') }) it('does not display a price when upgrading an add-on and no price is returned from the api', async function () { const addon = { - name: 'kafka-swiftly-123', addon_service: {name: 'heroku-kafka'}, app: {name: 'myapp'}, + name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } api - .post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-kafka'}) + .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) .reply(200, [addon]) .patch('/apps/myapp/addons/kafka-swiftly-123', {plan: {name: 'heroku-kafka:hobby'}}) .reply(200, {plan: {}, provision_message: 'provision msg'}) @@ -86,19 +87,19 @@ describe('addons:upgrade', function () { 'heroku-kafka:hobby', ]) expect(stdout.output).to.equal('provision msg\n') - expect(stderr.output).to.contain('Changing kafka-swiftly-123 on myapp from premium-0 to heroku-kafka:hobby... done') + expect(stderr.output).to.contain('Changing kafka-swiftly-123 on ⬢ myapp from premium-0 to heroku-kafka:hobby... done') }) it('upgrades to a contract add-on', async function () { const addon = { - name: 'connect-swiftly-123', addon_service: {name: 'heroku-connect'}, app: {name: 'myapp'}, + name: 'connect-swiftly-123', plan: {name: 'free'}, } api - .post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-connect'}) + .post('/actions/addons/resolve', {addon: 'heroku-connect', app: 'myapp'}) .reply(200, [addon]) .patch('/apps/myapp/addons/connect-swiftly-123', {plan: {name: 'heroku-connect:contract'}}) .reply(200, {plan: {price: {cents: 0, contract: true}}, provision_message: 'provision msg'}) @@ -110,18 +111,18 @@ describe('addons:upgrade', function () { 'heroku-connect:contract', ]) expect(stdout.output).to.equal('provision msg\n') - expect(stderr.output).to.contain('Changing connect-swiftly-123 on myapp from free to heroku-connect:contract... done, contract') + expect(stderr.output).to.contain('Changing connect-swiftly-123 on ⬢ myapp from free to heroku-connect:contract... done, contract') }) it('upgrades an add-on with only one argument', async function () { const addon = { - name: 'postgresql-swiftly-123', addon_service: {name: 'heroku-postgresql'}, app: {name: 'myapp'}, + name: 'postgresql-swiftly-123', plan: {name: 'premium-0'}, } api - .post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-postgresql'}) + .post('/actions/addons/resolve', {addon: 'heroku-postgresql', app: 'myapp'}) .reply(200, [addon]) .patch('/apps/myapp/addons/postgresql-swiftly-123', {plan: {name: 'heroku-postgresql:hobby'}}) .reply(200, {plan: {price: {cents: 0}}}) @@ -132,7 +133,7 @@ describe('addons:upgrade', function () { 'heroku-postgresql:hobby', ]) expect(stdout.output, 'to be empty') - expect(stderr.output).to.contain('Changing postgresql-swiftly-123 on myapp from premium-0 to heroku-postgresql:hobby... done, free') + expect(stderr.output).to.contain('Changing postgresql-swiftly-123 on ⬢ myapp from premium-0 to heroku-postgresql:hobby... done, free') }) it('errors with no plan', async function () { @@ -151,14 +152,14 @@ describe('addons:upgrade', function () { it('errors with invalid plan', async function () { const addon = { - name: 'db1-swiftly-123', addon_service: {name: 'heroku-db1'}, app: {name: 'myapp'}, + name: 'db1-swiftly-123', plan: {name: 'premium-0'}, } api - .post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-db1'}) + .post('/actions/addons/resolve', {addon: 'heroku-db1', app: 'myapp'}) .reply(200, [addon]) .get('/addon-services/heroku-db1/plans') .reply(200, [ @@ -183,8 +184,8 @@ describe('addons:upgrade', function () { }) it('displays an error when multiple matches exist', async function () { - api.post('/actions/addons/resolve', {app: 'myapp', addon: 'heroku-postgresql'}) - .reply(422, {message: 'Multiple matches', id: 'multiple_matches'}) + api.post('/actions/addons/resolve', {addon: 'heroku-postgresql', app: 'myapp'}) + .reply(422, {id: 'multiple_matches', message: 'Multiple matches'}) try { await runCommand(Cmd, [ '--app', @@ -199,7 +200,7 @@ describe('addons:upgrade', function () { }) it('handles multiple add-ons', async function () { - api.post('/actions/addons/resolve', {app: null, addon: 'heroku-redis'}) + api.post('/actions/addons/resolve', {addon: 'heroku-redis', app: null}) .reply(200, [{name: 'db1-swiftly-123'}, {name: 'db1-swiftly-456'}]) try { await runCommand(Cmd, [