@heroku/heroku-cli-util 9.0.1 → 9.1.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.
@@ -1,137 +1,193 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parsePostgresConnectionString = exports.getConnectionDetails = void 0;
4
- exports.getAttachment = getAttachment;
5
- exports.getDatabase = getDatabase;
3
+ const tslib_1 = require("tslib");
6
4
  const color_1 = require("@heroku-cli/color");
7
5
  const api_client_1 = require("@heroku-cli/command/lib/api-client");
8
- const debug_1 = require("debug");
9
- const node_process_1 = require("node:process");
10
- const ambiguous_1 = require("../../types/errors/ambiguous");
11
- const resolve_1 = require("../addons/resolve");
6
+ const debug_1 = tslib_1.__importDefault(require("debug"));
7
+ const ambiguous_1 = require("../../errors/ambiguous");
8
+ const resolve_1 = tslib_1.__importDefault(require("../addons/resolve"));
12
9
  const bastion_1 = require("./bastion");
13
10
  const config_vars_1 = require("./config-vars");
14
11
  const pgDebug = (0, debug_1.default)('pg');
15
- async function allAttachments(heroku, appId) {
16
- const { body: attachments } = await heroku.get(`/apps/${appId}/addon-attachments`, {
17
- headers: { 'Accept-Inclusion': 'addon:plan,config_vars' },
18
- });
19
- return attachments.filter((a) => { var _a, _b; return (_b = (_a = a.addon.plan) === null || _a === void 0 ? void 0 : _a.name) === null || _b === void 0 ? void 0 : _b.startsWith('heroku-postgresql'); });
20
- }
21
- async function getAttachment(heroku, app, db = 'DATABASE_URL', namespace = '') {
22
- var _a;
23
- const matchesOrError = await matchesHelper(heroku, app, db, namespace);
24
- let { matches } = matchesOrError;
25
- const { error } = matchesOrError;
26
- // happy path where the resolver matches just one
27
- if (matches && matches.length === 1) {
28
- return matches[0];
12
+ class DatabaseResolver {
13
+ constructor(heroku, getConfigFn = config_vars_1.getConfig, fetchBastionConfigFn = bastion_1.fetchBastionConfig) {
14
+ this.heroku = heroku;
15
+ this.getConfigFn = getConfigFn;
16
+ this.fetchBastionConfigFn = fetchBastionConfigFn;
17
+ this.attachmentHeaders = {
18
+ Accept: 'application/vnd.heroku+json; version=3.sdk',
19
+ 'Accept-Inclusion': 'addon:plan,config_vars',
20
+ };
21
+ this.addonAttachmentResolver = new resolve_1.default(this.heroku);
29
22
  }
30
- // case for 404 where there are implicit attachments
31
- if (!matches) {
32
- const appConfigMatch = /^(.+?)::(.+)/.exec(db);
23
+ /**
24
+ * Resolves a database attachment based on the provided database identifier
25
+ * (attachment name, id, or config var name) and namespace (credential).
26
+ *
27
+ * @param appId - The ID of the app to get the attachment for
28
+ * @param attachmentId - The database identifier (defaults to 'DATABASE_URL')
29
+ * @param namespace - Optional namespace/credential for the attachment
30
+ * @returns Promise resolving to the database attachment
31
+ * @throws {Error} When no databases exist or when database identifier is unknown
32
+ * @throws {AmbiguousError} When multiple matching attachments are found
33
+ */
34
+ async getAttachment(appId, attachmentId = 'DATABASE_URL', namespace) {
35
+ // handle the case where the user passes an app::database format, overriding any app name option values.
36
+ const appConfigMatch = /^(.+?)::(.+)/.exec(attachmentId);
33
37
  if (appConfigMatch) {
34
- app = appConfigMatch[1];
35
- db = appConfigMatch[2];
38
+ appId = appConfigMatch[1];
39
+ attachmentId = appConfigMatch[2];
36
40
  }
37
- if (!db.endsWith('_URL')) {
38
- db += '_URL';
41
+ const { error, matches } = await this.matchesHelper(appId, attachmentId, namespace);
42
+ // happy path where the resolver matches just one
43
+ if (matches && matches.length === 1) {
44
+ return matches[0];
39
45
  }
40
- const [config = {}, attachments] = await Promise.all([
41
- (0, config_vars_1.getConfig)(heroku, app),
42
- allAttachments(heroku, app),
43
- ]);
44
- if (attachments.length === 0) {
45
- throw new Error(`${color_1.default.app(app)} has no databases`);
46
+ // handle the case where the resolver didn't find any matches for the given database and show valid options.
47
+ if (!matches) {
48
+ const attachments = await this.allPostgresAttachments(appId);
49
+ if (attachments.length === 0) {
50
+ throw new Error(`${color_1.color.app(appId)} has no databases`);
51
+ }
52
+ else {
53
+ 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(', ')}`);
55
+ }
46
56
  }
47
- matches = attachments.filter(attachment => config[db] && config[db] === config[(0, config_vars_1.getConfigVarName)(attachment.config_vars)]);
48
- if (matches.length === 0) {
49
- const validOptions = attachments.map(attachment => (0, config_vars_1.getConfigVarName)(attachment.config_vars));
50
- throw new Error(`Unknown database: ${db}. Valid options are: ${validOptions.join(', ')}`);
57
+ // handle the case where the resolver found multiple matches for the given database.
58
+ const first = matches[0];
59
+ // return the first attachment when all ambiguous attachments are equivalent (basically target the same database)
60
+ if (matches.every(match => first.addon.id === match.addon.id && first.app.id === match.app.id)) {
61
+ const config = await this.getConfigFn(this.heroku, first.app.name);
62
+ if (matches.every(match => config[(0, config_vars_1.getConfigVarName)(first.config_vars)] === config[(0, config_vars_1.getConfigVarName)(match.config_vars)])) {
63
+ return first;
64
+ }
51
65
  }
66
+ throw error;
52
67
  }
53
- // case for multiple attachments with passedDb
54
- const first = matches[0];
55
- // case for 422 where there are ambiguous attachments that are equivalent
56
- if (matches.every(match => { var _a, _b, _c, _d; return ((_a = first.addon) === null || _a === void 0 ? void 0 : _a.id) === ((_b = match.addon) === null || _b === void 0 ? void 0 : _b.id) && ((_c = first.app) === null || _c === void 0 ? void 0 : _c.id) === ((_d = match.app) === null || _d === void 0 ? void 0 : _d.id); })) {
57
- const config = (_a = await (0, config_vars_1.getConfig)(heroku, first.app.name)) !== null && _a !== void 0 ? _a : {};
58
- if (matches.every(match => config[(0, config_vars_1.getConfigVarName)(first.addon.config_vars)] === config[(0, config_vars_1.getConfigVarName)(match.config_vars)])) {
59
- return first;
68
+ /**
69
+ * Returns the connection details for a database attachment resolved through the identifiers passed as
70
+ * arguments: appId, attachmentId and namespace (credential).
71
+ *
72
+ * @param appId - The ID of the app containing the database
73
+ * @param attachmentId - Optional database identifier (defaults to 'DATABASE_URL')
74
+ * @param namespace - Optional namespace/credential for the attachment
75
+ * @returns Promise resolving to connection details with attachment information
76
+ */
77
+ async getDatabase(appId, attachmentId, namespace) {
78
+ const attached = await this.getAttachment(appId, attachmentId, namespace);
79
+ const config = await this.getConfigFn(this.heroku, attached.app.name);
80
+ const database = this.getConnectionDetails(attached, config);
81
+ // Add bastion configuration if it's a non-shielded Private Space add-on and we still don't have the config.
82
+ if ((0, bastion_1.bastionKeyPlan)(attached) && !database.bastionKey) {
83
+ const bastionConfig = await this.fetchBastionConfigFn(this.heroku, attached.addon);
84
+ Object.assign(database, bastionConfig);
60
85
  }
86
+ return database;
61
87
  }
62
- throw error;
63
- }
64
- const getConnectionDetails = (attachment, configVars = {}) => {
65
- const connStringVar = (0, config_vars_1.getConfigVarNameFromAttachment)(attachment, configVars);
66
- // remove _URL from the end of the config var name
67
- const baseName = connStringVar.slice(0, -4);
68
- // build the default payload for non-bastion dbs
69
- pgDebug(`Using "${connStringVar}" to connect to your database…`);
70
- const conn = (0, exports.parsePostgresConnectionString)(configVars[connStringVar]);
71
- const payload = {
72
- attachment,
73
- database: conn.database,
74
- host: conn.host,
75
- password: conn.password,
76
- pathname: conn.pathname,
77
- port: conn.port,
78
- url: conn.url,
79
- user: conn.user,
80
- };
81
- // If bastion creds exist, graft it into the payload
82
- const bastion = (0, bastion_1.getBastion)(configVars, baseName);
83
- if (bastion) {
84
- Object.assign(payload, bastion);
88
+ /**
89
+ * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
90
+ *
91
+ * @param connStringOrDbName - PostgreSQL connection string or local database name
92
+ * @returns Connection details object with parsed connection information
93
+ */
94
+ parsePostgresConnectionString(connStringOrDbName) {
95
+ const dbPath = /:\/\//.test(connStringOrDbName) ? connStringOrDbName : `postgres:///${connStringOrDbName}`;
96
+ const url = new URL(dbPath);
97
+ const { hostname, password, pathname, port, username } = url;
98
+ return {
99
+ database: pathname.slice(1), // remove the leading slash from the pathname
100
+ host: hostname,
101
+ password,
102
+ pathname,
103
+ port: port || process.env.PGPORT || (hostname && '5432'),
104
+ url: dbPath,
105
+ user: username,
106
+ };
85
107
  }
86
- return payload;
87
- };
88
- exports.getConnectionDetails = getConnectionDetails;
89
- async function getDatabase(heroku, app, db, namespace) {
90
- const attached = await getAttachment(heroku, app, db, namespace);
91
- // would inline this as well but in some cases attachment pulls down config
92
- // as well, and we would request twice at the same time but I did not want
93
- // to push this down into attachment because we do not always need config
94
- const config = await (0, config_vars_1.getConfig)(heroku, attached.app.name);
95
- const database = (0, exports.getConnectionDetails)(attached, config);
96
- if ((0, bastion_1.bastionKeyPlan)(attached.addon) && !database.bastionKey) {
97
- const { body: bastionConfig } = await (0, bastion_1.fetchConfig)(heroku, attached.addon);
98
- const bastionHost = bastionConfig.host;
99
- const bastionKey = bastionConfig.private_key;
100
- Object.assign(database, { bastionHost, bastionKey });
108
+ /**
109
+ * Fetches all Heroku PostgreSQL add-on attachments for a given app.
110
+ *
111
+ * This is used internally by the `getAttachment` function to get all valid Heroku PostgreSQL add-on attachments
112
+ * to generate a list of possible valid attachments when the user passes a database name that doesn't match any
113
+ * attachments.
114
+ *
115
+ * @param appId - The ID of the app to get the attachments for
116
+ * @returns Promise resolving to array of PostgreSQL add-on attachments
117
+ */
118
+ async allPostgresAttachments(appId) {
119
+ const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
120
+ const { body: attachments } = await this.heroku.get(`/apps/${appId}/addon-attachments`, {
121
+ headers: this.attachmentHeaders,
122
+ });
123
+ return attachments.filter(a => a.addon.plan.name.split(':', 2)[0] === addonService);
101
124
  }
102
- return database;
103
- }
104
- async function matchesHelper(heroku, app, db, namespace) {
105
- var _a;
106
- (0, debug_1.default)(`fetching ${db} on ${app}`);
107
- const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
108
- (0, debug_1.default)(`addon service: ${addonService}`);
109
- try {
110
- const attached = await (0, resolve_1.appAttachment)(heroku, app, db, { addon_service: addonService, namespace });
111
- return ({ matches: [attached] });
125
+ /**
126
+ * Returns the connection details for a database attachment according to the app config vars.
127
+ *
128
+ * @param attachment - The attachment to get the connection details for
129
+ * @param config - The record of app config vars with their values
130
+ * @returns Connection details with attachment information
131
+ */
132
+ getConnectionDetails(attachment, config = {}) {
133
+ const connStringVar = (0, config_vars_1.getConfigVarNameFromAttachment)(attachment, config);
134
+ // build the default payload for non-bastion dbs
135
+ pgDebug(`Using "${connStringVar}" to connect to your database…`);
136
+ const conn = this.parsePostgresConnectionString(config[connStringVar]);
137
+ const payload = {
138
+ attachment,
139
+ database: conn.database,
140
+ host: conn.host,
141
+ password: conn.password,
142
+ pathname: conn.pathname,
143
+ port: conn.port,
144
+ url: conn.url,
145
+ user: conn.user,
146
+ };
147
+ // This handles injection of bastion creds into the payload if they exist as config vars (Shield-tier databases).
148
+ const baseName = connStringVar.slice(0, -4);
149
+ const bastion = (0, bastion_1.getBastionConfig)(config, baseName);
150
+ if (bastion) {
151
+ Object.assign(payload, bastion);
152
+ }
153
+ return payload;
112
154
  }
113
- catch (error) {
114
- if (error instanceof ambiguous_1.AmbiguousError && ((_a = error.body) === null || _a === void 0 ? void 0 : _a.id) === 'multiple_matches' && error.matches) {
115
- return { error, matches: error.matches };
155
+ /**
156
+ * Helper function that attempts to find a single addon attachment matching the given database identifier
157
+ * (attachment name, id, or config var name).
158
+ *
159
+ * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
160
+ * It returns either a single match, multiple matches (for ambiguous cases), or null if no matches are found.
161
+ *
162
+ * The AddonAttachmentResolver uses the Platform API add-on attachment resolver endpoint to get the attachment.
163
+ *
164
+ * @param appId - The ID of the app to search for attachments
165
+ * @param attachmentId - The database identifier to match
166
+ * @param namespace - Optional namespace/credential filter
167
+ * @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
168
+ */
169
+ async matchesHelper(appId, attachmentId, namespace) {
170
+ (0, debug_1.default)(`fetching ${attachmentId} on ${appId}`);
171
+ const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
172
+ (0, debug_1.default)(`addon service: ${addonService}`);
173
+ try {
174
+ const attached = await this.addonAttachmentResolver.resolve(appId, attachmentId, { addonService, namespace });
175
+ return { error: undefined, matches: [attached] };
116
176
  }
117
- if (error instanceof api_client_1.HerokuAPIError && error.http.statusCode === 404 && error.body && error.body.id === 'not_found') {
118
- return { error, matches: null };
177
+ catch (error) {
178
+ if (error instanceof ambiguous_1.AmbiguousError && error.body.id === 'multiple_matches' && error.matches) {
179
+ return { error, matches: error.matches };
180
+ }
181
+ // This handles the case where the resolver returns a 404 error when making the request, but not the case
182
+ // where it returns a NotFound error because there were no matches after filtering by namespace.
183
+ if (error instanceof api_client_1.HerokuAPIError
184
+ && error.http.statusCode === 404
185
+ && error.body && error.body.id === 'not_found') {
186
+ return { error, matches: null };
187
+ }
188
+ // This re-throws a NotFound error or any other HerokuAPIError except for the 404 case which is handled above.
189
+ throw error;
119
190
  }
120
- throw error;
121
191
  }
122
192
  }
123
- const parsePostgresConnectionString = (db) => {
124
- const dbPath = /:\/\//.test(db) ? db : `postgres:///${db}`;
125
- const url = new URL(dbPath);
126
- const { hostname, password, pathname, port, username } = url;
127
- return {
128
- database: pathname.charAt(0) === '/' ? pathname.slice(1) : pathname,
129
- host: hostname,
130
- password,
131
- pathname,
132
- port: port || node_process_1.env.PGPORT || (hostname && '5432'),
133
- url: dbPath,
134
- user: username,
135
- };
136
- };
137
- exports.parsePostgresConnectionString = parsePostgresConnectionString;
193
+ exports.default = DatabaseResolver;
@@ -1,28 +1,116 @@
1
- import { type ChildProcess, type SpawnOptions, type SpawnOptionsWithStdioTuple } from 'node:child_process';
2
- import { EventEmitter } from 'node:events';
1
+ import { spawn } from 'node:child_process';
3
2
  import { Server } from 'node:net';
4
- import { Stream } from 'node:stream';
5
- import { ConnectionDetails, TunnelConfig } from '../../types/pg/tunnel';
6
- export declare function consumeStream(inputStream: Stream): Promise<unknown>;
7
- export declare function exec(db: ConnectionDetails, query: string, cmdArgs?: string[]): Promise<string>;
8
- export declare function psqlQueryOptions(query: string, dbEnv: NodeJS.ProcessEnv, cmdArgs?: string[]): {
9
- childProcessOptions: SpawnOptionsWithStdioTuple<"ignore", "pipe", "inherit">;
10
- dbEnv: NodeJS.ProcessEnv;
11
- psqlArgs: string[];
12
- };
13
- export declare function execPSQL({ childProcessOptions, dbEnv, psqlArgs }: {
14
- childProcessOptions: SpawnOptions;
15
- dbEnv: NodeJS.ProcessEnv;
16
- psqlArgs: string[];
17
- }): ChildProcess;
18
- export declare function runWithTunnel(db: ConnectionDetails, tunnelConfig: TunnelConfig, options: Parameters<typeof execPSQL>[0]): Promise<string>;
19
- export declare const trapAndForwardSignalsToChildProcess: (childProcess: ChildProcess) => () => void;
20
- export declare function waitForPSQLExit(psql: EventEmitter): Promise<void>;
3
+ import { ConnectionDetailsWithAttachment, TunnelConfig } from '../../types/pg/tunnel';
4
+ import { getPsqlConfigs, sshTunnel } from './bastion';
5
+ /**
6
+ * A small wrapper around tunnel-ssh so that other code doesn't have to worry about whether there is or is not a tunnel.
7
+ */
21
8
  export declare class Tunnel {
22
9
  private readonly bastionTunnel;
23
10
  private readonly events;
24
- constructor(bastionTunnel: Server);
25
- static connect(db: ConnectionDetails, tunnelConfig: TunnelConfig): Promise<Tunnel>;
11
+ /**
12
+ * Creates a new Tunnel instance.
13
+ *
14
+ * @param bastionTunnel - The SSH tunnel server or void if no tunnel is needed
15
+ */
16
+ constructor(bastionTunnel: Server | void);
17
+ /**
18
+ * Creates and connects to an SSH tunnel.
19
+ *
20
+ * @param connectionDetails - The database connection details with attachment information
21
+ * @param tunnelConfig - The tunnel configuration object
22
+ * @param tunnelFn - The function to create the SSH tunnel (default: sshTunnel)
23
+ * @returns Promise that resolves to a new Tunnel instance
24
+ */
25
+ static connect(connectionDetails: ConnectionDetailsWithAttachment, tunnelConfig: TunnelConfig, tunnelFn: typeof sshTunnel): Promise<Tunnel>;
26
+ /**
27
+ * Closes the tunnel if it exists, or emits a fake close event if no tunnel is needed.
28
+ *
29
+ * @returns void
30
+ */
26
31
  close(): void;
32
+ /**
33
+ * Waits for the tunnel to close.
34
+ *
35
+ * @returns Promise that resolves when the tunnel closes
36
+ * @throws Error if the secure tunnel fails
37
+ */
27
38
  waitForClose(): Promise<void>;
28
39
  }
40
+ export default class PsqlService {
41
+ private readonly connectionDetails;
42
+ private readonly getPsqlConfigsFn;
43
+ private readonly spawnFn;
44
+ private readonly tunnelFn;
45
+ constructor(connectionDetails: ConnectionDetailsWithAttachment, getPsqlConfigsFn?: typeof getPsqlConfigs, spawnFn?: typeof spawn, tunnelFn?: typeof sshTunnel);
46
+ /**
47
+ * Executes a PostgreSQL query 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 query.
50
+ *
51
+ * @param query - The SQL query 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
+ execQuery(query: string, psqlCmdArgs?: string[]): Promise<string>;
56
+ /**
57
+ * Consumes a stream and returns its content as a string.
58
+ *
59
+ * @param inputStream - The input stream to consume
60
+ * @returns Promise that resolves to the stream content as a string
61
+ */
62
+ private consumeStream;
63
+ /**
64
+ * Kills a child process if it hasn't been killed already.
65
+ * According to node.js docs, sending a kill to a process won't cause an error
66
+ * but could have unintended consequences if the PID gets reassigned.
67
+ * To be on the safe side, check if the process was already killed before sending the signal.
68
+ *
69
+ * @param childProcess - The child process to kill
70
+ * @param signal - The signal to send to the process
71
+ * @returns void
72
+ */
73
+ private kill;
74
+ /**
75
+ * Creates the options for spawning the psql process.
76
+ *
77
+ * @param query - The SQL query to execute
78
+ * @param dbEnv - The database environment variables
79
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
80
+ * @returns Object containing child process options, database environment, and psql arguments
81
+ */
82
+ private psqlQueryOptions;
83
+ /**
84
+ * Runs the psql command with tunnel support.
85
+ *
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
89
+ */
90
+ private runWithTunnel;
91
+ /**
92
+ * Spawns the psql process with the given options.
93
+ *
94
+ * @param options - The options for spawning the psql process
95
+ * @returns The spawned child process
96
+ */
97
+ private spawnPsql;
98
+ /**
99
+ * Traps SIGINT so that ctrl+c can be used by psql without killing the parent node process.
100
+ * You can use ctrl+c in psql to kill running queries while keeping the psql process open.
101
+ * This code is to stop the parent node process (heroku CLI) from exiting.
102
+ * If the parent Heroku CLI node process exits, then psql will exit as it is a child process.
103
+ *
104
+ * @param childProcess - The child process to forward signals to
105
+ * @returns Function to restore the original signal handlers
106
+ */
107
+ private trapAndForwardSignalsToChildProcess;
108
+ /**
109
+ * Waits for the psql process to exit and handles any errors.
110
+ *
111
+ * @param psql - The psql process event emitter
112
+ * @throws Error if psql exits with non-zero code or if psql command is not found
113
+ * @returns Promise that resolves to void when psql exits
114
+ */
115
+ private waitForPSQLExit;
116
+ }