diff --git a/bin/run.js b/bin/run.js index ce2cc3a93c..00443a999c 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,7 +1,5 @@ #!/usr/bin/env -S node --no-deprecation -/* eslint-disable n/no-unpublished-bin */ - import {execute, settings} from '@oclif/core' // Enable performance tracking when oclif:perf is specified in DEBUG @@ -14,10 +12,9 @@ process.env.HEROKU_UPDATE_INSTRUCTIONS = process.env.HEROKU_UPDATE_INSTRUCTIONS const now = new Date() const cliStartTime = now.getTime() -// Skip telemetry entirely on Windows for performance (unless explicitly enabled) -const enableTelemetry = process.platform !== 'win32' || process.env.ENABLE_WINDOWS_TELEMETRY === 'true' +const {isTelemetryEnabled} = await import('../dist/lib/analytics-telemetry/telemetry-utils.js') -if (enableTelemetry) { +if (isTelemetryEnabled()) { // Dynamically import telemetry modules const {setupTelemetryHandlers} = await import('../dist/lib/analytics-telemetry/worker-client.js') const {computeDuration} = await import('../dist/lib/analytics-telemetry/telemetry-utils.js') @@ -26,7 +23,7 @@ if (enableTelemetry) { setupTelemetryHandlers({ cliStartTime, computeDuration, - enableTelemetry, + enableTelemetry: isTelemetryEnabled(), }) } diff --git a/package-lock.json b/package-lock.json index 506efd1cf7..bf98db2f24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "version": "11.1.1", "license": "ISC", "dependencies": { - "@heroku-cli/command": "^12.2.2", + "@heroku-cli/command": "^12.3.1", "@heroku-cli/notifications": "^1.2.6", "@heroku-cli/schema": "^1.0.25", "@heroku/buildpack-registry": "^1.0.1", - "@heroku/heroku-cli-util": "^10.7.0", + "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", "@heroku/socksv5": "^0.0.9", @@ -2038,16 +2038,16 @@ } }, "node_modules/@heroku-cli/command": { - "version": "12.2.2", - "resolved": "https://registry.npmjs.org/@heroku-cli/command/-/command-12.2.2.tgz", - "integrity": "sha512-tthuj4NM26fRz22bIIQzxaMFXPaElGpByIWpMyvD7fu7zijcyPnQauTkWsHm0xGdFHzJGGzWU0zWkc6AtaSJ4A==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@heroku-cli/command/-/command-12.3.1.tgz", + "integrity": "sha512-q97+aMJt2WbCLOtXcnrWt7MtfMr1U5BesLwXgOL3Fetw0FqJa7ARILRJ+I3qN/ic+TblcweS2Fu0kaLrhBvGFQ==", "license": "ISC", "dependencies": { "@heroku/http-call": "^5.4.0", "@oclif/core": "^4.3.0", "ansis": "^4", "debug": "^4.4.0", - "inquirer": "^8.2.6", + "inquirer": "^12.11.1", "netrc-parser": "^3.1.6", "open": "^11.0.0", "yargs-parser": "^20.2.9", @@ -2057,6 +2057,32 @@ "node": ">= 20" } }, + "node_modules/@heroku-cli/command/node_modules/inquirer": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz", + "integrity": "sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/prompts": "^7.10.1", + "@inquirer/type": "^3.0.10", + "mute-stream": "^2.0.0", + "run-async": "^4.0.6", + "rxjs": "^7.8.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@heroku-cli/command/node_modules/is-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -2092,6 +2118,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@heroku-cli/command/node_modules/run-async": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", + "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/@heroku-cli/command/node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -2160,9 +2195,9 @@ "license": "MIT" }, "node_modules/@heroku/heroku-cli-util": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/@heroku/heroku-cli-util/-/heroku-cli-util-10.7.0.tgz", - "integrity": "sha512-pGYK3DjqEzp4WXktPGgmEnZv+jbnbWHAPnVv84lWoMoPMuKV6NeNnVYUFr6Y+04tFRCDmTWEMKXSvfIGJsXkFQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@heroku/heroku-cli-util/-/heroku-cli-util-10.8.0.tgz", + "integrity": "sha512-/P6eQPU6TaI/BY9+56NnAOPl9VSCo19kwk0CC2um2xDMiMA38Eyzz+wSo0sNxHeSLAIoDPM6n40VagVxwsN/tQ==", "license": "ISC", "dependencies": { "@heroku-cli/command": "^12.2.0", @@ -2172,6 +2207,7 @@ "ansis": "^4.1.0", "debug": "^4.4.0", "inquirer": "^12.6.1", + "open": "^11", "printf": "^0.6.1", "tsheredoc": "^1.0.1", "tunnel-ssh": "5.2.0" @@ -2206,6 +2242,41 @@ } } }, + "node_modules/@heroku/heroku-cli-util/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@heroku/heroku-cli-util/node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@heroku/heroku-cli-util/node_modules/run-async": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", @@ -2215,6 +2286,22 @@ "node": ">=0.12.0" } }, + "node_modules/@heroku/heroku-cli-util/node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@heroku/http-call": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@heroku/http-call/-/http-call-5.5.1.tgz", diff --git a/package.json b/package.json index ef24f5f191..097a82cb7b 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "bin": "./bin/run.js", "bugs": "https://github.com/heroku/cli/issues", "dependencies": { - "@heroku-cli/command": "^12.2.2", + "@heroku-cli/command": "^12.3.1", "@heroku-cli/notifications": "^1.2.6", "@heroku-cli/schema": "^1.0.25", "@heroku/buildpack-registry": "^1.0.1", - "@heroku/heroku-cli-util": "^10.7.0", + "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", "@heroku/socksv5": "^0.0.9", diff --git a/src/commands/addons/index.ts b/src/commands/addons/index.ts index 1bd07484d7..46bfc9405e 100644 --- a/src/commands/addons/index.ts +++ b/src/commands/addons/index.ts @@ -1,16 +1,65 @@ -import {color, hux} from '@heroku/heroku-cli-util' import {APIClient, Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' - import _ from 'lodash' import {formatPrice, formatState, grandfatheredPrice} from '../../lib/addons/util.js' const topic = 'addons' +export default class Addons extends Command { + static description = `Lists your add-ons and attachments. + + The default filter applied depends on whether you are in a Heroku app + directory. If so, the --app flag is implied. If not, the default of --all + is implied. Explicitly providing either flag overrides the default + behavior. + ` + static examples = [ + `${color.command(`heroku ${topic} --all`)}`, + `${color.command(`heroku ${topic} --app acme-inc-www`)}`, + ] + + static flags = { + all: flags.boolean({char: 'A', description: 'show add-ons and attachments for all accessible apps'}), + app: flags.app(), + json: flags.boolean({description: 'return add-ons in json format'}), + remote: flags.remote(), + } + + static topic = topic + + static usage = 'addons [--all|--app APP]' + + public async run(): Promise { + const {flags} = await this.parse(Addons) + const {all, app, json} = flags + + if (!all && app) { + const addons = await addonGetter(this.heroku, app) + if (json) + displayJSON(addons) + else + displayForApp(app, addons) + } else { + const addons = await addonGetter(this.heroku) + if (json) + displayJSON(addons) + else + displayAll(addons) + } + } +} + +export function renderAttachment(attachment: Heroku.AddOnAttachment, app: string, isLast = false): string { + const line = isLast ? '\u2514\u2500' : '\u251C\u2500' + const attName = formatAttachment(attachment, attachment.app?.name !== app) + return ` ${color.gray(line)} ${attName}` +} + async function addonGetter(api: APIClient, app?: string) { - let attachmentsResponse: ReturnType> | null = null + let attachmentsResponse: null | ReturnType> = null let addonsResponse: ReturnType> if (app) { // don't display attachments globally addonsResponse = api.get(`/apps/${app}/addons`, { @@ -96,21 +145,21 @@ function displayAll(addons: Heroku.AddOn[]) { Plan: { get({plan}) { if (plan === undefined) - return color.dim('?') + return color.inactive('?') return plan.name }, }, Price: { get({plan}) { if (plan?.price === undefined) - return color.dim('?') + return color.inactive('?') return formatPrice({hourly: true, price: plan?.price}) }, }, 'Max Price': { get({plan}) { if (plan?.price === undefined) - return color.dim('?') + return color.inactive('?') return formatPrice({hourly: false, price: plan?.price}) }, }, @@ -145,23 +194,6 @@ function displayAll(addons: Heroku.AddOn[]) { /* eslint-enable perfectionist/sort-objects */ } -function formatAttachment(attachment: Heroku.AddOnAttachment, showApp = true) { - const attName = color.attachment(attachment.name || '') - const output = [color.dim('as'), attName] - if (showApp) { - const appInfo = `on ${color.app(attachment.app?.name || '')} app` - output.push(color.dim(appInfo)) - } - - return output.join(' ') -} - -export function renderAttachment(attachment: Heroku.AddOnAttachment, app: string, isLast = false): string { - const line = isLast ? '\u2514\u2500' : '\u251C\u2500' - const attName = formatAttachment(attachment, attachment.app?.name !== app) - return ` ${color.dim(line)} ${attName}` -} - function displayForApp(app: string, addons: Heroku.AddOn[]) { if (addons.length === 0) { ux.stdout(`No add-ons for app ${app}.`) @@ -173,7 +205,7 @@ function displayForApp(app: string, addons: Heroku.AddOn[]) { const name = color.addon(addon.name || '') let service = addon.addon_service?.name if (service === undefined) { - service = color.dim('?') + service = color.gray('?') } const addonLine = `${service} (${name})` @@ -196,7 +228,7 @@ function displayForApp(app: string, addons: Heroku.AddOn[]) { Plan: { get: ({plan}) => plan && plan.name !== undefined ? plan.name.replace(/^[^:]+:/, '') - : color.dim('?'), + : color.inactive('?'), }, Price: { get(addon) { @@ -204,7 +236,7 @@ function displayForApp(app: string, addons: Heroku.AddOn[]) { return formatPrice({hourly: true, price: addon.plan?.price}) } - return color.dim(`(billed to ${color.app(addon.app?.name || '')} app)`) + return color.gray(`(billed to ${color.app(addon.app?.name || '')} app)`) }, }, // eslint-disable-next-line perfectionist/sort-objects @@ -214,7 +246,7 @@ function displayForApp(app: string, addons: Heroku.AddOn[]) { return formatPrice({hourly: false, price: addon.plan?.price}) } - return color.dim(`(billed to ${color.app(addon.app?.name || '')} app)`) + return color.gray(`(billed to ${color.app(addon.app?.name || '')} app)`) }, }, State: { @@ -232,46 +264,13 @@ function displayJSON(addons: Heroku.AddOn[]) { ux.stdout(JSON.stringify(addons, null, 2)) } -export default class Addons extends Command { - static description = `Lists your add-ons and attachments. - - The default filter applied depends on whether you are in a Heroku app - directory. If so, the --app flag is implied. If not, the default of --all - is implied. Explicitly providing either flag overrides the default - behavior. - ` - static examples = [ - `${color.command(`heroku ${topic} --all`)}`, - `${color.command(`heroku ${topic} --app acme-inc-www`)}`, - ] - - static flags = { - all: flags.boolean({char: 'A', description: 'show add-ons and attachments for all accessible apps'}), - app: flags.app(), - json: flags.boolean({description: 'return add-ons in json format'}), - remote: flags.remote(), +function formatAttachment(attachment: Heroku.AddOnAttachment, showApp = true) { + const attName = color.attachment(attachment.name || '') + const output = [color.gray('as'), attName] + if (showApp) { + const appInfo = `on ${color.app(attachment.app?.name || '')} app` + output.push(color.gray(appInfo)) } - static topic = topic - - static usage = 'addons [--all|--app APP]' - - public async run(): Promise { - const {flags} = await this.parse(Addons) - const {all, app, json} = flags - - if (!all && app) { - const addons = await addonGetter(this.heroku, app) - if (json) - displayJSON(addons) - else - displayForApp(app, addons) - } else { - const addons = await addonGetter(this.heroku) - if (json) - displayJSON(addons) - else - displayAll(addons) - } - } + return output.join(' ') } diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index d3851e30b3..67a844320f 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -97,7 +97,7 @@ function displayNotifications(notifications?: {read: boolean}[]) { } } -const dim = (s: string) => color.dim(s) +const dim = (s: string) => color.gray(s) const bold = (s: string) => color.bold(s) const label = (s: string) => color.label(s) diff --git a/src/commands/data/maintenances/run.ts b/src/commands/data/maintenances/run.ts index 396d6cb1a6..165b6cbdc3 100644 --- a/src/commands/data/maintenances/run.ts +++ b/src/commands/data/maintenances/run.ts @@ -1,6 +1,6 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {flags as Flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import BaseCommand from '../../../lib/data/baseCommand.js' @@ -48,7 +48,7 @@ export default class DataMaintenancesRun extends BaseCommand { // app is in maintenance mode, or it was forced } else if (!confirm || confirm !== appName) { ux.warn('Application is not in maintenance mode.') - this.error(`To proceed, put the application into maintenance mode or re-run the command with ${color.bold.red(`--confirm ${appName}`)}`) + this.error(`To proceed, put the application into maintenance mode or re-run the command with ${color.warning(`--confirm ${appName}`)}`) } } diff --git a/src/commands/data/pg/create.ts b/src/commands/data/pg/create.ts index 4f9e361a6c..26575a8ff8 100644 --- a/src/commands/data/pg/create.ts +++ b/src/commands/data/pg/create.ts @@ -1,8 +1,7 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {flags as Flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, utils} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' - import inquirer from 'inquirer' import tsheredoc from 'tsheredoc' @@ -17,7 +16,7 @@ import notify from '../../../lib/notify.js' const heredoc = tsheredoc.default // eslint-disable-next-line import/no-named-as-default-member -const {Separator, prompt} = inquirer +const {prompt, Separator} = inquirer export default class DataPgCreate extends BaseCommand { static baseFlags = BaseCommand.baseFlagsWithoutPrompt() @@ -113,7 +112,7 @@ export default class DataPgCreate extends BaseCommand { try { this.addon = await createAddon(this.heroku, app, servicePlan, confirm, wait, { - actionStartMessage: `Creating a ${color.cyan(this.leaderLevel)} database on ${color.app(app)}`, + actionStartMessage: `Creating a ${color.addon(this.leaderLevel || '')} database on ${color.app(app)}`, actionStopMessage: 'done', as, config, name, }) @@ -223,7 +222,7 @@ export default class DataPgCreate extends BaseCommand { name: 'Remove high availability' + ( renderPricingInfo(leaderPricing) === 'free' ? '' - : ` ${color.yellowBright(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}` + : ` ${color.info(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}` ), value: 'remove', }, @@ -247,14 +246,14 @@ export default class DataPgCreate extends BaseCommand { const instancePrice = renderPricingInfo(leaderLevelInfo?.pricing) process.stderr.write(heredoc` ${`${color.green('✓ Configure Leader Pool')} ${totalPrice}`} - ${color.dim( - `${this.leaderLevel} ${leaderLevelInfo?.vcpu} ${color.inverse('vCPU')} ` - + `${leaderLevelInfo?.memory_in_gb} GB ${color.inverse('MEM')} ` + ${color.gray( + `${this.leaderLevel} ${leaderLevelInfo?.vcpu} ${color.ansis.inverse('vCPU')} ` + + `${leaderLevelInfo?.memory_in_gb} GB ${color.ansis.inverse('MEM')} ` + instancePrice, )} `) if (this.highAvailability) { - process.stderr.write(color.dim(` Standby (High Availability) ${instancePrice}\n`)) + process.stderr.write(color.gray(` Standby (High Availability) ${instancePrice}\n`)) } process.stderr.write('\n') @@ -285,13 +284,13 @@ export default class DataPgCreate extends BaseCommand { process.stderr.write(heredoc` Create a Heroku Postgres Advanced database - ${color.dim('Press Ctrl+C to cancel')} + ${color.gray('Press Ctrl+C to cancel')} `) process.stderr.write(heredoc` → Configure Leader Pool - ${color.dim(' Configure Follower Pool(s)')}\n + ${color.gray(' Configure Follower Pool(s)')}\n `) let configReady = false diff --git a/src/commands/data/pg/credentials/create.ts b/src/commands/data/pg/credentials/create.ts index dd41564f4c..3cf7fac9ac 100644 --- a/src/commands/data/pg/credentials/create.ts +++ b/src/commands/data/pg/credentials/create.ts @@ -1,5 +1,5 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {flags as Flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -42,7 +42,7 @@ export default class DataPgCredentialsCreate extends BaseCommand { const data = {name} let attachCmd = '' try { - ux.action.start(`Creating credential ${color.cyan.bold(name)}`) + ux.action.start(`Creating credential ${color.name(name)}`) if (utils.pg.isAdvancedDatabase(addon)) { await this.dataApi.post(`/data/postgres/v1/${addon.id}/credentials`, {body: data}) attachCmd = `heroku data:pg:attachments:create ${addon.name} --credential ${name} -a ${app}` diff --git a/src/commands/data/pg/info.ts b/src/commands/data/pg/info.ts index 1bce982da8..3ca8116801 100644 --- a/src/commands/data/pg/info.ts +++ b/src/commands/data/pg/info.ts @@ -1,6 +1,6 @@ -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 {color, hux, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -17,52 +17,6 @@ const poolStatusRenderMap: Record = { unknown: color.failure('? Unknown'), } -function renderPoolSummary(pool: PoolInfoResponse, attachments: Required[]) { - const poolAttachmentNames = attachments - .filter(a => { - if (pool.name === 'leader') { - return !a.namespace && a.addon.app.id === a.app.id - } - - return a.namespace === `pool:${pool.name}` && a.addon.app.id === a.app.id - }) - .map(a => color.attachment(a.name)) - .join(', ') - - const poolStatus = poolStatusRenderMap[pool.status] - const connections = `Connections: ${pool.connections_used ?? color.dim('?')} / ${pool.expected_connection_limit} used` - const {expected_count: expectedCount, expected_level: expectedLevel} = pool - const poolSize = color.bold(`${expectedCount} instance${expectedCount === 1 ? '' : 's'} of ${expectedLevel}${expectedCount > 1 ? ' (HA)' : ''}:`) - - const instances: string[] = [] - const {compute_instances: computeInstances, name: poolName} = pool - computeInstances.forEach(({id, role, status}) => { - let instanceName: string - - if (role === 'standby') { - instanceName = color.dim(`${role}.${id}`) - } else { - instanceName = `${role}.${id}` - } - - const instanceStatus = status === 'up' ? color.success(status) : color.warning(status) - instances.push(` ${instanceName}: ${instanceStatus}`) - }) - - if (poolName === 'leader') { - hux.styledHeader(`Leader pool${poolAttachmentNames ? color.dim(` (attached as ${poolAttachmentNames})`) : ''}`) - } else { - hux.styledHeader(`Follower pool ${color.name(poolName)}${poolAttachmentNames ? color.dim(` (attached as ${poolAttachmentNames})`) : ''}`) - } - - ux.stdout( - ` ${poolStatus}\n` - + ` ${connections}\n` - + ` ${poolSize}\n` - + ` ${instances.join('\n ')}\n`, - ) -} - export default class DataPgInfo extends BaseCommand { static args = { database: Args.string({ @@ -200,6 +154,52 @@ export default class DataPgInfo extends BaseCommand { const tableLimitCompliance = tableLimit && tableLimit.current <= tableLimit.limit ? 'In compliance' : 'Not in compliance' - return tableLimit ? `${tableLimit.current} / ${tableLimit.limit} (${tableLimitCompliance})` : color.dim('N/A') + return tableLimit ? `${tableLimit.current} / ${tableLimit.limit} (${tableLimitCompliance})` : color.inactive('N/A') } } + +function renderPoolSummary(pool: PoolInfoResponse, attachments: Required[]) { + const poolAttachmentNames = attachments + .filter(a => { + if (pool.name === 'leader') { + return !a.namespace && a.addon.app.id === a.app.id + } + + return a.namespace === `pool:${pool.name}` && a.addon.app.id === a.app.id + }) + .map(a => color.attachment(a.name)) + .join(', ') + + const poolStatus = poolStatusRenderMap[pool.status] + const connections = `Connections: ${pool.connections_used ?? color.inactive('?')} / ${pool.expected_connection_limit} used` + const {expected_count: expectedCount, expected_level: expectedLevel} = pool + const poolSize = color.bold(`${expectedCount} instance${expectedCount === 1 ? '' : 's'} of ${expectedLevel}${expectedCount > 1 ? ' (HA)' : ''}:`) + + const instances: string[] = [] + const {compute_instances: computeInstances, name: poolName} = pool + computeInstances.forEach(({id, role, status}) => { + let instanceName: string + + if (role === 'standby') { + instanceName = color.inactive(`${role}.${id}`) + } else { + instanceName = `${role}.${id}` + } + + const instanceStatus = status === 'up' ? color.success(status) : color.warning(status) + instances.push(` ${instanceName}: ${instanceStatus}`) + }) + + if (poolName === 'leader') { + hux.styledHeader(`Leader pool${poolAttachmentNames ? color.gray(` (attached as ${poolAttachmentNames})`) : ''}`) + } else { + hux.styledHeader(`Follower pool ${color.name(poolName)}${poolAttachmentNames ? color.gray(` (attached as ${poolAttachmentNames})`) : ''}`) + } + + ux.stdout( + ` ${poolStatus}\n` + + ` ${connections}\n` + + ` ${poolSize}\n` + + ` ${instances.join('\n ')}\n`, + ) +} diff --git a/src/commands/data/pg/settings.ts b/src/commands/data/pg/settings.ts index e55df168ab..9764d23568 100644 --- a/src/commands/data/pg/settings.ts +++ b/src/commands/data/pg/settings.ts @@ -1,5 +1,5 @@ -import {color, hux, utils} from '@heroku/heroku-cli-util' import {flags as Flags} from '@heroku-cli/command' +import {color, hux, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -21,9 +21,9 @@ const settingsHeaders = { } const settingsChangeTableData = (response: SettingsChangeResponse) => response.changes.map(change => ({ - From: color.yellow(change.previous), + From: color.label(change.previous?.toString() || ''), Settings: change.name, - To: color.cyan(change.current), + To: color.info(change.current?.toString() || ''), })) const settingsTableData = (response: SettingsResponse) => { diff --git a/src/commands/data/pg/update.ts b/src/commands/data/pg/update.ts index 3d11dd84d9..9e2d136fbd 100644 --- a/src/commands/data/pg/update.ts +++ b/src/commands/data/pg/update.ts @@ -1,10 +1,10 @@ import type {Answers, DistinctChoice, ListChoiceMap} from 'inquirer' +import {flags as Flags} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' import { color, hux, pg, 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 inquirer from 'inquirer' import tsheredoc from 'tsheredoc' @@ -22,7 +22,7 @@ import {fetchLevelsAndPricing, renderPricingInfo} from '../../../lib/data/utils. const heredoc = tsheredoc.default // eslint-disable-next-line import/no-named-as-default-member -const {Separator, prompt} = inquirer +const {prompt, Separator} = inquirer export default class DataPgUpdate extends BaseCommand { static args = { @@ -155,7 +155,7 @@ export default class DataPgUpdate extends BaseCommand { name: 'Remove high availability' + ( renderPricingInfo(leaderPricing) === 'free' ? '' - : ` ${color.yellowBright(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}` + : ` ${color.info(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}` ), value: '__remove_ha', }) @@ -296,7 +296,7 @@ export default class DataPgUpdate extends BaseCommand { process.stderr.write(heredoc` Update ${color.addon(this.database!.name)} on ${color.app(app)} - ${color.dim('Press Ctrl+C to cancel')} + ${color.gray('Press Ctrl+C to cancel')} `) @@ -356,9 +356,9 @@ export default class DataPgUpdate extends BaseCommand { * @returns Promise resolving to all Heroku Postgres databases * @throws {Error} When no legacy database add-on exists on the app */ - private async getAllAdvancedDatabases(app: string): Promise> { + private async getAllAdvancedDatabases(app: string): Promise> { const allAttachments = await this.allAdvancedDatabaseAttachments(app) - const addons: Array<{attachment_names?: string[]} & pg.ExtendedAddonAttachment['addon']> = [] + const addons: Array = [] for (const attachment of allAttachments) { if (!addons.some(a => a.id === attachment.addon.id)) { addons.push(attachment.addon) @@ -453,7 +453,7 @@ export default class DataPgUpdate extends BaseCommand { this.pool = pools.find(pool => pool.name === this.selectedPoolOption) } - private renderDatabaseChoices(databases: Array<{attachment_names?: string[]} & pg.ExtendedAddonAttachment['addon']>) { + private renderDatabaseChoices(databases: Array) { const choices: Array>> = [] databases.forEach(database => { @@ -477,8 +477,8 @@ export default class DataPgUpdate extends BaseCommand { const levelInfo = this.extendedLevelsInfo!.find(level => level.name === leaderPool.expected_level) choices.push({ name: `Leader: ${levelInfo!.name}` - + ` ${`${levelInfo!.vcpu} ${color.inverse('vCPU')}`}` - + ` ${`${levelInfo!.memory_in_gb} GB ${color.inverse('MEM')}`}` + + ` ${`${levelInfo!.vcpu} ${color.ansis.inverse('vCPU')}`}` + + ` ${`${levelInfo!.memory_in_gb} GB ${color.ansis.inverse('MEM')}`}` + color.green( ` ${leaderPool.expected_count} instance${leaderPool.expected_count === 1 ? '' : 's'}` + ` ${`starting at ${renderPricingInfo(levelInfo!.pricing)}`}` @@ -492,8 +492,8 @@ export default class DataPgUpdate extends BaseCommand { const levelInfo = this.extendedLevelsInfo!.find(level => level.name === pool.expected_level) choices.push({ name: `Follower ${color.bold(pool.name)}: ${levelInfo!.name}` - + ` ${`${levelInfo!.vcpu} ${color.inverse('vCPU')}`}` - + ` ${`${levelInfo!.memory_in_gb} GB ${color.inverse('MEM')}`}` + + ` ${`${levelInfo!.vcpu} ${color.ansis.inverse('vCPU')}`}` + + ` ${`${levelInfo!.memory_in_gb} GB ${color.ansis.inverse('MEM')}`}` + color.green( ` ${pool.expected_count} instance${pool.expected_count === 1 ? '' : 's'}` + ` ${`starting at ${renderPricingInfo(levelInfo!.pricing)}`}` diff --git a/src/commands/notifications/index.ts b/src/commands/notifications/index.ts index ae2b5a922a..986b701d64 100644 --- a/src/commands/notifications/index.ts +++ b/src/commands/notifications/index.ts @@ -1,26 +1,12 @@ -import {color, hux} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' - import wrap from 'word-wrap' import * as time from '../../lib/time.js' import {Notifications} from '../../lib/types/notifications.js' -function displayNotifications(notifications: Notifications, app: Heroku.App | null, readNotification: boolean) { - const read = readNotification ? 'Read' : 'Unread' - hux.styledHeader(app ? `${read} Notifications for ${color.app(app.name!)}` : `${read} Notifications`) - for (const n of notifications) { - ux.stdout(color.info(`\n${n.title}\n`)) - ux.stdout(wrap(`\n${color.dim(time.ago(new Date(n.created_at)))}\n${n.body}`, {width: 80})) - for (const followup of n.followup) { - ux.stdout() - ux.stdout(wrap(`${color.gray.dim(time.ago(new Date(followup.created_at)))}\n${followup.body}`, {width: 80})) - } - } -} - export default class NotificationsIndex extends Command { static description = 'display notifications' static flags = { @@ -43,7 +29,7 @@ export default class NotificationsIndex extends Command { if (app) notifications = notifications.filter(n => n.target.id === app.id) if (!flags.read) { notifications = notifications.filter(n => !n.read) - await Promise.all(notifications.map(n => this.heroku.patch(`/user/notifications/${n.id}`, {hostname: 'telex.heroku.com', body: {read: true}}))) + await Promise.all(notifications.map(n => this.heroku.patch(`/user/notifications/${n.id}`, {body: {read: true}, hostname: 'telex.heroku.com'}))) } if (flags.json) { @@ -60,3 +46,16 @@ export default class NotificationsIndex extends Command { } else displayNotifications(notifications, app!, flags.read) } } + +function displayNotifications(notifications: Notifications, app: Heroku.App | null, readNotification: boolean) { + const read = readNotification ? 'Read' : 'Unread' + hux.styledHeader(app ? `${read} Notifications for ${color.app(app.name!)}` : `${read} Notifications`) + for (const n of notifications) { + ux.stdout(color.info(`\n${n.title}\n`)) + ux.stdout(wrap(`\n${color.gray(time.ago(new Date(n.created_at)))}\n${n.body}`, {width: 80})) + for (const followup of n.followup) { + ux.stdout() + ux.stdout(wrap(`${color.gray(time.ago(new Date(followup.created_at)))}\n${followup.body}`, {width: 80})) + } + } +} diff --git a/src/commands/pg/backups/download.ts b/src/commands/pg/backups/download.ts index ea2ebf3fe4..47ffbee003 100644 --- a/src/commands/pg/backups/download.ts +++ b/src/commands/pg/backups/download.ts @@ -1,5 +1,5 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import fs from 'fs-extra' @@ -8,17 +8,6 @@ import type {BackupTransfer, PublicUrlResponse} from '../../../lib/pg/types.js' import pgBackupsApi from '../../../lib/pg/backups.js' import download from '../../../lib/pg/download.js' -function defaultFilename() { - let f = 'latest.dump' - if (!fs.existsSync(f)) - return f - let i = 1 - do - f = `latest.dump.${i++}` - while (fs.existsSync(f)) - return f -} - export default class Download extends Command { static args = { backup_id: Args.string({description: 'ID of the backup. If omitted, we use the last backup ID.'}), @@ -51,7 +40,7 @@ export default class Download extends Command { .filter(t => t.succeeded && t.to_type === 'gof3r') .sort((a, b) => b.created_at.localeCompare(a.created_at))[0] if (!lastBackup) - throw new Error(`No backups on ${color.app(app)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) + throw new Error(`No backups on ${color.app(app)}. Capture one with ${color.code('heroku pg:backups:capture')}`) num = lastBackup.num } @@ -62,3 +51,14 @@ export default class Download extends Command { await download(info.url, output, {progress: true}) } } + +function defaultFilename() { + let f = 'latest.dump' + if (!fs.existsSync(f)) + return f + let i = 1 + do + f = `latest.dump.${i++}` + while (fs.existsSync(f)) + return f +} diff --git a/src/commands/pg/backups/restore.ts b/src/commands/pg/backups/restore.ts index 8089250533..83f6bed085 100644 --- a/src/commands/pg/backups/restore.ts +++ b/src/commands/pg/backups/restore.ts @@ -1,5 +1,5 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -11,19 +11,6 @@ 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 args = { backup: Args.string({description: 'URL or backup ID from another app'}), @@ -122,7 +109,7 @@ export default class Restore extends Command { return 0 }).pop() if (!backup) { - throw new Error(`No backups for ${color.app(backupApp)}. Capture one with ${color.cyan.bold('heroku pg:backups:capture')}`) + throw new Error(`No backups for ${color.app(backupApp)}. Capture one with ${color.code('heroku pg:backups:capture')}`) } backupName = pgbackups.name(backup) @@ -137,8 +124,8 @@ export default class Restore extends Command { 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')}. + Use ${color.code('heroku pg:backups')} to check progress. + Stop a running restore with ${color.code('heroku pg:backups:cancel')}. `)) const {body: restore} = await this.heroku.post<{uuid: string}>(`/client/v11/databases/${db.id}/restores`, { @@ -150,3 +137,15 @@ export default class Restore extends Command { } } +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 +} diff --git a/src/commands/pg/backups/schedule.ts b/src/commands/pg/backups/schedule.ts index dd9dbfa5fe..5fecb03840 100644 --- a/src/commands/pg/backups/schedule.ts +++ b/src/commands/pg/backups/schedule.ts @@ -1,60 +1,62 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {HTTPError} from '@heroku/http-call' import {Args, ux} from '@oclif/core' + 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 + BST: string CDT: string - EST: string + CEST: string + CET: string + CST: string EDT: string - Z: string + EST: string GMT: string - BST: string - CET: string - CEST: string + MDT: string + MST: string + PDT: string + PST: string + Z: string } const TZ: Timezone = { - PST: 'America/Los_Angeles', - PDT: 'America/Los_Angeles', - MST: 'America/Boise', - MDT: 'America/Boise', - CST: 'America/Chicago', + BST: 'Europe/London', CDT: 'America/Chicago', - EST: 'America/New_York', + CEST: 'Europe/Paris', + CET: 'Europe/Paris', + CST: 'America/Chicago', EDT: 'America/New_York', - Z: 'UTC', + EST: 'America/New_York', GMT: 'Europe/London', - BST: 'Europe/London', - CET: 'Europe/Paris', - CEST: 'Europe/Paris', + MDT: 'America/Boise', + MST: 'America/Boise', + PDT: 'America/Los_Angeles', + PST: 'America/Los_Angeles', + Z: 'UTC', } type BackupSchedule = { hour: string - timezone: string schedule_name?: string + timezone: string } export default class Schedule extends Command { - static topic = 'pg' + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}), + } + 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}), + at: flags.string({description: "at a specific (24h) hour in the given timezone. Defaults to UTC. --at '[HOUR]:00 [TIMEZONE]'", required: true}), remote: flags.remote(), } - static args = { - database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}), - } + static topic = 'pg' parseDate = function (at: string): BackupSchedule { const m = at.match(/^(0?\d|1\d|2[0-3]):00 ?(\S*)$/) @@ -68,7 +70,7 @@ export default class Schedule extends Command { } public async run(): Promise { - const {flags, args} = await this.parse(Schedule) + const {args, flags} = await this.parse(Schedule) const {app} = flags const {database} = args @@ -82,7 +84,7 @@ export default class Schedule extends Command { .catch((error: HTTPError) => { if (error.statusCode !== 404) throw error - ux.error(`${color.datastore(db.name)} is not yet provisioned.\nRun ${color.cyan.bold('heroku addons:wait')} to wait until the db is provisioned.`, {exit: 1}) + ux.error(`${color.datastore(db.name)} is not yet provisioned.\nRun ${color.code('heroku addons:wait')} to wait until the db is provisioned.`, {exit: 1}) }) const {body: dbInfo} = pgResponse || {body: null} if (dbInfo) { @@ -101,4 +103,3 @@ export default class Schedule extends Command { ux.action.stop() } } - diff --git a/src/commands/pg/backups/url.ts b/src/commands/pg/backups/url.ts index afeea92307..6237be85ed 100644 --- a/src/commands/pg/backups/url.ts +++ b/src/commands/pg/backups/url.ts @@ -1,5 +1,5 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import type {BackupTransfer, PublicUrlResponse} from '../../../lib/pg/types.js' @@ -36,7 +36,7 @@ export default class Url extends Command { 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')}`) + throw new Error(`No backups on ${color.app(app)}. Capture one with ${color.code('heroku pg:backups:capture')}`) num = lastBackup.num } @@ -44,4 +44,3 @@ export default class Url extends Command { ux.stdout(info.url + '\n') } } - diff --git a/src/commands/pg/credentials/create.ts b/src/commands/pg/credentials/create.ts index 6455d1ed28..244bac0f5c 100644 --- a/src/commands/pg/credentials/create.ts +++ b/src/commands/pg/credentials/create.ts @@ -1,5 +1,5 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -33,16 +33,16 @@ export default class Create extends Command { } const data = {name} - ux.action.start(`Creating credential ${color.cyan.bold(name)}`) + ux.action.start(`Creating credential ${color.name(name)}`) - await this.heroku.post(`/postgres/v0/databases/${db.name}/credentials`, {hostname: utils.pg.host(), body: data}) + await this.heroku.post(`/postgres/v0/databases/${db.name}/credentials`, {body: data, hostname: utils.pg.host()}) ux.action.stop() const attachCmd = `heroku addons:attach ${db.name} --credential ${name} -a ${app}` const psqlCmd = `heroku pg:psql ${db.name} -a ${app}` ux.stdout(heredoc(` - Please attach the credential to the apps you want to use it in by running ${color.cyan.bold(attachCmd)}. - Please define the new grants for the credential within Postgres: ${color.cyan.bold(psqlCmd)}.`)) + Please attach the credential to the apps you want to use it in by running ${color.code(attachCmd)}. + Please define the new grants for the credential within Postgres: ${color.code(psqlCmd)}.`)) } } diff --git a/src/commands/pg/credentials/destroy.ts b/src/commands/pg/credentials/destroy.ts index 5632395709..1630b1bf5c 100644 --- a/src/commands/pg/credentials/destroy.ts +++ b/src/commands/pg/credentials/destroy.ts @@ -1,6 +1,6 @@ -import {color, utils} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import ConfirmCommand from '../../../lib/confirmCommand.js' @@ -46,7 +46,7 @@ export default class Destroy extends Command { .join(', ')} before destroying.`) await new ConfirmCommand().confirm(app, confirm) - ux.action.start(`Destroying credential ${color.cyan.bold(name)}`) + ux.action.start(`Destroying credential ${color.name(name)}`) await this.heroku.delete(`/postgres/v0/databases/${db.name}/credentials/${encodeURIComponent(name)}`, {hostname: utils.pg.host()}) ux.action.stop() ux.stdout(`The credential has been destroyed within ${db.name}.`) diff --git a/src/commands/pg/credentials/rotate.ts b/src/commands/pg/credentials/rotate.ts index fb262c0e09..726e745a7f 100644 --- a/src/commands/pg/credentials/rotate.ts +++ b/src/commands/pg/credentials/rotate.ts @@ -1,7 +1,7 @@ import type {AddOnAttachment} from '@heroku-cli/schema' -import {color, utils} from '@heroku/heroku-cli-util' import {APIClient, Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import ConfirmCommand from '../../../lib/confirmCommand.js' @@ -63,7 +63,7 @@ export default class Rotate extends Command { } if (force) { - warnings.push(`Any followers lagging in replication (see ${color.cyan.bold('heroku pg:info')}) will be inaccessible until caught up.`) + warnings.push(`Any followers lagging in replication (see ${color.code('heroku pg:info')}) will be inaccessible until caught up.`) } if (attachments.length > 0) { diff --git a/src/commands/ps/copy.ts b/src/commands/ps/copy.ts index 40c3f33c46..5c4ba93f3a 100644 --- a/src/commands/ps/copy.ts +++ b/src/commands/ps/copy.ts @@ -38,10 +38,10 @@ export default class Copy extends Command { const src = args.file const dest = output || path.basename(src) - ux.stdout(`Copying ${color.white.bold(src)} to ${color.white.bold(dest)}`) + ux.stdout(`Copying ${color.bold(src)} to ${color.bold(dest)}`) if (fs.existsSync(dest)) { - ux.error(`The local file ${color.white.bold(dest)} already exists`) + ux.error(`The local file ${color.bold(dest)} already exists`) } const context = { @@ -55,7 +55,7 @@ export default class Copy extends Command { await exec.initFeature(context, this.heroku, async (configVars: Heroku.ConfigVars) => { await exec.updateClientKey(context, this.heroku, configVars, async (privateKey, dyno, response) => { - const message = `Connecting to ${color.cyan.bold(dyno)} on ${color.app(app)}` + const message = `Connecting to ${color.name(dyno)} on ${color.app(app)}` ux.action.start(message) const json = JSON.parse(response.body) await ssh.scp(json.tunnel_host, json.client_user, privateKey, json.proxy_public_key, src, dest) diff --git a/src/commands/ps/exec.ts b/src/commands/ps/exec.ts index ea87c846fd..8f40adb277 100644 --- a/src/commands/ps/exec.ts +++ b/src/commands/ps/exec.ts @@ -1,9 +1,7 @@ -import * as color from '@heroku/heroku-cli-util/color' - import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import * as color from '@heroku/heroku-cli-util/color' import {ux} from '@oclif/core/ux' - import debug from 'debug' import {HerokuExec} from '../../lib/ps-exec/exec.js' @@ -56,7 +54,7 @@ export default class Exec extends Command { await exec.checkStatus(context, this.heroku, configVars) } else { await exec.updateClientKey(context, this.heroku, configVars, async (privateKey, dyno, response) => { - const message = `Connecting to ${color.cyan.bold(dyno)} on ${color.app(app)}` + const message = `Connecting to ${color.name(dyno)} on ${color.app(app)}` ux.action.start(message) psExecDebug(response.body) const json = JSON.parse(response.body) diff --git a/src/commands/ps/forward.ts b/src/commands/ps/forward.ts index 06b4c4b3e0..d79c1d733f 100644 --- a/src/commands/ps/forward.ts +++ b/src/commands/ps/forward.ts @@ -67,7 +67,7 @@ export default class Forward extends Command { for (const portMapping of portMappings) { const [localPortNum, remotePort] = portMapping - ux.stdout(`Listening on ${color.white.bold(localPortNum)} and forwarding to ${color.white.bold(`${dynoName}:${remotePort}`)}`) + ux.stdout(`Listening on ${color.bold(localPortNum)} and forwarding to ${color.bold(`${dynoName}:${remotePort}`)}`) net.createServer(connIn => { socks.connect({ diff --git a/src/commands/ps/index.ts b/src/commands/ps/index.ts index 447241b96e..284ad87232 100644 --- a/src/commands/ps/index.ts +++ b/src/commands/ps/index.ts @@ -1,7 +1,6 @@ -import {color, hux} from '@heroku/heroku-cli-util' import {APIClient, Command, flags} from '@heroku-cli/command' +import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' - import tsheredoc from 'tsheredoc' import {ago} from '../../lib/time.js' @@ -12,21 +11,83 @@ import {Account} from '../../lib/types/fir.js' const heredoc = tsheredoc.default -function getProcessNumber(s: string) : number { - const [processType, dynoNumber] = (s.match(/^([^.]+)\.(.*)$/) || []).slice(1, 3) +export default class Index extends Command { + static description = 'list dynos for an app' + static examples = [heredoc` + ${color.command('heroku ps')} + === run: one-off dyno + run.1: up for 5m: bash + === web: bundle exec thin start -p $PORT + web.1: created for 30s + `, heredoc` + ${color.command('heroku ps run')} # specifying types + === run: one-off dyno + run.1: up for 5m: bash + `] - if (!processType || !dynoNumber?.match(/^\d+$/)) - return 0 + static flags = { + app: flags.app({required: true}), + extended: flags.boolean({char: 'x', hidden: true}), // only works with sudo privileges + json: flags.boolean({description: 'display as json'}), + remote: flags.remote(), + } - return Number.parseInt(dynoNumber, 10) -} + static strict = false -function uniqueValues(value: string, index: number, self: string[]) : boolean { - return self.indexOf(value) === index -} + static topic = 'ps' -function byProcessNumber(a: DynoExtended, b: DynoExtended) : number { - return getProcessNumber(a.name) - getProcessNumber(b.name) + static usage = 'ps [TYPE [TYPE ...]]' + + public async run(): Promise { + const {flags, ...restParse} = await this.parse(Index) + const {app, extended, json} = flags + const types = restParse.argv as string[] + const suffix = extended ? '?extended=true' : '' + const promises = { + accountInfo: this.heroku.request('/account', { + headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, + }), + appInfo: this.heroku.request(`/apps/${app}`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, + }), + dynos: this.heroku.request(`/apps/${app}/dynos${suffix}`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, + }), + } + const [{body: dynos}, {body: appInfo}, {body: accountInfo}] = await Promise.all([promises.dynos, promises.appInfo, promises.accountInfo]) + const shielded = appInfo.space && appInfo.space.shield + + if (shielded) { + dynos.forEach(d => { + d.size = d.size.replace('Private-', 'Shield-') + }) + } + + let selectedDynos = dynos + + if (types.length > 0) { + selectedDynos = selectedDynos.filter(dyno => types.find((t: string) => dyno.type === t)) + types.forEach(t => { + if (!selectedDynos.some(d => d.type === t)) { + throw new Error(`No ${color.info(t)} dynos on ${color.app(app)}`) + } + }) + } + + selectedDynos = selectedDynos.sort(byProcessName) + + if (json) + hux.styledJSON(selectedDynos) + else if (extended) + printExtended(selectedDynos) + else { + await printAccountQuota(this.heroku, appInfo, accountInfo) + if (selectedDynos.length === 0) + ux.stdout(`No dynos on ${color.app(app)}`) + else + printDynos(selectedDynos) + } + } } function byProcessName(a: DynoExtended, b: DynoExtended) : number { @@ -41,6 +102,10 @@ function byProcessName(a: DynoExtended, b: DynoExtended) : number { return 0 } +function byProcessNumber(a: DynoExtended, b: DynoExtended) : number { + return getProcessNumber(a.name) - getProcessNumber(b.name) +} + function byProcessTypeAndNumber(a: DynoExtended, b: DynoExtended) : number { if (a.type > b.type) { return 1 @@ -53,37 +118,29 @@ function byProcessTypeAndNumber(a: DynoExtended, b: DynoExtended) : number { return getProcessNumber(a.name) - getProcessNumber(b.name) } -function truncate(s: string) { - return s.length > 35 ? `${s.slice(0, 34)}…` : s +function decorateCommandDyno(dyno: DynoExtended) : string { + const since = ago(new Date(dyno.updated_at)) + const state = dyno.state === 'up' ? color.success(dyno.state) : color.warning(dyno.state) + + return `${dyno.name}: ${state} ${color.gray(since)}` } -function printExtended(dynos: DynoExtended[]) { - const sortedDynos = dynos.sort(byProcessTypeAndNumber) +function decorateOneOffDyno(dyno: DynoExtended) : string { + const since = ago(new Date(dyno.updated_at)) + // eslint-disable-next-line unicorn/explicit-length-check + const size = dyno.size || '1X' + const state = dyno.state === 'up' ? color.success(dyno.state) : color.warning(dyno.state) - /* eslint-disable perfectionist/sort-objects */ - hux.table( - sortedDynos, - { - ID: {get: (dyno: DynoExtended) => dyno.id}, - Process: {get: (dyno: DynoExtended) => dyno.name}, - State: {get: (dyno: DynoExtended) => `${dyno.state} ${ago(new Date(dyno.updated_at))}`}, - Region: {get: (dyno: DynoExtended) => dyno.extended?.region ?? ''}, - 'Execution Plane': {get: (dyno: DynoExtended) => dyno.extended?.execution_plane ?? ''}, - Fleet: {get: (dyno: DynoExtended) => dyno.extended?.fleet ?? ''}, - Instance: {get: (dyno: DynoExtended) => dyno.extended?.instance ?? ''}, - IP: {get: (dyno: DynoExtended) => dyno.extended?.ip ?? ''}, - Port: {get: (dyno: DynoExtended) => dyno.extended?.port?.toString() ?? ''}, - AZ: {get: (dyno: DynoExtended) => dyno.extended?.az ?? ''}, - Release: {get: (dyno: DynoExtended) => dyno.release.version}, - Command: {get: (dyno: DynoExtended) => truncate(dyno.command)}, - Route: {get: (dyno: DynoExtended) => dyno.extended?.route ?? ''}, - Size: {get: (dyno: DynoExtended) => dyno.size}, - }, - { - overflow: 'wrap', - }, - ) - /* eslint-enable perfectionist/sort-objects */ + return `${dyno.name} (${color.info(size)}): ${state} ${color.gray(since)}: ${dyno.command}` +} + +function getProcessNumber(s: string) : number { + const [processType, dynoNumber] = (s.match(/^([^.]+)\.(.*)$/) || []).slice(1, 3) + + if (!processType || !dynoNumber?.match(/^\d+$/)) + return 0 + + return Number.parseInt(dynoNumber, 10) } async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account: Account) { @@ -124,22 +181,6 @@ async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account ux.stdout() } -function decorateOneOffDyno(dyno: DynoExtended) : string { - const since = ago(new Date(dyno.updated_at)) - // eslint-disable-next-line unicorn/explicit-length-check - const size = dyno.size || '1X' - const state = dyno.state === 'up' ? color.success(dyno.state) : color.warning(dyno.state) - - return `${dyno.name} (${color.info(size)}): ${state} ${color.dim(since)}: ${dyno.command}` -} - -function decorateCommandDyno(dyno: DynoExtended) : string { - const since = ago(new Date(dyno.updated_at)) - const state = dyno.state === 'up' ? color.success(dyno.state) : color.warning(dyno.state) - - return `${dyno.name}: ${state} ${color.dim(since)}` -} - function printDynos(dynos: DynoExtended[]) : void { const oneOffs = dynos.filter(d => d.type === 'run').sort(byProcessNumber) @@ -166,81 +207,39 @@ function printDynos(dynos: DynoExtended[]) : void { }) } -export default class Index extends Command { - static description = 'list dynos for an app' - static examples = [heredoc` - ${color.command('heroku ps')} - === run: one-off dyno - run.1: up for 5m: bash - === web: bundle exec thin start -p $PORT - web.1: created for 30s - `, heredoc` - ${color.command('heroku ps run')} # specifying types - === run: one-off dyno - run.1: up for 5m: bash - `] - - static flags = { - app: flags.app({required: true}), - extended: flags.boolean({char: 'x', hidden: true}), // only works with sudo privileges - json: flags.boolean({description: 'display as json'}), - remote: flags.remote(), - } - - static strict = false - - static topic = 'ps' - - static usage = 'ps [TYPE [TYPE ...]]' - - public async run(): Promise { - const {flags, ...restParse} = await this.parse(Index) - const {app, extended, json} = flags - const types = restParse.argv as string[] - const suffix = extended ? '?extended=true' : '' - const promises = { - accountInfo: this.heroku.request('/account', { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - appInfo: this.heroku.request(`/apps/${app}`, { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - dynos: this.heroku.request(`/apps/${app}/dynos${suffix}`, { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - } - const [{body: dynos}, {body: appInfo}, {body: accountInfo}] = await Promise.all([promises.dynos, promises.appInfo, promises.accountInfo]) - const shielded = appInfo.space && appInfo.space.shield - - if (shielded) { - dynos.forEach(d => { - d.size = d.size.replace('Private-', 'Shield-') - }) - } - - let selectedDynos = dynos +function printExtended(dynos: DynoExtended[]) { + const sortedDynos = dynos.sort(byProcessTypeAndNumber) - if (types.length > 0) { - selectedDynos = selectedDynos.filter(dyno => types.find((t: string) => dyno.type === t)) - types.forEach(t => { - if (!selectedDynos.some(d => d.type === t)) { - throw new Error(`No ${color.info(t)} dynos on ${color.app(app)}`) - } - }) - } + /* eslint-disable perfectionist/sort-objects */ + hux.table( + sortedDynos, + { + ID: {get: (dyno: DynoExtended) => dyno.id}, + Process: {get: (dyno: DynoExtended) => dyno.name}, + State: {get: (dyno: DynoExtended) => `${dyno.state} ${ago(new Date(dyno.updated_at))}`}, + Region: {get: (dyno: DynoExtended) => dyno.extended?.region ?? ''}, + 'Execution Plane': {get: (dyno: DynoExtended) => dyno.extended?.execution_plane ?? ''}, + Fleet: {get: (dyno: DynoExtended) => dyno.extended?.fleet ?? ''}, + Instance: {get: (dyno: DynoExtended) => dyno.extended?.instance ?? ''}, + IP: {get: (dyno: DynoExtended) => dyno.extended?.ip ?? ''}, + Port: {get: (dyno: DynoExtended) => dyno.extended?.port?.toString() ?? ''}, + AZ: {get: (dyno: DynoExtended) => dyno.extended?.az ?? ''}, + Release: {get: (dyno: DynoExtended) => dyno.release.version}, + Command: {get: (dyno: DynoExtended) => truncate(dyno.command)}, + Route: {get: (dyno: DynoExtended) => dyno.extended?.route ?? ''}, + Size: {get: (dyno: DynoExtended) => dyno.size}, + }, + { + overflow: 'wrap', + }, + ) + /* eslint-enable perfectionist/sort-objects */ +} - selectedDynos = selectedDynos.sort(byProcessName) +function truncate(s: string) { + return s.length > 35 ? `${s.slice(0, 34)}…` : s +} - if (json) - hux.styledJSON(selectedDynos) - else if (extended) - printExtended(selectedDynos) - else { - await printAccountQuota(this.heroku, appInfo, accountInfo) - if (selectedDynos.length === 0) - ux.stdout(`No dynos on ${color.app(app)}`) - else - printDynos(selectedDynos) - } - } +function uniqueValues(value: string, index: number, self: string[]) : boolean { + return self.indexOf(value) === index } diff --git a/src/commands/releases/rollback.ts b/src/commands/releases/rollback.ts index 6cb0994866..f35d29e09d 100644 --- a/src/commands/releases/rollback.ts +++ b/src/commands/releases/rollback.ts @@ -1,7 +1,6 @@ -import * as color from '@heroku/heroku-cli-util/color' - import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import * as color from '@heroku/heroku-cli-util/color' import {Args, ux} from '@oclif/core' import {stream} from '../../lib/releases/output.js' @@ -47,7 +46,7 @@ export default class Rollback extends Command { ux.action.stop(`done, ${color.green('v' + latest.version)}`) ux.warn("Rollback affects code and config vars; it doesn't add or remove addons.") if (latest.version) { - ux.warn(`To undo, run: ${color.cyan.bold('heroku rollback v' + (latest.version - 1))}`) + ux.warn(`To undo, run: ${color.code('heroku rollback v' + (latest.version - 1))}`) } if (streamUrl) { @@ -64,4 +63,3 @@ export default class Rollback extends Command { } } } - diff --git a/src/commands/spaces/destroy.ts b/src/commands/spaces/destroy.ts index 1a97108ae2..597cb928c6 100644 --- a/src/commands/spaces/destroy.ts +++ b/src/commands/spaces/destroy.ts @@ -1,7 +1,6 @@ -import * as color from '@heroku/heroku-cli-util/color' - import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import * as color from '@heroku/heroku-cli-util/color' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -12,7 +11,7 @@ import {Space} from '../../lib/types/fir.js' const heredoc = tsheredoc.default -type RequiredSpaceWithNat = {outbound_ips?: Required} & Required +type RequiredSpaceWithNat = Required & {outbound_ips?: Required} export default class Destroy extends Command { static args = { @@ -53,15 +52,15 @@ export default class Destroy extends Command { if (space.outbound_ips && space.outbound_ips.state === 'enabled') { const ipv6 = getGeneration(space) === 'fir' ? ' and IPv6' : '' natWarning = heredoc` - ${color.dim('===')} ${color.label('WARNING: Outbound IPs Will Be Reused')} + ${color.gray('===')} ${color.label('WARNING: Outbound IPs Will Be Reused')} ${color.warning(`⚠️ Deleting this space frees up the following outbound IPv4${ipv6} IPs for reuse:`)} ${color.label(displayNat(space.outbound_ips) ?? '')} - ${color.dim('Update the following configurations:')} - ${color.dim('=')} IP allowlists - ${color.dim('=')} Firewall rules - ${color.dim('=')} Security group configurations - ${color.dim('=')} Network ACLs + ${color.gray('Update the following configurations:')} + ${color.gray('=')} IP allowlists + ${color.gray('=')} Firewall rules + ${color.gray('=')} Security group configurations + ${color.gray('=')} Network ACLs ${color.warning(`Ensure that you remove the listed IPv4${ipv6} addresses from your security configurations.`)} ` diff --git a/src/commands/spaces/drains/set.ts b/src/commands/spaces/drains/set.ts index e316b34089..0937d1bb13 100644 --- a/src/commands/spaces/drains/set.ts +++ b/src/commands/spaces/drains/set.ts @@ -1,7 +1,6 @@ -import * as color from '@heroku/heroku-cli-util/color' - import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import * as color from '@heroku/heroku-cli-util/color' import {Args, ux} from '@oclif/core' export default class Set extends Command { @@ -25,7 +24,7 @@ export default class Set extends Command { body: {url}, headers: {Accept: 'application/vnd.heroku+json; version=3.dogwood'}, }) - ux.stdout(`Successfully set drain ${color.cyan(drain.url)} for ${color.space(space)}.`) + ux.stdout(`Successfully set drain ${color.info(drain.url || '')} for ${color.space(space)}.`) ux.warn('It may take a few moments for the changes to take effect.') } } diff --git a/src/commands/spaces/peerings/accept.ts b/src/commands/spaces/peerings/accept.ts index 1c379a2a3e..4f611572dd 100644 --- a/src/commands/spaces/peerings/accept.ts +++ b/src/commands/spaces/peerings/accept.ts @@ -35,6 +35,6 @@ Accepting and configuring peering connection pcx-4bd27022`)] body: {pcx_id: pcxID}, headers: {Accept: 'application/vnd.heroku+json; version=3.dogwood'}, }) - ux.stdout(`Accepting and configuring peering connection ${color.cyan.bold(pcxID)}`) + ux.stdout(`Accepting and configuring peering connection ${color.name(pcxID)}`) } } diff --git a/src/commands/spaces/ps.ts b/src/commands/spaces/ps.ts index c9f3b2ad88..cfdc0978fc 100644 --- a/src/commands/spaces/ps.ts +++ b/src/commands/spaces/ps.ts @@ -1,6 +1,6 @@ -import {color, hux} from '@heroku/heroku-cli-util' import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, hux} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' import {ago} from '../../lib/time.js' @@ -66,7 +66,7 @@ export default class Ps extends Command { } else { key = `${color.name(dyno.type)} (${color.info(size)}): ${dyno.command}` const state = dyno.state === 'up' ? color.success(dyno.state) : color.warning(dyno.state) - item = `${dyno.name}: ${color.info(state)} ${color.dim(since)}` + item = `${dyno.name}: ${color.info(state)} ${color.gray(since)}` } if (!dynosByCommand.has(key)) { diff --git a/src/commands/spaces/trusted-ips/add.ts b/src/commands/spaces/trusted-ips/add.ts index d590a2d831..7cab9c41cb 100644 --- a/src/commands/spaces/trusted-ips/add.ts +++ b/src/commands/spaces/trusted-ips/add.ts @@ -1,7 +1,6 @@ -import * as color from '@heroku/heroku-cli-util/color' - import {Command, flags} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import * as color from '@heroku/heroku-cli-util/color' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -40,7 +39,7 @@ export default class Add extends Command { ruleset.rules.push({action: 'allow', source: args.source}) await this.heroku.put(url, {body: ruleset}) - ux.stdout(`Added ${color.cyan.bold(args.source)} to trusted IP ranges on ${color.space(space)}`) + ux.stdout(`Added ${color.name(args.source)} to trusted IP ranges on ${color.space(space)}`) // Fetch updated ruleset to check applied status const {body: updatedRuleset} = await this.heroku.get(url) diff --git a/src/commands/spaces/trusted-ips/remove.ts b/src/commands/spaces/trusted-ips/remove.ts index 1536c6066f..0f55f4e2ae 100644 --- a/src/commands/spaces/trusted-ips/remove.ts +++ b/src/commands/spaces/trusted-ips/remove.ts @@ -45,7 +45,7 @@ export default class Remove extends Command { } await this.heroku.put(url, {body: rules}) - ux.stdout(`Removed ${color.cyan.bold(args.source)} from trusted IP ranges on ${color.space(space)}`) + ux.stdout(`Removed ${color.name(args.source)} from trusted IP ranges on ${color.space(space)}`) // Fetch updated ruleset to check applied status const {body: updatedRuleset} = await this.heroku.get(url) diff --git a/src/lib/analytics-telemetry/backboard-otel-client.ts b/src/lib/analytics-telemetry/backboard-otel-client.ts index 26a4548c1b..311b96ce34 100644 --- a/src/lib/analytics-telemetry/backboard-otel-client.ts +++ b/src/lib/analytics-telemetry/backboard-otel-client.ts @@ -11,7 +11,7 @@ import { getToken, getVersion, isDev, - isTelemetryDisabled, + isTelemetryEnabled, TelemetryData, telemetryDebug, } from './telemetry-utils.js' @@ -97,7 +97,7 @@ export default class BackboardOtelClient { * Ensure OpenTelemetry is initialized (lazy initialization) */ private async ensureInitialized(): Promise { - if (isInitialized || isTelemetryDisabled) { + if (isInitialized || !isTelemetryEnabled()) { return } diff --git a/src/lib/analytics-telemetry/sentry-client.ts b/src/lib/analytics-telemetry/sentry-client.ts index 2a107bd478..b7188e76c3 100644 --- a/src/lib/analytics-telemetry/sentry-client.ts +++ b/src/lib/analytics-telemetry/sentry-client.ts @@ -4,7 +4,7 @@ import {PII_PATTERNS} from '../data-scrubber/patterns.js' import {GDPR_FIELDS, HEROKU_FIELDS, PCI_FIELDS} from '../data-scrubber/presets.js' import {Scrubber} from '../data-scrubber/scrubber.js' import { - CLIError, getVersion, isDev, isTelemetryDisabled, telemetryDebug, + CLIError, getVersion, isDev, isTelemetryEnabled, telemetryDebug, } from './telemetry-utils.js' export default class SentryClient { @@ -47,7 +47,7 @@ export default class SentryClient { * Ensure Sentry is initialized (lazy initialization) */ private ensureInitialized(): void { - if (this.isInitialized || isTelemetryDisabled) { + if (this.isInitialized || !isTelemetryEnabled()) { return } diff --git a/src/lib/analytics-telemetry/telemetry-manager.ts b/src/lib/analytics-telemetry/telemetry-manager.ts index 7be2851cc7..daa7020179 100644 --- a/src/lib/analytics-telemetry/telemetry-manager.ts +++ b/src/lib/analytics-telemetry/telemetry-manager.ts @@ -5,9 +5,12 @@ import type {Config} from '@oclif/core/interfaces' +import type BackboardOtelClient from './backboard-otel-client.js' +import type SentryClient from './sentry-client.js' + // Import internal dependencies import { - isTelemetryDisabled, + isTelemetryEnabled, setVersion, Telemetry, TelemetryData, @@ -28,8 +31,8 @@ interface TelemetryOptions { * TelemetryManager - Singleton class for managing CLI telemetry */ class TelemetryManager { - private backboardOtelClient: any - private sentryClient: any + private backboardOtelClient?: BackboardOtelClient + private sentryClient?: SentryClient /** * Create telemetry object for command_not_found errors @@ -61,7 +64,7 @@ class TelemetryManager { * - Regular telemetry goes to Honeycomb only */ async sendTelemetry(currentTelemetry: TelemetryData): Promise { - if (isTelemetryDisabled) { + if (!isTelemetryEnabled()) { telemetryDebug('Telemetry disabled, skipping send') return } @@ -141,8 +144,11 @@ class TelemetryManager { * Lazy load telemetry clients to avoid loading heavy OpenTelemetry/Sentry * libraries during CLI initialization */ - private async getClients() { - if (!this.backboardOtelClient) { + private async getClients(): Promise<{ + backboardOtelClient: BackboardOtelClient + sentryClient: SentryClient + }> { + if (!this.backboardOtelClient || !this.sentryClient) { const [{default: BackboardOtelClient}, {default: SentryClient}] = await Promise.all([ import('./backboard-otel-client.js'), import('./sentry-client.js'), @@ -152,6 +158,7 @@ class TelemetryManager { telemetryDebug('Lazy-loaded telemetry clients') } + // TypeScript: Both clients are guaranteed to be defined after the above check return { backboardOtelClient: this.backboardOtelClient, sentryClient: this.sentryClient, diff --git a/src/lib/analytics-telemetry/telemetry-utils.ts b/src/lib/analytics-telemetry/telemetry-utils.ts index 73989c194e..6c26780776 100644 --- a/src/lib/analytics-telemetry/telemetry-utils.ts +++ b/src/lib/analytics-telemetry/telemetry-utils.ts @@ -13,7 +13,6 @@ telemetryDebug.color = '147' // Environment flags export const isDev = process.env.IS_DEV_ENVIRONMENT === 'true' -export const isTelemetryDisabled = process.env.DISABLE_TELEMETRY === 'true' // Cached values let version: string | undefined @@ -170,8 +169,8 @@ export function spawnTelemetryWorker(data: WorkerData): void { const workerPath = path.join(__dirname, '..', '..', '..', 'dist', 'lib', 'analytics-telemetry', 'telemetry-worker.js') const child = spawn(process.execPath, [workerPath], { detached: true, - // Keep stderr attached to see DEBUG output, but ignore stdout - stdio: ['pipe', 'ignore', 'inherit'], + // Only inherit stderr when debugging to avoid keeping parent process alive + stdio: ['pipe', 'ignore', process.env.DEBUG ? 'inherit' : 'ignore'], // On Windows, prevent console window from appearing windowsHide: true, }) diff --git a/src/lib/analytics-telemetry/telemetry-worker.ts b/src/lib/analytics-telemetry/telemetry-worker.ts index 4a512f2ea5..3f1aa4a0b0 100644 --- a/src/lib/analytics-telemetry/telemetry-worker.ts +++ b/src/lib/analytics-telemetry/telemetry-worker.ts @@ -4,16 +4,35 @@ * This runs as a separate process to avoid blocking the main CLI */ -import {telemetryDebug} from './telemetry-utils.js' import {telemetryManager} from './telemetry-manager.js' +import {telemetryDebug} from './telemetry-utils.js' // Set maximum lifetime for worker process (10 seconds) // This ensures the worker never hangs indefinitely due to network issues or other failures const MAX_WORKER_LIFETIME_MS = 10000 +/** + * Close stderr before exiting to avoid keeping parent process alive + * This is important when stderr is inherited in DEBUG mode + */ +function exitWorker(code: number): void { + // Use setImmediate to allow any pending stderr writes to flush + // before destroying the stream + setImmediate(() => { + try { + // Close stderr to release the file descriptor reference to parent + process.stderr.destroy() + } catch { + // Ignore errors during cleanup + } + + process.exit(code) + }) +} + setTimeout(() => { telemetryDebug('Worker timeout reached after %dms, force exiting', MAX_WORKER_LIFETIME_MS) - process.exit(0) + exitWorker(0) }, MAX_WORKER_LIFETIME_MS) // Read telemetry data from stdin @@ -42,7 +61,7 @@ process.stdin.on('end', async () => { // Send herokulytics data await client.send(parsed) - process.exit(0) + exitWorker(0) return } @@ -61,13 +80,13 @@ process.stdin.on('end', async () => { // Send telemetry (this will initialize OpenTelemetry and Sentry if needed) await telemetryManager.sendTelemetry(telemetryData) - process.exit(0) + exitWorker(0) } catch { // Silently fail - don't let telemetry errors affect user experience - process.exit(1) + exitWorker(1) } }) // Handle errors silently -process.on('uncaughtException', () => process.exit(1)) -process.on('unhandledRejection', () => process.exit(1)) +process.on('uncaughtException', () => exitWorker(1)) +process.on('unhandledRejection', () => exitWorker(1)) diff --git a/src/lib/data/poolConfig.ts b/src/lib/data/poolConfig.ts index f1c1ba3ecd..726d282edf 100644 --- a/src/lib/data/poolConfig.ts +++ b/src/lib/data/poolConfig.ts @@ -142,8 +142,8 @@ export default class PoolConfig { process.stderr.write('\n') process.stderr.write(heredoc` ${`${color.green('✓ Configure Follower Pool')} ${totalPrice}`} - ${this.followerName ? `${color.bold(this.followerName)}\n ${color.dim(this.followerLevel)}` : `${color.dim(this.followerLevel)}`} - ${color.dim(`${this.followerCount} instance${this.followerCount! > 1 ? 's (High Availability)' : ''}`)} + ${this.followerName ? `${color.bold(this.followerName)}\n ${color.gray(this.followerLevel || '')}` : `${color.gray(this.followerLevel || '')}`} + ${color.gray(`${this.followerCount} instance${this.followerCount! > 1 ? 's (High Availability)' : ''}`)} `) process.stderr.write('\n') diff --git a/src/lib/data/utils.ts b/src/lib/data/utils.ts index 67c83693cb..cdfeacb9de 100644 --- a/src/lib/data/utils.ts +++ b/src/lib/data/utils.ts @@ -1,7 +1,7 @@ import type {DistinctChoice, ListChoiceMap} from 'inquirer' -import {color, hux} from '@heroku/heroku-cli-util' import {APIClient} from '@heroku-cli/command' +import {color, hux} from '@heroku/heroku-cli-util' import inquirer from 'inquirer' import printf from 'printf' @@ -22,7 +22,7 @@ const {Separator} = inquirer * @param count - The number of units to calculate the pricing for * @returns A formatted string with colored pricing information, or empty string if no pricing info provided */ -export function renderPricingInfo(pricingInfo?: PricingInfo | null, count: number = 1) { +export function renderPricingInfo(pricingInfo?: null | PricingInfo, count: number = 1) { if (!pricingInfo) return '' const priceHourly = hux.formatPrice(pricingInfo.rate * count, true) @@ -105,12 +105,12 @@ export async function fetchLevelsAndPricing( const extendedLevelsInfo = levels.items.map(level => ({ ...level, pricing: Object.entries(pricing[tier]).find( - ([_, value]) => value.product_description === level.name, // eslint-disable-line @typescript-eslint/no-unused-vars + ([_, value]) => value.product_description === level.name, )?.[1], })) const optimizedStoragePricing = Object.entries(pricing[tier]).find( - ([key, _]) => key === 'storage-optimized', // eslint-disable-line @typescript-eslint/no-unused-vars + ([key, _]) => key === 'storage-optimized', )?.[1] return {extendedLevelsInfo, optimizedStoragePricing} @@ -156,8 +156,8 @@ export async function renderLevelChoices( const columns: string[] = [] columns.push( `${level.name}`, - `${printf('%3d', level.vcpu)} ${color.inverse('vCPU')} `, - `${printf('%4d', level.memory_in_gb)} GB ${color.inverse('MEM')} `, + `${printf('%3d', level.vcpu)} ${color.ansis.inverse('vCPU')} `, + `${printf('%4d', level.memory_in_gb)} GB ${color.ansis.inverse('MEM')} `, `starting at ${color.green(renderPricingInfo(level.pricing))}`, ) choices.push(columns) diff --git a/src/lib/ps-exec/exec.ts b/src/lib/ps-exec/exec.ts index 12bdbd1e04..1c8c64c3ae 100644 --- a/src/lib/ps-exec/exec.ts +++ b/src/lib/ps-exec/exec.ts @@ -1,12 +1,11 @@ -import {color, hux} from '@heroku/heroku-cli-util' import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' +import {color, hux} from '@heroku/heroku-cli-util' import {ux} from '@oclif/core/ux' - import debug from 'debug' -import forge from 'node-forge' import got, {Response} from 'got' import keypair from 'keypair' +import forge from 'node-forge' import child from 'node:child_process' import {URL} from 'node:url' import tsheredoc from 'tsheredoc' @@ -58,7 +57,7 @@ export class HerokuExec { statuses.push({ dyno_name: color.name(name), - dyno_status: dyno ? (dyno.state === 'up' ? color.success(dyno.state) : color.yellow(dyno.state)) : color.error('missing!'), + dyno_status: dyno ? (dyno.state === 'up' ? color.success(dyno.state) : color.yellow(dyno.state || '')) : color.error('missing!'), proxy_status: 'running', }) } diff --git a/src/lib/run/colorize.ts b/src/lib/run/colorize.ts index 2275df5b7d..4d140fc58f 100644 --- a/src/lib/run/colorize.ts +++ b/src/lib/run/colorize.ts @@ -7,11 +7,11 @@ export const COLORS: Array<(s: string) => string> = [ s => color.cyan(s), s => color.magenta(s), s => color.blue(s), - s => color.bold.green(s), - s => color.bold.cyan(s), - s => color.bold.magenta(s), - s => color.bold.yellow(s), - s => color.bold.blue(s), + s => color.teal(s), + s => color.pink(s), + s => color.gold(s), + s => color.purple(s), + s => color.orange(s), ] const assignedColors: any = {} let isInitialized = false @@ -37,7 +37,7 @@ function getColorForIdentifier(i: string) { const lineRegex = /^(.*?\[([\w-]+)([\d.]+)?]:)(.*)?$/ -const dim = (i: string) => color.dim(i) +const dim = (i: string) => color.inactive(i) const other = dim const path = (i: string) => color.green(i) const method = (i: string) => color.magenta(i) @@ -53,10 +53,10 @@ const status = (code: any) => { const ms = (s: string) => { const ms = Number.parseInt(s, 10) if (!ms) return s - if (ms < 100) return color.greenBright(s) - if (ms < 500) return color.success(s) + if (ms < 100) return color.success(s) + if (ms < 500) return color.info(s) if (ms < 5000) return color.warning(s) - if (ms < 10000) return color.yellowBright(s) + if (ms < 10000) return color.error(s) return color.failure(s) } @@ -139,7 +139,7 @@ const state = (s: string) => { } case 'starting': { - return color.yellowBright(s) + return color.info(s) } case 'complete': { diff --git a/test/helpers/init.mjs b/test/helpers/init.mjs index ecb211414c..128f54ef3b 100644 --- a/test/helpers/init.mjs +++ b/test/helpers/init.mjs @@ -2,7 +2,6 @@ import chai from 'chai' import chaiAsPromised from 'chai-as-promised' import nock from 'nock' import path from 'path' -import {fileURLToPath} from 'url' globalThis.setInterval = () => ({unref() {}}) const tm = globalThis.setTimeout @@ -17,6 +16,11 @@ process.env.HEROKU_SKIP_NEW_VERSION_CHECK = 'true' process.env.HEROKU_DATA_CONTROL_PLANE = 'test-control-plane' +// Force ANSI color support for tests while they run in non-tty +// TODO: We should consider turning this off for most tests and tests colors +// in a targeted way +process.env.FORCE_COLOR = '3' + // Set terminal size for tests to 200x50 process.env.COLUMNS = '200' process.env.LINES = '50' diff --git a/test/unit/analytics-telemetry/backboard-otel-client.unit.test.ts b/test/unit/analytics-telemetry/backboard-otel-client.unit.test.ts index 328ab4bf51..d865520eb1 100644 --- a/test/unit/analytics-telemetry/backboard-otel-client.unit.test.ts +++ b/test/unit/analytics-telemetry/backboard-otel-client.unit.test.ts @@ -10,15 +10,32 @@ const isDev = process.env.IS_DEV_ENVIRONMENT === 'true' describe('backboard-otel-client', function () { let sandbox: sinon.SinonSandbox let client: BackboardOtelClient + let originalTestEnv: string | undefined + let originalWindowsTelemetry: string | undefined beforeEach(function () { sandbox = sinon.createSandbox() + // Temporarily enable telemetry for these tests + originalTestEnv = process.env.IS_HEROKU_TEST_ENV + originalWindowsTelemetry = process.env.ENABLE_WINDOWS_TELEMETRY + delete process.env.IS_HEROKU_TEST_ENV + process.env.ENABLE_WINDOWS_TELEMETRY = 'true' client = new BackboardOtelClient() }) afterEach(function () { sandbox.restore() nock.cleanAll() + // Restore test environment + if (originalTestEnv !== undefined) { + process.env.IS_HEROKU_TEST_ENV = originalTestEnv + } + + if (originalWindowsTelemetry === undefined) { + delete process.env.ENABLE_WINDOWS_TELEMETRY + } else { + process.env.ENABLE_WINDOWS_TELEMETRY = originalWindowsTelemetry + } }) describe('send', function () { diff --git a/test/unit/analytics-telemetry/telemetry-manager.unit.test.ts b/test/unit/analytics-telemetry/telemetry-manager.unit.test.ts index d2aeb0689a..ee488a4c27 100644 --- a/test/unit/analytics-telemetry/telemetry-manager.unit.test.ts +++ b/test/unit/analytics-telemetry/telemetry-manager.unit.test.ts @@ -6,8 +6,29 @@ import {telemetryManager} from '../../../src/lib/analytics-telemetry/telemetry-m const isDev = process.env.IS_DEV_ENVIRONMENT === 'true' describe('telemetry-manager', function () { + let originalTestEnv: string | undefined + let originalWindowsTelemetry: string | undefined + + beforeEach(function () { + // Temporarily enable telemetry for these tests + originalTestEnv = process.env.IS_HEROKU_TEST_ENV + originalWindowsTelemetry = process.env.ENABLE_WINDOWS_TELEMETRY + delete process.env.IS_HEROKU_TEST_ENV + process.env.ENABLE_WINDOWS_TELEMETRY = 'true' + }) + afterEach(function () { nock.cleanAll() + // Restore test environment + if (originalTestEnv !== undefined) { + process.env.IS_HEROKU_TEST_ENV = originalTestEnv + } + + if (originalWindowsTelemetry === undefined) { + delete process.env.ENABLE_WINDOWS_TELEMETRY + } else { + process.env.ENABLE_WINDOWS_TELEMETRY = originalWindowsTelemetry + } }) describe('setupTelemetry', function () { @@ -162,5 +183,42 @@ describe('telemetry-manager', function () { process.env.DISABLE_TELEMETRY = originalDisableTelemetry }) + + it('skips sending on Windows without ENABLE_WINDOWS_TELEMETRY', async function () { + const originalPlatform = process.platform + const originalWindowsTelemetry = process.env.ENABLE_WINDOWS_TELEMETRY + + // Simulate Windows environment + Object.defineProperty(process, 'platform', {configurable: true, value: 'win32'}) + delete process.env.ENABLE_WINDOWS_TELEMETRY + + const mockTelemetry = { + _type: 'otel' as const, + cliRunDuration: 100, + command: 'test:command', + commandRunDuration: 50, + exitCode: 0, + exitState: 'successful', + isTTY: true, + isVersionOrHelp: false, + lifecycleHookCompletion: { + command_not_found: false, + init: true, + postrun: true, + prerun: true, + }, + os: 'win32', + version: '1.0.0', + } + + // Should not make any HTTP calls when on Windows without explicit opt-in + await telemetryManager.sendTelemetry(mockTelemetry) + + // Restore environment + Object.defineProperty(process, 'platform', {configurable: true, value: originalPlatform}) + if (originalWindowsTelemetry !== undefined) { + process.env.ENABLE_WINDOWS_TELEMETRY = originalWindowsTelemetry + } + }) }) }) diff --git a/test/unit/commands/pg/copy.unit.test.ts b/test/unit/commands/pg/copy.unit.test.ts index 457daa4127..229bece7b0 100644 --- a/test/unit/commands/pg/copy.unit.test.ts +++ b/test/unit/commands/pg/copy.unit.test.ts @@ -1,33 +1,34 @@ -import {stdout, stderr} from 'stdout-stderr' -import nock from 'nock' -import {expect} from 'chai' import ansis from 'ansis' +import {expect} from 'chai' +import nock from 'nock' +import {stderr, stdout} from 'stdout-stderr' + import Cmd from '../../../../src/commands/pg/copy.js' import runCommand from '../../../helpers/runCommand.js' const addon = { - id: 1, name: 'postgres-1', app: {name: 'myapp'}, config_vars: ['READONLY_URL', 'DATABASE_URL', 'HEROKU_POSTGRESQL_RED_URL'], plan: {name: 'heroku-postgresql:standard-0'}, + app: {name: 'myapp'}, config_vars: ['READONLY_URL', 'DATABASE_URL', 'HEROKU_POSTGRESQL_RED_URL'], id: 1, name: 'postgres-1', plan: {name: 'heroku-postgresql:standard-0'}, } const otherAddon = { - id: 2, name: 'postgres-2', app: {name: 'myotherapp'}, config_vars: ['DATABASE_URL', 'HEROKU_POSTGRESQL_BLUE_URL'], plan: {name: 'heroku-postgresql:standard-0'}, + app: {name: 'myotherapp'}, config_vars: ['DATABASE_URL', 'HEROKU_POSTGRESQL_BLUE_URL'], id: 2, name: 'postgres-2', plan: {name: 'heroku-postgresql:standard-0'}, } const lowercaseAddon = { - id: 2, name: 'postgres-3', app: {name: 'mylowercaseapp'}, config_vars: ['LOWERCASE_DATABASE_URL'], plan: {name: 'heroku-postgresql:standard-0'}, + app: {name: 'mylowercaseapp'}, config_vars: ['LOWERCASE_DATABASE_URL'], id: 2, name: 'postgres-3', plan: {name: 'heroku-postgresql:standard-0'}, } const attachment = { - name: 'HEROKU_POSTGRESQL_RED', app: {name: 'myapp'}, addon, + addon, app: {name: 'myapp'}, name: 'HEROKU_POSTGRESQL_RED', } const otherAttachment = { - name: 'HEROKU_POSTGRESQL_BLUE', app: {name: 'myotherapp'}, addon: otherAddon, + addon: otherAddon, app: {name: 'myotherapp'}, name: 'HEROKU_POSTGRESQL_BLUE', } const lowercaseAttachment = { - name: 'lowercase_database', app: {name: 'mylowercaseapp'}, addon: lowercaseAddon, + addon: lowercaseAddon, app: {name: 'mylowercaseapp'}, name: 'lowercase_database', } const attachedBlueAttachment = { - name: 'ATTACHED_BLUE', app: {name: 'myapp'}, addon: otherAddon, + addon: otherAddon, app: {name: 'myapp'}, name: 'ATTACHED_BLUE', } const myappConfig = { - READONLY_URL: 'postgres://readonly-heroku/db', DATABASE_URL: 'postgres://heroku/db', HEROKU_POSTGRESQL_RED_URL: 'postgres://heroku/db', ATTACHED_BLUE_URL: 'postgres://heroku/otherdb', + ATTACHED_BLUE_URL: 'postgres://heroku/otherdb', DATABASE_URL: 'postgres://heroku/db', HEROKU_POSTGRESQL_RED_URL: 'postgres://heroku/db', READONLY_URL: 'postgres://readonly-heroku/db', } const myotherappConfig = { DATABASE_URL: 'postgres://heroku/otherdb', HEROKU_POSTGRESQL_BLUE_URL: 'postgres://heroku/otherdb', @@ -35,9 +36,9 @@ const myotherappConfig = { const mylowercaseappConfig = { LOWERCASE_DATABASE_URL: 'postgres://heroku/lowercasedb', } -const copyingText = () => process.stderr.isTTY ? 'Copying... pending\nCopying... done\n' : 'Copying... done\n' +const copyingText = 'Copying... done' -const copyingFailText = () => process.stderr.isTTY ? 'Copying... pending\nCopying... !\n' : 'Copying... !\n' +const copyingFailText = 'Copying... !\n' describe('pg:copy', function () { let pg: nock.Scope @@ -58,7 +59,7 @@ describe('pg:copy', function () { api.get('/addons/postgres-1') .reply(200, addon) api.post('/actions/addon-attachments/resolve', { - app: 'myapp', addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', + addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', app: 'myapp', }) .reply(200, [attachment]) api.get('/apps/myapp/config-vars') @@ -85,7 +86,7 @@ describe('pg:copy', function () { ]) expect(stdout.output).to.equal('') expect(stderr.output).to.include('Starting copy of database bar on foo.com:5432 to RED... done\n') - expect(stderr.output).to.include(copyingText()) + expect(stderr.output).to.include(copyingText) }) it('copies (with port number)', async function () { await runCommand(Cmd, [ @@ -98,7 +99,7 @@ describe('pg:copy', function () { ]) expect(stdout.output).to.equal('') expect(stderr.output).to.include('Starting copy of database bar on boop.com:5678 to RED... done\n') - expect(stderr.output).to.include(copyingText()) + expect(stderr.output).to.include(copyingText) }) }) context('heroku to heroku with additional credentials', function () { @@ -108,11 +109,11 @@ describe('pg:copy', function () { api.get('/addons/postgres-2') .reply(200, otherAddon) api.post('/actions/addon-attachments/resolve', { - app: 'myapp', addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', + addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', app: 'myapp', }) .reply(200, [attachment]) api.post('/actions/addon-attachments/resolve', { - app: 'myotherapp', addon_attachment: 'DATABASE_URL', + addon_attachment: 'DATABASE_URL', app: 'myotherapp', }) .reply(200, [otherAttachment]) api.get('/apps/myapp/config-vars') @@ -139,10 +140,13 @@ describe('pg:copy', function () { ]) expect(stdout.output).to.equal('') expect(stderr.output).to.include('Starting copy of RED to BLUE... done\n') - expect(stderr.output).to.include('Warning: pg:copy will only copy your default credential and the data it \n') - expect(stderr.output).to.include('has access to. Any additional credentials and data that only they can \n') - expect(stderr.output).to.include('access will not be copied.\n') - expect(stderr.output).to.include(copyingText()) + // Check for warning message parts (handle line wrapping in CI) + // Normalize: remove newlines and oclif warning prefixes (› on Unix, » on Windows), then collapse spaces + const normalizedOutput = stderr.output.replace(/\n\s*[›»]?\s*/g, ' ').replace(/\s+/g, ' ') + expect(normalizedOutput).to.include('Warning: pg:copy will only copy your default credential') + expect(normalizedOutput).to.include('and the data it has access to') + expect(normalizedOutput).to.include('Any additional credentials and data that only they can access will not be copied') + expect(stderr.output).to.include(copyingText) }) }) context('heroku to heroku with non-billing app attachment name', function () { @@ -152,11 +156,11 @@ describe('pg:copy', function () { api.get('/addons/postgres-2') .reply(200, otherAddon) api.post('/actions/addon-attachments/resolve', { - app: 'myapp', addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', + addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', app: 'myapp', }) .reply(200, [attachment]) api.post('/actions/addon-attachments/resolve', { - app: 'myapp', addon_attachment: 'ATTACHED_BLUE', + addon_attachment: 'ATTACHED_BLUE', app: 'myapp', }) .reply(200, [attachedBlueAttachment]) api.get('/apps/myapp/config-vars') @@ -182,7 +186,7 @@ describe('pg:copy', function () { ]) expect(stdout.output).to.equal('') expect(stderr.output).to.include('Starting copy of RED to ATTACHED_BLUE... done\n') - expect(stderr.output).to.include(copyingText()) + expect(stderr.output).to.include(copyingText) }) }) context('heroku to heroku with lower case attachment name', function () { @@ -192,11 +196,11 @@ describe('pg:copy', function () { api.get('/addons/postgres-2') .reply(200, otherAddon) api.post('/actions/addon-attachments/resolve', { - app: 'mylowercaseapp', addon_attachment: 'lowercase_database_URL', + addon_attachment: 'lowercase_database_URL', app: 'mylowercaseapp', }) .reply(200, [lowercaseAttachment]) api.post('/actions/addon-attachments/resolve', { - app: 'myotherapp', addon_attachment: 'DATABASE_URL', + addon_attachment: 'DATABASE_URL', app: 'myotherapp', }) .reply(200, [otherAttachment]) api.get('/apps/mylowercaseapp/config-vars') @@ -223,7 +227,7 @@ describe('pg:copy', function () { ]) expect(stdout.output).to.equal('') expect(stderr.output).to.include('Starting copy of lowercase_database to BLUE... done\n') - expect(stderr.output).to.include(copyingText()) + expect(stderr.output).to.include(copyingText) }) }) context('fails', function () { @@ -231,7 +235,7 @@ describe('pg:copy', function () { api.get('/addons/postgres-1') .reply(200, addon) api.post('/actions/addon-attachments/resolve', { - app: 'myapp', addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', + addon_attachment: 'HEROKU_POSTGRESQL_RED_URL', app: 'myapp', }) .reply(200, [attachment]) api.get('/apps/myapp/config-vars') @@ -241,9 +245,11 @@ describe('pg:copy', function () { }) .reply(200, {uuid: '100-001'}) pg.get('/client/v11/apps/myapp/transfers/100-001') - .reply(200, {finished_at: '100', succeeded: false, num: 1}) + .reply(200, {finished_at: '100', num: 1, succeeded: false}) pg.get('/client/v11/apps/myapp/transfers/100-001?verbose=true') - .reply(200, {finished_at: '100', succeeded: false, num: 1, logs: [{message: 'foobar'}]}) + .reply(200, { + finished_at: '100', logs: [{message: 'foobar'}], num: 1, succeeded: false, + }) }) it('fails to copy', async function () { const err = 'An error occurred and the backup did not finish.\n\nfoobar\n\nRun heroku pg:backups:info b001 for more details.' @@ -257,7 +263,7 @@ describe('pg:copy', function () { ]).catch(error => expect(ansis.strip(error.message)).to.contain(err)) expect(stdout.output).to.equal('') expect(stderr.output).to.include('Starting copy of database bar on foo.com:5432 to RED... done\n') - expect(stderr.output).to.include(copyingFailText()) + expect(stderr.output).to.include(copyingFailText) }) }) }) diff --git a/test/unit/lib/spaces/format.unit.test.ts b/test/unit/lib/spaces/format.unit.test.ts index 66946bf2fb..7aa47e7e65 100644 --- a/test/unit/lib/spaces/format.unit.test.ts +++ b/test/unit/lib/spaces/format.unit.test.ts @@ -29,7 +29,7 @@ describe('spaces/format', function () { expect(hostStatus('under-assessment')).to.eq('\u001B[38;5;43munder-assessment\u001B[39m') expect(hostStatus('permanent-failure')).to.eq('\u001B[38;2;255;135;135mpermanent-failure\u001B[39m') expect(hostStatus('released-permanent-failure')).to.eq('\u001B[38;2;255;135;135mreleased-permanent-failure\u001B[39m') - expect(hostStatus('released')).to.eq('\u001B[90mreleased\u001B[39m') + expect(hostStatus('released')).to.eq('\u001B[38;5;248mreleased\u001B[39m') expect(hostStatus('foo')).to.eq('foo') }) })