From cc7b8650f621440d94217bb0b800787aa7246d5e Mon Sep 17 00:00:00 2001 From: Eric Black Date: Tue, 19 May 2026 14:19:28 -0700 Subject: [PATCH 01/11] refactor: use @heroku/sdk for addons list and addons:info - addons (list) now calls SDK addOn.list / addOn.listByApp and addOnAttachment.list / addOnAttachment.listByApp. The Accept-Expansion: addon_service,plan header that drives nested service/plan inlining is now passed once via createPlatformClient options. - addons:info now uses addOnAttachment.listByAddOn for the attachment fetch. resolveAddon keeps its existing /actions/addons/resolve flow and cache. --- src/commands/addons/index.ts | 36 +++++++++++++++++------------------- src/commands/addons/info.ts | 4 +++- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/commands/addons/index.ts b/src/commands/addons/index.ts index f34fc2525f..acfd7bc8e2 100644 --- a/src/commands/addons/index.ts +++ b/src/commands/addons/index.ts @@ -1,12 +1,19 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {createPlatformClient} from '@heroku/sdk/platform' import {ux} from '@oclif/core/ux' import _ from 'lodash' import {formatPrice, formatState, grandfatheredPrice} from '../../lib/addons/util.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' +// The Platform expands nested addon_service and plan when this header is set. +const ADDON_EXPANSION_HEADERS = { + Accept: 'application/vnd.heroku+json; version=3.sdk', + 'Accept-Expansion': 'addon_service,plan', +} + const topic = 'addons' export default class Addons extends Command { @@ -58,43 +65,34 @@ export function renderAttachment(attachment: Heroku.AddOnAttachment, app: string } async function addonGetter(api: APIClient, app?: string) { - let attachmentsResponse: null | ReturnType> = null - let addonsResponse: ReturnType> + const heroku = createPlatformClient({headers: ADDON_EXPANSION_HEADERS}) + let attachmentsResponse: null | Promise = null + let addonsResponse: Promise if (app) { // don't display attachments globally - addonsResponse = api.get(`/apps/${app}/addons`, { - headers: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - 'Accept-Expansion': 'addon_service,plan', - }, - }) + addonsResponse = heroku.addOn.listByApp(app) as unknown as Promise const sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}') // eslint-disable-next-line unicorn/prefer-ternary 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. - attachmentsResponse = api.get(`/apps/${app}/addon-attachments`) + attachmentsResponse = heroku.addOnAttachment.listByApp(app) as unknown as Promise } else { // In order to display all foreign attachments, we'll get out entire // attachment list - attachmentsResponse = api.get('/addon-attachments') + attachmentsResponse = heroku.addOnAttachment.list() as unknown as Promise } } else { - addonsResponse = api.get('/addons', { - headers: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - 'Accept-Expansion': 'addon_service,plan', - }, - }) + addonsResponse = heroku.addOn.list() as unknown as Promise } // Get addons and attachments in parallel - const [{body: addonsRaw}, potentialAttachments] = await Promise.all([addonsResponse, attachmentsResponse]) + const [addonsRaw, potentialAttachments] = await Promise.all([addonsResponse, attachmentsResponse]) function isRelevantToApp(addon: Heroku.AddOn) { return !app || addon.app?.name === app || _.some(addon.attachments, att => att.app.name === app) } - const groupedAttachments = _.groupBy(potentialAttachments?.body, 'addon.id') + const groupedAttachments = _.groupBy(potentialAttachments ?? [], 'addon.id') const addons: Heroku.AddOn[] = [] addonsRaw.forEach((addon: Heroku.AddOn) => { addon.attachments = groupedAttachments[addon.id as string] || [] @@ -114,7 +112,7 @@ async function addonGetter(api: APIClient, app?: string) { // if the attachment looks relevant to the app, and then render whatever for (const atts of _.values(groupedAttachments)) { const inaccessibleAddon = { - addon_service: {}, app: atts[0].addon.app, attachments: atts, name: atts[0].addon.name, plan: {}, + addon_service: {}, app: atts[0].addon!.app, attachments: atts, name: atts[0].addon!.name, plan: {}, } if (isRelevantToApp(inaccessibleAddon)) { addons.push(inaccessibleAddon) diff --git a/src/commands/addons/info.ts b/src/commands/addons/info.ts index dc90273d81..9b2a07680b 100644 --- a/src/commands/addons/info.ts +++ b/src/commands/addons/info.ts @@ -1,6 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {createPlatformClient} from '@heroku/sdk/platform' import {Args} from '@oclif/core' import {resolveAddon} from '../../lib/addons/resolve.js' @@ -25,7 +26,8 @@ export default class Info extends Command { const {app} = flags const addon = await resolveAddon(this.heroku, app, args.addon) - const {body: attachments} = await this.heroku.get(`/addons/${addon.id}/addon-attachments`) + const heroku = createPlatformClient() + const attachments = await heroku.addOnAttachment.listByAddOn(addon.id!) as unknown as Heroku.AddOnAttachment[] addon.plan.price = grandfatheredPrice(addon) addon.attachments = attachments From fa0d59e5ac84994790c668f4507671911b3aaae7 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Wed, 20 May 2026 16:33:24 -0700 Subject: [PATCH 02/11] refactor: use @heroku/sdk for addons commands Replace direct Platform API calls in addons (list), addons:info, addons:create, addons:services, addons:plans, and addons:upgrade with @heroku/sdk equivalents: - addOn.list / addOn.listByApp + addOnAttachment.list / listByApp - describeAddon (resolves, fetches attachments, applies grandfathered pricing in one call) - addOn.create - addOnService.list - plan.listByAddOn - upgrade composition (resolves + updates in one call, with the onResolved callback firing between for the action display line) Drops legacy lib/addons/resolve.ts dependency from upgrade in favor of the SDK's typed AddonAmbiguousError. Tests updated for the SDK's body/header/error shapes (UUIDs in PATCH paths, no `app: null` on resolve bodies, etc.). Adds tmp/ to the eslint ignore list (build artifacts produced 138k unrelated lint errors). --- package.json | 2 +- src/commands/addons/info.ts | 22 +++--- src/commands/addons/plans.ts | 8 +- src/commands/addons/services.ts | 7 +- src/commands/addons/upgrade.ts | 77 +++++++++---------- src/lib/addons/create-addon.ts | 12 +-- test/unit/commands/addons/create.unit.test.ts | 32 +++----- test/unit/commands/addons/info.unit.test.ts | 68 ++++------------ .../unit/commands/addons/upgrade.unit.test.ts | 32 ++++---- 9 files changed, 104 insertions(+), 156 deletions(-) diff --git a/package.json b/package.json index 287808bca9..0c10782bfd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#main", + "@heroku/sdk": "github:heroku/heroku-sdk#eb/fix/describe-addon-expansion", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", diff --git a/src/commands/addons/info.ts b/src/commands/addons/info.ts index 9b2a07680b..56627da4e9 100644 --- a/src/commands/addons/info.ts +++ b/src/commands/addons/info.ts @@ -1,11 +1,10 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import {createPlatformClient} from '@heroku/sdk/platform' +import {describeAddon} from '@heroku/sdk/compositions/add-on' import {Args} from '@oclif/core' -import {resolveAddon} from '../../lib/addons/resolve.js' -import {formatPrice, formatState, grandfatheredPrice} from '../../lib/addons/util.js' +import {formatPrice, formatState} from '../../lib/addons/util.js' const topic = 'addons' @@ -25,22 +24,19 @@ export default class Info extends Command { const {args, flags} = await this.parse(Info) const {app} = flags - const addon = await resolveAddon(this.heroku, app, args.addon) - const heroku = createPlatformClient() - const attachments = await heroku.addOnAttachment.listByAddOn(addon.id!) as unknown as Heroku.AddOnAttachment[] + const addon = await describeAddon(args.addon, {appIdentity: app}) + const plan = addon.plan as undefined | {name?: string; price?: Heroku.AddOn['price']} - addon.plan.price = grandfatheredPrice(addon) - addon.attachments = attachments hux.styledHeader(color.addon(addon.name ?? '')) /* eslint-disable perfectionist/sort-objects */ hux.styledObject({ - Plan: addon.plan.name, - Price: formatPrice({hourly: true, price: addon.plan.price}), - 'Max Price': formatPrice({hourly: false, price: addon.plan.price}), - Attachments: addon.attachments.map((att: Heroku.AddOnAttachment) => [ + Plan: plan?.name, + Price: formatPrice({hourly: true, price: plan?.price}), + 'Max Price': formatPrice({hourly: false, price: plan?.price}), + Attachments: addon.attachments.map(att => [ color.app(att.app?.name || ''), color.attachment(att.name || ''), ].join('::')).sort(), - 'Owning app': color.app(addon.app?.name ?? ''), + 'Owning app': color.app(addon.app.name ?? ''), 'Installed at': (new Date(addon.created_at ?? '')) .toString(), State: formatState(addon.state), diff --git a/src/commands/addons/plans.ts b/src/commands/addons/plans.ts index 8f9e03bc39..0dbdabc841 100644 --- a/src/commands/addons/plans.ts +++ b/src/commands/addons/plans.ts @@ -1,6 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import {Plan} from '@heroku-cli/schema' import {hux} from '@heroku/heroku-cli-util' +import {createPlatformClient} from '@heroku/sdk/platform' import {Args} from '@oclif/core' import _ from 'lodash' import printf from 'printf' @@ -29,11 +30,8 @@ export default class Plans extends Command { public async run(): Promise { const {args, flags} = await this.parse(Plans) const {service} = args - let {body: plans} = await this.heroku.get(`/addon-services/${service}/plans`, { - headers: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - }, - }) + const heroku = createPlatformClient() + let plans = (await heroku.plan.listByAddOn(service)) as unknown as PlanWithMeteredPrice[] plans = _.sortBy(plans, ['price.contract', 'price.cents']) if (flags.json) { hux.styledJSON(plans) diff --git a/src/commands/addons/services.ts b/src/commands/addons/services.ts index fd1133e41f..cdf8219b85 100644 --- a/src/commands/addons/services.ts +++ b/src/commands/addons/services.ts @@ -1,6 +1,6 @@ import {Command, flags} from '@heroku-cli/command' -import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {createPlatformClient} from '@heroku/sdk/platform' import {ux} from '@oclif/core/ux' export default class Services extends Command { @@ -12,12 +12,13 @@ export default class Services extends Command { public async run(): Promise { const {flags} = await this.parse(Services) - const {body: services} = await this.heroku.get('/addon-services') + const heroku = createPlatformClient() + const services = await heroku.addOnService.list() if (flags.json) { hux.styledJSON(services) } else { /* eslint-disable perfectionist/sort-objects */ - hux.table(services, { + hux.table(services as Array>, { name: { header: 'Slug', }, diff --git a/src/commands/addons/upgrade.ts b/src/commands/addons/upgrade.ts index 9644e58e71..9913536cad 100644 --- a/src/commands/addons/upgrade.ts +++ b/src/commands/addons/upgrade.ts @@ -1,16 +1,17 @@ import type {AddOn, Plan} from '@heroku-cli/schema' import {Command, flags} from '@heroku-cli/command' -import {HerokuAPIError} from '@heroku-cli/command/lib/api-client.js' import * as color from '@heroku/heroku-cli-util/color' -import {HTTP} from '@heroku/http-call' +import {AddonAmbiguousError, upgrade as upgradeAddon} from '@heroku/sdk/compositions/add-on' +import {createPlatformClient} from '@heroku/sdk/platform' 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' +function isApiError(error: unknown): error is Error & {statusCode: number} { + return error instanceof Error && 'statusCode' in error && typeof (error as {statusCode?: unknown}).statusCode === 'number' +} + export default class Upgrade extends Command { static aliases = ['addons:downgrade'] static args = { @@ -79,9 +80,13 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}` } protected async getPlans(addonServiceName: string | undefined): Promise { + if (!addonServiceName) { + return [] + } + try { - const plansResponse: HTTP = await this.heroku.get(`/addon-services/${addonServiceName}/plans`) - const {body: plans} = plansResponse + const heroku = createPlatformClient() + const plans = (await heroku.plan.listByAddOn(addonServiceName)) as unknown as Plan[] plans.sort((a, b) => { if (a?.price?.cents === b?.price?.cents) { return 0 @@ -109,43 +114,31 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}` // called with just one argument in the form of `heroku addons:upgrade heroku-redis:hobby` const {addon, plan} = this.getAddonPartsFromArgs(args) - let resolvedAddon: ExtendedAddon | Required - try { - resolvedAddon = await addonResolver(this.heroku, app, addon) - } catch (error) { - if (error instanceof HerokuAPIError && error.http.statusCode === 422 && error.body.id === 'multiple_matches') { - throw new Error(this.buildApiErrorMessage(error.http.body.message, ctx)) - } - - throw error - } - - const {name: addonServiceName} = resolvedAddon.addon_service - const {name: appName} = resolvedAddon.app - const {name: addonName, plan: resolvedAddonPlan} = resolvedAddon ?? {} - const updatedPlanName = `${addonServiceName}:${plan}` - ux.action.start(`Changing ${color.addon(addonName ?? '')} on ${color.app(appName ?? '')} from ${color.blue(resolvedAddonPlan?.name ?? '')} to ${color.blue(updatedPlanName)}`) + let addonServiceName: string | undefined + let updatedAddon: Required try { - const patchResult: HTTP> = await this.heroku.patch( - `/apps/${appName}/addons/${addonName}`, - { - body: {plan: {name: updatedPlanName}}, - headers: { - 'Accept-Expansion': 'plan', 'X-Heroku-Legacy-Provider-Messages': 'true', - }, + updatedAddon = await upgradeAddon(addon, plan, { + appIdentity: app, + onResolved(resolved) { + addonServiceName = (resolved.addon_service as undefined | {name?: string})?.name + const resolvedPlan = resolved.plan as undefined | {name?: string} + const updatedPlanName = plan.includes(':') ? plan : `${addonServiceName}:${plan}` + ux.action.start(`Changing ${color.addon(resolved.name ?? '')} on ${color.app(resolved.app.name ?? '')} from ${color.blue(resolvedPlan?.name ?? '')} to ${color.blue(updatedPlanName)}`) }, - ) - resolvedAddon = patchResult.body + }) as Required } catch (error) { + if (error instanceof AddonAmbiguousError) { + // eslint-disable-next-line unicorn/prefer-type-error + throw new Error(this.buildApiErrorMessage(error.message, ctx)) + } + let errorToThrow = error as Error - if (error instanceof HerokuAPIError) { - const {http} = error - if (http.statusCode === 422 - && http.body.message - && http.body.message.startsWith('Couldn\'t find either the add-on')) { + if (isApiError(error)) { + const message = error.message || '' + if (error.statusCode === 422 && message.startsWith('Couldn\'t find either the add-on')) { const plans = await this.getPlans(addonServiceName) - errorToThrow = new Error(`${http.body.message} + errorToThrow = new Error(`${message} 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)} @@ -156,11 +149,13 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}`) ux.action.stop() throw errorToThrow } + + throw errorToThrow } - ux.action.stop(`done${resolvedAddon.plan?.price ? `, ${formatPriceText(resolvedAddon.plan.price)}` : ''}`) - if (resolvedAddon.provision_message) { - ux.stdout(resolvedAddon.provision_message) + ux.action.stop(`done${updatedAddon.plan?.price ? `, ${formatPriceText(updatedAddon.plan.price)}` : ''}`) + if (updatedAddon.provision_message) { + ux.stdout(updatedAddon.provision_message) } } } diff --git a/src/lib/addons/create-addon.ts b/src/lib/addons/create-addon.ts index c18cf3f3c0..276b6dd98e 100644 --- a/src/lib/addons/create-addon.ts +++ b/src/lib/addons/create-addon.ts @@ -1,6 +1,7 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' +import {createPlatformClient} from '@heroku/sdk/platform' import {ux} from '@oclif/core/ux' import {waitForAddonProvisioning} from './addons-wait.js' @@ -37,18 +38,13 @@ export default async function createAddon( config: options.config, confirm: confirmed, name: options.name, - plan: {name: plan}, + plan, } try { ux.action.start(options.actionStartMessage || `Creating ${plan} on ${color.app(app)}`) - const {body: addon} = await heroku.post(`/apps/${app}/addons`, { - body, - headers: { - 'accept-expansion': 'plan', - 'x-heroku-legacy-provider-messages': 'true', - }, - }) + const sdk = createPlatformClient() + const addon = await sdk.addOn.create(app, body as Parameters[1]) as Heroku.AddOn ux.action.stop(options.actionStopMessage || color.green(util.formatPriceText(addon.plan?.price || ''))) return addon diff --git a/test/unit/commands/addons/create.unit.test.ts b/test/unit/commands/addons/create.unit.test.ts index 37afa7a7d1..360653e357 100644 --- a/test/unit/commands/addons/create.unit.test.ts +++ b/test/unit/commands/addons/create.unit.test.ts @@ -4,7 +4,6 @@ import {HTTPError} from '@heroku/http-call' import ansis from 'ansis' import {expect} from 'chai' import _ from 'lodash' -import lolex from 'lolex' import nock from 'nock' import {createSandbox} from 'sinon' @@ -42,7 +41,7 @@ describe('addons:create', function () { context('creating a db with a name', function () { beforeEach(function () { api.post('/apps/myapp/addons', { - attachment: {}, config: {}, name: 'foobar', plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {}, config: {}, name: 'foobar', plan: 'heroku-postgresql:standard-0', }) .reply(200, addon) }) @@ -76,7 +75,7 @@ describe('addons:create', function () { api.post('/apps/myapp/addons', { attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, - plan: {name: 'heroku-postgresql:standard-0'}, + plan: 'heroku-postgresql:standard-0', }) .reply(200, addon) }) @@ -145,7 +144,7 @@ describe('addons:create', function () { beforeEach(function () { const asyncAddon = {..._.clone(addon), config_vars: [], state: 'provisioning'} api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', }) .reply(200, asyncAddon) }) @@ -167,7 +166,7 @@ describe('addons:create', function () { ..._.clone(addon), config_vars: [], provision_message: undefined, state: 'provisioning', } api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', }) .reply(200, asyncAddon) }) @@ -187,7 +186,7 @@ describe('addons:create', function () { beforeEach(function () { const asyncAddon = {..._.clone(addon), config_vars: undefined, state: 'provisioning'} api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', }) .reply(200, asyncAddon) }) @@ -204,25 +203,18 @@ describe('addons:create', function () { }) }) context('--wait', function () { - let clock: ReturnType let sandbox: ReturnType beforeEach(function () { sandbox = createSandbox() - clock = lolex.install() - clock.setTimeout = function (callback: () => void, _timeout: number, ..._args: any[]): number { - callback() - return 1 - } }) afterEach(function () { - clock.uninstall() sandbox.restore() }) it('waits for response and notifies', async function () { const notifySpy = sandbox.spy(Cmd, 'notifier') const asyncAddon = {..._.clone(addon), state: 'provisioning'} const post = api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {wait: true}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {wait: true}, plan: 'heroku-postgresql:standard-0', }) .reply(200, asyncAddon) const provisioningResponse = api.get('/apps/myapp/addons/postgresql-swiftly-123') @@ -253,7 +245,7 @@ describe('addons:create', function () { const asyncAddon = _.clone(addon) asyncAddon.state = 'provisioning' api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {wait: true}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {wait: true}, plan: 'heroku-postgresql:standard-0', }) .reply(200, asyncAddon) api.get('/apps/myapp/addons/postgresql-swiftly-123') @@ -284,7 +276,7 @@ describe('addons:create', function () { const deprovisionedAddon = _.clone(addon) deprovisionedAddon.state = 'deprovisioned' api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', }) .reply(200, deprovisionedAddon) const {error} = await runCommand(Cmd, [ @@ -301,7 +293,7 @@ describe('addons:create', function () { context('creating a db requiring confirmation', function () { it('aborts if confirmation does not match', function () { api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, confirm: 'not-my-app', plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, confirm: 'not-my-app', plan: 'heroku-postgresql:standard-0', }) .reply(423, {id: 'confirmation_required', message: 'This add-on is not automatically networked with this Private Space. '}, {'X-Confirmation-Required': 'myapp-confirm'}) @@ -326,7 +318,7 @@ describe('addons:create', function () { it('succeeds if confirmation does match', async function () { api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, confirm: 'myapp', plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {follow: 'otherdb', foo: true, rollback: true}, confirm: 'myapp', plan: 'heroku-postgresql:standard-0', }) .reply(200, addon) const {stderr, stdout} = await runCommand(Cmd, [ @@ -350,7 +342,7 @@ describe('addons:create', function () { context('--follow=--otherdb', function () { beforeEach(function () { api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {follow: '--otherdb', foo: true, rollback: true}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {follow: '--otherdb', foo: true, rollback: true}, plan: 'heroku-postgresql:standard-0', }) .reply(200, addon) }) @@ -373,7 +365,7 @@ describe('addons:create', function () { const noConfigAddon = {..._.clone(addon), config_vars: undefined} api.post('/apps/myapp/addons', { - attachment: {name: 'mydb'}, config: {}, plan: {name: 'heroku-postgresql:standard-0'}, + attachment: {name: 'mydb'}, config: {}, plan: 'heroku-postgresql:standard-0', }) .reply(200, noConfigAddon) }) diff --git a/test/unit/commands/addons/info.unit.test.ts b/test/unit/commands/addons/info.unit.test.ts index b660180d3f..a2ed81de0e 100644 --- a/test/unit/commands/addons/info.unit.test.ts +++ b/test/unit/commands/addons/info.unit.test.ts @@ -2,11 +2,8 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import nock from 'nock' import Cmd from '../../../../src/commands/addons/info.js' -import {resolveAddon} from '../../../../src/lib/addons/resolve.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' -const {cache} = resolveAddon - describe('addons:info', function () { let api: nock.Scope let apiSdk: nock.Scope @@ -19,7 +16,6 @@ describe('addons:info', function () { 'Accept-Expansion': 'addon_service,plan', }, }) - cache.clear() }) afterEach(function () { @@ -31,9 +27,9 @@ describe('addons:info', function () { context('with add-ons', function () { beforeEach(function () { apiSdk - .post('/actions/addons/resolve', {addon: 'www-db', app: null}) + .post('/actions/addons/resolve', {addon: 'www-db'}) .reply(200, [fixtures.addons['www-db']]) - api.get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`).reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) + apiSdk.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 () { const {stdout} = await runCommand(Cmd, [ @@ -57,14 +53,7 @@ State: created\n apiSdk .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(`/addons/${fixtures.addons['www-db'].id}`) - .reply(200, fixtures.addons['www-db']) - api + apiSdk .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) @@ -89,19 +78,14 @@ State: created\n }) context('with app but not an app add-on', function () { beforeEach(function () { + // The SDK's resolver tries app-scoped first, falls back to global on 404 add_on. apiSdk .post('/actions/addons/resolve', {addon: 'www-db', app: 'example'}) + .reply(404, {id: 'not_found', resource: 'add_on'}) + apiSdk + .post('/actions/addons/resolve', {addon: 'www-db'}) .reply(200, [fixtures.addons['www-db']]) - nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) - .get('/apps/example/addons/www-db') - .reply(404) - nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) - .get('/addons/www-db') - .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']) - api + apiSdk .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) @@ -130,16 +114,9 @@ State: created\n const addon = fixtures.addons['dwh-db'] addon.billed_price = {cents: 10_000} apiSdk - .post('/actions/addons/resolve', {addon: 'dwh-db', app: null}) + .post('/actions/addons/resolve', {addon: 'dwh-db'}) .reply(200, [addon]) - nock('https://api.heroku.com', { - reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - }, - }) - .get(`/addons/${addon.id}`) - .reply(200, addon) - api + apiSdk .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) }) @@ -166,16 +143,9 @@ State: created\n const addon = fixtures.addons['dwh-db'] addon.billed_price = {cents: 0, contract: true} apiSdk - .post('/actions/addons/resolve', {addon: 'dwh-db', app: null}) + .post('/actions/addons/resolve', {addon: 'dwh-db'}) .reply(200, [addon]) - nock('https://api.heroku.com', { - reqheaders: { - 'Accept-Expansion': 'addon_service,plan', - }, - }) - .get(`/addons/${addon.id}`) - .reply(200, addon) - api + apiSdk .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) }) @@ -201,12 +171,9 @@ State: created\n beforeEach(function () { const provisioningAddon = fixtures.addons['www-redis'] apiSdk - .post('/actions/addons/resolve', {addon: 'www-redis', app: null}) + .post('/actions/addons/resolve', {addon: 'www-redis'}) .reply(200, [provisioningAddon]) - nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) - .get(`/addons/${provisioningAddon.id}`) - .reply(200, provisioningAddon) - api + apiSdk .get(`/addons/${provisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) }) @@ -232,12 +199,9 @@ State: creating\n beforeEach(function () { const deprovisioningAddon = fixtures.addons['www-redis-2'] apiSdk - .post('/actions/addons/resolve', {addon: 'www-redis-2', app: null}) + .post('/actions/addons/resolve', {addon: 'www-redis-2'}) .reply(200, [deprovisioningAddon]) - nock('https://api.heroku.com', {reqheaders: {'Accept-Expansion': 'addon_service,plan'}}) - .get(`/addons/${deprovisioningAddon.id}`) - .reply(200, deprovisioningAddon) - api + apiSdk .get(`/addons/${deprovisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) }) diff --git a/test/unit/commands/addons/upgrade.unit.test.ts b/test/unit/commands/addons/upgrade.unit.test.ts index 9721d8c9f6..1150439d12 100644 --- a/test/unit/commands/addons/upgrade.unit.test.ts +++ b/test/unit/commands/addons/upgrade.unit.test.ts @@ -21,14 +21,15 @@ describe('addons:upgrade', function () { it('upgrades an add-on', async function () { const addon: AddOn = { addon_service: {name: 'heroku-kafka'}, - app: {name: 'myapp'}, + app: {id: 'app-1', name: 'myapp'}, + id: 'addon-1', name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } api .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) .reply(200, [addon]) - .patch('/apps/myapp/addons/kafka-swiftly-123', {plan: {name: 'heroku-kafka:hobby'}}) + .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-kafka:hobby'}) .reply(200, {plan: {price: {cents: 0}}, provision_message: 'provision msg'}) const {stderr, stdout} = await runCommand(Cmd, [ @@ -44,7 +45,8 @@ describe('addons:upgrade', function () { it('displays hourly and monthly price when upgrading an add-on', async function () { const addon: AddOn = { addon_service: {name: 'heroku-kafka'}, - app: {name: 'myapp'}, + app: {id: 'app-1', name: 'myapp'}, + id: 'addon-1', name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } @@ -52,7 +54,7 @@ describe('addons:upgrade', function () { api .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) .reply(200, [addon]) - .patch('/apps/myapp/addons/kafka-swiftly-123', {plan: {name: 'heroku-kafka:standard'}}) + .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-kafka:standard'}) .reply(200, {plan: {price: {cents: 2500, unit: 'month'}}, provision_message: 'provision msg'}) const {stderr, stdout} = await runCommand(Cmd, [ @@ -68,7 +70,8 @@ describe('addons:upgrade', function () { it('does not display a price when upgrading an add-on and no price is returned from the api', async function () { const addon = { addon_service: {name: 'heroku-kafka'}, - app: {name: 'myapp'}, + app: {id: 'app-1', name: 'myapp'}, + id: 'addon-1', name: 'kafka-swiftly-123', plan: {name: 'premium-0'}, } @@ -76,7 +79,7 @@ describe('addons:upgrade', function () { api .post('/actions/addons/resolve', {addon: 'heroku-kafka', app: 'myapp'}) .reply(200, [addon]) - .patch('/apps/myapp/addons/kafka-swiftly-123', {plan: {name: 'heroku-kafka:hobby'}}) + .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-kafka:hobby'}) .reply(200, {plan: {}, provision_message: 'provision msg'}) const {stderr, stdout} = await runCommand(Cmd, [ @@ -92,7 +95,8 @@ describe('addons:upgrade', function () { it('upgrades to a contract add-on', async function () { const addon = { addon_service: {name: 'heroku-connect'}, - app: {name: 'myapp'}, + app: {id: 'app-1', name: 'myapp'}, + id: 'addon-1', name: 'connect-swiftly-123', plan: {name: 'free'}, } @@ -100,7 +104,7 @@ describe('addons:upgrade', function () { api .post('/actions/addons/resolve', {addon: 'heroku-connect', app: 'myapp'}) .reply(200, [addon]) - .patch('/apps/myapp/addons/connect-swiftly-123', {plan: {name: 'heroku-connect:contract'}}) + .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-connect:contract'}) .reply(200, {plan: {price: {cents: 0, contract: true}}, provision_message: 'provision msg'}) const {stderr, stdout} = await runCommand(Cmd, [ @@ -116,14 +120,15 @@ describe('addons:upgrade', function () { it('upgrades an add-on with only one argument', async function () { const addon = { addon_service: {name: 'heroku-postgresql'}, - app: {name: 'myapp'}, + app: {id: 'app-1', name: 'myapp'}, + id: 'addon-1', name: 'postgresql-swiftly-123', plan: {name: 'premium-0'}, } api .post('/actions/addons/resolve', {addon: 'heroku-postgresql', app: 'myapp'}) .reply(200, [addon]) - .patch('/apps/myapp/addons/postgresql-swiftly-123', {plan: {name: 'heroku-postgresql:hobby'}}) + .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-postgresql:hobby'}) .reply(200, {plan: {price: {cents: 0}}}) const {stderr, stdout} = await runCommand(Cmd, [ @@ -152,7 +157,8 @@ describe('addons:upgrade', function () { it('errors with invalid plan', async function () { const addon = { addon_service: {name: 'heroku-db1'}, - app: {name: 'myapp'}, + app: {id: 'app-1', name: 'myapp'}, + id: 'addon-1', name: 'db1-swiftly-123', plan: {name: 'premium-0'}, } @@ -166,7 +172,7 @@ describe('addons:upgrade', function () { {name: 'heroku-db1:basic', plan: {cents: 25}}, {name: 'heroku-db1:premium-0', price: {cents: 3500}}, ]) - .patch('/apps/myapp/addons/db1-swiftly-123', {plan: {name: 'heroku-db1:invalid'}}) + .patch('/apps/app-1/addons/addon-1', {plan: 'heroku-db1:invalid'}) .reply(422, {message: 'Couldn\'t find either the add-on service or the add-on plan of "heroku-db1:invalid".'}) try { @@ -199,7 +205,7 @@ describe('addons:upgrade', function () { }) it('handles multiple add-ons', async function () { - api.post('/actions/addons/resolve', {addon: 'heroku-redis', app: null}) + api.post('/actions/addons/resolve', {addon: 'heroku-redis'}) .reply(200, [{name: 'db1-swiftly-123'}, {name: 'db1-swiftly-456'}]) try { await runCommand(Cmd, [ From c09408b8fb4523b8347eaaa875fc172a3a8d60b2 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 11:20:26 -0700 Subject: [PATCH 03/11] refactor: migrate addons/maintenance commands to @heroku/sdk extensions The SDK was rearchitected to replace the compositions/ helpers with a HerokuSDK + extension factory pattern. Update each command to construct a HerokuSDK with the extensions it needs and call methods through the resulting platform proxy. Also bumps the SDK pin to the working branch so addOnExtensions.upgrade exposes onResolved (previously dropped from the extension's options type). --- package.json | 2 +- src/commands/addons/info.ts | 6 ++++-- src/commands/addons/upgrade.ts | 28 +++++++--------------------- src/commands/maintenance/off.ts | 6 ++++-- src/commands/maintenance/on.ts | 6 ++++-- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 0c10782bfd..39f0a7912a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#eb/fix/describe-addon-expansion", + "@heroku/sdk": "github:heroku/heroku-sdk#eb/fix/filters-apps-accept-header", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", diff --git a/src/commands/addons/info.ts b/src/commands/addons/info.ts index 56627da4e9..0b03d2e677 100644 --- a/src/commands/addons/info.ts +++ b/src/commands/addons/info.ts @@ -1,7 +1,8 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import {describeAddon} from '@heroku/sdk/compositions/add-on' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' +import {HerokuSDK} from '@heroku/sdk/sdk' import {Args} from '@oclif/core' import {formatPrice, formatState} from '../../lib/addons/util.js' @@ -24,7 +25,8 @@ export default class Info extends Command { const {args, flags} = await this.parse(Info) const {app} = flags - const addon = await describeAddon(args.addon, {appIdentity: app}) + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) + const addon = await platform.addOn.describe(args.addon, {appIdentity: app}) const plan = addon.plan as undefined | {name?: string; price?: Heroku.AddOn['price']} hux.styledHeader(color.addon(addon.name ?? '')) diff --git a/src/commands/addons/upgrade.ts b/src/commands/addons/upgrade.ts index 9913536cad..031e0cdd46 100644 --- a/src/commands/addons/upgrade.ts +++ b/src/commands/addons/upgrade.ts @@ -2,8 +2,9 @@ import type {AddOn, Plan} from '@heroku-cli/schema' import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {AddonAmbiguousError, upgrade as upgradeAddon} from '@heroku/sdk/compositions/add-on' -import {createPlatformClient} from '@heroku/sdk/platform' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' +import {AddonAmbiguousError} from '@heroku/sdk/resources/platform/add-on' +import {HerokuSDK} from '@heroku/sdk/sdk' import {Args, ux} from '@oclif/core' import {formatPriceText} from '../../lib/addons/util.js' @@ -85,24 +86,8 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}` } try { - const heroku = createPlatformClient() - const plans = (await heroku.plan.listByAddOn(addonServiceName)) as unknown as Plan[] - 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 + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) + return (await platform.addOn.listPlans(addonServiceName)) as unknown as Plan[] } catch { return [] } @@ -114,11 +99,12 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}` // called with just one argument in the form of `heroku addons:upgrade heroku-redis:hobby` const {addon, plan} = this.getAddonPartsFromArgs(args) + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) let addonServiceName: string | undefined let updatedAddon: Required try { - updatedAddon = await upgradeAddon(addon, plan, { + updatedAddon = await platform.addOn.upgrade(addon, plan, { appIdentity: app, onResolved(resolved) { addonServiceName = (resolved.addon_service as undefined | {name?: string})?.name diff --git a/src/commands/maintenance/off.ts b/src/commands/maintenance/off.ts index 0f28eaf034..d5cdf34077 100644 --- a/src/commands/maintenance/off.ts +++ b/src/commands/maintenance/off.ts @@ -1,6 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {disableMaintenanceMode} from '@heroku/sdk/compositions/app' +import {appExtensions} from '@heroku/sdk/extensions/platform' +import {HerokuSDK} from '@heroku/sdk/sdk' import {ux} from '@oclif/core/ux' export default class MaintenanceOff extends Command { @@ -14,7 +15,8 @@ export default class MaintenanceOff extends Command { async run() { const {flags} = await this.parse(MaintenanceOff) ux.action.start(`Disabling maintenance mode for ${color.app(flags.app)}`) - await disableMaintenanceMode(flags.app) + const {platform} = new HerokuSDK({extensions: [appExtensions]}) + await platform.app.disableMaintenance(flags.app) ux.action.stop() } } diff --git a/src/commands/maintenance/on.ts b/src/commands/maintenance/on.ts index dbbe98774f..e338ae0b49 100644 --- a/src/commands/maintenance/on.ts +++ b/src/commands/maintenance/on.ts @@ -1,6 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {enableMaintenanceMode} from '@heroku/sdk/compositions/app' +import {appExtensions} from '@heroku/sdk/extensions/platform' +import {HerokuSDK} from '@heroku/sdk/sdk' import {ux} from '@oclif/core/ux' export default class MaintenanceOn extends Command { @@ -14,7 +15,8 @@ export default class MaintenanceOn extends Command { async run() { const {flags} = await this.parse(MaintenanceOn) ux.action.start(`Enabling maintenance mode for ${color.app(flags.app)}`) - await enableMaintenanceMode(flags.app) + const {platform} = new HerokuSDK({extensions: [appExtensions]}) + await platform.app.enableMaintenance(flags.app) ux.action.stop() } } From 7c2b9d0435d39448796b298bce08f034be357246 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 11:34:49 -0700 Subject: [PATCH 04/11] refactor: migrate rename/detach/plans to @heroku/sdk - rename: addOn.info + addOn.update (passes plan through unchanged to satisfy the schema's required field) - detach: addOnAttachment.infoByApp + addOnAttachment.delete + release.list (Range header via withHeaders) - plans: addOn.listPlans extension (replaces lodash sort with native contract+cents sort) Test fixtures updated to return JSON bodies for the PATCH/DELETE responses the SDK now parses. --- src/commands/addons/detach.ts | 13 +++++++------ src/commands/addons/plans.ts | 14 +++++++++----- src/commands/addons/rename.ts | 7 ++++--- test/unit/commands/addons/detach.unit.test.ts | 2 +- test/unit/commands/addons/rename.unit.test.ts | 4 ++-- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/commands/addons/detach.ts b/src/commands/addons/detach.ts index 5892680efc..3324e842eb 100644 --- a/src/commands/addons/detach.ts +++ b/src/commands/addons/detach.ts @@ -1,6 +1,6 @@ import {Command, flags} from '@heroku-cli/command' -import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {createPlatformClient} from '@heroku/sdk/platform' import {Args, ux} from '@oclif/core' export default class Detach extends Command { @@ -17,19 +17,20 @@ export default class Detach extends Command { public async run(): Promise { const {args, flags} = await this.parse(Detach) const {app} = flags - const {body: attachment} = await this.heroku.get(`/apps/${app}/addon-attachments/${args.attachment_name}`) + const platform = createPlatformClient() + const attachment = await platform.addOnAttachment.infoByApp(app, args.attachment_name) ux.action.start(`Detaching ${color.attachment(attachment.name || '')} to ${color.addon(attachment.addon?.name || '')} from ${color.app(app)}`) - await this.heroku.delete(`/addon-attachments/${attachment.id}`) + await platform.addOnAttachment.delete(attachment.id!) ux.action.stop() ux.action.start(`Unsetting ${color.attachment(attachment.name || '')} config vars and restarting ${color.app(app)}`) - const {body: releases} = await this.heroku.get(`/apps/${app}/releases`, { - headers: {Range: 'version ..; max=1, order=desc'}, partial: true, - }) + const releases = await platform + .withHeaders({Range: 'version ..; max=1, order=desc'}) + .release.list(app) ux.action.stop(`done, v${releases[0]?.version || ''}`) } diff --git a/src/commands/addons/plans.ts b/src/commands/addons/plans.ts index 0dbdabc841..278270e773 100644 --- a/src/commands/addons/plans.ts +++ b/src/commands/addons/plans.ts @@ -1,9 +1,9 @@ import {Command, flags} from '@heroku-cli/command' import {Plan} from '@heroku-cli/schema' import {hux} from '@heroku/heroku-cli-util' -import {createPlatformClient} from '@heroku/sdk/platform' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' +import {HerokuSDK} from '@heroku/sdk/sdk' import {Args} from '@oclif/core' -import _ from 'lodash' import printf from 'printf' import {formatPrice} from '../../lib/addons/util.js' @@ -30,9 +30,13 @@ export default class Plans extends Command { public async run(): Promise { const {args, flags} = await this.parse(Plans) const {service} = args - const heroku = createPlatformClient() - let plans = (await heroku.plan.listByAddOn(service)) as unknown as PlanWithMeteredPrice[] - plans = _.sortBy(plans, ['price.contract', 'price.cents']) + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) + const plans = ((await platform.addOn.listPlans(service)) as unknown as PlanWithMeteredPrice[]) + .sort((a, b) => { + const contractDelta = Number(a.price?.contract ?? false) - Number(b.price?.contract ?? false) + if (contractDelta !== 0) return contractDelta + return (a.price?.cents ?? 0) - (b.price?.cents ?? 0) + }) if (flags.json) { hux.styledJSON(plans) } else { diff --git a/src/commands/addons/rename.ts b/src/commands/addons/rename.ts index 8e61747cee..87164501f9 100644 --- a/src/commands/addons/rename.ts +++ b/src/commands/addons/rename.ts @@ -1,6 +1,6 @@ import {Command} from '@heroku-cli/command' -import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {createPlatformClient} from '@heroku/sdk/platform' import {Args, ux} from '@oclif/core' export default class Rename extends Command { @@ -13,8 +13,9 @@ export default class Rename extends Command { public async run(): Promise { const {args} = await this.parse(Rename) - const {body: addon} = await this.heroku.get(`/addons/${encodeURIComponent(args.addon_name)}`) - await this.heroku.patch(`/apps/${addon.app?.id}/addons/${addon.id}`, {body: {name: args.new_name}}) + const platform = createPlatformClient() + const addon = await platform.addOn.info(args.addon_name) + await platform.addOn.update(addon.app!.id!, addon.id!, {name: args.new_name, plan: addon.plan!.name!}) ux.stdout(`${color.addon(args.addon_name)} successfully renamed to ${color.info(args.new_name)}.`) } } diff --git a/test/unit/commands/addons/detach.unit.test.ts b/test/unit/commands/addons/detach.unit.test.ts index f9c6887150..fd86b61d5e 100644 --- a/test/unit/commands/addons/detach.unit.test.ts +++ b/test/unit/commands/addons/detach.unit.test.ts @@ -21,7 +21,7 @@ describe('addons:detach', function () { .get('/apps/myapp/addon-attachments/redis-123') .reply(200, {addon: {name: 'redis'}, id: 100, name: 'redis-123'}) .delete('/addon-attachments/100') - .reply(200) + .reply(200, {id: 100, name: 'redis-123'}) .get('/apps/myapp/releases') .reply(200, [{version: 10}]) diff --git a/test/unit/commands/addons/rename.unit.test.ts b/test/unit/commands/addons/rename.unit.test.ts index b75eb0f4a0..615418fff7 100644 --- a/test/unit/commands/addons/rename.unit.test.ts +++ b/test/unit/commands/addons/rename.unit.test.ts @@ -17,8 +17,8 @@ describe('addons:rename', function () { .get(`/addons/${redis.name}`) .reply(200, redis) renameRequest = nock('https://api.heroku.com') - .patch(`/apps/${redis.app?.id}/addons/${redis.id}`, {name: 'cache-redis'}) - .reply(201, '') + .patch(`/apps/${redis.app?.id}/addons/${redis.id}`, {name: 'cache-redis', plan: redis.plan!.name}) + .reply(201, {...redis, name: 'cache-redis'}) }) it('renames the add-on', async function () { const {stdout} = await runCommand(Cmd, [redis_name, 'cache-redis']) From bb0a11e02fd171a4b5100d673623dd532284d03b Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 11:58:50 -0700 Subject: [PATCH 05/11] refactor: migrate addons:create flow to @heroku/sdk + fix stale tests - waitForAddonProvisioning: use createPlatformClient + addOn.infoByApp with Accept-Expansion header via withHeaders. Drops APIClient param. - createAddon helper: drops APIClient param (no longer needed once waitForAddonProvisioning is migrated). - create.ts, wait.ts, data:pg:{create,fork,migrate}: update call sites to match the new signature (no this.heroku passed in). - addons:wait test: replace lolex install + setTimeout override with sinon.useFakeTimers({toFake: ['Date'], shouldAdvanceTime: true}) so the SDK's real setTimeout polling drives the test while Date.now can still be fake-ticked for the >5s notifier threshold. - pg create/fork test fixtures: nock body matchers changed from {plan: {name: 'foo'}} to {plan: 'foo'} to match the canonical AddOnCreateOpts shape the SDK sends. - migrate test: shift createAddonStub argument indexes to reflect the new signature (heroku param removed). --- src/commands/addons/create.ts | 2 +- src/commands/addons/wait.ts | 2 +- src/commands/data/pg/create.ts | 2 +- src/commands/data/pg/fork.ts | 2 +- src/commands/data/pg/migrate.ts | 2 +- src/lib/addons/addons-wait.ts | 10 ++++------ src/lib/addons/create-addon.ts | 4 +--- test/unit/commands/addons/wait.unit.test.ts | 12 +++++------- test/unit/commands/data/pg/create.unit.test.ts | 18 +++++++++--------- test/unit/commands/data/pg/fork.unit.test.ts | 10 +++++----- .../unit/commands/data/pg/migrate.unit.test.ts | 18 +++++++++--------- 11 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/commands/addons/create.ts b/src/commands/addons/create.ts index 132d328392..62dd54b883 100644 --- a/src/commands/addons/create.ts +++ b/src/commands/addons/create.ts @@ -58,7 +58,7 @@ export default class Create extends Command { const config = parseConfig(argv) let addon: Heroku.AddOn try { - addon = await createAddon(this.heroku, app, servicePlan, confirm, wait, {as, config, name}) + addon = await createAddon(app, servicePlan, confirm, wait, {as, config, name}) if (wait) { Create.notifier(`heroku addons:create ${addon.name}`, 'Add-on successfully provisioned') } diff --git a/src/commands/addons/wait.ts b/src/commands/addons/wait.ts index e1124a7133..1eb29a39df 100644 --- a/src/commands/addons/wait.ts +++ b/src/commands/addons/wait.ts @@ -48,7 +48,7 @@ export default class Wait extends Command { if (addon.state === 'provisioning') { let addonResponse try { - addonResponse = await waitForAddonProvisioning(this.heroku, addon as Heroku.AddOn, interval) + addonResponse = await waitForAddonProvisioning(addon as Heroku.AddOn, interval) } catch (error) { Wait.notifier(`heroku addons:wait ${addonName}`, 'Add-on failed to provision', false) throw error diff --git a/src/commands/data/pg/create.ts b/src/commands/data/pg/create.ts index 1bbbd3c2fb..6f46b67db5 100644 --- a/src/commands/data/pg/create.ts +++ b/src/commands/data/pg/create.ts @@ -108,7 +108,7 @@ export default class DataPgCreate extends BaseCommand { } try { - this.addon = await createAddon(this.heroku, app, servicePlan, confirm, wait, { + this.addon = await createAddon(app, servicePlan, confirm, wait, { actionStartMessage: `Creating a ${color.addon(this.leaderLevel || '')} database on ${color.app(app)}`, actionStopMessage: 'done', as, config, name, diff --git a/src/commands/data/pg/fork.ts b/src/commands/data/pg/fork.ts index 1e41e44545..551ab9a405 100644 --- a/src/commands/data/pg/fork.ts +++ b/src/commands/data/pg/fork.ts @@ -156,7 +156,7 @@ export default class Fork extends BaseCommand { const actionStartMessage = recoveryTime ? `Creating a fork for ${color.addon(addon.name)} on ${color.app(app)} ${rollbackMessage}` : `Creating a fork for ${color.addon(addon.name)} on ${color.app(app)}` - await createAddon(this.heroku, app, addon.plan.name!, confirm, wait, { + await createAddon(app, addon.plan.name!, confirm, wait, { actionStartMessage, actionStopMessage: 'done', as, config, name, }) diff --git a/src/commands/data/pg/migrate.ts b/src/commands/data/pg/migrate.ts index 6905206148..d4ed649b81 100644 --- a/src/commands/data/pg/migrate.ts +++ b/src/commands/data/pg/migrate.ts @@ -339,7 +339,7 @@ export default class DataPgMigrate extends BaseCommand { let addon: Heroku.AddOn | undefined try { - addon = await this.createAddon(this.heroku, sourceDatabase.app.name, servicePlan, undefined, false, { + addon = await this.createAddon(sourceDatabase.app.name, servicePlan, undefined, false, { actionStartMessage: `Creating a ${color.info(leaderLevel!)} database on ${color.app(sourceDatabase.app.name)}`, actionStopMessage: 'done', config, diff --git a/src/lib/addons/addons-wait.ts b/src/lib/addons/addons-wait.ts index 9e395c709f..2d2d259640 100644 --- a/src/lib/addons/addons-wait.ts +++ b/src/lib/addons/addons-wait.ts @@ -1,24 +1,22 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {createPlatformClient} from '@heroku/sdk/platform' import {ux} from '@oclif/core/ux' -export const waitForAddonProvisioning = async function (api: APIClient, addon: Heroku.AddOn, interval: number) { +export const waitForAddonProvisioning = async function (addon: Heroku.AddOn, interval: number) { const app = addon.app?.name || '' const addonName = addon.name let addonBody = {...addon} ux.action.start(`Creating ${color.addon(addonName || '')}`) + const platform = createPlatformClient().withHeaders({'Accept-Expansion': 'addon_service,plan'}) while (addonBody.state === 'provisioning') { // eslint-disable-next-line no-promise-executor-return await new Promise(resolve => setTimeout(resolve, interval * 1000)) - const addonResponse = await api.get(`/apps/${app}/addons/${addonName}`, { - headers: {'Accept-Expansion': 'addon_service,plan'}, - }) - - addonBody = addonResponse?.body + addonBody = (await platform.addOn.infoByApp(app, addonName!)) as unknown as Heroku.AddOn } if (addonBody.state === 'deprovisioned') { diff --git a/src/lib/addons/create-addon.ts b/src/lib/addons/create-addon.ts index 276b6dd98e..9f4df98b70 100644 --- a/src/lib/addons/create-addon.ts +++ b/src/lib/addons/create-addon.ts @@ -1,4 +1,3 @@ -import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' import {createPlatformClient} from '@heroku/sdk/platform' @@ -19,7 +18,6 @@ function formatConfigVarsMessage(addon: Heroku.AddOn) { // eslint-disable-next-line max-params export default async function createAddon( - heroku: APIClient, app: string, plan: string, confirm: string | undefined, @@ -63,7 +61,7 @@ export default async function createAddon( if (addon.state === 'provisioning') { if (wait) { ux.stdout(`Waiting for ${color.addon(addon.name || '')}...`) - addon = await waitForAddonProvisioning(heroku, addon, 5) + addon = await waitForAddonProvisioning(addon, 5) ux.stdout(formatConfigVarsMessage(addon)) } else { ux.stdout(`${color.addon(addon.name || '')} is being created in the background. The app will restart when complete...`) diff --git a/test/unit/commands/addons/wait.unit.test.ts b/test/unit/commands/addons/wait.unit.test.ts index 7384991779..5569cc212e 100644 --- a/test/unit/commands/addons/wait.unit.test.ts +++ b/test/unit/commands/addons/wait.unit.test.ts @@ -1,13 +1,12 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import _ from 'lodash' -import lolex from 'lolex' import nock from 'nock' import {createSandbox} from 'sinon' import Cmd from '../../../../src/commands/addons/wait.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' -let clock: any + const expansionHeaders = {'Accept-Expansion': 'addon_service,plan'} describe('addons:wait', function () { @@ -16,14 +15,13 @@ describe('addons:wait', function () { beforeEach(function () { sandbox = createSandbox() nock.cleanAll() - clock = lolex.install() - clock.setTimeout = function (fn: any) { - process.nextTick(fn) - } + // Fake only Date so the >5s notifier threshold can be advanced + // synchronously without faking setTimeout (the SDK's polling loop + // needs real setTimeout to drive the test forward). + sandbox.useFakeTimers({shouldAdvanceTime: true, toFake: ['Date']}) }) afterEach(function () { - clock.uninstall() sandbox.restore() }) context('waiting for an individual add-on to provision', function () { diff --git a/test/unit/commands/data/pg/create.unit.test.ts b/test/unit/commands/data/pg/create.unit.test.ts index d9f2b75ea6..0a0ce937e6 100644 --- a/test/unit/commands/data/pg/create.unit.test.ts +++ b/test/unit/commands/data/pg/create.unit.test.ts @@ -73,7 +73,7 @@ describe('data:pg:create', function () { .post('/apps/myapp/addons', { attachment: {}, config: {level: '4G-Performance'}, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) const dataApi = nock('https://api.data.heroku.com') @@ -105,7 +105,7 @@ describe('data:pg:create', function () { .post('/apps/myapp/addons', { attachment: {}, config: {level: '4G-Performance'}, - plan: {name: 'heroku-postgresql:advanced-private'}, + plan: 'heroku-postgresql:advanced-private', }) .reply(200, createAddonResponse) @@ -131,7 +131,7 @@ describe('data:pg:create', function () { .post('/apps/myapp/addons', { attachment: {}, config: {level: '4G-Performance'}, - plan: {name: 'heroku-postgresql:advanced-shield'}, + plan: 'heroku-postgresql:advanced-shield', }) .reply(200, createAddonResponse) @@ -187,7 +187,7 @@ describe('data:pg:create', function () { level: '4G-Performance', }, confirm: 'myapp', - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) @@ -214,7 +214,7 @@ describe('data:pg:create', function () { 'high-availability': true, level: levelsResponse.items[0].name, }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) @@ -284,7 +284,7 @@ describe('data:pg:create', function () { 'high-availability': false, level: levelsResponse.items[0].name, }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) @@ -321,7 +321,7 @@ describe('data:pg:create', function () { 'high-availability': true, level: levelsResponse.items[0].name, }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) @@ -374,7 +374,7 @@ describe('data:pg:create', function () { 'high-availability': true, level: levelsResponse.items[0].name, }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) @@ -450,7 +450,7 @@ describe('data:pg:create', function () { 'high-availability': true, level: levelsResponse.items[0].name, }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createAddonResponse) diff --git a/test/unit/commands/data/pg/fork.unit.test.ts b/test/unit/commands/data/pg/fork.unit.test.ts index a12071cf1f..6f3b6e8e7c 100644 --- a/test/unit/commands/data/pg/fork.unit.test.ts +++ b/test/unit/commands/data/pg/fork.unit.test.ts @@ -44,7 +44,7 @@ describe('data:pg:fork', function () { fork: 'advanced-horizontal-01234', level: '4G-Performance', }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createForkResponse) @@ -80,7 +80,7 @@ describe('data:pg:fork', function () { level: '4G-Performance', }, name: 'my-forked-db', - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createForkResponse) @@ -117,7 +117,7 @@ describe('data:pg:fork', function () { fork: 'advanced-horizontal-01234', level: '8G-Performance', }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createForkResponse) @@ -182,7 +182,7 @@ describe('data:pg:fork', function () { 'recovery-time': '2025-01-11T12:35:00', rollback: 'advanced-horizontal-01234', }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createForkResponse) @@ -225,7 +225,7 @@ describe('data:pg:fork', function () { 'recovery-time': '2025-01-30T00:00:00', rollback: 'advanced-horizontal-01234', }, - plan: {name: 'heroku-postgresql:advanced'}, + plan: 'heroku-postgresql:advanced', }) .reply(200, createForkResponse) diff --git a/test/unit/commands/data/pg/migrate.unit.test.ts b/test/unit/commands/data/pg/migrate.unit.test.ts index 03046e32e8..afbafe5968 100644 --- a/test/unit/commands/data/pg/migrate.unit.test.ts +++ b/test/unit/commands/data/pg/migrate.unit.test.ts @@ -525,10 +525,10 @@ describe('data:pg:migrate', function () { expect(stderr).to.equal('Configuring migration... done\n') expect(stdout).to.contain('→ Configure Leader Pool') expect(createAddonStub.calledOnce).to.be.true - expect(createAddonStub.args[0][1]).to.equal(premiumDbAttachment.addon.app.name) + expect(createAddonStub.args[0][0]).to.equal(premiumDbAttachment.addon.app.name) // Verify the service plan is correct (no private or shield networking) - expect(createAddonStub.args[0][2]).to.equal('heroku-postgresql:advanced') - expect(createAddonStub.args[0][5]).to.deep.include({ + expect(createAddonStub.args[0][1]).to.equal('heroku-postgresql:advanced') + expect(createAddonStub.args[0][4]).to.deep.include({ config: { from: premiumDbAttachment.addon.id, 'high-availability': true, @@ -596,10 +596,10 @@ describe('data:pg:migrate', function () { expect(stderr).to.equal('Configuring migration... done\n') expect(stdout).to.contain('→ Configure Leader Pool') expect(createAddonStub.calledOnce) - expect(createAddonStub.args[0][1]).to.equal(privateDbAttachment.addon.app.name) + expect(createAddonStub.args[0][0]).to.equal(privateDbAttachment.addon.app.name) // Verify the service plan is correct (private networking) - expect(createAddonStub.args[0][2]).to.equal('heroku-postgresql:advanced-private') - expect(createAddonStub.args[0][5]).to.deep.include({ + expect(createAddonStub.args[0][1]).to.equal('heroku-postgresql:advanced-private') + expect(createAddonStub.args[0][4]).to.deep.include({ config: { from: privateDbAttachment.addon.id, 'high-availability': true, @@ -667,10 +667,10 @@ describe('data:pg:migrate', function () { expect(stderr).to.equal('Configuring migration... done\n') expect(stdout).to.contain('→ Configure Leader Pool') expect(createAddonStub.calledOnce) - expect(createAddonStub.args[0][1]).to.equal(shieldDbAttachment.addon.app.name) + expect(createAddonStub.args[0][0]).to.equal(shieldDbAttachment.addon.app.name) // Verify the service plan is correct (shield networking) - expect(createAddonStub.args[0][2]).to.equal('heroku-postgresql:advanced-shield') - expect(createAddonStub.args[0][5]).to.deep.include({ + expect(createAddonStub.args[0][1]).to.equal('heroku-postgresql:advanced-shield') + expect(createAddonStub.args[0][4]).to.deep.include({ config: { from: shieldDbAttachment.addon.id, 'high-availability': true, From 168a9af3db6acc7983c94721b427cb5f30c791cd Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 13:30:33 -0700 Subject: [PATCH 06/11] refactor: use addOn.createAndWait for create-addon helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the local trapConfirmationRequired + waitForAddonProvisioning flow with a single platform.addOn.createAndWait call. The SDK now owns: - 423 confirmation_required → typed AddonConfirmationRequiredError, caught here and passed through ConfirmCommand for the UX prompt. - state=provisioning + wait=true → poll loop until terminal. - state=deprovisioned → typed AddonProvisioningFailedError. The two-phase status display (Creating ... , then Creating ... done while polling) is preserved by hooking the SDK's onProvisioning callback to close the create-phase action, print the provision message + 'Waiting for...' line, and start the wait-phase action. Bumps SDK pin to eb/feat/addon-create-and-wait. --- package.json | 2 +- src/lib/addons/create-addon.ts | 100 ++++++++++++------ test/unit/commands/addons/create.unit.test.ts | 2 +- test/unit/commands/addons/wait.unit.test.ts | 5 +- 4 files changed, 72 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 39f0a7912a..83861be305 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#eb/fix/filters-apps-accept-header", + "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/addon-create-and-wait", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", diff --git a/src/lib/addons/create-addon.ts b/src/lib/addons/create-addon.ts index 9f4df98b70..8bfe729f73 100644 --- a/src/lib/addons/create-addon.ts +++ b/src/lib/addons/create-addon.ts @@ -1,9 +1,11 @@ import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' -import {createPlatformClient} from '@heroku/sdk/platform' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' +import {AddonConfirmationRequiredError} from '@heroku/sdk/resources/platform/add-on' +import {HerokuSDK} from '@heroku/sdk/sdk' import {ux} from '@oclif/core/ux' -import {waitForAddonProvisioning} from './addons-wait.js' +import ConfirmCommand from '../confirm-command.js' import * as util from './util.js' function formatConfigVarsMessage(addon: Heroku.AddOn) { @@ -29,52 +31,84 @@ export default async function createAddon( config: Record, name?: string, }, -) { - async function createAddonRequest(confirmed?: string) { - const body = { - attachment: {name: options.as}, - config: options.config, - confirm: confirmed, - name: options.name, - plan, +): Promise { + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) + const buildBody = (confirmed?: string) => ({ + attachment: {name: options.as}, + config: options.config as Record, + confirm: confirmed, + name: options.name, + plan, + }) + + // Two-phase UX during a wait: the first ux.action shows + // "Creating ... " wrapping the initial create call. + // onProvisioning closes that, prints provision_message + "Waiting" + // to stdout, then opens a second ux.action ("Creating ") + // that wraps the poll loop and is closed after createAndWait returns. + let inWaitPhase = false + const onProvisioning = (created: Heroku.AddOn) => { + ux.action.stop(options.actionStopMessage || color.green(util.formatPriceText(created.plan?.price || ''))) + if (created.provision_message) { + ux.stdout(created.provision_message) } - try { - ux.action.start(options.actionStartMessage || `Creating ${plan} on ${color.app(app)}`) - const sdk = createPlatformClient() - const addon = await sdk.addOn.create(app, body as Parameters[1]) as Heroku.AddOn - ux.action.stop(options.actionStopMessage || color.green(util.formatPriceText(addon.plan?.price || ''))) + ux.stdout(`Waiting for ${color.addon(created.name || '')}...`) + ux.action.start(`Creating ${color.addon(created.name || '')}`) + inWaitPhase = true + } + + async function callCreateAndWait(confirmed?: string): Promise { + return (await platform.addOn.createAndWait( + app, + buildBody(confirmed), + {onProvisioning, wait}, + )) as Heroku.AddOn + } - return addon - } catch (error: unknown) { - ux.action.stop(color.red('!')) - throw error + ux.action.start(options.actionStartMessage || `Creating ${plan} on ${color.app(app)}`) + let addon: Heroku.AddOn + try { + try { + addon = await callCreateAndWait(confirm) + } catch (error) { + if (error instanceof AddonConfirmationRequiredError) { + ux.action.stop(color.red('!')) + await new ConfirmCommand().confirm(app, confirm, error.message) + ux.action.start(options.actionStartMessage || `Creating ${plan} on ${color.app(app)}`) + addon = await callCreateAndWait(app) + } else { + throw error + } } + } catch (error) { + ux.action.stop(color.red('!')) + throw error } - let addon = await util.trapConfirmationRequired(app, confirm, confirm => (createAddonRequest(confirm))) + if (inWaitPhase) { + // Closes the wait-phase action started in onProvisioning. + ux.action.stop() + ux.stdout(formatConfigVarsMessage(addon)) + } else { + // No two-phase: close the create-phase action with the price line. + ux.action.stop(options.actionStopMessage || color.green(util.formatPriceText(addon.plan?.price || ''))) - if (addon.provision_message) { - ux.stdout(addon.provision_message) - } + if (addon.provision_message) { + ux.stdout(addon.provision_message) + } - if (addon.state === 'provisioning') { - if (wait) { - ux.stdout(`Waiting for ${color.addon(addon.name || '')}...`) - addon = await waitForAddonProvisioning(addon, 5) - ux.stdout(formatConfigVarsMessage(addon)) - } else { + if (addon.state === 'provisioning') { + // wait was false; surface guidance for the user to check progress. ux.stdout(`${color.addon(addon.name || '')} is being created in the background. The app will restart when complete...`) // eslint-disable-next-line @typescript-eslint/no-explicit-any if (utils.pg.isAdvancedDatabase(addon as any)) ux.stdout(`Run ${color.code('heroku data:pg:info ' + addon.name + ' -a ' + addon.app!.name)} to check creation progress.`) else ux.stdout(`Run ${color.code('heroku addons:info ' + addon.name)} to check creation progress.`) + } else { + ux.stdout(formatConfigVarsMessage(addon)) } - } else if (addon.state === 'deprovisioned') { - throw new Error(`The add-on was unable to be created, with status ${addon.state}`) - } else { - ux.stdout(formatConfigVarsMessage(addon)) } return addon diff --git a/test/unit/commands/addons/create.unit.test.ts b/test/unit/commands/addons/create.unit.test.ts index 360653e357..2edf7971ce 100644 --- a/test/unit/commands/addons/create.unit.test.ts +++ b/test/unit/commands/addons/create.unit.test.ts @@ -286,7 +286,7 @@ describe('addons:create', function () { 'mydb', 'heroku-postgresql:standard-0', ]) - expect((error as HTTPError)?.message).to.equal('The add-on was unable to be created, with status deprovisioned') + expect((error as HTTPError)?.message).to.equal('The add-on was unable to be created, with status deprovisioned.') }) }) }) diff --git a/test/unit/commands/addons/wait.unit.test.ts b/test/unit/commands/addons/wait.unit.test.ts index 5569cc212e..22c5daf98e 100644 --- a/test/unit/commands/addons/wait.unit.test.ts +++ b/test/unit/commands/addons/wait.unit.test.ts @@ -2,7 +2,7 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import {expect} from 'chai' import _ from 'lodash' import nock from 'nock' -import {createSandbox} from 'sinon' +import {createSandbox, type SinonFakeTimers} from 'sinon' import Cmd from '../../../../src/commands/addons/wait.js' import * as fixtures from '../../../fixtures/addons/fixtures.js' @@ -11,6 +11,7 @@ const expansionHeaders = {'Accept-Expansion': 'addon_service,plan'} describe('addons:wait', function () { let sandbox: any + let clock: SinonFakeTimers beforeEach(function () { sandbox = createSandbox() @@ -18,7 +19,7 @@ describe('addons:wait', function () { // Fake only Date so the >5s notifier threshold can be advanced // synchronously without faking setTimeout (the SDK's polling loop // needs real setTimeout to drive the test forward). - sandbox.useFakeTimers({shouldAdvanceTime: true, toFake: ['Date']}) + clock = sandbox.useFakeTimers({shouldAdvanceTime: true, toFake: ['Date']}) }) afterEach(function () { From 1d66fefd4b01020fd48f1bd9f281c85f5bb340be Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 13:37:10 -0700 Subject: [PATCH 07/11] refactor: import HerokuSDK from '@heroku/sdk' root Per @heroku/sdk#26 (chore!: cleans-up exports), HerokuSDK is now exported from the package root and the './sdk' subpath is gone. Bump the lockfile to pick up the SDK 0.4.0 build that includes both this exports change and the createAndWait + onProvisioning callback work that lib/addons/create-addon.ts depends on. --- src/commands/addons/info.ts | 2 +- src/commands/addons/plans.ts | 2 +- src/commands/addons/upgrade.ts | 2 +- src/commands/maintenance/off.ts | 2 +- src/commands/maintenance/on.ts | 2 +- src/lib/addons/create-addon.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/addons/info.ts b/src/commands/addons/info.ts index 0b03d2e677..6d5102cd2f 100644 --- a/src/commands/addons/info.ts +++ b/src/commands/addons/info.ts @@ -2,7 +2,7 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' import {addOnExtensions} from '@heroku/sdk/extensions/platform' -import {HerokuSDK} from '@heroku/sdk/sdk' +import {HerokuSDK} from '@heroku/sdk' import {Args} from '@oclif/core' import {formatPrice, formatState} from '../../lib/addons/util.js' diff --git a/src/commands/addons/plans.ts b/src/commands/addons/plans.ts index 278270e773..21bbdea5f4 100644 --- a/src/commands/addons/plans.ts +++ b/src/commands/addons/plans.ts @@ -2,7 +2,7 @@ import {Command, flags} from '@heroku-cli/command' import {Plan} from '@heroku-cli/schema' import {hux} from '@heroku/heroku-cli-util' import {addOnExtensions} from '@heroku/sdk/extensions/platform' -import {HerokuSDK} from '@heroku/sdk/sdk' +import {HerokuSDK} from '@heroku/sdk' import {Args} from '@oclif/core' import printf from 'printf' diff --git a/src/commands/addons/upgrade.ts b/src/commands/addons/upgrade.ts index 031e0cdd46..8679d2cde8 100644 --- a/src/commands/addons/upgrade.ts +++ b/src/commands/addons/upgrade.ts @@ -4,7 +4,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {AddonAmbiguousError} from '@heroku/sdk/resources/platform/add-on' -import {HerokuSDK} from '@heroku/sdk/sdk' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import {formatPriceText} from '../../lib/addons/util.js' diff --git a/src/commands/maintenance/off.ts b/src/commands/maintenance/off.ts index d5cdf34077..d0647729cf 100644 --- a/src/commands/maintenance/off.ts +++ b/src/commands/maintenance/off.ts @@ -1,7 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' import {appExtensions} from '@heroku/sdk/extensions/platform' -import {HerokuSDK} from '@heroku/sdk/sdk' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' export default class MaintenanceOff extends Command { diff --git a/src/commands/maintenance/on.ts b/src/commands/maintenance/on.ts index e338ae0b49..46ad9a751d 100644 --- a/src/commands/maintenance/on.ts +++ b/src/commands/maintenance/on.ts @@ -1,7 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' import {appExtensions} from '@heroku/sdk/extensions/platform' -import {HerokuSDK} from '@heroku/sdk/sdk' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' export default class MaintenanceOn extends Command { diff --git a/src/lib/addons/create-addon.ts b/src/lib/addons/create-addon.ts index 8bfe729f73..6230c56cd9 100644 --- a/src/lib/addons/create-addon.ts +++ b/src/lib/addons/create-addon.ts @@ -2,7 +2,7 @@ import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {AddonConfirmationRequiredError} from '@heroku/sdk/resources/platform/add-on' -import {HerokuSDK} from '@heroku/sdk/sdk' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' import ConfirmCommand from '../confirm-command.js' From c45c44f691b009bd2eb035acebf2e89bde948544 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 14:08:03 -0700 Subject: [PATCH 08/11] fix(addons): scope Accept-Expansion to per-app addon list calls The global /addons endpoint rejects Accept-Expansion: addon_service,plan ('must be within ``'), but the per-app /apps/:id/addons endpoint accepts it. Move the expansion off createPlatformClient's defaults and onto a withHeaders-scoped client used only for the app-scoped list. The attachment endpoints (list / listByApp) use Accept-Inclusion, not Accept-Expansion, so they don't need the header either. Bumps the SDK pin to pick up describeAddon's matching expansion-scoping fix. --- src/commands/addons/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/addons/index.ts b/src/commands/addons/index.ts index acfd7bc8e2..8f691e6025 100644 --- a/src/commands/addons/index.ts +++ b/src/commands/addons/index.ts @@ -65,11 +65,14 @@ export function renderAttachment(attachment: Heroku.AddOnAttachment, app: string } async function addonGetter(api: APIClient, app?: string) { - const heroku = createPlatformClient({headers: ADDON_EXPANSION_HEADERS}) + const heroku = createPlatformClient() + // Apply Accept-Expansion only on add-on list calls (the global list + // endpoint rejects it; the attachments endpoints don't need it). + const herokuWithExpansion = heroku.withHeaders(ADDON_EXPANSION_HEADERS) let attachmentsResponse: null | Promise = null let addonsResponse: Promise if (app) { // don't display attachments globally - addonsResponse = heroku.addOn.listByApp(app) as unknown as Promise + addonsResponse = herokuWithExpansion.addOn.listByApp(app) as unknown as Promise const sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}') // eslint-disable-next-line unicorn/prefer-ternary if (sudoHeaders['X-Heroku-Sudo'] && !sudoHeaders['X-Heroku-Sudo-User']) { @@ -83,6 +86,7 @@ async function addonGetter(api: APIClient, app?: string) { attachmentsResponse = heroku.addOnAttachment.list() as unknown as Promise } } else { + // The global /addons endpoint doesn't support Accept-Expansion. addonsResponse = heroku.addOn.list() as unknown as Promise } From 937d4954ecab8de0ecdf7f886b02485e6f84b5b5 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 14:43:04 -0700 Subject: [PATCH 09/11] fix(pipelines): migrate compositions/pipeline imports to resources/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration branch's #3717 imports from @heroku/sdk/compositions/pipeline, but the SDK's exports cleanup (heroku-sdk#26) replaced compositions/ with resources/. Update each call site to use the new shape: - listPipelineApps → pipelineCouplingExtensions.listApps via HerokuSDK - promotePipeline → standalone import (kept as a static reference on the Promote command class so existing sinon stubs continue to work) - AppWithPipelineCoupling, ReleaseStreamContext → type imports from resources/platform/pipeline-{coupling,promotion} The promote test's stub callback now sees a 3-arg signature (ctx, body, options) instead of 2-arg, and firstCall.args[0] becomes [1] for body assertions. Test scope cleanup for addons/index and addons/info: split the 'apiSdk' nock scope (Accept-Expansion required) from the 'api' scope (no expansion). Global /addons and /addons//addon-attachments don't accept the expansion header, matching the SDK's per-call header scoping in heroku-sdk#27. --- package-lock.json | 43 +++++++++---------- src/commands/pipelines/diff.ts | 6 ++- src/commands/pipelines/info.ts | 6 ++- src/commands/pipelines/promote.ts | 28 ++++++------ src/commands/pipelines/transfer.ts | 6 ++- src/lib/pipelines/ownership.ts | 2 +- src/lib/pipelines/render-pipeline.ts | 2 +- test/unit/commands/addons/index.unit.test.ts | 8 ++-- test/unit/commands/addons/info.unit.test.ts | 14 +++--- .../commands/pipelines/promote.unit.test.ts | 8 ++-- 10 files changed, 67 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 332ee54152..c14a5ea8fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#main", + "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/addon-create-and-wait", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", @@ -2447,24 +2447,6 @@ "sinon": ">=20" } }, - "node_modules/@heroku/api-client": { - "name": "@heroku/heroku-fetch", - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-fetch.git#7e4cc4918e7995e9d499ec8d2f3dfeb0964e7a0d", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.3.4", - "ky": "^1.2.0" - }, - "engines": { - "node": ">=22" - }, - "optionalDependencies": { - "@heroku/heroku-cli-util": "^10.8.0", - "netrc-parser": "^3.1.6", - "open": "^10.0.3" - } - }, "node_modules/@heroku/buildpack-registry": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@heroku/buildpack-registry/-/buildpack-registry-1.0.1.tgz", @@ -2592,6 +2574,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@heroku/heroku-fetch": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/heroku/heroku-fetch.git#90940bdb0191cafcd9571492b2145980643ba7f0", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.4", + "ky": "^1.2.0" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "@heroku/heroku-cli-util": "^10.8.0", + "netrc-parser": "^3.1.6", + "open": "^10.0.3" + } + }, "node_modules/@heroku/http-call": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@heroku/http-call/-/http-call-5.5.1.tgz", @@ -2668,11 +2667,11 @@ } }, "node_modules/@heroku/sdk": { - "version": "0.2.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#34fe268d74bfabad2dcbc62278ac70a624154420", + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#a473f6c0fbf84b2a1daecc4ef3b486a8444744cd", "license": "Apache-2.0", "dependencies": { - "@heroku/api-client": "github:heroku/heroku-fetch", + "@heroku/heroku-fetch": "github:heroku/heroku-fetch", "@heroku/types": "github:heroku/heroku-types", "debug": "^4.4.0" }, diff --git a/src/commands/pipelines/diff.ts b/src/commands/pipelines/diff.ts index f1ce5cc0a6..8e8bca2487 100644 --- a/src/commands/pipelines/diff.ts +++ b/src/commands/pipelines/diff.ts @@ -1,7 +1,8 @@ import {Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' import {HTTP} from '@heroku/http-call' -import {listPipelineApps} from '@heroku/sdk/compositions/pipeline' +import {HerokuSDK} from '@heroku/sdk' +import {pipelineCouplingExtensions} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' import type {OciImage, PipelineCoupling, Slug} from '../../lib/types/fir.js' @@ -87,7 +88,8 @@ export default class PipelinesDiff extends Command { const generation = getGeneration(pipeline)! ux.action.start('Fetching apps from pipeline') - const allApps = await listPipelineApps(coupling!.pipeline!.id!) + const {platform} = new HerokuSDK({extensions: [pipelineCouplingExtensions]}) + const allApps = await platform.pipelineCoupling.listApps(coupling!.pipeline!.id!) ux.action.stop() const sourceStage = coupling.stage diff --git a/src/commands/pipelines/info.ts b/src/commands/pipelines/info.ts index cf5246a1e1..df1ee06d40 100644 --- a/src/commands/pipelines/info.ts +++ b/src/commands/pipelines/info.ts @@ -1,7 +1,8 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import {listPipelineApps} from '@heroku/sdk/compositions/pipeline' +import {HerokuSDK} from '@heroku/sdk' +import {pipelineCouplingExtensions} from '@heroku/sdk/extensions/platform' import {Args} from '@oclif/core' import disambiguate from '../../lib/pipelines/disambiguate.js' @@ -31,7 +32,8 @@ export default class PipelinesInfo extends Command { async run() { const {args, flags} = await this.parse(PipelinesInfo) const pipeline: Heroku.Pipeline = await disambiguate(this.heroku, args.pipeline) - const pipelineApps = await listPipelineApps(pipeline.id!) + const {platform} = new HerokuSDK({extensions: [pipelineCouplingExtensions]}) + const pipelineApps = await platform.pipelineCoupling.listApps(pipeline.id!) if (flags.json) { // eslint-disable-next-line perfectionist/sort-objects diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index 003ee22226..a7c37af002 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -1,12 +1,10 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import { - type AppWithPipelineCoupling, - listPipelineApps, - promotePipeline, - type ReleaseStreamContext, -} from '@heroku/sdk/compositions/pipeline' +import {HerokuSDK} from '@heroku/sdk' +import {pipelineCouplingExtensions} from '@heroku/sdk/extensions/platform' +import type {AppWithPipelineCoupling} from '@heroku/sdk/resources/platform/pipeline-coupling' +import {promotePipeline, type ReleaseStreamContext} from '@heroku/sdk/resources/platform/pipeline-promotion' import {ux} from '@oclif/core/ux' import assert from 'node:assert' @@ -49,7 +47,9 @@ export default class Promote extends Command { const appNameOrId = flags.app const coupling = await getCoupling(this.heroku, appNameOrId) ux.stdout(`Fetching apps from ${color.pipeline(coupling.pipeline!.name)}...`) - const allApps = await listPipelineApps(coupling.pipeline!.id!) + const sdk = new HerokuSDK({extensions: [pipelineCouplingExtensions]}) + const {platform} = sdk + const allApps = await platform.pipelineCoupling.listApps(coupling.pipeline!.id!) const sourceStage = coupling.stage let promotionActionName = '' @@ -103,11 +103,15 @@ export default class Promote extends Command { } } - const {targets: promotionTargets} = await Promote.promotePipeline({ - pipeline: {id: coupling.pipeline!.id!}, - source: {app: {id: coupling.app!.id!}}, - targets: targetApps.map(app => ({app: {id: app.id}})), - }, {onReleaseStream}) + const {targets: promotionTargets} = await Promote.promotePipeline( + {platform}, + { + pipeline: {id: coupling.pipeline!.id!}, + source: {app: {id: coupling.app!.id!}}, + targets: targetApps.map(app => ({app: {id: app.id}})), + }, + {onReleaseStream}, + ) if (releaseStreamError) { ux.error(releaseStreamError as Error) diff --git a/src/commands/pipelines/transfer.ts b/src/commands/pipelines/transfer.ts index 276172e7d5..eb9a3fbfe1 100644 --- a/src/commands/pipelines/transfer.ts +++ b/src/commands/pipelines/transfer.ts @@ -1,6 +1,7 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' -import {listPipelineApps} from '@heroku/sdk/compositions/pipeline' +import {HerokuSDK} from '@heroku/sdk' +import {pipelineCouplingExtensions} from '@heroku/sdk/extensions/platform' import {Args, ux} from '@oclif/core' import { @@ -50,7 +51,8 @@ export default class PipelinesTransfer extends Command { const {args, flags} = await this.parse(PipelinesTransfer) const pipeline = await disambiguate(this.heroku, flags.pipeline) const newOwner = await getOwner(this.heroku, args.owner) - const apps = await listPipelineApps(pipeline.id!) + const {platform} = new HerokuSDK({extensions: [pipelineCouplingExtensions]}) + const apps = await platform.pipelineCoupling.listApps(pipeline.id!) const displayType = newOwner.type === 'user' ? 'account' : newOwner.type let confirmName = flags.confirm diff --git a/src/lib/pipelines/ownership.ts b/src/lib/pipelines/ownership.ts index ba9c4e69a8..67302f0306 100644 --- a/src/lib/pipelines/ownership.ts +++ b/src/lib/pipelines/ownership.ts @@ -1,4 +1,4 @@ -import type {AppWithPipelineCoupling} from '@heroku/sdk/compositions/pipeline' +import type {AppWithPipelineCoupling} from '@heroku/sdk/resources/platform/pipeline-coupling' import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' diff --git a/src/lib/pipelines/render-pipeline.ts b/src/lib/pipelines/render-pipeline.ts index 97fc15f25b..0da03a92f9 100644 --- a/src/lib/pipelines/render-pipeline.ts +++ b/src/lib/pipelines/render-pipeline.ts @@ -1,4 +1,4 @@ -import type {AppWithPipelineCoupling} from '@heroku/sdk/compositions/pipeline' +import type {AppWithPipelineCoupling} from '@heroku/sdk/resources/platform/pipeline-coupling' import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' diff --git a/test/unit/commands/addons/index.unit.test.ts b/test/unit/commands/addons/index.unit.test.ts index 7765fa5198..3de313e195 100644 --- a/test/unit/commands/addons/index.unit.test.ts +++ b/test/unit/commands/addons/index.unit.test.ts @@ -39,7 +39,9 @@ describe('addons', function () { context('with add-ons', function () { beforeEach(function () { - api + // Global /addons doesn't accept Accept-Expansion, so use a + // separate scope without the expansion reqheaders. + nock('https://api.heroku.com') .get('/addons') .reply(200, addons) }) @@ -82,7 +84,7 @@ describe('addons', function () { beforeEach(function () { const addon = fixtures.addons['dwh-db'] addon.billed_price = {cents: 10_000} - api + nock('https://api.heroku.com') .get('/addons') .reply(200, [addon]) }) @@ -101,7 +103,7 @@ describe('addons', function () { beforeEach(function () { const addon = fixtures.addons['dwh-db'] addon.billed_price = {cents: 0, contract: true} - api + nock('https://api.heroku.com') .get('/addons') .reply(200, [addon]) }) diff --git a/test/unit/commands/addons/info.unit.test.ts b/test/unit/commands/addons/info.unit.test.ts index a2ed81de0e..a6a5c16f25 100644 --- a/test/unit/commands/addons/info.unit.test.ts +++ b/test/unit/commands/addons/info.unit.test.ts @@ -29,7 +29,7 @@ describe('addons:info', function () { apiSdk .post('/actions/addons/resolve', {addon: 'www-db'}) .reply(200, [fixtures.addons['www-db']]) - apiSdk.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 () { const {stdout} = await runCommand(Cmd, [ @@ -53,7 +53,7 @@ State: created\n apiSdk .post('/actions/addons/resolve', {addon: 'www-db', app: 'example'}) .reply(200, [fixtures.addons['www-db']]) - apiSdk + api .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) @@ -85,7 +85,7 @@ State: created\n apiSdk .post('/actions/addons/resolve', {addon: 'www-db'}) .reply(200, [fixtures.addons['www-db']]) - apiSdk + api .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) }) @@ -116,7 +116,7 @@ State: created\n apiSdk .post('/actions/addons/resolve', {addon: 'dwh-db'}) .reply(200, [addon]) - apiSdk + api .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) }) @@ -145,7 +145,7 @@ State: created\n apiSdk .post('/actions/addons/resolve', {addon: 'dwh-db'}) .reply(200, [addon]) - apiSdk + api .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) }) @@ -173,7 +173,7 @@ State: created\n apiSdk .post('/actions/addons/resolve', {addon: 'www-redis'}) .reply(200, [provisioningAddon]) - apiSdk + api .get(`/addons/${provisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) }) @@ -201,7 +201,7 @@ State: creating\n apiSdk .post('/actions/addons/resolve', {addon: 'www-redis-2'}) .reply(200, [deprovisioningAddon]) - apiSdk + api .get(`/addons/${deprovisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) }) diff --git a/test/unit/commands/pipelines/promote.unit.test.ts b/test/unit/commands/pipelines/promote.unit.test.ts index 7e28fdb345..5d0a48320c 100644 --- a/test/unit/commands/pipelines/promote.unit.test.ts +++ b/test/unit/commands/pipelines/promote.unit.test.ts @@ -103,7 +103,7 @@ describe('pipelines:promote', function () { const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`]) expect(promoteStub.calledOnce).to.be.true - expect(promoteStub.firstCall.args[0]).to.deep.equal({ + expect(promoteStub.firstCall.args[1]).to.deep.equal({ pipeline: {id: pipeline.id}, source: {app: {id: sourceApp.id}}, targets: [ @@ -128,7 +128,7 @@ describe('pipelines:promote', function () { const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`, `--to=${targetApp1.name}`]) - expect(promoteStub.firstCall.args[0].targets).to.deep.equal([{app: {id: targetApp1.id}}]) + expect(promoteStub.firstCall.args[1].targets).to.deep.equal([{app: {id: targetApp1.id}}]) expect(stdout).to.contain('failed') expect(stdout).to.contain('Because reasons') }) @@ -145,7 +145,7 @@ describe('pipelines:promote', function () { const {stdout} = await runCommand(Cmd, [`--app=${sourceApp.name}`, `--to=${targetApp1.id}`]) - expect(promoteStub.firstCall.args[0].targets).to.deep.equal([{app: {id: targetApp1.id}}]) + expect(promoteStub.firstCall.args[1].targets).to.deep.equal([{app: {id: targetApp1.id}}]) expect(stdout).to.contain('failed') expect(stdout).to.contain('Because reasons') }) @@ -160,7 +160,7 @@ describe('pipelines:promote', function () { controller.close() }, }) - const promoteStub = stub(Cmd, 'promotePipeline').callsFake(async (_body, options) => { + const promoteStub = stub(Cmd, 'promotePipeline').callsFake(async (_ctx, _body, options) => { await options!.onReleaseStream!({ stream: streamBody, target: {app: {id: targetApp1.id}, status: 'pending'}, From 7d097022eb1bc3df9c6120536c0434b3a172a8dc Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 21 May 2026 14:52:19 -0700 Subject: [PATCH 10/11] chore: pin @heroku/sdk to main + apply auto-fix import sorts heroku-sdk PR #27 has merged; flip the pin from the feature branch back to main. Resolves to commit efce1d4. Also pulls in the eslint --fix import-sort cleanup across the seven files we touched (each was importing '@heroku/sdk/extensions/platform' or '@heroku/sdk/resources/platform/...' before '@heroku/sdk', which the perfectionist/sort-imports rule flagged as an error). --- package-lock.json | 4 ++-- package.json | 2 +- src/commands/addons/info.ts | 2 +- src/commands/addons/plans.ts | 2 +- src/commands/addons/upgrade.ts | 2 +- src/commands/maintenance/off.ts | 2 +- src/commands/maintenance/on.ts | 2 +- src/commands/pipelines/promote.ts | 3 ++- src/lib/addons/create-addon.ts | 2 +- 9 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index c14a5ea8fd..a1901c98aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/addon-create-and-wait", + "@heroku/sdk": "github:heroku/heroku-sdk#main", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", @@ -2668,7 +2668,7 @@ }, "node_modules/@heroku/sdk": { "version": "0.4.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#a473f6c0fbf84b2a1daecc4ef3b486a8444744cd", + "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#efce1d4fffd1be97127f4fe78bc65b1e22d6d0de", "license": "Apache-2.0", "dependencies": { "@heroku/heroku-fetch": "github:heroku/heroku-fetch", diff --git a/package.json b/package.json index 83861be305..287808bca9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#eb/feat/addon-create-and-wait", + "@heroku/sdk": "github:heroku/heroku-sdk#main", "@heroku/socksv5": "^0.0.9", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", diff --git a/src/commands/addons/info.ts b/src/commands/addons/info.ts index 6d5102cd2f..cdbdc260b7 100644 --- a/src/commands/addons/info.ts +++ b/src/commands/addons/info.ts @@ -1,8 +1,8 @@ import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {HerokuSDK} from '@heroku/sdk' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {Args} from '@oclif/core' import {formatPrice, formatState} from '../../lib/addons/util.js' diff --git a/src/commands/addons/plans.ts b/src/commands/addons/plans.ts index 21bbdea5f4..394dfae8c2 100644 --- a/src/commands/addons/plans.ts +++ b/src/commands/addons/plans.ts @@ -1,8 +1,8 @@ import {Command, flags} from '@heroku-cli/command' import {Plan} from '@heroku-cli/schema' import {hux} from '@heroku/heroku-cli-util' -import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {HerokuSDK} from '@heroku/sdk' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {Args} from '@oclif/core' import printf from 'printf' diff --git a/src/commands/addons/upgrade.ts b/src/commands/addons/upgrade.ts index 8679d2cde8..e776b8d0f9 100644 --- a/src/commands/addons/upgrade.ts +++ b/src/commands/addons/upgrade.ts @@ -2,9 +2,9 @@ import type {AddOn, Plan} from '@heroku-cli/schema' import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {AddonAmbiguousError} from '@heroku/sdk/resources/platform/add-on' -import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' import {formatPriceText} from '../../lib/addons/util.js' diff --git a/src/commands/maintenance/off.ts b/src/commands/maintenance/off.ts index d0647729cf..c22f6a34d0 100644 --- a/src/commands/maintenance/off.ts +++ b/src/commands/maintenance/off.ts @@ -1,7 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {appExtensions} from '@heroku/sdk/extensions/platform' import {HerokuSDK} from '@heroku/sdk' +import {appExtensions} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' export default class MaintenanceOff extends Command { diff --git a/src/commands/maintenance/on.ts b/src/commands/maintenance/on.ts index 46ad9a751d..8efacbee5f 100644 --- a/src/commands/maintenance/on.ts +++ b/src/commands/maintenance/on.ts @@ -1,7 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {appExtensions} from '@heroku/sdk/extensions/platform' import {HerokuSDK} from '@heroku/sdk' +import {appExtensions} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' export default class MaintenanceOn extends Command { diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index a7c37af002..7c9c8a03d0 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -1,9 +1,10 @@ +import type {AppWithPipelineCoupling} from '@heroku/sdk/resources/platform/pipeline-coupling' + import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' import {HerokuSDK} from '@heroku/sdk' import {pipelineCouplingExtensions} from '@heroku/sdk/extensions/platform' -import type {AppWithPipelineCoupling} from '@heroku/sdk/resources/platform/pipeline-coupling' import {promotePipeline, type ReleaseStreamContext} from '@heroku/sdk/resources/platform/pipeline-promotion' import {ux} from '@oclif/core/ux' import assert from 'node:assert' diff --git a/src/lib/addons/create-addon.ts b/src/lib/addons/create-addon.ts index 6230c56cd9..675e4edf67 100644 --- a/src/lib/addons/create-addon.ts +++ b/src/lib/addons/create-addon.ts @@ -1,8 +1,8 @@ import * as Heroku from '@heroku-cli/schema' import {color, utils} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {AddonConfirmationRequiredError} from '@heroku/sdk/resources/platform/add-on' -import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' import ConfirmCommand from '../confirm-command.js' From 41f1bc7084403ffde80fc9454a8323ca7c1b2da5 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Fri, 22 May 2026 09:57:44 -0700 Subject: [PATCH 11/11] refactor: standardize SDK access on HerokuSDK + extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #3718: the PR mixed two patterns for getting a reference to the platform service — some files used createPlatformClient, others used new HerokuSDK({extensions: [...]}).platform. Pick the HerokuSDK form everywhere so there's one shape in the codebase. For files that don't need any extension methods (detach, index/list, rename, services, maintenance/index, addons-wait), HerokuSDK is constructed without an extensions array — the resulting platform proxy exposes all the route-registry methods exactly the same as createPlatformClient. --- src/commands/addons/detach.ts | 4 ++-- src/commands/addons/index.ts | 14 +++++++------- src/commands/addons/rename.ts | 4 ++-- src/commands/addons/services.ts | 6 +++--- src/commands/maintenance/index.ts | 6 +++--- src/lib/addons/addons-wait.ts | 7 ++++--- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/commands/addons/detach.ts b/src/commands/addons/detach.ts index 3324e842eb..b549e99edd 100644 --- a/src/commands/addons/detach.ts +++ b/src/commands/addons/detach.ts @@ -1,6 +1,6 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' export default class Detach extends Command { @@ -17,7 +17,7 @@ export default class Detach extends Command { public async run(): Promise { const {args, flags} = await this.parse(Detach) const {app} = flags - const platform = createPlatformClient() + const {platform} = new HerokuSDK() const attachment = await platform.addOnAttachment.infoByApp(app, args.attachment_name) ux.action.start(`Detaching ${color.attachment(attachment.name || '')} to ${color.addon(attachment.addon?.name || '')} from ${color.app(app)}`) diff --git a/src/commands/addons/index.ts b/src/commands/addons/index.ts index 8f691e6025..b9a9a04853 100644 --- a/src/commands/addons/index.ts +++ b/src/commands/addons/index.ts @@ -1,7 +1,7 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' import _ from 'lodash' @@ -65,29 +65,29 @@ export function renderAttachment(attachment: Heroku.AddOnAttachment, app: string } async function addonGetter(api: APIClient, app?: string) { - const heroku = createPlatformClient() + const {platform} = new HerokuSDK() // Apply Accept-Expansion only on add-on list calls (the global list // endpoint rejects it; the attachments endpoints don't need it). - const herokuWithExpansion = heroku.withHeaders(ADDON_EXPANSION_HEADERS) + const platformWithExpansion = platform.withHeaders(ADDON_EXPANSION_HEADERS) let attachmentsResponse: null | Promise = null let addonsResponse: Promise if (app) { // don't display attachments globally - addonsResponse = herokuWithExpansion.addOn.listByApp(app) as unknown as Promise + addonsResponse = platformWithExpansion.addOn.listByApp(app) as unknown as Promise const sudoHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}') // eslint-disable-next-line unicorn/prefer-ternary 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. - attachmentsResponse = heroku.addOnAttachment.listByApp(app) as unknown as Promise + attachmentsResponse = platform.addOnAttachment.listByApp(app) as unknown as Promise } else { // In order to display all foreign attachments, we'll get out entire // attachment list - attachmentsResponse = heroku.addOnAttachment.list() as unknown as Promise + attachmentsResponse = platform.addOnAttachment.list() as unknown as Promise } } else { // The global /addons endpoint doesn't support Accept-Expansion. - addonsResponse = heroku.addOn.list() as unknown as Promise + addonsResponse = platform.addOn.list() as unknown as Promise } // Get addons and attachments in parallel diff --git a/src/commands/addons/rename.ts b/src/commands/addons/rename.ts index 87164501f9..2a603bb391 100644 --- a/src/commands/addons/rename.ts +++ b/src/commands/addons/rename.ts @@ -1,6 +1,6 @@ import {Command} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {Args, ux} from '@oclif/core' export default class Rename extends Command { @@ -13,7 +13,7 @@ export default class Rename extends Command { public async run(): Promise { const {args} = await this.parse(Rename) - const platform = createPlatformClient() + const {platform} = new HerokuSDK() const addon = await platform.addOn.info(args.addon_name) await platform.addOn.update(addon.app!.id!, addon.id!, {name: args.new_name, plan: addon.plan!.name!}) ux.stdout(`${color.addon(args.addon_name)} successfully renamed to ${color.info(args.new_name)}.`) diff --git a/src/commands/addons/services.ts b/src/commands/addons/services.ts index cdf8219b85..786d39ed27 100644 --- a/src/commands/addons/services.ts +++ b/src/commands/addons/services.ts @@ -1,6 +1,6 @@ import {Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' export default class Services extends Command { @@ -12,8 +12,8 @@ export default class Services extends Command { public async run(): Promise { const {flags} = await this.parse(Services) - const heroku = createPlatformClient() - const services = await heroku.addOnService.list() + const {platform} = new HerokuSDK() + const services = await platform.addOnService.list() if (flags.json) { hux.styledJSON(services) } else { diff --git a/src/commands/maintenance/index.ts b/src/commands/maintenance/index.ts index 0a62aae33c..83c2a5a144 100644 --- a/src/commands/maintenance/index.ts +++ b/src/commands/maintenance/index.ts @@ -1,5 +1,5 @@ import {Command, flags} from '@heroku-cli/command' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' export default class MaintenanceIndex extends Command { @@ -12,8 +12,8 @@ export default class MaintenanceIndex extends Command { async run() { const {flags} = await this.parse(MaintenanceIndex) - const heroku = createPlatformClient() - const app = await heroku.app.info(flags.app) + const {platform} = new HerokuSDK() + const app = await platform.app.info(flags.app) ux.stdout(app.maintenance ? 'on' : 'off') } } diff --git a/src/lib/addons/addons-wait.ts b/src/lib/addons/addons-wait.ts index 2d2d259640..b768297c30 100644 --- a/src/lib/addons/addons-wait.ts +++ b/src/lib/addons/addons-wait.ts @@ -1,7 +1,7 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' -import {createPlatformClient} from '@heroku/sdk/platform' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' export const waitForAddonProvisioning = async function (addon: Heroku.AddOn, interval: number) { @@ -11,12 +11,13 @@ export const waitForAddonProvisioning = async function (addon: Heroku.AddOn, int ux.action.start(`Creating ${color.addon(addonName || '')}`) - const platform = createPlatformClient().withHeaders({'Accept-Expansion': 'addon_service,plan'}) + const {platform} = new HerokuSDK() + const platformWithExpansion = platform.withHeaders({'Accept-Expansion': 'addon_service,plan'}) while (addonBody.state === 'provisioning') { // eslint-disable-next-line no-promise-executor-return await new Promise(resolve => setTimeout(resolve, interval * 1000)) - addonBody = (await platform.addOn.infoByApp(app, addonName!)) as unknown as Heroku.AddOn + addonBody = (await platformWithExpansion.addOn.infoByApp(app, addonName!)) as unknown as Heroku.AddOn } if (addonBody.state === 'deprovisioned') {