From 400fc6e26eb7b7fdd634e0e4465fc5c19762303a Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Mon, 18 May 2026 15:40:26 -0300 Subject: [PATCH 1/8] fix: add missing warning to 'data:pg:migrate' (W-22544849) (#3716) fix: add missing warning to 'data:pg:migrate' --- src/commands/data/pg/migrate.ts | 4 ++++ test/unit/commands/data/pg/migrate.unit.test.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/src/commands/data/pg/migrate.ts b/src/commands/data/pg/migrate.ts index 6905206148..da4aace9dc 100644 --- a/src/commands/data/pg/migrate.ts +++ b/src/commands/data/pg/migrate.ts @@ -170,6 +170,7 @@ export default class DataPgMigrate extends BaseCommand { let currentStep = '__select_source' let sourceDatabaseId: string | undefined let targetDatabaseId: string | undefined + let targetDatabaseName: string | undefined while (currentStep !== '__exit') { switch (currentStep) { @@ -181,6 +182,7 @@ export default class DataPgMigrate extends BaseCommand { You'll receive an email when the preparation is complete or if there's an error. You have 24 hours to begin migration after the preparation is complete. Your source database will be unavailable during the migration. + Preparing the migration deletes all the data on the destination database ${color.datastore(targetDatabaseName!)}. `)) const {action} = await this.prompt<{action: string}>({ @@ -281,6 +283,7 @@ export default class DataPgMigrate extends BaseCommand { name: 'database', type: 'list', })).database + targetDatabaseName = this.advancedDatabases.find(db => db.id === targetDatabaseId)?.name if (targetDatabaseId === '__go_back') { currentStep = '__select_source' @@ -288,6 +291,7 @@ export default class DataPgMigrate extends BaseCommand { const addon = await this.createTargetDatabase(sourceDatabaseId!) if (addon) { targetDatabaseId = addon.id + targetDatabaseName = addon.name currentStep = '__confirm_migration' } else { currentStep = '__select_target' diff --git a/test/unit/commands/data/pg/migrate.unit.test.ts b/test/unit/commands/data/pg/migrate.unit.test.ts index 03046e32e8..65cc9bf22d 100644 --- a/test/unit/commands/data/pg/migrate.unit.test.ts +++ b/test/unit/commands/data/pg/migrate.unit.test.ts @@ -368,6 +368,7 @@ describe('data:pg:migrate', function () { // Verify the confirmation message is shown expect(stdout).to.contain('By continuing, we prepare the necessary steps for the migration.') + expect(stdout).to.contain('Preparing the migration deletes all the data on the destination database ⛁ postgresql-obscured-12345.') expect(stderr).to.equal('Configuring migration... done\n') // Verify the new migration is shown on the configured migrations table expect(stdout).to.match(/⛁ postgresql-convex-12345\s+⛁ postgresql-obscured-12345\s+Preparing/) From 8abed2faad739a1897734fe414cc2e1b70ea245a Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 22 May 2026 15:46:34 -0700 Subject: [PATCH 2/8] chore: add CLAUDE.md and Copilot instructions pointing to AGENTS.md (#3724) Both Claude Code and GitHub Copilot Code Review look for tool-specific instruction files (CLAUDE.md and .github/copilot-instructions.md respectively). Point both at the canonical AGENTS.md so we don't have to maintain duplicate guidelines per tool. --- .github/copilot-instructions.md | 1 + CLAUDE.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..189603835a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +See [AGENTS.md](../AGENTS.md). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..fded5dcf77 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md). From ebdf082ed317cb74a59945c60dc274615b735b81 Mon Sep 17 00:00:00 2001 From: Johnny Winn <88165065+heroku-johnny@users.noreply.github.com> Date: Tue, 26 May 2026 13:58:20 -0600 Subject: [PATCH 3/8] fix: pass empty string to rl.write in repl finally block (W-22295448) (#3721) Co-authored-by: Claude Sonnet 4.6 --- src/lib/repl.ts | 2 +- test/unit/lib/repl.unit.test.ts | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/lib/repl.ts b/src/lib/repl.ts index 2dadb905a6..61fa8b28d9 100644 --- a/src/lib/repl.ts +++ b/src/lib/repl.ts @@ -168,7 +168,7 @@ export class HerokuRepl { this.createInterface() this.start() // Force readline to refresh the current line - this.rl.write(null, {ctrl: true, name: 'u'}) + this.rl.write('', {ctrl: true, name: 'u'}) } } /** diff --git a/test/unit/lib/repl.unit.test.ts b/test/unit/lib/repl.unit.test.ts index c1bef82bdb..41016cd189 100644 --- a/test/unit/lib/repl.unit.test.ts +++ b/test/unit/lib/repl.unit.test.ts @@ -172,6 +172,41 @@ describe('HerokuRepl', function () { }) }) + describe('processLine', function () { + it('passes an empty string to rl.write when clearing the line after command execution', async function () { + const writeSpy = stub() + const mockRl = { + close: stub(), + history: [], + off: stub(), + on: stub(), + once: stub(), + prompt: stub(), + setPrompt: stub(), + write: writeSpy, + } + + stub(HerokuRepl.prototype, 'createInterface' as any).callsFake(function (this: any) { + this.rl = mockRl + }) + + config = { + commands: [], + findCommand: stub().returns({flags: {}, id: 'apps:info'}), + root: '/fake/root', + runCommand: stub().resolves(), + } as any + + repl = new HerokuRepl(config) + + await (repl as any).processLine('apps:info') + + const writeCall = writeSpy.args.find((args: any[]) => args[1]?.ctrl === true && args[1]?.name === 'u') + expect(writeCall, 'rl.write should be called for ctrl+u line clear').to.exist + expect(writeCall![0]).to.equal('') + }) + }) + describe('readline interface creation', function () { it('should create readline interface with correct configuration', function () { stub(HerokuRepl.prototype, 'fsExistsSync' as any).returns(false) From 355113e8253b8547ea86be4ef540287ea263af80 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Wed, 27 May 2026 12:50:31 -0300 Subject: [PATCH 4/8] fix: 'run:inside' args ordering (W-22693654) (#3727) fix: 'run:inside' args ordering --- src/commands/run/inside.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/run/inside.ts b/src/commands/run/inside.ts index 90b28c6e1a..74ec713c99 100644 --- a/src/commands/run/inside.ts +++ b/src/commands/run/inside.ts @@ -11,16 +11,18 @@ const debug = debugFactory('heroku:run:inside') const heredoc = tsheredoc.default export default class RunInside extends Command { + /* eslint-disable perfectionist/sort-objects */ static args = { - command: Args.string({ - description: 'command to run (Heroku automatically prepends \'launcher\' to the command)', - required: true, - }), dyno_name: Args.string({ description: 'name of the dyno to run command inside', required: true, }), + command: Args.string({ + description: 'command to run (Heroku automatically prepends \'launcher\' to the command)', + required: true, + }), } + /* eslint-enable perfectionist/sort-objects */ static description = 'run a command inside an existing dyno (for Fir-generation apps only)' static examples = [ heredoc` From cfaee8342ff77e4216c24301a9bc1c29bb455457 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Thu, 28 May 2026 12:51:43 -0700 Subject: [PATCH 5/8] Centralize PG upgrade SDK calls in src/lib/pg/sdk-adapter.ts by adding shared runUpgrade/prepareUpgrade/dryRunUpgrade helpers and refactor the corresponding upgrade commands to use them instead of repeating inline SDK method casting. --- src/commands/data/maintenances/run.ts | 1 + src/commands/data/maintenances/schedule.ts | 2 +- src/commands/data/maintenances/wait.ts | 1 + src/commands/ps/index.ts | 41 ++- src/commands/ps/restart.ts | 27 +- src/commands/ps/scale.ts | 18 +- src/lib/pg/types.ts | 1 - src/lib/ps/sdk-adapter.ts | 38 +++ test/unit/commands/ps/index.unit.test.ts | 374 +++++++++------------ test/unit/commands/ps/restart.unit.test.ts | 33 +- test/unit/commands/ps/scale.unit.test.ts | 64 ++-- 11 files changed, 297 insertions(+), 303 deletions(-) create mode 100644 src/lib/ps/sdk-adapter.ts diff --git a/src/commands/data/maintenances/run.ts b/src/commands/data/maintenances/run.ts index 2bcc4683cb..e5c9a57bc1 100644 --- a/src/commands/data/maintenances/run.ts +++ b/src/commands/data/maintenances/run.ts @@ -70,6 +70,7 @@ export default class DataMaintenancesRun extends BaseCommand { if (wait) { ux.action.start('Waiting for maintenance to complete') + // waitUntilMaintenanceComplete still uses legacy dataApi (polling loop not yet migrated to SDK) await waitUntilMaintenanceComplete(addon.id!, this.dataApi) ux.action.stop('maintenance completed') } diff --git a/src/commands/data/maintenances/schedule.ts b/src/commands/data/maintenances/schedule.ts index 188a84e8f4..0abf1ddd8c 100644 --- a/src/commands/data/maintenances/schedule.ts +++ b/src/commands/data/maintenances/schedule.ts @@ -73,7 +73,7 @@ export default class DataMaintenancesSchedule extends Command { const schedule: MaintenanceScheduleResult = await data.maintenance.schedule(addon.id!, {delay_weeks: delayWeeks}) ux.action.stop('maintenance scheduled') - const alreadyScheduled = !!schedule.previously_scheduled_for + const alreadyScheduled = Boolean(schedule.previously_scheduled_for) if (alreadyScheduled) { this.log(`Scheduled maintenance for ${color.addon(addon.name!)} changed from ${schedule.previously_scheduled_for} to ${schedule.scheduled_for}`) diff --git a/src/commands/data/maintenances/wait.ts b/src/commands/data/maintenances/wait.ts index 5e901e6270..087258720d 100644 --- a/src/commands/data/maintenances/wait.ts +++ b/src/commands/data/maintenances/wait.ts @@ -39,6 +39,7 @@ export default class DataMaintenancesWait extends BaseCommand { } ux.action.start(`Waiting for maintenance on ${color.addon(addon.name!)} to complete`) + // waitUntilMaintenanceComplete still uses legacy dataApi (polling loop not yet migrated to SDK) await waitUntilMaintenanceComplete(addon.id!, this.dataApi) ux.action.stop('maintenance completed') } diff --git a/src/commands/ps/index.ts b/src/commands/ps/index.ts index 58575259c4..e5a60c197e 100644 --- a/src/commands/ps/index.ts +++ b/src/commands/ps/index.ts @@ -1,13 +1,15 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import type {Account} from '@heroku/types/3.sdk' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' +import {getAppInfo, listDynos} from '../../lib/ps/sdk-adapter.js' import {ago} from '../../lib/time.js' import {AccountQuota} from '../../lib/types/account-quota.js' import {AppProcessTier} from '../../lib/types/app-process-tier.js' import {DynoExtended} from '../../lib/types/dyno-extended.js' -import {Account} from '../../lib/types/fir.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' const heredoc = tsheredoc.default @@ -40,19 +42,32 @@ export default class Index extends Command { const {flags, ...restParse} = await this.parse(Index) const {app, extended, json} = flags const types = restParse.argv as string[] - const suffix = extended ? '?extended=true' : '' - const promises = { - accountInfo: this.heroku.request('/account', { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - appInfo: this.heroku.request(`/apps/${app}`, { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - dynos: this.heroku.request(`/apps/${app}/dynos${suffix}`, { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), + + const {platform} = new HerokuSDK() + + let dynos: DynoExtended[] + let appInfo: AppProcessTier + let accountInfo: Account + + if (extended) { + const [{body: extDynos}, appInfoResult, accountInfoResult] = await Promise.all([ + this.heroku.request(`/apps/${app}/dynos?extended=true`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, + }), + getAppInfo(platform, app), + platform.account.info(), + ]) + dynos = extDynos + appInfo = appInfoResult + accountInfo = accountInfoResult + } else { + [dynos, appInfo, accountInfo] = await Promise.all([ + listDynos(platform, app), + getAppInfo(platform, app), + platform.account.info(), + ]) } - const [{body: dynos}, {body: appInfo}, {body: accountInfo}] = await Promise.all([promises.dynos, promises.appInfo, promises.accountInfo]) + const shielded = appInfo.space && appInfo.space.shield if (shielded) { diff --git a/src/commands/ps/restart.ts b/src/commands/ps/restart.ts index e29d951ef2..563b8bc4d9 100644 --- a/src/commands/ps/restart.ts +++ b/src/commands/ps/restart.ts @@ -1,8 +1,9 @@ import {Command, flags} from '@heroku-cli/command' import {ProcessTypeCompletion} from '@heroku-cli/command/lib/completions.js' -import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' +import {dynoExtensions} from '@heroku/sdk/extensions/platform' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -44,32 +45,24 @@ export default class Restart extends Command { const {app} = flags const dyno = flags['dyno-name'] || args.dyno const type = flags['process-type'] - let msg = 'Restarting' - let restartUrl + + const {platform} = new HerokuSDK({extensions: [dynoExtensions]}) if (type) { - msg += ` all ${color.info(type)} dynos` - restartUrl = `/apps/${app}/formations/${encodeURIComponent(type)}` + ux.action.start(`Restarting all ${color.info(type)} dynos on ${color.app(app)}`) + await platform.dyno.restart(app, {type}) } else if (dyno) { if (args.dyno) { ux.warn(`DYNO is a deprecated argument. Use ${color.code('--dyno-name')} or ${color.code('--process-type')} instead.`) } - msg += ` dyno ${color.name(dyno)}` - restartUrl = `/apps/${app}/dynos/${encodeURIComponent(dyno)}` + ux.action.start(`Restarting dyno ${color.name(dyno)} on ${color.app(app)}`) + await platform.dyno.restart(app, {dyno}) } else { - msg += ' all dynos' - restartUrl = `/apps/${app}/dynos` + ux.action.start(`Restarting all dynos on ${color.app(app)}`) + await platform.dyno.restart(app) } - msg += ` on ${color.app(app)}` - - ux.action.start(msg) - await this.heroku.delete(restartUrl, { - headers: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - }, - }) ux.action.stop() } } diff --git a/src/commands/ps/scale.ts b/src/commands/ps/scale.ts index 818ca703c0..7aa778fa45 100644 --- a/src/commands/ps/scale.ts +++ b/src/commands/ps/scale.ts @@ -1,10 +1,11 @@ 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 {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' import {lazyModuleLoader} from '../../lib/lazy-module-loader.js' +import {type ScaleUpdate, scaleDynos} from '../../lib/ps/sdk-adapter.js' const heredoc = tsheredoc.default @@ -42,7 +43,7 @@ export default class Scale extends Command { const argv = restParse.argv as string[] const {app} = flags - function parse(args: string[]) { + function parse(args: string[]): ScaleUpdate[] { return _.compact(args.map(arg => { const change = arg.match(/^([\w-]+)([=+-]\d+)(?::([\w-]+))?$/) if (!change) @@ -54,11 +55,14 @@ export default class Scale extends Command { })) } + const {platform} = new HerokuSDK() const changes = parse(argv) if (changes.length === 0) { - const {body: formation} = await this.heroku.get(`/apps/${app}/formation`) - const {body: appProps} = await this.heroku.get(`/apps/${app}`) + const [formation, appProps] = await Promise.all([ + platform.formation.list(app), + platform.app.info(app), + ]) const shielded = appProps.space && appProps.space.shield if (shielded) { for (const d of formation) { @@ -77,8 +81,10 @@ export default class Scale extends Command { .join(' ')) } else { ux.action.start('Scaling dynos') - const {body: appProps} = await this.heroku.get(`/apps/${app}`) - const {body: formation} = await this.heroku.patch(`/apps/${app}/formation`, {body: {updates: changes}}) + const [appProps, formation] = await Promise.all([ + platform.app.info(app), + scaleDynos(platform, app, changes), + ]) const shielded = appProps.space && appProps.space.shield if (shielded) { for (const d of formation) { diff --git a/src/lib/pg/types.ts b/src/lib/pg/types.ts index ca12b35b93..93dbcc5c53 100644 --- a/src/lib/pg/types.ts +++ b/src/lib/pg/types.ts @@ -173,7 +173,6 @@ export type PgDatabaseTenant = { export type PgDatabase = PgDatabaseService & PgDatabaseTenant - // Updated according to https://github.com/heroku/shogun/blob/main/lib/shogun/serializers/link_serializer.rb export type Link = { created_at: string, diff --git a/src/lib/ps/sdk-adapter.ts b/src/lib/ps/sdk-adapter.ts new file mode 100644 index 0000000000..8bdf8faf80 --- /dev/null +++ b/src/lib/ps/sdk-adapter.ts @@ -0,0 +1,38 @@ +import type {Formation} from '@heroku/types/3.sdk' + +import type {HerokuSDK} from '@heroku/sdk' + +import type {AppProcessTier} from '../types/app-process-tier.js' +import type {DynoExtended} from '../types/dyno-extended.js' + +// Temporary adapter: the SDK's types are incomplete in @heroku/types. +// These wrappers cast once so command files stay type-safe. +// Remove when heroku-types adds: +// - FormationBatchUpdateOpts.updates[].size as string +// - FormationBatchUpdateOpts.updates[].quantity as number | string +// - App.process_tier +// - Dyno.extended + +type PlatformClient = HerokuSDK['platform'] + +export type ScaleUpdate = { + quantity: string + size?: string + type: string +} + +export async function scaleDynos(platform: PlatformClient, appIdentity: string, updates: ScaleUpdate[]): Promise { + const fn = platform.formation.batchUpdate as ( + appIdentity: string, + body: {updates: ScaleUpdate[]}, + ) => Promise + return fn(appIdentity, {updates}) +} + +export async function getAppInfo(platform: PlatformClient, appIdentity: string): Promise { + return platform.app.info(appIdentity) as unknown as AppProcessTier +} + +export async function listDynos(platform: PlatformClient, appIdentity: string): Promise { + return platform.dyno.list(appIdentity) as unknown as DynoExtended[] +} diff --git a/test/unit/commands/ps/index.unit.test.ts b/test/unit/commands/ps/index.unit.test.ts index a10eed9b17..f3178129a2 100644 --- a/test/unit/commands/ps/index.unit.test.ts +++ b/test/unit/commands/ps/index.unit.test.ts @@ -8,6 +8,7 @@ import strftime from 'strftime' import tsheredoc from 'tsheredoc' import Cmd from '../../../../src/commands/ps/index.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' import normalizeTableOutput from '../../../helpers/utils/normalize-table-output.js' const heredoc = tsheredoc.default @@ -15,68 +16,39 @@ const heredoc = tsheredoc.default const hourAgo = new Date(Date.now() - (60 * 60 * 1000)) const hourAgoStr = strftime('%Y/%m/%d %H:%M:%S %z', hourAgo) -function stubAccountQuota(code: number, body: Record) { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [{ - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }]) - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) +function stubQuotaApi(accountId: string, code: number, body: Record) { nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.account-quotas'}}) - .get('/accounts/1234/actions/get-quota') + .get(`/accounts/${accountId}/actions/get-quota`) .reply(code, body) } -function stubAppAndAccount() { - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) -} - describe('ps', function () { + let sdkMock: MockSDK + afterEach(function () { nock.cleanAll() restore() + sdkMock.restore() }) it('shows dyno list', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', - name: 'web.1', - size: 'Eco', - state: 'up', - type: 'web', - updated_at: hourAgo, - }, - { - command: 'bash', - name: 'run.1', - size: 'Eco', - state: 'up', - type: 'run', - updated_at: hourAgo, - }, - ]) - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - api.done() - expect(stdout).to.equal(heredoc` === run: one-off processes (1) @@ -91,28 +63,26 @@ describe('ps', function () { }) it('shows dyno list for Fir apps', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.4ed720fa31-ur8z1', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'npm start', name: 'web.4ed720fa31-5om2v', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'npm start ./worker.js', name: 'node-worker.4ed720fa31-w4llb', size: '2X-Compute', state: 'up', type: 'node-worker', updated_at: hourAgo, - }, - ]) - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.4ed720fa31-ur8z1', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'npm start', name: 'web.4ed720fa31-5om2v', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'npm start ./worker.js', name: 'node-worker.4ed720fa31-w4llb', size: '2X-Compute', state: 'up', type: 'node-worker', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - api.done() - expect(stdout).to.equal(heredoc` === node-worker (2X-Compute): npm start ./worker.js (1) @@ -128,28 +98,23 @@ describe('ps', function () { }) it('shows shield dynos in dyno list for apps in a shielded private space', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {space: {shield: true}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.1', size: 'Private-M', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'bash', name: 'run.1', size: 'Private-L', state: 'up', type: 'run', updated_at: hourAgo, - }, - ]) - - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Private-M', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'bash', name: 'run.1', size: 'Private-L', state: 'up', type: 'run', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic', space: {shield: true}}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - api.done() - expect(stdout).to.equal(heredoc` === run: one-off processes (1) @@ -164,18 +129,17 @@ describe('ps', function () { }) it('errors when no dynos found', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }, - ]) - - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) const {error, stdout} = await runCommand(Cmd, [ 'foo', @@ -183,24 +147,18 @@ describe('ps', function () { 'myapp', ]) expect(ansis.strip(error!.message)).to.include('No foo dynos on ⬢ myapp') - - api.done() - expect(stdout).to.equal('') }) it('shows dyno list as json', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, - }, - ]) + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -208,29 +166,21 @@ describe('ps', function () { '--json', ]) - api.done() expect(JSON.parse(stdout)[0].command).to.equal('npm start') expect(stderr).to.equal('') }) it('shows extended info', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.1', - port: 8000, - region: 'us', - route: 'da route', + az: 'us-east', execution_plane: 'execution_plane', fleet: 'fleet', instance: 'instance', ip: '10.0.0.1', port: 8000, region: 'us', route: 'da route', }, id: '100', name: 'web.1', @@ -242,14 +192,7 @@ describe('ps', function () { }, { command: 'bash', extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.2', - port: 8000, - region: 'us', - route: 'da route', + az: 'us-east', execution_plane: 'execution_plane', fleet: 'fleet', instance: 'instance', ip: '10.0.0.2', port: 8000, region: 'us', route: 'da route', }, id: '101', name: 'run.1', @@ -266,8 +209,6 @@ describe('ps', function () { '--extended', ]) - api.done() - expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ─────── ─────── ───────── ──────── ──── @@ -279,23 +220,16 @@ describe('ps', function () { }) it('passes no-wrap option through to extended table rendering', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.1', - port: 8000, - region: 'us', - route: 'da route', + az: 'us-east', execution_plane: 'execution_plane', fleet: 'fleet', instance: 'instance', ip: '10.0.0.1', port: 8000, region: 'us', route: 'da route', }, id: '100', name: 'web.1', @@ -314,23 +248,16 @@ describe('ps', function () { }) it('shows extended info for Private Space app', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', extended: { - az: null, - execution_plane: null, - fleet: null, - instance: 'instance', - ip: '10.0.0.1', - port: null, - region: 'us', - route: null, + az: null, execution_plane: null, fleet: null, instance: 'instance', ip: '10.0.0.1', port: null, region: 'us', route: null, }, id: '100', name: 'web.1', @@ -342,14 +269,7 @@ describe('ps', function () { }, { command: 'bash', extended: { - az: null, - execution_plane: null, - fleet: null, - instance: 'instance', - ip: '10.0.0.1', - port: null, - region: 'us', - route: null, + az: null, execution_plane: null, fleet: null, instance: 'instance', ip: '10.0.0.1', port: null, region: 'us', route: null, }, id: '101', name: 'run.1', @@ -366,8 +286,6 @@ describe('ps', function () { '--extended', ]) - api.done() - expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ── ─────── ───────── ───── ──── @@ -378,11 +296,11 @@ describe('ps', function () { }) it('shows shield dynos in extended info if app is in a shielded private space', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {space: {shield: true}}) + const infoStub = stub().resolves({space: {shield: true}}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', @@ -416,8 +334,6 @@ describe('ps', function () { '--extended', ]) - api.done() - expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ─────── ─────── ───────── ──────── ──────── @@ -428,6 +344,14 @@ describe('ps', function () { }) it('shows eco quota remaining', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 1000, apps: [], quota_used: 1}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 0h 16m (99%) Eco dyno usage for this app: 0h 0m (0%) @@ -439,7 +363,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 1000, apps: [], quota_used: 1}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -451,6 +374,14 @@ describe('ps', function () { }) it('shows eco quota remaining in hours and minutes', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 3_600_000, apps: [], quota_used: 178_200}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 950h 30m (95%) Eco dyno usage for this app: 0h 0m (0%) @@ -462,7 +393,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 3_600_000, apps: [], quota_used: 178_200}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -474,6 +404,14 @@ describe('ps', function () { }) it('shows eco quota usage of eco apps', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 3_600_000, apps: [{app_uuid: '6789', quota_used: 178_200}], quota_used: 178_200}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 950h 30m (95%) Eco dyno usage for this app: 49h 30m (4%) @@ -485,7 +423,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 3_600_000, apps: [{app_uuid: '6789', quota_used: 178_200}], quota_used: 178_200}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -497,6 +434,14 @@ describe('ps', function () { }) it('shows eco quota remaining even when account_quota is zero', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 0, apps: [], quota_used: 0}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 0h 0m (0%) Eco dyno usage for this app: 0h 0m (0%) @@ -508,7 +453,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 0, apps: [], quota_used: 0}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -520,13 +464,20 @@ describe('ps', function () { }) it('handles quota 404 properly', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 404, {id: 'not_found'}) + const ecoExpression = heredoc` === run: one-off processes (1) run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(404, {id: 'not_found'}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -538,13 +489,20 @@ describe('ps', function () { }) it('handles quota 200 not_found properly', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {id: 'not_found'}) + const ecoExpression = heredoc` === run: one-off processes (1) run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {id: 'not_found'}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -556,22 +514,13 @@ describe('ps', function () { }) it('does not print out for apps that are not owned', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, { - owner: {id: '5678'}, process_tier: 'eco', - }) - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.account-quotas'}}) - .get('/accounts/1234/actions/get-quota') - .reply(200, {account_quota: 1000, apps: [], quota_used: 1}) - const dynos = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [{ - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }]) + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({owner: {id: '5678'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + const ecoExpression = heredoc` === run: one-off processes (1) @@ -584,24 +533,18 @@ describe('ps', function () { 'myapp', ]) - dynos.done() - expect(stdout).to.equal(ecoExpression) expect(stderr).to.equal('') }) it('does not print out for non-eco apps', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {owner: {id: 1234}, process_tier: 'eco'}) - const dynos = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [{ - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }]) + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + const ecoExpression = heredoc` === run: one-off processes (1) @@ -614,20 +557,25 @@ describe('ps', function () { 'myapp', ]) - dynos.done() - expect(stdout).to.equal(ecoExpression) expect(stderr).to.equal('') }) it('traps errors properly', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + stubQuotaApi('1234', 503, {id: 'server_error'}) + const ecoExpression = heredoc` === run: one-off processes (1) run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(503, {id: 'server_error'}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -639,18 +587,16 @@ describe('ps', function () { }) it('logs to stdout and exits zero when no dynos', async function () { - const dynos = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, []) - stubAppAndAccount() + const listStub = stub().resolves([]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - dynos.done() - expect(stdout).to.equal('No dynos on ⬢ myapp\n') expect(stderr).to.equal('') }) diff --git a/test/unit/commands/ps/restart.unit.test.ts b/test/unit/commands/ps/restart.unit.test.ts index 34eb9a9841..cfa6c1a7d8 100644 --- a/test/unit/commands/ps/restart.unit.test.ts +++ b/test/unit/commands/ps/restart.unit.test.ts @@ -1,27 +1,33 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/ps/restart.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('ps:restart', function () { + let sdkMock: MockSDK + + afterEach(function () { + sdkMock.restore() + }) + it('restarts all dynos', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/dynos') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', 'myapp', ]) expectOutput(stderr, 'Restarting all dynos on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp')).to.be.true }) it('restarts web dynos', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/formations/web') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', @@ -30,12 +36,12 @@ describe('ps:restart', function () { 'web', ]) expectOutput(stderr, 'Restarting all web dynos on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp', {type: 'web'})).to.be.true }) it('restarts a specific dyno', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/dynos/web.1') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', @@ -44,12 +50,12 @@ describe('ps:restart', function () { 'web.1', ]) expectOutput(stderr, 'Restarting dyno web.1 on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp', {dyno: 'web.1'})).to.be.true }) it('emits a warning when passing dyno as an arg', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/dynos/web.1') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', @@ -58,5 +64,6 @@ describe('ps:restart', function () { ]) expect(ansis.strip(stderr)).to.include('DYNO is a deprecated argument.') expect(stderr).to.include('Restarting dyno web.1 on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp', {dyno: 'web.1'})).to.be.true }) }) diff --git a/test/unit/commands/ps/scale.unit.test.ts b/test/unit/commands/ps/scale.unit.test.ts index 117243fe8c..b807d6c0e5 100644 --- a/test/unit/commands/ps/scale.unit.test.ts +++ b/test/unit/commands/ps/scale.unit.test.ts @@ -1,28 +1,22 @@ import {runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/ps/scale.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('ps:scale', function () { - let api: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() }) it('shows formation with no args', async function () { - api - .get('/apps/myapp/formation') - .reply(200, [{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const listStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) + const infoStub = stub().resolves({name: 'myapp'}) + sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -31,14 +25,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('web=1:Free worker=2:Free\n') expect(stderr).to.equal('') + expect(listStub.calledOnceWith('myapp')).to.be.true }) it('shows formation with shield dynos for apps in a shielded private space', async function () { - api - .get('/apps/myapp/formation') - .reply(200, [{quantity: 1, size: 'Private-L', type: 'web'}, {quantity: 2, size: 'Private-M', type: 'worker'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp', space: {shield: true}}) + const listStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}, {quantity: 2, size: 'Private-M', type: 'worker'}]) + const infoStub = stub().resolves({name: 'myapp', space: {shield: true}}) + sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -50,11 +43,9 @@ describe('ps:scale', function () { }) it('errors with no process types', async function () { - api - .get('/apps/myapp/formation') - .reply(200, []) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const listStub = stub().resolves([]) + const infoStub = stub().resolves({name: 'myapp'}) + sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {list: listStub}}) const {error, stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -67,11 +58,9 @@ describe('ps:scale', function () { }) it('scales web=1 worker=2', async function () { - api - .patch('/apps/myapp/formation', {updates: [{quantity: '1', type: 'web'}, {quantity: '2', type: 'worker'}]}) - .reply(200, [{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const batchUpdateStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) + const infoStub = stub().resolves({name: 'myapp'}) + sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {batchUpdate: batchUpdateStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -82,14 +71,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 1:Free, worker at 2:Free\n') + expect(batchUpdateStub.calledOnceWith('myapp', {updates: [{quantity: '1', size: undefined, type: 'web'}, {quantity: '2', size: undefined, type: 'worker'}]})).to.be.true }) it('scales up a shield dyno if the app is in a shielded private space', async function () { - api - .patch('/apps/myapp/formation', {updates: [{quantity: '1', size: 'Private-L', type: 'web'}]}) - .reply(200, [{quantity: 1, size: 'Private-L', type: 'web'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp', space: {shield: true}}) + const batchUpdateStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}]) + const infoStub = stub().resolves({name: 'myapp', space: {shield: true}}) + sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {batchUpdate: batchUpdateStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -99,14 +87,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 1:Shield-L\n') + expect(batchUpdateStub.calledOnceWith('myapp', {updates: [{quantity: '1', size: 'Private-L', type: 'web'}]})).to.be.true }) it('scales web-1', async function () { - api - .patch('/apps/myapp/formation', {updates: [{quantity: '+1', type: 'web'}]}) - .reply(200, [{quantity: 2, size: 'Free', type: 'web'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const batchUpdateStub = stub().resolves([{quantity: 2, size: 'Free', type: 'web'}]) + const infoStub = stub().resolves({name: 'myapp'}) + sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {batchUpdate: batchUpdateStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -116,5 +103,6 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 2:Free\n') + expect(batchUpdateStub.calledOnceWith('myapp', {updates: [{quantity: '+1', size: undefined, type: 'web'}]})).to.be.true }) }) From ce645da7981df496ef320df76f5bf3a85955219d Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Thu, 28 May 2026 13:10:39 -0700 Subject: [PATCH 6/8] Tighten ps SDK adapter typings by replacing broad unknown casts with targeted @ts-expect-error annotations and explicit function types to document current heroku-types gaps while keeping calls type-safe. --- src/lib/ps/sdk-adapter.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/lib/ps/sdk-adapter.ts b/src/lib/ps/sdk-adapter.ts index 8bdf8faf80..1edcd5d52c 100644 --- a/src/lib/ps/sdk-adapter.ts +++ b/src/lib/ps/sdk-adapter.ts @@ -21,18 +21,19 @@ export type ScaleUpdate = { type: string } +type ScaleBatchUpdateFn = (appIdentity: string, body: {updates: ScaleUpdate[]}) => Promise + export async function scaleDynos(platform: PlatformClient, appIdentity: string, updates: ScaleUpdate[]): Promise { - const fn = platform.formation.batchUpdate as ( - appIdentity: string, - body: {updates: ScaleUpdate[]}, - ) => Promise + // @ts-expect-error — FormationBatchUpdateOpts is missing size:string and quantity:string (tracked for heroku-types fix) + const fn: ScaleBatchUpdateFn = platform.formation.batchUpdate return fn(appIdentity, {updates}) } export async function getAppInfo(platform: PlatformClient, appIdentity: string): Promise { - return platform.app.info(appIdentity) as unknown as AppProcessTier + // @ts-expect-error — App type is missing process_tier (tracked for heroku-types fix) + return platform.app.info(appIdentity) } export async function listDynos(platform: PlatformClient, appIdentity: string): Promise { - return platform.dyno.list(appIdentity) as unknown as DynoExtended[] + return platform.dyno.list(appIdentity) } From ed072f57c39edaaa0d3a44cfbc094adbd6fa802f Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Fri, 29 May 2026 13:06:19 -0700 Subject: [PATCH 7/8] Refactor ps commands to remove the dedicated SDK adapter, inline SDK interactions in ps:index/ps:scale, and update lockfile and unit tests to match the simplified implementation and typing behavior --- package-lock.json | 4 +- src/commands/ps/index.ts | 51 ++++++++++++++---------- src/commands/ps/scale.ts | 42 ++++++------------- src/lib/ps/sdk-adapter.ts | 39 ------------------ test/unit/commands/ps/index.unit.test.ts | 38 +++++++++--------- test/unit/commands/ps/scale.unit.test.ts | 36 ++++++++--------- 6 files changed, 83 insertions(+), 127 deletions(-) delete mode 100644 src/lib/ps/sdk-adapter.ts diff --git a/package-lock.json b/package-lock.json index 50b197cd93..c373de29f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3111,8 +3111,8 @@ } }, "node_modules/@heroku/sdk": { - "version": "0.4.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#19e799844a066c17b32a137840ce4630f18215d2", + "version": "0.4.3", + "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#4d52a7d6a74ae10262092453e5254d9d9d00a499", "license": "Apache-2.0", "dependencies": { "@heroku/heroku-fetch": "github:heroku/heroku-fetch#main", diff --git a/src/commands/ps/index.ts b/src/commands/ps/index.ts index e5a60c197e..c92dd815ab 100644 --- a/src/commands/ps/index.ts +++ b/src/commands/ps/index.ts @@ -1,15 +1,14 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' import {HerokuSDK} from '@heroku/sdk' +import {appExtensions, privateToShield} from '@heroku/sdk/extensions/platform' import type {Account} from '@heroku/types/3.sdk' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' -import {getAppInfo, listDynos} from '../../lib/ps/sdk-adapter.js' import {ago} from '../../lib/time.js' -import {AccountQuota} from '../../lib/types/account-quota.js' -import {AppProcessTier} from '../../lib/types/app-process-tier.js' -import {DynoExtended} from '../../lib/types/dyno-extended.js' +import type {AccountQuota} from '../../lib/types/account-quota.js' +import type {DynoExtended} from '../../lib/types/dyno-extended.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' const heredoc = tsheredoc.default @@ -43,36 +42,48 @@ export default class Index extends Command { const {app, extended, json} = flags const types = restParse.argv as string[] - const {platform} = new HerokuSDK() + const {platform} = new HerokuSDK({extensions: [appExtensions]}) let dynos: DynoExtended[] - let appInfo: AppProcessTier + let shielded: boolean + let processTier: string | undefined + let appId: string | undefined + let ownerId: string | undefined let accountInfo: Account if (extended) { - const [{body: extDynos}, appInfoResult, accountInfoResult] = await Promise.all([ + const [{body: extDynos}, shieldedResult, appInfo, accountInfoResult] = await Promise.all([ this.heroku.request(`/apps/${app}/dynos?extended=true`, { headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, }), - getAppInfo(platform, app), + platform.app.isShielded(app), + platform.app.info(app), platform.account.info(), ]) dynos = extDynos - appInfo = appInfoResult + shielded = shieldedResult + processTier = (appInfo as {process_tier?: string}).process_tier + appId = appInfo.id + ownerId = appInfo.owner?.id accountInfo = accountInfoResult } else { - [dynos, appInfo, accountInfo] = await Promise.all([ - listDynos(platform, app), - getAppInfo(platform, app), + const [dynoList, shieldedResult, appInfo, accountInfoResult] = await Promise.all([ + platform.dyno.list(app) as Promise, + platform.app.isShielded(app), + platform.app.info(app), platform.account.info(), ]) + dynos = dynoList + shielded = shieldedResult + processTier = (appInfo as {process_tier?: string}).process_tier + appId = appInfo.id + ownerId = appInfo.owner?.id + accountInfo = accountInfoResult } - const shielded = appInfo.space && appInfo.space.shield - if (shielded) { for (const d of dynos) { - d.size = d.size.replace('Private-', 'Shield-') + d.size = privateToShield(d.size) } } @@ -94,7 +105,7 @@ export default class Index extends Command { else if (extended) printExtended(selectedDynos, flags['no-wrap']) else { - await printAccountQuota(this.heroku, appInfo, accountInfo) + await printAccountQuota(this.heroku, processTier, appId, ownerId, accountInfo) if (selectedDynos.length === 0) ux.stdout(`No dynos on ${color.app(app)}`) else @@ -156,12 +167,12 @@ function getProcessNumber(s: string) : number { return Number.parseInt(dynoNumber, 10) } -async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account: Account) { - if (app.process_tier !== 'eco') { +async function printAccountQuota(heroku: APIClient, processTier: string | undefined, appId: string | undefined, ownerId: string | undefined, account: Account) { + if (processTier !== 'eco') { return } - if (app.owner.id !== account.id) { + if (ownerId !== account.id) { return } @@ -181,7 +192,7 @@ async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account const remainingMinutes = remaining / 60 const hours = Math.floor(remainingMinutes / 60) const minutes = Math.floor(remainingMinutes % 60) - const appQuota = quota.apps.find(appQuota => appQuota.app_uuid === app.id) + const appQuota = quota.apps.find(appQuota => appQuota.app_uuid === appId) const appQuotaUsed = appQuota ? appQuota.quota_used / 60 : 0 const appPercentage = appQuota ? Math.floor(appQuota.quota_used * 100 / quota.account_quota) : 0 const appHours = Math.floor(appQuotaUsed / 60) diff --git a/src/commands/ps/scale.ts b/src/commands/ps/scale.ts index 7aa778fa45..3ad7bdec0c 100644 --- a/src/commands/ps/scale.ts +++ b/src/commands/ps/scale.ts @@ -1,11 +1,12 @@ import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' import {HerokuSDK} from '@heroku/sdk' +import type {ScaleDynosUpdate} from '@heroku/sdk/resources/platform/dyno' +import {appExtensions, dynoExtensions, privateToShield, shieldToPrivate} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' import {lazyModuleLoader} from '../../lib/lazy-module-loader.js' -import {type ScaleUpdate, scaleDynos} from '../../lib/ps/sdk-adapter.js' const heredoc = tsheredoc.default @@ -43,59 +44,42 @@ export default class Scale extends Command { const argv = restParse.argv as string[] const {app} = flags - function parse(args: string[]): ScaleUpdate[] { + function parse(args: string[]): ScaleDynosUpdate[] { return _.compact(args.map(arg => { const change = arg.match(/^([\w-]+)([=+-]\d+)(?::([\w-]+))?$/) if (!change) return const quantity = change[2][0] === '=' ? change[2].slice(1) : change[2] - if (change[3]) - change[3] = change[3].replace('Shield-', 'Private-') - return {quantity, size: change[3], type: change[1]} + const size = change[3] ? shieldToPrivate(change[3]) : undefined + return {quantity, size, type: change[1]} })) } - const {platform} = new HerokuSDK() + const {platform} = new HerokuSDK({extensions: [appExtensions, dynoExtensions]}) const changes = parse(argv) if (changes.length === 0) { - const [formation, appProps] = await Promise.all([ + const [formation, shielded] = await Promise.all([ platform.formation.list(app), - platform.app.info(app), + platform.app.isShielded(app), ]) - const shielded = appProps.space && appProps.space.shield - if (shielded) { - for (const d of formation) { - if (d.size !== undefined) { - d.size = d.size.replace('Private-', 'Shield-') - } - } - } if (formation.length === 0) { throw emptyFormationErr(app) } - ux.stdout(formation.map(d => `${d.type}=${d.quantity}:${d.size}`) + ux.stdout(formation.map(d => `${d.type}=${d.quantity}:${shielded ? privateToShield(d.size || '') : d.size}`) .sort() .join(' ')) } else { ux.action.start('Scaling dynos') - const [appProps, formation] = await Promise.all([ - platform.app.info(app), - scaleDynos(platform, app, changes), + const [shielded, formation] = await Promise.all([ + platform.app.isShielded(app), + platform.dyno.scale(app, changes), ]) - const shielded = appProps.space && appProps.space.shield - if (shielded) { - for (const d of formation) { - if (d.size !== undefined) { - d.size = d.size.replace('Private-', 'Shield-') - } - } - } const output = formation.filter(f => changes.find(c => c.type === f.type)) - .map(d => `${color.green(d.type || '')} at ${d.quantity}:${d.size}`) + .map(d => `${color.green(d.type || '')} at ${d.quantity}:${shielded ? privateToShield(d.size || '') : d.size}`) ux.action.stop(`done, now running ${output.join(', ')}`) } } diff --git a/src/lib/ps/sdk-adapter.ts b/src/lib/ps/sdk-adapter.ts deleted file mode 100644 index 1edcd5d52c..0000000000 --- a/src/lib/ps/sdk-adapter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type {Formation} from '@heroku/types/3.sdk' - -import type {HerokuSDK} from '@heroku/sdk' - -import type {AppProcessTier} from '../types/app-process-tier.js' -import type {DynoExtended} from '../types/dyno-extended.js' - -// Temporary adapter: the SDK's types are incomplete in @heroku/types. -// These wrappers cast once so command files stay type-safe. -// Remove when heroku-types adds: -// - FormationBatchUpdateOpts.updates[].size as string -// - FormationBatchUpdateOpts.updates[].quantity as number | string -// - App.process_tier -// - Dyno.extended - -type PlatformClient = HerokuSDK['platform'] - -export type ScaleUpdate = { - quantity: string - size?: string - type: string -} - -type ScaleBatchUpdateFn = (appIdentity: string, body: {updates: ScaleUpdate[]}) => Promise - -export async function scaleDynos(platform: PlatformClient, appIdentity: string, updates: ScaleUpdate[]): Promise { - // @ts-expect-error — FormationBatchUpdateOpts is missing size:string and quantity:string (tracked for heroku-types fix) - const fn: ScaleBatchUpdateFn = platform.formation.batchUpdate - return fn(appIdentity, {updates}) -} - -export async function getAppInfo(platform: PlatformClient, appIdentity: string): Promise { - // @ts-expect-error — App type is missing process_tier (tracked for heroku-types fix) - return platform.app.info(appIdentity) -} - -export async function listDynos(platform: PlatformClient, appIdentity: string): Promise { - return platform.dyno.list(appIdentity) -} diff --git a/test/unit/commands/ps/index.unit.test.ts b/test/unit/commands/ps/index.unit.test.ts index f3178129a2..11a6cfa972 100644 --- a/test/unit/commands/ps/index.unit.test.ts +++ b/test/unit/commands/ps/index.unit.test.ts @@ -42,7 +42,7 @@ describe('ps', function () { ]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -76,7 +76,7 @@ describe('ps', function () { ]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -108,7 +108,7 @@ describe('ps', function () { ]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic', space: {shield: true}}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(true)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -139,7 +139,7 @@ describe('ps', function () { ]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {error, stdout} = await runCommand(Cmd, [ 'foo', @@ -158,7 +158,7 @@ describe('ps', function () { ]) const infoStub = stub().resolves({name: 'myapp'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -173,7 +173,7 @@ describe('ps', function () { it('shows extended info', async function () { const infoStub = stub().resolves({name: 'myapp'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}}) nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') @@ -222,7 +222,7 @@ describe('ps', function () { it('passes no-wrap option through to extended table rendering', async function () { const infoStub = stub().resolves({name: 'myapp'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}}) nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') @@ -250,7 +250,7 @@ describe('ps', function () { it('shows extended info for Private Space app', async function () { const infoStub = stub().resolves({name: 'myapp'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}}) nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') @@ -298,7 +298,7 @@ describe('ps', function () { it('shows shield dynos in extended info if app is in a shielded private space', async function () { const infoStub = stub().resolves({space: {shield: true}}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(true)}}) nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') @@ -349,7 +349,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 200, {account_quota: 1000, apps: [], quota_used: 1}) const ecoExpression = heredoc` @@ -379,7 +379,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 200, {account_quota: 3_600_000, apps: [], quota_used: 178_200}) const ecoExpression = heredoc` @@ -409,7 +409,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 200, {account_quota: 3_600_000, apps: [{app_uuid: '6789', quota_used: 178_200}], quota_used: 178_200}) const ecoExpression = heredoc` @@ -439,7 +439,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 200, {account_quota: 0, apps: [], quota_used: 0}) const ecoExpression = heredoc` @@ -469,7 +469,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 404, {id: 'not_found'}) const ecoExpression = heredoc` @@ -494,7 +494,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 200, {id: 'not_found'}) const ecoExpression = heredoc` @@ -519,7 +519,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({owner: {id: '5678'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const ecoExpression = heredoc` === run: one-off processes (1) @@ -543,7 +543,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({owner: {id: '1234'}, process_tier: 'basic'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const ecoExpression = heredoc` === run: one-off processes (1) @@ -567,7 +567,7 @@ describe('ps', function () { }]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) stubQuotaApi('1234', 503, {id: 'server_error'}) const ecoExpression = heredoc` @@ -590,7 +590,7 @@ describe('ps', function () { const listStub = stub().resolves([]) const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) const accountStub = stub().resolves({id: '1234'}) - sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub}, dyno: {list: listStub}}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', diff --git a/test/unit/commands/ps/scale.unit.test.ts b/test/unit/commands/ps/scale.unit.test.ts index b807d6c0e5..05c1f43dd8 100644 --- a/test/unit/commands/ps/scale.unit.test.ts +++ b/test/unit/commands/ps/scale.unit.test.ts @@ -15,8 +15,8 @@ describe('ps:scale', function () { it('shows formation with no args', async function () { const listStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) - const infoStub = stub().resolves({name: 'myapp'}) - sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {list: listStub}}) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, formation: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -30,8 +30,8 @@ describe('ps:scale', function () { it('shows formation with shield dynos for apps in a shielded private space', async function () { const listStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}, {quantity: 2, size: 'Private-M', type: 'worker'}]) - const infoStub = stub().resolves({name: 'myapp', space: {shield: true}}) - sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {list: listStub}}) + const isShieldedStub = stub().resolves(true) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, formation: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -44,8 +44,8 @@ describe('ps:scale', function () { it('errors with no process types', async function () { const listStub = stub().resolves([]) - const infoStub = stub().resolves({name: 'myapp'}) - sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {list: listStub}}) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, formation: {list: listStub}}) const {error, stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -58,9 +58,9 @@ describe('ps:scale', function () { }) it('scales web=1 worker=2', async function () { - const batchUpdateStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) - const infoStub = stub().resolves({name: 'myapp'}) - sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {batchUpdate: batchUpdateStub}}) + const scaleStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, dyno: {scale: scaleStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -71,13 +71,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 1:Free, worker at 2:Free\n') - expect(batchUpdateStub.calledOnceWith('myapp', {updates: [{quantity: '1', size: undefined, type: 'web'}, {quantity: '2', size: undefined, type: 'worker'}]})).to.be.true + expect(scaleStub.calledOnceWith('myapp', [{quantity: '1', size: undefined, type: 'web'}, {quantity: '2', size: undefined, type: 'worker'}])).to.be.true }) it('scales up a shield dyno if the app is in a shielded private space', async function () { - const batchUpdateStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}]) - const infoStub = stub().resolves({name: 'myapp', space: {shield: true}}) - sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {batchUpdate: batchUpdateStub}}) + const scaleStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}]) + const isShieldedStub = stub().resolves(true) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, dyno: {scale: scaleStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -87,13 +87,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 1:Shield-L\n') - expect(batchUpdateStub.calledOnceWith('myapp', {updates: [{quantity: '1', size: 'Private-L', type: 'web'}]})).to.be.true + expect(scaleStub.calledOnceWith('myapp', [{quantity: '1', size: 'Private-L', type: 'web'}])).to.be.true }) it('scales web-1', async function () { - const batchUpdateStub = stub().resolves([{quantity: 2, size: 'Free', type: 'web'}]) - const infoStub = stub().resolves({name: 'myapp'}) - sdkMock = mockSDKPlatform({app: {info: infoStub}, formation: {batchUpdate: batchUpdateStub}}) + const scaleStub = stub().resolves([{quantity: 2, size: 'Free', type: 'web'}]) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, dyno: {scale: scaleStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -103,6 +103,6 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 2:Free\n') - expect(batchUpdateStub.calledOnceWith('myapp', {updates: [{quantity: '+1', size: undefined, type: 'web'}]})).to.be.true + expect(scaleStub.calledOnceWith('myapp', [{quantity: '+1', size: undefined, type: 'web'}])).to.be.true }) }) From a3d833520f1e127ddd1e58a6488776f1917b5d9e Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Fri, 29 May 2026 13:25:23 -0700 Subject: [PATCH 8/8] Refactor ps:index to unify extended/non-extended dyno fetching and simplify quota parameter handling, while tightening ps:scale type-safe shield size formatting to avoid unsafe fallback conversions --- src/commands/ps/index.ts | 78 ++++++++++++++++++---------------------- src/commands/ps/scale.ts | 11 +++--- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/src/commands/ps/index.ts b/src/commands/ps/index.ts index c92dd815ab..698203e0a7 100644 --- a/src/commands/ps/index.ts +++ b/src/commands/ps/index.ts @@ -2,13 +2,13 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' import {HerokuSDK} from '@heroku/sdk' import {appExtensions, privateToShield} from '@heroku/sdk/extensions/platform' -import type {Account} from '@heroku/types/3.sdk' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' -import {ago} from '../../lib/time.js' import type {AccountQuota} from '../../lib/types/account-quota.js' import type {DynoExtended} from '../../lib/types/dyno-extended.js' + +import {ago} from '../../lib/time.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' const heredoc = tsheredoc.default @@ -44,42 +44,20 @@ export default class Index extends Command { const {platform} = new HerokuSDK({extensions: [appExtensions]}) - let dynos: DynoExtended[] - let shielded: boolean - let processTier: string | undefined - let appId: string | undefined - let ownerId: string | undefined - let accountInfo: Account - - if (extended) { - const [{body: extDynos}, shieldedResult, appInfo, accountInfoResult] = await Promise.all([ - this.heroku.request(`/apps/${app}/dynos?extended=true`, { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - platform.app.isShielded(app), - platform.app.info(app), - platform.account.info(), - ]) - dynos = extDynos - shielded = shieldedResult - processTier = (appInfo as {process_tier?: string}).process_tier - appId = appInfo.id - ownerId = appInfo.owner?.id - accountInfo = accountInfoResult - } else { - const [dynoList, shieldedResult, appInfo, accountInfoResult] = await Promise.all([ - platform.dyno.list(app) as Promise, - platform.app.isShielded(app), - platform.app.info(app), - platform.account.info(), - ]) - dynos = dynoList - shielded = shieldedResult - processTier = (appInfo as {process_tier?: string}).process_tier - appId = appInfo.id - ownerId = appInfo.owner?.id - accountInfo = accountInfoResult - } + const dynosPromise = extended + ? this.heroku.request(`/apps/${app}/dynos?extended=true`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, + }).then(r => r.body) + : platform.dyno.list(app) as Promise + + const [dynos, shielded, appInfo, accountInfo] = await Promise.all([ + dynosPromise, + platform.app.isShielded(app), + platform.app.info(app), + platform.account.info(), + ]) + + const processTier = (appInfo as {process_tier?: string}).process_tier if (shielded) { for (const d of dynos) { @@ -105,7 +83,12 @@ export default class Index extends Command { else if (extended) printExtended(selectedDynos, flags['no-wrap']) else { - await printAccountQuota(this.heroku, processTier, appId, ownerId, accountInfo) + await printAccountQuota(this.heroku, { + account: accountInfo, + appId: appInfo.id, + ownerId: appInfo.owner?.id, + processTier, + }) if (selectedDynos.length === 0) ux.stdout(`No dynos on ${color.app(app)}`) else @@ -167,17 +150,24 @@ function getProcessNumber(s: string) : number { return Number.parseInt(dynoNumber, 10) } -async function printAccountQuota(heroku: APIClient, processTier: string | undefined, appId: string | undefined, ownerId: string | undefined, account: Account) { - if (processTier !== 'eco') { +type QuotaOpts = { + account: {id?: string} + appId?: string + ownerId?: string + processTier?: string +} + +async function printAccountQuota(heroku: APIClient, opts: QuotaOpts) { + if (opts.processTier !== 'eco') { return } - if (ownerId !== account.id) { + if (opts.ownerId !== opts.account.id) { return } const {body: quota} = await heroku.request( - `/accounts/${account.id}/actions/get-quota`, + `/accounts/${opts.account.id}/actions/get-quota`, {headers: {Accept: 'application/vnd.heroku+json; version=3.account-quotas'}}, ).catch(() => ( {body: null} @@ -192,7 +182,7 @@ async function printAccountQuota(heroku: APIClient, processTier: string | undefi const remainingMinutes = remaining / 60 const hours = Math.floor(remainingMinutes / 60) const minutes = Math.floor(remainingMinutes % 60) - const appQuota = quota.apps.find(appQuota => appQuota.app_uuid === appId) + const appQuota = quota.apps.find(appQuota => appQuota.app_uuid === opts.appId) const appQuotaUsed = appQuota ? appQuota.quota_used / 60 : 0 const appPercentage = appQuota ? Math.floor(appQuota.quota_used * 100 / quota.account_quota) : 0 const appHours = Math.floor(appQuotaUsed / 60) diff --git a/src/commands/ps/scale.ts b/src/commands/ps/scale.ts index 3ad7bdec0c..5be30c9a62 100644 --- a/src/commands/ps/scale.ts +++ b/src/commands/ps/scale.ts @@ -1,8 +1,11 @@ +import type {ScaleDynosUpdate} from '@heroku/sdk/resources/platform/dyno' + import {Command, flags} from '@heroku-cli/command' import * as color from '@heroku/heroku-cli-util/color' import {HerokuSDK} from '@heroku/sdk' -import type {ScaleDynosUpdate} from '@heroku/sdk/resources/platform/dyno' -import {appExtensions, dynoExtensions, privateToShield, shieldToPrivate} from '@heroku/sdk/extensions/platform' +import { + appExtensions, dynoExtensions, privateToShield, shieldToPrivate, +} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' @@ -68,7 +71,7 @@ export default class Scale extends Command { throw emptyFormationErr(app) } - ux.stdout(formation.map(d => `${d.type}=${d.quantity}:${shielded ? privateToShield(d.size || '') : d.size}`) + ux.stdout(formation.map(d => `${d.type}=${d.quantity}:${shielded && d.size !== undefined ? privateToShield(d.size) : d.size}`) .sort() .join(' ')) } else { @@ -79,7 +82,7 @@ export default class Scale extends Command { ]) const output = formation.filter(f => changes.find(c => c.type === f.type)) - .map(d => `${color.green(d.type || '')} at ${d.quantity}:${shielded ? privateToShield(d.size || '') : d.size}`) + .map(d => `${color.green(d.type || '')} at ${d.quantity}:${shielded && d.size !== undefined ? privateToShield(d.size) : d.size}`) ux.action.stop(`done, now running ${output.join(', ')}`) } }