ssh-client-wrapper 1.6.0 → 1.7.9

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/.eslintrc.json CHANGED
@@ -149,6 +149,7 @@
149
149
  "warn",
150
150
  "always"
151
151
  ],
152
+ "jsdoc/newline-after-description": "off",
152
153
  "jsdoc/require-param-description": "warn",
153
154
  "jsdoc/require-param-name": "warn",
154
155
  "jsdoc/require-param-type": "warn",
@@ -5,6 +5,7 @@ on:
5
5
  - 'v*'
6
6
  branches:
7
7
  - '*'
8
+ - '!main'
8
9
  workflow_call:
9
10
  concurrency:
10
11
  group: ${{ github.workflow }}-${{ github.ref }}
@@ -33,4 +34,25 @@ jobs:
33
34
  node-version: ${{ matrix.node-version }}
34
35
  - run: npm ci
35
36
  - run: npm run test
36
- - run: npm test
37
+ - run: npm run coverage
38
+ - uses: actions/upload-artifact@v3
39
+ with:
40
+ name: code-coverage-report
41
+ path: coverage-report.lcov
42
+ retention-days: 1
43
+ coverage:
44
+ needs: [test]
45
+ name: coverage report
46
+ runs-on: ubuntu-latest
47
+ env:
48
+ CC_TEST_REPORTER_ID: 52ae52d099fd6546c12030f23b20e2a4e0b485c6a1b139ec11e3615ab073e689
49
+ steps:
50
+ - run: echo ${CC_TEST_REPORTER_ID}
51
+ - run: echo '${{ toJSON(secrets.ccid) }}'
52
+ - uses: actions/checkout@v3
53
+ - uses: actions/download-artifact@v3
54
+ with:
55
+ name: code-coverage-report
56
+ - uses: paambaati/codeclimate-action@v5.0.0
57
+ with:
58
+ coverageLocations: "coverage-report.lcov:lcov"
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ npx lint-staged
package/README.md CHANGED
@@ -1,2 +1,6 @@
1
+ [![npm version](https://badge.fury.io/js/ssh-client-wrapper.svg)](https://badge.fury.io/js/ssh-client-wrapper)
2
+ [![Known Vulnerabilities](https://snyk.io/test/github/so5/ssh-client-wrapper/badge.svg)](https://snyk.io/test/github/so5/ssh-client-wrapper)
3
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/3f9cba3c4f04d90561e3/test_coverage)](https://codeclimate.com/github/so5/ssh-client-wrapper/test_coverage)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/8f3c0ea00e755ae31081/maintainability)](https://codeclimate.com/github/so5/ssh-client-wrapper/maintainability)
1
5
  # ssh-client-wrapper
2
6
  open ssh client wrapper library for nodejs
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ const debugLib = require("debug");
3
3
  const debug = debugLib("sshClientWrapper:interface");
4
4
  const { sshExec, canConnect, disconnect, ls } = require("./sshExec.js");
5
5
  const { send, recv } = require("./rsyncExec.js");
6
- const { isArrayOfString } = require("./util.js");
6
+ const { isArrayOfString, sanityCheck } = require("./util.js");
7
7
 
8
8
  /**
9
9
  * Integer means integer number but it is defined as Object for now
@@ -36,9 +36,7 @@ const { isArrayOfString } = require("./util.js");
36
36
  */
37
37
  class SshClientWrapper {
38
38
  constructor(hostInfo) {
39
- if (typeof hostInfo.host !== "string") {
40
- return new Error("hostname must be specified!");
41
- }
39
+ sanityCheck(hostInfo);
42
40
  this.hostInfo = { ...hostInfo };
43
41
  this.hostInfo.masterPty = null;
44
42
  }
@@ -78,8 +76,6 @@ class SshClientWrapper {
78
76
  * @param {number} retryDelay - Duration between each try (in sec).
79
77
  * @param {number} maxRetry - Max retry count.
80
78
  * @returns {Integer} - Return code of cmd.
81
- *
82
- * If stdout and stderr is array, last 10 line of stdout and stderr is stored in them.
83
79
  */
84
80
  async watch(cmd, regexp, retryDelay = 3, maxRetry = null) {
85
81
  debug("watch called", cmd, regexp, retryDelay, maxRetry);
@@ -95,7 +91,9 @@ class SshClientWrapper {
95
91
  err.maxRetry = maxRetry;
96
92
  return Promise.reject(err);
97
93
  }
98
- //TODO promise retry
94
+ //not implemented yet !!!
95
+ return this.exec(cmd, ()=>{
96
+ });
99
97
  }
100
98
 
101
99
  /**
@@ -159,7 +157,7 @@ class SshClientWrapper {
159
157
  * @param {Integer} timeout - timeout in secconds
160
158
  * @returns {Promise} - Resolved with true on success, otherwise rejected with Error.
161
159
  */
162
- canConnect(timeout = 60) {
160
+ async canConnect(timeout = 60) {
163
161
  debug("canConnect called timeout=", timeout);
164
162
 
165
163
  if (!Number.isInteger(timeout) || timeout <= 0) {
@@ -171,7 +169,7 @@ class SshClientWrapper {
171
169
  /**
172
170
  * Disconnect master session
173
171
  */
174
- disconnect() {
172
+ async disconnect() {
175
173
  debug("disconnect called");
176
174
  disconnect(this.hostInfo);
177
175
  }
package/lib/sshExec.js CHANGED
@@ -4,7 +4,7 @@ const crypto = require("crypto");
4
4
  const promiseRetry = require("promise-retry");
5
5
  const debug = require("debug")("sshClientWrapper:sshExec");
6
6
  const debugVerbose = require("debug")("sshClientWrapper:sshExec_verbose");
7
- const { sshCmd, getSshOption } = require("./util.js");
7
+ const { sshCmd, getSshOption, sanityCheck } = require("./util.js");
8
8
  const { fork, getMasterPty } = require("./fork.js");
9
9
 
10
10
  /**
@@ -45,60 +45,15 @@ async function sshExec(hostInfo, cmd, outputCallback) {
45
45
  * @param {Integer} timeout - timeout in secconds
46
46
  */
47
47
  async function canConnect(hostInfo, timeout = 60) {
48
- debug("check connectibity");
49
- const ac = new AbortController();
50
- const signal = ac.signal;
48
+ debug("sanity check");
49
+ sanityCheck(hostInfo);
51
50
 
52
- setTimeoutPromise(timeout * 1000, "neverUsedValue", { signal })
53
- .then(()=>{
54
- const err = new Error(`can not connect within ${timeout} sec`);
55
- err.host = hostInfo.host;
56
- err.user = hostInfo.user;
57
- err.port = hostInfo.port;
58
- throw err;
59
- })
60
- .catch((err)=>{
61
- if (err.name === "AbortError") {
62
- debug("timeout was aborted because connection check has done");
63
- }
64
- });
65
-
66
- if (await existsMaster(hostInfo)) {
67
- const words = crypto.randomUUID();
68
- const args = getSshOption(hostInfo);
69
- if (Number.isInteger(timeout) && timeout > 0) {
70
- args.push(`-oConnectTimeout=${timeout}`);
71
- }
72
- args.push(`echo ${words}`);
73
- let output = "";
74
- const cb = (e)=>{
75
- output += e;
76
- };
77
-
78
- debugVerbose(`canConnect: ${sshCmd} ${args.join(" ")}`);
79
- const rt = await retryWrapper(fork.bind(null, hostInfo, sshCmd, args, cb), hostInfo);
80
- ac.abort();
51
+ debug("check connectibity");
81
52
 
82
- if (rt !== 0) {
83
- const err = new Error(`can not connect to ${hostInfo.host}. return code=${rt}`);
84
- err.rt = rt;
85
- err.host = hostInfo.host;
86
- err.user = hostInfo.user;
87
- err.port = hostInfo.port;
88
- return err;
89
- }
90
- const re = new RegExp(words);
91
- if (!re.test(output)) {
92
- throw new Error("can not get echo result correctly");
93
- }
94
- return true;
95
- }
96
- //do NOT catch following try
97
- //if connect throw error, canConnect must be throw error
98
53
  try {
99
- await connect(hostInfo);
54
+ await connect(hostInfo, timeout);
100
55
  } finally {
101
- ac.abort();
56
+ await disconnect(hostInfo);
102
57
  }
103
58
  return true;
104
59
  }
@@ -116,11 +71,13 @@ async function existsMaster(hostInfo) {
116
71
  const cb = (e)=>{
117
72
  output += e;
118
73
  };
74
+
119
75
  try {
120
76
  debug(`check master session: ${sshCmd} ${args.join(" ")}`);
121
77
  await fork(hostInfo, sshCmd, args, cb);
122
78
  } catch (err) {
123
79
  if (err.rt !== 255) {
80
+ debug("check master session failed", err);
124
81
  throw err;
125
82
  }
126
83
  }
@@ -131,27 +88,48 @@ async function existsMaster(hostInfo) {
131
88
  * create master ssh connection
132
89
  * @param {Object} hostInfo - hostinfo object
133
90
  */
134
- async function connect(hostInfo) {
91
+ async function connect(hostInfo, timeout = 60) {
135
92
  if (await existsMaster(hostInfo)) {
136
93
  debugVerbose("master connection exists");
137
94
  return;
138
95
  }
139
-
140
96
  debug(`create master ssh session to ${hostInfo.host}`);
141
- const pty = hostInfo.masterPty || getMasterPty(hostInfo);
97
+ const pty = getMasterPty(hostInfo);
142
98
  hostInfo.masterPty = pty;
143
99
 
100
+ const ac = new AbortController();
101
+ const signal = ac.signal;
102
+ setTimeoutPromise(timeout * 1000, "neverUsedValue", { signal })
103
+ .then(()=>{
104
+ const err = new Error(`can not connect within ${timeout} sec`);
105
+ err.host = hostInfo.host;
106
+ err.user = hostInfo.user;
107
+ err.port = hostInfo.port;
108
+ pty.write("\n");
109
+ pty.write("exit\n");
110
+ throw err;
111
+ })
112
+ .catch((err)=>{
113
+ if (err.name === "AbortError") {
114
+ debug("timeout was aborted because connection check has done");
115
+ }
116
+ });
117
+
144
118
  const words = crypto.randomUUID();
145
119
  const re = new RegExp(`^${words}`);
146
-
147
120
  const p = new Promise((resolve, reject)=>{
121
+ let done = false;
148
122
  pty.onData((data)=>{
123
+ if (done) {
124
+ return;
125
+ }
149
126
  const output = data.toString();
150
127
  debugVerbose(output);
151
128
 
152
129
  if (re.test(output)) {
153
- debug("call exit on remotehost");
130
+ debug("test done. call exit on remotehost");
154
131
  pty.write("exit\n");
132
+ done = true;
155
133
  resolve();
156
134
  } else if (/Permission denied \(/.test(output)) {
157
135
  reject(new Error("Permission denied"));
@@ -175,6 +153,7 @@ async function connect(hostInfo) {
175
153
  debugVerbose(cmd);
176
154
  pty.write(cmd);
177
155
  await p;
156
+ ac.abort();
178
157
  debug(`connected to ${hostInfo.host}`);
179
158
  }
180
159
 
@@ -247,9 +226,9 @@ async function ls(hostInfo, target, lsOpt = []) {
247
226
  * @param {Function} func - function to be wrapped
248
227
  */
249
228
  async function retryWrapper(func, hostInfo) {
250
- const minTimeout = hostInfo.retryMinTimeout * 1000 || 60000;
251
- const maxTimeout = hostInfo.retryMaxTimeout * 1000 || 300000;
252
- const retries = hostInfo.maxRetry || 3;
229
+ const minTimeout = hostInfo.retryMinTimeout * 1000 || 1000;
230
+ const maxTimeout = hostInfo.retryMaxTimeout * 1000 || 5000;
231
+ const retries = hostInfo.maxRetry || 1;
253
232
  return promiseRetry({ retries, minTimeout, maxTimeout }, (retry, number)=>{
254
233
  return func()
255
234
  .catch((e)=>{
package/lib/util.js CHANGED
@@ -17,6 +17,22 @@ const buff2String = (func, data)=>{
17
17
  func(data.toString().replace(/\r\n/g, EOL));
18
18
  };
19
19
 
20
+ /**
21
+ * check if given hostInfo object is OK
22
+ * @param { Object} hostInfo - host information object defined in index.js
23
+ */
24
+ const sanityCheck = (hostInfo)=>{
25
+ if (typeof hostInfo.host !== "string" || hostInfo.host === "") {
26
+ throw new Error("host must be non-empty string");
27
+ }
28
+ if (Object.prototype.hasOwnProperty.call(hostInfo, "port") && (typeof hostInfo.port === "string" || Number.isInteger(hostInfo.port))) {
29
+ const port = Number(hostInfo.port);
30
+ if (port < 0 || port > 65535) {
31
+ throw new Error("port number must be in the range of 0 to 65535");
32
+ }
33
+ }
34
+ };
35
+
20
36
  /**
21
37
  * return ssh option with some hard corded options
22
38
  * @param { Object} hostInfo - host information object defined in index.js
@@ -41,7 +57,7 @@ const getSshOption = (hostInfo, withoutDestination)=>{
41
57
  args.push("-l");
42
58
  args.push(hostInfo.loginname);
43
59
  }
44
- if (typeof hostInfo.port === "string" || typeof hostInfo.port === "number") {
60
+ if (typeof hostInfo.port === "string" || Number.isInteger(hostInfo.port)) {
45
61
  args.push("-p");
46
62
  args.push(hostInfo.port);
47
63
  }
@@ -88,5 +104,6 @@ module.exports = {
88
104
  reNewHostPrompt,
89
105
  buff2String,
90
106
  getSshOption,
107
+ sanityCheck,
91
108
  isArrayOfString
92
109
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-client-wrapper",
3
- "version": "1.6.0",
3
+ "version": "1.7.9",
4
4
  "description": "wrapper library for open ssh client and rsync",
5
5
  "keywords": [
6
6
  "ssh",
@@ -13,9 +13,12 @@
13
13
  ],
14
14
  "main": "lib/index.js",
15
15
  "scripts": {
16
- "test": "mocha --recursive test",
16
+ "mocha": "mocha --recursive test",
17
17
  "lint": "eslint --fix lib test",
18
- "postversion": "git push && git push origin $(git describe --abbrev=0 --tags)"
18
+ "test": "nyc --reporter=html --reporter=text mocha",
19
+ "coverage": "nyc report --reporter=text-lcov > coverage-report.lcov",
20
+ "postversion": "git push && git push origin $(git describe --abbrev=0 --tags)",
21
+ "prepare": "husky install"
19
22
  },
20
23
  "engines": {
21
24
  "node": ">=12.9"
@@ -27,18 +30,14 @@
27
30
  "url": "https://github.com/so5/ssh-client-wrapper"
28
31
  },
29
32
  "homepage": "https://github.com/so5/ssh-client-wrapper#readme",
30
- "husky": {
31
- "hooks": {
32
- "pre-commit": "lint-staged"
33
- }
34
- },
35
33
  "lint-staged": {
36
34
  "*.js": "eslint --fix"
37
35
  },
38
36
  "dependencies": {
39
37
  "debug": "^4.3.4",
40
- "glob": "^10.2.2",
38
+ "glob": "^10.3.1",
41
39
  "node-pty": "^0.10.1",
40
+ "nyc": "^15.1.0",
42
41
  "promise-retry": "^2.0.1"
43
42
  },
44
43
  "devDependencies": {
@@ -52,7 +51,7 @@
52
51
  "eslint-plugin-jsdoc": "^43.0.6",
53
52
  "eslint-plugin-node": "^11.1.0",
54
53
  "fs-extra": "^11.1.1",
55
- "husky": "^8.0.3",
54
+ "husky": "^8.0.0",
56
55
  "lint-staged": "^13.2.1",
57
56
  "mocha": "^10.2.0",
58
57
  "sinon": "^15.0.3",
package/test/rsyncExec.js CHANGED
@@ -31,10 +31,13 @@ const {
31
31
  } = require("./util/testFiles");
32
32
 
33
33
  const formatLsOutput = (array)=>{
34
- return array.join(" ")
35
- .replace(/\n/g, " ")
36
- .trim()
37
- .split(/\s+/);
34
+ const rt = [];
35
+ for (const e of array) {
36
+ rt.push(...e.split("\n"));
37
+ }
38
+ return rt.filter((e)=>{
39
+ return e !== "";
40
+ });
38
41
  };
39
42
 
40
43
 
package/test/sshExec.js CHANGED
@@ -18,11 +18,10 @@ const { sshExec, canConnect, disconnect, ls } = require("../lib/sshExec.js");
18
18
 
19
19
  //test helpers
20
20
  const hostInfo = require("./util/hostInfo.js");
21
- const { clearRemoteTestFiles, createRemoteFiles, nonExisting, remoteRoot, remoteEmptyDir } = require("./util/testFiles.js");
22
-
21
+ const { clearRemoteTestFiles, clearLocalTestFiles, createRemoteFiles, nonExisting, remoteRoot, remoteEmptyDir } = require("./util/testFiles.js");
23
22
 
24
23
  describe("test for ssh execution", function() {
25
- this.timeout(20000);//eslint-disable-line no-invalid-this
24
+ this.timeout(65000);//eslint-disable-line no-invalid-this
26
25
  beforeEach(async ()=>{
27
26
  sshout.reset();
28
27
  await clearRemoteTestFiles(hostInfo);
@@ -30,6 +29,7 @@ describe("test for ssh execution", function() {
30
29
  after(async ()=>{
31
30
  if (!process.env.TEST_KEEP_FILES) {
32
31
  await clearRemoteTestFiles(hostInfo);
32
+ await clearLocalTestFiles();
33
33
  }
34
34
  await disconnect(hostInfo);
35
35
  });
@@ -73,7 +73,7 @@ describe("test for ssh execution", function() {
73
73
  await disconnect(hostInfo2);
74
74
  });
75
75
  it("should be resolved with true", async ()=>{
76
- expect(await canConnect(hostInfo2, 2)).to.be.true;
76
+ return expect(await canConnect(hostInfo2, 2)).to.be.true;
77
77
  });
78
78
  it("should be rejected if user does not exist", async ()=>{
79
79
  hostInfo2.user = "xxxx";