├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── FUNDING.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── .travis.yml
├── .yarnrc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── babel.config.js
├── jest.config.js
├── lerna.json
├── package.json
├── packages
├── shipit-cli
│ ├── .npmignore
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── bin
│ │ └── shipit
│ ├── package.json
│ ├── src
│ │ ├── Shipit.js
│ │ ├── Shipit.test.js
│ │ ├── cli.js
│ │ └── index.js
│ └── tests
│ │ ├── integration.test.js
│ │ └── sandbox
│ │ └── shipitfile.babel.js
├── shipit-deploy
│ ├── .npmignore
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── __mocks__
│ │ └── tmp-promise.js
│ ├── docs
│ │ └── Windows.md
│ ├── package.json
│ ├── src
│ │ ├── extendShipit.js
│ │ ├── index.js
│ │ └── tasks
│ │ │ ├── deploy
│ │ │ ├── clean.js
│ │ │ ├── clean.test.js
│ │ │ ├── fetch.js
│ │ │ ├── fetch.test.js
│ │ │ ├── finish.js
│ │ │ ├── finish.test.js
│ │ │ ├── index.js
│ │ │ ├── init.js
│ │ │ ├── init.test.js
│ │ │ ├── publish.js
│ │ │ ├── publish.test.js
│ │ │ ├── update.js
│ │ │ └── update.test.js
│ │ │ ├── pending
│ │ │ ├── index.js
│ │ │ ├── log.js
│ │ │ └── log.test.js
│ │ │ └── rollback
│ │ │ ├── finish.js
│ │ │ ├── finish.test.js
│ │ │ ├── index.js
│ │ │ ├── init.js
│ │ │ └── init.test.js
│ └── tests
│ │ ├── integration.test.js
│ │ ├── sandbox
│ │ └── shipitfile.babel.js
│ │ └── util.js
└── ssh-pool
│ ├── .npmignore
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── __mocks__
│ ├── child_process.js
│ ├── tmp.js
│ └── which.js
│ ├── examples
│ └── hostname.js
│ ├── package.json
│ ├── src
│ ├── Connection.js
│ ├── Connection.test.js
│ ├── ConnectionPool.js
│ ├── ConnectionPool.test.js
│ ├── commands
│ │ ├── cd.js
│ │ ├── cd.test.js
│ │ ├── mkdir.js
│ │ ├── mkdir.test.js
│ │ ├── raw.js
│ │ ├── raw.test.js
│ │ ├── rm.js
│ │ ├── rm.test.js
│ │ ├── rsync.js
│ │ ├── rsync.test.js
│ │ ├── scp.js
│ │ ├── scp.test.js
│ │ ├── ssh.js
│ │ ├── ssh.test.js
│ │ ├── tar.js
│ │ ├── tar.test.js
│ │ ├── util.js
│ │ └── util.test.js
│ ├── index.js
│ ├── remote.js
│ ├── remote.test.js
│ ├── util.js
│ └── util.test.js
│ └── tests
│ ├── __fixtures__
│ └── test.txt
│ └── integration.test.js
├── resources
├── shipit-logo-dark.png
├── shipit-logo-light.png
└── shipit-logo.sketch
├── ssh
├── id_rsa
└── id_rsa.pub
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | node_modules/
3 | lib/
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['airbnb-base', 'prettier'],
4 | parser: 'babel-eslint',
5 | parserOptions: {
6 | ecmaVersion: 8,
7 | sourceType: 'module',
8 | },
9 | env: {
10 | jest: true,
11 | },
12 | rules: {
13 | 'class-methods-use-this': 'off',
14 | 'import/prefer-default-export': 'off',
15 | },
16 | }
17 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: neoziro
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | packages/*/lib
3 | coverage/
4 | .eslintcache
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 10
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | lib/
2 | node_modules/
3 | CHANGELOG.md
4 | package.json
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "semi": false
5 | }
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - 8
5 | - 10
6 |
7 | before_install:
8 | - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 0.28.4
9 | - export PATH="$HOME/.yarn/bin:$PATH"
10 |
11 | script:
12 | - chmod 700 ssh && chmod 644 ssh/id_rsa.pub && chmod 600 ssh/id_rsa
13 | - yarn ci
14 |
15 | notifications:
16 | email: false
17 |
18 | cache:
19 | yarn: true
20 | directories:
21 | - '.eslintcache'
22 | - 'node_modules'
23 |
24 | addons:
25 | ssh_known_hosts: test.shipitjs.com
26 |
--------------------------------------------------------------------------------
/.yarnrc:
--------------------------------------------------------------------------------
1 | workspaces-experimental true
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | # [5.3.0](https://github.com/shipitjs/shipit/compare/v5.2.0...v5.3.0) (2020-03-18)
7 |
8 |
9 | ### Features
10 |
11 | * add support of `asUser` ([#260](https://github.com/shipitjs/shipit/issues/260)) ([4e79edb](https://github.com/shipitjs/shipit/commit/4e79edb))
12 |
13 |
14 |
15 |
16 |
17 | # [5.2.0](https://github.com/shipitjs/shipit/compare/v5.1.0...v5.2.0) (2020-03-07)
18 |
19 |
20 | ### Bug Fixes
21 |
22 | * **windows:** cd must run the specified drive letter ([#252](https://github.com/shipitjs/shipit/issues/252)) ([ab916a9](https://github.com/shipitjs/shipit/commit/ab916a9))
23 | * fix remote command wont reject on error, when cwd option is used ([#265](https://github.com/shipitjs/shipit/issues/265)) ([986aec1](https://github.com/shipitjs/shipit/commit/986aec1))
24 |
25 |
26 | ### Features
27 |
28 | * add a config validation function ([#258](https://github.com/shipitjs/shipit/issues/258)) ([d98ec8e](https://github.com/shipitjs/shipit/commit/d98ec8e))
29 |
30 |
31 |
32 |
33 |
34 | # [5.1.0](https://github.com/shipitjs/shipit/compare/v5.0.0...v5.1.0) (2019-08-28)
35 |
36 |
37 | ### Bug Fixes
38 |
39 | * correct peerDependencies field for shipit-deploy package ([#243](https://github.com/shipitjs/shipit/issues/243)) ([3586c21](https://github.com/shipitjs/shipit/commit/3586c21))
40 |
41 |
42 | ### Features
43 |
44 | * **shipit-deploy:** Added config so you can rsync including the folder ([#246](https://github.com/shipitjs/shipit/issues/246)) ([64481f8](https://github.com/shipitjs/shipit/commit/64481f8))
45 | * **ssh-pool:** Added ssh config array to remote server ([#248](https://github.com/shipitjs/shipit/issues/248)) ([ba1d8c2](https://github.com/shipitjs/shipit/commit/ba1d8c2))
46 |
47 |
48 |
49 |
50 |
51 | # [4.2.0](https://github.com/shipitjs/shipit/compare/v4.1.4...v4.2.0) (2019-03-01)
52 |
53 |
54 | ### Features
55 |
56 | * add "init:after_ssh_pool" event ([#230](https://github.com/shipitjs/shipit/issues/230)) ([e864338](https://github.com/shipitjs/shipit/commit/e864338))
57 |
58 |
59 |
60 |
61 |
62 | ## [4.1.4](https://github.com/shipitjs/shipit/compare/v4.1.3...v4.1.4) (2019-02-19)
63 |
64 |
65 | ### Bug Fixes
66 |
67 | * **shipit-deploy:** skip fetching git in case when repositoryUrl was not provided (closes [#207](https://github.com/shipitjs/shipit/issues/207)) ([#226](https://github.com/shipitjs/shipit/issues/226)) ([4ae0f89](https://github.com/shipitjs/shipit/commit/4ae0f89))
68 |
69 |
70 |
71 |
72 |
73 | ## [4.1.3](https://github.com/shipitjs/shipit/compare/v4.1.2...v4.1.3) (2018-11-11)
74 |
75 |
76 | ### Bug Fixes
77 |
78 | * fixes directory permissions ([#224](https://github.com/shipitjs/shipit/issues/224)) ([3277adf](https://github.com/shipitjs/shipit/commit/3277adf)), closes [#189](https://github.com/shipitjs/shipit/issues/189)
79 |
80 |
81 |
82 |
83 |
84 | ## [4.1.2](https://github.com/shipitjs/shipit/compare/v4.1.1...v4.1.2) (2018-11-04)
85 |
86 |
87 | ### Bug Fixes
88 |
89 | * **security:** use which instead of whereis ([#220](https://github.com/shipitjs/shipit/issues/220)) ([6f46cad](https://github.com/shipitjs/shipit/commit/6f46cad))
90 | * **shipit-deploy:** only remove workspace if not shallow clone ([#200](https://github.com/shipitjs/shipit/issues/200)) ([6ba6f00](https://github.com/shipitjs/shipit/commit/6ba6f00))
91 | * use correct deprecation warning ([#219](https://github.com/shipitjs/shipit/issues/219)) ([e0c0fa5](https://github.com/shipitjs/shipit/commit/e0c0fa5))
92 |
93 |
94 |
95 |
96 |
97 |
98 | ## [4.1.1](https://github.com/shipitjs/shipit/compare/v4.1.0...v4.1.1) (2018-05-30)
99 |
100 |
101 | ### Bug Fixes
102 |
103 | * update shipit-deploy's peerDependency to v4.1.0 ([#192](https://github.com/shipitjs/shipit/issues/192)) ([6f7b407](https://github.com/shipitjs/shipit/commit/6f7b407))
104 |
105 |
106 |
107 |
108 |
109 | # [4.1.0](https://github.com/shipitjs/shipit/compare/v4.0.2...v4.1.0) (2018-04-27)
110 |
111 |
112 | ### Features
113 |
114 | * **ssh-pool:** add SSH Verbosity Levels ([#191](https://github.com/shipitjs/shipit/issues/191)) ([327c63e](https://github.com/shipitjs/shipit/commit/327c63e))
115 |
116 |
117 |
118 |
119 |
120 | ## [4.0.2](https://github.com/shipitjs/shipit/compare/v4.0.1...v4.0.2) (2018-03-25)
121 |
122 |
123 | ### Bug Fixes
124 |
125 | * be compatible with CommonJS ([abd2316](https://github.com/shipitjs/shipit/commit/abd2316))
126 | * fix scpCopyFromRemote & scpCopyToRemote ([01bc213](https://github.com/shipitjs/shipit/commit/01bc213)), closes [#178](https://github.com/shipitjs/shipit/issues/178)
127 |
128 |
129 |
130 |
131 |
132 | ## [4.0.1](https://github.com/shipitjs/shipit/compare/v4.0.0...v4.0.1) (2018-03-18)
133 |
134 |
135 | ### Bug Fixes
136 |
137 | * **shipit-cli:** correctly publish binary ([6b60f20](https://github.com/shipitjs/shipit/commit/6b60f20))
138 |
139 |
140 |
141 |
142 |
143 |
144 | # 4.0.0 (2018-03-17)
145 |
146 | ## global
147 |
148 | ### Chores
149 |
150 | * Move to a Lerna repository
151 | * Add Codecov
152 | * Move to Jest for testing
153 | * Rewrite project in ES2017 targeting Node.js v6+
154 |
155 | ## shipit-cli
156 |
157 | ### Features
158 |
159 | * Improve Shipit cli utilities #75
160 | * Support ES6 modules in shipitfile.babel.js
161 | * Give access to raw config #93
162 | * Standardize errors #154
163 |
164 | ### Fixes
165 |
166 | * Fix usage of user directory #160
167 | * Fix SSH key config shipitjs/shipit-deploy#151 shipitjs/shipit-deploy#126
168 |
169 | ### Docs
170 |
171 | * Improve documentation #69 #148 #81
172 |
173 | ### Deprecations
174 |
175 | * Deprecate `remoteCopy` in favor of `copyToRemote` and `copyFromRemote`
176 |
177 | ### BREAKING CHANGES
178 |
179 | * Drop callbacks support and use native Promises
180 |
181 | ## ssh-pool
182 |
183 | ### Features
184 |
185 | * Introduce a "tty" option in "run" method #56
186 | * Support "cwd" in "run" command #9
187 | * Expose a "isRsyncSupported" method
188 |
189 | ### Fixes
190 |
191 | * Fix parallel issues using scp copy shipitjs/ssh-pool#22
192 | * Fix command escaping #91 #152
193 |
194 | ### Docs
195 |
196 | * Update readme with new documentation
197 |
198 | ### Deprecations
199 |
200 | * Deprecate automatic "sudo" removing when using "asUser" #56 #12
201 | * Deprecate "copy" method in favor of "copyToRemote", "copyFromRemote", "scpCopyToRemote" and "scpCopyFromRemote"
202 | * Deprecate using "deploy" as default user
203 | * Deprecate automatic "tty" when detecting "sudo" #56
204 |
205 | ### BREAKING CHANGES
206 |
207 | * Drop callbacks support and use native Promises
208 | * Standardise errors #154
209 | * Replace "cwd" behaviour in "run" command #9
210 |
211 | ## shipit-deploy
212 |
213 | ### Fixes
214 |
215 | * Use [ instead of [[ to improve compatiblity shipitjs/shipit-deploy#147 shipitjs/shipit-deploy#148
216 | * Use rmfr to improve compatibility shipitjs/shipit-deploy#135 shipitjs/shipit-deploy#155
217 |
218 | ### BREAKING CHANGES
219 |
220 | * Default shallowClone to `true`
221 | * Drop grunt-shipit support
222 | * Workspace is now a temp directory in shallow clone
223 | * An error is thrown if workspace is set to the current directory
224 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Greg Bergé and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Universal automation and deployment tool ⛵️
5 |
6 | [![Build Status][build-badge]][build]
7 | [![version][version-badge]][package]
8 | [![MIT License][license-badge]][license]
9 |
10 | [![PRs Welcome][prs-badge]][prs]
11 |
12 | [![Watch on GitHub][github-watch-badge]][github-watch]
13 | [![Star on GitHub][github-star-badge]][github-star]
14 | [![Tweet][twitter-badge]][twitter]
15 |
16 | ## Install shipit command line tools and shipit-deploy in your project
17 |
18 | ```
19 | npm install --save-dev shipit-cli
20 | npm install --save-dev shipit-deploy
21 | ```
22 |
23 | Shipit is an automation engine and a deployment tool.
24 |
25 | Shipit provides a good alternative to Capistrano or other build tools. It is easy to deploy or to automate simple tasks on your remote servers.
26 |
27 | **Features:**
28 |
29 | - Write your task using JavaScript
30 | - Task flow based on [orchestrator](https://github.com/orchestrator/orchestrator)
31 | - Login and interactive SSH commands
32 | - Easily extendable
33 |
34 | ## Deploy using Shipit
35 |
36 | 1. Create a `shipitfile.js` at the root of your project
37 |
38 | ```js
39 | // shipitfile.js
40 | module.exports = shipit => {
41 | // Load shipit-deploy tasks
42 | require('shipit-deploy')(shipit)
43 |
44 | shipit.initConfig({
45 | default: {
46 | deployTo: '/var/apps/super-project',
47 | repositoryUrl: 'https://github.com/user/super-project.git',
48 | },
49 | staging: {
50 | servers: 'deploy@staging.super-project.com',
51 | },
52 | })
53 | }
54 | ```
55 |
56 | 2. Run deploy command using [npx](https://www.npmjs.com/package/npx): `npx shipit staging deploy`
57 |
58 | 3. You can rollback using `npx shipit staging rollback`
59 |
60 | ## Recipes
61 |
62 | ### Copy config file
63 |
64 | Add a custom task in your `shipitfile.js` and run `copyToRemote`.
65 |
66 | ```js
67 | // shipitfile.js
68 | module.exports = shipit => {
69 | /* ... */
70 |
71 | shipit.task('copyConfig', async () => {
72 | await shipit.copyToRemote(
73 | 'config.json',
74 | '/var/apps/super-project/config.json',
75 | )
76 | })
77 | }
78 | ```
79 |
80 | ### Use events
81 |
82 | You can add custom event and listen to events.
83 |
84 | ```js
85 | shipit.task('build', function() {
86 | // ...
87 | shipit.emit('built')
88 | })
89 |
90 | shipit.on('built', function() {
91 | shipit.start('start-server')
92 | })
93 | ```
94 |
95 | Shipit emits the `init` event once initialized, before any tasks are run.
96 |
97 | ### Use Babel in your `shipitfile.js`
98 |
99 | Instead of using a `shipitfile.js`, use `shipitfile.babel.js`:
100 |
101 | ```js
102 | // shipitfile.babel.js
103 | export default shipit => {
104 | shipit.initConfig({
105 | /* ... */
106 | })
107 | }
108 | ```
109 |
110 | ### Customizing environments
111 |
112 | You can overwrite all default variables defined as part of the `default` object:
113 |
114 | ```js
115 | module.exports = shipit => {
116 | shipit.initConfig({
117 | default: {
118 | branch: 'dev',
119 | },
120 | staging: {
121 | servers: 'staging.myproject.com',
122 | workspace: '/home/vagrant/website'
123 | },
124 | production: {
125 | servers: [{
126 | host: 'app1.myproject.com',
127 | user: 'john',
128 | }, {
129 | host: 'app2.myproject.com',
130 | user: 'rob',
131 | }],
132 | branch: 'production',
133 | workspace: '/var/www/website'
134 | }
135 | });
136 |
137 | ...
138 | shipit.task('pwd', function () {
139 | return shipit.remote('pwd');
140 | });
141 | ...
142 | };
143 | ```
144 |
145 | ### Asynchronous config
146 |
147 | If you can't call `shipit.initConfig(...)` right away because
148 | you need to get data asynchronously to do so, you can return
149 | a promise from the module:
150 |
151 | ```js
152 | module.exports = async shipit => {
153 | const servers = await getServers()
154 | shipit.initConfig({
155 | production: {
156 | servers: servers,
157 | // ...
158 | },
159 | })
160 | }
161 | ```
162 |
163 | ## Usage
164 |
165 | ```
166 | Usage: shipit
167 |
168 | Options:
169 |
170 | -V, --version output the version number
171 | --shipitfile Specify a custom shipitfile to use
172 | --require Script required before launching Shipit
173 | --tasks List available tasks
174 | --environments List available environments
175 | -h, --help output usage information
176 | ```
177 |
178 | ### Global configuration
179 |
180 | #### ignores
181 |
182 | Type: `Array`
183 |
184 | List of files excluded in `copyFromRemote` or `copyToRemote` methods.
185 |
186 | #### key
187 |
188 | Type: `String`
189 |
190 | Path to SSH key.
191 |
192 | #### servers
193 |
194 | Type: `String` or `Array`
195 |
196 | The server can use the shorthand syntax or an object:
197 |
198 | - `user@host`: user and host
199 | - `user@host:4000`: user, host and port
200 | - `{ user, host, port, extraSshOptions }`: an object
201 |
202 | ### Shipit Deploy configuration
203 |
204 | #### asUser
205 |
206 | Type: `String`
207 |
208 | Allows you to ‘become’ another user, different from the user that logged into the machine (remote user).
209 |
210 | #### deleteOnRollback
211 |
212 | Type: `Boolean`, default to `false`
213 |
214 | Delete release when a rollback is done.
215 |
216 | #### deployTo
217 |
218 | Type: `String`
219 |
220 | Directory where the code will be deployed on remote servers.
221 |
222 | #### keepReleases
223 |
224 | Type: `Number`
225 |
226 | Number of releases kept on remote servers.
227 |
228 | #### repositoryUrl
229 |
230 | Type: `String`
231 |
232 | Repository URL to clone, must be defined using `https` or `git+ssh` format.
233 |
234 | #### shallowClone
235 |
236 | Type: `Boolean`, default `true`
237 |
238 | Clone only the last commit of the repository.
239 |
240 | #### workspace
241 |
242 | Type: `String`
243 |
244 | If `shallowClone` is set to `false`, this directory will be used to clone the repository before deploying it.
245 |
246 | #### verboseSSHLevel
247 |
248 | Type: `Number`, default `0`
249 |
250 | SSH verbosity level to use when connecting to remote servers. **0** (none), **1** (-v), **2** (-vv), **3** (-vvv).
251 |
252 | ### API
253 |
254 | #### shipit.task(name, [deps], fn)
255 |
256 | Create a new Shipit task. If a promise is returned task will wait for completion.
257 |
258 | ```js
259 | shipit.task('hello', async () => {
260 | await shipit.remote('echo "hello on remote"')
261 | await shipit.local('echo "hello from local"')
262 | })
263 | ```
264 |
265 | #### shipit.blTask(name, [deps], fn)
266 |
267 | Create a new Shipit task that will block other tasks during its execution. If a promise is returned other task will wait before start.
268 |
269 | ```js
270 | shipit.blTask('hello', async () => {
271 | await shipit.remote('echo "hello on remote"')
272 | await shipit.local('echo "hello from local"')
273 | })
274 | ```
275 |
276 | #### shipit.start(tasks)
277 |
278 | Run Shipit tasks.
279 |
280 | ```js
281 | shipit.start('task')
282 | shipit.start('task1', 'task2')
283 | shipit.start(['task1', 'task2'])
284 | ```
285 |
286 | #### shipit.local(command, [options])
287 |
288 | Run a command locally and streams the result. See [ssh-pool#exec](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#exec).
289 |
290 | ```js
291 | shipit
292 | .local('ls -lah', {
293 | cwd: '/tmp/deploy/workspace',
294 | })
295 | .then(({ stdout }) => console.log(stdout))
296 | .catch(({ stderr }) => console.error(stderr))
297 | ```
298 |
299 | #### shipit.remote(command, [options])
300 |
301 | Run a command remotely and streams the result. See [ssh-pool#connection.run](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#connectionruncommand-options).
302 |
303 | ```js
304 | shipit
305 | .remote('ls -lah')
306 | .then(([server1Result, server2Result]) => {
307 | console.log(server1Result.stdout)
308 | console.log(server2Result.stdout)
309 | })
310 | .catch(error => {
311 | console.error(error.stderr)
312 | })
313 | ```
314 |
315 | #### shipit.copyToRemote(src, dest, [options])
316 |
317 | Make a remote copy from a local path to a remote path. See [ssh-pool#connection.copyToRemote](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#connectioncopytoremotesrc-dest-options).
318 |
319 | ```js
320 | shipit.copyToRemote('/tmp/workspace', '/opt/web/myapp')
321 | ```
322 |
323 | #### shipit.copyFromRemote(src, dest, [options])
324 |
325 | Make a remote copy from a remote path to a local path. See [ssh-pool#connection.copyFromRemote](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#connectioncopyfromremotesrc-dest-options).
326 |
327 | ```js
328 | shipit.copyFromRemote('/opt/web/myapp', '/tmp/workspace')
329 | ```
330 |
331 | #### shipit.log(...args)
332 |
333 | Log using Shipit, same API as `console.log`.
334 |
335 | ```js
336 | shipit.log('hello %s', 'world')
337 | ```
338 |
339 | ## Dependencies
340 |
341 | - [OpenSSH](http://www.openssh.com/) 5+
342 | - [rsync](https://rsync.samba.org/) 3+
343 |
344 | ## Known Plugins
345 |
346 | ### Official
347 |
348 | - [shipit-deploy](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy)
349 |
350 | ### Third Party
351 |
352 | - [shipit-shared](https://github.com/timkelty/shipit-shared)
353 | - [shipit-db](https://github.com/timkelty/shipit-db)
354 | - [shipit-assets](https://github.com/timkelty/shipit-assets)
355 | - [shipit-ssh](https://github.com/timkelty/shipit-ssh)
356 | - [shipit-utils](https://github.com/timkelty/shipit-utils)
357 | - [shipit-npm](https://github.com/callerc1/shipit-npm)
358 | - [shipit-aws](https://github.com/KrashStudio/shipit-aws)
359 | - [shipit-captain](https://github.com/timkelty/shipit-captain/)
360 | - [shipit-bower](https://github.com/willsteinmetz/shipit-bower)
361 | - [shipit-composer](https://github.com/jeremyzahner/shipit-composer)
362 | - [shipit-bastion](https://github.com/BrokerageEngine/shipit-bastion)
363 | - [shipit-yaml](https://github.com/davidbernal/shipit-yaml)
364 | - [shipit-conditional](https://github.com/BrokerageEngine/shipit-conditional)
365 |
366 | ## Who use Shipit?
367 |
368 | - [Le Monde](http://www.lemonde.fr)
369 | - [Ghost blogging platform](https://ghost.org/)
370 | - [Fusionary](http://fusionary.com)
371 |
372 | ## License
373 |
374 | MIT
375 |
376 | [build-badge]: https://img.shields.io/travis/shipitjs/shipit.svg?style=flat-square
377 | [build]: https://travis-ci.org/shipitjs/shipit
378 | [version-badge]: https://img.shields.io/npm/v/shipit-cli.svg?style=flat-square
379 | [package]: https://www.npmjs.com/package/shipit-cli
380 | [license-badge]: https://img.shields.io/npm/l/shipit-cli.svg?style=flat-square
381 | [license]: https://github.com/shipitjs/shipit/blob/master/LICENSE
382 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square
383 | [prs]: http://makeapullrequest.com
384 | [github-watch-badge]: https://img.shields.io/github/watchers/shipitjs/shipit.svg?style=social
385 | [github-watch]: https://github.com/shipitjs/shipit/watchers
386 | [github-star-badge]: https://img.shields.io/github/stars/shipitjs/shipit.svg?style=social
387 | [github-star]: https://github.com/shipitjs/shipit/stargazers
388 | [twitter]: https://twitter.com/intent/tweet?text=Check%20out%20ShipitJS!%20https://github.com/shipitjs/shipit%20%F0%9F%91%8D
389 | [twitter-badge]: https://img.shields.io/twitter/url/https/github.com/shipitjs/shipit.svg?style=social
390 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env',
5 | {
6 | targets: {
7 | node: '6',
8 | },
9 | loose: true,
10 | },
11 | ],
12 | ],
13 | }
14 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | process.env.FORCE_COLOR=0;
2 |
3 | module.exports = {
4 | testEnvironment: 'node',
5 | roots: ['packages'],
6 | coverageDirectory: './coverage/',
7 | watchPlugins: [
8 | 'jest-watch-typeahead/filename',
9 | 'jest-watch-typeahead/testname',
10 | ],
11 | }
12 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "lerna": "3.4.3",
3 | "npmClient": "yarn",
4 | "version": "5.3.0",
5 | "useWorkspaces": true
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "MIT",
3 | "private": true,
4 | "scripts": {
5 | "postinstall": "chmod 400 ./ssh/id_rsa ./ssh/id_rsa.pub",
6 | "build": "lerna run build",
7 | "ci": "yarn build && yarn lint && yarn test --ci --coverage && codecov",
8 | "dev": "lerna run build --parallel -- --watch",
9 | "format": "prettier --write \"**/*.{js,json,md}\"",
10 | "lint": "eslint .",
11 | "release": "lerna publish --conventional-commits && conventional-github-releaser --preset angular",
12 | "test": "jest --runInBand"
13 | },
14 | "devDependencies": {
15 | "@babel/cli": "^7.5.5",
16 | "@babel/core": "^7.5.5",
17 | "@babel/node": "^7.5.5",
18 | "@babel/preset-env": "^7.5.5",
19 | "babel-core": "^7.0.0-0",
20 | "babel-eslint": "^10.0.3",
21 | "babel-jest": "^24.9.0",
22 | "babel-preset-env": "^1.7.0",
23 | "chalk": "^2.4.1",
24 | "codecov": "^3.1.0",
25 | "conventional-github-releaser": "^3.1.2",
26 | "eslint": "^6.2.2",
27 | "eslint-config-airbnb-base": "^14.0.0",
28 | "eslint-config-prettier": "^6.1.0",
29 | "eslint-plugin-import": "^2.18.2",
30 | "glob": "^7.1.3",
31 | "jest": "^24.9.0",
32 | "jest-watch-typeahead": "^0.4.0",
33 | "lerna": "^3.16.4",
34 | "mkdirp": "^0.5.1",
35 | "mock-utf8-stream": "^0.1.1",
36 | "prettier": "^1.14.3",
37 | "std-mocks": "^1.0.1"
38 | },
39 | "workspaces": [
40 | "packages/*"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/packages/shipit-cli/.npmignore:
--------------------------------------------------------------------------------
1 | /*
2 | !/lib/**/*.js
3 | !/bin/*
4 | *.test.js
5 |
--------------------------------------------------------------------------------
/packages/shipit-cli/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | # [5.3.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.2.0...v5.3.0) (2020-03-18)
7 |
8 |
9 | ### Features
10 |
11 | * add support of `asUser` ([#260](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/260)) ([4e79edb](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/4e79edb))
12 |
13 |
14 |
15 |
16 |
17 | # [5.2.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.1.0...v5.2.0) (2020-03-07)
18 |
19 | **Note:** Version bump only for package shipit-cli
20 |
21 |
22 |
23 |
24 |
25 | # [5.1.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.0.0...v5.1.0) (2019-08-28)
26 |
27 | **Note:** Version bump only for package shipit-cli
28 |
29 |
30 |
31 |
32 |
33 | # [4.2.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v4.1.4...v4.2.0) (2019-03-01)
34 |
35 |
36 | ### Features
37 |
38 | * add "init:after_ssh_pool" event ([#230](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/230)) ([e864338](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/e864338))
39 |
40 |
41 |
42 |
43 |
44 | ## [4.1.2](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v4.1.1...v4.1.2) (2018-11-04)
45 |
46 | **Note:** Version bump only for package shipit-cli
47 |
48 |
49 |
50 |
51 |
52 |
53 | ## [4.1.1](https://github.com/shipitjs/shipit/compare/v4.1.0...v4.1.1) (2018-05-30)
54 |
55 |
56 |
57 |
58 | **Note:** Version bump only for package shipit-cli
59 |
60 |
61 | # [4.1.0](https://github.com/shipitjs/shipit/compare/v4.0.2...v4.1.0) (2018-04-27)
62 |
63 |
64 | ### Features
65 |
66 | * **ssh-pool:** add SSH Verbosity Levels ([#191](https://github.com/shipitjs/shipit/issues/191)) ([327c63e](https://github.com/shipitjs/shipit/commit/327c63e))
67 |
68 |
69 |
70 |
71 |
72 | ## [4.0.2](https://github.com/shipitjs/shipit/compare/v4.0.1...v4.0.2) (2018-03-25)
73 |
74 |
75 | ### Bug Fixes
76 |
77 | * be compatible with CommonJS ([abd2316](https://github.com/shipitjs/shipit/commit/abd2316))
78 |
79 |
80 |
81 |
82 |
83 | ## [4.0.1](https://github.com/shipitjs/shipit/compare/v4.0.0...v4.0.1) (2018-03-18)
84 |
85 |
86 | ### Bug Fixes
87 |
88 | * **shipit-cli:** correctly publish binary ([6b60f20](https://github.com/shipitjs/shipit/commit/6b60f20))
89 |
90 |
91 |
92 |
93 |
94 |
95 | # 4.0.0 (2018-03-17)
96 |
97 | ### Features
98 |
99 | * Improve Shipit cli utilities #75
100 | * Support ES6 modules in shipitfile.babel.js
101 | * Give access to raw config #93
102 | * Standardize errors #154
103 |
104 | ### Fixes
105 |
106 | * Fix usage of user directory #160
107 | * Fix SSH key config shipitjs/shipit-deploy#151 shipitjs/shipit-deploy#126
108 |
109 | ### Chores
110 |
111 | * Move to a Lerna repository
112 | * Add Codecov
113 | * Move to Jest for testing
114 | * Rewrite project in ES2017 targeting Node.js v6+
115 |
116 | ### Docs
117 |
118 | * Improve documentation #69 #148 #81
119 |
120 | ### Deprecations
121 |
122 | * Deprecate `remoteCopy` in favor of `copyToRemote` and `copyFromRemote`
123 |
124 | ### BREAKING CHANGES
125 |
126 | * Drop callbacks support and use native Promises
127 |
--------------------------------------------------------------------------------
/packages/shipit-cli/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Greg Bergé and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/shipit-cli/README.md:
--------------------------------------------------------------------------------
1 | # shipit-cli
2 |
3 | [![Build Status][build-badge]][build]
4 | [![version][version-badge]][package]
5 | [![MIT License][license-badge]][license]
6 |
7 | Shipit command line interface.
8 |
9 | ```
10 | npm install --save-dev shipit-cli
11 | ```
12 |
13 | ## Usage
14 |
15 | ```
16 | Usage: shipit
17 |
18 | Options:
19 |
20 | -V, --version output the version number
21 | --shipitfile Specify a custom shipitfile to use
22 | --require Script required before launching Shipit
23 | --tasks List available tasks
24 | --environments List available environments
25 | -h, --help output usage information
26 | ```
27 |
28 | ## `shipitfile.js`
29 |
30 | ```js
31 | module.exports = shipit => {
32 | shipit.initConfig({
33 | staging: {
34 | servers: 'myproject.com',
35 | },
36 | })
37 |
38 | shipit.task('pwd', async () => {
39 | await shipit.remote('pwd')
40 | })
41 | }
42 | ```
43 |
44 | ## API
45 |
46 | #### shipit.task(name, [deps], fn)
47 |
48 | Create a new Shipit task. If a promise is returned task will wait for completion.
49 |
50 | ```js
51 | shipit.task('hello', async () => {
52 | await shipit.remote('echo "hello on remote"')
53 | await shipit.local('echo "hello from local"')
54 | })
55 | ```
56 |
57 | #### shipit.blTask(name, [deps], fn)
58 |
59 | Create a new Shipit task that will block other tasks during its execution. If a promise is returned other task will wait before start.
60 |
61 | ```js
62 | shipit.blTask('hello', async () => {
63 | await shipit.remote('echo "hello on remote"')
64 | await shipit.local('echo "hello from local"')
65 | })
66 | ```
67 |
68 | #### shipit.start(tasks)
69 |
70 | Run Shipit tasks.
71 |
72 | ```js
73 | shipit.start('task')
74 | shipit.start('task1', 'task2')
75 | shipit.start(['task1', 'task2'])
76 | ```
77 |
78 | #### shipit.local(command, [options])
79 |
80 | Run a command locally and streams the result. See [ssh-pool#exec](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#exec).
81 |
82 | ```js
83 | shipit
84 | .local('ls -lah', {
85 | cwd: '/tmp/deploy/workspace',
86 | })
87 | .then(({ stdout }) => console.log(stdout))
88 | .catch(({ stderr }) => console.error(stderr))
89 | ```
90 |
91 | #### shipit.remote(command, [options])
92 |
93 | Run a command remotely and streams the result. Run a command locally and streams the result. See [ssh-pool#connection.run](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#connectionruncommand-options).
94 |
95 | ```js
96 | shipit
97 | .remote('ls -lah')
98 | .then(([server1Result, server2Result]) => {
99 | console.log(server1Result.stdout)
100 | console.log(server2Result.stdout)
101 | })
102 | .catch(error => {
103 | console.error(error.stderr)
104 | })
105 | ```
106 |
107 | #### shipit.copyToRemote(src, dest, [options])
108 |
109 | Make a remote copy from a local path to a remote path. See [ssh-pool#connection.copyToRemote](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#connectioncopytoremotesrc-dest-options).
110 |
111 | ```js
112 | shipit.copyToRemote('/tmp/workspace', '/opt/web/myapp')
113 | ```
114 |
115 | #### shipit.copyFromRemote(src, dest, [options])
116 |
117 | Make a remote copy from a remote path to a local path. See [ssh-pool#connection.copyFromRemote](https://github.com/shipitjs/shipit/tree/master/packages/ssh-pool#connectioncopyfromremotesrc-dest-options).
118 |
119 | ```js
120 | shipit.copyFromRemote('/opt/web/myapp', '/tmp/workspace')
121 | ```
122 |
123 | #### shipit.log(...args)
124 |
125 | Log using Shipit, same API as `console.log`.
126 |
127 | ```js
128 | shipit.log('hello %s', 'world')
129 | ```
130 |
131 | ## Workflow tasks
132 |
133 | When the system initializes it automatically emits events:
134 |
135 | - Emit event "init"
136 | - Emit event "init:after_ssh_pool"
137 |
138 | Each shipit task also generates events:
139 |
140 | - Emit event "task_start"
141 | - Emit event "task_stop"
142 | - Emit event "task_err"
143 | - Emit event "task_not_found"
144 |
145 | Inside the task events, you can test for the task name.
146 |
147 | ```js
148 | shipit.on('task_start', event => {
149 | if (event.task == 'first_task') {
150 | shipit.log("I'm the first task")
151 | }
152 | })
153 | ```
154 |
155 | ## License
156 |
157 | MIT
158 |
159 | [build-badge]: https://img.shields.io/travis/shipitjs/shipit.svg?style=flat-square
160 | [build]: https://travis-ci.org/shipitjs/shipit
161 | [version-badge]: https://img.shields.io/npm/v/shipit-cli.svg?style=flat-square
162 | [package]: https://www.npmjs.com/package/shipit-cli
163 | [license-badge]: https://img.shields.io/npm/l/shipit-cli.svg?style=flat-square
164 | [license]: https://github.com/shipitjs/shipit/blob/master/LICENSE
165 |
--------------------------------------------------------------------------------
/packages/shipit-cli/bin/shipit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('../lib/cli')
4 |
--------------------------------------------------------------------------------
/packages/shipit-cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shipit-cli",
3 | "version": "5.3.0",
4 | "description": "Universal automation and deployment tool written in JavaScript.",
5 | "engines": {
6 | "node": ">=6"
7 | },
8 | "author": "Greg Bergé ",
9 | "license": "MIT",
10 | "repository": "https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy",
11 | "main": "lib/index.js",
12 | "keywords": [
13 | "shipit",
14 | "automation",
15 | "deployment",
16 | "deploy",
17 | "ssh"
18 | ],
19 | "scripts": {
20 | "prebuild": "rm -rf lib/",
21 | "build": "babel --config-file ../../babel.config.js -d lib --ignore \"**/*.test.js\" src",
22 | "prepublishOnly": "yarn run build"
23 | },
24 | "bin": {
25 | "shipit": "./bin/shipit"
26 | },
27 | "dependencies": {
28 | "chalk": "^2.4.1",
29 | "commander": "^3.0.0",
30 | "interpret": "^1.1.0",
31 | "liftoff": "^3.1.0",
32 | "orchestrator": "^0.3.7",
33 | "pretty-hrtime": "^1.0.0",
34 | "ssh-pool": "^5.3.0",
35 | "stream-line-wrapper": "^0.1.1",
36 | "v8flags": "^3.1.0"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/shipit-cli/src/Shipit.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console, no-underscore-dangle */
2 | import { ConnectionPool, exec } from 'ssh-pool'
3 | import LineWrapper from 'stream-line-wrapper'
4 | import Orchestrator from 'orchestrator'
5 | import chalk from 'chalk'
6 | import prettyTime from 'pretty-hrtime'
7 |
8 | /**
9 | * An ExecResult returned when a command is executed with success.
10 | * @typedef {object} ExecResult
11 | * @property {Buffer} stdout
12 | * @property {Buffer} stderr
13 | * @property {ChildProcess} child
14 | */
15 |
16 | /**
17 | * An ExecResult returned when a command is executed with success.
18 | * @typedef {object} MultipleExecResult
19 | * @property {Buffer} stdout
20 | * @property {Buffer} stderr
21 | * @property {ChildProcess[]} children
22 | */
23 |
24 | /**
25 | * An ExecError returned when a command is executed with an error.
26 | * @typedef {Error} ExecError
27 | * @property {Buffer} stdout
28 | * @property {Buffer} stderr
29 | * @property {ChildProcess} child
30 | */
31 |
32 | /**
33 | * Format orchestrator error.
34 | *
35 | * @param {Error} e
36 | * @returns {Error}
37 | */
38 | function formatError(e) {
39 | if (!e.err) {
40 | return e.message
41 | }
42 |
43 | // PluginError
44 | if (typeof e.err.showStack === 'boolean') {
45 | return e.err.toString()
46 | }
47 |
48 | // normal error
49 | if (e.err.stack) {
50 | return e.err.stack
51 | }
52 |
53 | // unknown (string, number, etc.)
54 | return new Error(String(e.err)).stack
55 | }
56 |
57 | class Shipit extends Orchestrator {
58 | constructor(options) {
59 | super()
60 |
61 | const defaultOptions = {
62 | stdout: process.stdout,
63 | stderr: process.stderr,
64 | log: console.log.bind(console),
65 | }
66 |
67 | this.config = {}
68 | this.globalConfig = {}
69 | this.options = { ...defaultOptions, ...options }
70 | this.environment = options.environment
71 |
72 | this.initializeEvents()
73 |
74 | if (this.options.stdout === process.stdout)
75 | process.stdout.setMaxListeners(100)
76 |
77 | if (this.options.stderr === process.stderr)
78 | process.stderr.setMaxListeners(100)
79 | }
80 |
81 | /**
82 | * Initialize the `shipit`.
83 | *
84 | * @returns {Shipit} for chaining
85 | */
86 | initialize() {
87 | if (!this.globalConfig[this.environment])
88 | throw new Error(`Environment '${this.environment}' not found in config`)
89 |
90 | this.emit('init')
91 | return this.initSshPool()
92 | }
93 |
94 | /**
95 | * Initialize events.
96 | */
97 | initializeEvents() {
98 | this.on('task_start', e => {
99 | // Specific log for noop functions.
100 | if (this.tasks[e.task].fn.toString() === 'function () {}') return
101 |
102 | this.log('\nRunning', `'${chalk.cyan(e.task)}' task...`)
103 | })
104 |
105 | this.on('task_stop', e => {
106 | const task = this.tasks[e.task]
107 | // Specific log for noop functions.
108 | if (task.fn.toString() === 'function () {}') {
109 | this.log(
110 | 'Finished',
111 | `'${chalk.cyan(e.task)}'`,
112 | chalk.cyan(`[ ${task.dep.join(', ')} ]`),
113 | )
114 | return
115 | }
116 |
117 | const time = prettyTime(e.hrDuration)
118 | this.log(
119 | 'Finished',
120 | `'${chalk.cyan(e.task)}'`,
121 | 'after',
122 | chalk.magenta(time),
123 | )
124 | })
125 |
126 | this.on('task_err', e => {
127 | const msg = formatError(e)
128 | const time = prettyTime(e.hrDuration)
129 | this.log(
130 | `'${chalk.cyan(e.task)}'`,
131 | chalk.red('errored after'),
132 | chalk.magenta(time),
133 | )
134 | this.log(msg)
135 | })
136 |
137 | this.on('task_not_found', err => {
138 | this.log(chalk.red(`Task '${err.task}' is not in your shipitfile`))
139 | this.log(
140 | 'Please check the documentation for proper shipitfile formatting',
141 | )
142 | })
143 | }
144 |
145 | /**
146 | * Initialize SSH connections.
147 | *
148 | * @returns {Shipit} for chaining
149 | */
150 | initSshPool() {
151 | if (!this.config.servers) throw new Error('Servers not filled')
152 |
153 | const servers = Array.isArray(this.config.servers)
154 | ? this.config.servers
155 | : [this.config.servers]
156 |
157 | const options = {
158 | ...this.options,
159 | key: this.config.key,
160 | asUser: this.config.asUser,
161 | strict: this.config.strict,
162 | verbosityLevel:
163 | this.config.verboseSSHLevel === undefined
164 | ? 0
165 | : this.config.verboseSSHLevel,
166 | }
167 |
168 | this.pool = new ConnectionPool(servers, options)
169 |
170 | this.emit('init:after_ssh_pool')
171 | return this
172 | }
173 |
174 | /**
175 | * Initialize shipit configuration.
176 | *
177 | * @param {object} config
178 | * @returns {Shipit} for chaining
179 | */
180 | initConfig(config = {}) {
181 | this.globalConfig = config
182 | this.config = {
183 | ...config.default,
184 | ...config[this.environment],
185 | }
186 | return this
187 | }
188 |
189 | /**
190 | * Run a command locally.
191 | *
192 | * @param {string} command
193 | * @param {object} options
194 | * @returns {ChildObject}
195 | */
196 | local(command, { stdout, stderr, ...cmdOptions } = {}) {
197 | this.log('Running "%s" on local.', command)
198 | const prefix = '@ '
199 | return exec(command, cmdOptions, child => {
200 | if (this.options.stdout)
201 | child.stdout.pipe(new LineWrapper({ prefix })).pipe(this.options.stdout)
202 |
203 | if (this.options.stderr)
204 | child.stderr.pipe(new LineWrapper({ prefix })).pipe(this.options.stderr)
205 | })
206 | }
207 |
208 | /**
209 | * Run a command remotely.
210 | *
211 | * @param {string} command
212 | * @returns {ExecResult}
213 | * @throws {ExecError}
214 | */
215 | async remote(command, options) {
216 | return this.pool.run(command, options)
217 | }
218 |
219 | /**
220 | * Copy from local to remote or vice versa.
221 | *
222 | * @param {string} src
223 | * @param {string} dest
224 | * @returns {ExecResult|MultipleExecResult}
225 | * @throws {ExecError}
226 | */
227 | async remoteCopy(src, dest, options) {
228 | const defaultOptions = {
229 | ignores: this.config && this.config.ignores ? this.config.ignores : [],
230 | rsync: this.config && this.config.rsync ? this.config.rsync : [],
231 | }
232 | const copyOptions = { ...defaultOptions, ...options }
233 |
234 | return this.pool.copy(src, dest, copyOptions)
235 | }
236 |
237 | /**
238 | * Run a copy from the local to the remote using rsync.
239 | * All exec options are also available.
240 | *
241 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
242 | *
243 | * @param {string} src
244 | * @param {string} dest
245 | * @param {object} [options] Options
246 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
247 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
248 | * @returns {MultipleExecResult}
249 | * @throws {ExecError}
250 | */
251 | async copyToRemote(src, dest, options) {
252 | const defaultOptions = {
253 | ignores: this.config && this.config.ignores ? this.config.ignores : [],
254 | rsync: this.config && this.config.rsync ? this.config.rsync : [],
255 | }
256 | const copyOptions = { ...defaultOptions, ...options }
257 | return this.pool.copyToRemote(src, dest, copyOptions)
258 | }
259 |
260 | /**
261 | * Run a copy from the remote to the local using rsync.
262 | * All exec options are also available.
263 | *
264 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
265 | * @param {string} src Source
266 | * @param {string} dest Destination
267 | * @param {object} [options] Options
268 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
269 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
270 | * @returns {MultipleExecResult}
271 | * @throws {ExecError}
272 | */
273 | async copyFromRemote(src, dest, options) {
274 | const defaultOptions = {
275 | ignores: this.config && this.config.ignores ? this.config.ignores : [],
276 | rsync: this.config && this.config.rsync ? this.config.rsync : [],
277 | }
278 | const copyOptions = { ...defaultOptions, ...options }
279 | return this.pool.copyFromRemote(src, dest, copyOptions)
280 | }
281 |
282 | /**
283 | * Log.
284 | *
285 | * @see console.log
286 | */
287 | log(...args) {
288 | this.options.log(...args)
289 | }
290 |
291 | /**
292 | * Create a new blocking task.
293 | *
294 | * @see shipit.task
295 | */
296 | blTask(name, ...rest) {
297 | this.task(name, ...rest)
298 | const task = this.tasks[name]
299 | task.blocking = true
300 | return task
301 | }
302 |
303 | /**
304 | * Test if we are ready to run a task.
305 | * Implement blocking task.
306 | */
307 | _readyToRunTask(...args) {
308 | if (
309 | Object.keys(this.tasks).some(key => {
310 | const task = this.tasks[key]
311 | return task.running === true && task.blocking === true
312 | })
313 | )
314 | return false
315 |
316 | return super._readyToRunTask(...args) // eslint-disable-line no-underscore-dangle
317 | }
318 | }
319 |
320 | export default Shipit
321 |
--------------------------------------------------------------------------------
/packages/shipit-cli/src/Shipit.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import Stream from 'mock-utf8-stream'
3 | import { ConnectionPool } from 'ssh-pool'
4 | import Shipit from './Shipit'
5 |
6 | describe('Shipit', () => {
7 | let shipit
8 | let stdout
9 | let stderr
10 |
11 | beforeEach(() => {
12 | stdout = new Stream.MockWritableStream()
13 | stderr = new Stream.MockWritableStream()
14 | shipit = new Shipit({
15 | stdout,
16 | stderr,
17 | environment: 'stage',
18 | log: jest.fn(),
19 | })
20 | shipit.stage = 'stage'
21 | })
22 |
23 | describe('#initConfig', () => {
24 | it('should set config and globalConfig', () => {
25 | const config = {
26 | default: { foo: 'bar', servers: ['1', '2'] },
27 | stage: { kung: 'foo', servers: ['3'] },
28 | }
29 |
30 | shipit.initConfig(config)
31 |
32 | expect(shipit.config).toEqual({ foo: 'bar', kung: 'foo', servers: ['3'] })
33 |
34 | expect(shipit.globalConfig).toBe(config)
35 | })
36 | })
37 |
38 | describe('#initialize', () => {
39 | beforeEach(() => {
40 | shipit.initConfig({ stage: {} })
41 | shipit.initSshPool = jest.fn(() => shipit)
42 | })
43 |
44 | it('should return an error if environment is not found', () => {
45 | shipit.initConfig({})
46 | expect(() => shipit.initialize()).toThrow(
47 | "Environment 'stage' not found in config",
48 | )
49 | })
50 |
51 | it('should add stage and initialize shipit', () => {
52 | shipit.initialize()
53 | expect(shipit.initSshPool).toBeCalled()
54 | })
55 | it('should emit a "init" event', async () => {
56 | const spy = jest.fn()
57 | shipit.on('init', spy)
58 | expect(spy).toHaveBeenCalledTimes(0)
59 | shipit.initialize()
60 | expect(spy).toHaveBeenCalledTimes(1)
61 | })
62 | })
63 |
64 | describe('#initSshPool', () => {
65 | it('should initialize an ssh pool', () => {
66 | shipit.config = { servers: ['deploy@my-server'] }
67 | shipit.initSshPool()
68 |
69 | expect(shipit.pool).toEqual(expect.any(ConnectionPool))
70 | expect(shipit.pool.connections[0].remote.user).toBe('deploy')
71 | expect(shipit.pool.connections[0].remote.host).toBe('my-server')
72 | })
73 | it('should emit a "init:after_ssh_pool" event', async () => {
74 | shipit.config = { servers: ['deploy@my-server'] }
75 | const spy = jest.fn()
76 | shipit.on('init:after_ssh_pool', spy)
77 | expect(spy).toHaveBeenCalledTimes(0)
78 | shipit.initSshPool()
79 | expect(spy).toHaveBeenCalledTimes(1)
80 | })
81 | })
82 |
83 | describe('#local', () => {
84 | it('should wrap and log to stdout', async () => {
85 | stdout.startCapture()
86 | const res = await shipit.local('echo "hello"')
87 | expect(stdout.capturedData).toBe('@ hello\n')
88 | expect(res.stdout).toBeDefined()
89 | expect(res.stderr).toBeDefined()
90 | expect(res.child).toBeDefined()
91 | })
92 | })
93 |
94 | describe('#remote', () => {
95 | beforeEach(() => {
96 | shipit.pool = { run: jest.fn() }
97 | })
98 |
99 | it('should run command on pool', () => {
100 | shipit.remote('my-command')
101 |
102 | expect(shipit.pool.run).toBeCalledWith('my-command', undefined)
103 | })
104 |
105 | it('should support options', () => {
106 | shipit.remote('my-command', { cwd: '/my-directory' })
107 |
108 | expect(shipit.pool.run).toBeCalledWith('my-command', {
109 | cwd: '/my-directory',
110 | })
111 | })
112 | })
113 |
114 | describe('#remoteCopy', () => {
115 | beforeEach(() => {
116 | shipit.pool = { copy: jest.fn() }
117 | })
118 |
119 | it('should run command on pool', () => {
120 | shipit.remoteCopy('src', 'dest')
121 |
122 | expect(shipit.pool.copy).toBeCalledWith('src', 'dest', {
123 | ignores: [],
124 | rsync: [],
125 | })
126 | })
127 |
128 | it('should accept options for shipit.pool.copy', () => {
129 | shipit.remoteCopy('src', 'dest', {
130 | direction: 'remoteToLocal',
131 | })
132 |
133 | expect(shipit.pool.copy).toBeCalledWith('src', 'dest', {
134 | direction: 'remoteToLocal',
135 | ignores: [],
136 | rsync: [],
137 | })
138 | })
139 |
140 | it('should support options specified in config', () => {
141 | shipit.config = {
142 | ignores: ['foo'],
143 | rsync: ['--bar'],
144 | }
145 |
146 | shipit.remoteCopy('src', 'dest', {
147 | direction: 'remoteToLocal',
148 | })
149 |
150 | expect(shipit.pool.copy).toBeCalledWith('src', 'dest', {
151 | direction: 'remoteToLocal',
152 | ignores: ['foo'],
153 | rsync: ['--bar'],
154 | })
155 | })
156 | })
157 |
158 | describe('#copyFromRemote', () => {
159 | beforeEach(() => {
160 | shipit.pool = { copyFromRemote: jest.fn() }
161 | })
162 |
163 | it('should run command on pool', () => {
164 | shipit.copyFromRemote('src', 'dest')
165 |
166 | expect(shipit.pool.copyFromRemote).toBeCalledWith('src', 'dest', {
167 | ignores: [],
168 | rsync: [],
169 | })
170 | })
171 |
172 | it('should accept options for shipit.pool.copyFromRemote', () => {
173 | shipit.copyFromRemote('src', 'dest', {
174 | ignores: ['foo'],
175 | })
176 |
177 | expect(shipit.pool.copyFromRemote).toBeCalledWith('src', 'dest', {
178 | ignores: ['foo'],
179 | rsync: [],
180 | })
181 | })
182 |
183 | it('should support options specified in config', () => {
184 | shipit.config = {
185 | ignores: ['foo'],
186 | rsync: ['--bar'],
187 | }
188 |
189 | shipit.copyFromRemote('src', 'dest')
190 |
191 | expect(shipit.pool.copyFromRemote).toBeCalledWith('src', 'dest', {
192 | ignores: ['foo'],
193 | rsync: ['--bar'],
194 | })
195 | })
196 | })
197 |
198 | describe('#copyToRemote', () => {
199 | beforeEach(() => {
200 | shipit.pool = { copyToRemote: jest.fn() }
201 | })
202 |
203 | it('should run command on pool', () => {
204 | shipit.copyToRemote('src', 'dest')
205 |
206 | expect(shipit.pool.copyToRemote).toBeCalledWith('src', 'dest', {
207 | ignores: [],
208 | rsync: [],
209 | })
210 | })
211 |
212 | it('should accept options for shipit.pool.copyToRemote', () => {
213 | shipit.copyToRemote('src', 'dest', {
214 | ignores: ['foo'],
215 | })
216 |
217 | expect(shipit.pool.copyToRemote).toBeCalledWith('src', 'dest', {
218 | ignores: ['foo'],
219 | rsync: [],
220 | })
221 | })
222 |
223 | it('should support options specified in config', () => {
224 | shipit.config = {
225 | ignores: ['foo'],
226 | rsync: ['--bar'],
227 | }
228 |
229 | shipit.copyToRemote('src', 'dest')
230 |
231 | expect(shipit.pool.copyToRemote).toBeCalledWith('src', 'dest', {
232 | ignores: ['foo'],
233 | rsync: ['--bar'],
234 | })
235 | })
236 | })
237 | })
238 |
--------------------------------------------------------------------------------
/packages/shipit-cli/src/cli.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import chalk from 'chalk'
3 | import interpret from 'interpret'
4 | import v8flags from 'v8flags'
5 | import Liftoff from 'liftoff'
6 | import program from 'commander'
7 | import Shipit from './Shipit'
8 | import pkg from '../package.json'
9 |
10 | function exit(code) {
11 | if (process.platform === 'win32' && process.stdout.bufferSize) {
12 | process.stdout.once('drain', () => {
13 | process.exit(code)
14 | })
15 | return
16 | }
17 |
18 | process.exit(code)
19 | }
20 |
21 | program
22 | .version(pkg.version)
23 | .allowUnknownOption()
24 | .usage(' ')
25 | .option('--shipitfile ', 'Specify a custom shipitfile to use')
26 | .option('--require ', 'Script required before launching Shipit')
27 | .option('--tasks', 'List available tasks')
28 | .option('--environments', 'List available environments')
29 |
30 | program.parse(process.argv)
31 |
32 | if (!process.argv.slice(2).length) {
33 | program.help()
34 | }
35 |
36 | function logTasks(shipit) {
37 | console.log(
38 | Object.keys(shipit.tasks)
39 | .join('\n')
40 | .trim(),
41 | )
42 | }
43 |
44 | function logEnvironments(shipit) {
45 | console.log(
46 | Object.keys(shipit.globalConfig)
47 | .join('\n')
48 | .trim(),
49 | )
50 | }
51 |
52 | async function asyncInvoke(env) {
53 | if (!env.configPath) {
54 | console.error(chalk.red('shipitfile not found'))
55 | exit(1)
56 | }
57 |
58 | const [environment, ...tasks] = program.args
59 |
60 | const shipit = new Shipit({ environment })
61 |
62 | try {
63 | /* eslint-disable global-require, import/no-dynamic-require */
64 | const module = require(env.configPath)
65 | /* eslint-enable global-require, import/no-dynamic-require */
66 | const initialize =
67 | typeof module.default === 'function' ? module.default : module
68 | await initialize(shipit)
69 | } catch (error) {
70 | console.error(chalk.red('Could not load async config'))
71 | throw error
72 | }
73 |
74 | if (program.tasks === true) {
75 | logTasks(shipit)
76 | } else if (program.environments === true) {
77 | logEnvironments(shipit)
78 | } else {
79 | // Run the 'default' task if no task is specified
80 | const runTasks = tasks.length === 0 ? ['default'] : tasks
81 |
82 | shipit.initialize()
83 |
84 | shipit.on('task_err', () => exit(1))
85 | shipit.on('task_not_found', () => exit(1))
86 |
87 | shipit.start(runTasks)
88 | }
89 | }
90 |
91 | function invoke(env) {
92 | asyncInvoke(env).catch(error => {
93 | setTimeout(() => {
94 | throw error
95 | })
96 | })
97 | }
98 |
99 | const cli = new Liftoff({
100 | name: 'shipit',
101 | extensions: interpret.jsVariants,
102 | v8flags,
103 | })
104 | cli.launch(
105 | {
106 | configPath: program.shipitfile,
107 | require: program.require,
108 | },
109 | invoke,
110 | )
111 |
--------------------------------------------------------------------------------
/packages/shipit-cli/src/index.js:
--------------------------------------------------------------------------------
1 | import Shipit from './Shipit'
2 |
3 | module.exports = Shipit
4 |
--------------------------------------------------------------------------------
/packages/shipit-cli/tests/integration.test.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { exec } from 'ssh-pool'
3 |
4 | const shipitCli = path.resolve(__dirname, '../src/cli.js')
5 | const shipitFile = path.resolve(__dirname, './sandbox/shipitfile.babel.js')
6 | const babelNode = require.resolve('@babel/node/bin/babel-node');
7 |
8 | describe('shipit-cli', () => {
9 | it('should run a local task', async () => {
10 | let { stdout } = await exec(`FORCE_COLOR=0 ${babelNode} ${shipitCli} --shipitfile ${shipitFile} test localHello`)
11 | stdout = stdout.trim();
12 |
13 | expect(stdout).toMatch(/Running 'localHello' task.../)
14 | expect(stdout).toMatch(/Running "echo "hello"" on local./)
15 | expect(stdout).toMatch(/@ hello/)
16 | expect(stdout).toMatch(/Finished 'localHello' after/)
17 | }, 10000)
18 |
19 | it('should run a remote task', async () => {
20 | let { stdout } = await exec(`FORCE_COLOR=0 ${babelNode} ${shipitCli} --shipitfile ${shipitFile} test remoteUser`)
21 | stdout = stdout.trim();
22 |
23 | expect(stdout).toMatch(/Running 'remoteUser' task.../)
24 | expect(stdout).toMatch(/Running "echo \$USER" on host "test.shipitjs.com"./)
25 | expect(stdout).toMatch(/@test.shipitjs.com deploy/)
26 | expect(stdout).toMatch(/Finished 'remoteUser' after/)
27 | }, 10000)
28 |
29 | it('should work with "~"', async () => {
30 | const { stdout } = await exec(
31 | `${babelNode} ${shipitCli} --shipitfile ${shipitFile} test cwdSsh`,
32 | )
33 | expect(stdout).toMatch(/@test.shipitjs.com \/home\/deploy\/\.ssh/)
34 | }, 10000)
35 | })
36 |
--------------------------------------------------------------------------------
/packages/shipit-cli/tests/sandbox/shipitfile.babel.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | export default shipit => {
4 | shipit.initConfig({
5 | default: {
6 | key: './ssh/id_rsa',
7 | },
8 | test: {
9 | servers: 'deploy@test.shipitjs.com',
10 | },
11 | })
12 |
13 | shipit.task('localHello', async () => {
14 | await shipit.local('echo "hello"')
15 | })
16 |
17 | shipit.task('remoteUser', async () => {
18 | await shipit.remote('echo $USER')
19 | })
20 |
21 | shipit.task('cwdSsh', async () => {
22 | await shipit.remote('pwd', { cwd: '~/.ssh' })
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/.npmignore:
--------------------------------------------------------------------------------
1 | /*
2 | !/lib/**/*.js
3 | *.test.js
4 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | # [5.3.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.2.0...v5.3.0) (2020-03-18)
7 |
8 |
9 | ### Features
10 |
11 | * add support of `asUser` ([#260](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/260)) ([4e79edb](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/4e79edb))
12 |
13 |
14 |
15 |
16 |
17 | # [5.2.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.1.0...v5.2.0) (2020-03-07)
18 |
19 |
20 | ### Features
21 |
22 | * add a config validation function ([#258](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/258)) ([d98ec8e](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/d98ec8e))
23 |
24 |
25 |
26 |
27 |
28 | # [5.1.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.0.0...v5.1.0) (2019-08-28)
29 |
30 |
31 | ### Bug Fixes
32 |
33 | * correct peerDependencies field for shipit-deploy package ([#243](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/243)) ([3586c21](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/3586c21))
34 |
35 |
36 | ### Features
37 |
38 | * **shipit-deploy:** Added config so you can rsync including the folder ([#246](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/246)) ([64481f8](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/64481f8))
39 |
40 |
41 |
42 |
43 |
44 | ## [4.1.4](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v4.1.3...v4.1.4) (2019-02-19)
45 |
46 |
47 | ### Bug Fixes
48 |
49 | * **shipit-deploy:** skip fetching git in case when repositoryUrl was not provided (closes [#207](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/207)) ([#226](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/226)) ([4ae0f89](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/4ae0f89))
50 |
51 |
52 |
53 |
54 |
55 | ## [4.1.3](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v4.1.2...v4.1.3) (2018-11-11)
56 |
57 |
58 | ### Bug Fixes
59 |
60 | * fixes directory permissions ([#224](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/224)) ([3277adf](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/3277adf)), closes [#189](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/189)
61 |
62 |
63 |
64 |
65 |
66 | ## [4.1.2](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v4.1.1...v4.1.2) (2018-11-04)
67 |
68 |
69 | ### Bug Fixes
70 |
71 | * **shipit-deploy:** only remove workspace if not shallow clone ([#200](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/200)) ([6ba6f00](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/6ba6f00))
72 |
73 |
74 |
75 |
76 |
77 |
78 | ## [4.1.1](https://github.com/shipitjs/shipit/compare/v4.1.0...v4.1.1) (2018-05-30)
79 |
80 |
81 | ### Bug Fixes
82 |
83 | * update shipit-deploy's peerDependency to v4.1.0 ([#192](https://github.com/shipitjs/shipit/issues/192)) ([6f7b407](https://github.com/shipitjs/shipit/commit/6f7b407))
84 |
85 |
86 |
87 |
88 |
89 | # [4.1.0](https://github.com/shipitjs/shipit/compare/v4.0.2...v4.1.0) (2018-04-27)
90 |
91 |
92 |
93 |
94 | **Note:** Version bump only for package shipit-deploy
95 |
96 |
97 | ## [4.0.2](https://github.com/shipitjs/shipit/compare/v4.0.1...v4.0.2) (2018-03-25)
98 |
99 |
100 | ### Bug Fixes
101 |
102 | * be compatible with CommonJS ([abd2316](https://github.com/shipitjs/shipit/commit/abd2316))
103 |
104 |
105 |
106 |
107 |
108 |
109 | # 4.0.0 (2018-03-17)
110 |
111 | ## shipit-deploy
112 |
113 | ### Fixes
114 |
115 | * Use [ instead of [[ to improve compatiblity shipitjs/shipit-deploy#147 shipitjs/shipit-deploy#148
116 | * Use rmfr to improve compatibility shipitjs/shipit-deploy#135 shipitjs/shipit-deploy#155
117 |
118 | ### Chores
119 |
120 | * Move to a Lerna repository
121 | * Add Codecov
122 | * Move to Jest for testing
123 | * Rewrite project in ES2017 targeting Node.js v6+
124 |
125 | ### BREAKING CHANGES
126 |
127 | * Default shallowClone to `true`
128 | * Drop grunt-shipit support
129 | * Workspace is now a temp directory in shallow clone
130 | * An error is thrown if workspace is set to the current directory
131 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Greg Bergé and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/README.md:
--------------------------------------------------------------------------------
1 | # shipit-deploy
2 |
3 | [![Build Status][build-badge]][build]
4 | [![version][version-badge]][package]
5 | [![MIT License][license-badge]][license]
6 |
7 | Set of deployment tasks for [Shipit](https://github.com/shipitjs/shipit).
8 |
9 | **Features:**
10 |
11 | - Deploy tag, branch or commit
12 | - Add additional behaviour using hooks
13 | - Build your project locally or remotely
14 | - Easy rollback
15 |
16 | ## Install
17 |
18 | ```
19 | npm install shipit-deploy
20 | ```
21 |
22 | If you are deploying from Windows, you may want to have a look at the [wiki page about usage in Windows](https://github.com/shipitjs/shipit/blob/master/packages/shipit-deploy/docs/Windows.md).
23 |
24 | ## Usage
25 |
26 | ### Example `shipitfile.js`
27 |
28 | ```js
29 | module.exports = shipit => {
30 | require('shipit-deploy')(shipit)
31 |
32 | shipit.initConfig({
33 | default: {
34 | workspace: '/tmp/myapp',
35 | deployTo: '/var/myapp',
36 | repositoryUrl: 'https://github.com/user/myapp.git',
37 | ignores: ['.git', 'node_modules'],
38 | keepReleases: 2,
39 | keepWorkspace: false, // should we remove workspace dir after deploy?
40 | deleteOnRollback: false,
41 | key: '/path/to/key',
42 | shallowClone: true,
43 | deploy: {
44 | remoteCopy: {
45 | copyAsDir: false, // Should we copy as the dir (true) or the content of the dir (false)
46 | },
47 | },
48 | },
49 | staging: {
50 | servers: 'user@myserver.com',
51 | },
52 | })
53 | }
54 | ```
55 |
56 | To deploy on staging, you must use the following command :
57 |
58 | ```
59 | shipit staging deploy
60 | ```
61 |
62 | You can rollback to the previous releases with the command :
63 |
64 | ```
65 | shipit staging rollback
66 | ```
67 |
68 | ## Options
69 |
70 | ### workspace
71 |
72 | Type: `String`
73 |
74 | Define a path to a directory where Shipit builds it's syncing source.
75 |
76 | > **Beware to not set this path to the root of your repository (unless you are set `keepWorkspace: true`) as shipit-deploy cleans the directory at the given path after successful deploy.**
77 |
78 | Here you have the following setup possibilities:
79 |
80 | - if you want to build and deploy from the directory with your repo:
81 | - set `keepWorkspace: true` so that your workspace dir won't be removed after deploy
82 | - optionally set `rsyncFrom` if you want to sync e.g. only `./build` dir
83 | - set `branch` so that we can get correct revision hash
84 | - if you want every time to fetch a fresh repo copy and dun reploy on it:
85 | - set `shallowClone: true` — this will speed up repo fetch speed and create a temporary workspace. **NOTE:** if you decide not to use `shallowClone`, you should set `workspace` path manually. If you set `shallowClone: true`, then the temporary workspace directory will be removed after deploy (unless you set `keepWorkspace: true`)
86 | - set `repositoryUrl` and optionally `branch` and `gitConfig`
87 |
88 | ### keepWorkspace
89 |
90 | Type: `Boolean`
91 |
92 | If `true` — we won't remove workspace dir after deploy.
93 |
94 | ### dirToCopy
95 |
96 | Type: `String`
97 | Default: same as workspace
98 |
99 | Define directory within the workspace which should be deployed.
100 |
101 | ### deployTo
102 |
103 | Type: `String`
104 |
105 | Define the remote path where the project will be deployed. A directory `releases` is automatically created. A symlink `current` is linked to the current release.
106 |
107 | ### repositoryUrl
108 |
109 | Type: `String`
110 |
111 | Git URL of the project repository.
112 |
113 | If empty Shipit will try to deploy without pulling the changes.
114 |
115 | In edge cases like quick PoC projects without a repository or a living on the edge production patch applying this can be helpful.
116 |
117 | ### branch
118 |
119 | Type: `String`
120 |
121 | Tag, branch or commit to deploy.
122 |
123 | ### ignores
124 |
125 | Type: `Array`
126 |
127 | An array of paths that match ignored files. These paths are used in the rsync command.
128 |
129 | ### deleteOnRollback
130 |
131 | Type: `Boolean`
132 |
133 | Whether or not to delete the old release when rolling back to a previous release.
134 |
135 | ### key
136 |
137 | Type: `String`
138 |
139 | Path to SSH key
140 |
141 | ### keepReleases
142 |
143 | Type: `Number`
144 |
145 | Number of releases to keep on the remote server.
146 |
147 | ### shallowClone
148 |
149 | Type: `Boolean`
150 |
151 | Perform a shallow clone. Default: `false`.
152 |
153 | ### updateSubmodules
154 |
155 | Type: Boolean
156 |
157 | Update submodules. Default: `false`.
158 |
159 | ### gitConfig
160 |
161 | type: `Object`
162 |
163 | Custom git configuration settings for the cloned repo.
164 |
165 | ### gitLogFormat
166 |
167 | Type: `String`
168 |
169 | Log format to pass to [`git log`](http://git-scm.com/docs/git-log#_pretty_formats). Used to display revision diffs in `pending` task. Default: `%h: %s - %an`.
170 |
171 | ### rsyncFrom
172 |
173 | Type: `String` _Optional_
174 |
175 | When deploying from Windows, prepend the workspace path with the drive letter. For example `/d/tmp/workspace` if your workspace is located in `d:\tmp\workspace`.
176 | By default, it will run rsync from the workspace folder.
177 |
178 | ### copy
179 |
180 | Type: `String`
181 |
182 | Parameter to pass to `cp` to copy the previous release. Non NTFS filesystems support `-r`. Default: `-a`
183 |
184 | ### deploy.remoteCopy.copyAsDir
185 |
186 | Type: `Boolean` _Optional_
187 |
188 | If `true` - We will copy the folder instead of the content of the folder. Default: `false`.
189 |
190 | ## Variables
191 |
192 | Several variables are attached during the deploy and the rollback process:
193 |
194 | ### shipit.config.\*
195 |
196 | All options described in the config sections are available in the `shipit.config` object.
197 |
198 | ### shipit.repository
199 |
200 | Attached during `deploy:fetch` task.
201 |
202 | You can manipulate the repository using git command, the API is describe in [gift](https://github.com/sentientwaffle/gift).
203 |
204 | ### shipit.releaseDirname
205 |
206 | Attached during `deploy:update` and `rollback:init` task.
207 |
208 | The current release dirname of the project, the format used is "YYYYMMDDHHmmss" (moment format).
209 |
210 | ### shipit.releasesPath
211 |
212 | Attached during `deploy:init`, `rollback:init`, and `pending:log` tasks.
213 |
214 | The remote releases path.
215 |
216 | ### shipit.releasePath
217 |
218 | Attached during `deploy:update` and `rollback:init` task.
219 |
220 | The complete release path : `path.join(shipit.releasesPath, shipit.releaseDirname)`.
221 |
222 | ### shipit.currentPath
223 |
224 | Attached during `deploy:init`, `rollback:init`, and `pending:log` tasks.
225 |
226 | The current symlink path : `path.join(shipit.config.deployTo, 'current')`.
227 |
228 | ## Workflow tasks
229 |
230 | - deploy
231 | - deploy:init
232 | - Emit event "deploy".
233 | - deploy:fetch
234 | - Create workspace.
235 | - Initialize repository.
236 | - Add remote.
237 | - Fetch repository.
238 | - Checkout commit-ish.
239 | - Merge remote branch in local branch.
240 | - Emit event "fetched".
241 | - deploy:update
242 | - Create and define release path.
243 | - Remote copy project.
244 | - Emit event "updated".
245 | - deploy:publish
246 | - Update symlink.
247 | - Emit event "published".
248 | - deploy:clean
249 | - Remove old releases.
250 | - Emit event "cleaned".
251 | - deploy:finish
252 | - Emit event "deployed".
253 | - rollback
254 | - rollback:init
255 | - Define release path.
256 | - Emit event "rollback".
257 | - deploy:publish
258 | - Update symlink.
259 | - Emit event "published".
260 | - deploy:clean
261 | - Remove old releases.
262 | - Emit event "cleaned".
263 | - rollback:finish
264 | - Emit event "rollbacked".
265 | - pending
266 | - pending:log
267 | - Log pending commits (diff between HEAD and currently deployed revision) to console.
268 |
269 | ## License
270 |
271 | MIT
272 |
273 | [build-badge]: https://img.shields.io/travis/shipitjs/shipit.svg?style=flat-square
274 | [build]: https://travis-ci.org/shipitjs/shipit
275 | [version-badge]: https://img.shields.io/npm/v/shipit-deploy.svg?style=flat-square
276 | [package]: https://www.npmjs.com/package/shipit-deploy
277 | [license-badge]: https://img.shields.io/npm/l/shipit-deploy.svg?style=flat-square
278 | [license]: https://github.com/shipitjs/shipit/blob/master/LICENSE
279 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/__mocks__/tmp-promise.js:
--------------------------------------------------------------------------------
1 | export default {
2 | async dir() {
3 | return {
4 | path: '/tmp/workspace-generated',
5 | cleanup: jest.fn(async () => {}),
6 | }
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/docs/Windows.md:
--------------------------------------------------------------------------------
1 | # Deploy from Windows
2 |
3 | Requires Powershell
4 |
5 | ## Install [Scoop](https://github.com/lukesampson/scoop)
6 |
7 | ```
8 | iex (new-object net.webclient).downloadstring('https://get.scoop.sh')
9 | set-executionpolicy unrestricted -s cu
10 | ```
11 |
12 | ## Install programs
13 |
14 | OpenSSH, rsync and [pshazz](https://github.com/lukesampson/pshazz)
15 |
16 | ```
17 | scoop install openssh
18 | scoop install rsync
19 | scoop install pshazz
20 | ```
21 |
22 | ## Configure shipit-deploy
23 |
24 | Here are some advices _(replace everything between <<>> with your own values)_:
25 |
26 | ### key
27 |
28 | Path can be an absolute windows-style path, just replace `\` by `/`
29 |
30 | ```
31 | "key" : "c:/users/<>/.ssh/id_rsa"
32 | ```
33 |
34 | ### Workspace
35 |
36 | Here is where I had problems. On one side, shipit will use the current drive letter as the root for folders. On the other side, rsync expects paths with drive letters in them. Here is what I tried (assuming project is located on `D:` drive):
37 |
38 | #### `"workspace" : "/workspace/"`
39 |
40 | - Copies project to `d:\workspace`
41 | - rsync fails to find the folder
42 |
43 | #### `"workspace" : "d:/workspace/"`
44 |
45 | - Copies project to `d:\workspace`
46 | - rsync fails to find the folder
47 |
48 | #### `"workspace" : "/d/workspace/"`
49 |
50 | - rsync would work and properly sync from `D:\workspace` but...
51 | - ... the copy of the project goes Copies project to `d:\d\workspace`
52 |
53 | In the end, I had picked option 1. and I had to make a change in the source code to append the drive letter to rsync's source path. Everything is described in [that commit of my pull request](https://github.com/vpratfr/shipit-deploy/commit/4bbf262a7d7036a2b534ab7233d0152e0d09ba20). You can then simply add a configuration variable to "default": `"rsyncDrive": "/d"`.
54 |
55 | ## Troubleshooting
56 |
57 | ### Conflict between ssh executables
58 |
59 | Rsync prefers to use its own ssh.exe version. If gitbash is installed (or any other program offering its own ssh.exe), make sure that the ssh.exe used by default is the one from the rsync folder.
60 |
61 | Hint: this could be done by making sure the rsync folder comes first in the PATH
62 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shipit-deploy",
3 | "version": "5.3.0",
4 | "description": "Official set of deploy tasks for Shipit.",
5 | "engines": {
6 | "node": ">=6"
7 | },
8 | "author": "Greg Bergé ",
9 | "license": "MIT",
10 | "repository": "https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy",
11 | "main": "lib/index.js",
12 | "keywords": [
13 | "shipit",
14 | "automation",
15 | "deployment",
16 | "deploy",
17 | "ssh"
18 | ],
19 | "scripts": {
20 | "prebuild": "rm -rf lib/",
21 | "build": "babel --config-file ../../babel.config.js -d lib --ignore \"**/*.test.js\" src",
22 | "prepublishOnly": "yarn run build"
23 | },
24 | "peerDependencies": {
25 | "shipit-cli": "^5.0.0"
26 | },
27 | "dependencies": {
28 | "chalk": "^2.4.1",
29 | "lodash": "^4.17.15",
30 | "moment": "^2.21.0",
31 | "path2": "^0.1.0",
32 | "rmfr": "^2.0.0",
33 | "shipit-utils": "^1.1.3",
34 | "tmp-promise": "^2.0.2"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/extendShipit.js:
--------------------------------------------------------------------------------
1 | import path from 'path2/posix'
2 | import _ from 'lodash'
3 | import util from 'util'
4 |
5 | /**
6 | * Compute the current release dir name.
7 | *
8 | * @param {object} result
9 | * @returns {string}
10 | */
11 | function computeReleases(result) {
12 | if (!result.stdout) return null
13 |
14 | // Trim last breakline.
15 | const dirs = result.stdout.replace(/\n$/, '')
16 |
17 | // Convert releases to an array.
18 | return dirs.split('\n')
19 | }
20 |
21 | /**
22 | * Test if all values are equal.
23 | *
24 | * @param {*[]} values
25 | * @returns {boolean}
26 | */
27 | function equalValues(values) {
28 | return values.every(value => _.isEqual(value, values[0]))
29 | }
30 |
31 | /**
32 | * Compute the current release dir name.
33 | *
34 | * @param {object} result
35 | * @returns {string}
36 | */
37 | function computeReleaseDirname(result) {
38 | if (!result.stdout) return null
39 |
40 | // Trim last breakline.
41 | const target = result.stdout.replace(/\n$/, '')
42 | return target.split(path.sep).pop()
43 | }
44 |
45 | function validateConfig(config) {
46 | const errors = []
47 | if (!config.deployTo) {
48 | errors.push("Config must include a 'deployTo' property")
49 | }
50 | if (errors.length) {
51 | console.log(errors)
52 | throw new Error(
53 | 'Config is invalid. Please refer to errors above and try again.',
54 | )
55 | }
56 | }
57 |
58 | function extendShipit(shipit) {
59 | /* eslint-disable no-param-reassign */
60 | validateConfig(shipit.config)
61 | shipit.currentPath = path.join(shipit.config.deployTo, 'current')
62 | shipit.releasesPath = path.join(shipit.config.deployTo, 'releases')
63 | const config = {
64 | branch: 'master',
65 | keepReleases: 5,
66 | shallowClone: true,
67 | keepWorkspace: false,
68 | gitLogFormat: '%h: %s - %an',
69 | ...shipit.config,
70 | }
71 | Object.assign(shipit.config, config)
72 | /* eslint-enable no-param-reassign */
73 |
74 | const Shipit = shipit.constructor
75 |
76 | /**
77 | * Return the current release dirname.
78 | */
79 | Shipit.prototype.getCurrentReleaseDirname = async function getCurrentReleaseDirname() {
80 | const results =
81 | (await this.remote(
82 | util.format(
83 | 'if [ -h %s ]; then readlink %s; fi',
84 | this.currentPath,
85 | this.currentPath,
86 | ),
87 | )) || []
88 |
89 | const releaseDirnames = results.map(computeReleaseDirname)
90 |
91 | if (!equalValues(releaseDirnames)) {
92 | throw new Error('Remote servers are not synced.')
93 | }
94 |
95 | if (!releaseDirnames[0]) {
96 | this.log('No current release found.')
97 | return null
98 | }
99 |
100 | return releaseDirnames[0]
101 | }
102 |
103 | /**
104 | * Return all remote releases (newest first)
105 | */
106 | Shipit.prototype.getReleases = async function getReleases() {
107 | const results = await this.remote(`ls -r1 ${this.releasesPath}`)
108 | const releases = results.map(computeReleases)
109 |
110 | if (!equalValues(releases)) {
111 | throw new Error('Remote servers are not synced.')
112 | }
113 |
114 | return releases[0]
115 | }
116 |
117 | /**
118 | * Return SHA from remote REVISION file.
119 | *
120 | * @param {string} releaseDir Directory name of the relesase dir (YYYYMMDDHHmmss).
121 | */
122 | Shipit.prototype.getRevision = async function getRevision(releaseDir) {
123 | const file = path.join(this.releasesPath, releaseDir, 'REVISION')
124 | const response = await this.remote(
125 | `if [ -f ${file} ]; then cat ${file} 2>/dev/null; fi;`,
126 | )
127 | return response[0].stdout.trim()
128 | }
129 |
130 | Shipit.prototype.getPendingCommits = async function getPendingCommits() {
131 | const currentReleaseDirname = await this.getCurrentReleaseDirname()
132 | if (!currentReleaseDirname) return null
133 |
134 | const deployedRevision = await this.getRevision(currentReleaseDirname)
135 | if (!deployedRevision) return null
136 |
137 | const res = await this.local('git remote', { cwd: this.config.workspace })
138 | const remotes = res && res.stdout ? res.stdout.split(/\s/) : []
139 | if (remotes.length < 1) return null
140 |
141 | // Compare against currently undeployed revision
142 | const compareRevision = `${remotes[0]}/${this.config.branch}`
143 |
144 | const response = await this.local(
145 | `git log --pretty=format:"${shipit.config.gitLogFormat}" ${deployedRevision}..${compareRevision}`,
146 | { cwd: shipit.workspace },
147 | )
148 | const commits = response.stdout.trim()
149 | return commits || null
150 | }
151 | }
152 |
153 | export default extendShipit
154 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/index.js:
--------------------------------------------------------------------------------
1 | import deploy from './tasks/deploy'
2 | import rollback from './tasks/rollback'
3 | import pending from './tasks/pending'
4 |
5 | module.exports = shipit => {
6 | deploy(shipit)
7 | rollback(shipit)
8 | pending(shipit)
9 | }
10 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/clean.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable prefer-template */
2 | import utils from 'shipit-utils'
3 | import extendShipit from '../../extendShipit'
4 |
5 | /**
6 | * Clean task.
7 | * - Remove old releases.
8 | */
9 | const cleanTask = shipit => {
10 | utils.registerTask(shipit, 'deploy:clean', async () => {
11 | extendShipit(shipit)
12 |
13 | shipit.log(
14 | 'Keeping "%d" last releases, cleaning others',
15 | shipit.config.keepReleases,
16 | )
17 |
18 | const command =
19 | '(ls -rd ' +
20 | shipit.releasesPath +
21 | '/*|head -n ' +
22 | shipit.config.keepReleases +
23 | ';ls -d ' +
24 | shipit.releasesPath +
25 | '/*)|sort|uniq -u|xargs rm -rf'
26 | await shipit.remote(command)
27 |
28 | shipit.emit('cleaned')
29 | })
30 | }
31 |
32 | export default cleanTask
33 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/clean.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import { start } from '../../../tests/util'
3 | import cleanTask from './clean'
4 |
5 | describe('deploy:clean task', () => {
6 | let shipit
7 |
8 | beforeEach(() => {
9 | shipit = new Shipit({
10 | environment: 'test',
11 | log: jest.fn(),
12 | })
13 |
14 | cleanTask(shipit)
15 |
16 | // Shipit config.
17 | shipit.initConfig({
18 | test: {
19 | deployTo: '/remote/deploy',
20 | keepReleases: 5,
21 | },
22 | })
23 |
24 | shipit.remote = jest.fn(async () => [])
25 | })
26 |
27 | it('should remove old releases', async () => {
28 | await start(shipit, 'deploy:clean')
29 | /* eslint-disable prefer-template */
30 | expect(shipit.remote).toBeCalledWith(
31 | '(ls -rd /remote/deploy/releases/*|head -n 5;ls -d /remote/deploy/releases/*)|sort|uniq -u|xargs rm -rf',
32 | )
33 | /* eslint-enable prefer-template */
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/fetch.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import fs from 'fs'
3 | import utils from 'shipit-utils'
4 | import chalk from 'chalk'
5 | import tmp from 'tmp-promise'
6 | import extendShipit from '../../extendShipit'
7 |
8 | /**
9 | * Fetch task.
10 | * - Create workspace.
11 | * - Fetch repository.
12 | * - Checkout commit-ish.
13 | */
14 | const fetchTask = shipit => {
15 | utils.registerTask(shipit, 'deploy:fetch', async () => {
16 | extendShipit(shipit)
17 |
18 | /**
19 | * Create workspace.
20 | */
21 | async function setupWorkspace() {
22 | const { keepWorkspace, workspace, shallowClone } = shipit.config
23 |
24 | shipit.log('Setup workspace...')
25 | if (workspace) {
26 | // eslint-disable-next-line no-param-reassign
27 | shipit.workspace = workspace
28 | }
29 |
30 | if (shallowClone) {
31 | const tmpDir = await tmp.dir({ mode: '0755' })
32 | // eslint-disable-next-line no-param-reassign
33 | shipit.workspace = tmpDir.path
34 |
35 | if (workspace) {
36 | shipit.log(
37 | chalk.yellow(
38 | `Warning: Workspace path from config ("${workspace}") is being ignored, when shallowClone: true`,
39 | ),
40 | )
41 | }
42 |
43 | shipit.log(`Temporary workspace created: "${shipit.workspace}"`)
44 | }
45 |
46 | if (!shipit.workspace || !fs.existsSync(shipit.workspace)) {
47 | throw new Error(
48 | `Workspace dir is required. Current value is: ${shipit.workspace}`,
49 | )
50 | }
51 |
52 | if (!keepWorkspace && path.resolve(shipit.workspace) === process.cwd()) {
53 | throw new Error(
54 | 'Workspace should be a temporary directory. To use current working directory set keepWorkspace: true',
55 | )
56 | }
57 |
58 | shipit.log(chalk.green('Workspace ready.'))
59 | }
60 |
61 | /**
62 | * Initialize repository.
63 | */
64 | async function initRepository() {
65 | shipit.log('Initialize local repository in "%s"', shipit.workspace)
66 | await shipit.local('git init', { cwd: shipit.workspace })
67 | shipit.log(chalk.green('Repository initialized.'))
68 | }
69 |
70 | /**
71 | * Set git config.
72 | */
73 | async function setGitConfig() {
74 | if (!shipit.config.gitConfig) return
75 |
76 | shipit.log('Set custom git config options for "%s"', shipit.workspace)
77 |
78 | await Promise.all(
79 | Object.keys(shipit.config.gitConfig || {}).map(key =>
80 | shipit.local(`git config ${key} "${shipit.config.gitConfig[key]}"`, {
81 | cwd: shipit.workspace,
82 | }),
83 | ),
84 | )
85 | shipit.log(chalk.green('Git config set.'))
86 | }
87 |
88 | /**
89 | * Add remote.
90 | */
91 | async function addRemote() {
92 | shipit.log('List local remotes.')
93 |
94 | const res = await shipit.local('git remote', {
95 | cwd: shipit.workspace,
96 | })
97 |
98 | const remotes = res.stdout ? res.stdout.split(/\s/) : []
99 | const method = remotes.indexOf('shipit') !== -1 ? 'set-url' : 'add'
100 |
101 | shipit.log(
102 | 'Update remote "%s" to local repository "%s"',
103 | shipit.config.repositoryUrl,
104 | shipit.workspace,
105 | )
106 |
107 | // Update remote.
108 | await shipit.local(
109 | `git remote ${method} shipit ${shipit.config.repositoryUrl}`,
110 | { cwd: shipit.workspace },
111 | )
112 |
113 | shipit.log(chalk.green('Remote updated.'))
114 | }
115 |
116 | /**
117 | * Fetch repository.
118 | */
119 | async function fetch() {
120 | let fetchCommand = 'git fetch shipit --prune'
121 | const fetchDepth = shipit.config.shallowClone ? ' --depth=1' : ''
122 |
123 | // fetch branches and tags separate to be compatible with git versions < 1.9
124 | fetchCommand += `${fetchDepth} && ${fetchCommand} "refs/tags/*:refs/tags/*"`
125 |
126 | shipit.log('Fetching repository "%s"', shipit.config.repositoryUrl)
127 |
128 | await shipit.local(fetchCommand, { cwd: shipit.workspace })
129 | shipit.log(chalk.green('Repository fetched.'))
130 | }
131 |
132 | /**
133 | * Checkout commit-ish.
134 | */
135 | async function checkout() {
136 | shipit.log('Checking out commit-ish "%s"', shipit.config.branch)
137 | await shipit.local(`git checkout ${shipit.config.branch}`, {
138 | cwd: shipit.workspace,
139 | })
140 | shipit.log(chalk.green('Checked out.'))
141 | }
142 |
143 | /**
144 | * Hard reset of working tree.
145 | */
146 | async function reset() {
147 | shipit.log('Resetting the working tree')
148 | await shipit.local('git reset --hard HEAD', {
149 | cwd: shipit.workspace,
150 | })
151 | shipit.log(chalk.green('Reset working tree.'))
152 | }
153 |
154 | /**
155 | * Merge branch.
156 | */
157 | async function merge() {
158 | shipit.log('Testing if commit-ish is a branch.')
159 |
160 | const res = await shipit.local(
161 | `git branch --list ${shipit.config.branch}`,
162 | {
163 | cwd: shipit.workspace,
164 | },
165 | )
166 |
167 | const isBranch = !!res.stdout
168 |
169 | if (!isBranch) {
170 | shipit.log(chalk.green('No branch, no merge.'))
171 | return
172 | }
173 |
174 | shipit.log('Commit-ish is a branch, merging...')
175 |
176 | // Merge branch.
177 | await shipit.local(`git merge shipit/${shipit.config.branch}`, {
178 | cwd: shipit.workspace,
179 | })
180 |
181 | shipit.log(chalk.green('Branch merged.'))
182 | }
183 |
184 | /**
185 | * update submodules
186 | */
187 | async function updateSubmodules() {
188 | if (!shipit.config.updateSubmodules) return
189 |
190 | shipit.log('Updating submodules.')
191 | await shipit.local('git submodule update --init --recursive', {
192 | cwd: shipit.workspace,
193 | })
194 | shipit.log(chalk.green('Submodules updated'))
195 | }
196 |
197 | await setupWorkspace()
198 |
199 | if (shipit.config.repositoryUrl) {
200 | await initRepository()
201 | await setGitConfig()
202 | await addRemote()
203 | await fetch()
204 | await checkout()
205 | await reset()
206 | await merge()
207 | await updateSubmodules()
208 | } else {
209 | shipit.log(chalk.yellow('Skip fetching repo. No repositoryUrl provided'))
210 | }
211 |
212 | shipit.emit('fetched')
213 | })
214 | }
215 |
216 | export default fetchTask
217 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/fetch.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import fs from 'fs'
3 | import tmp from 'tmp-promise'
4 | import { start } from '../../../tests/util'
5 | import fetchTask from './fetch'
6 |
7 | jest.mock('tmp-promise', () => ({
8 | dir: jest.fn(),
9 | }))
10 | jest.mock('fs')
11 |
12 | describe('deploy:fetch task', () => {
13 | let shipit
14 | let log
15 |
16 | beforeEach(() => {
17 | log = jest.fn()
18 | shipit = new Shipit({
19 | environment: 'test',
20 | log,
21 | })
22 |
23 | fetchTask(shipit)
24 |
25 | // Shipit config
26 | shipit.initConfig({
27 | test: {
28 | deployTo: '/var/apps/dep',
29 | shallowClone: false,
30 | workspace: '/tmp/workspace',
31 | repositoryUrl: 'git://website.com/user/repo',
32 | },
33 | })
34 |
35 | shipit.local = jest.fn(async () => ({ stdout: 'ok' }))
36 |
37 | fs.existsSync.mockImplementation(() => true)
38 | tmp.dir.mockImplementation(() =>
39 | Promise.resolve({
40 | path: '/tmp/workspace-generated',
41 | }),
42 | )
43 | })
44 |
45 | it('should throw an error if workspace is current directory', async () => {
46 | jest.spyOn(process, 'cwd').mockImplementation(() => '/tmp/workspace')
47 | expect.assertions(1)
48 |
49 | await expect(start(shipit, 'deploy:fetch')).rejects.toMatchInlineSnapshot(
50 | `[Error: Workspace should be a temporary directory. To use current working directory set keepWorkspace: true]`,
51 | )
52 |
53 | process.cwd.mockRestore()
54 | })
55 |
56 | describe('setup workspace', () => {
57 | it('should use config.workspace if any', async () => {
58 | await start(shipit, 'deploy:fetch')
59 |
60 | expect(shipit.workspace).toBe('/tmp/workspace')
61 | })
62 |
63 | it('should throw if workspace dir does not exists', async () => {
64 | fs.existsSync.mockImplementation(() => false)
65 |
66 | await expect(start(shipit, 'deploy:fetch')).rejects.toMatchInlineSnapshot(
67 | `[Error: Workspace dir is required. Current value is: /tmp/workspace]`,
68 | )
69 | })
70 |
71 | it('should create temp dir if shallowClone: true', async () => {
72 | shipit.config.shallowClone = true
73 |
74 | await start(shipit, 'deploy:fetch')
75 |
76 | expect(tmp.dir).toHaveBeenCalledWith({ mode: '0755' })
77 | expect(shipit.workspace).toBe('/tmp/workspace-generated')
78 | })
79 |
80 | it('should throw if workspace dir was not configured', async () => {
81 | delete shipit.config.workspace
82 |
83 | await expect(start(shipit, 'deploy:fetch')).rejects.toMatchInlineSnapshot(
84 | `[Error: Workspace dir is required. Current value is: undefined]`,
85 | )
86 | })
87 | })
88 |
89 | describe('fetch repo', () => {
90 | it('should create repo, checkout and call sync', async () => {
91 | await start(shipit, 'deploy:fetch')
92 |
93 | const opts = { cwd: '/tmp/workspace' }
94 |
95 | expect(shipit.local).toBeCalledWith('git init', opts)
96 | expect(shipit.local).toBeCalledWith('git remote', opts)
97 | expect(shipit.local).toBeCalledWith(
98 | 'git remote add shipit git://website.com/user/repo',
99 | opts,
100 | )
101 | expect(shipit.local).toBeCalledWith(
102 | 'git fetch shipit --prune && git fetch shipit --prune "refs/tags/*:refs/tags/*"',
103 | opts,
104 | )
105 | expect(shipit.local).toBeCalledWith('git checkout master', opts)
106 | expect(shipit.local).toBeCalledWith('git branch --list master', opts)
107 | })
108 |
109 | it('should create repo, checkout shallow and call sync', async () => {
110 | shipit.config.shallowClone = true
111 | delete shipit.config.workspace
112 |
113 | await start(shipit, 'deploy:fetch')
114 |
115 | const opts = { cwd: '/tmp/workspace-generated' }
116 |
117 | expect(shipit.local).toBeCalledWith('git init', opts)
118 | expect(shipit.local).toBeCalledWith('git remote', opts)
119 | expect(shipit.local).toBeCalledWith(
120 | 'git remote add shipit git://website.com/user/repo',
121 | opts,
122 | )
123 | expect(shipit.local).toBeCalledWith(
124 | 'git fetch shipit --prune --depth=1 && git fetch shipit --prune "refs/tags/*:refs/tags/*"',
125 | opts,
126 | )
127 | expect(shipit.local).toBeCalledWith('git checkout master', opts)
128 | expect(shipit.local).toBeCalledWith('git branch --list master', opts)
129 | })
130 |
131 | it('should create repo, checkout and call sync, update submodules', async () => {
132 | shipit.config.updateSubmodules = true
133 |
134 | await start(shipit, 'deploy:fetch')
135 |
136 | const opts = { cwd: '/tmp/workspace' }
137 |
138 | expect(shipit.local).toBeCalledWith('git init', opts)
139 | expect(shipit.local).toBeCalledWith('git remote', opts)
140 | expect(shipit.local).toBeCalledWith(
141 | 'git remote add shipit git://website.com/user/repo',
142 | opts,
143 | )
144 | expect(shipit.local).toBeCalledWith(
145 | 'git fetch shipit --prune && git fetch shipit --prune "refs/tags/*:refs/tags/*"',
146 | opts,
147 | )
148 | expect(shipit.local).toBeCalledWith('git checkout master', opts)
149 | expect(shipit.local).toBeCalledWith('git branch --list master', opts)
150 | expect(shipit.local).toBeCalledWith(
151 | 'git submodule update --init --recursive',
152 | opts,
153 | )
154 | })
155 |
156 | it('should create repo, set repo config, checkout and call sync', async () => {
157 | shipit.config.gitConfig = {
158 | foo: 'bar',
159 | baz: 'quux',
160 | }
161 |
162 | await start(shipit, 'deploy:fetch')
163 |
164 | const opts = { cwd: '/tmp/workspace' }
165 |
166 | expect(shipit.local).toBeCalledWith('git init', opts)
167 | expect(shipit.local).toBeCalledWith('git config foo "bar"', opts)
168 | expect(shipit.local).toBeCalledWith('git config baz "quux"', opts)
169 | expect(shipit.local).toBeCalledWith('git remote', opts)
170 | expect(shipit.local).toBeCalledWith(
171 | 'git remote add shipit git://website.com/user/repo',
172 | opts,
173 | )
174 | expect(shipit.local).toBeCalledWith(
175 | 'git fetch shipit --prune && git fetch shipit --prune "refs/tags/*:refs/tags/*"',
176 | opts,
177 | )
178 | expect(shipit.local).toBeCalledWith('git checkout master', opts)
179 | expect(shipit.local).toBeCalledWith('git branch --list master', opts)
180 | })
181 |
182 | it('should skip fetching if no repositoryUrl provided', async () => {
183 | delete shipit.config.repositoryUrl
184 |
185 | await start(shipit, 'deploy:fetch')
186 |
187 | expect(shipit.local).not.toHaveBeenCalled()
188 | expect(log).toBeCalledWith(
189 | expect.stringContaining(
190 | 'Skip fetching repo. No repositoryUrl provided',
191 | ),
192 | )
193 | })
194 | })
195 | })
196 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/finish.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import extendShipit from '../../extendShipit'
3 |
4 | /**
5 | * Finish task.
6 | * - Emit an event "deployed".
7 | */
8 |
9 | const finishTask = shipit => {
10 | utils.registerTask(shipit, 'deploy:finish', () => {
11 | extendShipit(shipit)
12 | shipit.emit('deployed')
13 | })
14 | }
15 |
16 | export default finishTask
17 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/finish.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import { start } from '../../../tests/util'
3 | import finishTask from './finish'
4 |
5 | describe('deploy:finish task', () => {
6 | let shipit
7 |
8 | beforeEach(() => {
9 | shipit = new Shipit({
10 | environment: 'test',
11 | log: jest.fn(),
12 | })
13 |
14 | finishTask(shipit)
15 |
16 | // Shipit config.
17 | shipit.initConfig({
18 | test: {
19 | deployTo: '/',
20 | },
21 | })
22 | shipit.releasesPath = '/remote/deploy/releases'
23 | })
24 |
25 | it('should emit a "deployed" event', async () => {
26 | const spy = jest.fn()
27 | shipit.on('deployed', spy)
28 | expect(spy).toHaveBeenCalledTimes(0)
29 | await start(shipit, 'deploy:finish')
30 | expect(spy).toHaveBeenCalledTimes(1)
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/index.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import init from './init'
3 | import fetch from './fetch'
4 | import update from './update'
5 | import publish from './publish'
6 | import clean from './clean'
7 | import finish from './finish'
8 |
9 | /**
10 | * Deploy task.
11 | * - deploy:fetch
12 | * - deploy:update
13 | * - deploy:publish
14 | * - deploy:clean
15 | * - deploy:finish
16 | */
17 |
18 | export default shipit => {
19 | init(shipit)
20 | fetch(shipit)
21 | update(shipit)
22 | publish(shipit)
23 | clean(shipit)
24 | finish(shipit)
25 |
26 | utils.registerTask(shipit, 'deploy', [
27 | 'deploy:init',
28 | 'deploy:fetch',
29 | 'deploy:update',
30 | 'deploy:publish',
31 | 'deploy:clean',
32 | 'deploy:finish',
33 | ])
34 | }
35 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/init.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import extendShipit from '../../extendShipit'
3 |
4 | /**
5 | * Init task.
6 | * - Emit deploy event.
7 | */
8 | const initTask = shipit => {
9 | utils.registerTask(shipit, 'deploy:init', () => {
10 | extendShipit(shipit)
11 | shipit.emit('deploy')
12 | })
13 | }
14 |
15 | export default initTask
16 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/init.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import { start } from '../../../tests/util'
3 | import initTask from './init'
4 |
5 | describe('deploy:init task', () => {
6 | let shipit
7 |
8 | beforeEach(() => {
9 | shipit = new Shipit({
10 | environment: 'test',
11 | log: jest.fn(),
12 | })
13 |
14 | initTask(shipit)
15 |
16 | // Shipit config.
17 | shipit.initConfig({
18 | test: {
19 | deployTo: '/',
20 | },
21 | })
22 | shipit.releasesPath = '/remote/deploy/releases'
23 | })
24 |
25 | it('should emit a "deploy" event', async () => {
26 | const spy = jest.fn()
27 | shipit.on('deploy', spy)
28 | expect(spy).toHaveBeenCalledTimes(0)
29 | await start(shipit, 'deploy:init')
30 | expect(spy).toHaveBeenCalledTimes(1)
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/publish.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import chalk from 'chalk'
3 | import path from 'path2/posix'
4 | import extendShipit from '../../extendShipit'
5 |
6 | /**
7 | * Publish task.
8 | * - Update symbolic link.
9 | */
10 | const publishTask = shipit => {
11 | utils.registerTask(shipit, 'deploy:publish', async () => {
12 | extendShipit(shipit)
13 |
14 | shipit.log('Publishing release "%s"', shipit.releasePath)
15 |
16 | const relativeReleasePath = path.join('releases', shipit.releaseDirname)
17 |
18 | /* eslint-disable prefer-template */
19 | const res = await shipit.remote(
20 | 'cd ' +
21 | shipit.config.deployTo +
22 | ' && ' +
23 | 'if [ -d current ] && [ ! -L current ]; then ' +
24 | 'echo "ERR: could not make symlink"; ' +
25 | 'else ' +
26 | 'ln -nfs ' +
27 | relativeReleasePath +
28 | ' current_tmp && ' +
29 | 'mv -fT current_tmp current; ' +
30 | 'fi',
31 | )
32 | /* eslint-enable prefer-template */
33 |
34 | const failedresult =
35 | res && res.stdout
36 | ? res.stdout.filter(r => r.indexOf('could not make symlink') > -1)
37 | : []
38 | if (failedresult.length && failedresult.length > 0) {
39 | shipit.log(
40 | chalk.yellow(
41 | `Symbolic link at remote not made, as something already exists at ${path(
42 | shipit.config.deployTo,
43 | 'current',
44 | )}`,
45 | ),
46 | )
47 | }
48 |
49 | shipit.log(chalk.green('Release published.'))
50 |
51 | shipit.emit('published')
52 | })
53 | }
54 |
55 | export default publishTask
56 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/publish.test.js:
--------------------------------------------------------------------------------
1 | import path from 'path2/posix'
2 | import Shipit from 'shipit-cli'
3 | import { start } from '../../../tests/util'
4 | import publishTask from './publish'
5 |
6 | describe('deploy:publish task', () => {
7 | let shipit
8 |
9 | beforeEach(() => {
10 | shipit = new Shipit({
11 | environment: 'test',
12 | log: jest.fn(),
13 | })
14 |
15 | publishTask(shipit)
16 |
17 | // Shipit config
18 | shipit.initConfig({
19 | test: {
20 | deployTo: '/remote/deploy',
21 | },
22 | })
23 |
24 | shipit.releasePath = '/remote/deploy/releases/20141704123138'
25 | shipit.releaseDirname = '20141704123138'
26 | shipit.currentPath = path.join(shipit.config.deployTo, 'current')
27 | shipit.releasesPath = path.join(shipit.config.deployTo, 'releases')
28 |
29 | shipit.remote = jest.fn(async () => [])
30 | })
31 |
32 | it('should update the symbolic link', async () => {
33 | await start(shipit, 'deploy:publish')
34 | expect(shipit.currentPath).toBe('/remote/deploy/current')
35 | expect(shipit.remote).toBeCalledWith(
36 | 'cd /remote/deploy && if [ -d current ] && [ ! -L current ]; then echo "ERR: could not make symlink"; else ln -nfs releases/20141704123138 current_tmp && mv -fT current_tmp current; fi',
37 | )
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/update.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import path from 'path2/posix'
3 | import p from 'path';
4 | import moment from 'moment'
5 | import chalk from 'chalk'
6 | import util from 'util'
7 | import rmfr from 'rmfr'
8 | import _ from 'lodash'
9 | import extendShipit from '../../extendShipit'
10 |
11 | /**
12 | * Update task.
13 | * - Set previous release.
14 | * - Set previous revision.
15 | * - Create and define release path.
16 | * - Copy previous release (for faster rsync)
17 | * - Set current revision and write REVISION file.
18 | * - Remote copy project.
19 | * - Remove workspace.
20 | */
21 | const updateTask = shipit => {
22 | utils.registerTask(shipit, 'deploy:update', async () => {
23 | extendShipit(shipit)
24 |
25 | /**
26 | * Copy previous release to release dir.
27 | */
28 |
29 | async function copyPreviousRelease() {
30 | const copyParameter = shipit.config.copy || '-a'
31 | if (!shipit.previousRelease || shipit.config.copy === false) return
32 | shipit.log('Copy previous release to "%s"', shipit.releasePath)
33 | await shipit.remote(
34 | util.format(
35 | 'cp %s %s/. %s',
36 | copyParameter,
37 | path.join(shipit.releasesPath, shipit.previousRelease),
38 | shipit.releasePath,
39 | ),
40 | )
41 | }
42 |
43 | /**
44 | * Create and define release path.
45 | */
46 | async function createReleasePath() {
47 | /* eslint-disable no-param-reassign */
48 | shipit.releaseDirname = moment.utc().format('YYYYMMDDHHmmss')
49 | shipit.releasePath = path.join(shipit.releasesPath, shipit.releaseDirname)
50 | /* eslint-enable no-param-reassign */
51 |
52 | shipit.log('Create release path "%s"', shipit.releasePath)
53 | await shipit.remote(`mkdir -p ${shipit.releasePath}`)
54 | shipit.log(chalk.green('Release path created.'))
55 | }
56 |
57 | /**
58 | * Remote copy project.
59 | */
60 |
61 | async function remoteCopy() {
62 | const options = _.get(shipit.config, 'deploy.remoteCopy') || {
63 | rsync: '--del',
64 | }
65 | const rsyncFrom = shipit.config.rsyncFrom || shipit.workspace
66 | const uploadDirPath = p.resolve(rsyncFrom, shipit.config.dirToCopy || '');
67 |
68 | shipit.log('Copy project to remote servers.')
69 |
70 | let srcDirectory = `${uploadDirPath}/`;
71 | if(options.copyAsDir){
72 | srcDirectory = srcDirectory.slice(0, -1);
73 | }
74 | await shipit.remoteCopy(srcDirectory, shipit.releasePath, options)
75 | shipit.log(chalk.green('Finished copy.'))
76 | }
77 |
78 | /**
79 | * Set shipit.previousRevision from remote REVISION file.
80 | */
81 | async function setPreviousRevision() {
82 | /* eslint-disable no-param-reassign */
83 | shipit.previousRevision = null
84 | /* eslint-enable no-param-reassign */
85 |
86 | if (!shipit.previousRelease) return
87 |
88 | const revision = await shipit.getRevision(shipit.previousRelease)
89 | if (revision) {
90 | shipit.log(chalk.green('Previous revision found.'))
91 | /* eslint-disable no-param-reassign */
92 | shipit.previousRevision = revision
93 | /* eslint-enable no-param-reassign */
94 | }
95 | }
96 |
97 | /**
98 | * Set shipit.previousRelease.
99 | */
100 | async function setPreviousRelease() {
101 | /* eslint-disable no-param-reassign */
102 | shipit.previousRelease = null
103 | /* eslint-enable no-param-reassign */
104 | const currentReleaseDirname = await shipit.getCurrentReleaseDirname()
105 | if (currentReleaseDirname) {
106 | shipit.log(chalk.green('Previous release found.'))
107 | /* eslint-disable no-param-reassign */
108 | shipit.previousRelease = currentReleaseDirname
109 | /* eslint-enable no-param-reassign */
110 | }
111 | }
112 |
113 | /**
114 | * Set shipit.currentRevision and write it to REVISION file.
115 | */
116 | async function setCurrentRevision() {
117 | shipit.log('Setting current revision and creating revision file.')
118 |
119 | const response = await shipit.local(
120 | `git rev-parse ${shipit.config.branch}`,
121 | {
122 | cwd: shipit.workspace,
123 | },
124 | )
125 |
126 | /* eslint-disable no-param-reassign */
127 | shipit.currentRevision = response.stdout.trim()
128 | /* eslint-enable no-param-reassign */
129 |
130 | await shipit.remote(
131 | `echo "${shipit.currentRevision}" > ${path.join(
132 | shipit.releasePath,
133 | 'REVISION',
134 | )}`,
135 | )
136 | shipit.log(chalk.green('Revision file created.'))
137 | }
138 |
139 | async function removeWorkspace() {
140 | if (!shipit.config.keepWorkspace && shipit.config.shallowClone) {
141 | shipit.log(`Removing workspace "${shipit.workspace}"`)
142 | await rmfr(shipit.workspace)
143 | shipit.log(chalk.green('Workspace removed.'))
144 | }
145 | }
146 |
147 | await setPreviousRelease()
148 | await setPreviousRevision()
149 | await createReleasePath()
150 | await copyPreviousRelease()
151 | await remoteCopy()
152 | await setCurrentRevision()
153 | await removeWorkspace()
154 | shipit.emit('updated')
155 | })
156 | }
157 |
158 | export default updateTask
159 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/deploy/update.test.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment'
2 | import _ from 'lodash'
3 | import rmfr from 'rmfr'
4 | import path from 'path2/posix'
5 | import Shipit from 'shipit-cli'
6 | import { start } from '../../../tests/util'
7 | import updateTask from './update'
8 |
9 | jest.mock('rmfr')
10 |
11 | function createShipitInstance(config) {
12 | const shipit = new Shipit({
13 | environment: 'test',
14 | log: jest.fn(),
15 | })
16 |
17 | updateTask(shipit)
18 |
19 | // Shipit config
20 | shipit.initConfig({
21 | test: _.merge(
22 | {
23 | workspace: '/tmp/workspace',
24 | deployTo: '/remote/deploy',
25 | },
26 | config,
27 | ),
28 | })
29 |
30 | shipit.workspace = '/tmp/workspace'
31 | shipit.currentPath = path.join(shipit.config.deployTo, 'current')
32 | shipit.releasesPath = path.join(shipit.config.deployTo, 'releases')
33 |
34 | return shipit
35 | }
36 |
37 | function stubShipit(shipit) {
38 | /* eslint-disable no-param-reassign */
39 | shipit.remote = jest.fn(async () => [])
40 | shipit.remoteCopy = jest.fn(async () => [])
41 | shipit.local = jest.fn(async command => {
42 | switch (command) {
43 | case `git rev-parse ${shipit.config.branch}`:
44 | return { stdout: '9d63d434a921f496c12854a53cef8d293e2b4756\n' }
45 | default:
46 | return {}
47 | }
48 | })
49 | /* eslint-enable no-param-reassign */
50 | }
51 |
52 | describe('deploy:update task', () => {
53 | let shipit
54 |
55 | beforeEach(() => {
56 | shipit = createShipitInstance()
57 | moment.utc = () => ({
58 | format: jest.fn(format => format),
59 | })
60 | })
61 |
62 | describe('update release', () => {
63 | beforeEach(() => {
64 | stubShipit(shipit)
65 | })
66 |
67 | it('should create release path, and do a remote copy', async () => {
68 | await start(shipit, 'deploy:update')
69 | expect(shipit.releaseDirname).toBe('YYYYMMDDHHmmss')
70 | expect(shipit.releasesPath).toBe('/remote/deploy/releases')
71 | expect(shipit.releasePath).toBe('/remote/deploy/releases/YYYYMMDDHHmmss')
72 | expect(shipit.remote).toBeCalledWith(
73 | 'mkdir -p /remote/deploy/releases/YYYYMMDDHHmmss',
74 | )
75 | expect(shipit.remoteCopy).toBeCalledWith(
76 | '/tmp/workspace/',
77 | '/remote/deploy/releases/YYYYMMDDHHmmss',
78 | { rsync: '--del' },
79 | )
80 | })
81 |
82 | describe('dirToCopy option', () => {
83 | it('should correct join relative path', () => {
84 | const paths = [
85 | { res: '/tmp/workspace/build/', dirToCopy: 'build' },
86 | { res: '/tmp/workspace/build/', dirToCopy: './build' },
87 | { res: '/tmp/workspace/build/', dirToCopy: './build/' },
88 | { res: '/tmp/workspace/build/', dirToCopy: 'build/.' },
89 | { res: '/tmp/workspace/build/src/', dirToCopy: 'build/src' },
90 | { res: '/tmp/workspace/build/src/', dirToCopy: 'build/src' },
91 | ]
92 | return Promise.all(
93 | paths.map(async p => {
94 | const sh = createShipitInstance({
95 | dirToCopy: p.dirToCopy,
96 | })
97 | stubShipit(sh)
98 | await start(sh, 'deploy:update')
99 | expect(sh.remoteCopy).toBeCalledWith(
100 | p.res,
101 | '/remote/deploy/releases/YYYYMMDDHHmmss',
102 | {
103 | rsync: '--del',
104 | },
105 | )
106 | }),
107 | )
108 | })
109 | })
110 |
111 | describe('remoteCopy option', () => {
112 | it('should accept rsync options', async () => {
113 | const sh = createShipitInstance({
114 | deploy: { remoteCopy: { rsync: '--foo' } },
115 | })
116 | stubShipit(sh)
117 |
118 | await start(sh, 'deploy:update')
119 |
120 | expect(sh.remoteCopy).toBeCalledWith(
121 | '/tmp/workspace/',
122 | '/remote/deploy/releases/YYYYMMDDHHmmss',
123 | { rsync: '--foo' },
124 | )
125 | })
126 | })
127 |
128 | it('should accept rsync options', async () => {
129 | const sh = createShipitInstance({
130 | deploy: { remoteCopy: { rsync: '--foo', copyAsDir: true } },
131 | })
132 | stubShipit(sh)
133 |
134 | await start(sh, 'deploy:update')
135 |
136 | expect(sh.remoteCopy).toBeCalledWith(
137 | '/tmp/workspace',
138 | '/remote/deploy/releases/YYYYMMDDHHmmss',
139 | { rsync: '--foo', copyAsDir: true },
140 | )
141 | })
142 | })
143 |
144 | describe('#setPreviousRevision', () => {
145 | beforeEach(() => {
146 | stubShipit(shipit)
147 | })
148 |
149 | describe('no previous revision', () => {
150 | it('should set shipit.previousRevision to null', async () => {
151 | await start(shipit, 'deploy:update')
152 |
153 | expect(shipit.previousRevision).toBe(null)
154 | expect(shipit.local).toBeCalledWith(
155 | `git rev-parse ${shipit.config.branch}`,
156 | { cwd: '/tmp/workspace' },
157 | )
158 | })
159 | })
160 | })
161 |
162 | describe('#setPreviousRelease', () => {
163 | beforeEach(() => {
164 | stubShipit(shipit)
165 | })
166 |
167 | it('should set shipit.previousRelease to null when no previous release', async () => {
168 | await start(shipit, 'deploy:update')
169 | expect(shipit.previousRelease).toBe(null)
170 | })
171 |
172 | it('should set shipit.previousRelease to (still) current release when one release exist', async () => {
173 | shipit.remote = jest.fn(async () => [{ stdout: '20141704123137\n' }])
174 | await start(shipit, 'deploy:update')
175 | expect(shipit.previousRelease).toBe('20141704123137')
176 | })
177 | })
178 |
179 | describe('#copyPreviousRelease', () => {
180 | beforeEach(() => {
181 | stubShipit(shipit)
182 | })
183 |
184 | describe('no previous release', () => {
185 | it('should proceed with rsync', async () => {
186 | await start(shipit, 'deploy:update')
187 | expect(shipit.previousRelease).toBe(null)
188 | })
189 | })
190 | })
191 |
192 | describe('#setCurrentRevision', () => {
193 | beforeEach(() => {
194 | stubShipit(shipit)
195 | shipit.remote = jest.fn(async command => {
196 | if (/^if \[ -f/.test(command)) {
197 | return [{ stdout: '9d63d434a921f496c12854a53cef8d293e2b4756\n' }]
198 | }
199 |
200 | if (
201 | command ===
202 | 'if [ -h /remote/deploy/current ]; then readlink /remote/deploy/current; fi'
203 | ) {
204 | return [{ stdout: '/remote/deploy/releases/20141704123137' }]
205 | }
206 |
207 | if (command === 'ls -r1 /remote/deploy/releases') {
208 | return [
209 | { stdout: '20141704123137\n20141704123133\n' },
210 | { stdout: '20141704123137\n20141704123133\n' },
211 | ]
212 | }
213 |
214 | if (/^cp/.test(command)) {
215 | const args = command.split(' ')
216 | if (/\/.$/.test(args[args.length - 2]) === false) {
217 | throw new Error('Copy folder contents, not the folder itself')
218 | }
219 | }
220 |
221 | return [{ stdout: '' }]
222 | })
223 | })
224 |
225 | it('should set shipit.currentRevision', async () => {
226 | await start(shipit, 'deploy:update')
227 | expect(shipit.currentRevision).toBe(
228 | '9d63d434a921f496c12854a53cef8d293e2b4756',
229 | )
230 | })
231 |
232 | it('should update remote REVISION file', async () => {
233 | await start(shipit, 'deploy:update')
234 | const revision = await shipit.getRevision('20141704123137')
235 | expect(revision).toBe('9d63d434a921f496c12854a53cef8d293e2b4756')
236 | })
237 |
238 | it('should copy contents of previous release into new folder', async () => {
239 | await start(shipit, 'deploy:update')
240 | expect(shipit.previousRelease).not.toBe(null)
241 | })
242 | })
243 |
244 | it('should remove workspace when shallow cloning', async () => {
245 | shipit.config.shallowClone = true
246 | stubShipit(shipit)
247 | rmfr.mockClear()
248 | expect(rmfr).not.toHaveBeenCalled()
249 | await start(shipit, 'deploy:update')
250 | expect(rmfr).toHaveBeenCalledWith('/tmp/workspace')
251 | })
252 |
253 | it('should keep workspace when not shallow cloning', async () => {
254 | shipit.config.shallowClone = false
255 | stubShipit(shipit)
256 | rmfr.mockClear()
257 | expect(rmfr).not.toHaveBeenCalled()
258 | await start(shipit, 'deploy:update')
259 | expect(rmfr).not.toHaveBeenCalledWith('/tmp/workspace')
260 | })
261 |
262 | it('should keep workspace when keepWorkspace is true', async () => {
263 | shipit.config.shallowClone = true
264 | shipit.config.keepWorkspace = true
265 |
266 | stubShipit(shipit)
267 |
268 | rmfr.mockClear()
269 |
270 | await start(shipit, 'deploy:update')
271 |
272 | expect(rmfr).not.toHaveBeenCalled()
273 | })
274 | })
275 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/pending/index.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import logTask from './log'
3 | import fetchTask from '../deploy/fetch'
4 |
5 | /**
6 | * Pending task.
7 | * - deploy:fetch
8 | * - pending:log
9 | */
10 | export default shipit => {
11 | logTask(shipit)
12 | fetchTask(shipit)
13 | utils.registerTask(shipit, 'pending', ['deploy:fetch', 'pending:log'])
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/pending/log.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import chalk from 'chalk'
3 | import extendShipit from '../../extendShipit'
4 |
5 | /**
6 | * Log task.
7 | */
8 | const logTask = shipit => {
9 | utils.registerTask(shipit, 'pending:log', async () => {
10 | extendShipit(shipit)
11 | const commits = await shipit.getPendingCommits()
12 | const msg = commits
13 | ? chalk.yellow(chalk.underline('\nPending commits:\n') + commits)
14 | : chalk.green('\nNo pending commits.')
15 |
16 | shipit.log(msg)
17 | })
18 | }
19 |
20 | export default logTask
21 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/pending/log.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import { start } from '../../../tests/util'
3 | import logTask from './log'
4 |
5 | describe('pending:log task', () => {
6 | let shipit
7 |
8 | beforeEach(() => {
9 | shipit = new Shipit({
10 | environment: 'test',
11 | log: jest.fn(),
12 | })
13 |
14 | logTask(shipit)
15 |
16 | // Shipit config
17 | shipit.initConfig({
18 | test: {
19 | deployTo: '/remote/deploy',
20 | },
21 | })
22 |
23 | shipit.releasePath = '/remote/deploy/releases/20141704123138'
24 | shipit.releaseDirname = '20141704123138'
25 |
26 | shipit.remote = jest.fn(async () => [])
27 | })
28 |
29 | describe('#getPendingCommits', () => {
30 | describe('no current release', () => {
31 | it('should return null', async () => {
32 | await start(shipit, 'pending:log')
33 | const commits = await shipit.getPendingCommits()
34 | expect(commits).toBe(null)
35 | })
36 | })
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/rollback/finish.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import extendShipit from '../../extendShipit'
3 |
4 | /**
5 | * Update task.
6 | * - Emit an event "rollbacked".
7 | */
8 | export default shipit => {
9 | utils.registerTask(shipit, 'rollback:finish', async () => {
10 | extendShipit(shipit)
11 |
12 | if (shipit.config.deleteOnRollback) {
13 | if (!shipit.prevReleaseDirname || !shipit.prevReleasePath) {
14 | throw new Error("Can't find release to delete")
15 | }
16 |
17 | const command = `rm -rf ${shipit.prevReleasePath}`
18 | await shipit.remote(command)
19 | }
20 |
21 | shipit.emit('rollbacked')
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/rollback/finish.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import path from 'path2/posix'
3 | import { start } from '../../../tests/util'
4 | import finishTask from './finish'
5 |
6 | describe('rollback:finish task', () => {
7 | let shipit
8 | const readLinkCommand =
9 | 'if [ -h /remote/deploy/current ]; then readlink /remote/deploy/current; fi'
10 |
11 | beforeEach(() => {
12 | shipit = new Shipit({
13 | environment: 'test',
14 | log: jest.fn(),
15 | })
16 |
17 | finishTask(shipit)
18 |
19 | // Shipit config
20 | shipit.initConfig({
21 | test: {
22 | deployTo: '/remote/deploy',
23 | deleteOnRollback: false,
24 | },
25 | })
26 |
27 | shipit.releasePath = '/remote/deploy/releases/20141704123137'
28 | shipit.releaseDirname = '20141704123137'
29 | shipit.currentPath = path.join(shipit.config.deployTo, 'current')
30 | shipit.releasesPath = path.join(shipit.config.deployTo, 'releases')
31 | shipit.rollbackDirName = '20141704123137'
32 | })
33 |
34 | describe('delete rollbacked release', () => {
35 | beforeEach(() => {
36 | shipit.remote = jest.fn(async command => {
37 | switch (command) {
38 | case readLinkCommand:
39 | return [{ stdout: '/remote/deploy/releases/20141704123136\n' }]
40 | case 'ls -r1 /remote/deploy/releases':
41 | return [{ stdout: '20141704123137\n20141704123136\n' }]
42 | case 'rm -rf /remote/deploy/releases/20141704123137':
43 | default:
44 | return []
45 | }
46 | })
47 | shipit.config.deleteOnRollback = true
48 | })
49 |
50 | it('undefined releases path', async () => {
51 | expect.assertions(1)
52 | try {
53 | await start(shipit, 'rollback:finish')
54 | } catch (err) {
55 | expect(err.message).toBe("Can't find release to delete")
56 | }
57 | })
58 |
59 | it('undefined previous directory name', async () => {
60 | shipit.prevReleasePath = '/remote/deploy/releases/'
61 | expect.assertions(1)
62 | try {
63 | await start(shipit, 'rollback:finish')
64 | } catch (err) {
65 | expect(err.message).toBe("Can't find release to delete")
66 | }
67 | })
68 |
69 | it('successful delete', async () => {
70 | // set up test specific variables
71 | shipit.prevReleaseDirname = '20141704123137'
72 | shipit.prevReleasePath = '/remote/deploy/releases/20141704123137'
73 |
74 | const spy = jest.fn()
75 | shipit.on('rollbacked', spy)
76 | await start(shipit, 'rollback:finish')
77 | expect(shipit.prevReleaseDirname).toBe('20141704123137')
78 | expect(shipit.remote).toBeCalledWith(
79 | 'rm -rf /remote/deploy/releases/20141704123137',
80 | )
81 | expect(spy).toBeCalled()
82 | })
83 | })
84 |
85 | it('should emit an event', async () => {
86 | const spy = jest.fn()
87 | shipit.on('rollbacked', spy)
88 | await start(shipit, 'rollback:finish')
89 | expect(spy).toBeCalled()
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/rollback/index.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import initTask from './init'
3 | import fetchTask from '../deploy/fetch'
4 | import cleanTask from '../deploy/clean'
5 | import finishTask from './finish'
6 |
7 | /**
8 | * Rollback task.
9 | * - rollback:init
10 | * - deploy:publish
11 | * - deploy:clean
12 | */
13 | export default shipit => {
14 | initTask(shipit)
15 | fetchTask(shipit)
16 | cleanTask(shipit)
17 | finishTask(shipit)
18 |
19 | utils.registerTask(shipit, 'rollback', [
20 | 'rollback:init',
21 | 'deploy:publish',
22 | 'deploy:clean',
23 | 'rollback:finish',
24 | ])
25 | }
26 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/rollback/init.js:
--------------------------------------------------------------------------------
1 | import utils from 'shipit-utils'
2 | import path from 'path2/posix'
3 | import extendShipit from '../../extendShipit'
4 |
5 | /**
6 | * Update task.
7 | * - Create and define release path.
8 | * - Remote copy project.
9 | */
10 | export default shipit => {
11 | utils.registerTask(shipit, 'rollback:init', async () => {
12 | extendShipit(shipit)
13 |
14 | shipit.log('Get current release dirname.')
15 |
16 | const currentRelease = await shipit.getCurrentReleaseDirname()
17 |
18 | if (!currentRelease) {
19 | throw new Error('Cannot find current release dirname.')
20 | }
21 |
22 | shipit.log('Current release dirname : %s.', currentRelease)
23 | shipit.log('Getting dist releases.')
24 |
25 | const releases = await shipit.getReleases()
26 |
27 | if (!releases) {
28 | throw new Error('Cannot read releases.')
29 | }
30 |
31 | shipit.log('Dist releases : %j.', releases)
32 |
33 | const currentReleaseIndex = releases.indexOf(currentRelease)
34 | const rollbackReleaseIndex = currentReleaseIndex + 1
35 |
36 | /* eslint-disable no-param-reassign */
37 | shipit.releaseDirname = releases[rollbackReleaseIndex]
38 |
39 | // Save the previous release in case we need to delete it later
40 | shipit.prevReleaseDirname = releases[currentReleaseIndex]
41 | shipit.prevReleasePath = path.join(
42 | shipit.releasesPath,
43 | shipit.prevReleaseDirname,
44 | )
45 |
46 | shipit.log('Will rollback to %s.', shipit.releaseDirname)
47 |
48 | if (!shipit.releaseDirname) {
49 | throw new Error('Cannot rollback, release not found.')
50 | }
51 |
52 | shipit.releasePath = path.join(shipit.releasesPath, shipit.releaseDirname)
53 | /* eslint-enable no-param-reassign */
54 |
55 | shipit.emit('rollback')
56 | })
57 | }
58 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/src/tasks/rollback/init.test.js:
--------------------------------------------------------------------------------
1 | import Shipit from 'shipit-cli'
2 | import { start } from '../../../tests/util'
3 | import initTask from './init'
4 |
5 | describe('rollback:init task', () => {
6 | let shipit
7 | const readLinkCommand =
8 | 'if [ -h /remote/deploy/current ]; then readlink /remote/deploy/current; fi'
9 |
10 | beforeEach(() => {
11 | shipit = new Shipit({
12 | environment: 'test',
13 | log: jest.fn(),
14 | })
15 |
16 | initTask(shipit)
17 |
18 | // Shipit config
19 | shipit.initConfig({
20 | test: {
21 | deployTo: '/remote/deploy',
22 | },
23 | })
24 | })
25 |
26 | describe('#getCurrentReleaseDirName', () => {
27 | describe('unsync server', () => {
28 | beforeEach(() => {
29 | shipit.remote = jest.fn(async () => [
30 | { stdout: '/remote/deploy/releases/20141704123138' },
31 | { stdout: '/remote/deploy/releases/20141704123137' },
32 | ])
33 | })
34 |
35 | it('should return an error', async () => {
36 | expect.assertions(2)
37 | try {
38 | await start(shipit, 'rollback:init')
39 | } catch (err) {
40 | expect(shipit.remote).toBeCalledWith(readLinkCommand)
41 | expect(err.message).toBe('Remote servers are not synced.')
42 | }
43 | })
44 | })
45 |
46 | describe('bad release dirname', () => {
47 | beforeEach(() => {
48 | shipit.remote = jest.fn(async () => [])
49 | })
50 |
51 | it('should return an error', async () => {
52 | expect.assertions(2)
53 | try {
54 | await start(shipit, 'rollback:init')
55 | } catch (err) {
56 | expect(shipit.remote).toBeCalledWith(readLinkCommand)
57 | expect(err.message).toBe('Cannot find current release dirname.')
58 | }
59 | })
60 | })
61 | })
62 |
63 | describe('#getReleases', () => {
64 | describe('unsync server', () => {
65 | beforeEach(() => {
66 | shipit.remote = jest.fn(async command => {
67 | if (command === readLinkCommand)
68 | return [{ stdout: '/remote/deploy/releases/20141704123137' }]
69 | if (command === 'ls -r1 /remote/deploy/releases')
70 | return [
71 | { stdout: '20141704123137\n20141704123134\n' },
72 | { stdout: '20141704123137\n20141704123133\n' },
73 | ]
74 | return null
75 | })
76 | })
77 |
78 | it('should return an error', async () => {
79 | expect.assertions(2)
80 | try {
81 | await start(shipit, 'rollback:init')
82 | } catch (err) {
83 | expect(shipit.remote).toBeCalledWith('ls -r1 /remote/deploy/releases')
84 | expect(err.message).toBe('Remote servers are not synced.')
85 | }
86 | })
87 | })
88 |
89 | describe('bad releases', () => {
90 | beforeEach(() => {
91 | shipit.remote = jest.fn(async command => {
92 | if (command === readLinkCommand)
93 | return [{ stdout: '/remote/deploy/releases/20141704123137' }]
94 | if (command === 'ls -r1 /remote/deploy/releases') return []
95 |
96 | return null
97 | })
98 | })
99 |
100 | it('should return an error', async () => {
101 | expect.assertions(3)
102 | try {
103 | await start(shipit, 'rollback:init')
104 | } catch (err) {
105 | expect(shipit.remote).toBeCalledWith(readLinkCommand)
106 | expect(shipit.remote).toBeCalledWith('ls -r1 /remote/deploy/releases')
107 | expect(err.message).toBe('Cannot read releases.')
108 | }
109 | })
110 | })
111 | })
112 |
113 | describe('release not exists', () => {
114 | beforeEach(() => {
115 | shipit.remote = jest.fn(async command => {
116 | if (command === readLinkCommand)
117 | return [{ stdout: '/remote/deploy/releases/20141704123137' }]
118 | if (command === 'ls -r1 /remote/deploy/releases')
119 | return [{ stdout: '20141704123137' }]
120 |
121 | return null
122 | })
123 | })
124 |
125 | it('should return an error', async () => {
126 | expect.assertions(3)
127 | try {
128 | await start(shipit, 'rollback:init')
129 | } catch (err) {
130 | expect(shipit.remote).toBeCalledWith(readLinkCommand)
131 | expect(shipit.remote).toBeCalledWith('ls -r1 /remote/deploy/releases')
132 | expect(err.message).toBe('Cannot rollback, release not found.')
133 | }
134 | })
135 | })
136 |
137 | describe('all good', () => {
138 | beforeEach(() => {
139 | shipit.remote = jest.fn(async command => {
140 | if (command === readLinkCommand)
141 | return [{ stdout: '/remote/deploy/releases/20141704123137\n' }]
142 | if (command === 'ls -r1 /remote/deploy/releases')
143 | return [{ stdout: '20141704123137\n20141704123136\n' }]
144 | return null
145 | })
146 | })
147 |
148 | it('define path', async () => {
149 | await start(shipit, 'rollback:init')
150 | expect(shipit.currentPath).toBe('/remote/deploy/current')
151 | expect(shipit.releasesPath).toBe('/remote/deploy/releases')
152 | expect(shipit.remote).toBeCalledWith(readLinkCommand)
153 | expect(shipit.remote).toBeCalledWith('ls -r1 /remote/deploy/releases')
154 | expect(shipit.releaseDirname).toBe('20141704123136')
155 | expect(shipit.releasePath).toBe('/remote/deploy/releases/20141704123136')
156 | })
157 | })
158 | })
159 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/tests/integration.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import path from 'path'
3 | import { exec } from 'ssh-pool'
4 |
5 | const shipitCli = path.resolve(__dirname, '../../shipit-cli/src/cli.js')
6 | const shipitFile = path.resolve(__dirname, './sandbox/shipitfile.babel.js')
7 | const babelNode = require.resolve('@babel/node/bin/babel-node');
8 |
9 | describe('shipit-cli', () => {
10 | it('should run a local task', async () => {
11 | try {
12 | await exec(
13 | `${babelNode} ${shipitCli} --shipitfile ${shipitFile} test deploy`,
14 | )
15 | } catch (error) {
16 | // eslint-disable-next-line no-console
17 | console.error(error.stdout)
18 |
19 | throw error
20 | }
21 |
22 | const { stdout: lsReleases } = await exec(
23 | `${babelNode} ${shipitCli} --shipitfile ${shipitFile} test ls-releases`,
24 | )
25 |
26 | const latestRelease = lsReleases
27 | .split('\n')
28 | .reverse()[2]
29 | .match(/\d{14}/)[0]
30 |
31 | const { stdout: lsCurrent } = await exec(
32 | `${babelNode} ${shipitCli} --shipitfile ${shipitFile} test ls-current`,
33 | )
34 |
35 | const currentRelease = lsCurrent
36 | .split('\n')[3]
37 | .match(/releases\/(\d{14})/)[1]
38 |
39 | expect(latestRelease).toBe(currentRelease)
40 | }, 30000)
41 | })
42 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/tests/sandbox/shipitfile.babel.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import shipitDeploy from '../..'
3 |
4 | export default shipit => {
5 | shipitDeploy(shipit)
6 |
7 | shipit.initConfig({
8 | default: {
9 | key: './ssh/id_rsa',
10 | workspace: '/tmp/shipit-workspace',
11 | deployTo: '/tmp/shipit',
12 | repositoryUrl: 'https://github.com/shipitjs/shipit.git',
13 | ignores: ['.git', 'node_modules'],
14 | shallowClone: true,
15 | },
16 | test: {
17 | servers: 'deploy@test.shipitjs.com',
18 | },
19 | })
20 |
21 | shipit.task('ls-releases', async () => {
22 | await shipit.remote('ls -lah /tmp/shipit/releases')
23 | })
24 |
25 | shipit.task('ls-current', async () => {
26 | await shipit.remote('ls -lah /tmp/shipit/current')
27 | })
28 | }
29 |
--------------------------------------------------------------------------------
/packages/shipit-deploy/tests/util.js:
--------------------------------------------------------------------------------
1 | export function start(shipit, ...tasks) {
2 | return new Promise((resolve, reject) => {
3 | shipit.start(...tasks, err => {
4 | if (err) reject(err)
5 | else resolve()
6 | })
7 | })
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ssh-pool/.npmignore:
--------------------------------------------------------------------------------
1 | /*
2 | !/lib/**/*.js
3 | *.test.js
4 |
--------------------------------------------------------------------------------
/packages/ssh-pool/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | # [5.3.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.2.0...v5.3.0) (2020-03-18)
7 |
8 |
9 | ### Features
10 |
11 | * add support of `asUser` ([#260](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/260)) ([4e79edb](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/4e79edb))
12 |
13 |
14 |
15 |
16 |
17 | # [5.2.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.1.0...v5.2.0) (2020-03-07)
18 |
19 |
20 | ### Bug Fixes
21 |
22 | * **windows:** cd must run the specified drive letter ([#252](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/252)) ([ab916a9](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/ab916a9))
23 | * fix remote command wont reject on error, when cwd option is used ([#265](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/265)) ([986aec1](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/986aec1))
24 |
25 |
26 |
27 |
28 |
29 | # [5.1.0](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v5.0.0...v5.1.0) (2019-08-28)
30 |
31 |
32 | ### Features
33 |
34 | * **ssh-pool:** Added ssh config array to remote server ([#248](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/248)) ([ba1d8c2](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/ba1d8c2))
35 |
36 |
37 |
38 |
39 |
40 | ## [4.1.2](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/compare/v4.1.1...v4.1.2) (2018-11-04)
41 |
42 |
43 | ### Bug Fixes
44 |
45 | * **security:** use which instead of whereis ([#220](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/220)) ([6f46cad](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/6f46cad))
46 | * use correct deprecation warning ([#219](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/issues/219)) ([e0c0fa5](https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy/commit/e0c0fa5))
47 |
48 |
49 |
50 |
51 |
52 |
53 | # [4.1.0](https://github.com/babel/babel/tree/master/packages/babel-traverse/compare/v4.0.2...v4.1.0) (2018-04-27)
54 |
55 |
56 | ### Features
57 |
58 | * **ssh-pool:** add SSH Verbosity Levels ([#191](https://github.com/babel/babel/tree/master/packages/babel-traverse/issues/191)) ([327c63e](https://github.com/babel/babel/tree/master/packages/babel-traverse/commit/327c63e))
59 |
60 |
61 |
62 |
63 |
64 | ## [4.0.2](https://github.com/babel/babel/tree/master/packages/babel-traverse/compare/v4.0.1...v4.0.2) (2018-03-25)
65 |
66 |
67 | ### Bug Fixes
68 |
69 | * be compatible with CommonJS ([abd2316](https://github.com/babel/babel/tree/master/packages/babel-traverse/commit/abd2316))
70 | * fix scpCopyFromRemote & scpCopyToRemote ([01bc213](https://github.com/babel/babel/tree/master/packages/babel-traverse/commit/01bc213)), closes [#178](https://github.com/babel/babel/tree/master/packages/babel-traverse/issues/178)
71 |
72 |
73 |
74 |
75 |
76 |
77 | # 4.0.0 (2018-03-17)
78 |
79 | ## ssh-pool
80 |
81 | ### Features
82 |
83 | * Introduce a "tty" option in "run" method #56
84 | * Support "cwd" in "run" command #9
85 | * Expose a "isRsyncSupported" method
86 |
87 | ### Fixes
88 |
89 | * Fix parallel issues using scp copy shipitjs/ssh-pool#22
90 | * Fix command escaping #91 #152
91 |
92 | ### Docs
93 |
94 | * Update readme with new documentation
95 |
96 | ### Chores
97 |
98 | * Move to a Lerna repository
99 | * Add Codecov
100 | * Move to Jest for testing
101 | * Rewrite project in ES2017 targeting Node.js v6+
102 |
103 | ### Deprecations
104 |
105 | * Deprecate automatic "sudo" removing when using "asUser" #56 #12
106 | * Deprecate "copy" method in favor of "copyToRemote", "copyFromRemote", "scpCopyToRemote" and "scpCopyFromRemote"
107 | * Deprecate using "deploy" as default user
108 | * Deprecate automatic "tty" when detecting "sudo" #56
109 |
110 | ### BREAKING CHANGES
111 |
112 | * Drop callbacks support and use native Promises
113 | * Standardise errors #154
114 | * Replace "cwd" behaviour in "run" command #9
115 |
--------------------------------------------------------------------------------
/packages/ssh-pool/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Greg Bergé and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/ssh-pool/README.md:
--------------------------------------------------------------------------------
1 | # ssh-pool
2 |
3 | [![Build Status][build-badge]][build]
4 | [![version][version-badge]][package]
5 | [![MIT License][license-badge]][license]
6 |
7 | Run remote commands over a pool of server using SSH.
8 |
9 | ```sh
10 | npm install ssh-pool
11 | ```
12 |
13 | ## Usage
14 |
15 | ```js
16 | import { ConnectionPool } from 'ssh-pool'
17 |
18 | const pool = new ConnectionPool(['user@server1', 'user@server2'])
19 |
20 | async function run() {
21 | const results = await pool.run('hostname')
22 | console.log(results[0].stdout) // 'server1'
23 | console.log(results[1].stdout) // 'server2'
24 | }
25 | ```
26 |
27 | ### new Connection(options)
28 |
29 | Create a new connection to run command on a remote server.
30 |
31 | **Parameters:**
32 |
33 | ```
34 | @param {object} options Options
35 | @param {string|object} options.remote Remote
36 | @param {Stream} [options.stdout] Stdout stream
37 | @param {Stream} [options.stderr] Stderr stream
38 | @param {string} [options.key] SSH key
39 | @param {function} [options.log] Log method
40 | @param {boolean} [options.asUser] Use a custom user to run command
41 | @param {number} [options.verbosityLevel] SSH verbosity level: 0 (none), 1 (-v), 2 (-vv), 3+ (-vvv)
42 | ```
43 |
44 | The remote can use the shorthand syntax or an object:
45 |
46 | ```js
47 | // You specify user and host
48 | new Connection({ remote: 'user@localhost' })
49 |
50 | // You can specify a custom SSH port
51 | new Connection({ remote: 'user@localhost:4000' })
52 |
53 | // You can also define remote using an object
54 | new Connection({
55 | remote: {
56 | user: 'user',
57 | host: 'localhost',
58 | port: 4000,
59 | },
60 | })
61 |
62 | // When defined as an object you can add extra ssh parameters
63 | new Connection({
64 | remote: {
65 | user: 'user',
66 | host: 'localhost',
67 | port: 4000,
68 | extraSshOptions: {
69 | ServerAliveInterval: '30',
70 | }
71 | },
72 | })
73 | ```
74 |
75 | The log method is used to log output directly:
76 |
77 | ```js
78 | import { Connection } from 'ssh-pool'
79 |
80 | const connection = new Connection({
81 | remote: 'localhost',
82 | log: (...args) => console.log(...args),
83 | })
84 |
85 | connection.run('pwd')
86 |
87 | // Will output:
88 | // Running "pwd" on host "localhost".
89 | // @localhost /my/directory
90 | ```
91 |
92 | ### connection.run(command, [options])
93 |
94 | Run a command on the remote server, you can specify custom `childProcess.exec` options.
95 |
96 | **Parameters:**
97 |
98 | ```
99 | @param {string} command Command to run
100 | @param {object} [options] Options
101 | @param {boolean} [options.tty] Force a TTY allocation.
102 | @returns {ExecResult}
103 | @throws {ExecError}
104 | ```
105 |
106 | ```js
107 | // Run "ls" command on a remote server
108 | connection.run('ls').then(res => {
109 | console.log(res.stdout) // file1 file2 file3
110 | })
111 | ```
112 |
113 | ### connection.copyToRemote(src, dest, [options])
114 |
115 | Copy a file or a directory from local to a remote server, you can specify custom `childProcess.exec` options. It uses rsync under the hood.
116 |
117 | **Parameters:**
118 |
119 | ```
120 | * @param {string} src Source
121 | * @param {string} dest Destination
122 | * @param {object} [options] Options
123 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
124 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
125 | * @returns {ExecResult}
126 | * @throws {ExecError}
127 | ```
128 |
129 | ```js
130 | // Copy a local file to a remote file using Rsync
131 | connection.copyToRemote('./localfile', '/remote-file').then(() => {
132 | console.log('File copied!')
133 | })
134 | ```
135 |
136 | ### connection.copyFromRemote(src, dest, [options])
137 |
138 | Copy a file or a directory from a remote server to local, you can specify custom `childProcess.exec` options. It uses rsync under the hood.
139 |
140 | **Parameters:**
141 |
142 | ```
143 | * @param {string} src Source
144 | * @param {string} dest Destination
145 | * @param {object} [options] Options
146 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
147 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
148 | * @returns {ExecResult}
149 | * @throws {ExecError}
150 | ```
151 |
152 | ```js
153 | // Copy a remote file to a local file using Rsync
154 | connection.copyFromRemote('/remote-file', './local-file').then(() => {
155 | console.log('File copied!')
156 | })
157 | ```
158 |
159 | ### new ConnectionPool(connections, [options])
160 |
161 | Create a new pool of connections and custom options for all connections.
162 | You can use either short syntax or connections to create a pool.
163 |
164 | ```js
165 | import { Connection, ConnectionPool } from 'ssh-pool'
166 |
167 | // Use shorthand.
168 | const pool = new ConnectionPool(['server1', 'server2'])
169 |
170 | // Use previously created connections.
171 | const connection1 = new Connection({ remote: 'server1' })
172 | const connection2 = new Connection({ remote: 'server2' })
173 | const pool = new ConnectionPool([connection1, connection2])
174 | ```
175 |
176 | Connection Pool accepts exactly the same methods as Connection. It runs commands in parallel on each server defined in the pool. You get an array of results.
177 |
178 | ### isRsyncSupported()
179 |
180 | Test if rsync is supported on the local machine.
181 |
182 | ```js
183 | import { isRsyncSupported } from 'ssh-pool'
184 |
185 | isRsyncSupported().then(supported => {
186 | if (supported) {
187 | console.log('Rsync is supported!')
188 | } else {
189 | console.log('Rsync is not supported!')
190 | }
191 | })
192 | ```
193 |
194 | ### exec(cmd, options, childModifier)
195 |
196 | Execute a command and return an object containing `{ child, stdout, stderr }`.
197 |
198 | ```js
199 | import { exec } from 'ssh-pool'
200 |
201 | exec('echo "hello"')
202 | .then(({ stdout }) => console.log(stdout))
203 | .catch(({ stderr, stdout }) => console.error(stderr))
204 | ```
205 |
206 | ## License
207 |
208 | MIT
209 |
210 | [build-badge]: https://img.shields.io/travis/shipitjs/shipit.svg?style=flat-square
211 | [build]: https://travis-ci.org/shipitjs/shipit
212 | [version-badge]: https://img.shields.io/npm/v/ssh-pool.svg?style=flat-square
213 | [package]: https://www.npmjs.com/package/ssh-pool
214 | [license-badge]: https://img.shields.io/npm/l/ssh-pool.svg?style=flat-square
215 | [license]: https://github.com/shipitjs/shipit/blob/master/LICENSE
216 |
--------------------------------------------------------------------------------
/packages/ssh-pool/__mocks__/child_process.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import EventEmitter from 'events'
3 | import { Readable } from 'stream'
4 |
5 | export const exec = jest.fn((command, options, cb) => {
6 | const child = new EventEmitter()
7 | child.stderr = new Readable()
8 | child.stderr._read = jest.fn()
9 | child.stdout = new Readable()
10 | child.stdout._read = jest.fn()
11 |
12 | process.nextTick(() => {
13 | cb(null, Buffer.from('stdout'), Buffer.from('stderr'))
14 | })
15 |
16 | return child
17 | })
18 |
--------------------------------------------------------------------------------
/packages/ssh-pool/__mocks__/tmp.js:
--------------------------------------------------------------------------------
1 | export const tmpName = (options, cb) => cb(null, '/tmp/foo.tar.gz')
2 |
--------------------------------------------------------------------------------
/packages/ssh-pool/__mocks__/which.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | let paths
3 |
4 | export const __setPaths__ = _paths => {
5 | paths = _paths
6 | }
7 |
8 | export default (name, cb) => {
9 | if (typeof paths[name] !== 'undefined') cb(null, paths[name])
10 | else cb(new Error(`Could not find ${name} on your system`))
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ssh-pool/examples/hostname.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | const sshPool = require('../')
4 |
5 | const pool = new sshPool.ConnectionPool([
6 | 'neoziro@localhost',
7 | 'neoziro@localhost',
8 | ])
9 |
10 | pool.run('hostname').then(results => {
11 | console.log(results[0].stdout)
12 | console.log(results[1].stdout)
13 | })
14 |
--------------------------------------------------------------------------------
/packages/ssh-pool/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ssh-pool",
3 | "version": "5.3.0",
4 | "description": "Run remote commands over a pool of server using SSH.",
5 | "engines": {
6 | "node": ">=6"
7 | },
8 | "author": "Greg Bergé ",
9 | "license": "MIT",
10 | "repository": "https://github.com/shipitjs/shipit/tree/master/packages/shipit-deploy",
11 | "main": "lib/index.js",
12 | "keywords": [
13 | "shipit",
14 | "automation",
15 | "deployment",
16 | "ssh"
17 | ],
18 | "scripts": {
19 | "prebuild": "rm -rf lib/",
20 | "build": "babel --config-file ../../babel.config.js -d lib --ignore \"**/*.test.js\" src",
21 | "prepublishOnly": "yarn run build"
22 | },
23 | "dependencies": {
24 | "stream-line-wrapper": "^0.1.1",
25 | "tmp": "^0.1.0",
26 | "which": "^1.3.1"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/Connection.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import LineWrapper from 'stream-line-wrapper'
3 | import { tmpName as asyncTmpName } from 'tmp'
4 | import { formatRsyncCommand, isRsyncSupported } from './commands/rsync'
5 | import { formatSshCommand } from './commands/ssh'
6 | import { formatTarCommand } from './commands/tar'
7 | import { formatCdCommand } from './commands/cd'
8 | import { formatMkdirCommand } from './commands/mkdir'
9 | import { formatScpCommand } from './commands/scp'
10 | import { formatRawCommand } from './commands/raw'
11 | import { formatRmCommand } from './commands/rm'
12 | import { joinCommandArgs } from './commands/util'
13 | import { parseRemote, formatRemote } from './remote'
14 | import { exec, series, deprecateV3, deprecateV5 } from './util'
15 |
16 | const tmpName = async options =>
17 | new Promise((resolve, reject) =>
18 | asyncTmpName(options, (err, name) => {
19 | if (err) reject(err)
20 | else resolve(name)
21 | }),
22 | )
23 |
24 | /**
25 | * An ExecResult returned when a command is executed with success.
26 | * @typedef {object} ExecResult
27 | * @property {Buffer} stdout
28 | * @property {Buffer} stderr
29 | * @property {ChildProcess} child
30 | */
31 |
32 | /**
33 | * An ExecResult returned when a command is executed with success.
34 | * @typedef {object} MultipleExecResult
35 | * @property {Buffer} stdout
36 | * @property {Buffer} stderr
37 | * @property {ChildProcess[]} children
38 | */
39 |
40 | /**
41 | * An ExecError returned when a command is executed with an error.
42 | * @typedef {Error} ExecError
43 | * @property {Buffer} stdout
44 | * @property {Buffer} stderr
45 | * @property {ChildProcess} child
46 | */
47 |
48 | /**
49 | * Materialize a connection to a remote server.
50 | */
51 | class Connection {
52 | /**
53 | * Initialize a new `Connection` with `options`.
54 | *
55 | * @param {object} options Options
56 | * @param {string|object} options.remote Remote
57 | * @param {Stream} [options.stdout] Stdout stream
58 | * @param {Stream} [options.stderr] Stderr stream
59 | * @param {string} [options.key] SSH key
60 | * @param {function} [options.log] Log method
61 | * @param {boolean} [options.asUser] Use a custom user to run command
62 | * @param {number} [options.verbosityLevel] The SSH verbosity level: 0 (none), 1 (-v), 2 (-vv), 3+ (-vvv)
63 | */
64 | constructor(options = {}) {
65 | this.options = options
66 | this.remote = parseRemote(options.remote)
67 | this.remote.user = this.remote.user || 'deploy'
68 | }
69 |
70 | /**
71 | * Run a command remotely using SSH.
72 | * All exec options are also available.
73 | *
74 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
75 | * @param {string} command Command to run
76 | * @param {object} [options] Options
77 | * @param {boolean} [options.tty] Force a TTY allocation.
78 | * @returns {ExecResult}
79 | * @throws {ExecError}
80 | */
81 | async run(command, { tty: ttyOption, cwd, ...cmdOptions } = {}) {
82 | let tty = ttyOption
83 | if (command.startsWith('sudo') && typeof ttyOption === 'undefined') {
84 | deprecateV3('You should set "tty" option explictly when you use "sudo".')
85 | tty = true
86 | }
87 | this.log('Running "%s" on host "%s".', command, this.remote.host)
88 | const cmd = this.buildSSHCommand(command, { tty, cwd })
89 | return this.runLocally(cmd, cmdOptions)
90 | }
91 |
92 | /**
93 | * Run a copy command using either rsync or scp.
94 | * All exec options are also available.
95 | *
96 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
97 | * @deprecated
98 | * @param {string} src Source
99 | * @param {string} dest Destination
100 | * @param {object} [options] Options
101 | * @param {boolean} [options.direction] Specify "remoteToLocal" to copy from "remote". By default it will copy from remote.
102 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
103 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
104 | * @returns {ExecResult|MultipleExecResult}
105 | * @throws {ExecError}
106 | */
107 | async copy(src, dest, { direction, ...options } = {}) {
108 | deprecateV5(
109 | '"copy" method is deprecated, please use "copyToRemote", "copyFromRemote", "scpCopyToRemote" or "scpCopyFromRemote".',
110 | )
111 | if (direction === 'remoteToLocal')
112 | return this.autoCopyFromRemote(src, dest, options)
113 |
114 | return this.autoCopyToRemote(src, dest, options)
115 | }
116 |
117 | /**
118 | * Run a copy from the local to the remote using rsync.
119 | * All exec options are also available.
120 | *
121 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
122 | * @param {string} src Source
123 | * @param {string} dest Destination
124 | * @param {object} [options] Options
125 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
126 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
127 | * @returns {ExecResult}
128 | * @throws {ExecError}
129 | */
130 | async copyToRemote(src, dest, options) {
131 | const remoteDest = `${formatRemote(this.remote)}:${dest}`
132 | return this.rsyncCopy(src, remoteDest, options)
133 | }
134 |
135 | /**
136 | * Run a copy from the remote to the local using rsync.
137 | * All exec options are also available.
138 | *
139 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
140 | * @param {string} src Source
141 | * @param {string} dest Destination
142 | * @param {object} [options] Options
143 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
144 | * @param {string[]|string} [options.rsync] Specify a set of rsync arguments.
145 | * @returns {ExecResult}
146 | * @throws {ExecError}
147 | */
148 | async copyFromRemote(src, dest, options) {
149 | const remoteSrc = `${formatRemote(this.remote)}:${src}`
150 | return this.rsyncCopy(remoteSrc, dest, options)
151 | }
152 |
153 | /**
154 | * Run a copy from the local to the remote using scp.
155 | * All exec options are also available.
156 | *
157 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
158 | * @param {string} src Source
159 | * @param {string} dest Destination
160 | * @param {object} [options] Options
161 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
162 | * @param {...object} [cmdOptions] Command options
163 | * @returns {ExecResult}
164 | * @throws {ExecError}
165 | */
166 | async scpCopyToRemote(src, dest, { ignores, ...cmdOptions } = {}) {
167 | const archive = path.basename(await tmpName({ postfix: '.tar.gz' }))
168 | const srcDir = path.dirname(src)
169 | const remoteDest = `${formatRemote(this.remote)}:${dest}`
170 |
171 | const compress = joinCommandArgs([
172 | formatCdCommand({ folder: srcDir }),
173 | '&&',
174 | formatTarCommand({
175 | mode: 'compress',
176 | file: path.basename(src),
177 | archive,
178 | excludes: ignores,
179 | }),
180 | ])
181 |
182 | const createDestFolder = formatMkdirCommand({ folder: dest })
183 |
184 | const copy = joinCommandArgs([
185 | formatCdCommand({ folder: srcDir }),
186 | '&&',
187 | formatScpCommand({
188 | port: this.remote.port,
189 | key: this.options.key,
190 | src: archive,
191 | dest: remoteDest,
192 | }),
193 | ])
194 |
195 | const cleanSrc = joinCommandArgs([
196 | formatCdCommand({ folder: srcDir }),
197 | '&&',
198 | formatRmCommand({ file: archive }),
199 | ])
200 |
201 | const extract = joinCommandArgs([
202 | formatCdCommand({ folder: dest }),
203 | '&&',
204 | formatTarCommand({ mode: 'extract', archive }),
205 | ])
206 |
207 | const cleanDest = joinCommandArgs([
208 | formatCdCommand({ folder: dest }),
209 | '&&',
210 | formatRmCommand({ file: archive }),
211 | ])
212 |
213 | return this.aggregate([
214 | () => this.runLocally(compress, cmdOptions),
215 | () => this.run(createDestFolder, cmdOptions),
216 | () => this.runLocally(copy, cmdOptions),
217 | () => this.runLocally(cleanSrc, cmdOptions),
218 | () => this.run(extract, cmdOptions),
219 | () => this.run(cleanDest, cmdOptions),
220 | ])
221 | }
222 |
223 | /**
224 | * Run a copy from the remote to the local using scp.
225 | * All exec options are also available.
226 | *
227 | * @see https://nodejs.org/dist/latest-v8.x/docs/api/child_process.html#child_process_child_process_exec_command_options_callback
228 | * @param {string} src Source
229 | * @param {string} dest Destination
230 | * @param {object} [options] Options
231 | * @param {string[]} [options.ignores] Specify a list of files to ignore.
232 | * @param {...object} [cmdOptions] Command options
233 | * @returns {MultipleExecResult}
234 | * @throws {ExecError}
235 | */
236 | async scpCopyFromRemote(src, dest, { ignores, ...cmdOptions } = {}) {
237 | const archive = path.basename(await tmpName({ postfix: '.tar.gz' }))
238 | const srcDir = path.dirname(src)
239 | const srcArchive = path.join(srcDir, archive)
240 | const remoteSrcArchive = `${formatRemote(this.remote)}:${srcArchive}`
241 |
242 | const compress = joinCommandArgs([
243 | formatCdCommand({ folder: srcDir }),
244 | '&&',
245 | formatTarCommand({
246 | mode: 'compress',
247 | file: path.basename(src),
248 | archive,
249 | excludes: ignores,
250 | }),
251 | ])
252 |
253 | const createDestFolder = formatMkdirCommand({ folder: dest })
254 |
255 | const copy = formatScpCommand({
256 | port: this.remote.port,
257 | key: this.options.key,
258 | src: remoteSrcArchive,
259 | dest,
260 | })
261 |
262 | const cleanSrc = joinCommandArgs([
263 | formatCdCommand({ folder: srcDir }),
264 | '&&',
265 | formatRmCommand({ file: archive }),
266 | ])
267 |
268 | const extract = joinCommandArgs([
269 | formatCdCommand({ folder: dest }),
270 | '&&',
271 | formatTarCommand({ mode: 'extract', archive }),
272 | ])
273 |
274 | const cleanDest = joinCommandArgs([
275 | formatCdCommand({ folder: dest }),
276 | '&&',
277 | formatRmCommand({ file: archive }),
278 | ])
279 |
280 | return this.aggregate([
281 | () => this.run(compress, cmdOptions),
282 | () => this.runLocally(createDestFolder, cmdOptions),
283 | () => this.runLocally(copy, cmdOptions),
284 | () => this.run(cleanSrc, cmdOptions),
285 | () => this.runLocally(extract, cmdOptions),
286 | () => this.runLocally(cleanDest, cmdOptions),
287 | ])
288 | }
289 |
290 | /**
291 | * Build an SSH command.
292 | *
293 | * @private
294 | * @param {string} command
295 | * @param {object} options
296 | * @returns {string}
297 | */
298 | buildSSHCommand(command, options) {
299 | return formatSshCommand({
300 | port: this.remote.port,
301 | key: this.options.key,
302 | strict: this.options.strict,
303 | tty: this.options.tty,
304 | extraSshOptions: this.remote.extraSshOptions,
305 | verbosityLevel: this.options.verbosityLevel,
306 | remote: formatRemote(this.remote),
307 | command: formatRawCommand({ command, asUser: this.options.asUser }),
308 | ...options,
309 | })
310 | }
311 |
312 | /**
313 | * Abstract method to copy using rsync.
314 | *
315 | * @private
316 | * @param {string} src
317 | * @param {string} dest
318 | * @param {object} options
319 | * @param {string[]|string} rsync Additional arguments
320 | * @param {string[]} ignores Files to ignore
321 | * @param {...object} cmdOptions Command options
322 | * @returns {ExecResult}
323 | * @throws {ExecError}
324 | */
325 | async rsyncCopy(src, dest, { rsync, ignores, ...cmdOptions } = {}) {
326 | this.log('Copy "%s" to "%s" via rsync', src, dest)
327 |
328 | const sshCommand = formatSshCommand({
329 | port: this.remote.port,
330 | key: this.options.key,
331 | strict: this.options.strict,
332 | extraSshOptions: this.remote.extraSshOptions,
333 | tty: this.options.tty,
334 | })
335 |
336 | const cmd = formatRsyncCommand({
337 | asUser: this.options.asUser,
338 | src,
339 | dest,
340 | remoteShell: sshCommand,
341 | additionalArgs: typeof rsync === 'string' ? [rsync] : rsync,
342 | excludes: ignores,
343 | })
344 |
345 | return this.runLocally(cmd, cmdOptions)
346 | }
347 |
348 | /**
349 | * Automatic copy to remote method.
350 | * Choose rsync and fallback to scp if not available.
351 | *
352 | * @private
353 | * @param {string} src
354 | * @param {string} dest
355 | * @param {object} options
356 | * @returns {ExecResult|MultipleExecResult}
357 | * @throws {ExecError}
358 | */
359 | async autoCopyToRemote(src, dest, options) {
360 | const rsyncAvailable = await isRsyncSupported()
361 | const method = rsyncAvailable ? 'copyToRemote' : 'scpCopyToRemote'
362 | return this[method](src, dest, options)
363 | }
364 |
365 | /**
366 | * Automatic copy from remote method.
367 | * Choose rsync and fallback to scp if not available.
368 | *
369 | * @private
370 | * @param {string} src
371 | * @param {string} dest
372 | * @param {object} options
373 | * @returns {ExecResult|MultipleExecResult}
374 | * @throws {ExecError}
375 | */
376 | async autoCopyFromRemote(src, dest, options) {
377 | const rsyncAvailable = await isRsyncSupported()
378 | const method = rsyncAvailable ? 'copyFromRemote' : 'scpCopyFromRemote'
379 | return this[method](src, dest, options)
380 | }
381 |
382 | /**
383 | * Aggregate some exec tasks.
384 | *
385 | * @private
386 | * @param {Promise.[]} tasks An array of tasks
387 | * @returns {MultipleExecResult}
388 | * @throws {ExecError}
389 | */
390 | async aggregate(tasks) {
391 | const results = await series(tasks)
392 |
393 | return results.reduce(
394 | (aggregate, result) => ({
395 | stdout: String(aggregate.stdout) + String(result.stdout),
396 | stderr: String(aggregate.stderr) + String(result.stderr),
397 | children: [...aggregate.children, result.child],
398 | }),
399 | {
400 | stdout: '',
401 | stderr: '',
402 | children: [],
403 | },
404 | )
405 | }
406 |
407 | /**
408 | * Log using logger.
409 | *
410 | * @private
411 | * @param {...*} args
412 | */
413 | log(...args) {
414 | if (this.options.log) this.options.log(...args)
415 | }
416 |
417 | /**
418 | * Method used to run a command locally.
419 | *
420 | * @private
421 | * @param {string} cmd
422 | * @param {object} [options]
423 | * @param {Buffer} [options.stdout] stdout buffer
424 | * @param {Buffer} [options.stderr] stderr buffer
425 | * @param {...object} [options.cmdOptions] Command options
426 | * @returns {ExecResult}
427 | * @throws {ExecError}
428 | */
429 | async runLocally(cmd, { stdout, stderr, ...cmdOptions } = {}) {
430 | const stdoutPipe = stdout || this.options.stdout
431 | const stderrPipe = stderr || this.options.stderr
432 |
433 | return exec(cmd, cmdOptions, child => {
434 | if (stdoutPipe)
435 | child.stdout
436 | .pipe(new LineWrapper({ prefix: `@${this.remote.host} ` }))
437 | .pipe(stdoutPipe)
438 |
439 | if (stderrPipe)
440 | child.stderr
441 | .pipe(new LineWrapper({ prefix: `@${this.remote.host}-err ` }))
442 | .pipe(stderrPipe)
443 | })
444 | }
445 | }
446 |
447 | export default Connection
448 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/Connection.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import stdMocks from 'std-mocks'
3 | import { exec } from 'child_process'
4 | import { __setPaths__ } from 'which'
5 | import * as util from './util'
6 | import Connection from './Connection'
7 |
8 | jest.mock('child_process')
9 | jest.mock('which')
10 | jest.mock('tmp')
11 |
12 | describe('Connection', () => {
13 | beforeEach(() => {
14 | exec.mockClear()
15 | util.deprecateV3 = jest.fn()
16 | __setPaths__({ rsync: '/bin/rsync' })
17 | })
18 |
19 | afterEach(() => {
20 | exec.mockClear()
21 | stdMocks.flush()
22 | stdMocks.restore()
23 | })
24 |
25 | describe('constructor', () => {
26 | it('should accept remote object', () => {
27 | const connection = new Connection({
28 | remote: { user: 'user', host: 'host' },
29 | })
30 | expect(connection.remote.user).toBe('user')
31 | expect(connection.remote.host).toBe('host')
32 | })
33 |
34 | it('should accept remote string', () => {
35 | const connection = new Connection({ remote: 'user@host' })
36 | expect(connection.remote.user).toBe('user')
37 | expect(connection.remote.host).toBe('host')
38 | })
39 | })
40 |
41 | describe('#run', () => {
42 | let connection
43 |
44 | beforeEach(() => {
45 | connection = new Connection({ remote: 'user@host' })
46 | })
47 |
48 | it('should call childProcess.exec', async () => {
49 | await connection.run('my-command -x', { cwd: '/root' })
50 |
51 | expect(exec).toHaveBeenCalledWith(
52 | 'ssh user@host "cd /root > /dev/null && my-command -x; cd - > /dev/null"',
53 | {
54 | maxBuffer: 1024000,
55 | },
56 | expect.any(Function),
57 | )
58 | })
59 |
60 | it('should escape double quotes', async () => {
61 | await connection.run('echo "ok"')
62 |
63 | expect(exec).toHaveBeenCalledWith(
64 | 'ssh user@host "echo \\"ok\\""',
65 | {
66 | maxBuffer: 1024000,
67 | },
68 | expect.any(Function),
69 | )
70 | })
71 |
72 | it('should return result correctly', async () => {
73 | const result = await connection.run('my-command -x', { cwd: '/root' })
74 | expect(result.stdout.toString()).toBe('stdout')
75 | expect(result.stderr.toString()).toBe('stderr')
76 | })
77 |
78 | it('should handle tty', async () => {
79 | await connection.run('sudo my-command -x', { tty: true })
80 |
81 | expect(exec).toHaveBeenCalledWith(
82 | 'ssh -tt user@host "sudo my-command -x"',
83 | {
84 | maxBuffer: 1024000,
85 | },
86 | expect.any(Function),
87 | )
88 | })
89 |
90 | it('should copy args', async () => {
91 | await connection.run('my-command -x')
92 | await connection.run('my-command2 -x')
93 |
94 | expect(exec).toHaveBeenCalledWith(
95 | 'ssh user@host "my-command -x"',
96 | { maxBuffer: 1024000 },
97 | expect.any(Function),
98 | )
99 | expect(exec).toHaveBeenCalledWith(
100 | 'ssh user@host "my-command2 -x"',
101 | { maxBuffer: 1024000 },
102 | expect.any(Function),
103 | )
104 | })
105 |
106 | it('should use key if present', async () => {
107 | connection = new Connection({
108 | remote: 'user@host',
109 | key: '/path/to/key',
110 | })
111 |
112 | await connection.run('my-command -x')
113 | expect(exec).toHaveBeenCalledWith(
114 | 'ssh -i /path/to/key user@host "my-command -x"',
115 | { maxBuffer: 1024000 },
116 | expect.any(Function),
117 | )
118 | })
119 |
120 | it('should use port if present', async () => {
121 | connection = new Connection({ remote: 'user@host:12345' })
122 | await connection.run('my-command -x')
123 | expect(exec).toHaveBeenCalledWith(
124 | 'ssh -p 12345 user@host "my-command -x"',
125 | { maxBuffer: 1024000 },
126 | expect.any(Function),
127 | )
128 | })
129 |
130 | it('should use StrictHostKeyChecking if present', async () => {
131 | connection = new Connection({
132 | remote: 'user@host',
133 | strict: 'no',
134 | })
135 | await connection.run('my-command -x')
136 | expect(exec).toHaveBeenCalledWith(
137 | 'ssh -o StrictHostKeyChecking=no user@host "my-command -x"',
138 | { maxBuffer: 1024000 },
139 | expect.any(Function),
140 | )
141 | })
142 |
143 | it('should use extra ssh options on remote if present', async () => {
144 | connection = new Connection({
145 | remote: {
146 | host: 'host',
147 | user: 'user',
148 | extraSshOptions: {
149 | ExtraOption: 'option',
150 | SshForwardAgent: 'forward',
151 | }
152 | },
153 | })
154 | await connection.run('my-command -x')
155 | expect(exec).toHaveBeenCalledWith(
156 | 'ssh -o ExtraOption=option -o SshForwardAgent=forward user@host "my-command -x"',
157 | { maxBuffer: 1024000 },
158 | expect.any(Function),
159 | )
160 | })
161 |
162 | it('should use port and key if both are present', async () => {
163 | connection = new Connection({
164 | remote: 'user@host:12345',
165 | key: '/path/to/key',
166 | })
167 | await connection.run('my-command -x')
168 | expect(exec).toHaveBeenCalledWith(
169 | 'ssh -p 12345 -i /path/to/key user@host "my-command -x"',
170 | { maxBuffer: 1024000 },
171 | expect.any(Function),
172 | )
173 | })
174 |
175 | it('should log output', async () => {
176 | const log = jest.fn()
177 | connection = new Connection({
178 | remote: 'user@host',
179 | log,
180 | stdout: process.stdout,
181 | stderr: process.stderr,
182 | })
183 |
184 | stdMocks.use()
185 | const result = await connection.run('my-command -x')
186 | result.child.stdout.push('first line\n')
187 | result.child.stdout.push(null)
188 |
189 | result.child.stderr.push('an error\n')
190 | result.child.stderr.push(null)
191 |
192 | const output = stdMocks.flush()
193 | stdMocks.restore()
194 | expect(log).toHaveBeenCalledWith(
195 | 'Running "%s" on host "%s".',
196 | 'my-command -x',
197 | 'host',
198 | )
199 |
200 | expect(output.stdout[0].toString()).toBe('@host first line\n')
201 | expect(output.stderr[0].toString()).toBe('@host-err an error\n')
202 | })
203 | })
204 |
205 | describe('#run asUser', () => {
206 | let connection
207 |
208 | beforeEach(() => {
209 | connection = new Connection({
210 | remote: 'user@host',
211 | asUser: 'test',
212 | })
213 | })
214 |
215 | it('should handle sudo as user correctly', async () => {
216 | await connection.run('my-command -x', { tty: true })
217 |
218 | expect(exec).toHaveBeenCalledWith(
219 | 'ssh -tt user@host "sudo -u test bash -c \'my-command -x\'"',
220 | {
221 | maxBuffer: 1000 * 1024,
222 | },
223 | expect.any(Function),
224 | )
225 | })
226 |
227 | it('should handle sudo as user without double sudo', () => {
228 | connection.run('sudo my-command -x', { tty: true })
229 |
230 | expect(exec).toHaveBeenCalledWith(
231 | 'ssh -tt user@host "sudo -u test bash -c \'my-command -x\'"',
232 | {
233 | maxBuffer: 1000 * 1024,
234 | },
235 | expect.any(Function),
236 | )
237 | })
238 | })
239 |
240 | describe('#copy', () => {
241 | let connection
242 |
243 | beforeEach(() => {
244 | connection = new Connection({ remote: 'user@host' })
245 | })
246 |
247 | it('should call cmd.spawn', async () => {
248 | await connection.copy('/src/dir', '/dest/dir')
249 | expect(exec).toHaveBeenCalledWith(
250 | 'rsync --archive --compress --rsh "ssh" /src/dir user@host:/dest/dir',
251 | { maxBuffer: 1024000 },
252 | expect.any(Function),
253 | )
254 | })
255 |
256 | it('should accept "ignores" option', async () => {
257 | await connection.copy('/src/dir', '/dest/dir', { ignores: ['a', 'b'] })
258 | expect(exec).toHaveBeenCalledWith(
259 | 'rsync --archive --compress --exclude "a" --exclude "b" --rsh "ssh" /src/dir user@host:/dest/dir',
260 | { maxBuffer: 1024000 },
261 | expect.any(Function),
262 | )
263 | })
264 |
265 | it('should accept "direction" option', async () => {
266 | await connection.copy('/src/dir', '/dest/dir', {
267 | direction: 'remoteToLocal',
268 | })
269 | expect(exec).toHaveBeenCalledWith(
270 | 'rsync --archive --compress --rsh "ssh" user@host:/src/dir /dest/dir',
271 | { maxBuffer: 1024000 },
272 | expect.any(Function),
273 | )
274 | })
275 |
276 | it('should accept "rsync" option', async () => {
277 | await connection.copy('/src/dir', '/dest/dir', {
278 | rsync: '--info=progress2',
279 | })
280 | expect(exec).toHaveBeenCalledWith(
281 | 'rsync --archive --compress --info=progress2 --rsh "ssh" /src/dir user@host:/dest/dir',
282 | { maxBuffer: 1024000 },
283 | expect.any(Function),
284 | )
285 | })
286 |
287 | describe('without rsync available', () => {
288 | beforeEach(() => {
289 | __setPaths__({})
290 | })
291 |
292 | it('should use tar+scp', async () => {
293 | const result = await connection.copy('/a/b/c', '/x/y/z')
294 | expect(exec.mock.calls[0]).toEqual([
295 | 'cd /a/b && tar -czf foo.tar.gz c',
296 | { maxBuffer: 1024000 },
297 | expect.any(Function),
298 | ])
299 | expect(exec.mock.calls[1]).toEqual([
300 | 'ssh user@host "mkdir -p /x/y/z"',
301 | { maxBuffer: 1024000 },
302 | expect.any(Function),
303 | ])
304 | expect(exec.mock.calls[2]).toEqual([
305 | 'cd /a/b && scp foo.tar.gz user@host:/x/y/z',
306 | { maxBuffer: 1024000 },
307 | expect.any(Function),
308 | ])
309 | expect(exec.mock.calls[3]).toEqual([
310 | 'cd /a/b && rm foo.tar.gz',
311 | { maxBuffer: 1024000 },
312 | expect.any(Function),
313 | ])
314 | expect(exec.mock.calls[4]).toEqual([
315 | 'ssh user@host "cd /x/y/z && tar -xzf foo.tar.gz"',
316 | { maxBuffer: 1024000 },
317 | expect.any(Function),
318 | ])
319 | expect(exec.mock.calls[5]).toEqual([
320 | 'ssh user@host "cd /x/y/z && rm foo.tar.gz"',
321 | { maxBuffer: 1024000 },
322 | expect.any(Function),
323 | ])
324 | expect(result.stdout.toString()).toBe('stdout'.repeat(6))
325 | expect(result.stderr.toString()).toBe('stderr'.repeat(6))
326 | expect(result.children.length).toBe(6)
327 | })
328 |
329 | it('should accept "direction" option when using tar+scp', async () => {
330 | const result = await connection.copy('/a/b/c', '/x/y/z', {
331 | direction: 'remoteToLocal',
332 | })
333 | expect(exec.mock.calls[0]).toEqual([
334 | 'ssh user@host "cd /a/b && tar -czf foo.tar.gz c"',
335 | { maxBuffer: 1024000 },
336 | expect.any(Function),
337 | ])
338 | expect(exec.mock.calls[1]).toEqual([
339 | 'mkdir -p /x/y/z',
340 | { maxBuffer: 1024000 },
341 | expect.any(Function),
342 | ])
343 | expect(exec.mock.calls[2]).toEqual([
344 | 'scp user@host:/a/b/foo.tar.gz /x/y/z',
345 | { maxBuffer: 1024000 },
346 | expect.any(Function),
347 | ])
348 | expect(exec.mock.calls[3]).toEqual([
349 | 'ssh user@host "cd /a/b && rm foo.tar.gz"',
350 | { maxBuffer: 1024000 },
351 | expect.any(Function),
352 | ])
353 | expect(exec.mock.calls[4]).toEqual([
354 | 'cd /x/y/z && tar -xzf foo.tar.gz',
355 | { maxBuffer: 1024000 },
356 | expect.any(Function),
357 | ])
358 | expect(exec.mock.calls[5]).toEqual([
359 | 'cd /x/y/z && rm foo.tar.gz',
360 | { maxBuffer: 1024000 },
361 | expect.any(Function),
362 | ])
363 | expect(result.stdout.toString()).toBe('stdout'.repeat(6))
364 | expect(result.stderr.toString()).toBe('stderr'.repeat(6))
365 | expect(result.children.length).toBe(6)
366 | })
367 |
368 | it('should accept port and key', async () => {
369 | connection = new Connection({
370 | remote: 'user@host:12345',
371 | key: '/path/to/key',
372 | })
373 | const result = await connection.copy('/a/b/c', '/x/y/z')
374 | expect(exec.mock.calls[0]).toEqual([
375 | 'cd /a/b && tar -czf foo.tar.gz c',
376 | { maxBuffer: 1024000 },
377 | expect.any(Function),
378 | ])
379 | expect(exec.mock.calls[1]).toEqual([
380 | 'ssh -p 12345 -i /path/to/key user@host "mkdir -p /x/y/z"',
381 | { maxBuffer: 1024000 },
382 | expect.any(Function),
383 | ])
384 | expect(exec.mock.calls[2]).toEqual([
385 | 'cd /a/b && scp -P 12345 -i /path/to/key foo.tar.gz user@host:/x/y/z',
386 | { maxBuffer: 1024000 },
387 | expect.any(Function),
388 | ])
389 | expect(exec.mock.calls[3]).toEqual([
390 | 'cd /a/b && rm foo.tar.gz',
391 | { maxBuffer: 1024000 },
392 | expect.any(Function),
393 | ])
394 | expect(exec.mock.calls[4]).toEqual([
395 | 'ssh -p 12345 -i /path/to/key user@host "cd /x/y/z && tar -xzf foo.tar.gz"',
396 | { maxBuffer: 1024000 },
397 | expect.any(Function),
398 | ])
399 | expect(exec.mock.calls[5]).toEqual([
400 | 'ssh -p 12345 -i /path/to/key user@host "cd /x/y/z && rm foo.tar.gz"',
401 | { maxBuffer: 1024000 },
402 | expect.any(Function),
403 | ])
404 | expect(result.stdout.toString()).toBe('stdout'.repeat(6))
405 | expect(result.stderr.toString()).toBe('stderr'.repeat(6))
406 | expect(result.children.length).toBe(6)
407 | })
408 | })
409 |
410 | it('should use key if present', async () => {
411 | connection = new Connection({
412 | remote: 'user@host',
413 | key: '/path/to/key',
414 | })
415 | await connection.copy('/src/dir', '/dest/dir')
416 | expect(exec).toHaveBeenCalledWith(
417 | 'rsync --archive --compress --rsh "ssh -i /path/to/key" /src/dir user@host:/dest/dir',
418 | { maxBuffer: 1024000 },
419 | expect.any(Function),
420 | )
421 | })
422 |
423 | it('should use port if present', async () => {
424 | connection = new Connection({
425 | remote: 'user@host:12345',
426 | })
427 | await connection.copy('/src/dir', '/dest/dir')
428 | expect(exec).toHaveBeenCalledWith(
429 | 'rsync --archive --compress --rsh "ssh -p 12345" /src/dir user@host:/dest/dir',
430 | { maxBuffer: 1024000 },
431 | expect.any(Function),
432 | )
433 | })
434 |
435 | it('should use StrictHostKeyChecking if present', async () => {
436 | connection = new Connection({
437 | remote: 'user@host',
438 | strict: 'yes',
439 | })
440 | await connection.copy('/src/dir', '/dest/dir')
441 | expect(exec).toHaveBeenCalledWith(
442 | 'rsync --archive --compress --rsh "ssh -o StrictHostKeyChecking=yes" /src/dir user@host:/dest/dir',
443 | { maxBuffer: 1024000 },
444 | expect.any(Function),
445 | )
446 | })
447 |
448 | it('should use port and key if both are present', async () => {
449 | connection = new Connection({
450 | remote: 'user@host:12345',
451 | key: '/path/to/key',
452 | })
453 | await connection.copy('/src/dir', '/dest/dir')
454 | expect(exec).toHaveBeenCalledWith(
455 | 'rsync --archive --compress --rsh "ssh -p 12345 -i /path/to/key" /src/dir user@host:/dest/dir',
456 | { maxBuffer: 1024000 },
457 | expect.any(Function),
458 | )
459 | })
460 |
461 | it('should log output', async () => {
462 | const log = jest.fn()
463 | connection = new Connection({
464 | remote: 'user@host',
465 | log,
466 | stdout: process.stdout,
467 | stderr: process.stderr,
468 | })
469 |
470 | stdMocks.use()
471 | const result = await connection.copy('/src/dir', '/dest/dir')
472 | result.child.stdout.push('first line\n')
473 | result.child.stdout.push(null)
474 |
475 | result.child.stderr.push('an error\n')
476 | result.child.stderr.push(null)
477 |
478 | const output = stdMocks.flush()
479 | expect(log).toHaveBeenCalledWith(
480 | 'Copy "%s" to "%s" via rsync',
481 | '/src/dir',
482 | 'user@host:/dest/dir',
483 | )
484 | expect(output.stdout.toString()).toContain('@host first line\n')
485 | expect(output.stderr.toString()).toContain('@host-err an error\n')
486 | })
487 | })
488 | })
489 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/ConnectionPool.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable func-names */
2 | import Connection from './Connection'
3 |
4 | class ConnectionPool {
5 | /**
6 | * Initialize a new `ConnectionPool` with `connections`.
7 | * All Connection options are also supported.
8 | *
9 | * @param {Connection|string[]} connections Connections
10 | * @param {object} [options] Options
11 | */
12 | constructor(connections, options) {
13 | this.connections = connections.map(connection => {
14 | if (connection instanceof Connection) return connection
15 | return new Connection({ remote: connection, ...options })
16 | })
17 | }
18 | }
19 |
20 | ;[
21 | 'run',
22 | 'copy',
23 | 'copyToRemote',
24 | 'copyFromRemote',
25 | 'scpCopyToRemote',
26 | 'scpCopyFromRemote',
27 | ].forEach(method => {
28 | ConnectionPool.prototype[method] = function(...args) {
29 | return Promise.all(
30 | this.connections.map(connection => connection[method](...args)),
31 | )
32 | }
33 | })
34 |
35 | export default ConnectionPool
36 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/ConnectionPool.test.js:
--------------------------------------------------------------------------------
1 | import { __setPaths__ } from 'which'
2 | import { exec } from 'child_process'
3 | import * as util from './util'
4 | import Connection from './Connection'
5 | import ConnectionPool from './ConnectionPool'
6 |
7 | jest.mock('which')
8 | jest.mock('child_process')
9 |
10 | describe('ConnectionPool', () => {
11 | beforeEach(() => {
12 | util.deprecateV3 = jest.fn()
13 | __setPaths__({ rsync: '/bin/rsync' })
14 | })
15 |
16 | describe('constructor', () => {
17 | it('should be possible to create a new ConnectionPool using shorthand syntax', () => {
18 | const pool = new ConnectionPool(['myserver', 'myserver2'])
19 | expect(pool.connections[0].remote).toEqual({
20 | user: 'deploy',
21 | host: 'myserver',
22 | })
23 |
24 | expect(pool.connections[1].remote).toEqual({
25 | user: 'deploy',
26 | host: 'myserver2',
27 | })
28 | })
29 |
30 | it('should be possible to create a new ConnectionPool with long syntax', () => {
31 | const connection1 = new Connection({ remote: 'myserver' })
32 | const connection2 = new Connection({ remote: 'myserver2' })
33 | const pool = new ConnectionPool([connection1, connection2])
34 | expect(pool.connections[0]).toBe(connection1)
35 | expect(pool.connections[1]).toBe(connection2)
36 | })
37 | })
38 |
39 | describe('#run', () => {
40 | let connection1
41 | let connection2
42 | let pool
43 |
44 | beforeEach(() => {
45 | connection1 = new Connection({ remote: 'myserver' })
46 | connection2 = new Connection({ remote: 'myserver2' })
47 | pool = new ConnectionPool([connection1, connection2])
48 | })
49 |
50 | it('should run command on each connection', async () => {
51 | const results = await pool.run('my-command -x', { cwd: '/root' })
52 | expect(results[0].stdout.toString()).toBe('stdout')
53 | expect(results[1].stdout.toString()).toBe('stdout')
54 | expect(exec).toHaveBeenCalledWith(
55 | 'ssh deploy@myserver2 "cd /root > /dev/null && my-command -x; cd - > /dev/null"',
56 | {
57 | maxBuffer: 1000 * 1024,
58 | },
59 | expect.any(Function),
60 | )
61 | expect(exec).toHaveBeenCalledWith(
62 | 'ssh deploy@myserver "cd /root > /dev/null && my-command -x; cd - > /dev/null"',
63 | {
64 | maxBuffer: 1000 * 1024,
65 | },
66 | expect.any(Function),
67 | )
68 | })
69 | })
70 |
71 | describe('#copy', () => {
72 | let connection1
73 | let connection2
74 | let pool
75 |
76 | beforeEach(() => {
77 | connection1 = new Connection({ remote: 'myserver' })
78 | connection2 = new Connection({ remote: 'myserver2' })
79 | pool = new ConnectionPool([connection1, connection2])
80 | })
81 |
82 | it('should run command on each connection', async () => {
83 | const results = await pool.copy('/src/dir', '/dest/dir')
84 | expect(results[0].stdout.toString()).toBe('stdout')
85 | expect(results[1].stdout.toString()).toBe('stdout')
86 |
87 | expect(exec).toHaveBeenCalledWith(
88 | 'rsync --archive --compress --rsh "ssh" /src/dir deploy@myserver:/dest/dir',
89 | { maxBuffer: 1024000 },
90 | expect.any(Function),
91 | )
92 |
93 | expect(exec).toHaveBeenCalledWith(
94 | 'rsync --archive --compress --rsh "ssh" /src/dir deploy@myserver2:/dest/dir',
95 | { maxBuffer: 1024000 },
96 | expect.any(Function),
97 | )
98 | })
99 | })
100 | })
101 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/cd.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { joinCommandArgs, requireArgs } from './util'
3 |
4 | const isWin = /^win/.test(process.platform)
5 |
6 | export function formatCdCommand({ folder }) {
7 | requireArgs(['folder'], { folder }, 'cd')
8 | const args = ['cd', folder]
9 | const { root } = path.parse(folder)
10 | const drive = root.replace(path.sep, '')
11 | if (isWin && root !== '/') {
12 | args.push(`&& ${drive}`)
13 | }
14 | return joinCommandArgs(args)
15 | }
16 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/cd.test.js:
--------------------------------------------------------------------------------
1 | import { formatCdCommand } from './cd'
2 |
3 | describe('mkdir', () => {
4 | describe('#formatCdCommand', () => {
5 | describe('without "folder"', () => {
6 | it('should throw an error', () => {
7 | expect(() => formatCdCommand({})).toThrow(
8 | '"folder" argument is required in "cd" command',
9 | )
10 | })
11 | })
12 |
13 | it('should format command', () => {
14 | expect(formatCdCommand({ folder: 'xxx' })).toBe('cd xxx')
15 | })
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/mkdir.js:
--------------------------------------------------------------------------------
1 | import { joinCommandArgs, requireArgs } from './util'
2 | import { formatRawCommand } from './raw'
3 |
4 | export function formatMkdirCommand({ asUser, folder }) {
5 | requireArgs(['folder'], { folder }, 'mkdir')
6 | return formatRawCommand({
7 | asUser,
8 | command: joinCommandArgs(['mkdir', '-p', folder]),
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/mkdir.test.js:
--------------------------------------------------------------------------------
1 | import { formatMkdirCommand } from './mkdir'
2 |
3 | describe('mkdir', () => {
4 | describe('#formatMkdirCommand', () => {
5 | describe('without "folder"', () => {
6 | it('should throw an error', () => {
7 | expect(() => formatMkdirCommand({})).toThrow(
8 | '"folder" argument is required in "mkdir" command',
9 | )
10 | })
11 | })
12 |
13 | it('should format command', () => {
14 | expect(formatMkdirCommand({ folder: 'xxx' })).toBe('mkdir -p xxx')
15 | })
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/raw.js:
--------------------------------------------------------------------------------
1 | import { joinCommandArgs } from './util'
2 | import { deprecateV3 } from '../util'
3 |
4 | const SUDO_REGEXP = /sudo\s/
5 |
6 | export function formatRawCommand({ asUser, command }) {
7 | let args = []
8 | if (asUser) args = [...args, 'sudo', '-u', asUser]
9 | // Deprecate
10 | if (asUser && command) {
11 | if (command.match(SUDO_REGEXP)) {
12 | deprecateV3(
13 | 'You should not use "sudo" and "asUser" options together. Please remove "sudo" from command.',
14 | )
15 | }
16 |
17 | const commandWithoutSudo = command.replace(SUDO_REGEXP, '');
18 | args = [...args, 'bash', '-c', `'${commandWithoutSudo}'`]
19 | } else if (command) args = [...args, command]
20 | return joinCommandArgs(args)
21 | }
22 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/raw.test.js:
--------------------------------------------------------------------------------
1 | import * as util from '../util'
2 | import { formatRawCommand } from './raw'
3 |
4 | describe('raw', () => {
5 | beforeEach(() => {
6 | util.deprecateV3 = jest.fn()
7 | })
8 |
9 | describe('#formatRawCommand', () => {
10 | it('should support command', () => {
11 | expect(formatRawCommand({ command: 'echo "ok"' })).toBe('echo "ok"')
12 | })
13 |
14 | it('should support asUser', () => {
15 | expect(formatRawCommand({ asUser: 'foo', command: 'echo "ok"' })).toBe(
16 | 'sudo -u foo bash -c \'echo "ok"\'',
17 | )
18 |
19 | expect(
20 | formatRawCommand({ asUser: 'foo', command: 'sudo echo "ok"' }),
21 | ).toBe('sudo -u foo bash -c \'echo "ok"\'')
22 | })
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/rm.js:
--------------------------------------------------------------------------------
1 | import { joinCommandArgs, requireArgs } from './util'
2 | import { formatRawCommand } from './raw'
3 |
4 | export function formatRmCommand({ asUser, file }) {
5 | requireArgs(['file'], { file }, 'rm')
6 | return formatRawCommand({
7 | asUser,
8 | command: joinCommandArgs(['rm', file]),
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/rm.test.js:
--------------------------------------------------------------------------------
1 | import { formatRmCommand } from './rm'
2 |
3 | describe('rm', () => {
4 | describe('#formatRmCommand', () => {
5 | describe('without "file"', () => {
6 | it('should throw an error', () => {
7 | expect(() => formatRmCommand({})).toThrow(
8 | '"file" argument is required in "rm" command',
9 | )
10 | })
11 | })
12 |
13 | it('should format command', () => {
14 | expect(formatRmCommand({ file: 'xxx' })).toBe('rm xxx')
15 | })
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/rsync.js:
--------------------------------------------------------------------------------
1 | import which from 'which'
2 | import { wrapCommand, joinCommandArgs, requireArgs } from './util'
3 |
4 | export async function isRsyncSupported() {
5 | return new Promise(resolve => which('rsync', err => resolve(!err)))
6 | }
7 |
8 | function formatExcludes(excludes) {
9 | return excludes.reduce(
10 | (args, current) => [...args, '--exclude', `"${current}"`],
11 | [],
12 | )
13 | }
14 |
15 | export function formatRsyncCommand({
16 | asUser,
17 | src,
18 | dest,
19 | excludes,
20 | additionalArgs,
21 | remoteShell,
22 | }) {
23 | requireArgs(['src', 'dest'], { src, dest }, 'rsync')
24 | let args = ['rsync', '--archive', '--compress']
25 | if (asUser) args = [...args, '--rsync-path', wrapCommand(`sudo -u ${asUser} rsync`)]
26 | if (additionalArgs) args = [...args, ...additionalArgs]
27 | if (excludes) args = [...args, ...formatExcludes(excludes)]
28 | if (remoteShell) args = [...args, '--rsh', wrapCommand(remoteShell)]
29 | args = [...args, src, dest]
30 | return joinCommandArgs(args)
31 | }
32 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/rsync.test.js:
--------------------------------------------------------------------------------
1 | import { formatRsyncCommand } from './rsync'
2 |
3 | describe('rsync', () => {
4 | describe('#formatRsyncCommand', () => {
5 | describe('without "src" or "dest"', () => {
6 | it('should throw an error', () => {
7 | expect(() => formatRsyncCommand({})).toThrow(
8 | '"src" argument is required in "rsync" command',
9 | )
10 | expect(() => formatRsyncCommand({ dest: 'foo' })).toThrow(
11 | '"src" argument is required in "rsync" command',
12 | )
13 | expect(() => formatRsyncCommand({ src: 'foo' })).toThrow(
14 | '"dest" argument is required in "rsync" command',
15 | )
16 | })
17 | })
18 |
19 | it('should support src and dest', () => {
20 | expect(formatRsyncCommand({ src: 'file.js', dest: 'foo/' })).toBe(
21 | 'rsync --archive --compress file.js foo/',
22 | )
23 | })
24 |
25 | it('should support additionalArgs', () => {
26 | expect(
27 | formatRsyncCommand({
28 | src: 'file.js',
29 | dest: 'foo/',
30 | additionalArgs: ['--max-size=10'],
31 | }),
32 | ).toBe('rsync --archive --compress --max-size=10 file.js foo/')
33 | })
34 |
35 | it('should support excludes', () => {
36 | expect(
37 | formatRsyncCommand({
38 | src: 'file.js',
39 | dest: 'foo/',
40 | excludes: ['foo'],
41 | }),
42 | ).toBe('rsync --archive --compress --exclude "foo" file.js foo/')
43 | })
44 |
45 | it('should support remoteShell', () => {
46 | expect(
47 | formatRsyncCommand({
48 | src: 'file.js',
49 | dest: 'foo/',
50 | remoteShell: 'ssh',
51 | }),
52 | ).toBe('rsync --archive --compress --rsh "ssh" file.js foo/')
53 | })
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/scp.js:
--------------------------------------------------------------------------------
1 | import { joinCommandArgs, requireArgs } from './util'
2 |
3 | export function formatScpCommand({ port, key, src, dest }) {
4 | requireArgs(['src', 'dest'], { src, dest }, 'scp')
5 | let args = ['scp']
6 | if (port) args = [...args, '-P', port]
7 | if (key) args = [...args, '-i', key]
8 | args = [...args, src, dest]
9 | return joinCommandArgs(args)
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/scp.test.js:
--------------------------------------------------------------------------------
1 | import { formatScpCommand } from './scp'
2 |
3 | describe('scp', () => {
4 | describe('#formatScpCommand', () => {
5 | describe('without "src" or "dest"', () => {
6 | it('should throw an error', () => {
7 | expect(() => formatScpCommand({})).toThrow(
8 | '"src" argument is required in "scp" command',
9 | )
10 | expect(() => formatScpCommand({ dest: 'foo' })).toThrow(
11 | '"src" argument is required in "scp" command',
12 | )
13 | expect(() => formatScpCommand({ src: 'foo' })).toThrow(
14 | '"dest" argument is required in "scp" command',
15 | )
16 | })
17 | })
18 |
19 | it('should support src and dest', () => {
20 | expect(formatScpCommand({ src: 'file.js', dest: 'foo/' })).toBe(
21 | 'scp file.js foo/',
22 | )
23 | })
24 |
25 | it('should support port', () => {
26 | expect(
27 | formatScpCommand({ src: 'file.js', dest: 'foo/', port: 3000 }),
28 | ).toBe('scp -P 3000 file.js foo/')
29 | })
30 |
31 | it('should support key', () => {
32 | expect(
33 | formatScpCommand({
34 | src: 'file.js',
35 | dest: 'foo/',
36 | key: 'foo',
37 | }),
38 | ).toBe('scp -i foo file.js foo/')
39 | })
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/ssh.js:
--------------------------------------------------------------------------------
1 | import { joinCommandArgs, wrapCommand } from './util'
2 |
3 | function wrapCwd(cwd, command) {
4 | return `cd ${cwd} > /dev/null && ${command}; cd - > /dev/null`
5 | }
6 |
7 | export function formatSshCommand({
8 | port,
9 | key,
10 | strict,
11 | tty,
12 | remote,
13 | cwd,
14 | command,
15 | extraSshOptions,
16 | verbosityLevel,
17 | }) {
18 | let args = ['ssh']
19 | if (verbosityLevel) {
20 | switch (verbosityLevel) {
21 | case verbosityLevel <= 0:
22 | break
23 | case 1:
24 | args = [...args, '-v']
25 | break
26 | case 2:
27 | args = [...args, '-vv']
28 | break
29 | default:
30 | args = [...args, '-vvv']
31 | break
32 | }
33 | }
34 | if (tty) args = [...args, '-tt']
35 | if (port) args = [...args, '-p', port]
36 | if (key) args = [...args, '-i', key]
37 | if(extraSshOptions && typeof extraSshOptions === 'object') {
38 | Object.keys(extraSshOptions).forEach((sshOptionsKey) => {
39 | args = [...args, '-o', `${sshOptionsKey}=${extraSshOptions[sshOptionsKey]}`]
40 | })
41 | }
42 | if (strict !== undefined)
43 | args = [...args, '-o', `StrictHostKeyChecking=${strict}`]
44 | if (remote) args = [...args, remote]
45 |
46 | const cwdCommand = cwd ? wrapCwd(cwd, command) : command
47 | if (command) args = [...args, wrapCommand(cwdCommand)]
48 | return joinCommandArgs(args)
49 | }
50 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/ssh.test.js:
--------------------------------------------------------------------------------
1 | import { formatSshCommand } from './ssh'
2 |
3 | describe('ssh', () => {
4 | describe('#formatSshCommand', () => {
5 | it('should support tty', () => {
6 | expect(formatSshCommand({ tty: true })).toBe('ssh -tt')
7 | })
8 |
9 | it('should support port', () => {
10 | expect(formatSshCommand({ port: 3000 })).toBe('ssh -p 3000')
11 | })
12 |
13 | it('should support key', () => {
14 | expect(formatSshCommand({ key: 'foo' })).toBe('ssh -i foo')
15 | })
16 |
17 | it('should support strict', () => {
18 | expect(formatSshCommand({ strict: true })).toBe(
19 | 'ssh -o StrictHostKeyChecking=true',
20 | )
21 | expect(formatSshCommand({ strict: false })).toBe(
22 | 'ssh -o StrictHostKeyChecking=false',
23 | )
24 | expect(formatSshCommand({ strict: 'no' })).toBe(
25 | 'ssh -o StrictHostKeyChecking=no',
26 | )
27 | expect(formatSshCommand({ strict: 'yes' })).toBe(
28 | 'ssh -o StrictHostKeyChecking=yes',
29 | )
30 | })
31 |
32 | it('should support extra ssh options', () => {
33 | expect(formatSshCommand({ extraSshOptions: {ExtraOption: 'test option', MoreOptions: 'more option'} })).toBe(
34 | 'ssh -o ExtraOption=test option -o MoreOptions=more option',
35 | )
36 | })
37 |
38 | it('should support remote', () => {
39 | expect(
40 | formatSshCommand({
41 | remote: 'user@host',
42 | }),
43 | ).toBe('ssh user@host')
44 | })
45 |
46 | it('should support command', () => {
47 | expect(
48 | formatSshCommand({
49 | remote: 'user@host',
50 | command: 'echo "ok"',
51 | }),
52 | ).toBe('ssh user@host "echo \\"ok\\""')
53 | })
54 |
55 | it('should support cwd', () => {
56 | expect(
57 | formatSshCommand({
58 | remote: 'user@host',
59 | command: 'echo "ok"',
60 | cwd: '/usr',
61 | }),
62 | ).toBe(
63 | 'ssh user@host "cd /usr > /dev/null && echo \\"ok\\"; cd - > /dev/null"',
64 | )
65 | })
66 |
67 | it('should support verbosityLevel', () => {
68 | expect(
69 | formatSshCommand({
70 | remote: 'user@host',
71 | command: 'echo "ok"',
72 | cwd: '/usr',
73 | verbosityLevel: 2,
74 | }),
75 | ).toBe(
76 | 'ssh -vv user@host "cd /usr > /dev/null && echo \\"ok\\"; cd - > /dev/null"',
77 | )
78 | })
79 | })
80 | })
81 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/tar.js:
--------------------------------------------------------------------------------
1 | import { joinCommandArgs, requireArgs } from './util'
2 |
3 | function formatExcludes(excludes) {
4 | return excludes.reduce(
5 | (args, current) => [...args, '--exclude', `"${current}"`],
6 | [],
7 | )
8 | }
9 |
10 | export function formatTarCommand({ file, archive, excludes, mode }) {
11 | let args = ['tar']
12 | switch (mode) {
13 | case 'compress': {
14 | requireArgs(['file', 'archive'], { file, archive }, 'tar')
15 | if (excludes) args = [...args, ...formatExcludes(excludes)]
16 | args = [...args, '-czf', archive, file]
17 | return joinCommandArgs(args)
18 | }
19 | case 'extract': {
20 | requireArgs(['archive'], { file, archive }, 'tar')
21 | args = [...args, '-xzf', archive]
22 | return joinCommandArgs(args)
23 | }
24 | default:
25 | throw new Error(
26 | `mode "${mode}" is not valid in "tar" command (valid values: ["extract", "compress"])`,
27 | )
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/tar.test.js:
--------------------------------------------------------------------------------
1 | import { formatTarCommand } from './tar'
2 |
3 | describe('tar', () => {
4 | describe('#formatTarCommand', () => {
5 | describe('mode: "compress", without "file" or "archive"', () => {
6 | it('should throw an error', () => {
7 | expect(() => formatTarCommand({ mode: 'compress' })).toThrow(
8 | '"file" argument is required in "tar" command',
9 | )
10 | expect(() =>
11 | formatTarCommand({ mode: 'compress', archive: 'foo' }),
12 | ).toThrow('"file" argument is required in "tar" command')
13 | expect(() =>
14 | formatTarCommand({ mode: 'compress', file: 'foo' }),
15 | ).toThrow('"archive" argument is required in "tar" command')
16 | })
17 | })
18 |
19 | describe('mode: "extract", without "archive"', () => {
20 | it('should throw an error', () => {
21 | expect(() => formatTarCommand({ mode: 'extract' })).toThrow(
22 | '"archive" argument is required in "tar" command',
23 | )
24 | })
25 | })
26 |
27 | describe('without a valid "mode"', () => {
28 | it('should throw an error', () => {
29 | expect(() => formatTarCommand({ file: 'foo', archive: 'foo' })).toThrow(
30 | 'mode "undefined" is not valid in "tar" command (valid values: ["extract", "compress"])',
31 | )
32 | expect(() =>
33 | formatTarCommand({ file: 'foo', archive: 'foo', mode: 'foo' }),
34 | ).toThrow(
35 | 'mode "foo" is not valid in "tar" command (valid values: ["extract", "compress"])',
36 | )
37 | })
38 | })
39 |
40 | it('should support compress mode', () => {
41 | expect(
42 | formatTarCommand({
43 | file: 'file',
44 | archive: 'file.tar.gz',
45 | mode: 'compress',
46 | }),
47 | ).toBe('tar -czf file.tar.gz file')
48 | })
49 |
50 | it('should support extract mode', () => {
51 | expect(
52 | formatTarCommand({
53 | file: 'file',
54 | archive: 'file.tar.gz',
55 | mode: 'extract',
56 | }),
57 | ).toBe('tar -xzf file.tar.gz')
58 | })
59 |
60 | it('should support "excludes"', () => {
61 | expect(
62 | formatTarCommand({
63 | file: 'file',
64 | archive: 'file.tar.gz',
65 | mode: 'compress',
66 | excludes: ['foo'],
67 | }),
68 | ).toBe('tar --exclude "foo" -czf file.tar.gz file')
69 | })
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/util.js:
--------------------------------------------------------------------------------
1 | export function escapeCommand(command) {
2 | return command.replace(/"/g, '\\"').replace(/\$/g, '\\$')
3 | }
4 |
5 | export function wrapCommand(command) {
6 | return `"${escapeCommand(command)}"`
7 | }
8 |
9 | export function joinCommandArgs(args) {
10 | return args.join(' ')
11 | }
12 |
13 | export function requireArgs(requiredArgs, args, command) {
14 | requiredArgs.forEach(required => {
15 | if (args[required] === undefined) {
16 | throw new Error(
17 | `"${required}" argument is required in "${command}" command`,
18 | )
19 | }
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/commands/util.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | escapeCommand,
3 | joinCommandArgs,
4 | wrapCommand,
5 | requireArgs,
6 | } from './util'
7 |
8 | describe('util', () => {
9 | describe('#escapeCommand', () => {
10 | it('should escape double quotes', () => {
11 | expect(escapeCommand('echo "ok"')).toBe('echo \\"ok\\"')
12 | })
13 |
14 | it('should escape $', () => {
15 | expect(escapeCommand('echo $FOO')).toBe('echo \\$FOO')
16 | })
17 | })
18 |
19 | describe('#wrapCommand', () => {
20 | it('should wrap command between double quotes', () => {
21 | expect(wrapCommand('echo "hello $USER"')).toBe(
22 | '"echo \\"hello \\$USER\\""',
23 | )
24 | })
25 | })
26 |
27 | describe('#joinCommandArgs', () => {
28 | it('should join command args', () => {
29 | expect(joinCommandArgs(['echo', '"foo"'])).toBe('echo "foo"')
30 | })
31 | })
32 |
33 | describe('#requireArgs', () => {
34 | it('should require some args', () => {
35 | expect(() => requireArgs(['foo'], { a: 'b' }, 'custom')).toThrow(
36 | '"foo" argument is required in "custom" command',
37 | )
38 | })
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/index.js:
--------------------------------------------------------------------------------
1 | import Connection from './Connection'
2 | import ConnectionPool from './ConnectionPool'
3 | import { exec } from './util'
4 | import { isRsyncSupported } from './commands/rsync'
5 |
6 | exports.Connection = Connection
7 | exports.ConnectionPool = ConnectionPool
8 | exports.exec = exec
9 | exports.isRsyncSupported = isRsyncSupported
10 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/remote.js:
--------------------------------------------------------------------------------
1 | import { deprecateV3 } from './util'
2 |
3 | export function parseRemote(remote) {
4 | if (remote && remote.host) return remote
5 | if (typeof remote !== 'string') throw new Error('A remote must be a string')
6 | if (remote === '') throw new Error('A remote cannot be an empty string')
7 |
8 | const matches = remote.match(/(([^@:]+)@)?([^@:]+)(:(.+))?/)
9 |
10 | if (matches) {
11 | const [, , user, host, , port] = matches
12 | const options = { user, host }
13 | if (port) options.port = Number(port)
14 | if (!user) {
15 | deprecateV3(
16 | 'Default user "deploy" is deprecated, please specify it explictly.',
17 | )
18 | options.user = 'deploy'
19 | }
20 | return options
21 | }
22 |
23 | return { user: 'deploy', host: remote }
24 | }
25 |
26 | export function formatRemote({ user, host }) {
27 | return `${user}@${host}`
28 | }
29 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/remote.test.js:
--------------------------------------------------------------------------------
1 | import * as util from './util'
2 | import { parseRemote, formatRemote } from './remote'
3 |
4 | describe('SSH remote', () => {
5 | beforeEach(() => {
6 | util.deprecateV3 = jest.fn()
7 | })
8 |
9 | describe('#parseRemote', () => {
10 | it('should return an error if not a string', () => {
11 | expect(() => {
12 | parseRemote({})
13 | }).toThrow('A remote must be a string')
14 | })
15 |
16 | it('should return an error if empty', () => {
17 | expect(() => {
18 | parseRemote('')
19 | }).toThrow('A remote cannot be an empty string')
20 | })
21 |
22 | it('should return remote if it has an host', () => {
23 | const objRemote = { host: 'foo' }
24 | expect(parseRemote(objRemote)).toBe(objRemote)
25 | })
26 |
27 | it('should use deploy as default user', () => {
28 | expect(parseRemote('host')).toEqual({
29 | host: 'host',
30 | user: 'deploy',
31 | })
32 | })
33 |
34 | it('should parseRemote remote without port', () => {
35 | expect(parseRemote('user@host')).toEqual({
36 | user: 'user',
37 | host: 'host',
38 | })
39 | })
40 |
41 | it('should parseRemote remote with port', () => {
42 | expect(parseRemote('user@host:300')).toEqual({
43 | user: 'user',
44 | host: 'host',
45 | port: 300,
46 | })
47 | })
48 | })
49 |
50 | describe('#format', () => {
51 | it('should format remote without port', () => {
52 | expect(formatRemote({ user: 'user', host: 'host' })).toBe('user@host')
53 | })
54 |
55 | it('should format remote with port', () => {
56 | expect(formatRemote({ user: 'user', host: 'host', port: 3000 })).toBe(
57 | 'user@host',
58 | )
59 | })
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/util.js:
--------------------------------------------------------------------------------
1 | import { exec as baseExec } from 'child_process'
2 |
3 | /* eslint-disable no-console */
4 | export const series = async tasks =>
5 | new Promise((resolve, reject) => {
6 | const tasksCopy = [...tasks]
7 | const next = results => {
8 | if (tasksCopy.length === 0) {
9 | resolve(results)
10 | return
11 | }
12 | const task = tasksCopy.shift()
13 | task()
14 | .then(result => next([...results, result]))
15 | .catch(reject)
16 | }
17 | next([])
18 | })
19 |
20 | const DEFAULT_CMD_OPTIONS = { maxBuffer: 1000 * 1024 }
21 |
22 | export const exec = async (cmd, options, childModifier) =>
23 | new Promise((resolve, reject) => {
24 | const child = baseExec(
25 | cmd,
26 | { ...DEFAULT_CMD_OPTIONS, ...options },
27 | (error, stdout, stderr) => {
28 | if (error) {
29 | /* eslint-disable no-param-reassign */
30 | error.stdout = stdout
31 | error.stderr = stderr
32 | error.child = child
33 | /* eslint-enable no-param-reassign */
34 | reject(error)
35 | } else {
36 | resolve({ child, stdout, stderr })
37 | }
38 | },
39 | )
40 |
41 | if (childModifier) childModifier(child)
42 | })
43 |
44 | export function deprecateV3(...args) {
45 | console.warn(...args, 'It will break in v3.0.0.')
46 | }
47 |
48 | export function deprecateV5(...args) {
49 | console.warn(...args, 'It will break in v5.0.0.')
50 | }
51 |
--------------------------------------------------------------------------------
/packages/ssh-pool/src/util.test.js:
--------------------------------------------------------------------------------
1 | import * as childProcess from 'child_process'
2 | import { series, exec } from './util'
3 |
4 | jest.mock('child_process')
5 |
6 | describe('util', () => {
7 | describe('#series', () => {
8 | it('should run tasks in series', async () => {
9 | const results = await series([async () => 'foo', async () => 'bar'])
10 | expect(results).toEqual(['foo', 'bar'])
11 | })
12 |
13 | it('should handle errors', async () => {
14 | expect.assertions(1)
15 | try {
16 | await series([
17 | async () => {
18 | throw new Error('bad')
19 | },
20 | async () => 'bar',
21 | ])
22 | } catch (error) {
23 | expect(error.message).toBe('bad')
24 | }
25 | })
26 | })
27 |
28 | describe('#exec', () => {
29 | it('should accept a childModifier', () => {
30 | const childModifier = jest.fn()
31 | exec('test', { foo: 'bar' }, childModifier)
32 | expect(childModifier).toBeCalled()
33 | })
34 |
35 | it('should return child, stdout and stderr', async () => {
36 | const result = await exec('test', { foo: 'bar' })
37 | expect(result.child).toBeDefined()
38 | expect(result.stdout).toBeDefined()
39 | expect(result.stderr).toBeDefined()
40 | })
41 |
42 | it('should return child, stdout and stderr', async () => {
43 | childProcess.exec = jest.fn((command, options, callback) => {
44 | setTimeout(() => callback(new Error('Oups'), 'stdout', 'stderr'))
45 | return 'child'
46 | })
47 | expect.assertions(4)
48 | try {
49 | await exec('test', { foo: 'bar' })
50 | } catch (error) {
51 | expect(error.child).toBeDefined()
52 | expect(error.stdout).toBeDefined()
53 | expect(error.stderr).toBeDefined()
54 | expect(error.message).toBe('Oups')
55 | }
56 | })
57 | })
58 | })
59 |
--------------------------------------------------------------------------------
/packages/ssh-pool/tests/__fixtures__/test.txt:
--------------------------------------------------------------------------------
1 | Hello
2 |
--------------------------------------------------------------------------------
/packages/ssh-pool/tests/integration.test.js:
--------------------------------------------------------------------------------
1 | import {resolve, basename} from 'path'
2 | import {copyFileSync, unlinkSync} from 'fs';
3 |
4 | const sshPool = require('../src')
5 |
6 | describe('ssh-pool', () => {
7 | let pool
8 |
9 | beforeEach(() => {
10 | pool = new sshPool.ConnectionPool(['deploy@test.shipitjs.com'], {
11 | key: resolve(__dirname, '../../../ssh/id_rsa'),
12 | })
13 | })
14 |
15 | it('should run a command remotely', async () => {
16 | const [{ stdout }] = await pool.run('hostname')
17 | expect(stdout).toBe('shipit-test\n')
18 | }, 10000)
19 |
20 | it('should escape command properly', async () => {
21 | const [{ stdout: first }] = await pool.run('echo $USER')
22 | expect(first).toBe('deploy\n')
23 |
24 | const [{ stdout: second }] = await pool.run("echo '$USER'")
25 | expect(second).toBe('$USER\n')
26 | }, 10000)
27 |
28 | it('should copy to remote', async () => {
29 | const time = (+new Date);
30 | const sourceFile = resolve(__dirname, '__fixtures__/test.txt')
31 | const targetFile = `${__dirname}/__fixtures__/test.${time}.txt`;
32 |
33 | copyFileSync(sourceFile, targetFile);
34 |
35 | try {
36 | await pool.scpCopyToRemote(targetFile, './',);
37 | const [{ stdout: first }] = await pool.run(`cd ./ && cat ${basename(targetFile)}`);
38 | expect(first).toBe('Hello\n')
39 | } finally {
40 | unlinkSync(targetFile);
41 | }
42 |
43 | }, 1e6)
44 | })
45 |
--------------------------------------------------------------------------------
/resources/shipit-logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shipitjs/shipit/5e9b5fb4396e75cbe43382781c0b06491a66ad3c/resources/shipit-logo-dark.png
--------------------------------------------------------------------------------
/resources/shipit-logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shipitjs/shipit/5e9b5fb4396e75cbe43382781c0b06491a66ad3c/resources/shipit-logo-light.png
--------------------------------------------------------------------------------
/resources/shipit-logo.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shipitjs/shipit/5e9b5fb4396e75cbe43382781c0b06491a66ad3c/resources/shipit-logo.sketch
--------------------------------------------------------------------------------
/ssh/id_rsa:
--------------------------------------------------------------------------------
1 | -----BEGIN RSA PRIVATE KEY-----
2 | MIIEpQIBAAKCAQEAvhm/0j4Hzm0wMZKRri3Nd+fmApLThXuyriR+CqiEONkXNNgt
3 | 7B6usy65EhAPpByo58vNrYeIkBAdzWrtt5j1blQiYfZYhKcGHrBhjbK8WNBnHuG5
4 | FepD04tZaaHjlzJ9J1zayKtU1AiZjl8pKmnU7n6Jo6sGhgYcPilUBmPfB0nFlvr0
5 | uc4xUrWg2Fl0h3Ww1JlNhnMfLK/wOjaSHLgNw6yiixyG1TJAcrlzNJ1pHXFExAHw
6 | 7j2nBcgwDUCNLgAnMWFN8FB6ibN6CFt7NCWtOLueAqU7FphwurBqCqcRH+gyOF23
7 | ZtmiupsyzCojTniZ0PpMWrDWgZ9aAqA+1BbfyQIDAQABAoIBAQCRCHQgotKx2vv5
8 | 1ijvCmLIKFSDgiF+pXEdCxpeZ1L5TCc4WfYvPvlqGyt3bGmCe5shvYud6Nl3j9Qs
9 | 9HeIq1oUYnwY4SmHiyZQI6FJyiOIXvdNyEi9P42fx6DfxnMs14hEj8Mbdhux6R2+
10 | UTvG8BdUHZZFGCZR+jdx9XX1qhxuIbiwzQG4979FjDnGAQ5AG5rEGXIDOTt7UuWl
11 | ZDLpUtiO6LD9tJt47oO6IHOX1u4uOGz1yYCkjyjdTL0oGaEHaiaVTJ10QsjPoGrL
12 | Wh+T7EXPxALClnhMfyjc4XfnnU5YSm3/yEeG2N6j4kcvw2/g/jm74ONulAEMVZDK
13 | fC+Y3NVBAoGBAO9lRDNEAw4317tBs/9L65E/yf32o/wSN8tRf/dgO89CJzvX2SFx
14 | w5Jw4ULo1QphRETJ3iWD7LL2lpY60VVijzCyg1hTjdD9BJQXgEnTgJI5qZyiJrt6
15 | rp0j+gyGy/XImbl9PnRdvaWdjqhczJdiNyJYxdnpxubJKrxmOXPt0ZqDAoGBAMtJ
16 | Mh1DCVqpGlt1va852caeIItMac8Ht8qCQ0zsyeUKUjOhny5uxKGC2NsOiNnqlzFi
17 | Txjr6igtuKInL9jYFOUhoC16MF5AsjQdO0+dslOflU9ptC6oKcdSLKIeFaYqsna2
18 | a6fiMf9CkEsZtGIz8ggBsbTMi1Iazr4NDphUObrDAoGBAMj7fsd/iQUt0ttubNyf
19 | 85SNNlsV71SYQulachHQZEY75s5yB+PxK91NEYFoEjvVr0gFJpDeciFJruFPXiHO
20 | TiL3LBhChaR4V5ixJk5U1/Nrn79VzyjE9cYNx0cvABtIH+8/e+icLrTVU0h8KHPL
21 | zDf0yZ6KiyeEqnFjbUar2bZbAoGBAK3XDlAPv7QT4EJOUcPDCQTcvJ/i3Kj6xKUc
22 | +EiURaLkTJ9ymxmuB+DGcIQDzevsvRayJ0n8lOV/E+E2+afKQTQgqUW6tBol4T7H
23 | sKzJAnKYiaq7jiZIEFIvZ5PLfl/3K15xaWbL/E15ssNGXAeOvG80Y69lK88utZW4
24 | vL5vaF7ZAoGAfoH1IMgYmZOK5M6/nSNtfEciPrEMBwEsQGXOhtdVf6Ul6OmScEU8
25 | BAOcwiWjUr1iS3Y9eYce6t4F3ZZf9zSuA5eaXuRkeJaOwFMwklNuezNGY1Y6ZVgH
26 | Syw/+CXag6aWwa+43qVFON1t5G5GXKvnI+W0FyTKleOENe3LSzAvrW4=
27 | -----END RSA PRIVATE KEY-----
28 |
--------------------------------------------------------------------------------
/ssh/id_rsa.pub:
--------------------------------------------------------------------------------
1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+Gb/SPgfObTAxkpGuLc135+YCktOFe7KuJH4KqIQ42Rc02C3sHq6zLrkSEA+kHKjny82th4iQEB3Nau23mPVuVCJh9liEpwYesGGNsrxY0Gce4bkV6kPTi1lpoeOXMn0nXNrIq1TUCJmOXykqadTufomjqwaGBhw+KVQGY98HScWW+vS5zjFStaDYWXSHdbDUmU2Gcx8sr/A6NpIcuA3DrKKLHIbVMkByuXM0nWkdcUTEAfDuPacFyDANQI0uACcxYU3wUHqJs3oIW3s0Ja04u54CpTsWmHC6sGoKpxEf6DI4Xbdm2aK6mzLMKiNOeJnQ+kxasNaBn1oCoD7UFt/J shipit-test
2 |
--------------------------------------------------------------------------------