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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
See [AGENTS.md](../AGENTS.md).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
See [AGENTS.md](./AGENTS.md).
72 changes: 30 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
"@heroku/heroku-cli-util": "^10.8.0",
"@heroku/http-call": "^5.5.1",
"@heroku/mcp-server": "^1.2.0",
"@heroku/sdk": "github:heroku/heroku-sdk#main",
"@heroku/sdk": "file:../heroku-sdk",
"@heroku/socksv5": "^0.0.9",
"@heroku/types": "github:heroku/heroku-types#main",
"@inquirer/prompts": "^7.0",
"@oclif/core": "^4.8.4",
"@oclif/plugin-commands": "^4.1.40",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/addons/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default class Services extends Command {
hux.styledJSON(services)
} else {
/* eslint-disable perfectionist/sort-objects */
hux.table(services as Array<Record<string, unknown>>, {
hux.table(services as unknown as Array<Record<string, unknown>>, {
name: {
header: 'Slug',
},
Expand Down
1 change: 1 addition & 0 deletions src/commands/data/maintenances/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/data/maintenances/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
1 change: 1 addition & 0 deletions src/commands/data/maintenances/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
4 changes: 4 additions & 0 deletions src/commands/data/pg/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}>({
Expand Down Expand Up @@ -281,13 +283,15 @@ 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'
} else if (targetDatabaseId === '__create_database') {
const addon = await this.createTargetDatabase(sourceDatabaseId!)
if (addon) {
targetDatabaseId = addon.id
targetDatabaseName = addon.name
currentStep = '__confirm_migration'
} else {
currentStep = '__select_target'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/pipelines/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export default class Promote extends Command {
{
pipeline: {id: coupling.pipeline!.id!},
source: {app: {id: coupling.app!.id!}},
targets: targetApps.map(app => ({app: {id: app.id}})),
targets: targetApps.map(app => ({app: {id: app.id!}})),
},
{onReleaseStream},
)
Expand Down
64 changes: 40 additions & 24 deletions src/commands/ps/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +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 {ux} from '@oclif/core/ux'
import tsheredoc from 'tsheredoc'

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 {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
Expand Down Expand Up @@ -40,24 +41,27 @@ 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>('/account', {
headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'},
}),
appInfo: this.heroku.request<AppProcessTier>(`/apps/${app}`, {
headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'},
}),
dynos: this.heroku.request<DynoExtended[]>(`/apps/${app}/dynos${suffix}`, {

const {platform} = new HerokuSDK({extensions: [appExtensions]})

const dynosPromise = extended
? this.heroku.request<DynoExtended[]>(`/apps/${app}/dynos?extended=true`, {
headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'},
}),
}
const [{body: dynos}, {body: appInfo}, {body: accountInfo}] = await Promise.all([promises.dynos, promises.appInfo, promises.accountInfo])
const shielded = appInfo.space && appInfo.space.shield
}).then(r => r.body)
: platform.dyno.list(app) as Promise<DynoExtended[]>

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) {
d.size = d.size.replace('Private-', 'Shield-')
d.size = privateToShield(d.size)
}
}

Expand All @@ -79,7 +83,12 @@ 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, {
account: accountInfo,
appId: appInfo.id,
ownerId: appInfo.owner?.id,
processTier,
})
if (selectedDynos.length === 0)
ux.stdout(`No dynos on ${color.app(app)}`)
else
Expand Down Expand Up @@ -141,17 +150,24 @@ function getProcessNumber(s: string) : number {
return Number.parseInt(dynoNumber, 10)
}

async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account: Account) {
if (app.process_tier !== '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 (app.owner.id !== account.id) {
if (opts.ownerId !== opts.account.id) {
return
}

const {body: quota} = await heroku.request<AccountQuota>(
`/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}
Expand All @@ -166,7 +182,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 === 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)
Expand Down
27 changes: 10 additions & 17 deletions src/commands/ps/restart.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<Heroku.Dyno>(restartUrl, {
headers: {
Accept: 'application/vnd.heroku+json; version=3.sdk',
},
})
ux.action.stop()
}
}
Loading
Loading