├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── index.js ├── package.json └── test ├── .gitignore ├── fixtures ├── another-file-at-root.txt ├── file-at-root.txt ├── folder │ ├── file-in-folder.txt │ └── subfolder │ │ └── file-in-subfolder.txt └── image.png ├── gulp-ssh-test.js └── scripts ├── ssh-setup.sh ├── ssh-teardown.sh └── sshrc /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | logs 4 | debug 5 | *.node 6 | /.nyc_output/ 7 | /coverage/ 8 | /node_modules/ 9 | /test/etc/ 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | build 5 | *.node 6 | components 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | git: 4 | depth: 2 5 | language: node_js 6 | node_js: 7 | - 6 8 | - 8 9 | - 9 10 | before_script: 11 | - test/scripts/ssh-setup.sh 12 | script: 13 | - eval $(cat ~/.ssh/agentrc) > /dev/null 14 | - yarn test 15 | after_script: 16 | - test/scripts/ssh-teardown.sh 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gulp-ssh 2 | ==== 3 | SSH and SFTP tasks for gulp 4 | 5 | [![NPM version][npm-image]][npm-url] 6 | [![Downloads][downloads-image]][downloads-url] 7 | [![CI Status][ci-image]][ci-url] 8 | 9 | ## Install 10 | 11 | Install with [npm](https://npmjs.org/package/gulp-ssh) 12 | 13 | ``` 14 | npm install --save-dev gulp-ssh 15 | ``` 16 | 17 | ## Example 18 | 19 | ```js 20 | 'use strict' 21 | 22 | var fs = require('fs'); 23 | var gulp = require('gulp') 24 | var GulpSSH = require('gulp-ssh') 25 | 26 | var config = { 27 | host: '192.168.0.21', 28 | port: 22, 29 | username: 'node', 30 | privateKey: fs.readFileSync('/Users/zensh/.ssh/id_rsa') 31 | } 32 | 33 | var gulpSSH = new GulpSSH({ 34 | ignoreErrors: false, 35 | sshConfig: config 36 | }) 37 | 38 | gulp.task('exec', function () { 39 | return gulpSSH 40 | .exec(['uptime', 'ls -a', 'pwd'], {filePath: 'commands.log'}) 41 | .pipe(gulp.dest('logs')) 42 | }) 43 | 44 | gulp.task('dest', function () { 45 | return gulp 46 | .src(['./**/*.js', '!**/node_modules/**']) 47 | .pipe(gulpSSH.dest('/home/iojs/test/gulp-ssh/')) 48 | }) 49 | 50 | gulp.task('sftp-read', function () { 51 | return gulpSSH.sftp('read', '/home/iojs/test/gulp-ssh/index.js', {filePath: 'index.js'}) 52 | .pipe(gulp.dest('logs')) 53 | }) 54 | 55 | gulp.task('sftp-write', function () { 56 | return gulp.src('index.js') 57 | .pipe(gulpSSH.sftp('write', '/home/iojs/test/gulp-ssh/test.js')) 58 | }) 59 | 60 | gulp.task('shell', function () { 61 | return gulpSSH 62 | .shell(['cd /home/iojs/test/thunks', 'git pull', 'npm install', 'npm update', 'npm test'], {filePath: 'shell.log'}) 63 | .pipe(gulp.dest('logs')) 64 | }) 65 | ``` 66 | 67 | ## API 68 | 69 | ```js 70 | var GulpSSH = require('gulp-ssh') 71 | ``` 72 | 73 | ### GulpSSH(options) 74 | 75 | ```js 76 | var gulpSSH = new GulpSSH(options) 77 | ``` 78 | 79 | #### options.sshConfig 80 | 81 | *Required* 82 | Type: `Object` 83 | 84 | * **host** - `String` - Hostname or IP address of the server. **Default:** 'localhost' 85 | 86 | * **port** - `Number` - Port number of the server. **Default:** 22 87 | 88 | * **username** - `String` - Username for authentication. **Default:** (none) 89 | 90 | * **password** - `String` - Password for password-based user authentication. **Default:** (none) 91 | 92 | * **privateKey** - `String` or `Buffer` - Buffer or string that contains a private key for key-based user authentication (OpenSSH format). **Default:** (none) 93 | 94 | * **privateKeyFile** - `String` - A path to a file that contains a private key for key-based user authentication (OpenSSH format). gulp-ssh extension. **Default:** (none) 95 | 96 | * **useAgent** - `Boolean` - Auto-detect the running SSH agent (via SSH_AUTH_SOCK environment variable) and use it to perform authentication. gulp-ssh extension. **Default:** (false) 97 | 98 | * ...and so forth. 99 | 100 | For a full list of connection options, see the reference for the [connect()](https://github.com/mscdex/ssh2#client-methods) method from the SSH2 module. 101 | 102 | #### options.ignoreErrors 103 | 104 | Type: `Boolean` 105 | 106 | Ignore errors when executing commands. **Default:** (false) 107 | 108 | ***** 109 | 110 | ### gulpSSH.shell(commands, options) 111 | 112 | return `stream`, there is a event "ssh2Data" on stream that emit ssh2 stream's chunk. 113 | 114 | **IMPORTANT:** If one of the commands requires user interaction, this function will hang. 115 | Observe the ssh2Data event to debug the interaction with the server. 116 | 117 | #### commands 118 | 119 | *Required* 120 | Type: `String` or `Array` 121 | 122 | #### options.filePath 123 | 124 | *Option* 125 | Type: `String` 126 | 127 | file path to write on local. **Default:** ('gulp-ssh.shell.log') 128 | 129 | #### options.autoExit 130 | 131 | *Option* 132 | Type: `Boolean` 133 | 134 | auto exit shell. **Default:** (true) 135 | 136 | ### gulpSSH.exec(commands, options) 137 | 138 | return `stream`, there is a event "ssh2Data" on stream that emit ssh2 stream's chunk. 139 | 140 | **IMPORTANT:** If one of the commands requires user interaction, this function will hang. 141 | Observe the ssh2Data event to debug the interaction with the server. 142 | 143 | #### commands 144 | 145 | *Required* 146 | Type: `String` or `Array` 147 | 148 | #### options.filePath 149 | 150 | *Option* 151 | Type: `String` 152 | 153 | file path to write on local. **Default:** ('gulp-ssh.exec.log') 154 | 155 | 156 | ### gulpSSH.sftp(command, filePath, options) 157 | 158 | return `stream` 159 | 160 | #### command 161 | 162 | *Required* 163 | Type: `String` 164 | Value: 'read' or 'write' 165 | 166 | #### filePath 167 | 168 | *Required* 169 | Type: `String` 170 | 171 | file path to read or write on server. **Default:** (none) 172 | 173 | #### options 174 | 175 | *Option* 176 | Type: `Object` 177 | 178 | ### gulpSSH.dest(destDir, options) 179 | 180 | return `stream`, copy the files to remote through sftp, acts similarly to Gulp dest, will make dirs if not exist. 181 | 182 | ## Tests 183 | 184 | This library is an SSH/SFTP transfer client. 185 | Therefore, we need to connect to an SSH server to test it. 186 | 187 | The strategy used to test this library is to connect to the current machine as the current user over SSH. 188 | This allows the tests to verify both ends of the SSH transfer. 189 | 190 | To run the tests, you need a local SSH server running and an SSH key the tests can use to authenticate against it. 191 | (The instructions in this section are specific to Linux). 192 | 193 | First, let's generate an SSH key without a passphrase for the tests to use. 194 | 195 | ```sh 196 | mkdir -p test/etc/ssh 197 | ssh-keygen -t rsa -b 4096 -N "" -f test/etc/ssh/id_rsa -q 198 | chmod 600 test/etc/ssh/id_rsa* 199 | ``` 200 | 201 | Next, add this key to the authorized SSH keys for the current user: 202 | 203 | ```sh 204 | mkdir -p ~/.ssh 205 | chmod 700 ~/.ssh 206 | cat test/etc/ssh/id_rsa.pub > ~/.ssh/authorized_keys 207 | chmod 600 ~/.ssh/authorized_keys 208 | ``` 209 | 210 | If you already have an SSH server running on your machine, you can use it to run the tests. 211 | Let's test to make sure we can connect to it. 212 | 213 | ```sh 214 | ssh -i etc/test/ssh $USER@localhost uptime 215 | ``` 216 | 217 | You should see the uptime of the current machine printed to the console. 218 | If that works, you're ready to run the tests! 219 | 220 | ```sh 221 | yarn test 222 | ``` 223 | 224 | If you don't have an SSH server running, you can run one on a user-space port (2222) for the tests. 225 | Start by creating a server configuration and host key: 226 | 227 | ```sh 228 | mkdir -p test/etc/sshd 229 | cat << EOF > test/etc/sshd/sshd_config 230 | Port 2222 231 | ListenAddress 127.0.0.1 232 | HostKey $(pwd)/test/etc/sshd/host_rsa 233 | PidFile $(pwd)/test/etc/sshd/pid 234 | PasswordAuthentication no 235 | PubkeyAuthentication yes 236 | ChallengeResponseAuthentication no 237 | Subsystem sftp /usr/lib/openssh/sftp-server 238 | UsePAM no 239 | EOF 240 | ssh-keygen -t rsa -b 4096 -N "" -f test/etc/sshd/host_rsa -q 241 | ``` 242 | 243 | Next, start the server: 244 | 245 | ``` 246 | /usr/sbin/sshd -f test/etc/sshd/sshd_config 247 | ``` 248 | 249 | If the sshd command is missing, you'll need to install the openssh-server package for your distribution. 250 | 251 | Let's try to connect to it make sure it's running properly: 252 | 253 | ```sh 254 | ssh -i etc/test/ssh -p 2222 $USER@localhost uptime 255 | ``` 256 | 257 | You should see the uptime of the current machine printed to the console. 258 | If that works, you're ready to run the tests! 259 | 260 | ```sh 261 | CI=true yarn test 262 | ``` 263 | 264 | We pass CI=true so that the tests use port 2222 to connect instead of the default port. 265 | 266 | When you're done running the tests, you can use this command to stop the SSH server: 267 | 268 | ``` 269 | kill $(cat test/etc/sshd/pid) 270 | ``` 271 | 272 | In the test/scripts directory you can find the setup and teardown process that's used in CI to run these tests, which is similar to the one described in this section. 273 | 274 | ## License 275 | 276 | MIT © [Teambition](https://www.teambition.com) 277 | 278 | [npm-url]: https://npmjs.org/package/gulp-ssh 279 | [npm-image]: http://img.shields.io/npm/v/gulp-ssh.svg 280 | 281 | [downloads-url]: https://npmjs.org/package/gulp-ssh 282 | [downloads-image]: http://img.shields.io/npm/dm/gulp-ssh.svg 283 | 284 | [ci-url]: https://travis-ci.org/teambition/gulp-ssh 285 | [ci-image]: https://img.shields.io/travis/teambition/gulp-ssh/master.svg 286 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* 3 | * gulp-ssh 4 | * https://github.com/teambition/gulp-ssh 5 | * 6 | * Licensed under the MIT license. 7 | */ 8 | 9 | const colors = require('ansicolors') 10 | const log = require('fancy-log') 11 | const fs = require('fs') 12 | const os = require('os') 13 | const path = require('path') 14 | const EventEmitter = require('events').EventEmitter 15 | const File = require('vinyl') 16 | const PluginError = require('plugin-error') 17 | 18 | const through = require('through2') 19 | const SSH2Client = require('ssh2').Client 20 | const packageName = require('./package.json').name 21 | 22 | class Client extends SSH2Client { 23 | gulpFlushReady () { 24 | this.gulpConnected = true 25 | while (this.gulpQueue.length) this.gulpQueue.shift().call(this) 26 | } 27 | gulpReady (cb) { 28 | if (this.gulpConnected) cb.call(this) 29 | else this.gulpQueue.push(cb) 30 | } 31 | } 32 | Client.prototype.gulpId = null 33 | Client.prototype.gulpQueue = null 34 | Client.prototype.gulpConnected = false 35 | 36 | var gulpId = 0 37 | class GulpSSH extends EventEmitter { 38 | constructor (options) { 39 | if (!options || !options.sshConfig) throw new Error('options.sshConfig required!') 40 | super() 41 | this.options = options 42 | this.connections = Object.create(null) 43 | } 44 | 45 | getClient () { 46 | const ctx = this 47 | const ssh = new Client() 48 | const options = this.options 49 | 50 | ssh.gulpId = gulpId++ 51 | ssh.gulpQueue = [] 52 | ssh.gulpConnected = false 53 | this.connections[ssh.gulpId] = ssh 54 | 55 | ssh 56 | .on('error', function (err) { 57 | ctx.emit('error', new PluginError(packageName, err)) 58 | }) 59 | .on('end', function () { 60 | delete ctx.connections[this.gulpId] 61 | }) 62 | .on('close', function () { 63 | delete ctx.connections[this.gulpId] 64 | }) 65 | .on('ready', ssh.gulpFlushReady) 66 | 67 | let sshConfig = options.sshConfig 68 | let privateKeyFile = sshConfig.privateKeyFile 69 | if (privateKeyFile) { 70 | if (privateKeyFile.charAt() === '~' && (path.sep === '\\' 71 | ? /\/|\\/.test(privateKeyFile.charAt(1)) : privateKeyFile.charAt(1) === '/')) { 72 | privateKeyFile = os.homedir() + privateKeyFile.substr(1) 73 | } 74 | 75 | const gulpSSH = this 76 | fs.readFile(privateKeyFile, function (err, privateKey) { 77 | if (err) throw err 78 | sshConfig = Object.assign({}, sshConfig, { privateKey }) 79 | delete sshConfig.privateKeyFile 80 | gulpSSH.options = Object.assign({}, options, { sshConfig }) 81 | ssh.connect(sshConfig) 82 | }) 83 | } else { 84 | if (sshConfig.useAgent) { 85 | sshConfig = Object.assign({}, sshConfig, { agent: process.env.SSH_AUTH_SOCK }) 86 | delete sshConfig.useAgent 87 | this.options = Object.assign({}, options, { sshConfig }) 88 | } 89 | ssh.connect(sshConfig) 90 | } 91 | 92 | return ssh 93 | } 94 | 95 | close () { 96 | for (let id of Object.keys(this.connections)) { 97 | this.connections[id].end() 98 | delete this.connections[id] 99 | } 100 | } 101 | 102 | exec (commands, options) { 103 | const ctx = this 104 | const ssh = this.getClient() 105 | const outStream = through.obj() 106 | const chunks = [] 107 | let chunkSize = 0 108 | 109 | if (!commands) throw new PluginError(packageName, '`commands` required.') 110 | 111 | options = options || {} 112 | commands = Array.isArray(commands) ? commands.slice() : [commands] 113 | 114 | ssh.gulpReady(execCommand) 115 | 116 | function endStream () { 117 | outStream.push(new File({ 118 | cwd: __dirname, 119 | base: __dirname, 120 | path: path.join(__dirname, options.filePath || 'gulp-ssh.exec.log'), 121 | contents: Buffer.concat(chunks, chunkSize) 122 | })) 123 | 124 | ssh.end() 125 | outStream.end() 126 | } 127 | 128 | function execCommand () { 129 | if (!commands.length) return endStream() 130 | 131 | const command = commands.shift() 132 | if (typeof command !== 'string') return execCommand() 133 | 134 | log(packageName + ' :: Executing :: ' + command) 135 | ssh.exec(command, options, function (err, stream) { 136 | if (err) return outStream.emit('error', new PluginError(packageName, err)) 137 | stream 138 | .on('data', function (chunk) { 139 | chunkSize += chunk.length 140 | chunks.push(chunk) 141 | outStream.emit('ssh2Data', chunk) 142 | }) 143 | .on('exit', function (code, signalName, didCoreDump, description) { 144 | if (ctx.ignoreErrors === false && code == null) { 145 | const message = signalName + ', ' + didCoreDump + ', ' + description 146 | outStream.emit('error', new PluginError(packageName, message)) 147 | } 148 | }) 149 | .on('close', execCommand) 150 | .stderr.on('data', function (data) { 151 | outStream.emit('error', new PluginError(packageName, data + '')) 152 | }) 153 | }) 154 | } 155 | 156 | return outStream 157 | } 158 | 159 | sftp (command, filePath, options) { 160 | const ssh = this.getClient() 161 | let outStream 162 | 163 | options = options || {} 164 | if (!command) throw new PluginError(packageName, '`command` required.') 165 | if (!filePath) throw new PluginError(packageName, '`filePath` required.') 166 | 167 | if (command === 'write') { 168 | outStream = through.obj(function (file, encoding, callback) { 169 | ssh.gulpReady(function () { 170 | ssh.sftp(function (err, sftp) { 171 | if (err) return callback(new PluginError(packageName, err)) 172 | options.autoClose = true 173 | const write = sftp.createWriteStream(filePath, options) 174 | 175 | write 176 | .on('error', function (error) { 177 | err = error 178 | }) 179 | .on('finish', function () { 180 | sftp.end() 181 | if (err) callback(err) 182 | else callback(null, file) 183 | }) 184 | 185 | if (file.isStream()) file.contents.pipe(write) 186 | else if (file.isBuffer()) write.end(file.contents) 187 | else { 188 | err = new PluginError(packageName, 'file error!') 189 | write.end() 190 | } 191 | }) 192 | }) 193 | }, function (callback) { 194 | ssh.end() 195 | callback() 196 | }) 197 | } else if (command === 'read') { 198 | const chunks = [] 199 | let chunkSize = 0 200 | 201 | outStream = through.obj() 202 | ssh.gulpReady(function () { 203 | ssh.sftp(function (err, sftp) { 204 | if (err) return outStream.emit('error', new PluginError(packageName, err)) 205 | const read = sftp.createReadStream(filePath, options) 206 | options.base = options.base || '' 207 | 208 | read 209 | .on('data', function (chunk) { 210 | chunkSize += chunk.length 211 | chunks.push(chunk) 212 | }) 213 | .on('error', function (err) { 214 | outStream.emit('error', err) 215 | }) 216 | .on('end', function () { 217 | outStream.push(new File({ 218 | cwd: __dirname, 219 | base: __dirname, 220 | path: path.join(__dirname, options.filePath || filePath), 221 | contents: Buffer.concat(chunks, chunkSize) 222 | })) 223 | this.close() 224 | }) 225 | .on('close', function () { 226 | sftp.end() 227 | ssh.end() 228 | outStream.end() 229 | }) 230 | }) 231 | }) 232 | } else throw new PluginError(packageName, 'Command "' + command + '" not supported.') 233 | 234 | return outStream 235 | } 236 | 237 | // Acts similarly to Gulp dest, will make dirs if not exist and copy the files 238 | // to the glob path 239 | dest (destDir, options) { 240 | if (!destDir) throw new PluginError(packageName, '`destDir` required.') 241 | 242 | let sftpClient = null 243 | const ssh = this.getClient() 244 | options = options || {} 245 | options.autoClose = false 246 | 247 | function getSftp (callback) { 248 | if (sftpClient) return callback(null, sftpClient) 249 | ssh.gulpReady(function () { 250 | ssh.sftp(function (err, sftp) { 251 | if (err) return callback(err) 252 | sftpClient = sftp 253 | callback(null, sftp) 254 | }) 255 | }) 256 | } 257 | 258 | function end (err, callback) { 259 | if (sftpClient) { 260 | sftpClient.end() 261 | sftpClient = null 262 | } 263 | ssh.end() 264 | if (err) err = new PluginError(packageName, err) 265 | callback(err) 266 | } 267 | 268 | return through.obj(function (file, encoding, callback) { 269 | if (file.isNull()) { 270 | log('"' + colors.cyan(file.path) + '" has no content. Skipping.') 271 | return callback() 272 | } 273 | getSftp(function (err, sftp) { 274 | if (err) return end(err, callback) 275 | 276 | let outPath = path.join(destDir, file.relative) 277 | if (path.sep === '\\') outPath = outPath.replace(/\\/g, '/') 278 | log('Preparing to write "' + colors.cyan(outPath) + '"') 279 | 280 | internalMkDirs(sftp, outPath, function (err) { 281 | if (err) return end(err, callback) 282 | log('Writing \'' + colors.cyan(outPath) + '\'') 283 | 284 | const write = sftp.createWriteStream(outPath, options) 285 | 286 | write 287 | .on('error', done) 288 | .on('finish', done) 289 | 290 | if (file.isStream()) { 291 | file.contents.pipe(write) 292 | } else if (file.isBuffer()) { 293 | write.end(file.contents) 294 | } 295 | 296 | function done (err) { 297 | if (err) return end(err, callback) 298 | log('Finished writing \'' + colors.cyan(outPath) + '\'') 299 | callback() 300 | } 301 | }) 302 | }) 303 | }, function (callback) { 304 | end(null, callback) 305 | }) 306 | } 307 | 308 | shell (commands, options) { 309 | const ssh = this.getClient() 310 | const outStream = through.obj() 311 | const chunks = [] 312 | let chunkSize = 0 313 | 314 | if (!commands) throw new PluginError(packageName, '`commands` required.') 315 | 316 | options = options || {} 317 | commands = Array.isArray(commands) ? commands.slice() : [commands] 318 | 319 | function endStream () { 320 | outStream.push(new File({ 321 | cwd: __dirname, 322 | base: __dirname, 323 | path: path.join(__dirname, options.filePath || 'gulp-ssh.exec.log'), 324 | contents: Buffer.concat(chunks, chunkSize) 325 | })) 326 | 327 | ssh.end() 328 | outStream.end() 329 | } 330 | 331 | ssh.gulpReady(function () { 332 | if (commands.length === 0) return endStream() 333 | ssh.shell(function (err, stream) { 334 | if (err) return outStream.emit('error', new PluginError(packageName, err)) 335 | 336 | stream 337 | .on('data', function (chunk) { 338 | chunkSize += chunk.length 339 | chunks.push(chunk) 340 | outStream.emit('ssh2Data', chunk) 341 | }) 342 | .on('close', endStream) 343 | .stderr.on('data', function (data) { 344 | outStream.emit('error', new PluginError(packageName, data + '')) 345 | }) 346 | 347 | let lastCommand 348 | commands.forEach(function (command) { 349 | if (command[command.length - 1] !== '\n') command += '\n' 350 | log(packageName + ' :: shell :: ' + command) 351 | stream.write(command) 352 | lastCommand = command 353 | }) 354 | if (options.autoExit !== false) stream.end(lastCommand === 'exit\n' ? null : 'exit\n') 355 | }) 356 | }) 357 | 358 | return outStream 359 | } 360 | } 361 | 362 | function internalMkDirs (sftp, filePath, callback) { 363 | const outPathDir = path.dirname(filePath).replace(/\\/g, '/') 364 | 365 | sftp.exists(outPathDir, function (result) { 366 | if (result) return callback() 367 | // recursively make parent directories as required 368 | internalMkDirs(sftp, outPathDir, function (err) { 369 | if (err) return callback(err) 370 | log('Creating directory \'' + colors.cyan(outPathDir) + '\'') 371 | sftp.mkdir(outPathDir, callback) 372 | }) 373 | }) 374 | } 375 | 376 | module.exports = GulpSSH 377 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gulp-ssh", 3 | "version": "0.7.1", 4 | "description": "SSH and SFTP tasks for gulp", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Yan Qing", 8 | "email": "admin@zensh.com", 9 | "url": "https://github.com/zensh" 10 | }, 11 | "engines": { 12 | "node": ">=6" 13 | }, 14 | "main": "index.js", 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/teambition/gulp-ssh" 18 | }, 19 | "homepage": "https://github.com/teambition/gulp-ssh", 20 | "keywords": [ 21 | "gulpplugin", 22 | "ssh", 23 | "ssh2", 24 | "sftp", 25 | "exec", 26 | "shell", 27 | "remote", 28 | "client", 29 | "upload" 30 | ], 31 | "dependencies": { 32 | "ansicolors": "^0.3.2", 33 | "fancy-log": "^1.3.2", 34 | "plugin-error": "^1.0.1", 35 | "ssh2": "~0.5.5", 36 | "through2": "^2.0.3", 37 | "vinyl": "^2.1.0" 38 | }, 39 | "devDependencies": { 40 | "chai": "^4.1.2", 41 | "chai-fs": "^2.0.0", 42 | "dirty-chai": "^2.0.1", 43 | "fs-extra": "^5.0.0", 44 | "mocha": "^5.0.0", 45 | "nyc": "^11.4.1", 46 | "standard": "^10.0.3", 47 | "vinyl-fs": "^3.0.2" 48 | }, 49 | "scripts": { 50 | "coverage": "nyc mocha", 51 | "test": "standard && mocha -t 5000" 52 | }, 53 | "files": [ 54 | "README.md", 55 | "index.js" 56 | ], 57 | "nyc": { 58 | "reporter": [ 59 | "lcov" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | test-settings.json 2 | -------------------------------------------------------------------------------- /test/fixtures/another-file-at-root.txt: -------------------------------------------------------------------------------- 1 | another file at root 2 | -------------------------------------------------------------------------------- /test/fixtures/file-at-root.txt: -------------------------------------------------------------------------------- 1 | file at root 2 | -------------------------------------------------------------------------------- /test/fixtures/folder/file-in-folder.txt: -------------------------------------------------------------------------------- 1 | file in folder 2 | -------------------------------------------------------------------------------- /test/fixtures/folder/subfolder/file-in-subfolder.txt: -------------------------------------------------------------------------------- 1 | file in subfolder 2 | -------------------------------------------------------------------------------- /test/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teambition/gulp-ssh/b947401c5ded0668f85811dce9ae079cd038c2e1/test/fixtures/image.png -------------------------------------------------------------------------------- /test/gulp-ssh-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict' 3 | 4 | const chai = require('chai') 5 | chai.use(require('chai-fs')) 6 | chai.use(require('dirty-chai')) 7 | const { expect } = chai 8 | const fs = require('fs-extra') 9 | const GulpSSH = require('..') 10 | const os = require('os') 11 | const path = require('path') 12 | const { obj: map } = require('through2') 13 | const vfs = require('vinyl-fs') 14 | 15 | const DEST_DIR = path.join(__dirname, 'dest') 16 | const FIXTURES_DIR = path.join(__dirname, 'fixtures') 17 | 18 | describe('GulpSSH', () => { 19 | let gulpSSH 20 | const privateKeyFile = path.resolve(__dirname, 'etc/ssh/id_rsa') 21 | let sshConfig = { 22 | host: 'localhost', 23 | port: process.env.CI ? 2222 : 22, 24 | username: process.env.USER, 25 | privateKey: fs.readFileSync(privateKeyFile) 26 | } 27 | 28 | const collectFiles = (files, cb) => { 29 | if (cb) { 30 | return map((file, enc, next) => files.push(file) && next(), cb) 31 | } else { 32 | return map((file, enc, next) => files.push(file) && next(null, file)) 33 | } 34 | } 35 | 36 | beforeEach(() => { 37 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig }) 38 | }) 39 | 40 | afterEach(() => { 41 | gulpSSH.close() 42 | }) 43 | 44 | describe('instantiate', () => { 45 | it('should fail if options are not provided', () => { 46 | expect(() => new GulpSSH()).to.throw('sshConfig required') 47 | }) 48 | 49 | it('should defer loading of private key specified in privateKeyFile option', () => { 50 | const localSshConfig = Object.assign({}, sshConfig, { privateKeyFile }) 51 | delete localSshConfig.privateKey 52 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig: localSshConfig }) 53 | expect(gulpSSH.options.sshConfig.privateKeyFile).to.equal(privateKeyFile) 54 | expect(gulpSSH.options.sshConfig).to.not.have.property('privateKey') 55 | }) 56 | }) 57 | 58 | describe('connect', () => { 59 | it('should connect if credentials provided by private key are good', (done) => { 60 | gulpSSH.on('error', done) 61 | gulpSSH.getClient().gulpReady(function () { 62 | expect(this.gulpConnected).to.equal(true) 63 | done() 64 | }) 65 | }) 66 | 67 | it('should connect if credentials provided by agent are good', function (done) { 68 | if (process.env.CI || process.env.SSH_AUTH_SOCK) { 69 | const localSshConfig = Object.assign({}, sshConfig, { agent: process.env.SSH_AUTH_SOCK }) 70 | delete localSshConfig.privateKey 71 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig: localSshConfig }) 72 | gulpSSH.on('error', done) 73 | gulpSSH.getClient().gulpReady(function () { 74 | expect(this.gulpConnected).to.equal(true) 75 | done() 76 | }) 77 | } else { 78 | console.log('SSH agent not detected. Skipping SSH agent test.') 79 | this.skip() 80 | done() 81 | } 82 | }) 83 | 84 | it('should connect if credentials provided by auto-detected agent are good', function (done) { 85 | if (process.env.CI || process.env.SSH_AUTH_SOCK) { 86 | const localSshConfig = Object.assign({}, sshConfig, { useAgent: true }) 87 | delete localSshConfig.privateKey 88 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig: localSshConfig }) 89 | gulpSSH.on('error', done) 90 | gulpSSH.getClient().gulpReady(function () { 91 | expect(this.gulpConnected).to.equal(true) 92 | done() 93 | }) 94 | } else { 95 | console.log('SSH agent not detected. Skipping SSH agent test.') 96 | this.skip() 97 | done() 98 | } 99 | }) 100 | 101 | it('should fail to connect if credentials are bad', (done) => { 102 | gulpSSH.options.sshConfig = Object.assign({}, sshConfig, { username: 'nobody' }) 103 | gulpSSH.on('error', () => done()) 104 | gulpSSH.getClient().gulpReady(() => { 105 | expect.fail() 106 | done() 107 | }) 108 | }) 109 | 110 | it('should load contents of private key on connect if privateKeyFile option is specified', (done) => { 111 | const localSshConfig = Object.assign({}, sshConfig, { privateKeyFile }) 112 | delete localSshConfig.privateKey 113 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig: localSshConfig }) 114 | gulpSSH.on('error', done) 115 | gulpSSH.getClient().gulpReady(function () { 116 | expect(this.gulpConnected).to.equal(true) 117 | expect(gulpSSH.options.sshConfig.privateKey).to.eql(sshConfig.privateKey) 118 | expect(gulpSSH.options.sshConfig).to.not.have.property('privateKeyFile') 119 | expect(localSshConfig.privateKeyFile).to.equal(privateKeyFile) 120 | expect(localSshConfig).to.not.have.property('privateKey') 121 | done() 122 | }) 123 | }) 124 | 125 | it('should expand leading tilde in privateKeyFile option', (done) => { 126 | const tildePrivateKeyFile = path.join('~', path.relative(os.homedir(), privateKeyFile)) 127 | const localSshConfig = Object.assign({}, sshConfig, { privateKeyFile: tildePrivateKeyFile }) 128 | delete localSshConfig.privateKey 129 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig: localSshConfig }) 130 | gulpSSH.on('error', done) 131 | gulpSSH.getClient().gulpReady(function () { 132 | expect(this.gulpConnected).to.equal(true) 133 | expect(gulpSSH.options.sshConfig.privateKey).to.eql(sshConfig.privateKey) 134 | expect(gulpSSH.options.sshConfig).to.not.have.property('privateKeyFile') 135 | expect(localSshConfig.privateKeyFile).to.equal(tildePrivateKeyFile) 136 | expect(localSshConfig).to.not.have.property('privateKey') 137 | done() 138 | }) 139 | }) 140 | 141 | it('should use private key from privateKeyFile option instead of privateKey option', (done) => { 142 | const localSshConfig = Object.assign({}, sshConfig, { privateKeyFile, privateKey: 'bogus' }) 143 | gulpSSH = new GulpSSH({ ignoreErrors: false, sshConfig: localSshConfig }) 144 | gulpSSH.on('error', done) 145 | gulpSSH.getClient().gulpReady(function () { 146 | expect(this.gulpConnected).to.equal(true) 147 | expect(gulpSSH.options.sshConfig.privateKey).to.eql(sshConfig.privateKey) 148 | expect(gulpSSH.options.sshConfig).to.not.have.property('privateKeyFile') 149 | expect(localSshConfig.privateKeyFile).to.equal(privateKeyFile) 150 | expect(localSshConfig.privateKey).to.equal('bogus') 151 | done() 152 | }) 153 | }) 154 | }) 155 | 156 | describe('exec', () => { 157 | it('should throw error if no commands are specified', () => { 158 | expect(() => gulpSSH.exec()).to.throw('`commands` required.') 159 | }) 160 | 161 | it('should execute single command on server', (done) => { 162 | const files = [] 163 | gulpSSH 164 | .exec('uptime') 165 | .pipe(collectFiles(files, () => { 166 | expect(files).to.have.lengthOf(1) 167 | expect(files[0].contents.toString()).to.include('load average') 168 | done() 169 | })) 170 | }) 171 | 172 | it('should execute multiple commands on server', (done) => { 173 | const files = [] 174 | gulpSSH 175 | .exec(['uptime', 'echo hello']) 176 | .pipe(collectFiles(files, () => { 177 | expect(files).to.have.lengthOf(1) 178 | const lines = files[0].contents.toString().split(/\r*\n/) 179 | expect(lines[0]).to.include('load average') 180 | expect(lines[1]).to.equal('hello') 181 | done() 182 | })) 183 | }) 184 | }) 185 | 186 | describe('dest', () => { 187 | const srcDir = FIXTURES_DIR 188 | 189 | afterEach(() => { 190 | fs.remove(DEST_DIR) 191 | }) 192 | 193 | it('should copy files to server', (done) => { 194 | const files = [] 195 | vfs 196 | .src('**/*', { cwd: srcDir, cwdbase: true }) 197 | .pipe(collectFiles(files)) 198 | .pipe(gulpSSH.dest(DEST_DIR)) 199 | .on('finish', () => { 200 | expect(DEST_DIR).to.be.a.directory() 201 | files.forEach((file) => { 202 | if (file.isNull()) { 203 | expect(path.join(DEST_DIR, file.relative)).to.be.a.directory() 204 | } else { 205 | expect(path.join(DEST_DIR, file.relative)).to.be.a.file().with.contents(file.contents.toString()) 206 | } 207 | }) 208 | done() 209 | }) 210 | }) 211 | 212 | it('should create interim directories on server', (done) => { 213 | const nestedDestDir = path.join(DEST_DIR, 'a/b/c') 214 | const files = [] 215 | vfs 216 | .src('**/*', { cwd: srcDir, cwdbase: true }) 217 | .pipe(collectFiles(files)) 218 | .pipe(gulpSSH.dest(nestedDestDir)) 219 | .on('finish', () => { 220 | expect(DEST_DIR).to.be.a.directory() 221 | expect(nestedDestDir).to.be.a.directory() 222 | files.forEach((file) => { 223 | if (file.isNull()) { 224 | expect(path.join(nestedDestDir, file.relative)).to.be.a.directory() 225 | } else { 226 | expect(path.join(nestedDestDir, file.relative)).to.be.a.file().with.contents(file.contents.toString()) 227 | } 228 | }) 229 | done() 230 | }) 231 | }) 232 | }) 233 | 234 | describe('sftp', () => { 235 | let clean 236 | 237 | afterEach(() => { 238 | if (clean) fs.remove(DEST_DIR) 239 | }) 240 | 241 | it('should throw error if command is not specified', () => { 242 | expect(() => gulpSSH.sftp()).to.throw('`command` required.') 243 | }) 244 | 245 | it('should throw error if command is unknown', () => { 246 | expect(() => gulpSSH.sftp('wat', '/path/to/file.txt')).to.throw('Command "wat" not supported.') 247 | }) 248 | 249 | it('should read file over sftp', (done) => { 250 | const localSrcFile = path.join(FIXTURES_DIR, 'folder/file-in-folder.txt') 251 | const remoteSrcFile = path.relative(process.env.HOME, localSrcFile) 252 | const filePath = 'file-copy.txt' 253 | const files = [] 254 | gulpSSH 255 | .sftp('read', remoteSrcFile, { filePath }) 256 | .pipe(collectFiles(files, () => { 257 | expect(files).to.have.lengthOf(1) 258 | expect(files[0].relative).to.equal(filePath) 259 | expect(files[0].contents.toString()).to.equal(fs.readFileSync(localSrcFile, 'utf8')) 260 | done() 261 | })) 262 | }) 263 | 264 | it('should write file over sftp', (done) => { 265 | clean = true 266 | const srcRelFile = 'folder/file-in-folder.txt' 267 | const srcAbsFile = path.join(FIXTURES_DIR, srcRelFile) 268 | const localDestFile = path.join(DEST_DIR, 'file-copy.txt') 269 | const remoteDestFile = path.relative(process.env.HOME, localDestFile) 270 | // NOTE sftp write requires dest directory to exist 271 | fs.ensureDirSync(DEST_DIR) 272 | vfs 273 | .src(srcRelFile, { cwd: FIXTURES_DIR, cwdbase: true }) 274 | .pipe(gulpSSH.sftp('write', remoteDestFile)) 275 | .on('finish', () => { 276 | expect(localDestFile).to.be.a.file().with.contents(fs.readFileSync(srcAbsFile, 'utf8')) 277 | done() 278 | }) 279 | }) 280 | }) 281 | 282 | describe('shell', () => { 283 | const parseLog = (log) => { 284 | let currentCommand 285 | const output = { _preamble: [] } 286 | log.trim().split(/\r*\n/).forEach((line) => { 287 | const $idx = line.indexOf('$') 288 | if (~$idx) { 289 | currentCommand = line.substr($idx + 1).trim() 290 | } else if (currentCommand) { 291 | if (currentCommand in output) { 292 | output[currentCommand].push(line) 293 | } else { 294 | output[currentCommand] = [line] 295 | } 296 | } else { 297 | output._preamble.push(line) 298 | } 299 | }) 300 | return output 301 | } 302 | 303 | it('should throw error if no commands are specified', () => { 304 | expect(() => gulpSSH.shell()).to.throw('`commands` required.') 305 | }) 306 | 307 | it('should execute single command in shell on server', (done) => { 308 | const files = [] 309 | gulpSSH 310 | .shell('pushd /tmp') 311 | .pipe(collectFiles(files, () => { 312 | expect(files).to.have.lengthOf(1) 313 | const output = parseLog(files[0].contents.toString()) 314 | expect(output._preamble).to.include.members(['pushd /tmp', 'exit']) 315 | expect(Object.keys(output)).to.include.members(['pushd /tmp', 'exit']) 316 | done() 317 | })) 318 | }) 319 | 320 | it('should execute multiple commands in shell on server', (done) => { 321 | const files = [] 322 | gulpSSH 323 | .shell(['pushd /tmp', 'pwd']) 324 | .pipe(collectFiles(files, () => { 325 | expect(files).to.have.lengthOf(1) 326 | const output = parseLog(files[0].contents.toString()) 327 | expect(output._preamble).to.include.members(['pushd /tmp', 'pwd', 'exit']) 328 | expect(Object.keys(output)).to.include.members(['pushd /tmp', 'pwd', 'exit']) 329 | expect(output.pwd).to.eql(['/tmp']) 330 | done() 331 | })) 332 | }) 333 | 334 | it('should receive ssh data via ssh2Data event', (done) => { 335 | const ssh2Data = [] 336 | gulpSSH 337 | .shell(['pushd /tmp', 'popd']) 338 | .on('ssh2Data', (chunk) => ssh2Data.push(chunk)) 339 | .on('finish', () => { 340 | expect(ssh2Data).to.not.be.empty() 341 | expect(ssh2Data[0]).to.be.instanceOf(Buffer) 342 | const output = parseLog(ssh2Data.map((data) => data.toString()).join('')) 343 | expect(Object.keys(output)).to.include.members(['pushd /tmp', 'popd', 'exit']) 344 | done() 345 | }) 346 | }) 347 | }) 348 | }) 349 | -------------------------------------------------------------------------------- /test/scripts/ssh-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . test/scripts/sshrc 4 | 5 | # set up ssh server 6 | mkdir -p $SSH_SERVER_HOME 7 | cat << EOF > $SSH_SERVER_HOME/sshd_config 8 | Port 2222 9 | ListenAddress 127.0.0.1 10 | HostKey $SSH_SERVER_HOME/host_rsa 11 | PidFile $SSH_SERVER_HOME/pid 12 | PasswordAuthentication no 13 | PubkeyAuthentication yes 14 | ChallengeResponseAuthentication no 15 | Subsystem sftp /usr/lib/openssh/sftp-server 16 | UsePAM no 17 | EOF 18 | ssh-keygen -t rsa -b 4096 -N "" -f $SSH_SERVER_HOME/host_rsa -q 19 | /usr/sbin/sshd -f $SSH_SERVER_HOME/sshd_config 20 | 21 | # set up ssh client 22 | mkdir -p $SSH_CLIENT_HOME 23 | ssh-keygen -t rsa -b 4096 -N "" -C gulp-ssh-test -f $SSH_CLIENT_HOME/id_rsa -q 24 | if [ -d ~/.ssh ]; then 25 | mv ~/.ssh ~/.ssh~ 26 | fi 27 | mkdir -p ~/.ssh 28 | chmod 700 ~/.ssh 29 | cat $SSH_CLIENT_HOME/id_rsa.pub > ~/.ssh/authorized_keys 30 | chmod 600 ~/.ssh/authorized_keys 31 | ssh-agent -s > ~/.ssh/agentrc 32 | eval $(cat ~/.ssh/agentrc) >/dev/null 33 | ssh-add $SSH_CLIENT_HOME/id_rsa 34 | -------------------------------------------------------------------------------- /test/scripts/ssh-teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . test/scripts/sshrc 4 | 5 | eval $(cat ~/.ssh/agentrc) >/dev/null 6 | eval $(ssh-agent -k) >/dev/null 7 | kill $(cat $SSH_SERVER_HOME/pid) 8 | rm -rf $SSH_SERVER_HOME 9 | rm -rf $SSH_CLIENT_HOME 10 | rm -rf ~/.ssh 11 | if [ -d ~/.ssh~ ]; then 12 | mv ~/.ssh~ ~/.ssh 13 | fi 14 | -------------------------------------------------------------------------------- /test/scripts/sshrc: -------------------------------------------------------------------------------- 1 | SSH_SERVER_HOME=$(node -p -e 'require("path").resolve("test/etc/sshd")') 2 | SSH_CLIENT_HOME=$(node -p -e 'require("path").resolve("test/etc/ssh")') 3 | --------------------------------------------------------------------------------