ssh-client-wrapper 2.15.0 → 2.17.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
@@ -60,6 +60,10 @@ Creates a new SSH client instance.
60
60
  * `keyFile` (string, optional): The path to the private key file for key-based authentication.
61
61
  * `passphrase` (string | Function, optional): The passphrase for the private key, or a function that returns it.
62
62
  * `noStrictHostKeyChecking` (boolean, optional): If `true`, bypasses strict host key checking.
63
+ * `maxRetry` (number, optional): Max number of retries for a retryable error. Defaults to `3`.
64
+ * `retryDuration` (number, optional): Time (msec) to wait between each retry. Defaults to `1000`.
65
+ * `retryableExitCodes` (number[], optional): Additional rsync exit codes to treat as retryable for `.send()`/`.recv()`. See [Retry behavior for `.send()` / `.recv()`](#retry-behavior-for-send--recv) below.
66
+ * `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in retryable exit code list instead of adding to it.
63
67
  * And more... see `lib/index.js` for all available options.
64
68
 
65
69
  ### `.exec(cmd, [timeout], [outputCallback], [rcfile], [prependCmd])`
@@ -82,20 +86,57 @@ Executes a command and interacts with it, similar to the `expect` tool.
82
86
 
83
87
  * Returns: `Promise<number>` - The return code of the command.
84
88
 
85
- ### `.send(src, dst, [opt], [timeout])`
89
+ ### `.send(src, dst, [opt], [timeout], [retryableExitCodes], [replaceRetryableExitCodes])`
86
90
  Uploads files or directories to the remote host using `rsync`.
87
91
 
88
92
  * `src` (string[]): An array of local paths to send.
89
93
  * `dst` (string): The remote destination path.
94
+ * `retryableExitCodes` (number[], optional): Per-call override of retryable rsync exit codes for this call only (takes precedence over `hostInfo.retryableExitCodes`). See [Retry behavior for `.send()` / `.recv()`](#retry-behavior-for-send--recv).
95
+ * `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in default instead of adding to it. Defaults to `false`.
90
96
  * Returns: `Promise<void>`
91
97
 
92
- ### `.recv(src, dst, [opt], [timeout])`
98
+ ### `.recv(src, dst, [opt], [timeout], [retryableExitCodes], [replaceRetryableExitCodes])`
93
99
  Downloads files or directories from the remote host using `rsync`.
94
100
 
95
101
  * `src` (string[]): An array of remote paths to receive.
96
102
  * `dst` (string): The local destination path.
103
+ * `retryableExitCodes` (number[], optional): Per-call override of retryable rsync exit codes for this call only (takes precedence over `hostInfo.retryableExitCodes`). See [Retry behavior for `.send()` / `.recv()`](#retry-behavior-for-send--recv).
104
+ * `replaceRetryableExitCodes` (boolean, optional): If `true`, `retryableExitCodes` replaces the built-in default instead of adding to it. Defaults to `false`.
97
105
  * Returns: `Promise<void>`
98
106
 
107
+ ### Retry behavior for `.send()` / `.recv()`
108
+
109
+ `.send()` and `.recv()` automatically retry (up to `hostInfo.maxRetry` times, default `3`, waiting `hostInfo.retryDuration` msec between attempts, default `1000`) when:
110
+
111
+ * the underlying `rsync` process exits with one of the built-in retryable exit codes: `10, 11, 12, 13, 14` (I/O and protocol errors that are commonly transient), or
112
+ * the SSH connection fails with a transient `kex_exchange_identification` error.
113
+
114
+ If your environment sees other rsync exit codes that should also be retried (for example on a shared HPC filesystem where a "partial transfer" exit code can occur while output files are still being flushed), you can add to or replace the retryable list without changing the library's default, at two levels:
115
+
116
+ * **Per instance**, via `hostInfo.retryableExitCodes` / `hostInfo.replaceRetryableExitCodes` — applies to every `.send()`/`.recv()` call made through that `SshClientWrapper`.
117
+ * **Per call**, via the trailing `retryableExitCodes` / `replaceRetryableExitCodes` arguments on `.send()`/`.recv()` — applies only to that call, and fully overrides the instance-level setting if provided.
118
+
119
+ By default (`replaceRetryableExitCodes: false`), `retryableExitCodes` is **added** to the built-in list — the common case, since it doesn't require knowing or repeating the built-in codes. Set `replaceRetryableExitCodes: true` to use `retryableExitCodes` verbatim instead (pass `[]` to disable exit-code-based retry entirely).
120
+
121
+ ```javascript
122
+ // add exit codes 23 and 24 to the built-in retryable list, for every send/recv on this instance
123
+ const ssh = new SshClientWrapper({
124
+ host: 'remote.server.com',
125
+ user: 'username',
126
+ password: 'password',
127
+ retryableExitCodes: [23, 24]
128
+ });
129
+
130
+ // or, just for a single call:
131
+ await ssh.recv(['/remote/output/*'], './local-output', [], 0, [23, 24]);
132
+
133
+ // full replace: only retry on 23 for this call
134
+ await ssh.recv(['/remote/output/*'], './local-output', [], 0, [23], true);
135
+
136
+ // disable exit-code-based retry entirely for this call
137
+ await ssh.recv(['/remote/output/*'], './local-output', [], 0, [], true);
138
+ ```
139
+
99
140
  ### `.remoteToRemoteCopy(src, dstHostInfo, dst, [opt], [timeout])`
100
141
  Copies files or directories directly from this remote host to another remote host using `rsync` with SSH agent forwarding.
101
142
 
package/lib/index.d.ts CHANGED
@@ -11,6 +11,8 @@ declare module "ssh-client-wrapper" {
11
11
  ConnectTimeout?: number;
12
12
  maxRetry?: number;
13
13
  retryDuration?: number;
14
+ retryableExitCodes?: number[];
15
+ replaceRetryableExitCodes?: boolean;
14
16
  rcfile?: string;
15
17
  prependCmd?: string;
16
18
  sshOpt?: string[];
@@ -45,13 +47,17 @@ declare module "ssh-client-wrapper" {
45
47
  src: string[],
46
48
  dst: string,
47
49
  opt?: string[],
48
- timeout?: number
50
+ timeout?: number,
51
+ retryableExitCodes?: number[],
52
+ replaceRetryableExitCodes?: boolean
49
53
  ): Promise<void>;
50
54
  recv(
51
55
  src: string[],
52
56
  dst: string,
53
57
  opt?: string[],
54
- timeout?: number
58
+ timeout?: number,
59
+ retryableExitCodes?: number[],
60
+ replaceRetryableExitCodes?: boolean
55
61
  ): Promise<void>;
56
62
  canConnect(timeout?: number): Promise<boolean>;
57
63
  disconnect(): Promise<void>;
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import path from "path";
3
3
  import Debug from "debug";
4
4
  import * as sshExecModule from "./sshExec.js";
5
5
  import * as rsyncExecModule from "./rsyncExec.js";
6
- import { isArrayOfString, sanityCheck } from "./util.js";
6
+ import { isArrayOfString, isArrayOfInteger, sanityCheck } from "./util.js";
7
7
 
8
8
  const debug = Debug("sshClientWrapper:debug:interface");
9
9
  const debugVerbose = Debug("sshClientWrapper:verbose:interface");
@@ -30,6 +30,8 @@ const debugVerbose = Debug("sshClientWrapper:verbose:interface");
30
30
  * @param {Integer} hostInfo.ConnectTimeout - timeout used when connectiong to ths SSH server
31
31
  * @param {Integer} hostInfo.maxRetry - max number of retry
32
32
  * @param {Integer} hostInfo.retryDuration - duration time (msec) between each retry
33
+ * @param {number[]} hostInfo.retryableExitCodes - rsync exit codes to treat as retryable for send/recv, in addition to the built-in default (unless hostInfo.replaceRetryableExitCodes is true)
34
+ * @param {boolean} hostInfo.replaceRetryableExitCodes - if true, hostInfo.retryableExitCodes replaces the built-in default set of retryable rsync exit codes instead of merging with it
33
35
  * @param {string} hostInfo.rcfile - rcfile path which will be sourced before executing actual command
34
36
  * @param {string} hostInfo.prependCmd - command string which will be executed before executing actual command
35
37
  * @param {string[]} hostInfo.sshOpt - additional options for ssh
@@ -149,9 +151,11 @@ class SshClientWrapper {
149
151
  * @param {string} dst - Destination path.
150
152
  * @param {string[]} opt - option for rsync
151
153
  * @param {Integer} timeout - timeout in secconds
154
+ * @param {number[]} retryableExitCodes - rsync exit codes to treat as retryable for this call (overrides hostInfo.retryableExitCodes)
155
+ * @param {boolean} replaceRetryableExitCodes - if true, retryableExitCodes replaces the default set instead of merging with it
152
156
  * @returns {Promise} - Resolved with undefined when file transfer is done.
153
157
  */
154
- async send(src, dst, opt = [], timeout = 0) {
158
+ async send(src, dst, opt = [], timeout = 0, retryableExitCodes = null, replaceRetryableExitCodes = false) {
155
159
  debug("send called", src, dst, opt);
156
160
 
157
161
  if (!isArrayOfString(src)) {
@@ -171,7 +175,13 @@ class SshClientWrapper {
171
175
  if (typeof opt !== "undefined" && !isArrayOfString(opt)) {
172
176
  return logAndReject("opt must be array of string");
173
177
  }
174
- return this.deps.send(this.hostInfo, src, dst, opt, timeout);
178
+ if (retryableExitCodes !== null && !isArrayOfInteger(retryableExitCodes)) {
179
+ return logAndReject("retryableExitCodes must be array of integer");
180
+ }
181
+ if (typeof replaceRetryableExitCodes !== "boolean") {
182
+ return logAndReject("replaceRetryableExitCodes must be boolean");
183
+ }
184
+ return this.deps.send(this.hostInfo, src, dst, opt, timeout, retryableExitCodes, replaceRetryableExitCodes);
175
185
  }
176
186
 
177
187
  /**
@@ -180,9 +190,11 @@ class SshClientWrapper {
180
190
  * @param {string} dst - Destination path.
181
191
  * @param {string[]} opt - option for rsync
182
192
  * @param {Integer} timeout - timeout in secconds
193
+ * @param {number[]} retryableExitCodes - rsync exit codes to treat as retryable for this call (overrides hostInfo.retryableExitCodes)
194
+ * @param {boolean} replaceRetryableExitCodes - if true, retryableExitCodes replaces the default set instead of merging with it
183
195
  * @returns {Promise} - Resolved with undefined when file transfer is done.
184
196
  */
185
- async recv(src, dst, opt = [], timeout = 0) {
197
+ async recv(src, dst, opt = [], timeout = 0, retryableExitCodes = null, replaceRetryableExitCodes = false) {
186
198
  debug("recv called", src, dst, opt);
187
199
 
188
200
  if (!isArrayOfString(src)) {
@@ -202,7 +214,13 @@ class SshClientWrapper {
202
214
  if (typeof opt !== "undefined" && !isArrayOfString(opt)) {
203
215
  return logAndReject("opt must be array of string");
204
216
  }
205
- return this.deps.recv(this.hostInfo, src, dst, opt, timeout);
217
+ if (retryableExitCodes !== null && !isArrayOfInteger(retryableExitCodes)) {
218
+ return logAndReject("retryableExitCodes must be array of integer");
219
+ }
220
+ if (typeof replaceRetryableExitCodes !== "boolean") {
221
+ return logAndReject("replaceRetryableExitCodes must be boolean");
222
+ }
223
+ return this.deps.recv(this.hostInfo, src, dst, opt, timeout, retryableExitCodes, replaceRetryableExitCodes);
206
224
  }
207
225
 
208
226
  /**
package/lib/rsyncExec.js CHANGED
@@ -6,7 +6,7 @@ import { glob, hasMagic } from "glob";
6
6
  import { connect, retryWrapper, sshExec } from "./sshExec.js";
7
7
  import { fork } from "./fork.js";
8
8
 
9
- import { sshCmd, rsyncCmd, acceptableRsyncRetrunCodes, getSshOption } from "./util.js";
9
+ import { sshCmd, rsyncCmd, getSshOption, resolveRetryableExitCodes } from "./util.js";
10
10
 
11
11
  const debug = Debug("sshClientWrapper:debug:rsyncExec");
12
12
  const debugVerbose = Debug("sshClientWrapper:verbose:rsyncExec");
@@ -32,10 +32,12 @@ export async function checkRsyncVersion(timeout) {
32
32
  * @param {string} dst - Destination path.
33
33
  * @param {[string]} rsyncOpt - optional arguments for rsync
34
34
  * @param {Integer} timeout - timeout in secconds
35
+ * @param {number[]} retryableExitCodes - rsync exit codes to treat as retryable for this call (overrides hostInfo-level setting)
36
+ * @param {boolean} replaceRetryableExitCodes - if true, retryableExitCodes replaces the default instead of merging with it
35
37
  *
36
38
  * this function execute rsync -avv --copy-unsafe-links src rsync://[user@]host[:port]/dst
37
39
  */
38
- export async function send(hostInfo, argSrc, dst, rsyncOpt, timeout) {
40
+ export async function send(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableExitCodes, replaceRetryableExitCodes) {
39
41
  await connect(hostInfo);
40
42
  const promises = await Promise.allSettled(argSrc.map((e)=>{
41
43
  return glob(e);
@@ -81,7 +83,8 @@ export async function send(hostInfo, argSrc, dst, rsyncOpt, timeout) {
81
83
  }
82
84
  debug(`send ${src} to ${hostInfo.host}:${dst}`);
83
85
  debugVerbose(`send: ${rsyncCmd} ${args.join(" ")}`);
84
- return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, acceptableRsyncRetrunCodes), hostInfo);
86
+ const exitCodes = resolveRetryableExitCodes(hostInfo, retryableExitCodes, replaceRetryableExitCodes);
87
+ return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, exitCodes), hostInfo);
85
88
  }
86
89
 
87
90
  /**
@@ -106,10 +109,12 @@ async function isNewRsync(hostInfo) {
106
109
  * @param {string} dst - Destination path.
107
110
  * @param {[string]} rsyncOpt - optional arguments for rsync
108
111
  * @param {Integer} timeout - timeout in secconds
112
+ * @param {number[]} retryableExitCodes - rsync exit codes to treat as retryable for this call (overrides hostInfo-level setting)
113
+ * @param {boolean} replaceRetryableExitCodes - if true, retryableExitCodes replaces the default instead of merging with it
109
114
  *
110
115
  * this function execute rsync -av --copy-unsafe-links rsync://[user@]host[:port]/src dst
111
116
  */
112
- export async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout) {
117
+ export async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout, retryableExitCodes, replaceRetryableExitCodes) {
113
118
  await connect(hostInfo);
114
119
  const src = `${hostInfo.host}:${argSrc.join(" ")}`;
115
120
  const sshArgs = getSshOption(hostInfo, true);
@@ -125,7 +130,8 @@ export async function recv(hostInfo, argSrc, dst, rsyncOpt, timeout) {
125
130
 
126
131
  debug(`recv ${src} to ${dst}`);
127
132
  debugVerbose(`recv: ${rsyncCmd} ${args.join(" ")}`);
128
- return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, acceptableRsyncRetrunCodes), hostInfo);
133
+ const exitCodes = resolveRetryableExitCodes(hostInfo, retryableExitCodes, replaceRetryableExitCodes);
134
+ return retryWrapper(fork.bind(null, hostInfo, rsyncCmd, args, timeout, null, exitCodes), hostInfo);
129
135
  }
130
136
 
131
137
  /**
package/lib/util.js CHANGED
@@ -42,6 +42,11 @@ const hostInfoSchema = {
42
42
  ControlPersistDir: { type: "string", pattern: "\\S+", transform: ["trim"] },
43
43
  maxRetry: { type: "number", minimum: 0, default: 3 },
44
44
  retryDuration: { type: "number", minimum: 0, default: 1000 },
45
+ retryableExitCodes: {
46
+ type: "array",
47
+ items: { type: "integer" }
48
+ },
49
+ replaceRetryableExitCodes: { type: "boolean" },
45
50
  rcfile: { type: "string", pattern: "\\S+", transform: ["trim"] },
46
51
  prependCmd: { type: "string", pattern: "\\S+", transform: ["trim"] },
47
52
  sshOpt: {
@@ -235,6 +240,39 @@ export const isArrayOfString = (target)=>{
235
240
  });
236
241
  };
237
242
 
243
+ export const isArrayOfInteger = (target)=>{
244
+ if (!Array.isArray(target)) {
245
+ return false;
246
+ }
247
+ return !target.some((e)=>{
248
+ //target has non integer member
249
+ return !Number.isInteger(e);
250
+ });
251
+ };
252
+
253
+ /**
254
+ * resolve which rsync exit codes should be treated as retryable for a single send/recv call
255
+ * @param {object} hostInfo - hostinfo object
256
+ * @param {number[]} methodCodes - retryableExitCodes argument passed directly to send/recv (highest priority)
257
+ * @param {boolean} methodReplace - replaceRetryableExitCodes argument passed directly to send/recv
258
+ * @returns {number[]} - exit codes which should be treated as retryable
259
+ *
260
+ * precedence: method-level argument (if given) fully supersedes hostInfo-level settings.
261
+ * at whichever level is used, replace=false (default) merges with acceptableRsyncRetrunCodes,
262
+ * replace=true uses the given codes verbatim (so [] disables exit-code-based retry entirely).
263
+ */
264
+ export const resolveRetryableExitCodes = (hostInfo, methodCodes, methodReplace)=>{
265
+ if (methodCodes != null) {
266
+ return methodReplace ? methodCodes : [...acceptableRsyncRetrunCodes, ...methodCodes];
267
+ }
268
+ if (hostInfo.retryableExitCodes != null) {
269
+ return hostInfo.replaceRetryableExitCodes
270
+ ? hostInfo.retryableExitCodes
271
+ : [...acceptableRsyncRetrunCodes, ...hostInfo.retryableExitCodes];
272
+ }
273
+ return acceptableRsyncRetrunCodes;
274
+ };
275
+
238
276
  /**
239
277
  * log and send message to pty
240
278
  * @param {object} pty - pty object which message will be send to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-client-wrapper",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "wrapper library for open ssh client and rsync",
5
5
  "keywords": [
6
6
  "ssh",
@@ -34,6 +34,9 @@
34
34
  "lint-staged": {
35
35
  "*.js": "eslint --fix"
36
36
  },
37
+ "overrides": {
38
+ "@semantic-release/npm": "^13.1.0"
39
+ },
37
40
  "dependencies": {
38
41
  "ajv": "^8.17.1",
39
42
  "ajv-keywords": "^5.1.0",
@@ -46,7 +49,7 @@
46
49
  "@semantic-release/changelog": "^6.0.3",
47
50
  "@semantic-release/git": "^10.0.1",
48
51
  "@semantic-release/github": "^11.0.4",
49
- "@semantic-release/npm": "^12.0.2",
52
+ "@semantic-release/npm": "^13.1.0",
50
53
  "@stylistic/eslint-plugin": "^5.2.3",
51
54
  "c8": "^10.1.3",
52
55
  "chai": "^5.3.1",