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