@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.
@@ -3,155 +3,41 @@ import { spawn, } from 'node:child_process';
3
3
  import { EventEmitter, once } from 'node:events';
4
4
  import { Stream } from 'node:stream';
5
5
  import { finished } from 'node:stream/promises';
6
- import { getConfigs, sshTunnel } from './bastion.js';
6
+ import { getPsqlConfigs, sshTunnel } from './bastion.js';
7
7
  const pgDebug = debug('pg');
8
- export function consumeStream(inputStream) {
9
- let result = '';
10
- const throughStream = new Stream.PassThrough();
11
- // eslint-disable-next-line no-async-promise-executor
12
- const promise = new Promise(async (resolve, reject) => {
13
- try {
14
- await finished(throughStream);
15
- resolve(result);
16
- }
17
- catch (error) {
18
- reject(error);
19
- }
20
- });
21
- // eslint-disable-next-line no-return-assign
22
- throughStream.on('data', chunk => result += chunk.toString());
23
- inputStream.pipe(throughStream);
24
- return promise;
25
- }
26
- export async function exec(db, query, cmdArgs = []) {
27
- const configs = getConfigs(db);
28
- const options = psqlQueryOptions(query, configs.dbEnv, cmdArgs);
29
- return runWithTunnel(db, configs.dbTunnelConfig, options);
30
- }
31
- export function psqlQueryOptions(query, dbEnv, cmdArgs = []) {
32
- pgDebug('Running query: %s', query.trim());
33
- const psqlArgs = ['-c', query, '--set', 'sslmode=require', ...cmdArgs];
34
- const childProcessOptions = {
35
- stdio: ['ignore', 'pipe', 'inherit'],
36
- };
37
- return {
38
- childProcessOptions,
39
- dbEnv,
40
- psqlArgs,
41
- };
42
- }
43
- export function execPSQL({ childProcessOptions, dbEnv, psqlArgs }) {
44
- const options = {
45
- env: dbEnv,
46
- ...childProcessOptions,
47
- };
48
- pgDebug('opening psql process');
49
- const psql = spawn('psql', psqlArgs, options);
50
- psql.once('spawn', () => pgDebug('psql process spawned'));
51
- return psql;
52
- }
53
- // According to node.js docs, sending a kill to a process won't cause an error
54
- // but could have unintended consequences if the PID gets reassigned:
55
- // https://nodejs.org/docs/latest-v14.x/api/child_process.html#child_process_subprocess_kill_signal
56
- // To be on the safe side, check if the process was already killed before sending the signal
57
- function kill(childProcess, signal) {
58
- if (!childProcess.killed) {
59
- pgDebug('killing psql child process');
60
- childProcess.kill(signal);
61
- }
62
- }
63
- export async function runWithTunnel(db, tunnelConfig, options) {
64
- const tunnel = await Tunnel.connect(db, tunnelConfig);
65
- pgDebug('after create tunnel');
66
- const psql = execPSQL(options);
67
- // interactive opens with stdio: 'inherit'
68
- // which gives the child process the same stdin,stdout,stderr of the node process (global `process`)
69
- // https://nodejs.org/api/child_process.html#child_process_options_stdio
70
- // psql.stdout will be null in this case
71
- // return a string for consistency but ideally we should return the child process from this function
72
- // and let the caller decide what to do with stdin/stdout/stderr
73
- const stdoutPromise = psql.stdout ? consumeStream(psql.stdout) : Promise.resolve('');
74
- const cleanupSignalTraps = trapAndForwardSignalsToChildProcess(psql);
75
- try {
76
- pgDebug('waiting for psql or tunnel to exit');
77
- // wait for either psql or tunnel to exit;
78
- // the important bit is that we ensure both processes are
79
- // always cleaned up in the `finally` block below
80
- await Promise.race([
81
- waitForPSQLExit(psql),
82
- tunnel.waitForClose(),
83
- ]);
84
- }
85
- catch (error) {
86
- pgDebug('wait for psql or tunnel error', error);
87
- throw error;
88
- }
89
- finally {
90
- pgDebug('begin tunnel cleanup');
91
- cleanupSignalTraps();
92
- tunnel.close();
93
- kill(psql, 'SIGKILL');
94
- pgDebug('end tunnel cleanup');
95
- }
96
- return stdoutPromise;
97
- }
98
- // trap SIGINT so that ctrl+c can be used by psql without killing the
99
- // parent node process.
100
- // you can use ctrl+c in psql to kill running queries
101
- // while keeping the psql process open.
102
- // This code is to stop the parent node process (heroku CLI)
103
- // from exiting. If the parent Heroku CLI node process exits, then psql will exit as it
104
- // is a child process of the Heroku CLI node process.
105
- export const trapAndForwardSignalsToChildProcess = (childProcess) => {
106
- const signalsToTrap = ['SIGINT'];
107
- const signalTraps = signalsToTrap.map(signal => {
108
- process.removeAllListeners(signal);
109
- const listener = () => kill(childProcess, signal);
110
- process.on(signal, listener);
111
- return [signal, listener];
112
- });
113
- // restores the built-in node ctrl+c and other handlers
114
- return () => {
115
- for (const [signal, listener] of signalTraps) {
116
- process.removeListener(signal, listener);
117
- }
118
- };
119
- };
120
- export async function waitForPSQLExit(psql) {
121
- let errorToThrow = null;
122
- try {
123
- const [exitCode] = await once(psql, 'close');
124
- pgDebug(`psql exited with code ${exitCode}`);
125
- if (exitCode > 0) {
126
- errorToThrow = new Error(`psql exited with code ${exitCode}`);
127
- }
128
- }
129
- catch (error) {
130
- pgDebug('psql process error', error);
131
- const { code } = error;
132
- if (code === 'ENOENT') {
133
- errorToThrow = new Error('The local psql command could not be located. For help installing psql, see https://devcenter.heroku.com/articles/heroku-postgresql#local-setup');
134
- }
135
- }
136
- if (errorToThrow) {
137
- throw errorToThrow;
138
- }
139
- }
140
- // a small wrapper around tunnel-ssh
141
- // so that other code doesn't have to worry about
142
- // whether there is or is not a tunnel
8
+ /**
9
+ * A small wrapper around tunnel-ssh so that other code doesn't have to worry about whether there is or is not a tunnel.
10
+ */
143
11
  export class Tunnel {
144
12
  bastionTunnel;
145
13
  events;
14
+ /**
15
+ * Creates a new Tunnel instance.
16
+ *
17
+ * @param bastionTunnel - The SSH tunnel server or void if no tunnel is needed
18
+ */
146
19
  constructor(bastionTunnel) {
147
20
  this.bastionTunnel = bastionTunnel;
148
21
  // eslint-disable-next-line unicorn/prefer-event-target
149
22
  this.events = new EventEmitter();
150
23
  }
151
- static async connect(db, tunnelConfig) {
152
- const tunnel = await sshTunnel(db, tunnelConfig);
24
+ /**
25
+ * Creates and connects to an SSH tunnel.
26
+ *
27
+ * @param connectionDetails - The database connection details with attachment information
28
+ * @param tunnelConfig - The tunnel configuration object
29
+ * @param tunnelFn - The function to create the SSH tunnel (default: sshTunnel)
30
+ * @returns Promise that resolves to a new Tunnel instance
31
+ */
32
+ static async connect(connectionDetails, tunnelConfig, tunnelFn) {
33
+ const tunnel = await tunnelFn(connectionDetails, tunnelConfig);
153
34
  return new Tunnel(tunnel);
154
35
  }
36
+ /**
37
+ * Closes the tunnel if it exists, or emits a fake close event if no tunnel is needed.
38
+ *
39
+ * @returns void
40
+ */
155
41
  close() {
156
42
  if (this.bastionTunnel) {
157
43
  pgDebug('close tunnel');
@@ -162,6 +48,12 @@ export class Tunnel {
162
48
  this.events.emit('close', 0);
163
49
  }
164
50
  }
51
+ /**
52
+ * Waits for the tunnel to close.
53
+ *
54
+ * @returns Promise that resolves when the tunnel closes
55
+ * @throws Error if the secure tunnel fails
56
+ */
165
57
  async waitForClose() {
166
58
  if (this.bastionTunnel) {
167
59
  try {
@@ -175,8 +67,203 @@ export class Tunnel {
175
67
  }
176
68
  }
177
69
  else {
178
- pgDebug('no bastion required; waiting for fake close event');
70
+ pgDebug('no tunnel required; waiting for fake close event');
179
71
  await once(this.events, 'close');
180
72
  }
181
73
  }
182
74
  }
75
+ export default class PsqlService {
76
+ connectionDetails;
77
+ getPsqlConfigsFn;
78
+ spawnFn;
79
+ tunnelFn;
80
+ constructor(connectionDetails, getPsqlConfigsFn = getPsqlConfigs, spawnFn = spawn, tunnelFn = sshTunnel) {
81
+ this.connectionDetails = connectionDetails;
82
+ this.getPsqlConfigsFn = getPsqlConfigsFn;
83
+ this.spawnFn = spawnFn;
84
+ this.tunnelFn = tunnelFn;
85
+ }
86
+ /**
87
+ * Executes a PostgreSQL query using the instance's database connection details.
88
+ * It uses the `getPsqlConfigs` function to get the configuration for the database and the tunnel,
89
+ * and then calls the `runWithTunnel` function to execute the query.
90
+ *
91
+ * @param query - The SQL query to execute
92
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
93
+ * @returns Promise that resolves to the query result as a string
94
+ */
95
+ async execQuery(query, psqlCmdArgs = []) {
96
+ const configs = this.getPsqlConfigsFn(this.connectionDetails);
97
+ const options = this.psqlQueryOptions(query, configs.dbEnv, psqlCmdArgs);
98
+ return this.runWithTunnel(configs.dbTunnelConfig, options);
99
+ }
100
+ /**
101
+ * Consumes a stream and returns its content as a string.
102
+ *
103
+ * @param inputStream - The input stream to consume
104
+ * @returns Promise that resolves to the stream content as a string
105
+ */
106
+ consumeStream(inputStream) {
107
+ let result = '';
108
+ const throughStream = new Stream.PassThrough();
109
+ // eslint-disable-next-line no-async-promise-executor
110
+ const promise = new Promise(async (resolve, reject) => {
111
+ try {
112
+ await finished(throughStream);
113
+ resolve(result);
114
+ }
115
+ catch (error) {
116
+ reject(error);
117
+ }
118
+ });
119
+ // eslint-disable-next-line no-return-assign
120
+ throughStream.on('data', chunk => result += chunk.toString());
121
+ inputStream.pipe(throughStream);
122
+ return promise;
123
+ }
124
+ /**
125
+ * Kills a child process if it hasn't been killed already.
126
+ * According to node.js docs, sending a kill to a process won't cause an error
127
+ * but could have unintended consequences if the PID gets reassigned.
128
+ * To be on the safe side, check if the process was already killed before sending the signal.
129
+ *
130
+ * @param childProcess - The child process to kill
131
+ * @param signal - The signal to send to the process
132
+ * @returns void
133
+ */
134
+ kill(childProcess, signal) {
135
+ if (!childProcess.killed) {
136
+ pgDebug('killing psql child process');
137
+ childProcess.kill(signal);
138
+ }
139
+ }
140
+ /**
141
+ * Creates the options for spawning the psql process.
142
+ *
143
+ * @param query - The SQL query to execute
144
+ * @param dbEnv - The database environment variables
145
+ * @param psqlCmdArgs - Additional command-line arguments for psql (default: [])
146
+ * @returns Object containing child process options, database environment, and psql arguments
147
+ */
148
+ psqlQueryOptions(query, dbEnv, psqlCmdArgs = []) {
149
+ pgDebug('Running query: %s', query.trim());
150
+ const psqlArgs = ['-c', query, '--set', 'sslmode=require', ...psqlCmdArgs];
151
+ const childProcessOptions = {
152
+ stdio: ['ignore', 'pipe', 'inherit'],
153
+ };
154
+ return {
155
+ childProcessOptions,
156
+ dbEnv,
157
+ psqlArgs,
158
+ };
159
+ }
160
+ /**
161
+ * Runs the psql command with tunnel support.
162
+ *
163
+ * @param tunnelConfig - The tunnel configuration object
164
+ * @param options - The options for spawning the psql process
165
+ * @returns Promise that resolves to the query result as a string
166
+ */
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');
197
+ }
198
+ return stdoutPromise;
199
+ }
200
+ /**
201
+ * Spawns the psql process with the given options.
202
+ *
203
+ * @param options - The options for spawning the psql process
204
+ * @returns The spawned child process
205
+ */
206
+ spawnPsql(options) {
207
+ const { childProcessOptions, dbEnv, psqlArgs } = options;
208
+ const spawnOptions = {
209
+ env: dbEnv,
210
+ ...childProcessOptions,
211
+ };
212
+ pgDebug('opening psql process');
213
+ const psql = this.spawnFn('psql', psqlArgs, spawnOptions);
214
+ psql.once('spawn', () => pgDebug('psql process spawned'));
215
+ return psql;
216
+ }
217
+ /**
218
+ * Traps SIGINT so that ctrl+c can be used by psql without killing the parent node process.
219
+ * You can use ctrl+c in psql to kill running queries while keeping the psql process open.
220
+ * This code is to stop the parent node process (heroku CLI) from exiting.
221
+ * If the parent Heroku CLI node process exits, then psql will exit as it is a child process.
222
+ *
223
+ * @param childProcess - The child process to forward signals to
224
+ * @returns Function to restore the original signal handlers
225
+ */
226
+ trapAndForwardSignalsToChildProcess(childProcess) {
227
+ const signalsToTrap = ['SIGINT'];
228
+ const signalTraps = signalsToTrap.map(signal => {
229
+ process.removeAllListeners(signal);
230
+ const listener = () => this.kill(childProcess, signal);
231
+ process.on(signal, listener);
232
+ return [signal, listener];
233
+ });
234
+ // restores the built-in node ctrl+c and other handlers
235
+ return () => {
236
+ for (const [signal, listener] of signalTraps) {
237
+ process.removeListener(signal, listener);
238
+ }
239
+ };
240
+ }
241
+ /**
242
+ * Waits for the psql process to exit and handles any errors.
243
+ *
244
+ * @param psql - The psql process event emitter
245
+ * @throws Error if psql exits with non-zero code or if psql command is not found
246
+ * @returns Promise that resolves to void when psql exits
247
+ */
248
+ async waitForPSQLExit(psql) {
249
+ let errorToThrow = null;
250
+ try {
251
+ const [exitCode] = await once(psql, 'close');
252
+ pgDebug(`psql exited with code ${exitCode}`);
253
+ if (exitCode > 0) {
254
+ errorToThrow = new Error(`psql exited with code ${exitCode}`);
255
+ }
256
+ }
257
+ catch (error) {
258
+ pgDebug('psql process error', error);
259
+ const { code } = error;
260
+ if (code === 'ENOENT') {
261
+ errorToThrow = new Error('The local psql command could not be located. For help installing psql, see '
262
+ + 'https://devcenter.heroku.com/articles/heroku-postgresql#local-setup');
263
+ }
264
+ }
265
+ if (errorToThrow) {
266
+ throw errorToThrow;
267
+ }
268
+ }
269
+ }
@@ -10,5 +10,5 @@ type Columns<T extends Record<string, unknown>> = {
10
10
  };
11
11
  export declare function table<T extends Record<string, unknown>>(data: T[], columns: Columns<T>, options?: {
12
12
  printLine?(s: unknown): void;
13
- } & Omit<TableOptions<T>, 'data'>): void;
13
+ } & Omit<TableOptions<T>, 'columns' | 'data'>): void;
14
14
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@heroku/heroku-cli-util",
4
- "version": "10.0.0-beta.2",
4
+ "version": "10.1.0",
5
5
  "description": "Set of helpful CLI utilities",
