From 0bca71fe926ef7776101a83258dfade6e3b888b7 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Mon, 9 Feb 2026 13:23:21 -0300 Subject: [PATCH 1/3] Copying and refactoring data:pg:attachments topic commands and tests --- cspell-dictionary.txt | 2 + src/commands/data/pg/attachments/create.ts | 125 +++++++ src/commands/data/pg/attachments/destroy.ts | 65 ++++ src/commands/data/pg/attachments/index.ts | 78 ++++ .../data/pg/attachments/create.unit.test.ts | 343 ++++++++++++++++++ .../data/pg/attachments/destroy.unit.test.ts | 178 +++++++++ .../data/pg/attachments/index.unit.test.ts | 129 +++++++ 7 files changed, 920 insertions(+) create mode 100644 src/commands/data/pg/attachments/create.ts create mode 100644 src/commands/data/pg/attachments/destroy.ts create mode 100644 src/commands/data/pg/attachments/index.ts create mode 100644 test/unit/commands/data/pg/attachments/create.unit.test.ts create mode 100644 test/unit/commands/data/pg/attachments/destroy.unit.test.ts create mode 100644 test/unit/commands/data/pg/attachments/index.unit.test.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index d47ad59ece..88677aedd9 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -201,6 +201,7 @@ mworks myapp mybillingapp mycert +mycredential mydb myfeature myhost @@ -209,6 +210,7 @@ mylowercaseapp myotherapp mypath myplugin +mypool myschema myscript myteam diff --git a/src/commands/data/pg/attachments/create.ts b/src/commands/data/pg/attachments/create.ts new file mode 100644 index 0000000000..20673f6753 --- /dev/null +++ b/src/commands/data/pg/attachments/create.ts @@ -0,0 +1,125 @@ +import {color, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import type {DeepRequired} from '../../../../lib/data/types.js' + +import {trapConfirmationRequired} from '../../../../lib/addons/util.js' +import BaseCommand from '../../../../lib/data/baseCommand.js' + +const heredoc = tsheredoc.default + +export default class DataPgAttachmentsCreate extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'attach an existing Postgres Advanced database to an app' + + static flags = { + app: Flags.app({required: true}), + as: Flags.string({description: 'name for Postgres database attachment'}), + confirm: Flags.string({hidden: true}), + credential: Flags.string({ + description: 'credential to use for database', + exclusive: ['pool'], + }), + pool: Flags.string({description: 'instance pool to attach'}), + remote: Flags.remote(), + } + + public async run(): Promise { + const {args, flags} = await this.parse(DataPgAttachmentsCreate) + const {database: databaseArg} = args + const {app, as, confirm, credential, pool} = flags + + const {body: addon} = await this.heroku.get>(`/addons/${databaseArg}`) + + if (!utils.pg.isAdvancedDatabase(addon)) { + const cmd = `heroku addons:attach ${addon.name} -a ${app}${as ? ` --as ${as}` : ''}` + + `${credential ? ` --credential ${credential}` : ''}` + ux.error( + 'You can only use this command on Advanced-tier databases.\n' + + `Use ${color.code(cmd)} instead.`, + ) + } + + const createAttachment = async (confirmed?: string): Promise> => { + let namespace: string | undefined + let attachMessage: string | undefined + if (credential) { + namespace = 'role:' + credential + attachMessage = `Attaching ${color.yellow(credential) + ' on '}${color.addon(addon.name)}` + + `${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}` + } else if (pool) { + namespace = 'pool:' + pool + attachMessage = `Attaching ${color.yellow(pool) + ' on '}${color.addon(addon.name)}` + + `${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}` + } else { + attachMessage = `Attaching ${color.addon(addon.name)}` + + `${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}` + } + + const body = { + addon: {name: addon.name}, + app: {name: app}, + confirm: confirmed, + name: as, + namespace, + } + + try { + ux.action.start(attachMessage) + const {body: attachment} = await this.heroku.post>('/addon-attachments', {body}) + ux.action.stop() + + return attachment + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } + + if (credential) { + const {body: credentialConfig} = await this.heroku.get[]>( + `/addons/${addon.name}/config/role:${encodeURIComponent(credential)}`, + ) + if (credentialConfig.length === 0) { + ux.error(heredoc` + The credential ${color.yellow(credential)} doesn't exist on the database ${color.addon(addon.name)}. + Use ${color.code(`heroku data:pg:credentials ${addon.name} -a ${app}`)} to list the credentials on the database.`, + {exit: 1}, + ) + } + } else if (pool) { + const {body: poolConfig} = await this.heroku.get[]>( + `/addons/${addon.name}/config/pool:${encodeURIComponent(pool)}`, + ) + if (poolConfig.length === 0) { + ux.error(heredoc` + The pool ${color.yellow(pool)} doesn't exist on the database ${color.addon(addon.name)}. + Use ${color.code(`heroku data:pg:info ${addon.name} -a ${app}`)} to list the pools on the database.`, + {exit: 1}, + ) + } + } + + const attachment = await trapConfirmationRequired>(app, confirm, (confirmed?: string) => createAttachment(confirmed)) + + try { + ux.action.start(`Setting ${color.attachment(attachment.name)} config vars and restarting ${color.app(app)}`) + const {body: releases} = await this.heroku.get[]>(`/apps/${app}/releases`, { + headers: {Range: 'version ..; max=1, order=desc'}, partial: true, + }) + ux.action.stop(`done, v${releases[0].version}`) + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } +} diff --git a/src/commands/data/pg/attachments/destroy.ts b/src/commands/data/pg/attachments/destroy.ts new file mode 100644 index 0000000000..88a5e03283 --- /dev/null +++ b/src/commands/data/pg/attachments/destroy.ts @@ -0,0 +1,65 @@ +import {color, hux, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' +import {Args, ux} from '@oclif/core' + +import BaseCommand from '../../../../lib/data/baseCommand.js' + +export default class DataPgAttachmentsDestroy extends BaseCommand { + static args = { + attachment_name: Args.string({ + description: 'unique identifier of the database attachment', + required: true, + }), + } + + static description = 'detach an existing database attachment from an app' + + static flags = { + app: Flags.app({required: true}), + confirm: Flags.string({hidden: true}), + remote: Flags.remote(), + } + + public async run(): Promise { + const {args, flags} = await this.parse(DataPgAttachmentsDestroy) + const {attachment_name: attachmentName} = args + const {app, confirm} = flags + const {body: attachment} = await this.heroku.get>( + `/apps/${app}/addon-attachments/${attachmentName}`, + ) + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(attachment.addon.name, app, utils.pg.addonService()) + + if (!utils.pg.isAdvancedDatabase(addon)) { + ux.error( + 'You can only use this command on Advanced-tier databases.\n' + + `Use ${color.code(`heroku addons:detach ${attachmentName} -a ${app}`)} instead.`, + ) + } + + await hux.confirmCommand({comparison: app, confirmation: confirm}) + + try { + ux.action.start( + `Detaching ${color.attachment(attachmentName)} on ${color.datastore(addon.name)} from ${color.app(app)}`, + ) + await this.heroku.delete(`/addon-attachments/${attachment.id}`) + ux.action.stop() + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + + try { + ux.action.start(`Unsetting ${color.attachment(attachmentName)} config vars and restarting ${color.app(app)}`) + const {body: releases} = await this.heroku.get[]>(`/apps/${app}/releases`, { + headers: {Range: 'version ..; max=1, order=desc'}, partial: true, + }) + ux.action.stop(`done, v${releases[0].version}`) + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + } +} diff --git a/src/commands/data/pg/attachments/index.ts b/src/commands/data/pg/attachments/index.ts new file mode 100644 index 0000000000..48724320c9 --- /dev/null +++ b/src/commands/data/pg/attachments/index.ts @@ -0,0 +1,78 @@ +import {color, hux, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' +import {Args, ux} from '@oclif/core' + +import type {CredentialInfo, CredentialsInfo} from '../../../../lib/data/types.js' + +import BaseCommand from '../../../../lib/data/baseCommand.js' + +export default class DataPgAttachmentsIndex extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'list attachments on a Postgres Advanced database' + + static examples = [ + '<%= config.bin %> <%= command.id %> database_name -a example-app', + ] + + static flags = { + app: Flags.app({required: true}), + remote: Flags.remote(), + } + + async run() { + const {args, flags} = await this.parse(DataPgAttachmentsIndex) + const {app} = flags + const {database} = args + + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(database, app, utils.pg.addonService()) + + if (!utils.pg.isAdvancedDatabase(addon)) { + ux.error( + 'You can only use this command on Advanced-tier databases.\n' + + `Use ${color.code(`heroku addons:info ${addon.name} -a ${app}`)} instead.`, + ) + } + + const [{body: {items: credentials}}, {body: attachments}] = await Promise.all([ + this.dataApi.get(`/data/postgres/v1/${addon.id}/credentials`), + this.heroku.get[]>(`/addons/${addon.id}/addon-attachments`), + ]) + const ownerCred = credentials.find((cred: CredentialInfo) => cred.type === 'owner') + + if (attachments.length === 0) { + ux.stdout('No attachments found for this database.') + return + } + + hux.styledHeader(`Attachments for ${color.datastore(addon.name)}`) + hux.table(attachments, { + Attachment: { + get: attachment => `${color.attachment(attachment.app.name! + '::' + attachment.name)}`, + }, + Credential: { + get(attachment) { + if (attachment.namespace?.startsWith('role:')) { + return color.name(attachment.namespace.split(':')[1]) + } + + return `${ownerCred?.name ? `${color.name(ownerCred.name)} (owner)` : ''}` + }, + }, + Pool: { + get(attachment) { + return color.name(attachment.namespace?.startsWith('pool:') + ? attachment.namespace.split(':')[1] + : 'leader') + }, + }, + }) + } +} diff --git a/test/unit/commands/data/pg/attachments/create.unit.test.ts b/test/unit/commands/data/pg/attachments/create.unit.test.ts new file mode 100644 index 0000000000..6d1cf3bb4d --- /dev/null +++ b/test/unit/commands/data/pg/attachments/create.unit.test.ts @@ -0,0 +1,343 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgAttachmentsCreate from '../../../../../../src/commands/data/pg/attachments/create.js' +import { + addon, + createAttachmentResponse, + createCredentialAttachmentResponse, + createForeignAttachmentResponse, + createPoolAttachmentResponse, + credentialConfigResponse, + nonAdvancedAddon, + poolConfigResponse, + releasesResponse, +} from '../../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:attachments:create', function () { + it('shows error for non-advanced databases', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, nonAdvancedAddon) + + try { + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--as=TEST', + ]) + } catch (error: unknown) { + const err = error as Error + + herokuApi.done() + expect(ansis.strip(err.message)).to.equal( + 'You can only use this command on Advanced-tier databases.\n' + + 'Use heroku addons:attach standard-database -a myapp --as TEST instead.', + ) + } + }) + + describe('basic attachment creation', function () { + it('creates a basic attachment to the same app', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: addon.app.name}, + name: 'TEST', + }) + .reply(200, createAttachmentResponse) + .get('/apps/myapp/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--as=TEST', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching advanced-horizontal-01234 as TEST to ⬢ myapp... done + Setting TEST config vars and restarting ⬢ myapp... done, v123 + `) + }) + + it('creates a basic (foreign) attachment to a different app using the database name', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: 'myapp2'}, + name: 'TEST2', + }) + .reply(200, createForeignAttachmentResponse) + .get('/apps/myapp2/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp2', + '--as=TEST2', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching advanced-horizontal-01234 as TEST2 to ⬢ myapp2... done + Setting TEST2 config vars and restarting ⬢ myapp2... done, v123 + `) + }) + + it('creates a basic (foreign) attachment to a different app using an app namespaced attachment name', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/myapp::DATABASE') + .reply(200, addon) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: 'myapp2'}, + name: 'TEST2', + }) + .reply(200, createForeignAttachmentResponse) + .get('/apps/myapp2/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsCreate, [ + 'myapp::DATABASE', + '--app=myapp2', + '--as=TEST2', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching advanced-horizontal-01234 as TEST2 to ⬢ myapp2... done + Setting TEST2 config vars and restarting ⬢ myapp2... done, v123 + `) + }) + + it('creates a basic attachment without a custom attachment name', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: addon.app.name}, + }) + .reply(200, { + ...createAttachmentResponse, + name: 'HEROKU_POSTGRESQL_COBALT', + }) + .get('/apps/myapp/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching advanced-horizontal-01234 to ⬢ myapp... done + Setting HEROKU_POSTGRESQL_COBALT config vars and restarting ⬢ myapp... done, v123 + `) + }) + + it('handles API errors gracefully on the attachment creation', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: addon.app.name}, + name: 'TEST', + }) + .reply(500, { + id: 'internal_server_error', + message: 'Internal server error.', + }) + + try { + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--as=TEST', + ]) + } catch (error: unknown) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + Internal server error. + + Error ID: internal_server_error`, + ) + } + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching advanced-horizontal-01234 as TEST to ⬢ myapp... ! + `) + }) + + it('handles API errors gracefully on the release retrieval', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: addon.app.name}, + name: 'TEST', + }) + .reply(200, createAttachmentResponse) + .get('/apps/myapp/releases') + .reply(500, { + id: 'internal_server_error', + message: 'Internal server error.', + }) + + try { + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--as=TEST', + ]) + } catch (error: unknown) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + Internal server error. + + Error ID: internal_server_error`, + ) + } + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching advanced-horizontal-01234 as TEST to ⬢ myapp... done + Setting TEST config vars and restarting ⬢ myapp... ! + `) + }) + }) + + describe('credential-based attachment', function () { + it('creates attachment with credential', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .get(`/addons/${addon.name}/config/role:mycredential`) + .reply(200, credentialConfigResponse) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: addon.app.name}, + name: 'MYCREDENTIAL', + namespace: 'role:mycredential', + }) + .reply(200, createCredentialAttachmentResponse) + .get('/apps/myapp/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--as=MYCREDENTIAL', + '--credential=mycredential', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Attaching mycredential on advanced-horizontal-01234 as MYCREDENTIAL to ⬢ myapp... done + Setting MYCREDENTIAL config vars and restarting ⬢ myapp... done, v123 + `) + }) + + it('throws error when credential does not exist', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .get(`/addons/${addon.name}/config/role:nonexistent`) + .reply(200, []) + + try { + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--credential=nonexistent', + ]) + } catch (error: unknown) { + const err = error as Error + + expect(ansis.strip(err.message)).to.equal( + 'The credential nonexistent doesn\'t exist on the database advanced-horizontal-01234.\n' + + 'Use heroku data:pg:credentials advanced-horizontal-01234 -a myapp ' + + 'to list the credentials on the database.', + ) + } + + herokuApi.done() + }) + }) + + describe('pool-based attachment', function () { + it('creates attachment with pool', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .get(`/addons/${addon.name}/config/pool:mypool`) + .reply(200, poolConfigResponse) + .post('/addon-attachments', { + addon: {name: addon.name}, + app: {name: addon.app.name}, + name: 'MYPOOL', + namespace: 'pool:mypool', + }) + .reply(200, createPoolAttachmentResponse) + .get('/apps/myapp/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--as=MYPOOL', + '--pool=mypool', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal( + heredoc(` + Attaching mypool on advanced-horizontal-01234 as MYPOOL to ⬢ myapp... done + Setting MYPOOL config vars and restarting ⬢ myapp... done, v123 + `), + ) + }) + + it('throws error when pool does not exist', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/addons/advanced-horizontal-01234') + .reply(200, addon) + .get(`/addons/${addon.name}/config/pool:nonexistent`) + .reply(200, []) + + try { + await runCommand(DataPgAttachmentsCreate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--pool=nonexistent', + ]) + } catch (error: unknown) { + const err = error as Error + + expect(ansis.strip(err.message)).to.equal( + 'The pool nonexistent doesn\'t exist on the database advanced-horizontal-01234.\n' + + 'Use heroku data:pg:info advanced-horizontal-01234 -a myapp ' + + 'to list the pools on the database.', + ) + } + + herokuApi.done() + }) + }) +}) diff --git a/test/unit/commands/data/pg/attachments/destroy.unit.test.ts b/test/unit/commands/data/pg/attachments/destroy.unit.test.ts new file mode 100644 index 0000000000..e7bd19877c --- /dev/null +++ b/test/unit/commands/data/pg/attachments/destroy.unit.test.ts @@ -0,0 +1,178 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgAttachmentsDestroy from '../../../../../../src/commands/data/pg/attachments/destroy.js' +import { + addon, + multipleAttachmentsResponse, + nonAdvancedAddon, + releasesResponse, +} from '../../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:attachments:destroy', function () { + it('shows error for non-advanced databases', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments/DATABASE_ANALYST') + .reply(200, { + ...multipleAttachmentsResponse[1], + addon: { + app: { + id: nonAdvancedAddon.app.id, + name: nonAdvancedAddon.app.name, + }, + id: nonAdvancedAddon.id, + name: nonAdvancedAddon.name, + }, + }) + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(DataPgAttachmentsDestroy, [ + 'DATABASE_ANALYST', + '--app=myapp', + '--confirm=myapp', + ]) + } catch (error: unknown) { + const err = error as Error + + herokuApi.done() + expect(ansis.strip(err.message)).to.equal( + 'You can only use this command on Advanced-tier databases.\n' + + 'Use heroku addons:detach DATABASE_ANALYST -a myapp instead.', + ) + } + }) + + describe('basic attachment destruction', function () { + it('destroys an attachment successfully', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments/DATABASE_ANALYST') + .reply(200, multipleAttachmentsResponse[1]) + .post('/actions/addons/resolve') + .reply(200, [addon]) + .delete('/addon-attachments/9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4') + .reply(200, multipleAttachmentsResponse[1]) + .get('/apps/myapp/releases') + .reply(200, releasesResponse) + + await runCommand(DataPgAttachmentsDestroy, [ + 'DATABASE_ANALYST', + '--app=myapp', + '--confirm=myapp', + ]) + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Detaching DATABASE_ANALYST on ⛁ advanced-horizontal-01234 from ⬢ myapp... done + Unsetting DATABASE_ANALYST config vars and restarting ⬢ myapp... done, v123 + `) + expect(stdout.output).to.equal('') + }) + }) + + describe('error handling', function () { + it('handles attachment not found error', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments/NONEXISTENT') + .reply(404, { + id: 'not_found', + message: 'Couldn\'t find that attachment.', + resource: 'attachment', + }) + + try { + await runCommand(DataPgAttachmentsDestroy, [ + 'NONEXISTENT', + '--app=myapp', + '--confirm=myapp', + ]) + } catch (error: unknown) { + const err = error as Error + + expect(err.message).to.equal(heredoc` + Couldn't find that attachment. + + Error ID: not_found`, + ) + } + + herokuApi.done() + }) + + it('handles API errors gracefully on the attachment destruction', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments/DATABASE_ANALYST') + .reply(200, multipleAttachmentsResponse[1]) + .post('/actions/addons/resolve') + .reply(200, [addon]) + .delete('/addon-attachments/9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4') + .reply(500, { + id: 'internal_server_error', + message: 'Internal server error.', + }) + + try { + await runCommand(DataPgAttachmentsDestroy, [ + 'DATABASE_ANALYST', + '--app=myapp', + '--confirm=myapp', + ]) + } catch (error: unknown) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + Internal server error. + + Error ID: internal_server_error`, + ) + } + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Detaching DATABASE_ANALYST on ⛁ advanced-horizontal-01234 from ⬢ myapp... ! + `) + }) + + it('handles API errors gracefully on the release retrieval', async function () { + const herokuApi = nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments/DATABASE_ANALYST') + .reply(200, multipleAttachmentsResponse[1]) + .post('/actions/addons/resolve') + .reply(200, [addon]) + .delete('/addon-attachments/9a301cce-e1f7-4f1e-a955-5a0ab1d62cb4') + .reply(200, multipleAttachmentsResponse[1]) + .get('/apps/myapp/releases') + .reply(500, { + id: 'internal_server_error', + message: 'Internal server error.', + }) + + try { + await runCommand(DataPgAttachmentsDestroy, [ + 'DATABASE_ANALYST', + '--app=myapp', + '--confirm=myapp', + ]) + } catch (error: unknown) { + const err = error as Error + expect(ansis.strip(err.message)).to.equal(heredoc` + Internal server error. + + Error ID: internal_server_error`, + ) + } + + herokuApi.done() + expect(ansis.strip(stderr.output)).to.equal(heredoc` + Detaching DATABASE_ANALYST on ⛁ advanced-horizontal-01234 from ⬢ myapp... done + Unsetting DATABASE_ANALYST config vars and restarting ⬢ myapp... ! + `) + }) + }) +}) diff --git a/test/unit/commands/data/pg/attachments/index.unit.test.ts b/test/unit/commands/data/pg/attachments/index.unit.test.ts new file mode 100644 index 0000000000..3213cc9d19 --- /dev/null +++ b/test/unit/commands/data/pg/attachments/index.unit.test.ts @@ -0,0 +1,129 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' + +import DataPgAttachmentsIndex from '../../../../../../src/commands/data/pg/attachments/index.js' +import { + addon, + advancedCredentialsResponse, + attachmentWithMissingNamespace, + emptyAttachmentsResponse, + multipleAttachmentsResponse, + nonAdvancedAddon, + singleAttachmentResponse, +} from '../../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../../helpers/runCommand.js' +import removeAllWhitespace from '../../../../../helpers/utils/remove-whitespaces.js' + +describe('data:pg:attachments', function () { + it('shows error for non-advanced databases', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(DataPgAttachmentsIndex, [ + 'DATABASE', + '--app=myapp', + ]) + } catch (error: unknown) { + const err = error as Error + + herokuApi.done() + expect(ansis.strip(err.message)).to.equal( + 'You can only use this command on Advanced-tier databases.\n' + + 'Use heroku addons:info standard-database -a myapp instead.', + ) + } + }) + + describe('when attachments exist', function () { + it('displays single attachment information', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .get(`/addons/${addon.id}/addon-attachments`) + .reply(200, singleAttachmentResponse) + const dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/credentials`) + .reply(200, advancedCredentialsResponse) + + await runCommand(DataPgAttachmentsIndex, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + const output = ansis.strip(removeAllWhitespace(stdout.output)) + expect(output).to.include(removeAllWhitespace('Attachments for ⛁ advanced-horizontal-01234')) + expect(output).to.include(removeAllWhitespace('Attachment Credential Pool')) + expect(output).to.include(removeAllWhitespace('myapp::DATABASE u2vi1nt40t3mcq (owner) leader')) + }) + + it('displays multiple attachments', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .get(`/addons/${addon.id}/addon-attachments`) + .reply(200, multipleAttachmentsResponse) + const dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/credentials`) + .reply(200, advancedCredentialsResponse) + + await runCommand(DataPgAttachmentsIndex, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + const output = ansis.strip(removeAllWhitespace(stdout.output)) + expect(output).to.include(removeAllWhitespace('Attachments for ⛁ advanced-horizontal-01234')) + expect(output).to.include(removeAllWhitespace('Attachment Credential Pool')) + expect(output).to.include(removeAllWhitespace('myapp::DATABASE u2vi1nt40t3mcq (owner) leader')) + expect(output).to.include(removeAllWhitespace('myapp::DATABASE_ANALYST analyst leader')) + expect(output).to.include(removeAllWhitespace('myapp::DATABASE_ANALYTICS u2vi1nt40t3mcq (owner) analytics')) + }) + + it('handles missing namespace gracefully', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .get(`/addons/${addon.id}/addon-attachments`) + .reply(200, attachmentWithMissingNamespace) + + const dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/credentials`) + .reply(200, advancedCredentialsResponse) + + await runCommand(DataPgAttachmentsIndex, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + const output = ansis.strip(removeAllWhitespace(stdout.output)) + expect(output).to.include(removeAllWhitespace('Attachments for ⛁ advanced-horizontal-01234')) + expect(output).to.include(removeAllWhitespace('Attachment Credential Pool')) + expect(output).to.include(removeAllWhitespace('myapp::DATABASE u2vi1nt40t3mcq (owner) leader')) + }) + }) + + describe('when no attachments exist', function () { + it('displays appropriate message for empty attachments', async function () { + const herokuApi = nock('https://api.heroku.com') + .post('/actions/addons/resolve') + .reply(200, [addon]) + .get(`/addons/${addon.id}/addon-attachments`) + .reply(200, emptyAttachmentsResponse) + + const dataApi = nock('https://api.data.heroku.com') + .get(`/data/postgres/v1/${addon.id}/credentials`) + .reply(200, advancedCredentialsResponse) + + await runCommand(DataPgAttachmentsIndex, ['DATABASE', '--app=myapp']) + + herokuApi.done() + dataApi.done() + expect(stderr.output).to.equal('') + expect(ansis.strip(stdout.output)).to.equal('No attachments found for this database.\n') + }) + }) +}) From d9862f20345cc9ce0081d30b750a54aa37d2301e Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Mon, 9 Feb 2026 13:52:47 -0300 Subject: [PATCH 2/3] Copying and refactoring data:pg:quotas topic commands and tests --- cspell-dictionary.txt | 1 + src/commands/data/pg/quotas/index.ts | 54 +++++ src/commands/data/pg/quotas/update.ts | 88 ++++++++ .../data/pg/quotas/index.unit.test.ts | 113 ++++++++++ .../data/pg/quotas/update.unit.test.ts | 196 ++++++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 src/commands/data/pg/quotas/index.ts create mode 100644 src/commands/data/pg/quotas/update.ts create mode 100644 test/unit/commands/data/pg/quotas/index.unit.test.ts create mode 100644 test/unit/commands/data/pg/quotas/update.unit.test.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 88677aedd9..ed5e6378e3 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -241,6 +241,7 @@ osslsigncode ossp otherapp otherdb +otherquota otta otlpgrpc otlphttp diff --git a/src/commands/data/pg/quotas/index.ts b/src/commands/data/pg/quotas/index.ts new file mode 100644 index 0000000000..bf1e8936d7 --- /dev/null +++ b/src/commands/data/pg/quotas/index.ts @@ -0,0 +1,54 @@ +import {utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' + +import type {Quota, Quotas} from '../../../../lib/data/types.js' + +import BaseCommand from '../../../../lib/data/baseCommand.js' +import {displayQuota} from '../../../../lib/data/displayQuota.js' + +export default class DataPgQuotasIndex extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'display quotas set on a Postgres Advanced database' + + static examples = [ + '<%= config.bin %> <%= command.id %> database_name --app example-app', + ] + + static flags = { + app: Flags.app({required: true}), + remote: Flags.remote(), + type: Flags.string({description: 'type of quota', options: ['storage']}), + } + + async run() { + const {args, flags} = await this.parse(DataPgQuotasIndex) + const {app, type} = flags + const {database} = args + + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(database, app, utils.pg.addonService()) + + if (!utils.pg.isAdvancedDatabase(addon)) { + ux.error('You can only use this command on Advanced-tier databases') + } + + if (type) { + const {body: quota} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/quotas/${type}`) + displayQuota(quota) + } else { + const {body: quotas} = await this.dataApi.get(`/data/postgres/v1/${addon.id}/quotas`) + + quotas.items.forEach(quota => { + displayQuota(quota) + ux.stdout('') + }) + } + } +} diff --git a/src/commands/data/pg/quotas/update.ts b/src/commands/data/pg/quotas/update.ts new file mode 100644 index 0000000000..3bfbc2a46d --- /dev/null +++ b/src/commands/data/pg/quotas/update.ts @@ -0,0 +1,88 @@ +import {color, utils} from '@heroku/heroku-cli-util' +import {flags as Flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import type {Quota} from '../../../../lib/data/types.js' + +import BaseCommand from '../../../../lib/data/baseCommand.js' +import {displayQuota} from '../../../../lib/data/displayQuota.js' + +type QuotaUpdate = { + critical_gb?: null | number, + enforcement_action?: string + warning_gb?: null | number, +} + +const heredoc = tsheredoc.default +const validateQuotaSetting = function (flagName: string, settingAmt: string | undefined) { + if (settingAmt && settingAmt !== 'none' && !Number.parseInt(settingAmt, 10)) { + ux.error(heredoc(` + Parsing --${flagName} + You can only enter an integer or "none" in the --${flagName} flag. + See more help with --help + `)) + } +} + +export default class DataPgQuotasUpdate extends BaseCommand { + static args = { + database: Args.string({ + description: 'database name, database attachment name, or related config var on an app', + required: true, + }), + } + + static description = 'update quota settings on a Postgres Advanced database' + + static examples = ['<%= config.bin %> <%= command.id %> --app example-app --type storage --warning 12 --critical 15 --enforcement-action notify'] + + static flags = { + app: Flags.app({required: true}), + critical: Flags.string({description: 'set critical threshold in GB, set to "none" to remove threshold'}), + 'enforcement-action': Flags.string({ + description: 'set enforcement action for when database surpasses the critical threshold', + options: ['notify', 'restrict', 'none'], + }), + remote: Flags.remote(), + type: Flags.string({ + description: 'type of quota to update', + options: ['storage'], + required: true, + }), + warning: Flags.string({description: 'set warning threshold in GB, set to "none" to remove threshold'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(DataPgQuotasUpdate) + const {database} = args + const {app, critical, 'enforcement-action': enforcementAction, type, warning} = flags + + if (!warning && !critical && !enforcementAction) { + ux.error('You must set a value for either the warning, critical, or enforcement-action flags') + } + + validateQuotaSetting('warning', warning) + validateQuotaSetting('critical', critical) + + const addonResolver = new utils.AddonResolver(this.heroku) + const addon = await addonResolver.resolve(database, app, utils.pg.addonService()) + + if (!utils.pg.isAdvancedDatabase(addon)) { + ux.error('You can only use this command on Advanced-tier databases.') + } + + const quotaUpdate: QuotaUpdate = {} + if (warning) quotaUpdate.warning_gb = warning === 'none' ? null : Number.parseInt(warning, 10) + if (critical) quotaUpdate.critical_gb = critical === 'none' ? null : Number.parseInt(critical, 10) + if (enforcementAction) quotaUpdate.enforcement_action = enforcementAction + + ux.action.start(`Updating ${type} quota on ${color.datastore(database)}`) + const {body: updatedQuota} = await this.dataApi.patch(`/data/postgres/v1/${addon.id}/quotas/${type}`, { + body: quotaUpdate, + }) + ux.action.stop() + + displayQuota(updatedQuota) + } +} diff --git a/test/unit/commands/data/pg/quotas/index.unit.test.ts b/test/unit/commands/data/pg/quotas/index.unit.test.ts new file mode 100644 index 0000000000..f4e7e7bfec --- /dev/null +++ b/test/unit/commands/data/pg/quotas/index.unit.test.ts @@ -0,0 +1,113 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgQuotasIndex from '../../../../../../src/commands/data/pg/quotas/index.js' +import { + addon, + nonAdvancedAddon, + quotasResponse, + storageQuotaResponse, +} from '../../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:quotas', function () { + let dataApi: nock.Scope + let herokuApi: nock.Scope + + beforeEach(function () { + dataApi = nock('https://api.data.heroku.com') + herokuApi = nock('https://api.heroku.com') + }) + + afterEach(function () { + dataApi.done() + herokuApi.done() + }) + + describe('without type flag', function () { + it('returns info on all quotas', async function () { + dataApi + .get(`/data/postgres/v1/${addon.id}/quotas`) + .reply(200, quotasResponse) + herokuApi + .post('/actions/addons/resolve') + .reply(200, [addon]) + + await runCommand(DataPgQuotasIndex, [ + 'advanced-horizontal-01234', + '--app=myapp', + ]) + + expect(stderr.output).to.equal('') + expect(ansis.strip(stdout.output)).to.equal( + heredoc(` + === Storage + + Warning: Not set + Critical: Not set + Enforcement Action: None + Status: 1.10 GB (No quotas set) + + === Otherquota + + Warning: 50.00 GB + Critical: 100.00 GB + Enforcement Action: Notify + Status: 1.10 GB / 100.00 GB (1.10%) (Within configured quotas) + + `), + ) + }) + }) + + describe('with type flag', function () { + it('returns info only on the specified type of quota', async function () { + dataApi + .get(`/data/postgres/v1/${addon.id}/quotas/storage`) + .reply(200, storageQuotaResponse) + herokuApi + .post('/actions/addons/resolve') + .reply(200, [addon]) + + await runCommand(DataPgQuotasIndex, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + ]) + + expect(stderr.output).to.equal('') + expect(ansis.strip(stdout.output)).to.equal( + heredoc(` + === Storage + + Warning: 50.00 GB + Critical: 100.00 GB + Enforcement Action: None + Status: 0.00 MB / 100.00 GB (Within configured quotas) + `), + ) + }) + }) + + describe('error handling', function () { + it('errors when used with non-Advanced-tier add-ons', async function () { + herokuApi + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(DataPgQuotasIndex, ['advanced-horizontal-01234', '--app=myapp']) + } catch (error: unknown) { + const err = error as Error + + herokuApi.done() + expect(ansis.strip(err.message)).to.equal('You can only use this command on Advanced-tier databases') + } + }) + }) +}) diff --git a/test/unit/commands/data/pg/quotas/update.unit.test.ts b/test/unit/commands/data/pg/quotas/update.unit.test.ts new file mode 100644 index 0000000000..9c2277f45a --- /dev/null +++ b/test/unit/commands/data/pg/quotas/update.unit.test.ts @@ -0,0 +1,196 @@ +import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' +import tsheredoc from 'tsheredoc' + +import DataPgQuotasUpdate from '../../../../../../src/commands/data/pg/quotas/update.js' +import { + addon, + nonAdvancedAddon, + storageQuotaResponse, +} from '../../../../../fixtures/data/pg/fixtures.js' +import runCommand from '../../../../../helpers/runCommand.js' + +const heredoc = tsheredoc.default + +describe('data:pg:quotas:update', function () { + let dataApi: nock.Scope + let herokuApi: nock.Scope + + beforeEach(function () { + dataApi = nock('https://api.data.heroku.com') + herokuApi = nock('https://api.heroku.com') + }) + + afterEach(function () { + dataApi.done() + herokuApi.done() + }) + + it('prints the updated quota settings when the quota update is successful', async function () { + herokuApi + .post('/actions/addons/resolve') + .reply(200, [addon]) + dataApi + .patch(`/data/postgres/v1/${addon.id}/quotas/storage`) + .reply(200, storageQuotaResponse) + + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--warning=50', + '--critical=100', + '--enforcement-action=none', + ]) + + expect(stderr.output).to.equal(heredoc(` + Updating storage quota on ⛁ advanced-horizontal-01234... done + `)) + expect(stdout.output).to.equal(heredoc(` + === Storage + + Warning: 50.00 GB + Critical: 100.00 GB + Enforcement Action: None + Status: 0.00 MB / 100.00 GB (Within configured quotas) + `)) + }) + + it('sends the correct quota updates to the data API', async function () { + herokuApi + .post('/actions/addons/resolve') + .reply(200, [addon]) + dataApi + .patch(`/data/postgres/v1/${addon.id}/quotas/storage`, { + critical_gb: 100, + enforcement_action: 'none', + warning_gb: 50, + }) + .reply(200, storageQuotaResponse) + + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--warning=50', + '--critical=100', + '--enforcement-action=none', + ]) + + // this test will fail with "Nock: No match for request" if the dataApi patch request body is incorrect + }) + + it('sets the warning_gb to null when the warning flag is set to "none"', async function () { + herokuApi + .post('/actions/addons/resolve') + .reply(200, [addon]) + dataApi + .patch(`/data/postgres/v1/${addon.id}/quotas/storage`, { + warning_gb: null, + }) + .reply(200, storageQuotaResponse) + + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--warning=none', + ]) + + // this test will fail with "Nock: No match for request" if the dataApi patch request body is incorrect + }) + + it('sets the critical_gp to null when the critical flag is set to "none"', async function () { + herokuApi + .post('/actions/addons/resolve') + .reply(200, [addon]) + dataApi + .patch(`/data/postgres/v1/${addon.id}/quotas/storage`, { + critical_gb: null, + }) + .reply(200, storageQuotaResponse) + + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--critical=none', + ]) + + // this test will fail with "Nock: No match for request" if the dataApi patch request body is incorrect + }) + + it('will fail if neither of the warning, critical, or enforcement-action flags are set', async function () { + try { + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + ]) + } catch (error: unknown) { + const err = error as Error + + expect(ansis.strip(err.message)).to.equal('You must set a value for either the warning, critical, or enforcement-action flags') + } + }) + + it('errors when used with non-Advanced-tier add-ons', async function () { + herokuApi + .post('/actions/addons/resolve') + .reply(200, [nonAdvancedAddon]) + + try { + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--warning=50', + ]) + } catch (error: unknown) { + const err = error as Error + + herokuApi.done() + expect(ansis.strip(err.message)).to.equal('You can only use this command on Advanced-tier databases.') + } + }) + + it('errors when the --warning flag is not an integer or "none"', async function () { + try { + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--warning=nope', + ]) + } catch (error: unknown) { + const err = error as Error + + expect(ansis.strip(err.message)).to.equal(heredoc(` + Parsing --warning + You can only enter an integer or "none" in the --warning flag. + See more help with --help + `)) + } + }) + + it('errors when the --critical flag is not an integer or "none"', async function () { + try { + await runCommand(DataPgQuotasUpdate, [ + 'advanced-horizontal-01234', + '--app=myapp', + '--type=storage', + '--critical=nope', + ]) + } catch (error: unknown) { + const err = error as Error + + expect(ansis.strip(err.message)).to.equal(heredoc(` + Parsing --critical + You can only enter an integer or "none" in the --critical flag. + See more help with --help + `)) + } + }) +}) From d768e760a205126ee8806096be2a71c55d8d2648 Mon Sep 17 00:00:00 2001 From: Santiago Bosio Date: Mon, 9 Feb 2026 18:21:57 -0300 Subject: [PATCH 3/3] Addressing PR feedback --- src/commands/data/pg/attachments/create.ts | 2 +- src/commands/data/pg/attachments/destroy.ts | 2 +- src/commands/data/pg/fork.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/data/pg/attachments/create.ts b/src/commands/data/pg/attachments/create.ts index 20673f6753..4d697896f3 100644 --- a/src/commands/data/pg/attachments/create.ts +++ b/src/commands/data/pg/attachments/create.ts @@ -24,7 +24,7 @@ export default class DataPgAttachmentsCreate extends BaseCommand { static flags = { app: Flags.app({required: true}), as: Flags.string({description: 'name for Postgres database attachment'}), - confirm: Flags.string({hidden: true}), + confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}), credential: Flags.string({ description: 'credential to use for database', exclusive: ['pool'], diff --git a/src/commands/data/pg/attachments/destroy.ts b/src/commands/data/pg/attachments/destroy.ts index 88a5e03283..af3098cd21 100644 --- a/src/commands/data/pg/attachments/destroy.ts +++ b/src/commands/data/pg/attachments/destroy.ts @@ -17,7 +17,7 @@ export default class DataPgAttachmentsDestroy extends BaseCommand { static flags = { app: Flags.app({required: true}), - confirm: Flags.string({hidden: true}), + confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}), remote: Flags.remote(), } diff --git a/src/commands/data/pg/fork.ts b/src/commands/data/pg/fork.ts index f801690bd7..e60e2a5080 100644 --- a/src/commands/data/pg/fork.ts +++ b/src/commands/data/pg/fork.ts @@ -40,7 +40,7 @@ export default class Fork extends BaseCommand { static flags = { app: Flags.app({required: true}), as: Flags.string({description: 'name for the initial database attachment'}), - confirm: Flags.string({hidden: true}), + confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}), level: Flags.string({description: 'set compute scale'}), name: Flags.string({char: 'n', description: 'name for the database'}), 'provision-option': Flags.string({