diff --git a/package-lock.json b/package-lock.json index 332ee54152..a1901c98aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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#efce1d4fffd1be97127f4fe78bc65b1e22d6d0de", "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/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/detach.ts b/src/commands/addons/detach.ts index 5892680efc..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 Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' 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} = 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)}`) - 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/index.ts b/src/commands/addons/index.ts index f34fc2525f..b9a9a04853 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 {HerokuSDK} from '@heroku/sdk' 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,38 @@ export function renderAttachment(attachment: Heroku.AddOnAttachment, app: string } async function addonGetter(api: APIClient, app?: string) { - let attachmentsResponse: null | ReturnType> = null - let addonsResponse: ReturnType> + 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 platformWithExpansion = platform.withHeaders(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 = 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 = api.get(`/apps/${app}/addon-attachments`) + 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 = api.get('/addon-attachments') + attachmentsResponse = platform.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', - }, - }) + // The global /addons endpoint doesn't support Accept-Expansion. + addonsResponse = platform.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 +116,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..cdbdc260b7 100644 --- a/src/commands/addons/info.ts +++ b/src/commands/addons/info.ts @@ -1,10 +1,11 @@ import {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 {addOnExtensions} from '@heroku/sdk/extensions/platform' 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' @@ -24,21 +25,20 @@ 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 {body: attachments} = await this.heroku.get(`/addons/${addon.id}/addon-attachments`) + 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']} - 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..394dfae8c2 100644 --- a/src/commands/addons/plans.ts +++ b/src/commands/addons/plans.ts @@ -1,8 +1,9 @@ import {Command, flags} from '@heroku-cli/command' import {Plan} from '@heroku-cli/schema' import {hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' import {Args} from '@oclif/core' -import _ from 'lodash' import printf from 'printf' import {formatPrice} from '../../lib/addons/util.js' @@ -29,12 +30,13 @@ 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', - }, - }) - 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..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 Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' 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} = 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 fd1133e41f..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 * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' 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 {platform} = new HerokuSDK() + const services = await platform.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..e776b8d0f9 100644 --- a/src/commands/addons/upgrade.ts +++ b/src/commands/addons/upgrade.ts @@ -1,16 +1,18 @@ 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 {HerokuSDK} from '@heroku/sdk' +import {addOnExtensions} from '@heroku/sdk/extensions/platform' +import {AddonAmbiguousError} from '@heroku/sdk/resources/platform/add-on' 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,25 +81,13 @@ ${color.cyan('https://devcenter.heroku.com/articles/managing-add-ons')}` } protected async getPlans(addonServiceName: string | undefined): Promise { - try { - const plansResponse: HTTP = await this.heroku.get(`/addon-services/${addonServiceName}/plans`) - const {body: plans} = plansResponse - plans.sort((a, b) => { - if (a?.price?.cents === b?.price?.cents) { - return 0 - } - - if (!a?.price?.cents || !b?.price?.cents || a.price.cents > b.price.cents) { - return 1 - } - - if (a.price.cents < b.price.cents) { - return -1 - } + if (!addonServiceName) { + return [] + } - return 0 - }) - return plans + try { + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) + return (await platform.addOn.listPlans(addonServiceName)) as unknown as Plan[] } catch { return [] } @@ -109,43 +99,32 @@ ${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)}`) + const {platform} = new HerokuSDK({extensions: [addOnExtensions]}) + 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 platform.addOn.upgrade(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 +135,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/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/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/commands/maintenance/off.ts b/src/commands/maintenance/off.ts index 0f28eaf034..c22f6a34d0 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 {HerokuSDK} from '@heroku/sdk' +import {appExtensions} from '@heroku/sdk/extensions/platform' 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..8efacbee5f 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 {HerokuSDK} from '@heroku/sdk' +import {appExtensions} from '@heroku/sdk/extensions/platform' 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() } } 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..7c9c8a03d0 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -1,12 +1,11 @@ +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 { - type AppWithPipelineCoupling, - listPipelineApps, - promotePipeline, - type ReleaseStreamContext, -} from '@heroku/sdk/compositions/pipeline' +import {HerokuSDK} from '@heroku/sdk' +import {pipelineCouplingExtensions} from '@heroku/sdk/extensions/platform' +import {promotePipeline, type ReleaseStreamContext} from '@heroku/sdk/resources/platform/pipeline-promotion' import {ux} from '@oclif/core/ux' import assert from 'node:assert' @@ -49,7 +48,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 +104,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/addons/addons-wait.ts b/src/lib/addons/addons-wait.ts index 9e395c709f..b768297c30 100644 --- a/src/lib/addons/addons-wait.ts +++ b/src/lib/addons/addons-wait.ts @@ -1,24 +1,23 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' 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} = 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)) - const addonResponse = await api.get(`/apps/${app}/addons/${addonName}`, { - headers: {'Accept-Expansion': 'addon_service,plan'}, - }) - - addonBody = addonResponse?.body + addonBody = (await platformWithExpansion.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 c18cf3f3c0..675e4edf67 100644 --- a/src/lib/addons/create-addon.ts +++ b/src/lib/addons/create-addon.ts @@ -1,9 +1,11 @@ -import {APIClient} from '@heroku-cli/command' 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 {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) { @@ -18,7 +20,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, @@ -30,57 +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: {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 {body: addon} = await heroku.post(`/apps/${app}/addons`, { - body, - headers: { - 'accept-expansion': 'plan', - 'x-heroku-legacy-provider-messages': 'true', - }, - }) - 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(heroku, 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/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/create.unit.test.ts b/test/unit/commands/addons/create.unit.test.ts index 37afa7a7d1..2edf7971ce 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, [ @@ -294,14 +286,14 @@ 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.') }) }) }) 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/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/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 b660180d3f..a6a5c16f25 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,7 +27,7 @@ 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']]) }) @@ -57,13 +53,6 @@ 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 .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) @@ -89,18 +78,13 @@ 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 .get(`/addons/${fixtures.addons['www-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::DATABASE']]) @@ -130,15 +114,8 @@ 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 .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) @@ -166,15 +143,8 @@ 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 .get(`/addons/${fixtures.addons['dwh-db'].id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-dwh::DATABASE']]) @@ -201,11 +171,8 @@ 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 .get(`/addons/${provisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) @@ -232,11 +199,8 @@ 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 .get(`/addons/${deprovisioningAddon.id}/addon-attachments`) .reply(200, [fixtures.attachments['acme-inc-www::REDIS']]) 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']) 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, [ diff --git a/test/unit/commands/addons/wait.unit.test.ts b/test/unit/commands/addons/wait.unit.test.ts index 7384991779..22c5daf98e 100644 --- a/test/unit/commands/addons/wait.unit.test.ts +++ b/test/unit/commands/addons/wait.unit.test.ts @@ -1,29 +1,28 @@ 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 {createSandbox, type SinonFakeTimers} 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 () { let sandbox: any + let clock: SinonFakeTimers 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). + clock = 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, 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'},