From f7e52a60c6460ed4139f96b3a284382e8a2f1838 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Tue, 6 Jan 2026 14:07:23 -0800 Subject: [PATCH 1/7] removing migrated functions to heroku-cli-util while converting remaining to esm and adding testing coverage --- package-lock.json | 39 ++++- packages/cli/src/lib/pg/util.ts | 31 ++-- .../cli/test/unit/lib/pg/util.unit.test.ts | 165 ++++++++++++++++-- 3 files changed, 194 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34aa260c48..d6c9d1507a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1402,7 +1402,8 @@ "node_modules/@cspell/dict-css": { "version": "4.0.18", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-dart": { "version": "2.3.1", @@ -1502,12 +1503,14 @@ "node_modules/@cspell/dict-html": { "version": "4.0.12", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.4", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -1651,7 +1654,8 @@ "node_modules/@cspell/dict-typescript": { "version": "3.2.3", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -4980,6 +4984,7 @@ "version": "4.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -5122,6 +5127,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -5143,6 +5149,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -5155,6 +5162,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -7271,6 +7279,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -7504,6 +7513,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -7521,6 +7531,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -7537,6 +7548,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -8602,6 +8614,7 @@ "node_modules/@types/node": { "version": "24.3.1", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -8652,6 +8665,7 @@ "node_modules/@types/react": { "version": "18.3.26", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -8736,6 +8750,7 @@ "version": "6.21.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -9248,6 +9263,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -13768,6 +13784,7 @@ "version": "8.57.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -14255,6 +14272,7 @@ "version": "2.32.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15124,6 +15142,7 @@ "version": "2.27.5", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -15386,6 +15405,7 @@ "version": "6.1.1", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -15397,6 +15417,7 @@ "version": "7.34.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlast": "^1.2.4", @@ -19265,6 +19286,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nrwl/cli": "15.6.3", "@nrwl/tao": "15.6.3", @@ -20870,6 +20892,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -22871,6 +22894,7 @@ "version": "15.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -24430,6 +24454,7 @@ "node_modules/typescript": { "version": "4.9.5", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25434,6 +25459,7 @@ "version": "7.23.9", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -27303,6 +27329,7 @@ "packages/cli/node_modules/@types/node": { "version": "22.16.5", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -27714,6 +27741,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -27771,6 +27799,7 @@ "version": "4.4.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -32052,6 +32081,7 @@ "version": "15.1.0", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", @@ -33525,6 +33555,7 @@ "packages/cli/node_modules/zod": { "version": "3.25.76", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/cli/src/lib/pg/util.ts b/packages/cli/src/lib/pg/util.ts index 55dea3074b..21949a57a6 100644 --- a/packages/cli/src/lib/pg/util.ts +++ b/packages/cli/src/lib/pg/util.ts @@ -1,20 +1,10 @@ -/* -import color from '@heroku-cli/color' +import {color} from '@heroku-cli/color' import type {AddOnAttachment} from '@heroku-cli/schema' -import {ux} from '@oclif/core' -import type {ExtendedAddonAttachment} from '@heroku/heroku-cli-util' -import {renderAttachment} from '../../commands/addons' -import {multiSortCompareFn} from '../utils/multisort' -import type {CredentialsInfo} from './types' +import {hux} from '@heroku/heroku-cli-util' +import {renderAttachment} from '../../commands/addons/index.js' +import {multiSortCompareFn} from '../utils/multisort.js' +import type {CredentialsInfo} from './types.js' import {utils} from '@heroku/heroku-cli-util' -import type {ExtendedAddon} from './types' - -export const essentialNumPlan = (addon: ExtendedAddonAttachment['addon'] | ExtendedAddon) => Boolean(addon?.plan?.name?.split(':')[1].match(/^essential/)) -export const legacyEssentialPlan = (addon: ExtendedAddonAttachment['addon'] | ExtendedAddon) => Boolean(addon?.plan?.name?.split(':')[1].match(/(dev|basic|mini)$/)) - -export function essentialPlan(addon: ExtendedAddonAttachment['addon'] | ExtendedAddon) { - return essentialNumPlan(addon) || legacyEssentialPlan(addon) -} export function formatResponseWithCommands(response: string): string { return response.replace(/`(.*?)`/g, (_, word) => color.cmd(word)) @@ -53,7 +43,10 @@ export function presentCredentialAttachments(app: string, credAttachments: Requi if (connectionInformationAvailable) { const prefix = ' ' rotationLines.push(`${prefix}Usernames currently active for this credential:`) - ux.table(formatted, { + const printLine = (line: unknown) => { + rotationLines.push(line as string) + } + hux.table(formatted, { user: { get(row: typeof formatted[0]) { return `${prefix}${row.user}` @@ -70,10 +63,7 @@ export function presentCredentialAttachments(app: string, credAttachments: Requi }, }, }, { - 'no-header': true, - printLine(line: unknown): void { - rotationLines.push(line) - }, + printLine, }) } } @@ -121,4 +111,3 @@ export const databaseNameFromUrl = (uri: string, config: Record) const conn = utils.pg.DatabaseResolver.parsePostgresConnectionString(uri) return `${conn.host}:${conn.port}${conn.pathname}` } -*/ \ No newline at end of file diff --git a/packages/cli/test/unit/lib/pg/util.unit.test.ts b/packages/cli/test/unit/lib/pg/util.unit.test.ts index a710d18472..ba030f79cb 100644 --- a/packages/cli/test/unit/lib/pg/util.unit.test.ts +++ b/packages/cli/test/unit/lib/pg/util.unit.test.ts @@ -1,23 +1,156 @@ -/* +import * as Heroku from '@heroku-cli/schema' import {expect} from 'chai' -import {essentialPlan} from '../../../../src/lib/pg/util.js' -import {ExtendedAddonAttachment} from '@heroku/heroku-cli-util' +import type {CredentialsInfo} from '../../../../src/lib/pg/types.js' +import { + configVarNamesFromValue, + databaseNameFromUrl, + formatResponseWithCommands, + presentCredentialAttachments, +} from '../../../../src/lib/pg/util.js' describe('util', function () { - describe('essentialPlan', function () { - it('correctly identifies essential plans', function () { - const addon = (plan: string) => ({plan: {name: plan}}) - const parsed = (addon: unknown) => essentialPlan(addon as ExtendedAddonAttachment['addon']) - - expect(parsed(addon('heroku-postgresql:mini'))).to.equal(true) - expect(parsed(addon('heroku-postgresql:basic'))).to.equal(true) - expect(parsed(addon('heroku-postgresql:essential-0'))).to.equal(true) - expect(parsed(addon('heroku-postgresql:standard-0'))).to.equal(false) - expect(parsed(addon('heroku-postgresql:private-0'))).to.equal(false) - expect(parsed(addon('heroku-postgresql:shield-0'))).to.equal(false) + describe('formatResponseWithCommands', function () { + it('formats commands in backticks', function () { + const input = 'Run `heroku pg:info` to see details' + const result = formatResponseWithCommands(input) + expect(result).to.include('heroku pg:info') + expect(result).not.to.include('`') }) }) -}) -*/ + describe('presentCredentialAttachments', function () { + it('returns credential name and attachments', function () { + const app = 'myapp' + const cred = 'default' + const credAttachments: Required[] = [ + { + id: '1', + name: 'DATABASE', + app: {id: 'app-id', name: app}, + addon: {id: 'addon-id', name: 'postgres-1'} as Required, + namespace: null, + config_vars: ['DATABASE_URL'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + web_url: 'https://example.com', + log_input_url: 'https://example.com/logs', + }, + ] + const credentials: CredentialsInfo = [] + + const result = presentCredentialAttachments(app, credAttachments, credentials, cred) + + expect(result).to.include(cred) + expect(result).to.include('DATABASE') + }) + + it('includes rotation information when credential is rotating', function () { + const app = 'myapp' + const cred = 'default' + const credAttachments: Required[] = [ + { + id: '1', + name: 'DATABASE', + app: {id: 'app-id', name: app}, + addon: {id: 'addon-id', name: 'postgres-1'} as Required, + namespace: null, + config_vars: ['DATABASE_URL'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + web_url: 'https://example.com', + log_input_url: 'https://example.com/logs', + }, + ] + const credentials: CredentialsInfo = [ + { + uuid: 'uuid', + name: cred, + state: 'rotating', + database: 'db', + host: 'host', + port: 5432, + credentials: [ + { + user: 'user1', + password: 'pass', + state: 'active', + connections: 5, + }, + ], + }, + ] + + const result = presentCredentialAttachments(app, credAttachments, credentials, cred) + + expect(result).to.include('user1') + expect(result).to.include('5 connections') + }) + }) + + describe('configVarNamesFromValue', function () { + it('finds exact matches', function () { + const config = { + DATABASE_URL: 'postgres://user:pass@host:5432/db', + OTHER_URL: 'postgres://user:pass@host:5432/db', + } + const value = 'postgres://user:pass@host:5432/db' + + const result = configVarNamesFromValue(config, value) + + expect(result).to.include('DATABASE_URL') + expect(result).to.include('OTHER_URL') + }) + + it('does not match URLs with different hostname', function () { + const config = { + DATABASE_URL: 'postgres://user:pass@host:5432/db', + OTHER_URL: 'postgres://user:pass@different-host:5432/db', + } + const value = 'postgres://user:pass@host:5432/db' + + const result = configVarNamesFromValue(config, value) + + expect(result).to.include('DATABASE_URL') + expect(result).not.to.include('OTHER_URL') + }) + + it('sorts DATABASE_URL last', function () { + const config = { + DATABASE_URL: 'postgres://user:pass@host:5432/db', + MY_DB_URL: 'postgres://user:pass@host:5432/db', + } + const value = 'postgres://user:pass@host:5432/db' + + const result = configVarNamesFromValue(config, value) + + expect(result.at(-1)).to.equal('DATABASE_URL') + }) + }) + + describe('databaseNameFromUrl', function () { + it('returns config var name without _URL suffix', function () { + const config = { + MY_DATABASE_URL: 'postgres://user:pass@host:5432/db', + DATABASE_URL: 'postgres://user:pass@host:5432/db', + } + const uri = 'postgres://user:pass@host:5432/db' + + const result = databaseNameFromUrl(uri, config) + + expect(result).to.include('MY_DATABASE') + }) + + it('returns host:port/pathname when no config var matches', function () { + const config = { + DATABASE_URL: 'postgres://user:pass@different-host:5432/db', + } + const uri = 'postgres://user:pass@host:5432/db' + + const result = databaseNameFromUrl(uri, config) + + expect(result).to.include('host:5432') + expect(result).to.include('/db') + }) + }) +}) From 654351274b0b72cc5a6fdac70a0ef5914f8d6f58 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Tue, 6 Jan 2026 15:00:56 -0800 Subject: [PATCH 2/7] removing migrated functions to heroku-cli-util while converting remaining to esm and adding testing coverage --- .../cli/test/unit/lib/pg/util.unit.test.ts | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/packages/cli/test/unit/lib/pg/util.unit.test.ts b/packages/cli/test/unit/lib/pg/util.unit.test.ts index ba030f79cb..d021858d92 100644 --- a/packages/cli/test/unit/lib/pg/util.unit.test.ts +++ b/packages/cli/test/unit/lib/pg/util.unit.test.ts @@ -44,48 +44,6 @@ describe('util', function () { expect(result).to.include(cred) expect(result).to.include('DATABASE') }) - - it('includes rotation information when credential is rotating', function () { - const app = 'myapp' - const cred = 'default' - const credAttachments: Required[] = [ - { - id: '1', - name: 'DATABASE', - app: {id: 'app-id', name: app}, - addon: {id: 'addon-id', name: 'postgres-1'} as Required, - namespace: null, - config_vars: ['DATABASE_URL'], - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - web_url: 'https://example.com', - log_input_url: 'https://example.com/logs', - }, - ] - const credentials: CredentialsInfo = [ - { - uuid: 'uuid', - name: cred, - state: 'rotating', - database: 'db', - host: 'host', - port: 5432, - credentials: [ - { - user: 'user1', - password: 'pass', - state: 'active', - connections: 5, - }, - ], - }, - ] - - const result = presentCredentialAttachments(app, credAttachments, credentials, cred) - - expect(result).to.include('user1') - expect(result).to.include('5 connections') - }) }) describe('configVarNamesFromValue', function () { From 55c43761fa7b879802b0aef94bdb33b8a5dbde61 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Thu, 8 Jan 2026 13:54:15 -0800 Subject: [PATCH 3/7] conversion of command set and their tests to esm --- packages/cli/src/commands/pg/backups/index.ts | 141 +++++++++++++++++ packages/cli/src/commands/pg/backups/info.ts | 99 ++++++++++++ .../cli/src/commands/pg/backups/restore.ts | 149 ++++++++++++++++++ .../cli/src/commands/pg/backups/schedule.ts | 105 ++++++++++++ .../cli/src/commands/pg/backups/schedules.ts | 32 ++++ .../cli/src/commands/pg/backups/unschedule.ts | 59 +++++++ packages/cli/src/commands/pg/backups/url.ts | 44 ++++++ packages/cli/src/nls.ts | 17 +- packages/cli/src/package.nls.json | 2 +- .../test/helpers/wrappers/backups-wrapper.ts | 3 + .../commands/pg/backups/index.unit.test.ts | 120 ++++++-------- .../commands/pg/backups/info.unit.test.ts | 5 +- .../commands/pg/backups/restore.unit.test.ts | 9 +- .../commands/pg/backups/schedule.unit.test.ts | 33 ++-- .../pg/backups/schedules.unit.test.ts | 5 +- .../pg/backups/unschedule.unit.test.ts | 5 +- .../unit/commands/pg/backups/url.unit.test.ts | 5 +- 17 files changed, 722 insertions(+), 111 deletions(-) create mode 100644 packages/cli/src/commands/pg/backups/index.ts create mode 100644 packages/cli/src/commands/pg/backups/info.ts create mode 100644 packages/cli/src/commands/pg/backups/restore.ts create mode 100644 packages/cli/src/commands/pg/backups/schedule.ts create mode 100644 packages/cli/src/commands/pg/backups/schedules.ts create mode 100644 packages/cli/src/commands/pg/backups/unschedule.ts create mode 100644 packages/cli/src/commands/pg/backups/url.ts create mode 100644 packages/cli/test/helpers/wrappers/backups-wrapper.ts diff --git a/packages/cli/src/commands/pg/backups/index.ts b/packages/cli/src/commands/pg/backups/index.ts new file mode 100644 index 0000000000..64b8985509 --- /dev/null +++ b/packages/cli/src/commands/pg/backups/index.ts @@ -0,0 +1,141 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {ux} from '@oclif/core' +import {hux} from '@heroku/heroku-cli-util' +import backupsFactory from '../../../lib/pg/backups.js' +import {utils} from '@heroku/heroku-cli-util' +import type {BackupTransfer} from '../../../lib/pg/types.js' + +export default class Index extends Command { + static topic = 'pg' + static description = 'list database backups' + static strict = false + static flags = { + verbose: flags.boolean({char: 'v', hidden: true}), + confirm: flags.string({char: 'c', hidden: true}), + output: flags.string({char: 'o', hidden: true}), + 'wait-interval': flags.string({hidden: true}), + at: flags.string({hidden: true}), + quiet: flags.boolean({char: 'q', hidden: true}), + app: flags.app({required: true}), + remote: flags.remote(), + } + + public async run(): Promise { + const {flags: {app}} = await this.parse(Index) + + const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + // NOTE that the sort order is descending + transfers.sort((transferA, transferB) => { + if (transferA.created_at > transferB.created_at) { + return -1 + } + + if (transferB.created_at > transferA.created_at) { + return 1 + } + + return 0 + }) + + this.displayBackups(transfers, app) + this.displayRestores(transfers, app) + this.displayCopies(transfers, app) + } + + private displayBackups(transfers: BackupTransfer[], app: string) { + const backups = transfers.filter(backupTransfer => backupTransfer.from_type === 'pg_dump' && backupTransfer.to_type === 'gof3r') + const backupsApi = backupsFactory(app, this.heroku) + const {name, status, filesize} = backupsApi + hux.styledHeader('Backups') + if (backups.length === 0) { + ux.stdout(`No backups. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}\n`) + } else { + hux.table(backups, { + ID: { + get: (transfer: BackupTransfer) => color.cyan(name(transfer)), + }, + 'Created at': { + get: (transfer: BackupTransfer) => transfer.created_at, + }, + Status: { + get: (transfer: BackupTransfer) => backupsApi.status(transfer), + }, + Size: { + get: (transfer: BackupTransfer) => filesize(transfer.processed_bytes), + }, + Database: { + get: (transfer: BackupTransfer) => color.green(transfer.from_name) || 'UNKNOWN', + }, + }) + } + + ux.stdout('\n') + } + + private displayRestores(transfers: BackupTransfer[], app: string) { + const restores = transfers + .filter(t => t.from_type !== 'pg_dump' && t.to_type === 'pg_restore') + .slice(0, 10) // first 10 only + const backupsApi = backupsFactory(app, this.heroku) + const {name, status, filesize} = backupsApi + hux.styledHeader('Restores') + if (restores.length === 0) { + ux.stdout(`No restores found. Use ${color.cyan.bold('heroku pg:backups:restore')} to restore a backup\n`) + } else { + hux.table(restores, { + ID: { + get: (transfer: BackupTransfer) => color.cyan(name(transfer)), + }, + 'Started at': { + get: (transfer: BackupTransfer) => transfer.created_at, + }, + Status: { + get: (transfer: BackupTransfer) => backupsApi.status(transfer), + }, + Size: { + get: (transfer: BackupTransfer) => filesize(transfer.processed_bytes), + }, + Database: { + get: (transfer: BackupTransfer) => color.green(transfer.to_name) || 'UNKNOWN', + }, + }) + } + + ux.stdout('\n') + } + + private displayCopies(transfers: BackupTransfer[], app: string) { + const backupsApi = backupsFactory(app, this.heroku) + const {name, status, filesize} = backupsApi + const copies = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'pg_restore').slice(0, 10) + hux.styledHeader('Copies') + if (copies.length === 0) { + ux.stdout(`No copies found. Use ${color.cyan.bold('heroku pg:copy')} to copy a database to another\n`) + } else { + hux.table(copies, { + ID: { + get: (transfer: BackupTransfer) => color.cyan(name(transfer)), + }, + 'Started at': { + get: (transfer: BackupTransfer) => transfer.created_at, + }, + Status: { + get: (transfer: BackupTransfer) => backupsApi.status(transfer), + }, + Size: { + get: (transfer: BackupTransfer) => filesize(transfer.processed_bytes), + }, + From: { + get: (transfer: BackupTransfer) => color.green(transfer.from_name) || 'UNKNOWN', + }, + To: { + get: (transfer: BackupTransfer) => color.green(transfer.to_name) || 'UNKNOWN', + }, + }) + } + + ux.stdout('\n') + } +} + diff --git a/packages/cli/src/commands/pg/backups/info.ts b/packages/cli/src/commands/pg/backups/info.ts new file mode 100644 index 0000000000..44c95cb41d --- /dev/null +++ b/packages/cli/src/commands/pg/backups/info.ts @@ -0,0 +1,99 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import {hux} from '@heroku/heroku-cli-util' +import {utils} from '@heroku/heroku-cli-util' +import pgBackupsApi from '../../../lib/pg/backups.js' +import type {BackupTransfer} from '../../../lib/pg/types.js' + +function status(backup: BackupTransfer) { + if (backup.succeeded) { + if (backup.warnings > 0) + return `Finished with ${backup.warnings} warnings` + return 'Completed' + } + + if (backup.canceled_at) + return 'Canceled' + if (backup.finished_at) + return 'Failed' + if (backup.started_at) + return 'Running' + return 'Pending' +} + +function compression(compressed: number, total: number) { + let pct = 0 + if (compressed > 0) { + pct = Math.round((total - compressed) / total * 100) + pct = Math.max(0, pct) + } + + return ` (${pct}% compression)` +} + +export default class Info extends Command { + static topic = 'pg' + static description = 'get information about a specific backup' + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + + static args = { + backup_id: Args.string({description: 'ID of the backup. If omitted, we use the last backup ID.'}), + } + + getBackup = async (id: string | undefined, app: string) => { + let backupID + if (id) { + const {num} = pgBackupsApi(app, this.heroku) + backupID = await num(id) + if (!backupID) + throw new Error(`Invalid ID: ${id}`) + } else { + const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + transfers.sort((a, b) => a.created_at.localeCompare(b.created_at)) + const backups = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'gof3r') + const lastBackup = backups.pop() + if (!lastBackup) + throw new Error(`No backups. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) + backupID = lastBackup.num + } + + const {body: backup} = await this.heroku.get(`/client/v11/apps/${app}/transfers/${backupID}?verbose=true`, {hostname: utils.pg.host()}) + return backup + } + + displayBackup = (backup: BackupTransfer, app: string) => { + const {filesize, name} = pgBackupsApi(app, this.heroku) + hux.styledHeader(`Backup ${color.cyan(name(backup))}`) + hux.styledObject({ + Database: color.green(backup.from_name), + 'Started at': backup.started_at, + 'Finished at': backup.finished_at, + Status: status(backup), + Type: backup.schedule ? 'Scheduled' : 'Manual', 'Original DB Size': filesize(backup.source_bytes), + 'Backup Size': `${filesize(backup.processed_bytes)}${backup.finished_at ? compression(backup.processed_bytes, backup.source_bytes) : ''}`, + }, ['Database', 'Started at', 'Finished at', 'Status', 'Type', 'Original DB Size', 'Backup Size']) + ux.stdout('\n') + } + + displayLogs = (backup: BackupTransfer) => { + hux.styledHeader('Backup Logs') + for (const log of backup.logs) + ux.stdout(`${log.created_at} ${log.message}\n`) + ux.stdout('\n') + } + + public async run(): Promise { + const {flags, args} = await this.parse(Info) + const {app} = flags + const {backup_id} = args + + const backup = await this.getBackup(backup_id, app) + this.displayBackup(backup, app) + this.displayLogs(backup) + } +} + diff --git a/packages/cli/src/commands/pg/backups/restore.ts b/packages/cli/src/commands/pg/backups/restore.ts new file mode 100644 index 0000000000..70ffde6424 --- /dev/null +++ b/packages/cli/src/commands/pg/backups/restore.ts @@ -0,0 +1,149 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' +import ConfirmCommand from '../../../lib/confirmCommand.js' +import backupsFactory from '../../../lib/pg/backups.js' +import {utils} from '@heroku/heroku-cli-util' +import type {BackupTransfer} from '../../../lib/pg/types.js' +import {nls} from '../../../nls.js' + +const heredoc = tsheredoc.default + +function dropboxURL(url: string) { + if (url.match(/^https?:\/\/www\.dropbox\.com/) && !url.endsWith('dl=1')) { + if (url.endsWith('dl=0')) + url = url.replace('dl=0', 'dl=1') + else if (url.includes('?')) + url += '&dl=1' + else + url += '?dl=1' + } + + return url +} + +export default class Restore extends Command { + static topic = 'pg' + static description = 'restore a backup (default latest) to a database' + static flags = { + 'wait-interval': flags.integer({default: 3}), + extensions: flags.string({ + char: 'e', + description: heredoc(` + comma-separated list of extensions to pre-install in the default + public schema or an optional custom schema + (for example: hstore or myschema.hstore) + `), + }), + verbose: flags.boolean({char: 'v'}), + confirm: flags.string({char: 'c'}), + app: flags.app({required: true}), + remote: flags.remote(), + } + + static args = { + backup: Args.string({description: 'URL or backup ID from another app'}), + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}), + } + + static examples = [ + heredoc(` + # Basic Restore from Backup ID + $ heroku pg:backups:restore b101 DATABASE_URL --app my-heroku-app + `), + heredoc(` + # Restore from Another App + $ heroku pg:backups:restore example-app::b101 DATABASE_URL --app my-heroku-app + `), + heredoc(` + # Restore from a Public URL + $ heroku pg:backups:restore 'https://s3.amazonaws.com/my-bucket/mydb.dump' DATABASE_URL --app my-heroku-app + `), + heredoc(` + # Verbose Output + $ heroku pg:backups:restore b101 DATABASE_URL --app my-heroku-app --verbose + `), + heredoc(` + # Restore with Confirmation Prompt + $ heroku pg:backups:restore b101 DATABASE_URL --app my-heroku-app --confirm my-heroku-app + `), + heredoc(` + # Restore with a Specific Database Name + $ heroku pg:backups:restore b101 HEROKU_POSTGRESQL_PINK --app my-heroku-app + `), + ] + + public async run(): Promise { + const {flags, args} = await this.parse(Restore) + const {app, 'wait-interval': waitInterval, extensions, confirm, verbose} = flags + const interval = Math.max(3, waitInterval) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const {addon: db} = await dbResolver.getAttachment(app as string, args.database) + const {name, wait} = backupsFactory(app, this.heroku) + let backupURL + let backupName = args.backup + + if (backupName && backupName.match(/^https?:\/\//)) { + backupURL = dropboxURL(backupName) + } else { + let backupApp + if (backupName && backupName.match(/::/)) { + [backupApp, backupName] = backupName.split('::') + } else { + backupApp = app + } + + const {body: transfers} = await this.heroku.get(`/client/v11/apps/${backupApp}/transfers`, {hostname: utils.pg.host()}) + const backups = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'gof3r') + + let backup + if (backupName) { + backup = backups.find(b => name(b) === backupName) + if (!backup) + throw new Error(`Backup ${color.cyan(backupName)} not found for ${color.app(backupApp)}`) + if (!backup.succeeded) + throw new Error(`Backup ${color.cyan(backupName)} for ${color.app(backupApp)} did not complete successfully`) + } else { + backup = backups.filter(b => b.succeeded).sort((a, b) => { + if (a.finished_at && b.finished_at) { + return a.finished_at.localeCompare(b.finished_at) + } + + if (a.finished_at) return 1 + if (b.finished_at) return -1 + return 0 + }).pop() + if (!backup) { + throw new Error(`No backups for ${color.app(backupApp)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) + } + + backupName = name(backup) + } + + backupURL = backup.to_url + } + + const confirmCmd = new ConfirmCommand() + await confirmCmd.confirm(app, confirm) + ux.action.start(`Starting restore of ${color.cyan(backupName)} to ${color.yellow(db.name)}`) + ux.stdout(heredoc(` + + Use Ctrl-C at any time to stop monitoring progress; the backup will continue restoring. + Use ${color.cyan.bold('heroku pg:backups')} to check progress. + Stop a running restore with ${color.cyan.bold('heroku pg:backups:cancel')}. + `)) + + const {body: restore} = await this.heroku.post<{uuid: string}>(`/client/v11/databases/${db.id}/restores`, { + body: {backup_url: backupURL, extensions: this.getSortedExtensions(extensions as string)}, hostname: utils.pg.host(), + }) + + ux.action.stop() + await wait('Restoring', restore.uuid, interval, verbose, db.app.id as string) + } + + protected getSortedExtensions(extensions: string | null | undefined): string[] | undefined { + return extensions?.split(',').map(ext => ext.trim().toLowerCase()).sort() + } +} + diff --git a/packages/cli/src/commands/pg/backups/schedule.ts b/packages/cli/src/commands/pg/backups/schedule.ts new file mode 100644 index 0000000000..f478168f4e --- /dev/null +++ b/packages/cli/src/commands/pg/backups/schedule.ts @@ -0,0 +1,105 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import {utils} from '@heroku/heroku-cli-util' +import {PgDatabase} from '../../../lib/pg/types.js' +import {HTTPError} from '@heroku/http-call' +import {nls} from '../../../nls.js' + +type Timezone = { + PST: string + PDT: string + MST: string + MDT: string + CST: string + CDT: string + EST: string + EDT: string + Z: string + GMT: string + BST: string + CET: string + CEST: string +} + +const TZ: Timezone = { + PST: 'America/Los_Angeles', + PDT: 'America/Los_Angeles', + MST: 'America/Boise', + MDT: 'America/Boise', + CST: 'America/Chicago', + CDT: 'America/Chicago', + EST: 'America/New_York', + EDT: 'America/New_York', + Z: 'UTC', + GMT: 'Europe/London', + BST: 'Europe/London', + CET: 'Europe/Paris', + CEST: 'Europe/Paris', +} + +type BackupSchedule = { + hour: string + timezone: string + schedule_name?: string +} + +export default class Schedule extends Command { + static topic = 'pg' + static description = 'schedule daily backups for given database' + static flags = { + at: flags.string({required: true, description: "at a specific (24h) hour in the given timezone. Defaults to UTC. --at '[HOUR]:00 [TIMEZONE]'"}), + app: flags.app({required: true}), + remote: flags.remote(), + } + + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}), + } + + parseDate = function (at: string): BackupSchedule { + const m = at.match(/^(0?\d|1\d|2[0-3]):00 ?(\S*)$/) + + if (m) { + const [, hour, timezone] = m + return {hour, timezone: TZ[timezone.toUpperCase() as keyof Timezone] || timezone || 'UTC'} + } + + return ux.error("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'", {exit: 1}) + } + + public async run(): Promise { + const {flags, args} = await this.parse(Schedule) + const {app} = flags + const {database} = args + + const schedule = this.parseDate(flags.at) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const attachment = await dbResolver.getAttachment(app, database) + const {addon: db, name} = attachment + const at = color.cyan(`${schedule.hour}:00 ${schedule.timezone}`) + + const pgResponse = await this.heroku.get(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()}) + .catch((error: HTTPError) => { + if (error.statusCode !== 404) + throw error + ux.error(`${color.yellow(db.name)} is not yet provisioned.\nRun ${color.cyan.bold('heroku addons:wait')} to wait until the db is provisioned.`, {exit: 1}) + }) + const {body: dbInfo} = pgResponse || {body: null} + if (dbInfo) { + const dbProtected = /On/.test(dbInfo.info.find(attribute => attribute.name === 'Continuous Protection')?.values[0] || '') + if (dbProtected) { + ux.warn('Continuous protection is already enabled for this database. Logical backups of large databases are likely to fail.') + ux.warn('See https://devcenter.heroku.com/articles/heroku-postgres-data-safety-and-continuous-protection#physical-backups-on-heroku-postgres.') + } + } + + ux.action.start(`Scheduling automatic daily backups of ${color.yellow(db.name)} at ${at}`) + schedule.schedule_name = name + '_URL' + await this.heroku.post(`/client/v11/databases/${db.id}/transfer-schedules`, { + body: schedule, hostname: utils.pg.host(), + }) + ux.action.stop() + } +} + diff --git a/packages/cli/src/commands/pg/backups/schedules.ts b/packages/cli/src/commands/pg/backups/schedules.ts new file mode 100644 index 0000000000..c2113c6c00 --- /dev/null +++ b/packages/cli/src/commands/pg/backups/schedules.ts @@ -0,0 +1,32 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {ux} from '@oclif/core' +import {hux} from '@heroku/heroku-cli-util' +import {utils} from '@heroku/heroku-cli-util' +import type {TransferSchedule} from '../../../lib/pg/types.js' + +export default class Schedules extends Command { + static topic = 'pg' + static description = 'list backup schedule' + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + + public async run(): Promise { + const {flags} = await this.parse(Schedules) + const {app} = flags + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getArbitraryLegacyDB(app) + const {body: schedules} = await this.heroku.get(`/client/v11/databases/${db.id}/transfer-schedules`, {hostname: utils.pg.host()}) + if (schedules.length === 0) { + ux.warn(`No backup schedules found on ${color.app(app)}\nUse ${color.cyan.bold('heroku pg:backups:schedule')} to set one up`) + } else { + hux.styledHeader('Backup Schedules') + for (const s of schedules) { + ux.stdout(`${color.green(s.name)}: daily at ${s.hour}:00 ${s.timezone}\n`) + } + } + } +} + diff --git a/packages/cli/src/commands/pg/backups/unschedule.ts b/packages/cli/src/commands/pg/backups/unschedule.ts new file mode 100644 index 0000000000..920220c2a1 --- /dev/null +++ b/packages/cli/src/commands/pg/backups/unschedule.ts @@ -0,0 +1,59 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import {utils} from '@heroku/heroku-cli-util' +import {TransferSchedule} from '../../../lib/pg/types.js' +import {nls} from '../../../nls.js' + +export default class Unschedule extends Command { + static topic = 'pg' + static description = 'stop daily backups' + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:arbitrary:suffix')}`}), + } + + public async run(): Promise { + const {flags, args} = await this.parse(Unschedule) + const {app} = flags + const {database} = args + let db = database + if (!db) { + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const appDB = await dbResolver.getArbitraryLegacyDB(app) + const {body: schedules} = await this.heroku.get( + `/client/v11/databases/${appDB.id}/transfer-schedules`, + {hostname: utils.pg.host()}, + ) + if (schedules.length === 0) + throw new Error(`No schedules on ${color.app(app)}`) + if (schedules.length > 1) { + throw new Error(`Specify schedule on ${color.app(app)}. Existing schedules: ${schedules.map(s => color.green(s.name)) + .join(', ')}`) + } + + db = schedules[0].name + } + + ux.action.start(`Unscheduling ${color.green(db)} daily backups`) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const {addon} = await dbResolver.getAttachment(app, db) + const {body: schedules} = await this.heroku.get( + `/client/v11/databases/${addon.id}/transfer-schedules`, + {hostname: utils.pg.host()}, + ) + const schedule = schedules.find(s => s.name.match(new RegExp(`${db}`, 'i'))) + if (!schedule) + throw new Error(`No daily backups found for ${color.yellow(addon.name)}`) + await this.heroku.delete( + `/client/v11/databases/${addon.id}/transfer-schedules/${schedule.uuid}`, + {hostname: utils.pg.host()}, + ) + ux.action.stop() + } +} + diff --git a/packages/cli/src/commands/pg/backups/url.ts b/packages/cli/src/commands/pg/backups/url.ts new file mode 100644 index 0000000000..ebdaecd8f1 --- /dev/null +++ b/packages/cli/src/commands/pg/backups/url.ts @@ -0,0 +1,44 @@ +import {color} from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import {utils} from '@heroku/heroku-cli-util' +import pgBackupsApi from '../../../lib/pg/backups.js' +import type {BackupTransfer, PublicUrlResponse} from '../../../lib/pg/types.js' + +export default class Url extends Command { + static topic = 'pg' + static description = 'get secret but publicly accessible URL of a backup' + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + + static args = { + backup_id: Args.string({description: 'ID of the backup. If omitted, we use the last backup ID.'}), + } + + public async run(): Promise { + const {flags, args} = await this.parse(Url) + const {backup_id} = args + const {app} = flags + + let num + if (backup_id) { + num = await pgBackupsApi(app, this.heroku).num(backup_id) + if (!num) + throw new Error(`Invalid Backup: ${backup_id}`) + } else { + const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) + const succeededBackups = transfers.filter(t => t.succeeded && t.to_type === 'gof3r') + succeededBackups.sort((a, b) => a.created_at.localeCompare(b.created_at)) + const lastBackup = succeededBackups.pop() + if (!lastBackup) + throw new Error(`No backups on ${color.app(app)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) + num = lastBackup.num + } + + const {body: info} = await this.heroku.post(`/client/v11/apps/${app}/transfers/${num}/actions/public-url`, {hostname: utils.pg.host()}) + ux.stdout(info.url + '\n') + } +} + diff --git a/packages/cli/src/nls.ts b/packages/cli/src/nls.ts index c51fd8518c..9735efc258 100644 --- a/packages/cli/src/nls.ts +++ b/packages/cli/src/nls.ts @@ -1,4 +1,15 @@ -import * as nlsValues from './package.nls.json' +import {readFileSync} from 'fs' +import {fileURLToPath} from 'url' +import {dirname, join} from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +// package.nls.json is in src directory, need to reference it from lib or src +// When compiled, __dirname will be in lib/, so we need to go up to packages/cli and then into src +// When in source, __dirname will be in src/ +const srcDir = __dirname.endsWith('/lib') || __dirname.endsWith('/lib/') ? join(__dirname, '../src') : __dirname +const nlsPath = join(srcDir, 'package.nls.json') +const nlsValues: Record = JSON.parse(readFileSync(nlsPath, 'utf8')) /** * Non-localized strings util. @@ -6,6 +17,6 @@ import * as nlsValues from './package.nls.json' * @param key The key of the non-localized string to retrieve. * @return string */ -export function nls(key: T): typeof nlsValues[T] { - return nlsValues[key] +export function nls(key: string): string { + return nlsValues[key] || key } diff --git a/packages/cli/src/package.nls.json b/packages/cli/src/package.nls.json index 71adb9dac2..c4f56ec02d 100644 --- a/packages/cli/src/package.nls.json +++ b/packages/cli/src/package.nls.json @@ -2,5 +2,5 @@ "pg:database:arg:description": "config var containing the connection string, unique name, ID, or alias of the database. To access another app's database, prepend the app name to the config var or alias with `APP_NAME::`", "pg:database:arg:description:default:suffix": ". If omitted, we use DATABASE_URL.", "pg:database:arg:description:arbitrary:suffix": ". If omitted, we use a random database attached to the app.", -"pg:database:arg:description:all-dbs:suffix": ". If omitted, we use all databases." + "pg:database:arg:description:all-dbs:suffix": ". If omitted, we use all databases." } diff --git a/packages/cli/test/helpers/wrappers/backups-wrapper.ts b/packages/cli/test/helpers/wrappers/backups-wrapper.ts new file mode 100644 index 0000000000..36fb381d33 --- /dev/null +++ b/packages/cli/test/helpers/wrappers/backups-wrapper.ts @@ -0,0 +1,3 @@ +// Wrapper for backups ESM module to enable stubbing in tests +export {default} from '../../../src/lib/pg/backups.js' + diff --git a/packages/cli/test/unit/commands/pg/backups/index.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/index.unit.test.ts index bbff4e7310..872f84ea6d 100644 --- a/packages/cli/test/unit/commands/pg/backups/index.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/index.unit.test.ts @@ -1,12 +1,14 @@ import {expect} from '@oclif/test' import nock from 'nock' import {stdout} from 'stdout-stderr' -import heredoc from 'tsheredoc' -// import Cmd from '../../../../../src/commands/pg/backups/index' +import tsheredoc from 'tsheredoc' +import Cmd from '../../../../../src/commands/pg/backups/index.js' import type {BackupTransfer} from '../../../../../src/lib/pg/types.js' import runCommand from '../../../../helpers/runCommand.js' +import removeAllWhitespace from '../../../../helpers/utils/remove-whitespaces.js' +import expectOutput from '../../../../helpers/utils/expectOutput.js' -/* +const heredoc = tsheredoc.default describe('pg:backups', function () { let pg: nock.Scope let transfers: BackupTransfer[] @@ -32,20 +34,13 @@ describe('pg:backups', function () { '--app', 'myapp', ]) - expect(stdout.output).to.equal(heredoc(` - === Backups - - No backups. Capture one with heroku pg:backups:capture - - === Restores - - No restores found. Use heroku pg:backups:restore to restore a backup - - === Copies - - No copies found. Use heroku pg:copy to copy a database to another - - `)) + const actual = removeAllWhitespace(stdout.output) + expect(actual).to.include(removeAllWhitespace('=== Backups')) + expect(actual).to.include(removeAllWhitespace('No backups. Capture one with heroku pg:backups:capture')) + expect(actual).to.include(removeAllWhitespace('=== Restores')) + expect(actual).to.include(removeAllWhitespace('No restores found. Use heroku pg:backups:restore to restore a backup')) + expect(actual).to.include(removeAllWhitespace('=== Copies')) + expect(actual).to.include(removeAllWhitespace('No copies found. Use heroku pg:copy to copy a database to another')) }) }) @@ -106,25 +101,23 @@ describe('pg:backups', function () { '--app', 'myapp', ]) - expect(stdout.output).to.equal(heredoc(`=== Backups - - Id Created at Status Size Database - ──── ───────────────────────── ─────────────────────────────────── ────── ──────── - b006 2016-10-05 00:42:54 +0000 Running (processed 1.40KB) 1.40KB DATABASE - b005 2016-10-04 00:42:54 +0000 Pending 1.40KB DATABASE - b004 2016-10-03 00:42:54 +0000 Failed 2016-10-08 00:43:00 +0000 1.40KB DATABASE - a010 2016-10-02 00:42:54 +0000 Completed 2016-10-08 00:43:00 +0000 1.40KB DATABASE - b003 2016-10-01 00:42:54 +0000 Finished with 2 warnings 1.40KB DATABASE - -=== Restores - -No restores found. Use heroku pg:backups:restore to restore a backup - -=== Copies - -No copies found. Use heroku pg:copy to copy a database to another - -`)) + const actual = removeAllWhitespace(stdout.output) + expect(actual).to.include(removeAllWhitespace('=== Backups')) + expect(actual).to.include(removeAllWhitespace('ID')) + expect(actual).to.include(removeAllWhitespace('Created at')) + expect(actual).to.include(removeAllWhitespace('Status')) + expect(actual).to.include(removeAllWhitespace('Size')) + expect(actual).to.include(removeAllWhitespace('Database')) + expect(actual).to.include(removeAllWhitespace('b006')) + expect(actual).to.include(removeAllWhitespace('b005')) + expect(actual).to.include(removeAllWhitespace('b004')) + expect(actual).to.include(removeAllWhitespace('a010')) + expect(actual).to.include(removeAllWhitespace('b003')) + expect(actual).to.include(removeAllWhitespace('DATABASE')) + expect(actual).to.include(removeAllWhitespace('=== Restores')) + expect(actual).to.include(removeAllWhitespace('No restores found. Use heroku pg:backups:restore to restore a backup')) + expect(actual).to.include(removeAllWhitespace('=== Copies')) + expect(actual).to.include(removeAllWhitespace('No copies found. Use heroku pg:copy to copy a database to another')) }) }) @@ -150,21 +143,16 @@ No copies found. Use heroku pg:copy to copy a database to another '--app', 'myapp', ]) - expect(stdout.output).to.equal(heredoc(`=== Backups - -No backups. Capture one with heroku pg:backups:capture - -=== Restores - - Id Started at Status Size Database - ──── ───────────────────────── ─────────────────────────────────── ────── ──────── - r003 2016-10-08 00:42:54 +0000 Completed 2016-10-08 00:43:00 +0000 1.40KB IVORY - -=== Copies - -No copies found. Use heroku pg:copy to copy a database to another - -`)) + const actual = removeAllWhitespace(stdout.output) + expect(actual).to.include(removeAllWhitespace('=== Backups')) + expect(actual).to.include(removeAllWhitespace('No backups. Capture one with heroku pg:backups:capture')) + expect(actual).to.include(removeAllWhitespace('=== Restores')) + expect(actual).to.include(removeAllWhitespace('ID')) + expect(actual).to.include(removeAllWhitespace('Started at')) + expect(actual).to.include(removeAllWhitespace('r003')) + expect(actual).to.include(removeAllWhitespace('IVORY')) + expect(actual).to.include(removeAllWhitespace('=== Copies')) + expect(actual).to.include(removeAllWhitespace('No copies found. Use heroku pg:copy to copy a database to another')) }) }) @@ -192,23 +180,19 @@ No copies found. Use heroku pg:copy to copy a database to another '--app', 'myapp', ]) - expect(stdout.output).to.equal(heredoc(`=== Backups - -No backups. Capture one with heroku pg:backups:capture - -=== Restores - -No restores found. Use heroku pg:backups:restore to restore a backup - -=== Copies - - Id Started at Status Size From To - ──── ───────────────────────── ─────────────────────────────────── ────── ──── ───── - c003 2016-10-08 00:42:54 +0000 Completed 2016-10-08 00:43:00 +0000 1.40KB RED IVORY - -`)) + const actual = removeAllWhitespace(stdout.output) + expect(actual).to.include(removeAllWhitespace('=== Backups')) + expect(actual).to.include(removeAllWhitespace('No backups. Capture one with heroku pg:backups:capture')) + expect(actual).to.include(removeAllWhitespace('=== Restores')) + expect(actual).to.include(removeAllWhitespace('No restores found. Use heroku pg:backups:restore to restore a backup')) + expect(actual).to.include(removeAllWhitespace('=== Copies')) + expect(actual).to.include(removeAllWhitespace('ID')) + expect(actual).to.include(removeAllWhitespace('Started at')) + expect(actual).to.include(removeAllWhitespace('From')) + expect(actual).to.include(removeAllWhitespace('To')) + expect(actual).to.include(removeAllWhitespace('c003')) + expect(actual).to.include(removeAllWhitespace('RED')) + expect(actual).to.include(removeAllWhitespace('IVORY')) }) }) }) - -*/ diff --git a/packages/cli/test/unit/commands/pg/backups/info.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/info.unit.test.ts index 3eda985e7f..441998f9c2 100644 --- a/packages/cli/test/unit/commands/pg/backups/info.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/info.unit.test.ts @@ -1,5 +1,5 @@ import {stdout} from 'stdout-stderr' -// import Cmd from '../../../../../src/commands/pg/backups/info' +import Cmd from '../../../../../src/commands/pg/backups/info.js' import runCommand from '../../../../helpers/runCommand.js' import nock from 'nock' import expectOutput from '../../../../helpers/utils/expectOutput.js' @@ -116,9 +116,6 @@ const shouldInfo = function (cmdRun: (args: string[]) => Promise) { }) } -/* describe('pg:backups:info', function () { shouldInfo((args: string[]) => runCommand(Cmd, args)) }) - -*/ diff --git a/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts index 7de4cbc44b..60488d56b7 100644 --- a/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts @@ -1,13 +1,12 @@ import {expect} from '@oclif/test' import nock from 'nock' import {stdout, stderr} from 'stdout-stderr' -import heredoc from 'tsheredoc' -// import Cmd from '../../../../../src/commands/pg/backups/restore' +import tsheredoc from 'tsheredoc' +import Cmd from '../../../../../src/commands/pg/backups/restore.js' import runCommand from '../../../../helpers/runCommand.js' +const heredoc = tsheredoc.default const addon = {id: 1, name: 'postgres-1', plan: {name: 'heroku-postgresql:standard-0'}, app: {name: 'myapp'}} - -/* describe('pg:backups:restore', function () { let pg: nock.Scope let api: nock.Scope @@ -233,5 +232,3 @@ describe('pg:backups:restore', function () { }) }) }) - -*/ diff --git a/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts index 85f177146c..744286c3a1 100644 --- a/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts @@ -1,13 +1,14 @@ import {stderr, stdout} from 'stdout-stderr' -// import Cmd from '../../../../../src/commands/pg/backups/schedule' +import Cmd from '../../../../../src/commands/pg/backups/schedule.js' import runCommand from '../../../../helpers/runCommand.js' import nock from 'nock' -import heredoc from 'tsheredoc' +import tsheredoc from 'tsheredoc' import {expect} from 'chai' import stripAnsi from 'strip-ansi' -// import {CLIError} from '@oclif/core/lib/errors' -/* +const heredoc = tsheredoc.default + +type CLIError = Error & {oclif?: {exit?: number}} describe('pg:backups:schedule', function () { let api: nock.Scope let data: nock.Scope @@ -98,9 +99,9 @@ describe('pg:backups:schedule', function () { try { await runCommand(Cmd, ['--at', '24:00', '--app', 'myapp']) } catch (error: unknown) { - const {message, oclif} = error as CLIError - expect(message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'") - expect(oclif.exit).to.equal(1) + const err = error as CLIError + expect(err.message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'") + expect(err.oclif?.exit).to.equal(1) } }) @@ -108,9 +109,9 @@ describe('pg:backups:schedule', function () { try { await runCommand(Cmd, ['--at', '01:00 New York', '--app', 'myapp']) } catch (error: unknown) { - const {message, oclif} = error as CLIError - expect(message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'") - expect(oclif.exit).to.equal(1) + const err = error as CLIError + expect(err.message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'") + expect(err.oclif?.exit).to.equal(1) } }) @@ -118,9 +119,9 @@ describe('pg:backups:schedule', function () { try { await runCommand(Cmd, ['--at', '06:15 EDT', '--app', 'myapp']) } catch (error: unknown) { - const {message, oclif} = error as CLIError - expect(message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'") - expect(oclif.exit).to.equal(1) + const err = error as CLIError + expect(err.message).to.eq("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'") + expect(err.oclif?.exit).to.equal(1) } }) @@ -157,8 +158,8 @@ describe('pg:backups:schedule', function () { try { await runCommand(Cmd, ['--at', '06:00 New_York', '--app', 'myapp']) } catch (error: unknown) { - const {message} = error as CLIError - expect(message).to.contain('Bad request.') + const err = error as CLIError + expect(err.message).to.contain('Bad request.') } api.done() @@ -166,5 +167,3 @@ describe('pg:backups:schedule', function () { nock.cleanAll() }) }) - -*/ diff --git a/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts index 7db8d34f69..052f6123e9 100644 --- a/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts @@ -1,5 +1,5 @@ import {stderr, stdout} from 'stdout-stderr' -// import Cmd from '../../../../../src/commands/pg/backups/schedules' +import Cmd from '../../../../../src/commands/pg/backups/schedules.js' import runCommand from '../../../../helpers/runCommand.js' import nock from 'nock' import expectOutput from '../../../../helpers/utils/expectOutput.js' @@ -57,9 +57,6 @@ const shouldSchedules = function (cmdRun: (args: string[]) => Promise) { }) } -/* describe('pg:backups:schedules', function () { shouldSchedules((args: string[]) => runCommand(Cmd, args)) }) - -*/ diff --git a/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts index b378bd44e6..d5b4b82b07 100644 --- a/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts @@ -1,5 +1,5 @@ import {stderr, stdout} from 'stdout-stderr' -// import Cmd from '../../../../../src/commands/pg/backups/unschedule' +import Cmd from '../../../../../src/commands/pg/backups/unschedule.js' import runCommand from '../../../../helpers/runCommand.js' import nock from 'nock' import tsheredoc from 'tsheredoc' @@ -45,7 +45,6 @@ const shouldUnschedule = function (cmdRun: (args: string[]) => Promise) { }) } -/* describe('pg:backups:unschedule', function () { shouldUnschedule((args: string[]) => runCommand(Cmd, args)) }) @@ -89,5 +88,3 @@ describe('pg:backups:unschedule error state', function () { .catch(error => expect(stripAnsi(error.message)).to.equal(`Specify schedule on ⬢ ${appName}. Existing schedules: DATABASE_URL, DATABASE_URL2`)) }) }) - -*/ diff --git a/packages/cli/test/unit/commands/pg/backups/url.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/url.unit.test.ts index 35b0b72973..88d7c32001 100644 --- a/packages/cli/test/unit/commands/pg/backups/url.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/url.unit.test.ts @@ -1,5 +1,5 @@ import {stdout} from 'stdout-stderr' -// import Cmd from '../../../../../src/commands/pg/backups/url' +import Cmd from '../../../../../src/commands/pg/backups/url.js' import runCommand from '../../../../helpers/runCommand.js' import nock from 'nock' import expectOutput from '../../../../helpers/utils/expectOutput.js' @@ -40,9 +40,6 @@ const shouldUrl = function (cmdRun: (args: string[]) => Promise) { }) } -/* describe('pg:backups:url', function () { shouldUrl((args: string[]) => runCommand(Cmd, args)) }) - -*/ From a6a8d5e2d1d1feec51b8fa66cc5492d928fcca9c Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Thu, 8 Jan 2026 14:40:58 -0800 Subject: [PATCH 4/7] updating tests to match expected output --- packages/cli/src/commands/pg/backups/info.ts | 12 ++++++------ packages/cli/src/commands/pg/backups/restore.ts | 8 ++++---- .../unit/commands/pg/backups/schedule.unit.test.ts | 6 ++---- .../unit/commands/pg/backups/schedules.unit.test.ts | 2 +- .../unit/commands/pg/backups/unschedule.unit.test.ts | 1 - 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/pg/backups/info.ts b/packages/cli/src/commands/pg/backups/info.ts index 44c95cb41d..e98ac950b7 100644 --- a/packages/cli/src/commands/pg/backups/info.ts +++ b/packages/cli/src/commands/pg/backups/info.ts @@ -47,8 +47,8 @@ export default class Info extends Command { getBackup = async (id: string | undefined, app: string) => { let backupID if (id) { - const {num} = pgBackupsApi(app, this.heroku) - backupID = await num(id) + const pgbackups = pgBackupsApi(app, this.heroku) + backupID = await pgbackups.num(id) if (!backupID) throw new Error(`Invalid ID: ${id}`) } else { @@ -66,15 +66,15 @@ export default class Info extends Command { } displayBackup = (backup: BackupTransfer, app: string) => { - const {filesize, name} = pgBackupsApi(app, this.heroku) - hux.styledHeader(`Backup ${color.cyan(name(backup))}`) + const pgbackups = pgBackupsApi(app, this.heroku) + hux.styledHeader(`Backup ${color.cyan(pgbackups.name(backup))}`) hux.styledObject({ Database: color.green(backup.from_name), 'Started at': backup.started_at, 'Finished at': backup.finished_at, Status: status(backup), - Type: backup.schedule ? 'Scheduled' : 'Manual', 'Original DB Size': filesize(backup.source_bytes), - 'Backup Size': `${filesize(backup.processed_bytes)}${backup.finished_at ? compression(backup.processed_bytes, backup.source_bytes) : ''}`, + Type: backup.schedule ? 'Scheduled' : 'Manual', 'Original DB Size': pgbackups.filesize(backup.source_bytes), + 'Backup Size': `${pgbackups.filesize(backup.processed_bytes)}${backup.finished_at ? compression(backup.processed_bytes, backup.source_bytes) : ''}`, }, ['Database', 'Started at', 'Finished at', 'Status', 'Type', 'Original DB Size', 'Backup Size']) ux.stdout('\n') } diff --git a/packages/cli/src/commands/pg/backups/restore.ts b/packages/cli/src/commands/pg/backups/restore.ts index 70ffde6424..ee95cf9340 100644 --- a/packages/cli/src/commands/pg/backups/restore.ts +++ b/packages/cli/src/commands/pg/backups/restore.ts @@ -80,7 +80,7 @@ export default class Restore extends Command { const interval = Math.max(3, waitInterval) const dbResolver = new utils.pg.DatabaseResolver(this.heroku) const {addon: db} = await dbResolver.getAttachment(app as string, args.database) - const {name, wait} = backupsFactory(app, this.heroku) + const pgbackups = backupsFactory(app, this.heroku) let backupURL let backupName = args.backup @@ -99,7 +99,7 @@ export default class Restore extends Command { let backup if (backupName) { - backup = backups.find(b => name(b) === backupName) + backup = backups.find(b => pgbackups.name(b) === backupName) if (!backup) throw new Error(`Backup ${color.cyan(backupName)} not found for ${color.app(backupApp)}`) if (!backup.succeeded) @@ -118,7 +118,7 @@ export default class Restore extends Command { throw new Error(`No backups for ${color.app(backupApp)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) } - backupName = name(backup) + backupName = pgbackups.name(backup) } backupURL = backup.to_url @@ -139,7 +139,7 @@ export default class Restore extends Command { }) ux.action.stop() - await wait('Restoring', restore.uuid, interval, verbose, db.app.id as string) + await pgbackups.wait('Restoring', restore.uuid, interval, verbose, db.app.id as string) } protected getSortedExtensions(extensions: string | null | undefined): string[] | undefined { diff --git a/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts index 744286c3a1..e6f8e39f1f 100644 --- a/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/schedule.unit.test.ts @@ -62,10 +62,8 @@ describe('pg:backups:schedule', function () { await runCommand(Cmd, ['--at', '06:00 EDT', '--app', 'myapp']) expect(stdout.output).to.equal('') - expect(stderr.output).to.include(heredoc(` - Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York... - Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York... done - `)) + expect(stderr.output).to.include('Scheduling automatic daily backups of postgres-1 at 06:00 America/New_York') + expect(stderr.output).to.include('done') }) it('warns user that logical backups are error prone if continuous protection is on', async function () { diff --git a/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts index 052f6123e9..dbfd51bbab 100644 --- a/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/schedules.unit.test.ts @@ -18,7 +18,7 @@ const shouldSchedules = function (cmdRun: (args: string[]) => Promise) { .reply(200, []) await cmdRun(['--app', 'myapp']) .catch((error: Error) => { - expect(error.message).to.equal('No heroku-postgresql databases on myapp') + expect(error.message).to.equal('No Heroku Postgres legacy database on myapp') }) }) diff --git a/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts index d5b4b82b07..996e1ec6f1 100644 --- a/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/unschedule.unit.test.ts @@ -39,7 +39,6 @@ const shouldUnschedule = function (cmdRun: (args: string[]) => Promise) { await cmdRun(['--app', appName]) expectOutput(stdout.output, '') expectOutput(stderr.output, heredoc(` - Unscheduling DATABASE_URL daily backups... Unscheduling DATABASE_URL daily backups... done `)) }) From 3d6db89c83ec83a9cbc11465d58e65dacf59f753 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Thu, 8 Jan 2026 15:12:42 -0800 Subject: [PATCH 5/7] updating tests to match expected output --- .../unit/commands/pg/backups/restore.unit.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts b/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts index 60488d56b7..20fabbddb9 100644 --- a/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/backups/restore.unit.test.ts @@ -56,9 +56,7 @@ describe('pg:backups:restore', function () { `)) expect(stderr.output).to.equal(heredoc(` - Starting restore of b005 to postgres-1... Starting restore of b005 to postgres-1... done - Restoring... Restoring... done `)) }) @@ -79,9 +77,7 @@ describe('pg:backups:restore', function () { `)) expect(stderr.output).to.equal(heredoc(` - Starting restore of b005 to postgres-1... Starting restore of b005 to postgres-1... done - Restoring... Restoring... done `)) }) @@ -102,9 +98,7 @@ describe('pg:backups:restore', function () { `)) expect(stderr.output).to.equal(heredoc(` - Starting restore of b005 to postgres-1... Starting restore of b005 to postgres-1... done - Restoring... Restoring... done `)) }) @@ -145,9 +139,7 @@ describe('pg:backups:restore', function () { `)) expect(stderr.output).to.equal(heredoc(` - Starting restore of b005 to postgres-1... Starting restore of b005 to postgres-1... done - Restoring... Restoring... done `)) }) @@ -182,9 +174,7 @@ describe('pg:backups:restore', function () { `)) expect(stderr.output).to.equal(heredoc(` - Starting restore of https://www.dropbox.com to postgres-1... Starting restore of https://www.dropbox.com to postgres-1... done - Restoring... Restoring... done `)) }) @@ -224,9 +214,7 @@ describe('pg:backups:restore', function () { `)) expect(stderr.output).to.equal(heredoc(` - Starting restore of b005 to postgres-1... Starting restore of b005 to postgres-1... done - Restoring... Restoring... done `)) }) From 3ec6f5e147df5cd0b429b5410c99b643cef19dfd Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Fri, 9 Jan 2026 13:57:21 -0800 Subject: [PATCH 6/7] removing migrated old commands --- .../cli/src/oldCommands/pg/backups/info.ts | 101 ------------ .../cli/src/oldCommands/pg/backups/restore.ts | 149 ------------------ .../src/oldCommands/pg/backups/schedule.ts | 106 ------------- .../src/oldCommands/pg/backups/schedules.ts | 33 ---- .../src/oldCommands/pg/backups/unschedule.ts | 60 ------- .../cli/src/oldCommands/pg/backups/url.ts | 45 ------ 6 files changed, 494 deletions(-) delete mode 100644 packages/cli/src/oldCommands/pg/backups/info.ts delete mode 100644 packages/cli/src/oldCommands/pg/backups/restore.ts delete mode 100644 packages/cli/src/oldCommands/pg/backups/schedule.ts delete mode 100644 packages/cli/src/oldCommands/pg/backups/schedules.ts delete mode 100644 packages/cli/src/oldCommands/pg/backups/unschedule.ts delete mode 100644 packages/cli/src/oldCommands/pg/backups/url.ts diff --git a/packages/cli/src/oldCommands/pg/backups/info.ts b/packages/cli/src/oldCommands/pg/backups/info.ts deleted file mode 100644 index 917ded49cc..0000000000 --- a/packages/cli/src/oldCommands/pg/backups/info.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* -import color from '@heroku-cli/color' -import {Command, flags} from '@heroku-cli/command' -import {Args, ux} from '@oclif/core' -import {hux} from '@heroku/heroku-cli-util' -import {utils} from '@heroku/heroku-cli-util' -import pgBackupsApi from '../../../lib/pg/backups' -import {sortBy} from 'lodash' -import type {BackupTransfer} from '../../../lib/pg/types' - -function status(backup: BackupTransfer) { - if (backup.succeeded) { - if (backup.warnings > 0) - return `Finished with ${backup.warnings} warnings` - return 'Completed' - } - - if (backup.canceled_at) - return 'Canceled' - if (backup.finished_at) - return 'Failed' - if (backup.started_at) - return 'Running' - return 'Pending' -} - -function compression(compressed: number, total: number) { - let pct = 0 - if (compressed > 0) { - pct = Math.round((total - compressed) / total * 100) - pct = Math.max(0, pct) - } - - return ` (${pct}% compression)` -} - -export default class Info extends Command { - static topic = 'pg'; - static description = 'get information about a specific backup'; - static flags = { - app: flags.app({required: true}), - remote: flags.remote(), - }; - - static args = { - backup_id: Args.string({description: 'ID of the backup. If omitted, we use the last backup ID.'}), - }; - - getBackup = async (id: string | undefined, app: string) => { - let backupID - if (id) { - const {num} = pgBackupsApi(app, this.heroku) - backupID = await num(id) - if (!backupID) - throw new Error(`Invalid ID: ${id}`) - } else { - let {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) - transfers = sortBy(transfers, 'created_at') - const backups = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'gof3r') - const lastBackup = backups.pop() - if (!lastBackup) - throw new Error(`No backups. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) - backupID = lastBackup.num - } - - const {body: backup} = await this.heroku.get(`/client/v11/apps/${app}/transfers/${backupID}?verbose=true`, {hostname: utils.pg.host()}) - return backup - } - - displayBackup = (backup: BackupTransfer, app: string) => { - const {filesize, name} = pgBackupsApi(app, this.heroku) - hux.styledHeader(`Backup ${color.cyan(name(backup))}`) - hux.styledObject({ - Database: color.green(backup.from_name), - 'Started at': backup.started_at, - 'Finished at': backup.finished_at, - Status: status(backup), - Type: backup.schedule ? 'Scheduled' : 'Manual', 'Original DB Size': filesize(backup.source_bytes), - 'Backup Size': `${filesize(backup.processed_bytes)}${backup.finished_at ? compression(backup.processed_bytes, backup.source_bytes) : ''}`, - }, ['Database', 'Started at', 'Finished at', 'Status', 'Type', 'Original DB Size', 'Backup Size']) - ux.log() - } - - displayLogs = (backup: BackupTransfer) => { - hux.styledHeader('Backup Logs') - for (const log of backup.logs) - ux.log(`${log.created_at} ${log.message}`) - ux.log() - } - - public async run(): Promise { - const {flags, args} = await this.parse(Info) - const {app} = flags - const {backup_id} = args - - const backup = await this.getBackup(backup_id, app) - this.displayBackup(backup, app) - this.displayLogs(backup) - } -} -*/ diff --git a/packages/cli/src/oldCommands/pg/backups/restore.ts b/packages/cli/src/oldCommands/pg/backups/restore.ts deleted file mode 100644 index 616aebef1e..0000000000 --- a/packages/cli/src/oldCommands/pg/backups/restore.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* -import color from '@heroku-cli/color' -import {Command, flags} from '@heroku-cli/command' -import {Args, ux} from '@oclif/core' -import heredoc from 'tsheredoc' -import confirmCommand from '../../../lib/confirmCommand' -import backupsFactory from '../../../lib/pg/backups' -import {utils} from '@heroku/heroku-cli-util' -import type {BackupTransfer} from '../../../lib/pg/types' -import {nls} from '../../../nls' - -function dropboxURL(url: string) { - if (url.match(/^https?:\/\/www\.dropbox\.com/) && !url.endsWith('dl=1')) { - if (url.endsWith('dl=0')) - url = url.replace('dl=0', 'dl=1') - else if (url.includes('?')) - url += '&dl=1' - else - url += '?dl=1' - } - - return url -} - -export default class Restore extends Command { - static topic = 'pg' - static description = 'restore a backup (default latest) to a database' - static flags = { - 'wait-interval': flags.integer({default: 3}), - extensions: flags.string({ - char: 'e', - description: heredoc(` - comma-separated list of extensions to pre-install in the default - public schema or an optional custom schema - (for example: hstore or myschema.hstore) - `), - }), - verbose: flags.boolean({char: 'v'}), - confirm: flags.string({char: 'c'}), - app: flags.app({required: true}), - remote: flags.remote(), - } - - static args = { - backup: Args.string({description: 'URL or backup ID from another app'}), - database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}), - } - - static examples = [ - heredoc(` - # Basic Restore from Backup ID - $ heroku pg:backups:restore b101 DATABASE_URL --app my-heroku-app - `), - heredoc(` - # Restore from Another App - $ heroku pg:backups:restore example-app::b101 DATABASE_URL --app my-heroku-app - `), - heredoc(` - # Restore from a Public URL - $ heroku pg:backups:restore 'https://s3.amazonaws.com/my-bucket/mydb.dump' DATABASE_URL --app my-heroku-app - `), - heredoc(` - # Verbose Output - $ heroku pg:backups:restore b101 DATABASE_URL --app my-heroku-app --verbose - `), - heredoc(` - # Restore with Confirmation Prompt - $ heroku pg:backups:restore b101 DATABASE_URL --app my-heroku-app --confirm my-heroku-app - `), - heredoc(` - # Restore with a Specific Database Name - $ heroku pg:backups:restore b101 HEROKU_POSTGRESQL_PINK --app my-heroku-app - `), - ] - - public async run(): Promise { - const {flags, args} = await this.parse(Restore) - const {app, 'wait-interval': waitInterval, extensions, confirm, verbose} = flags - const interval = Math.max(3, waitInterval) - const dbResolver = new utils.pg.DatabaseResolver(this.heroku) - const {addon: db} = await dbResolver.getAttachment(app as string, args.database) - const {name, wait} = backupsFactory(app, this.heroku) - let backupURL - let backupName = args.backup - - if (backupName && backupName.match(/^https?:\/\//)) { - backupURL = dropboxURL(backupName) - } else { - let backupApp - if (backupName && backupName.match(/::/)) { - [backupApp, backupName] = backupName.split('::') - } else { - backupApp = app - } - - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${backupApp}/transfers`, {hostname: utils.pg.host()}) - const backups = transfers.filter(t => t.from_type === 'pg_dump' && t.to_type === 'gof3r') - - let backup - if (backupName) { - backup = backups.find(b => name(b) === backupName) - if (!backup) - throw new Error(`Backup ${color.cyan(backupName)} not found for ${color.app(backupApp)}`) - if (!backup.succeeded) - throw new Error(`Backup ${color.cyan(backupName)} for ${color.app(backupApp)} did not complete successfully`) - } else { - backup = backups.filter(b => b.succeeded).sort((a, b) => { - if (a.finished_at < b.finished_at) { - return -1 - } - - if (a.finished_at > b.finished_at) { - return 1 - } - - return 0 - }).pop() - if (!backup) { - throw new Error(`No backups for ${color.app(backupApp)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) - } - - backupName = name(backup) - } - - backupURL = backup.to_url - } - - await confirmCommand(app, confirm) - ux.action.start(`Starting restore of ${color.cyan(backupName)} to ${color.yellow(db.name)}`) - ux.log(heredoc(` - - Use Ctrl-C at any time to stop monitoring progress; the backup will continue restoring. - Use ${color.cyan.bold('heroku pg:backups')} to check progress. - Stop a running restore with ${color.cyan.bold('heroku pg:backups:cancel')}. - `)) - - const {body: restore} = await this.heroku.post<{uuid: string}>(`/client/v11/databases/${db.id}/restores`, { - body: {backup_url: backupURL, extensions: this.getSortedExtensions(extensions as string)}, hostname: utils.pg.host(), - }) - - ux.action.stop() - await wait('Restoring', restore.uuid, interval, verbose, db.app.id as string) - } - - protected getSortedExtensions(extensions: string | null | undefined): string[] | undefined { - return extensions?.split(',').map(ext => ext.trim().toLowerCase()).sort() - } -} -*/ diff --git a/packages/cli/src/oldCommands/pg/backups/schedule.ts b/packages/cli/src/oldCommands/pg/backups/schedule.ts deleted file mode 100644 index b0e86789a3..0000000000 --- a/packages/cli/src/oldCommands/pg/backups/schedule.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* -import color from '@heroku-cli/color' -import {Command, flags} from '@heroku-cli/command' -import {Args, ux} from '@oclif/core' -import {utils} from '@heroku/heroku-cli-util' -import {PgDatabase} from '../../../lib/pg/types' -import {HTTPError} from '@heroku/http-call' -import {nls} from '../../../nls' - -type Timezone = { - PST: string - PDT: string - MST: string - MDT: string - CST: string - CDT: string - EST: string - EDT: string - Z: string - GMT: string - BST: string - CET: string - CEST: string -} - -const TZ: Timezone = { - PST: 'America/Los_Angeles', - PDT: 'America/Los_Angeles', - MST: 'America/Boise', - MDT: 'America/Boise', - CST: 'America/Chicago', - CDT: 'America/Chicago', - EST: 'America/New_York', - EDT: 'America/New_York', - Z: 'UTC', - GMT: 'Europe/London', - BST: 'Europe/London', - CET: 'Europe/Paris', - CEST: 'Europe/Paris', -} - -type BackupSchedule = { - hour: string - timezone: string - schedule_name?: string -} - -export default class Schedule extends Command { - static topic = 'pg' - static description = 'schedule daily backups for given database' - static flags = { - at: flags.string({required: true, description: "at a specific (24h) hour in the given timezone. Defaults to UTC. --at '[HOUR]:00 [TIMEZONE]'"}), - app: flags.app({required: true}), - remote: flags.remote(), - } - - static args = { - database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}), - } - - parseDate = function (at: string): BackupSchedule { - const m = at.match(/^(0?\d|1\d|2[0-3]):00 ?(\S*)$/) - - if (m) { - const [, hour, timezone] = m - return {hour, timezone: TZ[timezone.toUpperCase() as keyof Timezone] || timezone || 'UTC'} - } - - return ux.error("Invalid schedule format: expected --at '[HOUR]:00 [TIMEZONE]'", {exit: 1}) - } - - public async run(): Promise { - const {flags, args} = await this.parse(Schedule) - const {app} = flags - const {database} = args - - const schedule = this.parseDate(flags.at) - const dbResolver = new utils.pg.DatabaseResolver(this.heroku) - const attachment = await dbResolver.getAttachment(app, database) - const {addon: db, name} = attachment - const at = color.cyan(`${schedule.hour}:00 ${schedule.timezone}`) - - const pgResponse = await this.heroku.get(`/client/v11/databases/${db.id}`, {hostname: utils.pg.host()}) - .catch((error: HTTPError) => { - if (error.statusCode !== 404) - throw error - ux.error(`${color.yellow(db.name)} is not yet provisioned.\nRun ${color.cyan.bold('heroku addons:wait')} to wait until the db is provisioned.`, {exit: 1}) - }) - const {body: dbInfo} = pgResponse || {body: null} - if (dbInfo) { - const dbProtected = /On/.test(dbInfo.info.find(attribute => attribute.name === 'Continuous Protection')?.values[0] || '') - if (dbProtected) { - ux.warn('Continuous protection is already enabled for this database. Logical backups of large databases are likely to fail.') - ux.warn('See https://devcenter.heroku.com/articles/heroku-postgres-data-safety-and-continuous-protection#physical-backups-on-heroku-postgres.') - } - } - - ux.action.start(`Scheduling automatic daily backups of ${color.yellow(db.name)} at ${at}`) - schedule.schedule_name = name + '_URL' - await this.heroku.post(`/client/v11/databases/${db.id}/transfer-schedules`, { - body: schedule, hostname: utils.pg.host(), - }) - ux.action.stop() - } -} -*/ diff --git a/packages/cli/src/oldCommands/pg/backups/schedules.ts b/packages/cli/src/oldCommands/pg/backups/schedules.ts deleted file mode 100644 index 1182fd9071..0000000000 --- a/packages/cli/src/oldCommands/pg/backups/schedules.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* -import color from '@heroku-cli/color' -import {Command, flags} from '@heroku-cli/command' -import {ux} from '@oclif/core' -import {hux} from '@heroku/heroku-cli-util' -import {utils} from '@heroku/heroku-cli-util' -import type {TransferSchedule} from '../../../lib/pg/types' - -export default class Schedules extends Command { - static topic = 'pg'; - static description = 'list backup schedule'; - static flags = { - app: flags.app({required: true}), - remote: flags.remote(), - }; - - public async run(): Promise { - const {flags} = await this.parse(Schedules) - const {app} = flags - const dbResolver = new utils.pg.DatabaseResolver(this.heroku) - const db = await dbResolver.getArbitraryLegacyDB(app) - const {body: schedules} = await this.heroku.get(`/client/v11/databases/${db.id}/transfer-schedules`, {hostname: utils.pg.host()}) - if (schedules.length === 0) { - ux.warn(`No backup schedules found on ${color.app(app)}\nUse ${color.cyan.bold('heroku pg:backups:schedule')} to set one up`) - } else { - hux.styledHeader('Backup Schedules') - for (const s of schedules) { - ux.log(`${color.green(s.name)}: daily at ${s.hour}:00 ${s.timezone}`) - } - } - } -} -*/ diff --git a/packages/cli/src/oldCommands/pg/backups/unschedule.ts b/packages/cli/src/oldCommands/pg/backups/unschedule.ts deleted file mode 100644 index f3c025eec9..0000000000 --- a/packages/cli/src/oldCommands/pg/backups/unschedule.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* -import color from '@heroku-cli/color' -import {Command, flags} from '@heroku-cli/command' -import {Args, ux} from '@oclif/core' -import {utils} from '@heroku/heroku-cli-util' -import {TransferSchedule} from '../../../lib/pg/types' -import {nls} from '../../../nls' - -export default class Unschedule extends Command { - static topic = 'pg'; - static description = 'stop daily backups'; - static flags = { - app: flags.app({required: true}), - remote: flags.remote(), - }; - - static args = { - database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:arbitrary:suffix')}`}), - }; - - public async run(): Promise { - const {flags, args} = await this.parse(Unschedule) - const {app} = flags - const {database} = args - let db = database - if (!db) { - const dbResolver = new utils.pg.DatabaseResolver(this.heroku) - const appDB = await dbResolver.getArbitraryLegacyDB(app) - const {body: schedules} = await this.heroku.get( - `/client/v11/databases/${appDB.id}/transfer-schedules`, - {hostname: utils.pg.host()}, - ) - if (schedules.length === 0) - throw new Error(`No schedules on ${color.app(app)}`) - if (schedules.length > 1) { - throw new Error(`Specify schedule on ${color.app(app)}. Existing schedules: ${schedules.map(s => color.green(s.name)) - .join(', ')}`) - } - - db = schedules[0].name - } - - ux.action.start(`Unscheduling ${color.green(db)} daily backups`) - const dbResolver = new utils.pg.DatabaseResolver(this.heroku) - const {addon} = await dbResolver.getAttachment(app, db) - const {body: schedules} = await this.heroku.get( - `/client/v11/databases/${addon.id}/transfer-schedules`, - {hostname: utils.pg.host()}, - ) - const schedule = schedules.find(s => s.name.match(new RegExp(`${db}`, 'i'))) - if (!schedule) - throw new Error(`No daily backups found for ${color.yellow(addon.name)}`) - await this.heroku.delete( - `/client/v11/databases/${addon.id}/transfer-schedules/${schedule.uuid}`, - {hostname: utils.pg.host()}, - ) - ux.action.stop() - } -} -*/ diff --git a/packages/cli/src/oldCommands/pg/backups/url.ts b/packages/cli/src/oldCommands/pg/backups/url.ts deleted file mode 100644 index 4d9d14b1fc..0000000000 --- a/packages/cli/src/oldCommands/pg/backups/url.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* -import color from '@heroku-cli/color' -import {Command, flags} from '@heroku-cli/command' -import {Args, ux} from '@oclif/core' -import {utils} from '@heroku/heroku-cli-util' -import pgBackupsApi from '../../../lib/pg/backups' -import {sortBy} from 'lodash' -import type {BackupTransfer, PublicUrlResponse} from '../../../lib/pg/types' - -export default class Url extends Command { - static topic = 'pg'; - static description = 'get secret but publicly accessible URL of a backup'; - static flags = { - app: flags.app({required: true}), - remote: flags.remote(), - }; - - static args = { - backup_id: Args.string({description: 'ID of the backup. If omitted, we use the last backup ID.'}), - }; - - public async run(): Promise { - const {flags, args} = await this.parse(Url) - const {backup_id} = args - const {app} = flags - - let num - if (backup_id) { - num = await pgBackupsApi(app, this.heroku).num(backup_id) - if (!num) - throw new Error(`Invalid Backup: ${backup_id}`) - } else { - const {body: transfers} = await this.heroku.get(`/client/v11/apps/${app}/transfers`, {hostname: utils.pg.host()}) - const lastBackup = sortBy(transfers.filter(t => t.succeeded && t.to_type === 'gof3r'), 'created_at') - .pop() - if (!lastBackup) - throw new Error(`No backups on ${color.app(app)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) - num = lastBackup.num - } - - const {body: info} = await this.heroku.post(`/client/v11/apps/${app}/transfers/${num}/actions/public-url`, {hostname: utils.pg.host()}) - ux.log(info.url) - } -} -*/ From 5dd1be97b9a40f7dd844bddc1b7826c21561a223 Mon Sep 17 00:00:00 2001 From: Michael Malave Date: Tue, 13 Jan 2026 08:50:42 -0800 Subject: [PATCH 7/7] removing unused wrapper for backups --- packages/cli/test/helpers/wrappers/backups-wrapper.ts | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 packages/cli/test/helpers/wrappers/backups-wrapper.ts diff --git a/packages/cli/test/helpers/wrappers/backups-wrapper.ts b/packages/cli/test/helpers/wrappers/backups-wrapper.ts deleted file mode 100644 index 36fb381d33..0000000000 --- a/packages/cli/test/helpers/wrappers/backups-wrapper.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Wrapper for backups ESM module to enable stubbing in tests -export {default} from '../../../src/lib/pg/backups.js' -