@heroku/heroku-cli-util 9.1.3 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,7 +105,6 @@ try {
105
105
  * types.pg.AddOnAttachmentWithConfigVarsAndPlan
106
106
  * types.pg.AddOnWithRelatedData
107
107
  * types.pg.ConnectionDetails
108
- * types.pg.ConnectionDetailsWithAttachment
109
108
  * types.pg.Link
110
109
  * types.pg.TunnelConfig
111
110
  */
@@ -1,10 +1,10 @@
1
- import type { ExtendedAddonAttachment } from '../types/pg/data-api';
1
+ import type { ExtendedAddon, ExtendedAddonAttachment } from '../types/pg/platform-api';
2
2
  /**
3
3
  * This error is used internally to signal when the `AddonAttachmentResolver` cannot resolve
4
4
  * to a single attachment.
5
5
  */
6
6
  export declare class AmbiguousError extends Error {
7
- readonly matches: ExtendedAddonAttachment[];
7
+ readonly matches: ExtendedAddon[] | ExtendedAddonAttachment[];
8
8
  readonly type: string;
9
9
  readonly body: {
10
10
  id: string;
@@ -12,5 +12,5 @@ export declare class AmbiguousError extends Error {
12
12
  };
13
13
  readonly message: string;
14
14
  readonly statusCode = 422;
15
- constructor(matches: ExtendedAddonAttachment[], type: string);
15
+ constructor(matches: ExtendedAddon[] | ExtendedAddonAttachment[], type: string);
16
16
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { APIClient } from '@heroku-cli/command';
2
1
  import { AmbiguousError } from './errors/ambiguous';
3
2
  import { NotFound } from './errors/not-found';
4
- import { AddOnWithRelatedData, ExtendedAddonAttachment, Link } from './types/pg/data-api';
5
- import { ConnectionDetails, ConnectionDetailsWithAttachment, TunnelConfig } from './types/pg/tunnel';
3
+ import { AddOnWithRelatedData, ExtendedAddon, ExtendedAddonAttachment, Link } from './types/pg/platform-api';
4
+ import { ConnectionDetails, TunnelConfig } from './types/pg/tunnel';
5
+ import AddonResolver from './utils/addons/addon-resolver';
6
6
  import { getPsqlConfigs, sshTunnel } from './utils/pg/bastion';
7
7
  import { getConfigVarNameFromAttachment } from './utils/pg/config-vars';
8
8
  import DatabaseResolver from './utils/pg/databases';
@@ -15,20 +15,21 @@ import { styledJSON } from './ux/styled-json';
15
15
  import { styledObject } from './ux/styled-object';
16
16
  import { table } from './ux/table';
17
17
  import { wait } from './ux/wait';
18
- export type { AddOnWithRelatedData, ExtendedAddonAttachment, Link, } from './types/pg/data-api';
19
- export type { ConnectionDetails, ConnectionDetailsWithAttachment, TunnelConfig, } from './types/pg/tunnel';
18
+ export type { AddOnWithRelatedData, ExtendedAddon, ExtendedAddonAttachment, Link, } from './types/pg/platform-api';
19
+ export type { ConnectionDetails, TunnelConfig, } from './types/pg/tunnel';
20
20
  /** @deprecated Use direct type imports instead */
21
21
  export declare const types: {
22
22
  pg: {
23
23
  AddOnWithRelatedData: AddOnWithRelatedData;
24
24
  ConnectionDetails: ConnectionDetails;
25
- ConnectionDetailsWithAttachment: ConnectionDetailsWithAttachment;
25
+ ExtendedAddon: ExtendedAddon;
26
26
  ExtendedAddonAttachment: ExtendedAddonAttachment;
27
27
  Link: Link;
28
28
  TunnelConfig: TunnelConfig;
29
29
  };
30
30
  };
31
31
  export declare const utils: {
32
+ AddonResolver: typeof AddonResolver;
32
33
  errors: {
33
34
  AmbiguousError: typeof AmbiguousError;
34
35
  NotFound: typeof NotFound;
@@ -36,12 +37,15 @@ export declare const utils: {
36
37
  pg: {
37
38
  DatabaseResolver: typeof DatabaseResolver;
38
39
  PsqlService: typeof PsqlService;
39
- fetcher: {
40
- database(heroku: APIClient, appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
41
- };
40
+ addonService: () => string;
42
41
  host: typeof getHost;
42
+ isAdvancedDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
43
+ isAdvancedPrivateDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
44
+ isEssentialDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
45
+ isLegacyDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
46
+ isLegacyEssentialDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
47
+ isPostgresAddon: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
43
48
  psql: {
44
- exec(connectionDetails: ConnectionDetailsWithAttachment, query: string, psqlCmdArgs?: string[]): Promise<string>;
45
49
  getConfigVarNameFromAttachment: typeof getConfigVarNameFromAttachment;
46
50
  getPsqlConfigs: typeof getPsqlConfigs;
47
51
  sshTunnel: typeof sshTunnel;
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ exports.hux = exports.utils = exports.types = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const ambiguous_1 = require("./errors/ambiguous");
6
6
  const not_found_1 = require("./errors/not-found");
7
+ const addon_resolver_1 = tslib_1.__importDefault(require("./utils/addons/addon-resolver"));
8
+ const helpers_1 = require("./utils/addons/helpers");
7
9
  const bastion_1 = require("./utils/pg/bastion");
8
10
  const config_vars_1 = require("./utils/pg/config-vars");
9
11
  const databases_1 = tslib_1.__importDefault(require("./utils/pg/databases"));
@@ -22,13 +24,14 @@ exports.types = {
22
24
  pg: {
23
25
  AddOnWithRelatedData: {},
24
26
  ConnectionDetails: {},
25
- ConnectionDetailsWithAttachment: {},
27
+ ExtendedAddon: {},
26
28
  ExtendedAddonAttachment: {},
27
29
  Link: {},
28
30
  TunnelConfig: {},
29
31
  },
30
32
  };
31
33
  exports.utils = {
34
+ AddonResolver: addon_resolver_1.default,
32
35
  errors: {
33
36
  AmbiguousError: ambiguous_1.AmbiguousError,
34
37
  NotFound: not_found_1.NotFound, // This should be NotFoundError for consistency, but we're keeping it for backwards compatibility
@@ -36,18 +39,15 @@ exports.utils = {
36
39
  pg: {
37
40
  DatabaseResolver: databases_1.default,
38
41
  PsqlService: psql_1.default,
39
- fetcher: {
40
- database(heroku, appId, attachmentId, namespace) {
41
- const databaseResolver = new databases_1.default(heroku);
42
- return databaseResolver.getDatabase(appId, attachmentId, namespace);
43
- },
44
- },
42
+ addonService: helpers_1.getAddonService,
45
43
  host: host_1.default,
44
+ isAdvancedDatabase: helpers_1.isAdvancedDatabase,
45
+ isAdvancedPrivateDatabase: helpers_1.isAdvancedPrivateDatabase,
46
+ isEssentialDatabase: helpers_1.isEssentialDatabase,
47
+ isLegacyDatabase: helpers_1.isLegacyDatabase,
48
+ isLegacyEssentialDatabase: helpers_1.isLegacyEssentialDatabase,
49
+ isPostgresAddon: helpers_1.isPostgresAddon,
46
50
  psql: {
47
- exec(connectionDetails, query, psqlCmdArgs = []) {
48
- const psqlService = new psql_1.default(connectionDetails);
49
- return psqlService.execQuery(query, psqlCmdArgs);
50
- },
51
51
  getConfigVarNameFromAttachment: config_vars_1.getConfigVarNameFromAttachment,
52
52
  getPsqlConfigs: bastion_1.getPsqlConfigs,
53
53
  sshTunnel: bastion_1.sshTunnel,
@@ -31,6 +31,18 @@ type AddonAttachmentWithConfigVarsInclusion = {
31
31
  export type ExtendedAddonAttachment = {
32
32
  addon: AddonDescriptorWithPlanInclusion;
33
33
  } & AddonAttachmentWithConfigVarsInclusion;
34
+ /**
35
+ * This is the modified type for the `AddOn` we use on these lib functions because all requests made to
36
+ * Platform API to get add-ons, either through the Add-on List endpoint or the add-on resolver action endpoint,
37
+ * include the header `Accept-Expansion: addon_service,plan`.
38
+ */
39
+ export type ExtendedAddon = {
40
+ addon_service: DeepRequired<Heroku.AddOnService>;
41
+ plan: DeepRequired<Heroku.Plan>;
42
+ } & DeepRequired<Heroku.AddOn>;
43
+ /**
44
+ * The next two types need review and cleanup. They're not used anywhere in this codebase yet.
45
+ */
34
46
  export type AddOnWithRelatedData = {
35
47
  attachment_names?: string[];
36
48
  links?: Link[];
@@ -1,7 +1,8 @@
1
1
  import { Server } from 'node:net';
2
- import type { ExtendedAddonAttachment } from './data-api';
2
+ import type { ExtendedAddonAttachment } from './platform-api';
3
3
  export type ConnectionDetails = {
4
4
  _tunnel?: Server;
5
+ attachment?: ExtendedAddonAttachment;
5
6
  database: string;
6
7
  host: string;
7
8
  password: string;
@@ -10,9 +11,6 @@ export type ConnectionDetails = {
10
11
  url: string;
11
12
  user: string;
12
13
  } & BastionConfig;
13
- export type ConnectionDetailsWithAttachment = {
14
- attachment: ExtendedAddonAttachment;
15
- } & ConnectionDetails;
16
14
  export interface TunnelConfig {
17
15
  dstHost: string;
18
16
  dstPort: number;
@@ -0,0 +1,8 @@
1
+ import type { APIClient } from '@heroku-cli/command';
2
+ import type { ExtendedAddon } from '../../types/pg/platform-api';
3
+ export default class AddonResolver {
4
+ private readonly heroku;
5
+ private readonly addonHeaders;
6
+ constructor(heroku: APIClient);
7
+ resolve(addon: string, app?: string, addonService?: string): Promise<ExtendedAddon>;
8
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const ambiguous_1 = require("../../errors/ambiguous");
4
+ class AddonResolver {
5
+ constructor(heroku) {
6
+ this.heroku = heroku;
7
+ this.addonHeaders = {
8
+ Accept: 'application/vnd.heroku+json; version=3.sdk',
9
+ 'Accept-Expansion': 'addon_service,plan',
10
+ };
11
+ }
12
+ async resolve(addon, app, addonService) {
13
+ var _a, _b;
14
+ const [appPart, addonPart] = (_b = (_a = addon.match(/^(.+)::(.+)$/)) === null || _a === void 0 ? void 0 : _a.slice(1)) !== null && _b !== void 0 ? _b : [app, addon];
15
+ console.log('appPart', appPart);
16
+ console.log('addonPart', addonPart);
17
+ const { body: addons } = await this.heroku.post('/actions/addons/resolve', {
18
+ body: {
19
+ addon: addonPart,
20
+ addon_service: addonService,
21
+ app: appPart,
22
+ },
23
+ headers: this.addonHeaders,
24
+ });
25
+ if (addons.length === 1) {
26
+ return addons[0];
27
+ }
28
+ throw new ambiguous_1.AmbiguousError(addons, 'addon');
29
+ }
30
+ }
31
+ exports.default = AddonResolver;
@@ -1,5 +1,5 @@
1
1
  import type { APIClient } from '@heroku-cli/command';
2
- import type { ExtendedAddonAttachment } from '../../types/pg/data-api';
2
+ import type { ExtendedAddonAttachment } from '../../types/pg/platform-api';
3
3
  export interface AddonAttachmentResolverOptions {
4
4
  addonService?: string;
5
5
  namespace?: string;
@@ -0,0 +1,8 @@
1
+ import { ExtendedAddon, ExtendedAddonAttachment } from '../../types/pg/platform-api';
2
+ export declare const getAddonService: () => string;
3
+ export declare const isPostgresAddon: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
4
+ export declare const isAdvancedDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
5
+ export declare const isAdvancedPrivateDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
6
+ export declare const isLegacyDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
7
+ export declare const isLegacyEssentialDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
8
+ export declare const isEssentialDatabase: (addon: ExtendedAddon | ExtendedAddonAttachment["addon"]) => boolean;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isEssentialDatabase = exports.isLegacyEssentialDatabase = exports.isLegacyDatabase = exports.isAdvancedPrivateDatabase = exports.isAdvancedDatabase = exports.isPostgresAddon = exports.getAddonService = void 0;
4
+ const getAddonService = () => process.env.HEROKU_POSTGRESQL_ADDON_NAME || process.env.HEROKU_DATA_SERVICE || 'heroku-postgresql';
5
+ exports.getAddonService = getAddonService;
6
+ const isPostgresAddon = (addon) => addon.plan.name.split(':', 2)[0] === (0, exports.getAddonService)();
7
+ exports.isPostgresAddon = isPostgresAddon;
8
+ const isAdvancedDatabase = (addon) => (0, exports.isPostgresAddon)(addon) && /^(advanced|performance)/.test(addon.plan.name.split(':', 2)[1]);
9
+ exports.isAdvancedDatabase = isAdvancedDatabase;
10
+ const isAdvancedPrivateDatabase = (addon) => (0, exports.isAdvancedDatabase)(addon) && /(private|shield)/.test(addon.plan.name.split(':', 2)[1]);
11
+ exports.isAdvancedPrivateDatabase = isAdvancedPrivateDatabase;
12
+ const isLegacyDatabase = (addon) => (0, exports.isPostgresAddon)(addon) && !(0, exports.isAdvancedDatabase)(addon);
13
+ exports.isLegacyDatabase = isLegacyDatabase;
14
+ const isLegacyEssentialDatabase = (addon) => (0, exports.isLegacyDatabase)(addon) && /^(dev|basic|mini)/.test(addon.plan.name.split(':', 2)[1]);
15
+ exports.isLegacyEssentialDatabase = isLegacyEssentialDatabase;
16
+ const isEssentialDatabase = (addon) => (0, exports.isLegacyDatabase)(addon) && addon.plan.name.split(':', 2)[1].startsWith('essential');
17
+ exports.isEssentialDatabase = isEssentialDatabase;
@@ -1,6 +1,6 @@
1
1
  import type { APIClient } from '@heroku-cli/command';
2
2
  import { Server } from 'node:net';
3
- import { ExtendedAddonAttachment } from '../../types/pg/data-api';
3
+ import { ExtendedAddonAttachment } from '../../types/pg/platform-api';
4
4
  import { BastionConfig, ConnectionDetails, TunnelConfig } from '../../types/pg/tunnel';
5
5
  /**
6
6
  * Determines whether the attachment belongs to an add-on installed onto a non-shield Private Space.
@@ -1,6 +1,6 @@
1
1
  import type { HTTP } from '@heroku/http-call';
2
2
  import type { APIClient } from '@heroku-cli/command';
3
- import type { ExtendedAddonAttachment } from '../../types/pg/data-api';
3
+ import type { ExtendedAddonAttachment } from '../../types/pg/platform-api';
4
4
  /**
5
5
  * Cache of app config vars.
6
6
  */
@@ -1,6 +1,6 @@
1
1
  import { APIClient } from '@heroku-cli/command';
2
- import type { ExtendedAddonAttachment } from '../../types/pg/data-api';
3
- import type { ConnectionDetails, ConnectionDetailsWithAttachment } from '../../types/pg/tunnel';
2
+ import type { ExtendedAddon, ExtendedAddonAttachment } from '../../types/pg/platform-api';
3
+ import type { ConnectionDetails } from '../../types/pg/tunnel';
4
4
  import { fetchBastionConfig } from './bastion';
5
5
  import { getConfig } from './config-vars';
6
6
  export default class DatabaseResolver {
@@ -8,8 +8,34 @@ export default class DatabaseResolver {
8
8
  private readonly getConfigFn;
9
9
  private readonly fetchBastionConfigFn;
10
10
  private readonly addonAttachmentResolver;
11
+ private readonly addonHeaders;
11
12
  private readonly attachmentHeaders;
12
13
  constructor(heroku: APIClient, getConfigFn?: typeof getConfig, fetchBastionConfigFn?: typeof fetchBastionConfig);
14
+ /**
15
+ * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
16
+ *
17
+ * @param connStringOrDbName - PostgreSQL connection string or local database name
18
+ * @returns Connection details object with parsed connection information
19
+ */
20
+ static parsePostgresConnectionString(connStringOrDbName: string): ConnectionDetails;
21
+ /**
22
+ * Return all Heroku Postgres databases on the Legacy tiers for a given app.
23
+ *
24
+ * @param app - The name of the app to get the databases for
25
+ * @returns Promise resolving to all Heroku Postgres databases
26
+ * @throws {Error} When no legacy database add-on exists on the app
27
+ */
28
+ getAllLegacyDatabases(app: string): Promise<Array<{
29
+ attachment_names?: string[];
30
+ } & ExtendedAddonAttachment['addon']>>;
31
+ /**
32
+ * Resolves an arbitrary legacy database add-on based on the provided app name.
33
+ *
34
+ * @param app - The name of the app to get the arbitrary legacy database for
35
+ * @returns Promise resolving to the arbitrary legacy database add-on
36
+ * @throws {Error} When no legacy database add-on exists on the app
37
+ */
38
+ getArbitraryLegacyDB(app: string): Promise<ExtendedAddon>;
13
39
  /**
14
40
  * Resolves a database attachment based on the provided database identifier
15
41
  * (attachment name, id, or config var name) and namespace (credential).
@@ -22,6 +48,14 @@ export default class DatabaseResolver {
22
48
  * @throws {AmbiguousError} When multiple matching attachments are found
23
49
  */
24
50
  getAttachment(appId: string, attachmentId?: string, namespace?: string): Promise<ExtendedAddonAttachment>;
51
+ /**
52
+ * Returns the connection details for a database attachment according to the app config vars.
53
+ *
54
+ * @param attachment - The attachment to get the connection details for
55
+ * @param config - The record of app config vars with their values
56
+ * @returns Connection details with attachment information
57
+ */
58
+ getConnectionDetails(attachment: ExtendedAddonAttachment, config?: Record<string, string>): ConnectionDetails;
25
59
  /**
26
60
  * Returns the connection details for a database attachment resolved through the identifiers passed as
27
61
  * arguments: appId, attachmentId and namespace (credential).
@@ -31,14 +65,14 @@ export default class DatabaseResolver {
31
65
  * @param namespace - Optional namespace/credential for the attachment
32
66
  * @returns Promise resolving to connection details with attachment information
33
67
  */
34
- getDatabase(appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetailsWithAttachment>;
68
+ getDatabase(appId: string, attachmentId?: string, namespace?: string): Promise<ConnectionDetails>;
35
69
  /**
36
- * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
70
+ * Helper function that attempts to find all Heroku Postgres attachments on a given app.
37
71
  *
38
- * @param connStringOrDbName - PostgreSQL connection string or local database name
39
- * @returns Connection details object with parsed connection information
72
+ * @param app - The name of the app to get the attachments for
73
+ * @returns Promise resolving to an array of all Heroku Postgres attachments on the app
40
74
  */
41
- static parsePostgresConnectionString(connStringOrDbName: string): ConnectionDetails;
75
+ private allLegacyDatabaseAttachments;
42
76
  /**
43
77
  * Fetches all Heroku PostgreSQL add-on attachments for a given app.
44
78
  *
@@ -51,15 +85,29 @@ export default class DatabaseResolver {
51
85
  */
52
86
  private allPostgresAttachments;
53
87
  /**
54
- * Returns the connection details for a database attachment according to the app config vars.
88
+ * Helper function that groups Heroku Postgres attachments by addon.
55
89
  *
56
- * @param attachment - The attachment to get the connection details for
57
- * @param config - The record of app config vars with their values
58
- * @returns Connection details with attachment information
90
+ * @param attachments - The attachments to group by addon
91
+ * @returns A record of addon IDs with their attachment names
92
+ */
93
+ private getAttachmentNamesByAddon;
94
+ /**
95
+ * Helper function that attempts to find a single addon attachment matching the given database identifier
96
+ * by comparing the identifier to the config var names of all attachments on the app
97
+ * (attachment name, id, or config var name).
98
+ *
99
+ * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
100
+ * It returns either an array with a single match or an empty array if no matches are found.
101
+ *
102
+ * @param attachments - Array of attachments for the specified app ID
103
+ * @param appId - The ID of the app to search for attachments
104
+ * @param attachmentId - The database identifier to match
105
+ * @returns Promise resolving to either a single match or no matches
59
106
  */
60
- getConnectionDetails(attachment: ExtendedAddonAttachment, config?: Record<string, string>): ConnectionDetailsWithAttachment;
107
+ private getAttachmentsViaConfigVarNames;
61
108
  /**
62
109
  * Helper function that attempts to find a single addon attachment matching the given database identifier
110
+ * via the add-on attachments resolver
63
111
  * (attachment name, id, or config var name).
64
112
  *
65
113
  * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
@@ -72,5 +120,5 @@ export default class DatabaseResolver {
72
120
  * @param namespace - Optional namespace/credential filter
73
121
  * @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
74
122
  */
75
- private matchesHelper;
123
+ private getAttachmentsViaResolver;
76
124
  }
@@ -5,7 +5,8 @@ const color_1 = require("@heroku-cli/color");
5
5
  const api_client_1 = require("@heroku-cli/command/lib/api-client");
6
6
  const debug_1 = tslib_1.__importDefault(require("debug"));
7
7
  const ambiguous_1 = require("../../errors/ambiguous");
8
- const resolve_1 = tslib_1.__importDefault(require("../addons/resolve"));
8
+ const attachment_resolver_1 = tslib_1.__importDefault(require("../addons/attachment-resolver"));
9
+ const helpers_1 = require("../addons/helpers");
9
10
  const bastion_1 = require("./bastion");
10
11
  const config_vars_1 = require("./config-vars");
11
12
  const pgDebug = (0, debug_1.default)('pg');
@@ -14,11 +15,73 @@ class DatabaseResolver {
14
15
  this.heroku = heroku;
15
16
  this.getConfigFn = getConfigFn;
16
17
  this.fetchBastionConfigFn = fetchBastionConfigFn;
18
+ this.addonHeaders = {
19
+ Accept: 'application/vnd.heroku+json; version=3.sdk',
20
+ 'Accept-Expansion': 'addon_service,plan',
21
+ };
17
22
  this.attachmentHeaders = {
18
23
  Accept: 'application/vnd.heroku+json; version=3.sdk',
19
24
  'Accept-Inclusion': 'addon:plan,config_vars',
20
25
  };
21
- this.addonAttachmentResolver = new resolve_1.default(this.heroku);
26
+ this.addonAttachmentResolver = new attachment_resolver_1.default(this.heroku);
27
+ }
28
+ /**
29
+ * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
30
+ *
31
+ * @param connStringOrDbName - PostgreSQL connection string or local database name
32
+ * @returns Connection details object with parsed connection information
33
+ */
34
+ static parsePostgresConnectionString(connStringOrDbName) {
35
+ const dbPath = /:\/\//.test(connStringOrDbName) ? connStringOrDbName : `postgres:///${connStringOrDbName}`;
36
+ const url = new URL(dbPath);
37
+ const { hostname, password, pathname, port, username } = url;
38
+ return {
39
+ database: pathname.slice(1), // remove the leading slash from the pathname
40
+ host: hostname,
41
+ password,
42
+ pathname,
43
+ port: port || process.env.PGPORT || (hostname && '5432'),
44
+ url: dbPath,
45
+ user: username,
46
+ };
47
+ }
48
+ /**
49
+ * Return all Heroku Postgres databases on the Legacy tiers for a given app.
50
+ *
51
+ * @param app - The name of the app to get the databases for
52
+ * @returns Promise resolving to all Heroku Postgres databases
53
+ * @throws {Error} When no legacy database add-on exists on the app
54
+ */
55
+ async getAllLegacyDatabases(app) {
56
+ pgDebug(`fetching all legacy databases on ${app}`);
57
+ const allAttachments = await this.allLegacyDatabaseAttachments(app);
58
+ const addons = [];
59
+ for (const attachment of allAttachments) {
60
+ if (!addons.some(a => a.id === attachment.addon.id)) {
61
+ addons.push(attachment.addon);
62
+ }
63
+ }
64
+ const attachmentNamesByAddon = this.getAttachmentNamesByAddon(allAttachments);
65
+ for (const addon of addons) {
66
+ // eslint-disable-next-line camelcase
67
+ addon.attachment_names = attachmentNamesByAddon[addon.id];
68
+ }
69
+ return addons;
70
+ }
71
+ /**
72
+ * Resolves an arbitrary legacy database add-on based on the provided app name.
73
+ *
74
+ * @param app - The name of the app to get the arbitrary legacy database for
75
+ * @returns Promise resolving to the arbitrary legacy database add-on
76
+ * @throws {Error} When no legacy database add-on exists on the app
77
+ */
78
+ async getArbitraryLegacyDB(app) {
79
+ pgDebug(`fetching arbitrary legacy database on ${app}`);
80
+ const { body: addons } = await this.heroku.get(`/apps/${app}/addons`, { headers: this.addonHeaders });
81
+ const addon = addons.find(a => a.app.name === app && (0, helpers_1.isLegacyDatabase)(a));
82
+ if (!addon)
83
+ throw new Error(`No Heroku Postgres legacy database on ${app}`);
84
+ return addon;
22
85
  }
23
86
  /**
24
87
  * Resolves a database attachment based on the provided database identifier
@@ -38,20 +101,24 @@ class DatabaseResolver {
38
101
  appId = appConfigMatch[1];
39
102
  attachmentId = appConfigMatch[2];
40
103
  }
41
- const { error, matches } = await this.matchesHelper(appId, attachmentId, namespace);
104
+ let { error, matches } = await this.getAttachmentsViaResolver(appId, attachmentId, namespace);
42
105
  // happy path where the resolver matches just one
43
106
  if (matches && matches.length === 1) {
44
107
  return matches[0];
45
108
  }
46
- // handle the case where the resolver didn't find any matches for the given database and show valid options.
109
+ // handle the case where the resolver didn't find any matches for the given database.
47
110
  if (!matches) {
48
111
  const attachments = await this.allPostgresAttachments(appId);
49
112
  if (attachments.length === 0) {
50
113
  throw new Error(`${color_1.color.app(appId)} has no databases`);
51
114
  }
52
- else {
115
+ // attempt to find a match using config var names
116
+ matches = await this.getAttachmentsViaConfigVarNames(attachments, appId, attachmentId);
117
+ if (matches.length === 0) {
118
+ const databaseName = attachmentId.endsWith('_URL') ? attachmentId.slice(0, -4) : attachmentId;
53
119
  const validOptions = attachments.map(attachment => (0, config_vars_1.getConfigVarName)(attachment.config_vars));
54
- throw new Error(`Unknown database: ${attachmentId}. Valid options are: ${validOptions.join(', ')}`);
120
+ const validOptionsString = validOptions.map(option => option.endsWith('_URL') ? option.slice(0, -4) : option).join(', ');
121
+ throw new Error(`Unknown database: ${databaseName}. Valid options are: ${validOptionsString}`);
55
122
  }
56
123
  }
57
124
  // handle the case where the resolver found multiple matches for the given database.
@@ -65,6 +132,36 @@ class DatabaseResolver {
65
132
  }
66
133
  throw error;
67
134
  }
135
+ /**
136
+ * Returns the connection details for a database attachment according to the app config vars.
137
+ *
138
+ * @param attachment - The attachment to get the connection details for
139
+ * @param config - The record of app config vars with their values
140
+ * @returns Connection details with attachment information
141
+ */
142
+ getConnectionDetails(attachment, config = {}) {
143
+ const connStringVar = (0, config_vars_1.getConfigVarNameFromAttachment)(attachment, config);
144
+ // build the default payload for non-bastion dbs
145
+ pgDebug(`Using "${connStringVar}" to connect to your database…`);
146
+ const conn = DatabaseResolver.parsePostgresConnectionString(config[connStringVar]);
147
+ const payload = {
148
+ attachment,
149
+ database: conn.database,
150
+ host: conn.host,
151
+ password: conn.password,
152
+ pathname: conn.pathname,
153
+ port: conn.port,
154
+ url: conn.url,
155
+ user: conn.user,
156
+ };
157
+ // This handles injection of bastion creds into the payload if they exist as config vars (Shield-tier databases).
158
+ const baseName = connStringVar.slice(0, -4);
159
+ const bastion = (0, bastion_1.getBastionConfig)(config, baseName);
160
+ if (bastion) {
161
+ Object.assign(payload, bastion);
162
+ }
163
+ return payload;
164
+ }
68
165
  /**
69
166
  * Returns the connection details for a database attachment resolved through the identifiers passed as
70
167
  * arguments: appId, attachmentId and namespace (credential).
@@ -86,25 +183,14 @@ class DatabaseResolver {
86
183
  return database;
87
184
  }
88
185
  /**
89
- * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
186
+ * Helper function that attempts to find all Heroku Postgres attachments on a given app.
90
187
  *
91
- * @param connStringOrDbName - PostgreSQL connection string or local database name
92
- * @returns Connection details object with parsed connection information
188
+ * @param app - The name of the app to get the attachments for
189
+ * @returns Promise resolving to an array of all Heroku Postgres attachments on the app
93
190
  */
94
- // eslint-disable-next-line perfectionist/sort-classes
95
- static parsePostgresConnectionString(connStringOrDbName) {
96
- const dbPath = /:\/\//.test(connStringOrDbName) ? connStringOrDbName : `postgres:///${connStringOrDbName}`;
97
- const url = new URL(dbPath);
98
- const { hostname, password, pathname, port, username } = url;
99
- return {
100
- database: pathname.slice(1), // remove the leading slash from the pathname
101
- host: hostname,
102
- password,
103
- pathname,
104
- port: port || process.env.PGPORT || (hostname && '5432'),
105
- url: dbPath,
106
- user: username,
107
- };
191
+ async allLegacyDatabaseAttachments(app) {
192
+ const { body: attachments } = await this.heroku.get(`/apps/${app}/addon-attachments`, { headers: this.attachmentHeaders });
193
+ return attachments.filter(a => (0, helpers_1.isLegacyDatabase)(a.addon));
108
194
  }
109
195
  /**
110
196
  * Fetches all Heroku PostgreSQL add-on attachments for a given app.
@@ -117,45 +203,45 @@ class DatabaseResolver {
117
203
  * @returns Promise resolving to array of PostgreSQL add-on attachments
118
204
  */
119
205
  async allPostgresAttachments(appId) {
120
- const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
121
206
  const { body: attachments } = await this.heroku.get(`/apps/${appId}/addon-attachments`, {
122
207
  headers: this.attachmentHeaders,
123
208
  });
124
- return attachments.filter(a => a.addon.plan.name.split(':', 2)[0] === addonService);
209
+ return attachments.filter(a => a.addon.plan.name.split(':', 2)[0] === (0, helpers_1.getAddonService)());
125
210
  }
126
211
  /**
127
- * Returns the connection details for a database attachment according to the app config vars.
212
+ * Helper function that groups Heroku Postgres attachments by addon.
128
213
  *
129
- * @param attachment - The attachment to get the connection details for
130
- * @param config - The record of app config vars with their values
131
- * @returns Connection details with attachment information
214
+ * @param attachments - The attachments to group by addon
215
+ * @returns A record of addon IDs with their attachment names
132
216
  */
133
- // eslint-disable-next-line perfectionist/sort-classes
134
- getConnectionDetails(attachment, config = {}) {
135
- const connStringVar = (0, config_vars_1.getConfigVarNameFromAttachment)(attachment, config);
136
- // build the default payload for non-bastion dbs
137
- pgDebug(`Using "${connStringVar}" to connect to your database…`);
138
- const conn = DatabaseResolver.parsePostgresConnectionString(config[connStringVar]);
139
- const payload = {
140
- attachment,
141
- database: conn.database,
142
- host: conn.host,
143
- password: conn.password,
144
- pathname: conn.pathname,
145
- port: conn.port,
146
- url: conn.url,
147
- user: conn.user,
148
- };
149
- // This handles injection of bastion creds into the payload if they exist as config vars (Shield-tier databases).
150
- const baseName = connStringVar.slice(0, -4);
151
- const bastion = (0, bastion_1.getBastionConfig)(config, baseName);
152
- if (bastion) {
153
- Object.assign(payload, bastion);
217
+ getAttachmentNamesByAddon(attachments) {
218
+ const addons = {};
219
+ for (const attachment of attachments) {
220
+ addons[attachment.addon.id] = [...(addons[attachment.addon.id] || []), attachment.name];
154
221
  }
155
- return payload;
222
+ return addons;
223
+ }
224
+ /**
225
+ * Helper function that attempts to find a single addon attachment matching the given database identifier
226
+ * by comparing the identifier to the config var names of all attachments on the app
227
+ * (attachment name, id, or config var name).
228
+ *
229
+ * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
230
+ * It returns either an array with a single match or an empty array if no matches are found.
231
+ *
232
+ * @param attachments - Array of attachments for the specified app ID
233
+ * @param appId - The ID of the app to search for attachments
234
+ * @param attachmentId - The database identifier to match
235
+ * @returns Promise resolving to either a single match or no matches
236
+ */
237
+ async getAttachmentsViaConfigVarNames(attachments, appId, attachmentId) {
238
+ const targetConfigVarName = attachmentId.endsWith('_URL') ? attachmentId : `${attachmentId}_URL`;
239
+ const config = await this.getConfigFn(this.heroku, appId);
240
+ return attachments.filter(attachment => config[targetConfigVarName] && config[targetConfigVarName] === config[(0, config_vars_1.getConfigVarName)(attachment.config_vars)]);
156
241
  }
157
242
  /**
158
243
  * Helper function that attempts to find a single addon attachment matching the given database identifier
244
+ * via the add-on attachments resolver
159
245
  * (attachment name, id, or config var name).
160
246
  *
161
247
  * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
@@ -168,9 +254,9 @@ class DatabaseResolver {
168
254
  * @param namespace - Optional namespace/credential filter
169
255
  * @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
170
256
  */
171
- async matchesHelper(appId, attachmentId, namespace) {
257
+ async getAttachmentsViaResolver(appId, attachmentId, namespace) {
172
258
  (0, debug_1.default)(`fetching ${attachmentId} on ${appId}`);
173
- const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
259
+ const addonService = (0, helpers_1.getAddonService)();
174
260
  (0, debug_1.default)(`addon service: ${addonService}`);
175
261
  try {
176
262
  const attached = await this.addonAttachmentResolver.resolve(appId, attachmentId, { addonService, namespace });
@@ -43,6 +43,16 @@ export default class PsqlService {
43
43
  private readonly spawnFn;
44
44
  private readonly tunnelFn;
45
45
  constructor(connectionDetails: ConnectionDetails, getPsqlConfigsFn?: typeof getPsqlConfigs, spawnFn?: typeof spawn, tunnelFn?: typeof sshTunnel);
46
+ /**
47
+ * Executes a file containing SQL commands using the instance's database connection details.
48
+ * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
49
+ * and then calls the `runWithTunnel` function to execute the file.
50
+ *
51
+ * @param file - The path to the SQL file to execute
52
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
53
+ * @returns Promise that resolves to the query result as a string
54
+ */
55
+ execFile(file: string, psqlCmdArgs?: string[]): Promise<string>;
46
56
  /**
47
57
  * Executes a PostgreSQL query using the instance's database connection details.
48
58
  * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
@@ -53,6 +63,29 @@ export default class PsqlService {
53
63
  * @returns Promise that resolves to the query result as a string
54
64
  */
55
65
  execQuery(query: string, psqlCmdArgs?: string[]): Promise<string>;
66
+ /**
67
+ * Fetches the PostgreSQL version from the database by executing the `SHOW server_version` query.
68
+ *
69
+ * @returns Promise that resolves to the PostgreSQL version as a string (or undefined).
70
+ */
71
+ fetchVersion(): Promise<string | undefined>;
72
+ /**
73
+ * Executes a PostgreSQL interactive session using the instance's database connection details.
74
+ * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
75
+ * and then calls the `runWithTunnel` function to execute the query.
76
+ *
77
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
78
+ * @returns Promise that resolves to the query result as a string
79
+ */
80
+ interactiveSession(psqlCmdArgs?: string[]): Promise<string>;
81
+ /**
82
+ * Runs the psql command with tunnel support.
83
+ *
84
+ * @param tunnelConfig - The tunnel configuration object
85
+ * @param options - The options for spawning the psql process
86
+ * @returns Promise that resolves to the query result as a string
87
+ */
88
+ runWithTunnel(tunnelConfig: TunnelConfig, options: Parameters<typeof this.spawnPsql>[0]): Promise<string>;
56
89
  /**
57
90
  * Consumes a stream and returns its content as a string.
58
91
  *
@@ -72,22 +105,32 @@ export default class PsqlService {
72
105
  */
73
106
  private kill;
74
107
  /**
75
- * Creates the options for spawning the psql process.
108
+ * Creates the options for spawning the psql process for a SQL file execution.
76
109
  *
77
- * @param query - The SQL query to execute
110
+ * @param file - The path to the SQL file to execute
78
111
  * @param dbEnv - The database environment variables
79
112
  * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
80
113
  * @returns Object containing child process options, database environment, and psql arguments
81
114
  */
82
- private psqlQueryOptions;
115
+ private psqlFileOptions;
83
116
  /**
84
- * Runs the psql command with tunnel support.
117
+ * Creates the options for spawning the psql process for an interactive psql session.
85
118
  *
86
- * @param tunnelConfig - The tunnel configuration object
87
- * @param options - The options for spawning the psql process
88
- * @returns Promise that resolves to the query result as a string
119
+ * @param prompt - The prompt to use for the interactive psql session
120
+ * @param dbEnv - The database environment variables
121
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
122
+ * @returns Object containing child process options, database environment, and psql arguments
89
123
  */
90
- runWithTunnel(tunnelConfig: TunnelConfig, options: Parameters<typeof this.spawnPsql>[0]): Promise<string>;
124
+ private psqlInteractiveOptions;
125
+ /**
126
+ * Creates the options for spawning the psql process for a single query execution.
127
+ *
128
+ * @param query - The SQL query to execute
129
+ * @param dbEnv - The database environment variables
130
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
131
+ * @returns Object containing child process options, database environment, and psql arguments
132
+ */
133
+ private psqlQueryOptions;
91
134
  /**
92
135
  * Spawns the psql process with the given options.
93
136
  *
@@ -2,9 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Tunnel = void 0;
4
4
  const tslib_1 = require("tslib");
5
+ const core_1 = require("@oclif/core");
5
6
  const debug_1 = tslib_1.__importDefault(require("debug"));
6
7
  const node_child_process_1 = require("node:child_process");
7
8
  const node_events_1 = require("node:events");
9
+ const node_fs_1 = tslib_1.__importDefault(require("node:fs"));
10
+ const node_path_1 = tslib_1.__importDefault(require("node:path"));
8
11
  const node_stream_1 = require("node:stream");
9
12
  const promises_1 = require("node:stream/promises");
10
13
  const bastion_1 = require("./bastion");
@@ -82,6 +85,20 @@ class PsqlService {
82
85
  this.spawnFn = spawnFn;
83
86
  this.tunnelFn = tunnelFn;
84
87
  }
88
+ /**
89
+ * Executes a file containing SQL commands using the instance's database connection details.
90
+ * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
91
+ * and then calls the `runWithTunnel` function to execute the file.
92
+ *
93
+ * @param file - The path to the SQL file to execute
94
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
95
+ * @returns Promise that resolves to the query result as a string
96
+ */
97
+ async execFile(file, psqlCmdArgs = []) {
98
+ const configs = this.getPsqlConfigsFn(this.connectionDetails);
99
+ const options = this.psqlFileOptions(file, configs.dbEnv, psqlCmdArgs);
100
+ return this.runWithTunnel(configs.dbTunnelConfig, options);
101
+ }
85
102
  /**
86
103
  * Executes a PostgreSQL query using the instance's database connection details.
87
104
  * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
@@ -96,6 +113,72 @@ class PsqlService {
96
113
  const options = this.psqlQueryOptions(query, configs.dbEnv, psqlCmdArgs);
97
114
  return this.runWithTunnel(configs.dbTunnelConfig, options);
98
115
  }
116
+ /**
117
+ * Fetches the PostgreSQL version from the database by executing the `SHOW server_version` query.
118
+ *
119
+ * @returns Promise that resolves to the PostgreSQL version as a string (or undefined).
120
+ */
121
+ async fetchVersion() {
122
+ var _a;
123
+ const output = await this.execQuery('SHOW server_version', ['-X', '-q']);
124
+ return (_a = output.match(/\d+\.\d+/)) === null || _a === void 0 ? void 0 : _a[0];
125
+ }
126
+ /**
127
+ * Executes a PostgreSQL interactive session using the instance's database connection details.
128
+ * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
129
+ * and then calls the `runWithTunnel` function to execute the query.
130
+ *
131
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
132
+ * @returns Promise that resolves to the query result as a string
133
+ */
134
+ async interactiveSession(psqlCmdArgs = []) {
135
+ const attachmentName = this.connectionDetails.attachment.name;
136
+ const prompt = `${this.connectionDetails.attachment.app.name}::${attachmentName}%R%# `;
137
+ const configs = this.getPsqlConfigsFn(this.connectionDetails);
138
+ configs.dbEnv.PGAPPNAME = 'psql interactive'; // default was 'psql non-interactive`
139
+ const options = this.psqlInteractiveOptions(prompt, configs.dbEnv, psqlCmdArgs);
140
+ return this.runWithTunnel(configs.dbTunnelConfig, options);
141
+ }
142
+ /**
143
+ * Runs the psql command with tunnel support.
144
+ *
145
+ * @param tunnelConfig - The tunnel configuration object
146
+ * @param options - The options for spawning the psql process
147
+ * @returns Promise that resolves to the query result as a string
148
+ */
149
+ async runWithTunnel(tunnelConfig, options) {
150
+ const tunnel = await Tunnel.connect(this.connectionDetails, tunnelConfig, this.tunnelFn);
151
+ pgDebug('after create tunnel');
152
+ const psql = this.spawnPsql(options);
153
+ // Note: In non-interactive mode, psql.stdout is available for capturing output.
154
+ // In interactive mode, stdio: 'inherit' would make psql.stdout null.
155
+ // Return a string for consistency but ideally we should return the child process from this function
156
+ // and let the caller decide what to do with stdin/stdout/stderr
157
+ const stdoutPromise = psql.stdout ? this.consumeStream(psql.stdout) : Promise.resolve('');
158
+ const cleanupSignalTraps = this.trapAndForwardSignalsToChildProcess(psql);
159
+ try {
160
+ pgDebug('waiting for psql or tunnel to exit');
161
+ // wait for either psql or tunnel to exit;
162
+ // the important bit is that we ensure both processes are
163
+ // always cleaned up in the `finally` block below
164
+ await Promise.race([
165
+ this.waitForPSQLExit(psql),
166
+ tunnel.waitForClose(),
167
+ ]);
168
+ }
169
+ catch (error) {
170
+ pgDebug('wait for psql or tunnel error', error);
171
+ throw error;
172
+ }
173
+ finally {
174
+ pgDebug('begin tunnel cleanup');
175
+ cleanupSignalTraps();
176
+ tunnel.close();
177
+ this.kill(psql, 'SIGKILL');
178
+ pgDebug('end tunnel cleanup');
179
+ }
180
+ return stdoutPromise;
181
+ }
99
182
  /**
100
183
  * Consumes a stream and returns its content as a string.
101
184
  *
@@ -137,19 +220,19 @@ class PsqlService {
137
220
  }
138
221
  }
139
222
  /**
140
- * Creates the options for spawning the psql process.
223
+ * Creates the options for spawning the psql process for a SQL file execution.
141
224
  *
142
- * @param query - The SQL query to execute
225
+ * @param file - The path to the SQL file to execute
143
226
  * @param dbEnv - The database environment variables
144
227
  * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
145
228
  * @returns Object containing child process options, database environment, and psql arguments
146
229
  */
147
- psqlQueryOptions(query, dbEnv, psqlCmdArgs = []) {
148
- pgDebug('Running query: %s', query.trim());
149
- const psqlArgs = ['-c', query, '--set', 'sslmode=require', ...psqlCmdArgs];
230
+ psqlFileOptions(file, dbEnv, psqlCmdArgs = []) {
231
+ pgDebug('Running SQL file: %s', file.trim());
150
232
  const childProcessOptions = {
151
233
  stdio: ['ignore', 'pipe', 'inherit'],
152
234
  };
235
+ const psqlArgs = ['-f', file, '--set', 'sslmode=require', ...psqlCmdArgs];
153
236
  return {
154
237
  childProcessOptions,
155
238
  dbEnv,
@@ -157,45 +240,59 @@ class PsqlService {
157
240
  };
158
241
  }
159
242
  /**
160
- * Runs the psql command with tunnel support.
243
+ * Creates the options for spawning the psql process for an interactive psql session.
161
244
  *
162
- * @param tunnelConfig - The tunnel configuration object
163
- * @param options - The options for spawning the psql process
164
- * @returns Promise that resolves to the query result as a string
245
+ * @param prompt - The prompt to use for the interactive psql session
246
+ * @param dbEnv - The database environment variables
247
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
248
+ * @returns Object containing child process options, database environment, and psql arguments
165
249
  */
166
- // eslint-disable-next-line perfectionist/sort-classes
167
- async runWithTunnel(tunnelConfig, options) {
168
- const tunnel = await Tunnel.connect(this.connectionDetails, tunnelConfig, this.tunnelFn);
169
- pgDebug('after create tunnel');
170
- const psql = this.spawnPsql(options);
171
- // Note: In non-interactive mode, psql.stdout is available for capturing output.
172
- // In interactive mode, stdio: 'inherit' would make psql.stdout null.
173
- // Return a string for consistency but ideally we should return the child process from this function
174
- // and let the caller decide what to do with stdin/stdout/stderr
175
- const stdoutPromise = psql.stdout ? this.consumeStream(psql.stdout) : Promise.resolve('');
176
- const cleanupSignalTraps = this.trapAndForwardSignalsToChildProcess(psql);
177
- try {
178
- pgDebug('waiting for psql or tunnel to exit');
179
- // wait for either psql or tunnel to exit;
180
- // the important bit is that we ensure both processes are
181
- // always cleaned up in the `finally` block below
182
- await Promise.race([
183
- this.waitForPSQLExit(psql),
184
- tunnel.waitForClose(),
185
- ]);
186
- }
187
- catch (error) {
188
- pgDebug('wait for psql or tunnel error', error);
189
- throw error;
190
- }
191
- finally {
192
- pgDebug('begin tunnel cleanup');
193
- cleanupSignalTraps();
194
- tunnel.close();
195
- this.kill(psql, 'SIGKILL');
196
- pgDebug('end tunnel cleanup');
250
+ psqlInteractiveOptions(prompt, dbEnv, psqlCmdArgs = []) {
251
+ let psqlArgs = ['--set', `PROMPT1=${prompt}`, '--set', `PROMPT2=${prompt}`];
252
+ const psqlHistoryPath = process.env.HEROKU_PSQL_HISTORY;
253
+ if (psqlHistoryPath) {
254
+ if (node_fs_1.default.existsSync(psqlHistoryPath) && node_fs_1.default.statSync(psqlHistoryPath).isDirectory()) {
255
+ const appLogFile = `${psqlHistoryPath}/${prompt.split(':')[0]}`;
256
+ pgDebug('Logging psql history to %s', appLogFile);
257
+ psqlArgs = [...psqlArgs, '--set', `HISTFILE=${appLogFile}`];
258
+ }
259
+ else if (node_fs_1.default.existsSync(node_path_1.default.dirname(psqlHistoryPath))) {
260
+ pgDebug('Logging psql history to %s', psqlHistoryPath);
261
+ psqlArgs = [...psqlArgs, '--set', `HISTFILE=${psqlHistoryPath}`];
262
+ }
263
+ else {
264
+ core_1.ux.warn(`HEROKU_PSQL_HISTORY is set but is not a valid path (${psqlHistoryPath})\n`);
265
+ }
197
266
  }
198
- return stdoutPromise;
267
+ psqlArgs = [...psqlArgs, '--set', 'sslmode=require', ...psqlCmdArgs];
268
+ const childProcessOptions = {
269
+ stdio: ['inherit', 'inherit', 'inherit'],
270
+ };
271
+ return {
272
+ childProcessOptions,
273
+ dbEnv,
274
+ psqlArgs,
275
+ };
276
+ }
277
+ /**
278
+ * Creates the options for spawning the psql process for a single query execution.
279
+ *
280
+ * @param query - The SQL query to execute
281
+ * @param dbEnv - The database environment variables
282
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
283
+ * @returns Object containing child process options, database environment, and psql arguments
284
+ */
285
+ psqlQueryOptions(query, dbEnv, psqlCmdArgs = []) {
286
+ pgDebug('Running query: %s', query.trim());
287
+ const psqlArgs = ['-c', query, '--set', 'sslmode=require', ...psqlCmdArgs];
288
+ const childProcessOptions = {
289
+ stdio: ['ignore', 'pipe', 'inherit'],
290
+ };
291
+ return {
292
+ childProcessOptions,
293
+ dbEnv,
294
+ psqlArgs,
295
+ };
199
296
  }
200
297
  /**
201
298
  * Spawns the psql process with the given options.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroku/heroku-cli-util",
3
- "version": "9.1.3",
3
+ "version": "9.2.0",
4
4
  "description": "Set of helpful CLI utilities",
5
5
  "author": "Heroku",
6
6
  "license": "ISC",
@@ -15,6 +15,7 @@
15
15
  "@types/chai": "^4.3.13",
16
16
  "@types/chai-as-promised": "^8.0.2",
17
17
  "@types/debug": "^4.1.12",
18
+ "@types/tmp": "^0.2.6",
18
19
  "@types/mocha": "^10.0.10",
19
20
  "@types/node": "^22.15.3",
20
21
  "@types/sinon": "^17.0.4",
@@ -33,6 +34,7 @@
33
34
  "sinon-chai": "^3.7.0",
34
35
  "stdout-stderr": "^0.1.13",
35
36
  "strip-ansi": "^6",
37
+ "tmp": "^0.2.5",
36
38
  "ts-node": "^10.9.2",
37
39
  "tsconfig-paths": "^4.2.0",
38
40
  "tsheredoc": "^1.0.1",
File without changes