@heroku/heroku-cli-util 10.0.0-beta.2 → 10.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,128 +1,193 @@
1
1
  import { color } from '@heroku-cli/color';
2
2
  import { HerokuAPIError } from '@heroku-cli/command/lib/api-client.js';
3
3
  import debug from 'debug';
4
- import { env } from 'node:process';
5
- import { AmbiguousError } from '../../types/errors/ambiguous.js';
6
- import { appAttachment } from '../addons/resolve.js';
7
- import { bastionKeyPlan, fetchConfig, getBastion } from './bastion.js';
4
+ import { AmbiguousError } from '../../errors/ambiguous.js';
5
+ import AddonAttachmentResolver from '../addons/resolve.js';
6
+ import { bastionKeyPlan, fetchBastionConfig, getBastionConfig } from './bastion.js';
8
7
  import { getConfig, getConfigVarName, getConfigVarNameFromAttachment } from './config-vars.js';
9
8
  const pgDebug = debug('pg');
10
- async function allAttachments(heroku, appId) {
11
- const { body: attachments } = await heroku.get(`/apps/${appId}/addon-attachments`, {
12
- headers: { 'Accept-Inclusion': 'addon:plan,config_vars' },
13
- });
14
- return attachments.filter((a) => a.addon.plan?.name?.startsWith('heroku-postgresql'));
15
- }
16
- export async function getAttachment(heroku, app, db = 'DATABASE_URL', namespace = '') {
17
- const matchesOrError = await matchesHelper(heroku, app, db, namespace);
18
- let { matches } = matchesOrError;
19
- const { error } = matchesOrError;
20
- // happy path where the resolver matches just one
21
- if (matches && matches.length === 1) {
22
- return matches[0];
9
+ export default class DatabaseResolver {
10
+ heroku;
11
+ getConfigFn;
12
+ fetchBastionConfigFn;
13
+ addonAttachmentResolver;
14
+ attachmentHeaders = {
15
+ Accept: 'application/vnd.heroku+json; version=3.sdk',
16
+ 'Accept-Inclusion': 'addon:plan,config_vars',
17
+ };
18
+ constructor(heroku, getConfigFn = getConfig, fetchBastionConfigFn = fetchBastionConfig) {
19
+ this.heroku = heroku;
20
+ this.getConfigFn = getConfigFn;
21
+ this.fetchBastionConfigFn = fetchBastionConfigFn;
22
+ this.addonAttachmentResolver = new AddonAttachmentResolver(this.heroku);
23
23
  }
24
- // case for 404 where there are implicit attachments
25
- if (!matches) {
26
- const appConfigMatch = /^(.+?)::(.+)/.exec(db);
24
+ /**
25
+ * Resolves a database attachment based on the provided database identifier
26
+ * (attachment name, id, or config var name) and namespace (credential).
27
+ *
28
+ * @param appId - The ID of the app to get the attachment for
29
+ * @param attachmentId - The database identifier (defaults to 'DATABASE_URL')
30
+ * @param namespace - Optional namespace/credential for the attachment
31
+ * @returns Promise resolving to the database attachment
32
+ * @throws {Error} When no databases exist or when database identifier is unknown
33
+ * @throws {AmbiguousError} When multiple matching attachments are found
34
+ */
35
+ async getAttachment(appId, attachmentId = 'DATABASE_URL', namespace) {
36
+ // handle the case where the user passes an app::database format, overriding any app name option values.
37
+ const appConfigMatch = /^(.+?)::(.+)/.exec(attachmentId);
27
38
  if (appConfigMatch) {
28
- app = appConfigMatch[1];
29
- db = appConfigMatch[2];
39
+ appId = appConfigMatch[1];
40
+ attachmentId = appConfigMatch[2];
30
41
  }
31
- if (!db.endsWith('_URL')) {
32
- db += '_URL';
42
+ const { error, matches } = await this.matchesHelper(appId, attachmentId, namespace);
43
+ // happy path where the resolver matches just one
44
+ if (matches && matches.length === 1) {
45
+ return matches[0];
33
46
  }
34
- const [config = {}, attachments] = await Promise.all([
35
- getConfig(heroku, app),
36
- allAttachments(heroku, app),
37
- ]);
38
- if (attachments.length === 0) {
39
- throw new Error(`${color.app(app)} has no databases`);
47
+ // handle the case where the resolver didn't find any matches for the given database and show valid options.
48
+ if (!matches) {
49
+ const attachments = await this.allPostgresAttachments(appId);
50
+ if (attachments.length === 0) {
51
+ throw new Error(`${color.app(appId)} has no databases`);
52
+ }
53
+ else {
54
+ const validOptions = attachments.map(attachment => getConfigVarName(attachment.config_vars));
55
+ throw new Error(`Unknown database: ${attachmentId}. Valid options are: ${validOptions.join(', ')}`);
56
+ }
40
57
  }
41
- matches = attachments.filter(attachment => config[db] && config[db] === config[getConfigVarName(attachment.config_vars)]);
42
- if (matches.length === 0) {
43
- const validOptions = attachments.map(attachment => getConfigVarName(attachment.config_vars));
44
- throw new Error(`Unknown database: ${db}. Valid options are: ${validOptions.join(', ')}`);
58
+ // handle the case where the resolver found multiple matches for the given database.
59
+ const first = matches[0];
60
+ // return the first attachment when all ambiguous attachments are equivalent (basically target the same database)
61
+ if (matches.every(match => first.addon.id === match.addon.id && first.app.id === match.app.id)) {
62
+ const config = await this.getConfigFn(this.heroku, first.app.name);
63
+ if (matches.every(match => config[getConfigVarName(first.config_vars)] === config[getConfigVarName(match.config_vars)])) {
64
+ return first;
65
+ }
45
66
  }
67
+ throw error;
46
68
  }
47
- // case for multiple attachments with passedDb
48
- const first = matches[0];
49
- // case for 422 where there are ambiguous attachments that are equivalent
50
- if (matches.every(match => first.addon?.id === match.addon?.id && first.app?.id === match.app?.id)) {
51
- const config = await getConfig(heroku, first.app.name) ?? {};
52
- if (matches.every(match => config[getConfigVarName(first.addon.config_vars)] === config[getConfigVarName(match.config_vars)])) {
53
- return first;
69
+ /**
70
+ * Returns the connection details for a database attachment resolved through the identifiers passed as
71
+ * arguments: appId, attachmentId and namespace (credential).
72
+ *
73
+ * @param appId - The ID of the app containing the database
74
+ * @param attachmentId - Optional database identifier (defaults to 'DATABASE_URL')
75
+ * @param namespace - Optional namespace/credential for the attachment
76
+ * @returns Promise resolving to connection details with attachment information
77
+ */
78
+ async getDatabase(appId, attachmentId, namespace) {
79
+ const attached = await this.getAttachment(appId, attachmentId, namespace);
80
+ const config = await this.getConfigFn(this.heroku, attached.app.name);
81
+ const database = this.getConnectionDetails(attached, config);
82
+ // Add bastion configuration if it's a non-shielded Private Space add-on and we still don't have the config.
83
+ if (bastionKeyPlan(attached) && !database.bastionKey) {
84
+ const bastionConfig = await this.fetchBastionConfigFn(this.heroku, attached.addon);
85
+ Object.assign(database, bastionConfig);
54
86
  }
87
+ return database;
55
88
  }
56
- throw error;
57
- }
58
- export const getConnectionDetails = (attachment, configVars = {}) => {
59
- const connStringVar = getConfigVarNameFromAttachment(attachment, configVars);
60
- // remove _URL from the end of the config var name
61
- const baseName = connStringVar.slice(0, -4);
62
- // build the default payload for non-bastion dbs
63
- pgDebug(`Using "${connStringVar}" to connect to your database…`);
64
- const conn = parsePostgresConnectionString(configVars[connStringVar]);
65
- const payload = {
66
- attachment,
67
- database: conn.database,
68
- host: conn.host,
69
- password: conn.password,
70
- pathname: conn.pathname,
71
- port: conn.port,
72
- url: conn.url,
73
- user: conn.user,
74
- };
75
- // If bastion creds exist, graft it into the payload
76
- const bastion = getBastion(configVars, baseName);
77
- if (bastion) {
78
- Object.assign(payload, bastion);
89
+ /**
90
+ * Parses a PostgreSQL connection string (or a local database name) into a ConnectionDetails object.
91
+ *
92
+ * @param connStringOrDbName - PostgreSQL connection string or local database name
93
+ * @returns Connection details object with parsed connection information
94
+ */
95
+ 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
+ };
79
108
  }
80
- return payload;
81
- };
82
- export async function getDatabase(heroku, app, db, namespace) {
83
- const attached = await getAttachment(heroku, app, db, namespace);
84
- // would inline this as well but in some cases attachment pulls down config
85
- // as well, and we would request twice at the same time but I did not want
86
- // to push this down into attachment because we do not always need config
87
- const config = await getConfig(heroku, attached.app.name);
88
- const database = getConnectionDetails(attached, config);
89
- if (bastionKeyPlan(attached.addon) && !database.bastionKey) {
90
- const { body: bastionConfig } = await fetchConfig(heroku, attached.addon);
91
- const bastionHost = bastionConfig.host;
92
- const bastionKey = bastionConfig.private_key;
93
- Object.assign(database, { bastionHost, bastionKey });
109
+ /**
110
+ * Fetches all Heroku PostgreSQL add-on attachments for a given app.
111
+ *
112
+ * This is used internally by the `getAttachment` function to get all valid Heroku PostgreSQL add-on attachments
113
+ * to generate a list of possible valid attachments when the user passes a database name that doesn't match any
114
+ * attachments.
115
+ *
116
+ * @param appId - The ID of the app to get the attachments for
117
+ * @returns Promise resolving to array of PostgreSQL add-on attachments
118
+ */
119
+ async allPostgresAttachments(appId) {
120
+ const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
121
+ const { body: attachments } = await this.heroku.get(`/apps/${appId}/addon-attachments`, {
122
+ headers: this.attachmentHeaders,
123
+ });
124
+ return attachments.filter(a => a.addon.plan.name.split(':', 2)[0] === addonService);
94
125
  }
95
- return database;
96
- }
97
- async function matchesHelper(heroku, app, db, namespace) {
98
- debug(`fetching ${db} on ${app}`);
99
- const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
100
- debug(`addon service: ${addonService}`);
101
- try {
102
- const attached = await appAttachment(heroku, app, db, { addon_service: addonService, namespace });
103
- return ({ matches: [attached] });
126
+ /**
127
+ * Returns the connection details for a database attachment according to the app config vars.
128
+ *
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
132
+ */
133
+ getConnectionDetails(attachment, config = {}) {
134
+ const connStringVar = getConfigVarNameFromAttachment(attachment, config);
135
+ // build the default payload for non-bastion dbs
136
+ pgDebug(`Using "${connStringVar}" to connect to your database…`);
137
+ const conn = this.parsePostgresConnectionString(config[connStringVar]);
138
+ const payload = {
139
+ attachment,
140
+ database: conn.database,
141
+ host: conn.host,
142
+ password: conn.password,
143
+ pathname: conn.pathname,
144
+ port: conn.port,
145
+ url: conn.url,
146
+ user: conn.user,
147
+ };
148
+ // This handles injection of bastion creds into the payload if they exist as config vars (Shield-tier databases).
149
+ const baseName = connStringVar.slice(0, -4);
150
+ const bastion = getBastionConfig(config, baseName);
151
+ if (bastion) {
152
+ Object.assign(payload, bastion);
153
+ }
154
+ return payload;
104
155
  }
105
- catch (error) {
106
- if (error instanceof AmbiguousError && error.body?.id === 'multiple_matches' && error.matches) {
107
- return { error, matches: error.matches };
156
+ /**
157
+ * Helper function that attempts to find a single addon attachment matching the given database identifier
158
+ * (attachment name, id, or config var name).
159
+ *
160
+ * This is used internally by the `getAttachment` function to handle the lookup of addon attachments.
161
+ * It returns either a single match, multiple matches (for ambiguous cases), or null if no matches are found.
162
+ *
163
+ * The AddonAttachmentResolver uses the Platform API add-on attachment resolver endpoint to get the attachment.
164
+ *
165
+ * @param appId - The ID of the app to search for attachments
166
+ * @param attachmentId - The database identifier to match
167
+ * @param namespace - Optional namespace/credential filter
168
+ * @returns Promise resolving to either a single match, multiple matches with error, or no matches with error
169
+ */
170
+ async matchesHelper(appId, attachmentId, namespace) {
171
+ debug(`fetching ${attachmentId} on ${appId}`);
172
+ const addonService = process.env.HEROKU_POSTGRESQL_ADDON_NAME || 'heroku-postgresql';
173
+ debug(`addon service: ${addonService}`);
174
+ try {
175
+ const attached = await this.addonAttachmentResolver.resolve(appId, attachmentId, { addonService, namespace });
176
+ return { error: undefined, matches: [attached] };
108
177
  }
109
- if (error instanceof HerokuAPIError && error.http.statusCode === 404 && error.body && error.body.id === 'not_found') {
110
- return { error, matches: null };
178
+ catch (error) {
179
+ if (error instanceof AmbiguousError && error.body.id === 'multiple_matches' && error.matches) {
180
+ return { error, matches: error.matches };
181
+ }
182
+ // This handles the case where the resolver returns a 404 error when making the request, but not the case
183
+ // where it returns a NotFound error because there were no matches after filtering by namespace.
184
+ if (error instanceof HerokuAPIError
185
+ && error.http.statusCode === 404
186
+ && error.body && error.body.id === 'not_found') {
187
+ return { error, matches: null };
188
+ }
189
+ // This re-throws a NotFound error or any other HerokuAPIError except for the 404 case which is handled above.
190
+ throw error;
111
191
  }
112
- throw error;
113
192
  }
114
193
  }
115
- export const parsePostgresConnectionString = (db) => {
116
- const dbPath = /:\/\//.test(db) ? db : `postgres:///${db}`;
117
- const url = new URL(dbPath);
118
- const { hostname, password, pathname, port, username } = url;
119
- return {
120
- database: pathname.charAt(0) === '/' ? pathname.slice(1) : pathname,
121
- host: hostname,
122
- password,
123
- pathname,
124
- port: port || env.PGPORT || (hostname && '5432'),
125
- url: dbPath,
126
- user: username,
127
- };
128
- };
@@ -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.js';
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.js';
4
+ import { getPsqlConfigs, sshTunnel } from './bastion.js';
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
+ }