6
6
  "author": "Heroku",
7
7
  "license": "ISC",
@@ -11,8 +11,8 @@
11
11
  "dist"
12
12
  ],
13
13
  "devDependencies": {
14
- "@heroku-cli/test-utils": "0.1.1",
15
14
  "@heroku-cli/schema": "^2.0.0",
15
+ "@heroku-cli/test-utils": "0.1.1",
16
16
  "@types/chai": "^4.3.13",
17
17
  "@types/chai-as-promised": "^8.0.2",
18
18
  "@types/debug": "^4.1.12",
@@ -20,7 +20,9 @@
20
20
  "@types/mocha": "^10.0.10",
21
21
  "@types/node": "^22.15.3",
22
22
  "@types/sinon": "^17.0.4",
23
+ "@types/sinon-chai": "^4.0.0",
23
24
  "@types/tunnel-ssh": "4.1.1",
25
+ "c8": "^7.7.0",
24
26
  "chai": "^4.4.1",
25
27
  "chai-as-promised": "^8.0.1",
26
28
  "eslint": "^8.57.0",
@@ -31,8 +33,8 @@
31
33
  "mocha": "^10.8.2",
32
34
  "mock-stdin": "^1.0.0",
33
35
  "nock": "^13.2.9",
34
- "nyc": "^17.1.0",
35
36
  "sinon": "^18.0.1",
37
+ "sinon-chai": "^3.7.0",
36
38
  "strip-ansi": "^6",
37
39
  "ts-node": "^10.9.2",
38
40
  "tsconfig-paths": "^4.2.0",
@@ -58,7 +60,7 @@
58
60
  "example": "sh examples/run.sh",
59
61
  "lint": "eslint . --ext .ts --config .eslintrc.cjs",
60
62
  "prepare": "npm run build",
61
- "test": "nyc mocha --forbid-only \"test/**/*.test.ts\"",
62
- "test:local": "nyc mocha \"${npm_config_file:-test/**/*.test.+(ts|tsx)}\""
63
+ "test": "c8 mocha --forbid-only \"test/**/*.test.ts\"",
64
+ "test:local": "c8 mocha \"${npm_config_file:-test/**/*.test.+(ts|tsx)}\""
63
65
  }
64
66
  }
@@ -1,15 +0,0 @@
1
- export declare class AmbiguousError extends Error {
2
- readonly matches: {
3
- name?: string;
4
- }[];
5
- readonly type: string;
6
- readonly body: {
7
- id: string;
8
- message: string;
9
- };
10
- readonly message: string;
11
- readonly statusCode = 422;
12
- constructor(matches: {
13
- name?: string;
14
- }[], type: string);
15
- }
@@ -1,5 +0,0 @@
1
- export declare class NotFound extends Error {
2
- readonly id = "not_found";
3
- readonly message = "Couldn't find that addon.";
4
- readonly statusCode = 404;
5
- }
@@ -1,5 +0,0 @@
1
- export class NotFound extends Error {
2
- id = 'not_found';
3
- message = 'Couldn\'t find that addon.';
4
- statusCode = 404;
5
- }