├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── stale.yml └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.mdown ├── Rakefile ├── assets └── images │ └── wordmove.png ├── bin ├── bundle ├── bundler ├── byebug ├── coderay ├── console ├── htmldiff ├── kwalify ├── ldiff ├── pry ├── rake ├── rspec ├── rubocop ├── ruby-parse ├── ruby-rewrite ├── rumoji ├── setup ├── thor └── wordmove ├── deploy └── deploy.sh ├── exe └── wordmove ├── lib ├── wordmove.rb └── wordmove │ ├── assets │ ├── dump.php.erb │ ├── import.php.erb │ ├── wordmove_schema_global.yml │ ├── wordmove_schema_local.yml │ └── wordmove_schema_remote.yml │ ├── cli.rb │ ├── deployer │ ├── base.rb │ ├── ftp.rb │ ├── ssh.rb │ └── ssh │ │ ├── default_sql_adapter.rb │ │ └── wpcli_sql_adapter.rb │ ├── doctor.rb │ ├── doctor │ ├── movefile.rb │ ├── mysql.rb │ ├── rsync.rb │ ├── ssh.rb │ └── wpcli.rb │ ├── environments_list.rb │ ├── exceptions.rb │ ├── generators │ ├── movefile.rb │ ├── movefile.yml │ └── movefile_adapter.rb │ ├── guardian.rb │ ├── hook.rb │ ├── logger.rb │ ├── movefile.rb │ ├── sql_adapter │ ├── default.rb │ └── wpcli.rb │ ├── version.rb │ ├── wordpress_directory.rb │ └── wordpress_directory │ └── path.rb ├── pkg ├── wordmove-0.0.1.gem └── wordmove-0.0.2.gem ├── spec ├── cli_spec.rb ├── deployer │ └── base_spec.rb ├── docotor_spec.rb ├── doctor │ ├── movefile_spec.rb │ ├── mysql_spec.rb │ ├── rsync_spec.rb │ ├── ssh_spec.rb │ └── wpcli_spec.rb ├── environments_list_spec.rb ├── features │ └── movefile_spec.rb ├── fixtures │ ├── dump.sql │ ├── movefiles │ │ ├── Movefile │ │ ├── multi_environments │ │ ├── multi_environments_wpcli_sql_adapter │ │ ├── with_forbidden_tasks │ │ ├── with_hooks │ │ ├── with_secrets │ │ └── with_secrets_with_empty_local_db_password │ ├── wp-cli.yml │ └── wp-config.php ├── hook_spec.rb ├── logger │ └── logger_spec.rb ├── movefile_spec.rb ├── spec_helper.rb ├── sql_adapter │ ├── default_spec.rb │ └── wpcli_spec.rb └── support │ └── fixture_helpers.rb └── wordmove.gemspec /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | > A clear and concise description of what the bug is. 13 | 14 | **Wordmove command** 15 | 16 | > Command used on the CLI: (e.g.: `wordmove pull --all --no-db`) 17 | 18 | **Expected behavior** 19 | 20 | > A clear and concise description of what you expected to happen. 21 | 22 | **movefile.yml** 23 | 24 | > Paste (removing personal data) the interesting part, if any, of your `movefile.yml` formatting it inside a code block with `yml` syntax and double checking the indentation. 25 | 26 | **Exception/trace** 27 | 28 | > Paste (removing personal data) the entire trace of error/exception you encountered, if any 29 | 30 | **Environment (please complete the following information):** 31 | 32 | - OS: 33 | - Ruby: (`ruby --version`) 34 | - Wordmove: (`wordmove --version`) 35 | 36 | **Doctor** 37 | 38 | * [x] running the `wordmove doctor` command returns all green 39 | 40 | > (If it is not, report the error you got.) 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussion 4 | url: https://github.com/welaika/wordmove/discussions/new?category=general 5 | about: Start an open discussion and ask for help 6 | - name: Idea or feature request 7 | url: https://github.com/welaika/wordmove/discussions/new?category=ideas 8 | about: Propose your own ideas. We'll triage them and decide if bring them to the issue tracker 9 | - name: Help 10 | url: https://github.com/welaika/wordmove/discussions/new?category=q-a 11 | about: Ask for help if you're stuck with wordmove or integrating it with your workflow 12 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - 💡 Feature 8 | - 🐛 Bug 9 | - 🏥 Triage 10 | # Label to use when marking an issue as stale 11 | staleLabel: status:wontfix 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Tests 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: 2.6 27 | - name: Install dependencies 28 | run: bundle install 29 | - name: Run tests 30 | run: bundle exec rake 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | *.rbc 4 | .bundle 5 | .config 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | spec/coverage 17 | spec/examples.txt 18 | test/tmp 19 | test/version_tmp 20 | tmp 21 | .vs/ 22 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 3 | DisplayCopNames: true 4 | DisplayStyleGuide: true 5 | 6 | Exclude: 7 | - 'bin/*' 8 | 9 | Metrics/LineLength: 10 | Max: 100 11 | 12 | Metrics/MethodLength: 13 | Max: 20 14 | 15 | Metrics/ClassLength: 16 | Max: 200 17 | 18 | Metrics/BlockLength: 19 | Exclude: 20 | - 'spec/**/*_spec.rb' 21 | - 'spec/factories/*.rb' 22 | - '*.gemspec' 23 | 24 | Metrics/CyclomaticComplexity: 25 | Max: 10 26 | 27 | Metrics/PerceivedComplexity: 28 | Max: 10 29 | 30 | Style/StringLiterals: 31 | Enabled: false 32 | EnforcedStyle: double_quotes 33 | 34 | Style/Documentation: 35 | Enabled: false 36 | 37 | Style/FrozenStringLiteralComment: 38 | Enabled: false 39 | 40 | Metrics/AbcSize: 41 | Max: 40 42 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | wordmove 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.5 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Local File", 6 | "type": "Ruby", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "program": "${workspaceRoot}/main.rb" 10 | }, 11 | { 12 | "name": "Listen for rdebug-ide", 13 | "type": "Ruby", 14 | "request": "attach", 15 | "cwd": "${workspaceRoot}", 16 | "remoteHost": "127.0.0.1", 17 | "remotePort": "1234", 18 | "remoteWorkspaceRoot": "${workspaceRoot}" 19 | }, 20 | { 21 | "name": "Rails server", 22 | "type": "Ruby", 23 | "request": "launch", 24 | "cwd": "${workspaceRoot}", 25 | "program": "${workspaceRoot}/bin/rails", 26 | "args": [ 27 | "server" 28 | ] 29 | }, 30 | { 31 | "name": "RSpec - all", 32 | "type": "Ruby", 33 | "request": "launch", 34 | "cwd": "${workspaceRoot}", 35 | "program": "${workspaceRoot}/bin/rspec", 36 | "args": [ 37 | "-I", 38 | "${workspaceRoot}" 39 | ] 40 | }, 41 | { 42 | "name": "RSpec - active spec file only", 43 | "type": "Ruby", 44 | "request": "launch", 45 | "cwd": "${workspaceRoot}", 46 | "program": "${workspaceRoot}/bin/rspec", 47 | "args": [ 48 | "-I", 49 | "${workspaceRoot}", 50 | "${file}" 51 | ] 52 | }, 53 | { 54 | "name": "RSpec - active test only", 55 | "type": "Ruby", 56 | "request": "launch", 57 | "cwd": "${workspaceRoot}", 58 | "program": "${workspaceRoot}/bin/rspec", 59 | "args": [ 60 | "-I", 61 | "${workspaceRoot}", 62 | "${file}:${lineNumber}" 63 | ] 64 | }, 65 | { 66 | "name": "Cucumber", 67 | "type": "Ruby", 68 | "request": "launch", 69 | "cwd": "${workspaceRoot}", 70 | "program": "${workspaceRoot}/bin/cucumber" 71 | } 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | see https://github.com/welaika/wordmove/releases 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Welcome to the contributor guide. If you can't find important information you're welcome 2 | to edit this page or [open a discussion](https://github.com/welaika/wordmove/discussions/new?category=general) to talk with maintainers. 3 | 4 | In this guide you'll find informations about: 5 | * [Bug reporting](#bug-reporting) 6 | * [Development](#development) 7 | * [Maintainer tasks](#maintainer-tasks) 8 | 9 | ### Bug reporting 10 | 11 | Wordmove is an hard piece of software to debug and it is used by many users with many 12 | different environments - Windows also, even if it isn't officially supported by the dev team. 13 | 14 | So *please*, follow the issue template when reporting a bug. 15 | 16 | If you're not sure if you're standing in front of a bug, please [open a discussion](https://github.com/welaika/wordmove/discussions/new?category=general) 17 | labeling it as "Triage", possibly using this template to report your problem (note: GH's discussions does not support templates ATM): 18 | 19 | ```markdown 20 | **Describe the bug** 21 | 22 | > A clear and concise description of what the bug is. 23 | 24 | **Wordmove command** 25 | 26 | > Command used on the CLI: (e.g.: `wordmove pull --all --no-db`) 27 | 28 | **Expected behavior** 29 | 30 | > A clear and concise description of what you expected to happen. 31 | 32 | **movefile.yml** 33 | 34 | > Paste (removing personal data) the interesting part, if any, of your `movefile.yml` formatting it inside a code block with `yml` syntax and double checking the indentation. 35 | 36 | **Exception/trace** 37 | 38 | > Paste (removing personal data) the entire trace of error/exception you encountered, if any 39 | 40 | **Environment (please complete the following information):** 41 | 42 | - OS: 43 | - Ruby: (`ruby --version`) 44 | - Wordmove: (`wordmove --version`) 45 | 46 | **Doctor** 47 | 48 | * [x] running the `wordmove doctor` command returns all green 49 | 50 | > (If it is not, report the error you got.) 51 | ``` 52 | 53 | As a general advise: we tend to not support Wordmove's versions older than the latest stable. 54 | We'd appreciate your help opening an in depth report if you'd find that an older version is working 55 | better for you. 56 | 57 | Thank you all for your support and for the love <3 58 | 59 | ### Development 60 | 61 | #### Get Wordmove 62 | 63 | * fork wordmove 64 | * clone your own repo 65 | * be sure to check-out the right branch, usually `master` 66 | 67 | ##### Installing Ruby 68 | 69 | To install ruby, please, use [rbenv](https://github.com/rbenv/rbenv) or [RVM](https://rvm.io). 70 | 71 | ##### Contribute 72 | 73 | * run `bundle install` to install gem dependencies 74 | * `git checkout -b my_feature_or_fix_name` 75 | * code, commit, push and send a pull request on GitHub 76 | 77 | > Version bump is considered to be a maintainer's task, so please leave the version 78 | alone while working on your branch. 79 | 80 | 81 | ##### Test Wordmove 82 | 83 | Wordmove has a decent test coverage. We _require_ that pull requests does not break tests launched by the CI. 84 | In order to launch tests on you dev machine 85 | 86 | ```fish 87 | rake 88 | ``` 89 | 90 | The command will launch the test suite - written with RSpec - and rubocop. 91 | 92 | In order to use the gem locally you can install it 93 | 94 | ```fish 95 | rake install 96 | wordmove --version 97 | ``` 98 | 99 | or run the executable directly 100 | 101 | ```fish 102 | bin/wordmove --version 103 | ``` 104 | 105 | ### Maintainer tasks 106 | 107 | ToDo: 108 | 109 | * [ ] versioning and version dumping 110 | * [ ] changelog/release 111 | * [ ] publishing the gem 112 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in wordmove.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-2019 weLaika Soc. Coop. 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.mdown: -------------------------------------------------------------------------------- 1 | # Wordmove 2 | 3 | ![logo](https://raw.githubusercontent.com/welaika/wordmove/master/assets/images/wordmove.png) 4 | 5 | Wordmove is a command line tool that lets you automatically mirror local WordPress 6 | installations and DB data back and forth from your local development machine to 7 | one or more remote servers. 8 | 9 | Wordmove has also a neat [hook](https://github.com/welaika/wordmove/wiki/Hooks) system which enables you to run arbitrary commands 10 | before and after push/pull actions. Local and remote commands are both supported (remote 11 | ones only on SSH protocol). 12 | 13 | [FTP support development has been discontinued](https://github.com/welaika/wordmove/wiki/FTP-support-disclaimer), thus not all features are granted when using this protocol. 14 | 15 | [![Tests](https://github.com/welaika/wordmove/workflows/Tests/badge.svg)](https://github.com/welaika/wordmove/actions) 16 | [![Slack channel](https://img.shields.io/badge/Slack-WP--Hub-blue.svg)](https://wphub-auto-invitation.herokuapp.com/) 17 | [![Gem Version](https://badge.fury.io/rb/wordmove.svg)](https://rubygems.org/gems/wordmove) 18 | [![Docker Build Status](https://img.shields.io/docker/automated/welaika/wordmove.svg)](https://hub.docker.com/r/welaika/wordmove/) 19 | 20 | 21 | ## Installation 22 | 23 | Wordmove is developed in ruby and packaged and distributed as a gem. 24 | 25 | To install: 26 | 27 | gem install wordmove 28 | 29 | And to update: 30 | 31 | gem update wordmove 32 | 33 | You can read more about ruby gems ecosystem on the official site https://rubygems.org/. 34 | 35 | ## Peer dependencies 36 | 37 | Wordmove acts as automation glue between tools you already have and love. These are its peer dependencies which **you need to have installed** and executable through your system $PATH: 38 | 39 | | Program | Mandatory? | 40 | | --------- | -------------------------------- | 41 | | rsync | Yes for SSH protocol | 42 | | mysql | Yes | 43 | | mysqldump | Yes | 44 | | wp-cli | Yes by default, but configurable | 45 | | lftp | Yes, for FTP protocol | 46 | 47 | Wordmove also expect that the remote server will have the following commands: `gzip`, `nice`, `mysql`, `rsync`. All of these should be always present by default on any WordPress hosting. 48 | 49 | ## Usage 50 | 51 | ``` 52 | > wordmove help 53 | Commands: 54 | wordmove --version, -v # Print the version 55 | wordmove doctor # Do some local configuration and environment checks 56 | wordmove help [TASK] # Describe available tasks or one specific task 57 | wordmove init # Generates a brand new movefile.yml 58 | wordmove list # List all environments and vhosts 59 | wordmove pull # Pulls WP data from remote host to the local machine 60 | wordmove push # Pushes WP data from local machine to remote host 61 | ``` 62 | 63 | Move inside the WordPress folder and use `wordmove init` to generate a new `movefile.yml` and edit it with your settings. Read the next paragraph for more info. 64 | 65 | **See the wiki article: [Usage and flags explained](https://github.com/welaika/wordmove/wiki/Usage-and-flags-explained) for more info.** 66 | 67 | 68 | 69 | ### Multistage 70 | 71 | You can define multiple remote environments in your `movefile.yml`, such as production, staging, etc. Every first level key in the YAML other than the defaults and mandatory `global` and `local` will be interpreted as a remote environment. 72 | 73 | Use `-e` with `pull` or `push` to run the command on the specified environment. 74 | 75 | For example: `wordmove push -e staging -d` will push your local database to the staging environment. 76 | 77 | We warmly **recommend to read the wiki article**: [Multiple environments explained](https://github.com/welaika/wordmove/wiki/Multiple-environments-explained) 78 | 79 | ## movefile.yml 80 | 81 | You can configure Wordmove creating a `movefile.yml`. That's a YAML file with local and remote host(s) infos: 82 | 83 | ```yaml 84 | global: 85 | sql_adapter: wpcli 86 | 87 | local: 88 | vhost: http://vhost.local 89 | wordpress_path: /home/john/sites/your_site # use an absolute path here 90 | 91 | database: 92 | name: database_name 93 | user: user 94 | password: password 95 | host: localhost 96 | 97 | # paths: # you can customize wordpress internal paths 98 | # wp_content: wp-content 99 | # uploads: wp-content/uploads 100 | # plugins: wp-content/plugins 101 | # themes: wp-content/themes 102 | # languages: wp-content/languages 103 | 104 | production: 105 | vhost: http://example.com 106 | wordpress_path: /var/www/your_site # use an absolute path here 107 | 108 | database: 109 | name: database_name 110 | user: user 111 | password: password 112 | host: host 113 | # port: 3308 # Use just in case you have exotic server config 114 | # mysqldump_options: --max_allowed_packet=50MB # Only available if using SSH 115 | # mysql_options: --protocol=TCP # Only available if using SSH 116 | 117 | exclude: 118 | - '.git/' 119 | - '.gitignore' 120 | - 'node_modules/' 121 | - 'bin/' 122 | - 'tmp/*' 123 | - 'Gemfile*' 124 | - 'Movefile' 125 | - 'movefile' 126 | - 'movefile.yml' 127 | - 'movefile.yaml' 128 | - 'wp-config.php' 129 | - 'wp-content/*.sql.gz' 130 | - '*.orig' 131 | 132 | ssh: 133 | host: host 134 | user: user 135 | 136 | # hooks: # Remote hooks won't work with FTP 137 | # push: 138 | # before: 139 | # - command: 'echo "do something"' 140 | # where: local 141 | # raise: false # raise is true by default 142 | # after: 143 | # - command: 'echo "do something"' 144 | # where: remote 145 | # pull: 146 | # before: 147 | # - command: 'echo "do something"' 148 | # where: local 149 | # raise: false 150 | # after: 151 | # - command: 'echo "do something"' 152 | # where: remote 153 | ``` 154 | 155 | We warmly **recommend to read the wiki articles** 156 | 157 | * [Multiple environments explained](https://github.com/welaika/wordmove/wiki/Multiple-environments-explained) 158 | * [Movefile configurations explained](https://github.com/welaika/wordmove/wiki/movefile.yml-configurations-explained) 159 | 160 | to understand more about supported configurations. 161 | 162 | ## Environment Variables 163 | 164 | Wordmove allows the use of environment variables in your movefiles. 165 | This is useful in order to protect sensitive variables and credentials, as well as make it easy to share movefiles between your team. 166 | 167 | Environment variables are written using the **ERB tags** syntax: 168 | ``` 169 | "<%= ENV['YOUR_SECRET_NAME'] %>" 170 | ``` 171 | 172 | ### Variables set up 173 | 174 | Environment variables can be set up using two methods: 175 | 176 | #### Using the shell: 177 | ```bash 178 | # bash 179 | export PROD_DB_USER="username" PROD_DB_PASS="password" 180 | 181 | # fish 182 | set --export --global PROD_DB_USER "username"; set --export --global PROD_DB_PASS "password" 183 | ``` 184 | #### Using a `.env` file: 185 | 186 | Wordmove supports the [dotenv](https://github.com/bkeepers/dotenv) module. 187 | 188 | Simply create a `.env` file next to your movefile structured as follows: 189 | ```bash 190 | PROD_DB_USER="username" 191 | PROD_DB_PASS="password" 192 | ``` 193 | Wordmove will take care of loading the file and making the environment variables ready to be used in your configuration file. 194 | 195 | You may also use `.env.{environmentname}`, but this is discouraged. 196 | 197 | ### Use them in your `movefile.yml` 198 | Using the ERB syntax described above, write your movefile as follows: 199 | ```yaml 200 | production: 201 | database: 202 | user: "<%= ENV['PROD_DB_USER'] %>" 203 | password: "<%= ENV['PROD_DB_PASS'] %>" 204 | ``` 205 | 206 | ### System variables 207 | You can use system variables to configure your movefile. 208 | 209 | For example: 210 | ```yaml 211 | local: 212 | vhost: "http://wordpress-site.localhost" 213 | wordpress_path: "<%= ENV['HOME'] %>/[wordpress directory path]/" 214 | # wordpress_path will be substituted with /home/user_name/[wordpress directory path] 215 | ``` 216 | 217 | ## Supports 218 | 219 | ### OS 220 | 221 | OS X and Linux are fully supported. 222 | 223 | See the [Windows (un)support disclaimer](https://github.com/welaika/wordmove/wiki/Windows-(un)support-disclaimer) 224 | 225 | ### Docker 226 | 227 | We have a docker image bringing the latest Wordmove's version with autobuild on new releases. 228 | 229 | [![Docker Build Status](https://img.shields.io/docker/automated/welaika/wordmove.svg)](https://hub.docker.com/r/welaika/wordmove/) 230 | 231 | ### SSH 232 | 233 | * You need `rsync` on your machine; as far as we know it's already installed on OS X and Linux. 234 | * To use your SSH public key for authentication, just delete the `production.ssh.password` field in your `movefile.yml`. Easy peasy. 235 | * writing the password inside `movefile.yml` was and is somewhat supported, but **we discourage this practice** in favor of password-less authentication with pub key. Read [here](https://github.com/welaika/wordmove/wiki/%5Bdeprecated%5D-SSH-password-inside-Movefile) for old informations. 236 | 237 | ### FTP and SFTP 238 | 239 | * You need to install `lftp` on your machine. See community wiki article: [Install lftp on OSX yosemite](https://github.com/welaika/wordmove/wiki/Install-lftp-on-OSX-yosemite)). 240 | * Use the relative FTP path as `production.wordpress_path` 241 | * Use the absolute FTP path as `production.wordpress_absolute_path` (you may need to recover this from the `__FILE__` [magic constant](http://php.net/manual/en/language.constants.predefined.php) 242 | * if you want to specify a passive FTP connection add to the YAML config a `production.ftp.passive` flag and set it to `true`. 243 | 244 | FTP support development is [discontinued](https://github.com/welaika/wordmove/wiki/FTP-support-disclaimer), but it's always there. 245 | 246 | Sice version 3.2.0 SFTP is fully supported, with same functionalities as FTP, through `production.ftp.scheme` 247 | configuration. More information found in the wiki. 248 | 249 | ## Notes 250 | 251 | ### Mirroring 252 | 253 | Push and pull actions on files will perform a **mirror** operation. Please, keep 254 | in mind that to mirror means to transfer new/updated files **and remove files** 255 | from destination if not present in source. 256 | 257 | This means that if you have files/directories on your remotes which you must 258 | preserve, you **must exclude those in your movefile.yml**, or they will be 259 | deleted. 260 | 261 | ### How the heck you are able to sync the DB via FTP? 262 | 263 | We're glad you asked! We basically upload via FTP a PHP script that performs the various 264 | import/export operations. This script then gets executed via HTTP. Don't worry 265 | too much about security though: the script is deleted just after the usage, 266 | and can only be executed by `wordmove`, as each time it requires a pre-shared 267 | one-time-password to be run. 268 | 269 | ### Yanked versions 270 | 271 | Wordmove `1.3.1` has been removed from `rubygems` due to a bug with FTP deploying system. If you are 272 | using this version, please update soon (`gem update wordmove`). 273 | 274 | ## Need more tools? 275 | Visit [Wordpress Tools](https://www.wptools.it). 276 | 277 | ## Credits 278 | 279 | * The dump script is the [`MYSQL-dump` PHP package](https://github.com/dg/MySQL-dump) by David Grudl 280 | * The import script used is the [BigDump](http://www.ozerov.de/bigdump/) library 281 | 282 | ## Contribute 283 | 284 | Please, read the [contributor guide](https://github.com/welaika/wordmove/blob/master/CONTRIBUTING.md). 285 | 286 | Feel free to open a discussion issue about contribution if you need more info. 287 | 288 | ## Author 289 | 290 | made with ❤️ and ☕️ by [weLaika](https://dev.welaika.com) 291 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require 'rubocop/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | task default: :spec 7 | RuboCop::RakeTask.new(:rubocop) 8 | task default: :rubocop 9 | -------------------------------------------------------------------------------- /assets/images/wordmove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welaika/wordmove/bc9bce6ded43b01dfd44230c4a8e51b4a016ce33/assets/images/wordmove.png -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 || ">= 0.a" 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= begin 65 | env_var_version || cli_arg_version || 66 | lockfile_version || "#{Gem::Requirement.default}.a" 67 | end 68 | end 69 | 70 | def load_bundler! 71 | ENV["BUNDLE_GEMFILE"] ||= gemfile 72 | 73 | # must dup string for RG < 1.8 compatibility 74 | activate_bundler(bundler_version.dup) 75 | end 76 | 77 | def activate_bundler(bundler_version) 78 | if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new("2.0") 79 | bundler_version = "< 2" 80 | end 81 | gem_error = activation_error_handling do 82 | gem "bundler", bundler_version 83 | end 84 | return if gem_error.nil? 85 | require_error = activation_error_handling do 86 | require "bundler/version" 87 | end 88 | return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 89 | warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" 90 | exit 42 91 | end 92 | 93 | def activation_error_handling 94 | yield 95 | nil 96 | rescue StandardError, LoadError => e 97 | e 98 | end 99 | end 100 | 101 | m.load_bundler! 102 | 103 | if m.invoked_as_script? 104 | load Gem.bin_path("bundler", "bundle") 105 | end 106 | -------------------------------------------------------------------------------- /bin/bundler: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'bundler' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("bundler", "bundler") 18 | -------------------------------------------------------------------------------- /bin/byebug: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'byebug' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("byebug", "byebug") 30 | -------------------------------------------------------------------------------- /bin/coderay: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'coderay' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("coderay", "coderay") 30 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "wordmove" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "pry" 10 | Pry.start 11 | -------------------------------------------------------------------------------- /bin/htmldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'htmldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("diff-lcs", "htmldiff") 30 | -------------------------------------------------------------------------------- /bin/kwalify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'kwalify' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("kwalify", "kwalify") 30 | -------------------------------------------------------------------------------- /bin/ldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("diff-lcs", "ldiff") 30 | -------------------------------------------------------------------------------- /bin/pry: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pry' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("pry", "pry") 30 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rake", "rake") 30 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rubocop", "rubocop") 30 | -------------------------------------------------------------------------------- /bin/ruby-parse: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-parse' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("parser", "ruby-parse") 30 | -------------------------------------------------------------------------------- /bin/ruby-rewrite: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-rewrite' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("parser", "ruby-rewrite") 30 | -------------------------------------------------------------------------------- /bin/rumoji: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rumoji' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rumoji", "rumoji") 30 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /bin/thor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'thor' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("thor", "thor") 30 | -------------------------------------------------------------------------------- /bin/wordmove: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'wordmove' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("wordmove", "wordmove") 30 | -------------------------------------------------------------------------------- /deploy/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | curl -H "Content-Type: application/json" -X POST "$DOCKER_TRIGGER" 4 | -------------------------------------------------------------------------------- /exe/wordmove: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | 5 | require 'wordmove' 6 | Wordmove::CLI.start 7 | -------------------------------------------------------------------------------- /lib/wordmove.rb: -------------------------------------------------------------------------------- 1 | require 'English' 2 | 3 | require 'active_support' 4 | require 'active_support/core_ext' 5 | require 'colorize' 6 | require 'dotenv' 7 | require 'erb' 8 | require 'kwalify' 9 | require 'logger' 10 | require 'open-uri' 11 | require 'ostruct' 12 | require 'thor' 13 | require 'thor/group' 14 | require 'yaml' 15 | 16 | require 'photocopier' 17 | 18 | require 'wordmove/cli' 19 | require 'wordmove/doctor' 20 | require 'wordmove/doctor/movefile' 21 | require 'wordmove/doctor/mysql' 22 | require 'wordmove/doctor/rsync' 23 | require 'wordmove/doctor/ssh' 24 | require 'wordmove/doctor/wpcli' 25 | require 'wordmove/exceptions' 26 | require 'wordmove/guardian' 27 | require 'wordmove/hook' 28 | require 'wordmove/logger' 29 | require 'wordmove/movefile' 30 | require 'wordmove/sql_adapter/default' 31 | require 'wordmove/sql_adapter/wpcli' 32 | require 'wordmove/wordpress_directory' 33 | require "wordmove/version" 34 | require "wordmove/environments_list" 35 | 36 | require 'wordmove/generators/movefile_adapter' 37 | require 'wordmove/generators/movefile' 38 | 39 | require 'wordmove/deployer/base' 40 | require 'wordmove/deployer/ftp' 41 | require 'wordmove/deployer/ssh' 42 | require 'wordmove/deployer/ssh/default_sql_adapter' 43 | require 'wordmove/deployer/ssh/wpcli_sql_adapter' 44 | 45 | module Wordmove 46 | end 47 | -------------------------------------------------------------------------------- /lib/wordmove/assets/dump.php.erb: -------------------------------------------------------------------------------- 1 | '; 4 | if ($_GET['shared_key'] != $shared_key) { 5 | die(); 6 | } 7 | 8 | /** 9 | * MySQL database dump. 10 | * 11 | * @author David Grudl (http://davidgrudl.com) 12 | * @copyright Copyright (c) 2008 David Grudl 13 | * @license New BSD License 14 | * @version 1.0 15 | */ 16 | class MySQLDump 17 | { 18 | const MAX_SQL_SIZE = 1e6; 19 | 20 | const NONE = 0; 21 | const DROP = 1; 22 | const CREATE = 2; 23 | const DATA = 4; 24 | const TRIGGERS = 8; 25 | const ALL = 15; // DROP | CREATE | DATA | TRIGGERS 26 | 27 | /** @var array */ 28 | public $tables = array( 29 | '*' => self::ALL, 30 | ); 31 | 32 | /** @var mysqli */ 33 | private $connection; 34 | 35 | 36 | /** 37 | * Connects to database. 38 | * @param mysqli connection 39 | */ 40 | public function __construct(mysqli $connection, $charset = 'utf8') 41 | { 42 | $this->connection = $connection; 43 | 44 | if ($connection->connect_errno) { 45 | throw new Exception($connection->connect_error); 46 | 47 | } elseif (!$connection->set_charset($charset)) { // was added in MySQL 5.0.7 and PHP 5.0.5, fixed in PHP 5.1.5) 48 | throw new Exception($connection->error); 49 | } 50 | } 51 | 52 | 53 | /** 54 | * Saves dump to the file. 55 | * @param string filename 56 | * @return void 57 | */ 58 | public function save($file) 59 | { 60 | $handle = strcasecmp(substr($file, -3), '.gz') ? fopen($file, 'wb') : gzopen($file, 'wb'); 61 | if (!$handle) { 62 | throw new Exception("ERROR: Cannot write file '$file'."); 63 | } 64 | $this->write($handle); 65 | } 66 | 67 | 68 | /** 69 | * Writes dump to logical file. 70 | * @param resource 71 | * @return void 72 | */ 73 | public function write($handle = NULL) 74 | { 75 | if ($handle === NULL) { 76 | $handle = fopen('php://output', 'wb'); 77 | } elseif (!is_resource($handle) || get_resource_type($handle) !== 'stream') { 78 | throw new Exception('Argument must be stream resource.'); 79 | } 80 | 81 | $tables = $views = array(); 82 | 83 | $res = $this->connection->query('SHOW FULL TABLES'); 84 | while ($row = $res->fetch_row()) { 85 | if ($row[1] === 'VIEW') { 86 | $views[] = $row[0]; 87 | } else { 88 | $tables[] = $row[0]; 89 | } 90 | } 91 | $res->close(); 92 | 93 | $tables = array_merge($tables, $views); // views must be last 94 | 95 | $this->connection->query('LOCK TABLES `' . implode('` READ, `', $tables) . '` READ'); 96 | 97 | $db = $this->connection->query('SELECT DATABASE()')->fetch_row(); 98 | fwrite($handle, "-- Created at " . @date('j.n.Y G:i') . " using David Grudl MySQL Dump Utility\n" 99 | . (isset($_SERVER['HTTP_HOST']) ? "-- Host: $_SERVER[HTTP_HOST]\n" : '') 100 | . "-- MySQL Server: " . $this->connection->server_info . "\n" 101 | . "-- Database: " . $db[0] . "\n" 102 | . "\n" 103 | . "SET NAMES utf8;\n" 104 | . "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n" 105 | . "SET FOREIGN_KEY_CHECKS=0;\n" 106 | ); 107 | 108 | foreach ($tables as $table) { 109 | $this->dumpTable($handle, $table); 110 | } 111 | 112 | fwrite($handle, "-- THE END\n"); 113 | 114 | $this->connection->query('UNLOCK TABLES'); 115 | } 116 | 117 | 118 | /** 119 | * Dumps table to logical file. 120 | * @param resource 121 | * @return void 122 | */ 123 | public function dumpTable($handle, $table) 124 | { 125 | $delTable = $this->delimite($table); 126 | $res = $this->connection->query("SHOW CREATE TABLE $delTable"); 127 | $row = $res->fetch_assoc(); 128 | $res->close(); 129 | 130 | fwrite($handle, "-- --------------------------------------------------------\n\n"); 131 | 132 | $mode = isset($this->tables[$table]) ? $this->tables[$table] : $this->tables['*']; 133 | $view = isset($row['Create View']); 134 | 135 | if ($mode & self::DROP) { 136 | fwrite($handle, 'DROP ' . ($view ? 'VIEW' : 'TABLE') . " IF EXISTS $delTable;\n\n"); 137 | } 138 | 139 | if ($mode & self::CREATE) { 140 | fwrite($handle, $row[$view ? 'Create View' : 'Create Table'] . ";\n\n"); 141 | } 142 | 143 | if (!$view && ($mode & self::DATA)) { 144 | $numeric = array(); 145 | $res = $this->connection->query("SHOW COLUMNS FROM $delTable"); 146 | $cols = array(); 147 | while ($row = $res->fetch_assoc()) { 148 | $col = $row['Field']; 149 | $cols[] = $this->delimite($col); 150 | $numeric[$col] = (bool) preg_match('#^[^(]*(BYTE|COUNTER|SERIAL|INT|LONG$|CURRENCY|REAL|MONEY|FLOAT|DOUBLE|DECIMAL|NUMERIC|NUMBER)#i', $row['Type']); 151 | } 152 | $cols = '(' . implode(', ', $cols) . ')'; 153 | $res->close(); 154 | 155 | 156 | $size = 0; 157 | $res = $this->connection->query("SELECT * FROM $delTable", MYSQLI_USE_RESULT); 158 | while ($row = $res->fetch_assoc()) { 159 | $s = '('; 160 | foreach ($row as $key => $value) { 161 | if ($value === NULL) { 162 | $s .= "NULL,\t"; 163 | } elseif ($numeric[$key]) { 164 | $s .= $value . ",\t"; 165 | } else { 166 | $s .= "'" . $this->connection->real_escape_string($value) . "',\t"; 167 | } 168 | } 169 | 170 | if ($size == 0) { 171 | $s = "INSERT INTO $delTable $cols VALUES\n$s"; 172 | } else { 173 | $s = ",\n$s"; 174 | } 175 | 176 | $len = strlen($s) - 1; 177 | $s[$len - 1] = ')'; 178 | fwrite($handle, $s, $len); 179 | 180 | $size += $len; 181 | if ($size > self::MAX_SQL_SIZE) { 182 | fwrite($handle, ";\n"); 183 | $size = 0; 184 | } 185 | } 186 | 187 | $res->close(); 188 | if ($size) { 189 | fwrite($handle, ";\n"); 190 | } 191 | fwrite($handle, "\n"); 192 | } 193 | 194 | if ($mode & self::TRIGGERS) { 195 | $res = $this->connection->query("SHOW TRIGGERS LIKE '" . $this->connection->real_escape_string($table) . "'"); 196 | if ($res->num_rows) { 197 | fwrite($handle, "DELIMITER ;;\n\n"); 198 | while ($row = $res->fetch_assoc()) { 199 | fwrite($handle, "CREATE TRIGGER {$this->delimite($row['Trigger'])} $row[Timing] $row[Event] ON $delTable FOR EACH ROW\n$row[Statement];;\n\n"); 200 | } 201 | fwrite($handle, "DELIMITER ;\n\n"); 202 | } 203 | $res->close(); 204 | } 205 | 206 | fwrite($handle, "\n"); 207 | } 208 | 209 | 210 | private function delimite($s) 211 | { 212 | return '`' . str_replace('`', '``', $s) . '`'; 213 | } 214 | 215 | } 216 | 217 | $db_host = '<%= escape_php db[:host] %>'; 218 | $db_port = '<%= db[:port] %>'; 219 | if (!$db_port) { 220 | $db_port = ini_get("mysqli.default_port"); 221 | } 222 | $db_user = '<%= escape_php db[:user] %>'; 223 | $db_password = '<%= escape_php db[:password] %>'; 224 | $db_name = '<%= escape_php db[:name] %>'; 225 | 226 | $connection = new mysqli($db_host, $db_user, $db_password, $db_name, $db_port); 227 | $dump = new MySQLDump($connection); 228 | $db_file = 'dump.mysql'; 229 | $dump->save($db_file); 230 | readfile($db_file); 231 | -------------------------------------------------------------------------------- /lib/wordmove/assets/wordmove_schema_global.yml: -------------------------------------------------------------------------------- 1 | type: map 2 | mapping: 3 | sql_adapter: 4 | required: true 5 | -------------------------------------------------------------------------------- /lib/wordmove/assets/wordmove_schema_local.yml: -------------------------------------------------------------------------------- 1 | type: map 2 | mapping: 3 | vhost: 4 | pattern: /^https?:\/\// 5 | wordpress_path: 6 | database: 7 | type: map 8 | required: true 9 | mapping: 10 | name: 11 | required: true 12 | user: 13 | required: true 14 | password: 15 | required: true 16 | host: 17 | required: true 18 | mysqldump_options: 19 | port: 20 | charset: 21 | paths: 22 | type: map 23 | mapping: 24 | wp_content: 25 | wp_config: 26 | uploads: 27 | plugins: 28 | mu_plugins: 29 | themes: 30 | languages: 31 | -------------------------------------------------------------------------------- /lib/wordmove/assets/wordmove_schema_remote.yml: -------------------------------------------------------------------------------- 1 | type: map 2 | mapping: 3 | vhost: 4 | type: str 5 | pattern: /^https?:\/\// 6 | required: true 7 | wordpress_path: 8 | type: str 9 | required: true 10 | wordpress_absolute_path: 11 | type: str 12 | database: 13 | type: map 14 | required: true 15 | mapping: 16 | name: 17 | type: str 18 | required: true 19 | user: 20 | type: str 21 | required: true 22 | password: 23 | type: str 24 | required: true 25 | host: 26 | type: str 27 | required: true 28 | mysqldump_options: 29 | type: str 30 | port: 31 | type: int 32 | charset: 33 | type: str 34 | exclude: 35 | type: seq 36 | sequence: 37 | - type: str 38 | paths: 39 | type: map 40 | mapping: 41 | wp_content: 42 | wp_config: 43 | uploads: 44 | plugins: 45 | mu_plugins: 46 | themes: 47 | languages: 48 | ssh: 49 | type: map 50 | mapping: 51 | host: 52 | required: true 53 | user: 54 | required: false # If host is configured in ~/.ssh/config 55 | password: 56 | port: 57 | type: int 58 | rsync_options: 59 | gateway: 60 | type: map 61 | mapping: 62 | host: 63 | required: true 64 | user: 65 | required: true 66 | password: 67 | ftp: 68 | type: map 69 | mapping: 70 | user: 71 | required: true 72 | password: 73 | required: true 74 | host: 75 | required: true 76 | passive: 77 | type: bool 78 | scheme: 79 | type: str 80 | pattern: /\Aftp\Z|\Aftps\Z|\Asftp\Z/ 81 | port: 82 | type: int 83 | hooks: 84 | type: map 85 | mapping: 86 | push: 87 | type: map 88 | mapping: 89 | before: 90 | type: seq 91 | sequence: 92 | - type: map 93 | mapping: 94 | command: 95 | type: str 96 | required: true 97 | where: 98 | type: str 99 | required: true 100 | pattern: /\Alocal\Z|\Aremote\Z/ 101 | raise: 102 | type: bool 103 | after: 104 | type: seq 105 | sequence: 106 | - type: map 107 | mapping: 108 | command: 109 | type: str 110 | required: true 111 | where: 112 | type: str 113 | required: true 114 | pattern: /\Alocal\Z|\Aremote\Z/ 115 | raise: 116 | type: bool 117 | pull: 118 | type: map 119 | mapping: 120 | before: 121 | type: seq 122 | sequence: 123 | - type: map 124 | mapping: 125 | command: 126 | type: str 127 | required: true 128 | where: 129 | type: str 130 | required: true 131 | pattern: /\Alocal\Z|\Aremote\Z/ 132 | raise: 133 | type: bool 134 | after: 135 | type: seq 136 | sequence: 137 | - type: map 138 | mapping: 139 | command: 140 | type: str 141 | required: true 142 | where: 143 | type: str 144 | required: true 145 | pattern: /\Alocal\Z|\Aremote\Z/ 146 | raise: 147 | type: bool 148 | 149 | forbid: 150 | type: map 151 | mapping: 152 | pull: 153 | type: map 154 | mapping: 155 | db: 156 | type: bool 157 | plugins: 158 | type: bool 159 | themes: 160 | type: bool 161 | languages: 162 | type: bool 163 | uploads: 164 | type: bool 165 | mu_plugins: 166 | type: bool 167 | push: 168 | type: map 169 | mapping: 170 | db: 171 | type: bool 172 | plugins: 173 | type: bool 174 | themes: 175 | type: bool 176 | languages: 177 | type: bool 178 | uploads: 179 | type: bool 180 | mu_plugins: 181 | type: bool 182 | -------------------------------------------------------------------------------- /lib/wordmove/cli.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class CLI < Thor 3 | map %w[--version -v] => :__print_version 4 | 5 | desc "--version, -v", "Print the version" 6 | def __print_version 7 | puts Wordmove::VERSION 8 | end 9 | 10 | desc "init", "Generates a brand new movefile.yml" 11 | def init 12 | Wordmove::Generators::Movefile.start 13 | end 14 | 15 | desc "doctor", "Do some local configuration and environment checks" 16 | def doctor 17 | Wordmove::Doctor.start 18 | end 19 | 20 | shared_options = { 21 | wordpress: { aliases: "-w", type: :boolean }, 22 | uploads: { aliases: "-u", type: :boolean }, 23 | themes: { aliases: "-t", type: :boolean }, 24 | plugins: { aliases: "-p", type: :boolean }, 25 | mu_plugins: { aliases: "-m", type: :boolean }, 26 | languages: { aliases: "-l", type: :boolean }, 27 | db: { aliases: "-d", type: :boolean }, 28 | verbose: { aliases: "-v", type: :boolean }, 29 | simulate: { aliases: "-s", type: :boolean }, 30 | environment: { aliases: "-e" }, 31 | config: { aliases: "-c" }, 32 | debug: { type: :boolean }, 33 | no_adapt: { type: :boolean }, 34 | all: { type: :boolean } 35 | } 36 | 37 | no_tasks do 38 | def handle_options(options) 39 | wordpress_options.each do |task| 40 | yield task if options[task] || (options["all"] && options[task] != false) 41 | end 42 | end 43 | 44 | def wordpress_options 45 | %w[wordpress uploads themes plugins mu_plugins languages db] 46 | end 47 | 48 | def ensure_wordpress_options_presence!(options) 49 | return if (options.keys & (wordpress_options + ["all"])).present? 50 | 51 | puts "No options given. See wordmove --help" 52 | exit 1 53 | end 54 | 55 | def logger 56 | Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } 57 | end 58 | end 59 | 60 | desc "list", "List all environments and vhosts" 61 | shared_options.each do |option, args| 62 | method_option option, args 63 | end 64 | def list 65 | Wordmove::EnvironmentsList.print(options) 66 | rescue Wordmove::MovefileNotFound => e 67 | logger.error(e.message) 68 | exit 1 69 | rescue Psych::SyntaxError => e 70 | logger.error("Your movefile is not parsable due to a syntax error: #{e.message}") 71 | exit 1 72 | end 73 | 74 | desc "pull", "Pulls WP data from remote host to the local machine" 75 | shared_options.each do |option, args| 76 | method_option option, args 77 | end 78 | def pull 79 | ensure_wordpress_options_presence!(options) 80 | begin 81 | deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys) 82 | rescue MovefileNotFound => e 83 | logger.error(e.message) 84 | exit 1 85 | end 86 | 87 | Wordmove::Hook.run(:pull, :before, options) 88 | 89 | guardian = Wordmove::Guardian.new(options: options, action: :pull) 90 | 91 | handle_options(options) do |task| 92 | deployer.send("pull_#{task}") if guardian.allows(task.to_sym) 93 | end 94 | 95 | Wordmove::Hook.run(:pull, :after, options) 96 | end 97 | 98 | desc "push", "Pushes WP data from local machine to remote host" 99 | shared_options.each do |option, args| 100 | method_option option, args 101 | end 102 | def push 103 | ensure_wordpress_options_presence!(options) 104 | begin 105 | deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys) 106 | rescue MovefileNotFound => e 107 | logger.error(e.message) 108 | exit 1 109 | end 110 | 111 | Wordmove::Hook.run(:push, :before, options) 112 | 113 | guardian = Wordmove::Guardian.new(options: options, action: :push) 114 | 115 | handle_options(options) do |task| 116 | deployer.send("push_#{task}") if guardian.allows(task.to_sym) 117 | end 118 | 119 | Wordmove::Hook.run(:push, :after, options) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/wordmove/deployer/base.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module Deployer 3 | class Base 4 | attr_reader :options 5 | attr_reader :logger 6 | attr_reader :environment 7 | 8 | class << self 9 | def deployer_for(cli_options) 10 | movefile = Wordmove::Movefile.new(cli_options[:config]) 11 | movefile.load_dotenv(cli_options) 12 | 13 | options = movefile.fetch.merge! cli_options 14 | environment = movefile.environment(cli_options) 15 | 16 | return FTP.new(environment, options) if options[environment][:ftp] 17 | 18 | if options[environment][:ssh] && options[:global][:sql_adapter] == 'wpcli' 19 | return Ssh::WpcliSqlAdapter.new(environment, options) 20 | end 21 | 22 | if options[environment][:ssh] && options[:global][:sql_adapter] == 'default' 23 | return Ssh::DefaultSqlAdapter.new(environment, options) 24 | end 25 | 26 | raise NoAdapterFound, "No valid adapter found." 27 | end 28 | 29 | def current_dir 30 | '.' 31 | end 32 | 33 | def logger(secrets) 34 | Logger.new(STDOUT, secrets).tap { |l| l.level = Logger::DEBUG } 35 | end 36 | end 37 | 38 | def initialize(environment, options = {}) 39 | @environment = environment.to_sym 40 | @options = options 41 | 42 | movefile_secrets = Wordmove::Movefile.new(options[:config]).secrets 43 | @logger = self.class.logger(movefile_secrets) 44 | end 45 | 46 | def push_db 47 | logger.task "Pushing Database" 48 | end 49 | 50 | def pull_db 51 | logger.task "Pulling Database" 52 | end 53 | 54 | def remote_get_directory; end 55 | 56 | def remote_put_directory; end 57 | 58 | def exclude_dir_contents(path) 59 | "#{path}/*" 60 | end 61 | 62 | def push_wordpress 63 | logger.task "Pushing wordpress core" 64 | 65 | local_path = local_options[:wordpress_path] 66 | remote_path = remote_options[:wordpress_path] 67 | exclude_wp_content = exclude_dir_contents(local_wp_content_dir.relative_path) 68 | exclude_paths = paths_to_exclude.push(exclude_wp_content) 69 | 70 | remote_put_directory(local_path, remote_path, exclude_paths) 71 | end 72 | 73 | def pull_wordpress 74 | logger.task "Pulling wordpress core" 75 | 76 | local_path = local_options[:wordpress_path] 77 | remote_path = remote_options[:wordpress_path] 78 | exclude_wp_content = exclude_dir_contents(remote_wp_content_dir.relative_path) 79 | exclude_paths = paths_to_exclude.push(exclude_wp_content) 80 | 81 | remote_get_directory(remote_path, local_path, exclude_paths) 82 | end 83 | 84 | protected 85 | 86 | def paths_to_exclude 87 | remote_options[:exclude] || [] 88 | end 89 | 90 | def run(command) 91 | logger.task_step true, command 92 | return true if simulate? 93 | 94 | system(command) 95 | raise ShellCommandError, "Return code reports an error" unless $CHILD_STATUS.success? 96 | end 97 | 98 | def download(url, local_path) 99 | logger.task_step true, "download #{url} > #{local_path}" 100 | 101 | return true if simulate? 102 | 103 | File.open(local_path, 'w') do |file| 104 | file << URI.open(url).read 105 | end 106 | end 107 | 108 | def simulate? 109 | options[:simulate] 110 | end 111 | 112 | [ 113 | WordpressDirectory::Path::WP_CONTENT, 114 | WordpressDirectory::Path::PLUGINS, 115 | WordpressDirectory::Path::MU_PLUGINS, 116 | WordpressDirectory::Path::THEMES, 117 | WordpressDirectory::Path::UPLOADS, 118 | WordpressDirectory::Path::LANGUAGES 119 | ].each do |type| 120 | %i[remote local].each do |location| 121 | define_method "#{location}_#{type}_dir" do 122 | options = send("#{location}_options") 123 | WordpressDirectory.new(type, options) 124 | end 125 | end 126 | end 127 | 128 | def mysql_dump_command(options, save_to_path) 129 | command = ["mysqldump"] 130 | command << "--host=#{Shellwords.escape(options[:host])}" if options[:host].present? 131 | command << "--port=#{Shellwords.escape(options[:port])}" if options[:port].present? 132 | command << "--user=#{Shellwords.escape(options[:user])}" if options[:user].present? 133 | if options[:password].present? 134 | command << "--password=#{Shellwords.escape(options[:password])}" 135 | end 136 | command << "--result-file=\"#{save_to_path}\"" 137 | if options[:mysqldump_options].present? 138 | command << Shellwords.split(options[:mysqldump_options]) 139 | end 140 | command << Shellwords.escape(options[:name]) 141 | command.join(" ") 142 | end 143 | 144 | def mysql_import_command(dump_path, options) 145 | command = ["mysql"] 146 | command << "--host=#{Shellwords.escape(options[:host])}" if options[:host].present? 147 | command << "--port=#{Shellwords.escape(options[:port])}" if options[:port].present? 148 | command << "--user=#{Shellwords.escape(options[:user])}" if options[:user].present? 149 | if options[:password].present? 150 | command << "--password=#{Shellwords.escape(options[:password])}" 151 | end 152 | command << "--database=#{Shellwords.escape(options[:name])}" 153 | command << Shellwords.split(options[:mysql_options]) if options[:mysql_options].present? 154 | command << "--execute=\"SET autocommit=0;SOURCE #{dump_path};COMMIT\"" 155 | command.join(" ") 156 | end 157 | 158 | def compress_command(path) 159 | command = ["gzip"] 160 | command << "-9" 161 | command << "-f" 162 | command << "\"#{path}\"" 163 | command.join(" ") 164 | end 165 | 166 | def uncompress_command(path) 167 | command = ["gzip"] 168 | command << "-d" 169 | command << "-f" 170 | command << "\"#{path}\"" 171 | command.join(" ") 172 | end 173 | 174 | def local_delete(path) 175 | logger.task_step true, "delete: '#{path}'" 176 | File.delete(path) unless simulate? 177 | end 178 | 179 | def save_local_db(local_dump_path) 180 | # dump local mysql into file 181 | run mysql_dump_command(local_options[:database], local_dump_path) 182 | end 183 | 184 | def remote_options 185 | options[environment].clone 186 | end 187 | 188 | def local_options 189 | options[:local].clone 190 | end 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/wordmove/deployer/ftp.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module Deployer 3 | class FTP < Base 4 | def initialize(environment, options) 5 | super(environment, options) 6 | ftp_options = remote_options[:ftp] 7 | @copier = Photocopier::FTP.new(ftp_options).tap { |c| c.logger = logger } 8 | end 9 | 10 | def push_db 11 | super 12 | 13 | return true if simulate? 14 | 15 | local_dump_path = local_wp_content_dir.path("dump.sql") 16 | remote_dump_path = remote_wp_content_dir.path("dump.sql") 17 | local_backup_path = local_wp_content_dir.path("remote-backup-#{Time.now.to_i}.sql") 18 | 19 | download_remote_db(local_backup_path) 20 | save_local_db(local_dump_path) 21 | 22 | # gsub sql 23 | adapt_sql(local_dump_path, local_options, remote_options) 24 | # upload it 25 | remote_put(local_dump_path, remote_dump_path) 26 | 27 | import_remote_dump 28 | 29 | # remove dump remotely 30 | remote_delete(remote_dump_path) 31 | # and locally 32 | local_delete(local_dump_path) 33 | end 34 | 35 | def pull_db 36 | super 37 | 38 | return true if simulate? 39 | 40 | local_dump_path = local_wp_content_dir.path("dump.sql") 41 | local_backup_path = local_wp_content_dir.path("local-backup-#{Time.now.to_i}.sql") 42 | 43 | save_local_db(local_backup_path) 44 | download_remote_db(local_dump_path) 45 | 46 | # gsub sql 47 | adapt_sql(local_dump_path, remote_options, local_options) 48 | # import locally 49 | run mysql_import_command(local_dump_path, local_options[:database]) 50 | 51 | # and locally 52 | if options[:debug] 53 | logger.debug "Remote dump located at: #{local_dump_path}" 54 | else 55 | local_delete(local_dump_path) 56 | end 57 | end 58 | 59 | private 60 | 61 | %w[uploads themes plugins mu_plugins languages].each do |task| 62 | define_method "push_#{task}" do 63 | logger.task "Pushing #{task.titleize}" 64 | local_path = send("local_#{task}_dir").path 65 | remote_path = send("remote_#{task}_dir").path 66 | remote_put_directory(local_path, remote_path, paths_to_exclude) 67 | end 68 | 69 | define_method "pull_#{task}" do 70 | logger.task "Pulling #{task.titleize}" 71 | local_path = send("local_#{task}_dir").path 72 | remote_path = send("remote_#{task}_dir").path 73 | remote_get_directory(remote_path, local_path, paths_to_exclude) 74 | end 75 | end 76 | 77 | %w[get get_directory put_directory delete].each do |command| 78 | define_method "remote_#{command}" do |*args| 79 | logger.task_step false, "#{command}: #{args.join(' ')}" 80 | @copier.send(command, *args) unless simulate? 81 | end 82 | end 83 | 84 | def remote_put(thing, path) 85 | if File.exist?(thing) 86 | logger.task_step false, "copying #{thing} to #{path}" 87 | else 88 | logger.task_step false, "write #{path}" 89 | end 90 | @copier.put(thing, path) unless simulate? 91 | end 92 | 93 | def escape_php(string) 94 | return '' unless string 95 | 96 | # replaces \ with \\ 97 | # replaces ' with \' 98 | string.gsub('\\', '\\\\\\').gsub(/[']/, '\\\\\'') 99 | end 100 | 101 | def generate_dump_script(db, password) 102 | template = ERB.new File.read(File.join(File.dirname(__FILE__), "../assets/dump.php.erb")) 103 | template.result(binding) 104 | end 105 | 106 | def generate_import_script(db, password) 107 | template = ERB.new File.read(File.join(File.dirname(__FILE__), "../assets/import.php.erb")) 108 | template.result(binding) 109 | end 110 | 111 | def download_remote_db(local_dump_path) 112 | remote_dump_script = remote_wp_content_dir.path("dump.php") 113 | # generate a secure one-time password 114 | one_time_password = SecureRandom.hex(40) 115 | # generate dump script 116 | dump_script = generate_dump_script(remote_options[:database], one_time_password) 117 | # upload the dump script 118 | remote_put(dump_script, remote_dump_script) 119 | # download the resulting dump (using the password) 120 | dump_url = "#{remote_wp_content_dir.url('dump.php')}?shared_key=#{one_time_password}" 121 | download(dump_url, local_dump_path) 122 | # cleanup remotely 123 | remote_delete(remote_dump_script) 124 | remote_delete(remote_wp_content_dir.path("dump.mysql")) 125 | end 126 | 127 | def import_remote_dump 128 | temp_path = local_wp_content_dir.path("log.html") 129 | remote_import_script_path = remote_wp_content_dir.path("import.php") 130 | # generate a secure one-time password 131 | one_time_password = SecureRandom.hex(40) 132 | # generate import script 133 | import_script = generate_import_script(remote_options[:database], one_time_password) 134 | # upload import script 135 | remote_put(import_script, remote_import_script_path) 136 | # run import script 137 | import_url = [ 138 | remote_wp_content_dir.url('import.php').to_s, 139 | "?shared_key=#{one_time_password}", 140 | "&start=1&foffset=0&totalqueries=0&fn=dump.sql" 141 | ].join 142 | download(import_url, temp_path) 143 | if options[:debug] 144 | logger.debug "Operation log located at: #{temp_path}" 145 | else 146 | local_delete(temp_path) 147 | end 148 | # remove script remotely 149 | remote_delete(remote_import_script_path) 150 | end 151 | 152 | def adapt_sql(save_to_path, local, remote) 153 | return if options[:no_adapt] 154 | 155 | logger.task_step true, "Adapt dump" 156 | SqlAdapter::Default.new(save_to_path, local, remote).adapt! unless simulate? 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/wordmove/deployer/ssh.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | module Wordmove 4 | module Deployer 5 | class SSH < Base 6 | attr_reader :local_dump_path, 7 | :local_backup_path, 8 | :local_gzipped_dump_path, 9 | :local_gzipped_backup_path 10 | 11 | def initialize(environment, options) 12 | super(environment, options) 13 | ssh_options = remote_options[:ssh] 14 | 15 | if simulate? && ssh_options[:rsync_options] 16 | ssh_options[:rsync_options].concat(" --dry-run") 17 | elsif simulate? 18 | ssh_options[:rsync_options] = "--dry-run" 19 | end 20 | 21 | @copier = Photocopier::SSH.new(ssh_options).tap { |c| c.logger = logger } 22 | 23 | @local_dump_path = local_wp_content_dir.path("dump.sql") 24 | @local_backup_path = local_wp_content_dir.path("local-backup-#{Time.now.to_i}.sql") 25 | @local_gzipped_dump_path = local_dump_path + '.gz' 26 | @local_gzipped_backup_path = local_wp_content_dir 27 | .path("#{environment}-backup-#{Time.now.to_i}.sql.gz") 28 | end 29 | 30 | private 31 | 32 | def push_db 33 | super 34 | 35 | return true if simulate? 36 | 37 | backup_remote_db! 38 | adapt_local_db! 39 | after_push_cleanup! 40 | end 41 | 42 | def pull_db 43 | super 44 | 45 | return true if simulate? 46 | 47 | backup_local_db! 48 | adapt_remote_db! 49 | after_pull_cleanup! 50 | end 51 | 52 | # In following commands, we do not guard for simulate? 53 | # because it is handled through --dry-run rsync option. 54 | # @see initialize 55 | %w[get put get_directory put_directory delete].each do |command| 56 | define_method "remote_#{command}" do |*args| 57 | logger.task_step false, "#{command}: #{args.join(' ')}" 58 | @copier.send(command, *args) 59 | end 60 | end 61 | 62 | def remote_run(command) 63 | logger.task_step false, command 64 | return true if simulate? 65 | 66 | _stdout, stderr, exit_code = @copier.exec! command 67 | 68 | return true if exit_code.zero? 69 | 70 | raise( 71 | ShellCommandError, 72 | "Error code #{exit_code} returned by command \"#{command}\": #{stderr}" 73 | ) 74 | end 75 | 76 | def download_remote_db(local_gizipped_dump_path) 77 | remote_dump_path = remote_wp_content_dir.path("dump.sql") 78 | # dump remote db into file 79 | remote_run mysql_dump_command(remote_options[:database], remote_dump_path) 80 | remote_run compress_command(remote_dump_path) 81 | remote_dump_path += '.gz' 82 | # download remote dump 83 | remote_get(remote_dump_path, local_gizipped_dump_path) 84 | remote_delete(remote_dump_path) 85 | end 86 | 87 | def import_remote_dump(local_gizipped_dump_path) 88 | remote_dump_path = remote_wp_content_dir.path("dump.sql") 89 | remote_gizipped_dump_path = remote_dump_path + '.gz' 90 | 91 | remote_put(local_gizipped_dump_path, remote_gizipped_dump_path) 92 | remote_run uncompress_command(remote_gizipped_dump_path) 93 | remote_run mysql_import_command(remote_dump_path, remote_options[:database]) 94 | remote_delete(remote_dump_path) 95 | end 96 | 97 | %w[uploads themes plugins mu_plugins languages].each do |task| 98 | define_method "push_#{task}" do 99 | logger.task "Pushing #{task.titleize}" 100 | local_path = local_options[:wordpress_path] 101 | remote_path = remote_options[:wordpress_path] 102 | 103 | remote_put_directory(local_path, remote_path, 104 | push_exclude_paths(task), push_inlcude_paths(task)) 105 | end 106 | 107 | define_method "pull_#{task}" do 108 | logger.task "Pulling #{task.titleize}" 109 | local_path = local_options[:wordpress_path] 110 | remote_path = remote_options[:wordpress_path] 111 | remote_get_directory(remote_path, local_path, 112 | pull_exclude_paths(task), pull_include_paths(task)) 113 | end 114 | end 115 | 116 | def push_inlcude_paths(task) 117 | Pathname.new(send(:"local_#{task}_dir").relative_path) 118 | .ascend 119 | .each_with_object([]) do |directory, array| 120 | path = directory.to_path 121 | path.prepend('/') unless path.match? %r{^/} 122 | path.concat('/') unless path.match? %r{/$} 123 | array << path 124 | end 125 | end 126 | 127 | def push_exclude_paths(task) 128 | Pathname.new(send(:"local_#{task}_dir").relative_path) 129 | .dirname 130 | .ascend 131 | .each_with_object([]) do |directory, array| 132 | path = directory.to_path 133 | path.prepend('/') unless path.match? %r{^/} 134 | path.concat('/') unless path.match? %r{/$} 135 | path.concat('*') 136 | array << path 137 | end 138 | .concat(paths_to_exclude) 139 | .concat(['/*']) 140 | end 141 | 142 | def pull_include_paths(task) 143 | Pathname.new(send(:"remote_#{task}_dir").relative_path) 144 | .ascend 145 | .each_with_object([]) do |directory, array| 146 | path = directory.to_path 147 | path.prepend('/') unless path.match? %r{^/} 148 | path.concat('/') unless path.match? %r{/$} 149 | array << path 150 | end 151 | end 152 | 153 | def pull_exclude_paths(task) 154 | Pathname.new(send(:"remote_#{task}_dir").relative_path) 155 | .dirname 156 | .ascend 157 | .each_with_object([]) do |directory, array| 158 | path = directory.to_path 159 | path.prepend('/') unless path.match? %r{^/} 160 | path.concat('/') unless path.match? %r{/$} 161 | path.concat('*') 162 | array << path 163 | end 164 | .concat(paths_to_exclude) 165 | .concat(['/*']) 166 | end 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/wordmove/deployer/ssh/default_sql_adapter.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module Deployer 3 | module Ssh 4 | class DefaultSqlAdapter < SSH 5 | private 6 | 7 | def backup_remote_db! 8 | download_remote_db(local_gzipped_backup_path) 9 | end 10 | 11 | def adapt_local_db! 12 | save_local_db(local_dump_path) 13 | adapt_sql(local_dump_path, local_options, remote_options) 14 | run compress_command(local_dump_path) 15 | import_remote_dump(local_gzipped_dump_path) 16 | end 17 | 18 | def after_push_cleanup! 19 | local_delete(local_gzipped_dump_path) 20 | end 21 | 22 | def backup_local_db! 23 | save_local_db(local_backup_path) 24 | run compress_command(local_backup_path) 25 | end 26 | 27 | def adapt_remote_db! 28 | download_remote_db(local_gzipped_dump_path) 29 | run uncompress_command(local_gzipped_dump_path) 30 | adapt_sql(local_dump_path, remote_options, local_options) 31 | run mysql_import_command(local_dump_path, local_options[:database]) 32 | end 33 | 34 | def after_pull_cleanup! 35 | local_delete(local_dump_path) 36 | end 37 | 38 | def adapt_sql(save_to_path, local, remote) 39 | return if options[:no_adapt] 40 | 41 | logger.task_step true, "Adapt dump" 42 | SqlAdapter::Default.new(save_to_path, local, remote).adapt! unless simulate? 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/wordmove/deployer/ssh/wpcli_sql_adapter.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module Deployer 3 | module Ssh 4 | class WpcliSqlAdapter < SSH 5 | def backup_remote_db! 6 | download_remote_db(local_gzipped_backup_path) 7 | end 8 | 9 | def adapt_local_db! 10 | save_local_db(local_dump_path) 11 | run wpcli_search_replace(local_options, remote_options, :vhost) 12 | run wpcli_search_replace(local_options, remote_options, :wordpress_path) 13 | 14 | local_search_replace_dump_path = local_wp_content_dir.path("search_replace_dump.sql") 15 | local_gzipped_search_replace_dump_path = local_search_replace_dump_path + '.gz' 16 | 17 | save_local_db(local_search_replace_dump_path) 18 | run compress_command(local_search_replace_dump_path) 19 | import_remote_dump(local_gzipped_search_replace_dump_path) 20 | local_delete(local_gzipped_search_replace_dump_path) 21 | run mysql_import_command(local_dump_path, local_options[:database]) 22 | end 23 | 24 | def after_push_cleanup! 25 | local_delete(local_dump_path) 26 | end 27 | 28 | def backup_local_db! 29 | save_local_db(local_backup_path) 30 | run compress_command(local_backup_path) 31 | end 32 | 33 | def adapt_remote_db! 34 | download_remote_db(local_gzipped_dump_path) 35 | run uncompress_command(local_gzipped_dump_path) 36 | run mysql_import_command(local_dump_path, local_options[:database]) 37 | run wpcli_search_replace(remote_options, local_options, :vhost) 38 | run wpcli_search_replace(remote_options, local_options, :wordpress_path) 39 | end 40 | 41 | def after_pull_cleanup! 42 | local_delete(local_dump_path) 43 | end 44 | 45 | def wpcli_search_replace(local, remote, config_key) 46 | return if options[:no_adapt] 47 | 48 | logger.task_step true, "adapt dump for #{config_key}" 49 | path = local_options[:wordpress_path] 50 | SqlAdapter::Wpcli.new(local, remote, config_key, path).command unless simulate? 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/wordmove/doctor.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Doctor 3 | def self.start 4 | banner 5 | movefile 6 | mysql 7 | wpcli 8 | rsync 9 | ssh 10 | end 11 | 12 | def self.movefile 13 | movefile_doctor = Wordmove::Doctor::Movefile.new 14 | movefile_doctor.validate! 15 | end 16 | 17 | def self.mysql 18 | mysql_doctor = Wordmove::Doctor::Mysql.new 19 | mysql_doctor.check! 20 | end 21 | 22 | def self.wpcli 23 | wpcli_doctor = Wordmove::Doctor::Wpcli.new 24 | wpcli_doctor.check! 25 | end 26 | 27 | def self.rsync 28 | rsync_doctor = Wordmove::Doctor::Rsync.new 29 | rsync_doctor.check! 30 | end 31 | 32 | def self.ssh 33 | ssh_doctor = Wordmove::Doctor::Ssh.new 34 | ssh_doctor.check! 35 | end 36 | 37 | # rubocop:disable Metrics/MethodLength 38 | def self.banner 39 | paint = <<-'ASCII' 40 | .------------------------. 41 | | PSYCHIATRIC | 42 | | HELP 5¢ | 43 | |________________________| 44 | || .-"""--. || 45 | || / \.-. || 46 | || | ._, \ || 47 | || \_/`-' '-.,_/ || 48 | || (_ (' _)') \ || 49 | || /| |\ || 50 | || | \ __ / | || 51 | || \_).,_____,/}/ || 52 | __||____;_--'___'/ ( || 53 | |\ || (__,\\ \_/------|| 54 | ||\||______________________|| 55 | |||| | 56 | |||| THE DOCTOR | 57 | \||| IS [IN] _____| 58 | \|| (______) 59 | `|___________________//||\\ 60 | //=||=\\ 61 | ` `` ` 62 | ASCII 63 | 64 | puts paint 65 | end 66 | # rubocop:enable Metrics/MethodLength 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/wordmove/doctor/movefile.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Doctor 3 | class Movefile 4 | MANDATORY_SECTIONS = %i[global local].freeze 5 | attr_reader :movefile, :contents, :root_keys 6 | 7 | def initialize(name = nil, dir = '.') 8 | @movefile = Wordmove::Movefile.new(name, dir) 9 | 10 | begin 11 | @contents = movefile.fetch 12 | @root_keys = contents.keys 13 | rescue Psych::SyntaxError 14 | movefile.logger.error "Your movefile is not parsable due to a syntax error"\ 15 | "so we can't continue to validate it." 16 | movefile.logger.debug "You could try to use https://yamlvalidator.com/ to"\ 17 | "get a clue about the problem." 18 | end 19 | end 20 | 21 | def validate! 22 | return false unless root_keys 23 | 24 | MANDATORY_SECTIONS.each do |key| 25 | movefile.logger.task "Validating movefile section: #{key}" 26 | validate_mandatory_section(key) 27 | end 28 | 29 | root_keys.each do |remote| 30 | movefile.logger.task "Validating movefile section: #{remote}" 31 | validate_remote_section(remote) 32 | end 33 | end 34 | 35 | private 36 | 37 | def validate_section(key) 38 | validator = validator_for(key) 39 | 40 | errors = validator.validate(contents[key].deep_stringify_keys) 41 | 42 | if errors&.empty? 43 | movefile.logger.success "Formal validation passed" 44 | 45 | return true 46 | end 47 | 48 | errors.each do |e| 49 | movefile.logger.error "[#{e.path}] #{e.message}" 50 | end 51 | end 52 | 53 | def validate_mandatory_section(key) 54 | return false unless root_keys.delete(key) do 55 | movefile.logger.error "#{key} section not present" 56 | 57 | false 58 | end 59 | 60 | validate_section(key) 61 | end 62 | 63 | def validate_remote_section(key) 64 | return false unless validate_protocol_presence(contents[key].keys) 65 | 66 | validate_section(key) 67 | end 68 | 69 | def validate_protocol_presence(keys) 70 | return true if keys.include?(:ssh) || keys.include?(:ftp) 71 | 72 | movefile.logger.error "This remote has not ssh nor ftp protocol defined" 73 | 74 | false 75 | end 76 | 77 | def validator_for(key) 78 | suffix = if MANDATORY_SECTIONS.include? key 79 | key 80 | else 81 | 'remote' 82 | end 83 | 84 | schema = Kwalify::Yaml.load_file("#{__dir__}/../assets/wordmove_schema_#{suffix}.yml") 85 | 86 | Kwalify::Validator.new(schema) 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/wordmove/doctor/mysql.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Doctor 3 | class Mysql 4 | attr_reader :config, :logger 5 | 6 | def initialize(movefile_name = nil, movefile_dir = '.') 7 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } 8 | begin 9 | @config = Wordmove::Movefile.new(movefile_name, movefile_dir).fetch[:local][:database] 10 | rescue Psych::SyntaxError 11 | return 12 | end 13 | end 14 | 15 | def check! 16 | logger.task "Checking local database commands and connection" 17 | 18 | return logger.error "Can't connect to mysql using your movefile.yml" if config.nil? 19 | 20 | mysql_client_doctor 21 | mysqldump_doctor 22 | mysql_server_doctor 23 | mysql_database_doctor 24 | end 25 | 26 | private 27 | 28 | def mysql_client_doctor 29 | if system("which mysql", out: File::NULL) 30 | logger.success "`mysql` command is in $PATH" 31 | else 32 | logger.error "`mysql` command is not in $PATH" 33 | end 34 | end 35 | 36 | def mysqldump_doctor 37 | if system("which mysqldump", out: File::NULL) 38 | logger.success "`mysqldump` command is in $PATH" 39 | else 40 | logger.error "`mysqldump` command is not in $PATH" 41 | end 42 | end 43 | 44 | def mysql_server_doctor 45 | command = mysql_command 46 | 47 | if system(command, out: File::NULL, err: File::NULL) 48 | logger.success "Successfully connected to the MySQL server" 49 | else 50 | logger.error <<-LONG 51 | We can't connect to the MySQL server using credentials 52 | specified in the Movefile. Double check them or try 53 | to debug your system configuration. 54 | 55 | The command used to test was: 56 | 57 | #{command} 58 | LONG 59 | end 60 | end 61 | 62 | def mysql_database_doctor 63 | command = mysql_command(database: config[:name]) 64 | 65 | if system(command, out: File::NULL, err: File::NULL) 66 | logger.success "Successfully connected to the database" 67 | else 68 | logger.error <<-LONG 69 | We can't connect to the database using credentials 70 | specified in the Movefile, or the database does not 71 | exists. Double check them or try to debug your 72 | system configuration. 73 | 74 | The command used to test was: 75 | 76 | #{command} 77 | LONG 78 | end 79 | end 80 | 81 | def mysql_command(database: nil) 82 | command = ["mysql"] 83 | command << "--host=#{Shellwords.escape(config[:host])}" if config[:host].present? 84 | command << "--port=#{Shellwords.escape(config[:port])}" if config[:port].present? 85 | command << "--user=#{Shellwords.escape(config[:user])}" if config[:user].present? 86 | if config[:password].present? 87 | command << "--password=#{Shellwords.escape(config[:password])}" 88 | end 89 | command << database if database.present? 90 | command << "-e'QUIT'" 91 | command.join(" ") 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/wordmove/doctor/rsync.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Doctor 3 | class Rsync 4 | attr_reader :logger 5 | 6 | def initialize 7 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } 8 | end 9 | 10 | def check! 11 | logger.task "Checking rsync" 12 | 13 | if (version = /\d\.\d.\d/.match(`rsync --version | head -n1`)[0]) 14 | logger.success "rsync is installed at version #{version}" 15 | else 16 | logger.error "rsync not found. And belive me: it's really strange it's not there." 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/wordmove/doctor/ssh.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Doctor 3 | class Ssh 4 | attr_reader :logger 5 | 6 | def initialize 7 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } 8 | end 9 | 10 | def check! 11 | logger.task "Checking SSH client" 12 | 13 | if system('which ssh', out: File::NULL, err: File::NULL) 14 | logger.success "SSH command found" 15 | else 16 | logger.error "SSH command not found. And belive me: it's really strange it's not there." 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/wordmove/doctor/wpcli.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Doctor 3 | class Wpcli 4 | attr_reader :logger 5 | 6 | def initialize 7 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } 8 | end 9 | 10 | def check! 11 | logger.task "Checking local wp-cli installation" 12 | 13 | if in_path? 14 | logger.success "wp-cli is correctly installed" 15 | 16 | if up_to_date? 17 | logger.success "wp-cli is up to date" 18 | else 19 | logger.error <<-LONG 20 | wp-cli is not up to date. 21 | Use `wp cli update` to update to the latest version. 22 | LONG 23 | end 24 | else 25 | logger.error <<-LONG 26 | wp-cli is not installed (or not in your $PATH). 27 | Read http://wp-cli.org/#installing for installation info. 28 | LONG 29 | end 30 | end 31 | 32 | private 33 | 34 | def in_path? 35 | system('which wp', out: File::NULL) 36 | end 37 | 38 | def up_to_date? 39 | `wp cli check-update --format=json`.empty? 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/wordmove/environments_list.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class EnvironmentsList 3 | attr_reader :movefile, :logger, :remote_vhosts, :local_vhost 4 | 5 | class << self 6 | def print(cli_options) 7 | new(cli_options).print 8 | end 9 | end 10 | 11 | def initialize(options) 12 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO } 13 | @movefile = Wordmove::Movefile.new(options[:config]) 14 | @remote_vhosts = [] 15 | @local_vhost = [] 16 | end 17 | 18 | def print 19 | contents = parse_movefile(movefile: movefile) 20 | generate_vhost_list(contents: contents) 21 | output 22 | end 23 | 24 | private 25 | 26 | def select_vhost(contents:) 27 | target = contents.select { |_key, env| env[:vhost].present? } 28 | target.map { |key, env| { env: key, vhost: env[:vhost] } } 29 | end 30 | 31 | def parse_movefile(movefile:) 32 | movefile.fetch 33 | end 34 | 35 | def output 36 | logger.task('Listing Local') 37 | logger.plain(output_string(vhost_list: local_vhost)) 38 | 39 | logger.task('Listing Remotes') 40 | logger.plain(output_string(vhost_list: remote_vhosts)) 41 | end 42 | 43 | def output_string(vhost_list:) 44 | return 'vhost list is empty' if vhost_list.empty? 45 | 46 | vhost_list.each_with_object("") do |entry, retval| 47 | retval << "#{entry[:env]}: #{entry[:vhost]}\n" 48 | end 49 | end 50 | 51 | # 52 | # return env, vhost map 53 | # Exp. {:env=>:local, :vhost=>"http://vhost.local"}, 54 | # {:env=>:production, :vhost=>"http://example.com"} 55 | # 56 | def generate_vhost_list(contents:) 57 | # select object which has 'vhost' only 58 | vhosts = select_vhost(contents: contents) 59 | vhosts.each do |list| 60 | if list[:env] == :local 61 | @local_vhost << list 62 | else 63 | @remote_vhosts << list 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/wordmove/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class UndefinedEnvironment < StandardError; end 3 | class NoAdapterFound < StandardError; end 4 | class MovefileNotFound < StandardError; end 5 | class ShellCommandError < StandardError; end 6 | class ImplementInSubclassError < StandardError; end 7 | class UnmetPeerDependencyError < StandardError; end 8 | class RemoteHookException < StandardError; end 9 | class LocalHookException < StandardError; end 10 | end 11 | -------------------------------------------------------------------------------- /lib/wordmove/generators/movefile.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module Generators 3 | class Movefile < Thor::Group 4 | include Thor::Actions 5 | include MovefileAdapter 6 | 7 | def self.source_root 8 | File.dirname(__FILE__) 9 | end 10 | 11 | def copy_movefile 12 | template "movefile.yml" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/wordmove/generators/movefile.yml: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: wpcli 3 | 4 | local: 5 | vhost: http://vhost.local 6 | wordpress_path: <%= wordpress_path %> # use an absolute path here 7 | 8 | database: 9 | name: <%= database.name %> 10 | user: <%= database.user %> 11 | password: "<%= database.password %>" # could be blank, so always use quotes around 12 | host: <%= database.host %> 13 | 14 | production: 15 | vhost: http://example.com 16 | wordpress_path: /var/www/your_site # use an absolute path here 17 | 18 | database: 19 | name: database_name 20 | user: user 21 | password: password 22 | host: host 23 | # port: 3308 # Use just in case you have exotic server config 24 | # mysqldump_options: '--max_allowed_packet=1G' # Only available if using SSH 25 | # mysql_options: '--protocol=TCP' # mysql command is used to import db 26 | 27 | exclude: 28 | - '.git/' 29 | - '.gitignore' 30 | - '.gitmodules' 31 | - '.env' 32 | - 'node_modules/' 33 | - 'bin/' 34 | - 'tmp/*' 35 | - 'Gemfile*' 36 | - 'Movefile' 37 | - 'movefile' 38 | - 'movefile.yml' 39 | - 'movefile.yaml' 40 | - 'wp-config.php' 41 | - 'wp-content/*.sql.gz' 42 | - '*.orig' 43 | 44 | # paths: # you can customize wordpress internal paths 45 | # wp_content: wp-content 46 | # uploads: wp-content/uploads 47 | # plugins: wp-content/plugins 48 | # mu_plugins: wp-content/mu-plugins 49 | # themes: wp-content/themes 50 | # languages: wp-content/languages 51 | 52 | # ssh: 53 | # host: host 54 | # user: user 55 | # password: password # password is optional, will use public keys if available. 56 | # port: 22 # Port is optional 57 | # rsync_options: '--verbose --itemize-changes' # Additional rsync options, optional 58 | # gateway: # Gateway is optional 59 | # host: host 60 | # user: user 61 | # password: password # password is optional, will use public keys if available. 62 | 63 | # ftp: 64 | # user: user 65 | # password: password 66 | # host: host 67 | # passive: true 68 | # port: 21 # Port is optional 69 | # scheme: ftps # default `ftp`. alternative `sftp` 70 | 71 | # hooks: # Remote hooks won't work with FTP 72 | # push: 73 | # before: 74 | # - command: 'echo "do something"' 75 | # where: local 76 | # raise: false # raise is true by default 77 | # after: 78 | # - command: 'echo "do something"' 79 | # where: remote 80 | # pull: 81 | # before: 82 | # - command: 'echo "do something"' 83 | # where: local 84 | # raise: false 85 | # after: 86 | # - command: 'echo "do something"' 87 | # where: remote 88 | # 89 | # forbid: 90 | # push: 91 | # db: false 92 | # plugins: false 93 | # themes: false 94 | # languages: false 95 | # uploads: false 96 | # mu-plugins: false 97 | # pull: 98 | # db: false 99 | # plugins: false 100 | # themes: false 101 | # languages: false 102 | # uploads: false 103 | # mu-plugins: false 104 | 105 | # staging: # multiple environments can be specified 106 | # [...] 107 | -------------------------------------------------------------------------------- /lib/wordmove/generators/movefile_adapter.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module Generators 3 | module MovefileAdapter 4 | def wordpress_path 5 | File.expand_path(Dir.pwd) 6 | end 7 | 8 | def database 9 | DBConfigReader.config 10 | end 11 | end 12 | 13 | class DBConfigReader 14 | def self.config 15 | new.config 16 | end 17 | 18 | def config 19 | OpenStruct.new(database_config) 20 | end 21 | 22 | def database_config 23 | if wp_config_exists? 24 | WordpressDBConfig.config 25 | else 26 | DefaultDBConfig.config 27 | end 28 | end 29 | 30 | def wp_config_exists? 31 | File.exist?(WordpressDirectory.default_path_for(:wp_config)) 32 | end 33 | end 34 | 35 | class DefaultDBConfig 36 | def self.config 37 | { 38 | name: "database_name", 39 | user: "user", 40 | password: "password", 41 | host: "127.0.0.1" 42 | } 43 | end 44 | end 45 | 46 | class WordpressDBConfig 47 | def self.config 48 | new.config 49 | end 50 | 51 | def wp_config 52 | @wp_config ||= File.read( 53 | WordpressDirectory.default_path_for(:wp_config) 54 | ).encode('utf-8', invalid: :replace) 55 | end 56 | 57 | def wp_definitions 58 | { 59 | name: 'DB_NAME', 60 | user: 'DB_USER', 61 | password: 'DB_PASSWORD', 62 | host: 'DB_HOST' 63 | } 64 | end 65 | 66 | def wp_definition_regex(definition) 67 | /['"]#{definition}['"],\s*["'](?.*)['"]/ 68 | end 69 | 70 | def defaults 71 | DefaultDBConfig.config.clone 72 | end 73 | 74 | def config 75 | wp_definitions.each_with_object(defaults) do |(key, definition), result| 76 | wp_config.match(wp_definition_regex(definition)) do |match| 77 | result[key] = match[:value] 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/wordmove/guardian.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Guardian 3 | attr_reader :movefile, :environment, :action, :logger 4 | 5 | def initialize(options: nil, action: nil) 6 | @movefile = Wordmove::Movefile.new(options[:config]) 7 | @environment = @movefile.environment(options).to_sym 8 | @action = action 9 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } 10 | end 11 | 12 | def allows(task) 13 | if forbidden?(task) 14 | logger.task("#{action.capitalize}ing #{task.capitalize}") 15 | logger.warn("You tried to #{action} #{task}, but is forbidden by configuration. Skipping") 16 | end 17 | 18 | !forbidden?(task) 19 | end 20 | 21 | private 22 | 23 | def forbidden?(task) 24 | return false unless forbidden_tasks[task].present? 25 | 26 | forbidden_tasks[task] == true 27 | end 28 | 29 | def forbidden_tasks 30 | environment_options = movefile.fetch(false)[environment] 31 | return {} unless environment_options.key?(:forbid) 32 | return {} unless environment_options[:forbid].key?(action) 33 | 34 | environment_options[:forbid][action] 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/wordmove/hook.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Hook 3 | def self.logger 4 | Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } 5 | end 6 | 7 | # rubocop:disable Metrics/MethodLength 8 | def self.run(action, step, cli_options) 9 | movefile = Wordmove::Movefile.new(cli_options[:config]) 10 | options = movefile.fetch(false) 11 | environment = movefile.environment(cli_options) 12 | 13 | hooks = Wordmove::Hook::Config.new( 14 | options[environment][:hooks], 15 | action, 16 | step 17 | ) 18 | 19 | return if hooks.empty? 20 | 21 | logger.task "Running #{action}/#{step} hooks" 22 | 23 | hooks.all_commands.each do |command| 24 | case command[:where] 25 | when 'local' 26 | Wordmove::Hook::Local.run(command, options[:local], cli_options[:simulate]) 27 | when 'remote' 28 | if options[environment][:ftp] 29 | logger.debug "You have configured remote hooks to run over "\ 30 | "an FTP connection, but this is not possible. Skipping." 31 | next 32 | end 33 | 34 | Wordmove::Hook::Remote.run(command, options[environment], cli_options[:simulate]) 35 | else 36 | next 37 | end 38 | end 39 | end 40 | # rubocop:enable Metrics/MethodLength 41 | 42 | Config = Struct.new(:options, :action, :step) do 43 | def empty? 44 | all_commands.empty? 45 | end 46 | 47 | def all_commands 48 | return [] if empty_step? 49 | 50 | options[action][step] || [] 51 | end 52 | 53 | def local_commands 54 | return [] if empty_step? 55 | 56 | options[action][step] 57 | .select { |hook| hook[:where] == 'local' } || [] 58 | end 59 | 60 | def remote_commands 61 | return [] if empty_step? 62 | 63 | options[action][step] 64 | .select { |hook| hook[:where] == 'remote' } || [] 65 | end 66 | 67 | private 68 | 69 | def empty_step? 70 | return true unless options 71 | return true if options[action].nil? 72 | return true if options[action][step].nil? 73 | return true if options[action][step].empty? 74 | 75 | false 76 | end 77 | end 78 | 79 | class Local 80 | def self.logger 81 | Wordmove::Hook.logger 82 | end 83 | 84 | def self.run(command_hash, options, simulate = false) 85 | wordpress_path = options[:wordpress_path] 86 | 87 | logger.task_step true, "Exec command: #{command_hash[:command]}" 88 | return true if simulate 89 | 90 | stdout_return = `cd #{wordpress_path} && #{command_hash[:command]} 2>&1` 91 | logger.task_step true, "Output: #{stdout_return}" 92 | 93 | if $CHILD_STATUS.exitstatus.zero? 94 | logger.success "" 95 | else 96 | logger.error "Error code: #{$CHILD_STATUS.exitstatus}" 97 | raise Wordmove::LocalHookException unless command_hash[:raise].eql? false 98 | end 99 | end 100 | end 101 | 102 | class Remote 103 | def self.logger 104 | Wordmove::Hook.logger 105 | end 106 | 107 | def self.run(command_hash, options, simulate = false) 108 | ssh_options = options[:ssh] 109 | wordpress_path = options[:wordpress_path] 110 | 111 | copier = Photocopier::SSH.new(ssh_options).tap { |c| c.logger = logger } 112 | 113 | logger.task_step false, "Exec command: #{command_hash[:command]}" 114 | return true if simulate 115 | 116 | stdout, stderr, exit_code = 117 | copier.exec!("cd #{wordpress_path} && #{command_hash[:command]}") 118 | 119 | if exit_code.zero? 120 | logger.task_step false, "Output: #{stdout}" 121 | logger.success "" 122 | else 123 | logger.task_step false, "Output: #{stderr}" 124 | logger.error "Error code #{exit_code}" 125 | raise Wordmove::RemoteHookException unless command_hash[:raise].eql? false 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/wordmove/logger.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Logger < ::Logger 3 | MAX_LINE = 70 4 | 5 | def initialize(device, strings_to_hide = []) 6 | super(device, formatter: proc { |_severity, _datetime, _progname, message| 7 | formatted_message = if strings_to_hide.empty? 8 | message 9 | else 10 | message.gsub( 11 | Regexp.new( 12 | strings_to_hide.map { |string| Regexp.escape(string) }.join('|') 13 | ), 14 | '[secret]' 15 | ) 16 | end 17 | 18 | "\n#{formatted_message}\n" 19 | }) 20 | end 21 | 22 | def task(title) 23 | prefix = "▬" * 2 24 | title = " #{title} " 25 | padding = "▬" * padding_length(title) 26 | add(INFO, prefix + title.light_white + padding) 27 | end 28 | 29 | def task_step(local_step, title) 30 | if local_step 31 | add(INFO, " local".cyan + " | ".black + title.to_s) 32 | else 33 | add(INFO, " remote".yellow + " | ".black + title.to_s) 34 | end 35 | end 36 | 37 | def error(message) 38 | add(ERROR, " ❌ error".red + " | ".black + message.to_s) 39 | end 40 | 41 | def success(message) 42 | add(INFO, " ✅ success".green + " | ".black + message.to_s) 43 | end 44 | 45 | def debug(message) 46 | add(DEBUG, " 🛠 debug".magenta + " | ".black + message.to_s) 47 | end 48 | 49 | def warn(message) 50 | add(WARN, " ⚠️ warning".yellow + " | ".black + message.to_s) 51 | end 52 | 53 | def info(message) 54 | add(INFO, " ℹ️ info".yellow + " | ".black + message.to_s) 55 | end 56 | 57 | def plain(message) 58 | add(INFO, message.to_s) 59 | end 60 | 61 | private 62 | 63 | def padding_length(line) 64 | result = MAX_LINE - line.length 65 | result.positive? ? result : 0 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/wordmove/movefile.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | class Movefile 3 | attr_reader :logger, :name, :start_dir 4 | 5 | def initialize(name = nil, start_dir = current_dir) 6 | @logger = Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG } 7 | @name = name 8 | @start_dir = start_dir 9 | end 10 | 11 | def fetch(verbose = true) 12 | entries = if name.nil? 13 | Dir["#{File.join(start_dir, '{M,m}ovefile')}{,.yml,.yaml}"] 14 | else 15 | Dir["#{File.join(start_dir, name)}{,.yml,.yaml}"] 16 | end 17 | 18 | if entries.empty? 19 | if last_dir?(start_dir) 20 | raise MovefileNotFound, "Could not find a valid Movefile. Searched"\ 21 | " for filename \"#{name}\" in folder \"#{start_dir}\"" 22 | end 23 | 24 | @start_dir = upper_dir(start_dir) 25 | return fetch(verbose) 26 | end 27 | 28 | found = entries.first 29 | logger.task("Using Movefile: #{found}") if verbose == true 30 | YAML.safe_load(ERB.new(File.read(found)).result, [], [], true).deep_symbolize_keys! 31 | end 32 | 33 | def load_dotenv(cli_options = {}) 34 | env = environment(cli_options) 35 | env_files = Dir[File.join(start_dir, ".env{.#{env},}")] 36 | 37 | found_env = env_files.first 38 | 39 | return false unless found_env.present? 40 | 41 | logger.info("Using .env file: #{found_env}") 42 | Dotenv.load(found_env) 43 | end 44 | 45 | def environment(cli_options = {}) 46 | options = fetch(false) 47 | available_enviroments = extract_available_envs(options) 48 | options.merge!(cli_options).deep_symbolize_keys! 49 | 50 | if options[:environment] != 'local' 51 | if available_enviroments.size > 1 && options[:environment].nil? 52 | raise( 53 | UndefinedEnvironment, 54 | "You need to specify an environment with --environment parameter" 55 | ) 56 | end 57 | 58 | if options[:environment].present? 59 | unless available_enviroments.include?(options[:environment].to_sym) 60 | raise UndefinedEnvironment, "No environment found for \"#{options[:environment]}\". "\ 61 | "Available Environments: #{available_enviroments.join(' ')}" 62 | end 63 | end 64 | end 65 | 66 | (options[:environment] || available_enviroments.first).to_sym 67 | end 68 | 69 | def secrets 70 | options = fetch(false) 71 | 72 | secrets = [] 73 | options.each_key do |env| 74 | secrets << options.dig(env, :database, :password) 75 | secrets << options.dig(env, :database, :host) 76 | secrets << options.dig(env, :vhost) 77 | secrets << options.dig(env, :ssh, :password) 78 | secrets << options.dig(env, :ssh, :host) 79 | secrets << options.dig(env, :ftp, :password) 80 | secrets << options.dig(env, :ftp, :host) 81 | secrets << options.dig(env, :wordpress_path) 82 | end 83 | 84 | secrets.compact.delete_if(&:empty?) 85 | end 86 | 87 | private 88 | 89 | def extract_available_envs(options) 90 | options.keys.map(&:to_sym) - %i[local global] 91 | end 92 | 93 | def last_dir?(directory) 94 | directory == "/" || File.exist?(File.join(directory, 'wp-config.php')) 95 | end 96 | 97 | def upper_dir(directory) 98 | File.expand_path(File.join(directory, '..')) 99 | end 100 | 101 | def current_dir 102 | '.' 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/wordmove/sql_adapter/default.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module SqlAdapter 3 | class Default 4 | attr_writer :sql_content 5 | attr_reader :sql_path, :source_config, :dest_config 6 | 7 | def initialize(sql_path, source_config, dest_config) 8 | @sql_path = sql_path 9 | @source_config = source_config 10 | @dest_config = dest_config 11 | end 12 | 13 | def sql_content 14 | @sql_content ||= File.open(sql_path).read 15 | end 16 | 17 | def adapt! 18 | replace_vhost! 19 | replace_wordpress_path! 20 | write_sql! 21 | end 22 | 23 | def replace_vhost! 24 | source_vhost = source_config[:vhost] 25 | dest_vhost = dest_config[:vhost] 26 | replace_field!(source_vhost, dest_vhost) 27 | end 28 | 29 | def replace_wordpress_path! 30 | source_path = source_config[:wordpress_absolute_path] || source_config[:wordpress_path] 31 | dest_path = dest_config[:wordpress_absolute_path] || dest_config[:wordpress_path] 32 | replace_field!(source_path, dest_path) 33 | end 34 | 35 | def replace_field!(source_field, dest_field) 36 | return false unless source_field && dest_field 37 | 38 | serialized_replace!(source_field, dest_field) 39 | simple_replace!(source_field, dest_field) 40 | end 41 | 42 | def serialized_replace!(source_field, dest_field) 43 | length_delta = source_field.length - dest_field.length 44 | 45 | sql_content.gsub!(/s:(\d+):([\\]*['"])(.*?)\2;/) do |_| 46 | length = Regexp.last_match(1).to_i 47 | delimiter = Regexp.last_match(2) 48 | string = Regexp.last_match(3) 49 | 50 | string.gsub!(/#{Regexp.escape(source_field)}/) do |_| 51 | length -= length_delta 52 | dest_field 53 | end 54 | 55 | %(s:#{length}:#{delimiter}#{string}#{delimiter};) 56 | end 57 | end 58 | 59 | def simple_replace!(source_field, dest_field) 60 | sql_content.gsub!(source_field, dest_field) 61 | end 62 | 63 | def write_sql! 64 | File.open(sql_path, 'w') { |f| f.write(sql_content) } 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/wordmove/sql_adapter/wpcli.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | module SqlAdapter 3 | class Wpcli 4 | attr_accessor :sql_content 5 | attr_reader :from, :to, :local_path 6 | 7 | def initialize(source_config, dest_config, config_key, local_path) 8 | @from = source_config[config_key] 9 | @to = dest_config[config_key] 10 | @local_path = local_path 11 | end 12 | 13 | def command 14 | unless wp_in_path? 15 | raise UnmetPeerDependencyError, "WP-CLI is not installed or not in your $PATH" 16 | end 17 | 18 | opts = [ 19 | "--path=#{cli_config_path}", 20 | from, 21 | to, 22 | "--quiet", 23 | "--skip-columns=guid", 24 | "--all-tables", 25 | "--allow-root" 26 | ] 27 | 28 | "wp search-replace #{opts.join(' ')}" 29 | end 30 | 31 | private 32 | 33 | def wp_in_path? 34 | system('which wp > /dev/null 2>&1') 35 | end 36 | 37 | def cli_config_path 38 | load_from_yml || load_from_cli || local_path 39 | end 40 | 41 | def load_from_yml 42 | cli_config_path = File.join(local_path, "wp-cli.yml") 43 | return unless File.exist?(cli_config_path) 44 | 45 | YAML.load_file(cli_config_path).with_indifferent_access["path"] 46 | end 47 | 48 | def load_from_cli 49 | cli_config = JSON.parse(`wp cli param-dump --with-values`, symbolize_names: true) 50 | cli_config.dig(:path, :current) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/wordmove/version.rb: -------------------------------------------------------------------------------- 1 | module Wordmove 2 | VERSION = "5.2.2".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/wordmove/wordpress_directory.rb: -------------------------------------------------------------------------------- 1 | require 'wordmove/wordpress_directory/path' 2 | 3 | class WordpressDirectory 4 | attr_accessor :type, :options 5 | 6 | def initialize(type, options) 7 | @type = type 8 | @options = options 9 | end 10 | 11 | DEFAULT_PATHS = { 12 | Path::WP_CONTENT => 'wp-content', 13 | Path::WP_CONFIG => 'wp-config.php', 14 | Path::PLUGINS => 'wp-content/plugins', 15 | Path::MU_PLUGINS => 'wp-content/mu-plugins', 16 | Path::THEMES => 'wp-content/themes', 17 | Path::UPLOADS => 'wp-content/uploads', 18 | Path::LANGUAGES => 'wp-content/languages' 19 | }.freeze 20 | 21 | def self.default_path_for(sym) 22 | DEFAULT_PATHS[sym] 23 | end 24 | 25 | def path(*args) 26 | File.join(options[:wordpress_path], relative_path(*args)) 27 | end 28 | 29 | def url(*args) 30 | File.join(options[:vhost], relative_path(*args)) 31 | end 32 | 33 | def relative_path(*args) 34 | path = if options[:paths] && options[:paths][type] 35 | options[:paths][type] 36 | else 37 | DEFAULT_PATHS[type] 38 | end 39 | File.join(path, *args) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/wordmove/wordpress_directory/path.rb: -------------------------------------------------------------------------------- 1 | class WordpressDirectory 2 | module Path 3 | WP_CONTENT = :wp_content 4 | WP_CONFIG = :wp_config 5 | PLUGINS = :plugins 6 | MU_PLUGINS = :mu_plugins 7 | THEMES = :themes 8 | UPLOADS = :uploads 9 | LANGUAGES = :languages 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /pkg/wordmove-0.0.1.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welaika/wordmove/bc9bce6ded43b01dfd44230c4a8e51b4a016ce33/pkg/wordmove-0.0.1.gem -------------------------------------------------------------------------------- /pkg/wordmove-0.0.2.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welaika/wordmove/bc9bce6ded43b01dfd44230c4a8e51b4a016ce33/pkg/wordmove-0.0.2.gem -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Wordmove::CLI do 4 | let(:cli) { described_class.new } 5 | let(:deployer) { double("deployer") } 6 | let(:options) { {} } 7 | 8 | before do 9 | allow(Wordmove::Deployer::Base).to receive(:deployer_for).with(options).and_return(deployer) 10 | end 11 | 12 | context "#init" do 13 | it "delagates the command to the Movefile Generator" do 14 | expect(Wordmove::Generators::Movefile).to receive(:start) 15 | cli.invoke(:init, [], options) 16 | end 17 | end 18 | 19 | context "#doctor" do 20 | it "delagates the command to Doctor class" do 21 | expect(Wordmove::Doctor).to receive(:start) 22 | cli.invoke(:doctor, [], options) 23 | end 24 | end 25 | 26 | context "#pull" do 27 | context "without a movefile" do 28 | it "it rescues from a MovefileNotFound exception" do 29 | expect { cli.invoke(:pull, []) }.to raise_error SystemExit 30 | end 31 | end 32 | end 33 | 34 | context "#list" do 35 | subject { cli.invoke(:list, []) } 36 | let(:list_class) { Wordmove::EnvironmentsList } 37 | # Werdmove::EnvironmentsList.print should be called 38 | it "delagates the command to EnvironmentsList class" do 39 | expect(list_class).to receive(:print) 40 | subject 41 | end 42 | 43 | context 'without a valid movefile' do 44 | context "no movefile" do 45 | it { expect { subject }.to raise_error SystemExit } 46 | end 47 | 48 | context "syntax error movefile " do 49 | before do 50 | # Ref. https://github.com/ruby/psych/blob/master/lib/psych/syntax_error.rb#L8 51 | # Arguments for initialization: file, line, col, offset, problem, context 52 | args = [nil, 1, 5, 0, 53 | "found character that cannot start any token", 54 | "while scanning for the next token"] 55 | allow(list_class).to receive(:print).and_raise(Psych::SyntaxError.new(*args)) 56 | end 57 | 58 | it { expect { subject }.to raise_error SystemExit } 59 | end 60 | end 61 | 62 | context "with a movefile" do 63 | subject { cli.invoke(:list, [], options) } 64 | let(:options) { { config: movefile_path_for('Movefile') } } 65 | it "invoke list without error" do 66 | expect { subject }.not_to raise_error 67 | end 68 | end 69 | end 70 | 71 | context "#push" do 72 | context "without a movefile" do 73 | it "it rescues from a MovefileNotFound exception" do 74 | expect { cli.invoke(:pull, []) }.to raise_error SystemExit 75 | end 76 | end 77 | end 78 | 79 | context "--all" do 80 | let(:options) { { all: true, config: movefile_path_for('Movefile') } } 81 | let(:ordered_components) { %i[wordpress uploads themes plugins mu_plugins languages db] } 82 | 83 | context "#pull" do 84 | it "invokes commands in the right order" do 85 | ordered_components.each do |component| 86 | expect(deployer).to receive("pull_#{component}") 87 | end 88 | cli.invoke(:pull, [], options) 89 | end 90 | 91 | context "with forbidden task" do 92 | let(:options) { { all: true, config: movefile_path_for('with_forbidden_tasks') } } 93 | 94 | it "does not pull the forbidden task" do 95 | expected_components = ordered_components - [:db] 96 | 97 | expected_components.each do |component| 98 | expect(deployer).to receive("pull_#{component}") 99 | end 100 | expect(deployer).to_not receive("pull_db") 101 | 102 | silence_stream(STDOUT) { cli.invoke(:pull, [], options) } 103 | end 104 | end 105 | end 106 | 107 | context "#push" do 108 | it "invokes commands in the right order" do 109 | ordered_components.each do |component| 110 | expect(deployer).to receive("push_#{component}") 111 | end 112 | cli.invoke(:push, [], options) 113 | end 114 | 115 | context "with forbidden task" do 116 | let(:options) { { all: true, config: movefile_path_for('with_forbidden_tasks') } } 117 | 118 | it "does not push the forbidden task" do 119 | expected_components = ordered_components - [:db] 120 | 121 | expected_components.each do |component| 122 | expect(deployer).to receive("push_#{component}") 123 | end 124 | expect(deployer).to_not receive("push_db") 125 | 126 | silence_stream(STDOUT) { cli.invoke(:push, [], options) } 127 | end 128 | end 129 | end 130 | 131 | context "excluding one of the components" do 132 | it "does not invoke the escluded component" do 133 | excluded_component = ordered_components.pop 134 | options[excluded_component] = false 135 | 136 | ordered_components.each do |component| 137 | expect(deployer).to receive("push_#{component}") 138 | end 139 | expect(deployer).to_not receive("push_#{excluded_component}") 140 | 141 | cli.invoke(:push, [], options) 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /spec/deployer/base_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Deployer::Base do 2 | let(:options) do 3 | { config: movefile_path_for("multi_environments") } 4 | end 5 | context ".deployer_for" do 6 | context "with more then one environment, but none chosen" do 7 | it "raises an exception" do 8 | expect { described_class.deployer_for(options) } 9 | .to raise_exception(Wordmove::UndefinedEnvironment) 10 | end 11 | end 12 | 13 | context "with more then one environment, but invalid chosen" do 14 | it "raises an exception" do 15 | options[:environment] = "doesnotexist" 16 | options[:simulate] = true 17 | 18 | expect { described_class.deployer_for(options) } 19 | .to raise_exception(Wordmove::UndefinedEnvironment) 20 | end 21 | end 22 | 23 | context "with ftp remote connection" do 24 | it "returns an instance of FTP deployer" do 25 | options[:environment] = "production" 26 | expect(described_class.deployer_for(options)).to be_a Wordmove::Deployer::FTP 27 | end 28 | end 29 | 30 | context "with ssh remote connection" do 31 | before do 32 | options[:environment] = "staging" 33 | end 34 | 35 | it "returns an instance of Ssh::Default deployer" do 36 | expect(described_class.deployer_for(options)) 37 | .to be_a Wordmove::Deployer::Ssh::DefaultSqlAdapter 38 | end 39 | 40 | context "when Movefile is configured with 'wpcli' sql_adapter" do 41 | it "returns an instance of Ssh::WpcliSqlAdapter deployer" do 42 | options[:config] = movefile_path_for('multi_environments_wpcli_sql_adapter') 43 | 44 | expect(described_class.deployer_for(options)) 45 | .to be_a Wordmove::Deployer::Ssh::WpcliSqlAdapter 46 | end 47 | end 48 | 49 | context "with --simulate" do 50 | it "rsync_options will contain --dry-run" do 51 | options[:environment] = "staging" 52 | options[:simulate] = true 53 | copier = double(:copier) 54 | 55 | allow(copier).to receive(:logger=) 56 | 57 | expect(Photocopier::SSH).to receive(:new) 58 | .with(hash_including(rsync_options: '--dry-run')) 59 | .and_return(copier) 60 | 61 | described_class.deployer_for(options) 62 | end 63 | end 64 | end 65 | 66 | context "with unknown type of connection " do 67 | it "raises an exception" do 68 | options[:environment] = "missing_protocol" 69 | expect { described_class.deployer_for(options) }.to raise_error(Wordmove::NoAdapterFound) 70 | end 71 | end 72 | end 73 | 74 | context "#mysql_dump_command" do 75 | let(:deployer) { described_class.new(:dummy_env, options) } 76 | 77 | it "creates a valid mysqldump command" do 78 | command = deployer.send( 79 | :mysql_dump_command, 80 | { 81 | host: "localhost", 82 | port: "8888", 83 | user: "root", 84 | password: "'\"$ciao", 85 | name: "database_name", 86 | mysqldump_options: "--max_allowed_packet=1G --no-create-db" 87 | }, 88 | "./mysql dump.sql" 89 | ) 90 | 91 | expect(command).to eq( 92 | [ 93 | "mysqldump --host=localhost", 94 | "--port=8888 --user=root --password=\\'\\\"\\$ciao", 95 | "--result-file=\"./mysql dump.sql\"", 96 | "--max_allowed_packet=1G --no-create-db database_name" 97 | ].join(' ') 98 | ) 99 | end 100 | end 101 | 102 | context "#mysql_import_command" do 103 | let(:deployer) { described_class.new(:dummy_env, options) } 104 | 105 | it "creates a valid mysql import command" do 106 | command = deployer.send( 107 | :mysql_import_command, 108 | "./my dump.sql", 109 | host: "localhost", 110 | port: "8888", 111 | user: "root", 112 | password: "'\"$ciao", 113 | name: "database_name", 114 | mysql_options: "--protocol=TCP" 115 | ) 116 | expect(command).to eq( 117 | [ 118 | "mysql --host=localhost --port=8888 --user=root", 119 | "--password=\\'\\\"\\$ciao", 120 | "--database=database_name", 121 | "--protocol=TCP", 122 | "--execute=\"SET autocommit=0;SOURCE ./my dump.sql;COMMIT\"" 123 | ].join(" ") 124 | ) 125 | end 126 | end 127 | 128 | context "#compress_command" do 129 | let(:deployer) { described_class.new(:dummy_env, options) } 130 | 131 | it "cerates a valid gzip command" do 132 | command = deployer.send( 133 | :compress_command, 134 | "dummy file.sql" 135 | ) 136 | 137 | expect(command).to eq("gzip -9 -f \"dummy file.sql\"") 138 | end 139 | end 140 | 141 | context "#uncompress_command" do 142 | let(:deployer) { described_class.new(:dummy_env, options) } 143 | 144 | it "creates a valid gunzip command" do 145 | command = deployer.send( 146 | :uncompress_command, 147 | "dummy file.sql" 148 | ) 149 | 150 | expect(command).to eq("gzip -d -f \"dummy file.sql\"") 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/docotor_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Doctor do 2 | context "#start" do 3 | it "calls all movefile doctors" do 4 | movefile_doctor = double(:movefile_doctor) 5 | allow(Wordmove::Doctor::Movefile).to receive(:new).and_return(movefile_doctor) 6 | expect(movefile_doctor).to receive(:validate!).exactly(1).times 7 | 8 | mysql_doctor = double(:mysql_doctor) 9 | allow(Wordmove::Doctor::Mysql).to receive(:new).and_return(mysql_doctor) 10 | expect(mysql_doctor).to receive(:check!).exactly(1).times 11 | 12 | wpcli_doctor = double(:wpcli_doctor) 13 | allow(Wordmove::Doctor::Wpcli).to receive(:new).and_return(wpcli_doctor) 14 | expect(wpcli_doctor).to receive(:check!).exactly(1).times 15 | 16 | rsync_doctor = double(:rsync_doctor) 17 | allow(Wordmove::Doctor::Rsync).to receive(:new).and_return(rsync_doctor) 18 | expect(rsync_doctor).to receive(:check!).exactly(1).times 19 | 20 | ssh_doctor = double(:ssh_doctor) 21 | allow(Wordmove::Doctor::Ssh).to receive(:new).and_return(ssh_doctor) 22 | expect(ssh_doctor).to receive(:check!).exactly(1).times 23 | 24 | described_class.start 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/doctor/movefile_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Doctor::Movefile do 2 | let(:movefile_name) { 'multi_environments' } 3 | let(:movefile_dir) { "spec/fixtures/movefiles" } 4 | let(:doctor) { Wordmove::Doctor::Movefile.new(movefile_name, movefile_dir) } 5 | 6 | context ".new" do 7 | it "create an Hash representing the Movefile content" do 8 | expect(doctor.contents).to be_a(Hash) 9 | end 10 | end 11 | 12 | context ".root_keys" do 13 | it "returns all the yml's root keys" do 14 | expected_root_keys = %i[ 15 | global 16 | local 17 | staging 18 | production 19 | missing_protocol 20 | ] 21 | 22 | expect(doctor.root_keys).to eq(expected_root_keys) 23 | end 24 | 25 | context ".validate!" do 26 | it "calls validation on each section of the actual movefile" do 27 | expect(doctor).to receive(:validate_section).exactly(4).times 28 | expect_any_instance_of(Wordmove::Logger).to receive(:task).exactly(5).times 29 | 30 | silence_stream(STDOUT) { doctor.validate! } 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/doctor/mysql_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Doctor::Mysql do 2 | let(:movefile_name) { 'multi_environments' } 3 | let(:movefile_dir) { "spec/fixtures/movefiles" } 4 | let(:doctor) { described_class.new(movefile_name, movefile_dir) } 5 | 6 | context ".new" do 7 | it "implements #check! method" do 8 | expect_any_instance_of(described_class).to receive(:check!) 9 | 10 | silence_stream(STDOUT) { doctor.check! } 11 | end 12 | 13 | it "calls mysql client check" do 14 | expect(doctor).to receive(:mysql_client_doctor) 15 | 16 | silence_stream(STDOUT) { doctor.check! } 17 | end 18 | 19 | it "calls mysqldump check" do 20 | expect(doctor).to receive(:mysqldump_doctor) 21 | 22 | silence_stream(STDOUT) { doctor.check! } 23 | end 24 | 25 | it "calls mysql server check" do 26 | expect(doctor).to receive(:mysql_server_doctor) 27 | 28 | silence_stream(STDOUT) { doctor.check! } 29 | end 30 | 31 | it "calls mysql database check" do 32 | # expect(doctor).to receive(:mysql_database_doctor) 33 | 34 | silence_stream(STDOUT) { doctor.check! } 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/doctor/rsync_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Doctor::Rsync do 2 | let(:doctor) { described_class.new } 3 | 4 | context ".new" do 5 | it "implements #check! method" do 6 | expect_any_instance_of(described_class).to receive(:check!) 7 | 8 | silence_stream(STDOUT) { doctor.check! } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/doctor/ssh_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Doctor::Ssh do 2 | let(:doctor) { described_class.new } 3 | 4 | context ".new" do 5 | it "implements #check! method" do 6 | expect_any_instance_of(described_class).to receive(:check!) 7 | 8 | silence_stream(STDOUT) { doctor.check! } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/doctor/wpcli_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Doctor::Wpcli do 2 | let(:doctor) { described_class.new } 3 | 4 | context ".new" do 5 | it "implements #check! method" do 6 | expect_any_instance_of(described_class).to receive(:check!) 7 | 8 | silence_stream(STDOUT) { doctor.check! } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/environments_list_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::EnvironmentsList do 2 | let(:instance) { described_class.new(options) } 3 | let(:options) { {} } 4 | 5 | describe ".print" do 6 | subject { described_class.print(options) } 7 | 8 | it "create new instance and call its #print" do 9 | expect_any_instance_of(described_class).to receive(:print).once 10 | subject 11 | end 12 | end 13 | 14 | describe ".new" do 15 | subject { instance } 16 | 17 | it "created instance has logger" do 18 | expect(subject.respond_to?(:logger, true)).to be_truthy 19 | end 20 | 21 | it "created instance has movefile" do 22 | expect(subject.respond_to?(:movefile, true)).to be_truthy 23 | end 24 | end 25 | 26 | describe "#print" do 27 | subject { instance.print } 28 | 29 | context "non exist movefile" do 30 | let(:options) { { config: 'non_exists_path' } } 31 | it "call parse_content" do 32 | expect(instance).to receive(:parse_movefile).and_call_original 33 | expect { subject }.to raise_error Wordmove::MovefileNotFound 34 | end 35 | end 36 | 37 | context "valid movefile" do 38 | let(:options) { { config: movefile_path_for('multi_environments') } } 39 | 40 | it "call parse_content" do 41 | expect(instance).to receive(:parse_movefile).and_call_original 42 | subject 43 | end 44 | end 45 | end 46 | 47 | describe "private #output_string" do 48 | subject { instance.send(:output_string, vhost_list: vhost_list) } 49 | 50 | let(:vhost_list) do 51 | [ 52 | { env: :staging, vhost: "https://staging.mysite.example.com" }, 53 | { env: :development, vhost: "http://development.mysite.example.com" } 54 | ] 55 | end 56 | 57 | it "return expected output" do 58 | result = subject 59 | expect(result).to match('staging: https://staging.mysite.example.com') 60 | expect(result).to match('development: http://development.mysite.example.com') 61 | end 62 | end 63 | 64 | describe "private #select_vhost" do 65 | subject { instance.send(:select_vhost, contents: contents) } 66 | 67 | let(:contents) do 68 | { 69 | local: { 70 | vhost: "http://localhost:8080", 71 | wordpress_path: "/home/welaika/sites/your_site", 72 | database: { 73 | name: "database_name", 74 | user: "user", 75 | password: "password", 76 | host: "host" 77 | } 78 | }, 79 | development: { 80 | vhost: "http://development.mysite.example.com", 81 | wordpress_path: "/var/www/your_site", 82 | database: { 83 | name: "database_name", 84 | user: "user", 85 | password: "password", 86 | host: "host" 87 | } 88 | } 89 | } 90 | end 91 | 92 | let(:result_list) do 93 | [ 94 | { env: :local, vhost: "http://localhost:8080" }, 95 | { env: :development, vhost: "http://development.mysite.example.com" } 96 | ] 97 | end 98 | 99 | it "return expected vhost list" do 100 | expect(subject).to match(result_list) 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/features/movefile_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Generators::Movefile do 2 | let(:movefile) { 'movefile.yml' } 3 | let(:tmpdir) { "/tmp/wordmove" } 4 | 5 | before do 6 | @pwd = Dir.pwd 7 | FileUtils.mkdir(tmpdir) 8 | Dir.chdir(tmpdir) 9 | end 10 | 11 | after do 12 | Dir.chdir(@pwd) 13 | FileUtils.rm_rf(tmpdir) 14 | end 15 | 16 | context "::start" do 17 | before do 18 | silence_stream(STDOUT) { Wordmove::Generators::Movefile.start } 19 | end 20 | 21 | it 'creates a Movefile' do 22 | expect(File.exist?(movefile)).to be true 23 | end 24 | 25 | it 'fills local wordpress_path using shell path' do 26 | yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) 27 | expect(yaml['local']['wordpress_path']).to eq(Dir.pwd) 28 | end 29 | 30 | it 'fills database configuration defaults' do 31 | yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) 32 | expect(yaml['local']['database']['name']).to eq('database_name') 33 | expect(yaml['local']['database']['user']).to eq('user') 34 | expect(yaml['local']['database']['password']).to eq('password') 35 | expect(yaml['local']['database']['host']).to eq('127.0.0.1') 36 | end 37 | 38 | it 'creates a Movifile having a "global.sql_adapter" key' do 39 | yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) 40 | expect(yaml['global']).to be_present 41 | expect(yaml['global']['sql_adapter']).to be_present 42 | expect(yaml['global']['sql_adapter']).to eq('wpcli') 43 | end 44 | end 45 | 46 | context "database configuration" do 47 | let(:wp_config) { File.join(File.dirname(__FILE__), "../fixtures/wp-config.php") } 48 | 49 | before do 50 | FileUtils.cp(wp_config, ".") 51 | silence_stream(STDOUT) { Wordmove::Generators::Movefile.start } 52 | end 53 | 54 | it 'fills database configuration from wp-config' do 55 | yaml = YAML.safe_load(ERB.new(File.read(movefile)).result) 56 | expect(yaml['local']['database']['name']).to eq('wordmove_db') 57 | expect(yaml['local']['database']['user']).to eq('wordmove_user') 58 | expect(yaml['local']['database']['password']).to eq('wordmove_password') 59 | expect(yaml['local']['database']['host']).to eq('wordmove_host') 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/Movefile: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: "default" 3 | local: 4 | vhost: "http://vhost.local" 5 | wordpress_path: "~/dev/sites/your_site" 6 | database: 7 | name: "database_name" 8 | user: "user" 9 | password: "password" 10 | host: "host" 11 | remote: 12 | vhost: "http://example.com" 13 | wordpress_path: "/var/www/your_site" 14 | database: 15 | name: "database_name" 16 | user: "user" 17 | password: "password" 18 | host: "host" 19 | ssh: 20 | user: "user" 21 | password: "password" 22 | host: "host" 23 | port: 30000 24 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/multi_environments: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: "default" 3 | 4 | local: 5 | vhost: "http://localhost:8080" 6 | wordpress_path: "/home/welaika/sites/your_site" 7 | 8 | database: 9 | name: "database_name" 10 | user: "user" 11 | password: "password" 12 | host: "host" 13 | 14 | staging: 15 | vhost: "http://staging.mysite.example.com" 16 | wordpress_path: "/var/www/your_site" # use an absolute path here 17 | 18 | database: 19 | name: "database_name" 20 | user: "user" 21 | password: "password" 22 | host: "host" 23 | 24 | ssh: 25 | host: "staging.mysite.example.com" 26 | user: "user" 27 | 28 | production: 29 | vhost: "http://production.mysite.example.com" 30 | wordpress_path: "/var/www/your_site" # use an absolute path here 31 | 32 | database: 33 | name: "database_name" 34 | user: "user" 35 | password: "password" 36 | host: "host" 37 | 38 | ftp: 39 | user: "user" 40 | password: "password" 41 | host: "production.mysite.example.com" 42 | passive: true 43 | 44 | missing_protocol: 45 | vhost: "http://production.mysite.example.com" 46 | wordpress_path: "/var/www/your_site" # use an absolute path here 47 | 48 | database: 49 | name: "database_name" 50 | user: "user" 51 | password: "password" 52 | host: "host" 53 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/multi_environments_wpcli_sql_adapter: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: "wpcli" 3 | 4 | local: 5 | vhost: "http://localhost:8080" 6 | wordpress_path: "/home/welaika/sites/your_site" 7 | 8 | database: 9 | name: "database_name" 10 | user: "user" 11 | password: "password" 12 | host: "host" 13 | 14 | staging: 15 | vhost: "http://staging.mysite.example.com" 16 | wordpress_path: "/var/www/your_site" # use an absolute path here 17 | 18 | database: 19 | name: "database_name" 20 | user: "user" 21 | password: "password" 22 | host: "host" 23 | 24 | ssh: 25 | host: "staging.mysite.example.com" 26 | user: "user" 27 | 28 | production: 29 | vhost: "http://production.mysite.example.com" 30 | wordpress_path: "/var/www/your_site" # use an absolute path here 31 | 32 | database: 33 | name: "database_name" 34 | user: "user" 35 | password: "password" 36 | host: "host" 37 | 38 | ftp: 39 | user: "user" 40 | password: "password" 41 | host: "production.mysite.example.com" 42 | passive: true 43 | 44 | missing_protocol: 45 | vhost: "http://production.mysite.example.com" 46 | wordpress_path: "/var/www/your_site" # use an absolute path here 47 | 48 | database: 49 | name: "database_name" 50 | user: "user" 51 | password: "password" 52 | host: "host" 53 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/with_forbidden_tasks: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: "default" 3 | local: 4 | vhost: "http://vhost.local" 5 | wordpress_path: "~/dev/sites/your_site" 6 | database: 7 | name: "database_name" 8 | user: "user" 9 | password: "password" 10 | host: "host" 11 | remote: 12 | vhost: "http://example.com" 13 | wordpress_path: "/var/www/your_site" 14 | database: 15 | name: "database_name" 16 | user: "user" 17 | password: "password" 18 | host: "host" 19 | ssh: 20 | user: "user" 21 | password: "password" 22 | host: "host" 23 | port: 30000 24 | forbid: 25 | pull: 26 | db: true 27 | push: 28 | db: true 29 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/with_hooks: -------------------------------------------------------------------------------- 1 | <% require 'tmpdir' %> 2 | 3 | global: 4 | sql_adapter: "default" 5 | 6 | local: 7 | vhost: "http://localhost:8080" 8 | wordpress_path: "<%= Dir.tmpdir %>" 9 | 10 | database: 11 | name: "database_name" 12 | user: "user" 13 | password: "password" 14 | host: "host" 15 | 16 | ssh_with_hooks: 17 | vhost: "http://staging.mysite.example.com" 18 | wordpress_path: "/var/www/your_site" # use an absolute path here 19 | 20 | database: 21 | name: "database_name" 22 | user: "user" 23 | password: "password" 24 | host: "host" 25 | 26 | ssh: 27 | host: "staging.mysite.example.com" 28 | user: "user" 29 | 30 | hooks: 31 | push: 32 | before: 33 | - command: 'echo "Calling hook push before local"' 34 | where: local 35 | - command: 'pwd' 36 | where: local 37 | - command: 'echo "Calling hook push before remote"' 38 | where: remote 39 | after: 40 | - command: 'echo "Calling hook push after local"' 41 | where: local 42 | - command: 'echo "Calling hook push after remote"' 43 | where: remote 44 | pull: 45 | before: 46 | - command: 'echo "Calling hook pull before local"' 47 | where: local 48 | - command: 'echo "Calling hook pull before remote"' 49 | where: remote 50 | after: 51 | - command: 'echo "Calling hook pull after local"' 52 | where: local 53 | - command: 'echo "Calling hook pull after remote"' 54 | where: remote 55 | 56 | ftp_with_hooks: 57 | vhost: "http://production.mysite.example.com" 58 | wordpress_path: "/var/www/your_site" # use an absolute path here 59 | 60 | database: 61 | name: "database_name" 62 | user: "user" 63 | password: "password" 64 | host: "host" 65 | 66 | ftp: 67 | user: "user" 68 | password: "password" 69 | host: "production.mysite.example.com" 70 | passive: true 71 | 72 | hooks: 73 | push: 74 | before: 75 | - command: echo "Calling hook push before remote" 76 | where: remote 77 | 78 | ssh_with_hooks_partially_filled: 79 | vhost: "http://staging.mysite.example.com" 80 | wordpress_path: "/var/www/your_site" # use an absolute path here 81 | 82 | database: 83 | name: "database_name" 84 | user: "user" 85 | password: "password" 86 | host: "host" 87 | 88 | ssh: 89 | host: "staging.mysite.example.com" 90 | user: "user" 91 | 92 | hooks: 93 | push: 94 | pull: 95 | after: 96 | - command: echo "I've partially configured my hooks" 97 | where: local 98 | 99 | ssh_with_hooks_which_return_error: 100 | vhost: "http://staging.mysite.example.com" 101 | wordpress_path: "/var/www/your_site" # use an absolute path here 102 | 103 | database: 104 | name: "database_name" 105 | user: "user" 106 | password: "password" 107 | host: "host" 108 | 109 | ssh: 110 | host: "staging.mysite.example.com" 111 | user: "user" 112 | 113 | hooks: 114 | push: 115 | after: 116 | - command: 'exit 127' 117 | where: local 118 | 119 | ssh_with_hooks_which_return_error_raise_false: 120 | vhost: "http://staging.mysite.example.com" 121 | wordpress_path: "/var/www/your_site" # use an absolute path here 122 | 123 | database: 124 | name: "database_name" 125 | user: "user" 126 | password: "password" 127 | host: "host" 128 | 129 | ssh: 130 | host: "staging.mysite.example.com" 131 | user: "user" 132 | 133 | hooks: 134 | push: 135 | after: 136 | - command: 'exit 127' 137 | where: local 138 | raise: false 139 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/with_secrets: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: "default" 3 | local: 4 | vhost: "http://secrets.local" 5 | wordpress_path: "~/dev/sites/your_site" 6 | database: 7 | name: "database_name" 8 | user: "user" 9 | password: "local_database_password" 10 | host: "local_database_host" 11 | remote: 12 | vhost: "http://secrets.example.com" 13 | wordpress_path: "/var/www/your_site" 14 | database: 15 | name: "database_name" 16 | user: "user" 17 | password: "remote_database_password" 18 | host: "remote_database_host" 19 | ssh: 20 | user: "user" 21 | password: "ssh_password" 22 | host: "ssh_host" 23 | port: 30000 24 | ftp: 25 | user: "user" 26 | password: "ftp_password" 27 | host: "ftp_host" 28 | foo: 29 | vhost: "https://foo.bar" 30 | -------------------------------------------------------------------------------- /spec/fixtures/movefiles/with_secrets_with_empty_local_db_password: -------------------------------------------------------------------------------- 1 | global: 2 | sql_adapter: "default" 3 | local: 4 | vhost: "http://secrets.local" 5 | wordpress_path: "~/dev/sites/your_site" 6 | database: 7 | name: "database_name" 8 | user: "user" 9 | password: "" 10 | host: "local_database_host" 11 | remote: 12 | vhost: "http://secrets.example.com" 13 | wordpress_path: "/var/www/your_site" 14 | database: 15 | name: "database_name" 16 | user: "user" 17 | password: "remote_database_password" 18 | host: "remote_database_host" 19 | ssh: 20 | user: "user" 21 | password: "ssh_password" 22 | host: "ssh_host" 23 | port: 30000 24 | ftp: 25 | user: "user" 26 | password: "ftp_password" 27 | host: "ftp_host" 28 | -------------------------------------------------------------------------------- /spec/fixtures/wp-cli.yml: -------------------------------------------------------------------------------- 1 | path: /path/to/steak 2 | -------------------------------------------------------------------------------- /spec/fixtures/wp-config.php: -------------------------------------------------------------------------------- 1 | true, "config" => movefile_path_for('with_hooks') } } 6 | let(:cli) { Wordmove::CLI.new } 7 | 8 | context 'testing comand order' do 9 | let(:options) { common_options.merge("environment" => 'ssh_with_hooks') } 10 | 11 | before do 12 | allow(Wordmove::Hook::Local).to receive(:run) 13 | allow(Wordmove::Hook::Remote).to receive(:run) 14 | end 15 | 16 | it 'checks the order' do 17 | cli.invoke(:push, [], options) 18 | 19 | expect(Wordmove::Hook::Local).to( 20 | have_received(:run).with( 21 | { command: 'echo "Calling hook push before local"', where: 'local' }, 22 | an_instance_of(Hash), 23 | nil 24 | ).ordered 25 | ) 26 | expect(Wordmove::Hook::Local).to( 27 | have_received(:run).with( 28 | { command: 'pwd', where: 'local' }, 29 | an_instance_of(Hash), 30 | nil 31 | ).ordered 32 | ) 33 | expect(Wordmove::Hook::Remote).to( 34 | have_received(:run).with( 35 | { command: 'echo "Calling hook push before remote"', where: 'remote' }, 36 | an_instance_of(Hash), 37 | nil 38 | ).ordered 39 | ) 40 | 41 | expect(Wordmove::Hook::Local).to( 42 | have_received(:run).with( 43 | { command: 'echo "Calling hook push after local"', where: 'local' }, 44 | an_instance_of(Hash), 45 | nil 46 | ).ordered 47 | ) 48 | expect(Wordmove::Hook::Remote).to( 49 | have_received(:run).with( 50 | { command: 'echo "Calling hook push after remote"', where: 'remote' }, 51 | an_instance_of(Hash), 52 | nil 53 | ).ordered 54 | ) 55 | end 56 | end 57 | 58 | context "#run" do 59 | before do 60 | allow_any_instance_of(Wordmove::Deployer::Base) 61 | .to receive(:pull_wordpress) 62 | .and_return(true) 63 | 64 | allow_any_instance_of(Wordmove::Deployer::Base) 65 | .to receive(:push_wordpress) 66 | .and_return(true) 67 | end 68 | 69 | context "when pushing to a remote with ssh" do 70 | before do 71 | allow_any_instance_of(Photocopier::SSH) 72 | .to receive(:exec!) 73 | .with(String) 74 | .and_return(['Stubbed remote stdout', nil, 0]) 75 | end 76 | 77 | let(:options) { common_options.merge("environment" => 'ssh_with_hooks') } 78 | 79 | it "runs registered before local hooks" do 80 | expect { cli.invoke(:push, [], options) } 81 | .to output(/Calling hook push before local/) 82 | .to_stdout_from_any_process 83 | end 84 | 85 | it "runs registered before local hooks in the wordpress folder" do 86 | expect { cli.invoke(:push, [], options) } 87 | .to output(/#{Dir.tmpdir}/) 88 | .to_stdout_from_any_process 89 | end 90 | 91 | it "runs registered before remote hooks" do 92 | expect { cli.invoke(:push, [], options) } 93 | .to output(/Calling hook push before remote/) 94 | .to_stdout_from_any_process 95 | end 96 | 97 | it "runs registered after local hooks" do 98 | expect { cli.invoke(:push, [], options) } 99 | .to output(/Calling hook push after local/) 100 | .to_stdout_from_any_process 101 | end 102 | 103 | it "runs registered after remote hooks" do 104 | expect { cli.invoke(:push, [], options) } 105 | .to output(/Calling hook push after remote/) 106 | .to_stdout_from_any_process 107 | end 108 | 109 | context "if --similate was passed by user on cli" do 110 | let(:options) do 111 | common_options.merge("environment" => 'ssh_with_hooks', "simulate" => true) 112 | end 113 | 114 | it "does not really run any commands" do 115 | expect { cli.invoke(:push, [], options) } 116 | .not_to output(/Output:/) 117 | .to_stdout_from_any_process 118 | end 119 | end 120 | 121 | context "with local hook errored" do 122 | let(:options) { common_options.merge("environment" => 'ssh_with_hooks_which_return_error') } 123 | 124 | it "logs an error and raises a LocalHookException" do 125 | expect do 126 | expect do 127 | cli.invoke(:push, [], options) 128 | end.to raise_exception(Wordmove::LocalHookException) 129 | end.to output(/Error code: 127/) 130 | .to_stdout_from_any_process 131 | end 132 | 133 | context "with raise set to `false`" do 134 | let(:options) do 135 | common_options.merge("environment" => 'ssh_with_hooks_which_return_error_raise_false') 136 | end 137 | 138 | it "logs an error without raising an exeption" do 139 | expect do 140 | expect do 141 | cli.invoke(:push, [], options) 142 | end.to_not raise_exception 143 | end.to output(/Error code: 127/) 144 | .to_stdout_from_any_process 145 | end 146 | end 147 | end 148 | end 149 | 150 | context "when pulling from a remote with ssh" do 151 | before do 152 | allow_any_instance_of(Photocopier::SSH) 153 | .to receive(:exec!) 154 | .with(String) 155 | .and_return(['Stubbed remote stdout', nil, 0]) 156 | end 157 | 158 | let(:options) { common_options.merge("environment" => 'ssh_with_hooks') } 159 | 160 | it "runs registered before local hooks" do 161 | expect { cli.invoke(:pull, [], options) } 162 | .to output(/Calling hook pull before local/) 163 | .to_stdout_from_any_process 164 | end 165 | 166 | it "runs registered before remote hooks" do 167 | expect { cli.invoke(:pull, [], options) } 168 | .to output(/Calling hook pull before remote/) 169 | .to_stdout_from_any_process 170 | end 171 | 172 | it "runs registered after local hooks" do 173 | expect { cli.invoke(:pull, [], options) } 174 | .to output(/Calling hook pull after local/) 175 | .to_stdout_from_any_process 176 | end 177 | 178 | it "runs registered after remote hooks" do 179 | expect { cli.invoke(:pull, [], options) } 180 | .to output(/Calling hook pull after remote/) 181 | .to_stdout_from_any_process 182 | end 183 | 184 | it "return remote stdout" do 185 | expect { cli.invoke(:pull, [], options) } 186 | .to output(/Stubbed remote stdout/) 187 | .to_stdout_from_any_process 188 | end 189 | 190 | context "with remote hook errored" do 191 | before do 192 | allow_any_instance_of(Photocopier::SSH) 193 | .to receive(:exec!) 194 | .with(String) 195 | .and_return(['Stubbed remote stdout', 'Stubbed remote stderr', 1]) 196 | end 197 | 198 | it "returns remote stdout and raise an exception" do 199 | expect do 200 | expect do 201 | cli.invoke(:pull, [], options) 202 | end.to raise_exception(Wordmove::RemoteHookException) 203 | end.to output(/Stubbed remote stderr/) 204 | .to_stdout_from_any_process 205 | end 206 | 207 | it "raises a RemoteHookException" do 208 | expect do 209 | silence_stream(STDOUT) do 210 | cli.invoke(:pull, [], options) 211 | end 212 | end.to raise_exception(Wordmove::RemoteHookException) 213 | end 214 | end 215 | end 216 | 217 | context "when pushing to a remote with ftp" do 218 | let(:options) { common_options.merge("environment" => 'ftp_with_hooks') } 219 | 220 | context "having remote hooks" do 221 | it "does not run the remote hooks" do 222 | expect(Wordmove::Hook::Remote) 223 | .to_not receive(:run) 224 | 225 | silence_stream(STDOUT) do 226 | cli.invoke(:push, [], options) 227 | end 228 | end 229 | end 230 | end 231 | 232 | context "with hooks partially filled" do 233 | let(:options) { common_options.merge("environment" => 'ssh_with_hooks_partially_filled') } 234 | 235 | it "works silently ignoring push hooks are not present" do 236 | expect(Wordmove::Hook::Remote) 237 | .to_not receive(:run) 238 | expect(Wordmove::Hook::Local) 239 | .to_not receive(:run) 240 | 241 | silence_stream(STDOUT) do 242 | cli.invoke(:push, [], options) 243 | end 244 | end 245 | 246 | it "works silently ignoring 'before' step is not present" do 247 | expect { cli.invoke(:pull, [], options) } 248 | .to output(/I've partially configured my hooks/) 249 | .to_stdout_from_any_process 250 | end 251 | end 252 | end 253 | end 254 | 255 | describe Wordmove::Hook::Config do 256 | let(:movefile) { Wordmove::Movefile.new(movefile_path_for('with_hooks')) } 257 | let(:options) { movefile.fetch(false)[:ssh_with_hooks][:hooks] } 258 | let(:config) { described_class.new(options, :push, :before) } 259 | 260 | context "#local_commands" do 261 | it "returns all the local hooks" do 262 | expect(config.remote_commands).to be_kind_of(Array) 263 | expect(config.local_commands.first[:command]).to eq 'echo "Calling hook push before local"' 264 | expect(config.local_commands.second[:command]).to eq 'pwd' 265 | end 266 | end 267 | 268 | context "#remote_commands" do 269 | it "returns all the remote hooks" do 270 | expect(config.remote_commands).to be_kind_of(Array) 271 | expect(config.remote_commands.first[:command]).to eq 'echo "Calling hook push before remote"' 272 | end 273 | end 274 | 275 | context "#empty?" do 276 | it "returns true if `all_commands` array is empty" do 277 | allow(config).to receive(:all_commands).and_return([]) 278 | 279 | expect(config.empty?).to be true 280 | end 281 | 282 | it "returns false if there is at least one hook registered" do 283 | allow(config).to receive(:local_commands).and_return([]) 284 | 285 | expect(config.empty?).to be false 286 | end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /spec/logger/logger_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Logger do 2 | context "#info" do 3 | context "having some string to filter" do 4 | let(:logger) { described_class.new(STDOUT, ['hidden']) } 5 | 6 | it "will hide the passed strings" do 7 | expect { logger.info('What I write is hidden') } 8 | .to output(/What I write is \[secret\]/) 9 | .to_stdout_from_any_process 10 | end 11 | end 12 | 13 | context "having a string with regexp special characters" do 14 | let(:logger) { described_class.new(STDOUT, ['comp/3xPa((w0r]']) } 15 | 16 | it "will hide the passed strings" do 17 | expect { logger.info('What I write is comp/3xPa((w0r]') } 18 | .to output(/What I write is \[secret\]/) 19 | .to_stdout_from_any_process 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/movefile_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::Movefile do 2 | let(:movefile) { described_class.new } 3 | 4 | context ".initialize" do 5 | it "instantiate a logger instance" do 6 | expect(movefile.logger).to be_an_instance_of(Wordmove::Logger) 7 | end 8 | end 9 | 10 | context ".load_env" do 11 | TMPDIR = "/tmp/wordmove".freeze 12 | 13 | let(:path) { File.join(TMPDIR, 'movefile.yml') } 14 | let(:dotenv_path) { File.join(TMPDIR, '.env') } 15 | let(:yaml) { "name: Waldo\njob: Hider" } 16 | let(:dotenv) { "OBIWAN=KENOBI" } 17 | let(:movefile) { described_class.new(nil, path) } 18 | 19 | before do 20 | FileUtils.mkdir(TMPDIR) 21 | allow(movefile).to receive(:current_dir).and_return(TMPDIR) 22 | allow(movefile).to receive(:logger).and_return(double('logger').as_null_object) 23 | File.open(path, 'w') { |f| f.write(yaml) } 24 | end 25 | 26 | after do 27 | FileUtils.rm_rf(TMPDIR) 28 | end 29 | 30 | context "when .env is present" do 31 | before do 32 | File.open(dotenv_path, 'w') { |f| f.write(dotenv) } 33 | end 34 | 35 | it "loads environment variables" do 36 | movefile.load_dotenv(environment: 'local') 37 | 38 | expect(ENV['OBIWAN']).to eq('KENOBI') 39 | end 40 | end 41 | end 42 | 43 | context ".fetch" do 44 | TMPDIR = "/tmp/wordmove".freeze 45 | 46 | let(:path) { File.join(TMPDIR, 'movefile.yml') } 47 | let(:yaml) { "name: Waldo\njob: Hider" } 48 | let(:movefile) { described_class.new(nil, path) } 49 | 50 | before do 51 | FileUtils.mkdir(TMPDIR) 52 | allow(movefile).to receive(:current_dir).and_return(TMPDIR) 53 | allow(movefile).to receive(:logger).and_return(double('logger').as_null_object) 54 | end 55 | 56 | after do 57 | FileUtils.rm_rf(TMPDIR) 58 | end 59 | 60 | context "when Movefile is missing" do 61 | it 'raises an exception' do 62 | expect { movefile.fetch }.to raise_error(Wordmove::MovefileNotFound) 63 | end 64 | end 65 | 66 | context "when Movefile is present" do 67 | before do 68 | File.open(path, 'w') { |f| f.write(yaml) } 69 | end 70 | 71 | it 'finds a Movefile in current dir' do 72 | result = movefile.fetch 73 | expect(result[:name]).to eq('Waldo') 74 | expect(result[:job]).to eq('Hider') 75 | end 76 | 77 | context "when movefile has no extensions" do 78 | let(:path) { File.join(TMPDIR, 'movefile') } 79 | 80 | it 'finds it aswell' do 81 | result = movefile.fetch 82 | expect(result[:name]).to eq('Waldo') 83 | expect(result[:job]).to eq('Hider') 84 | end 85 | end 86 | 87 | context "when Movefile has no extensions and has first capital" do 88 | let(:path) { File.join(TMPDIR, 'Movefile') } 89 | 90 | it 'finds it aswell' do 91 | result = movefile.fetch 92 | expect(result[:name]).to eq('Waldo') 93 | expect(result[:job]).to eq('Hider') 94 | end 95 | end 96 | 97 | context "when movefile.yaml has long extension" do 98 | let(:path) { File.join(TMPDIR, 'movefile.yaml') } 99 | 100 | it 'finds it aswell' do 101 | result = movefile.fetch 102 | expect(result[:name]).to eq('Waldo') 103 | expect(result[:job]).to eq('Hider') 104 | end 105 | end 106 | 107 | context "directories traversal" do 108 | before do 109 | @test_dir = File.join(TMPDIR, "test") 110 | FileUtils.mkdir(@test_dir) 111 | end 112 | 113 | it 'goes up through the directory tree and finds it' do 114 | movefile = described_class.new(nil, @test_dir) 115 | result = movefile.fetch 116 | expect(result[:name]).to eq('Waldo') 117 | expect(result[:job]).to eq('Hider') 118 | end 119 | 120 | context 'Movefile not found, met root node' do 121 | let(:movefile) { described_class.new(nil, '/tmp') } 122 | 123 | it 'raises an exception' do 124 | expect { movefile.fetch }.to raise_error(Wordmove::MovefileNotFound) 125 | end 126 | end 127 | 128 | context 'Movefile not found, found wp-config.php' do 129 | let(:movefile) { described_class.new(nil, '/tmp') } 130 | 131 | before do 132 | FileUtils.touch(File.join(@test_dir, "wp-config.php")) 133 | end 134 | 135 | it 'raises an exception' do 136 | expect { movefile.fetch }.to raise_error(Wordmove::MovefileNotFound) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | 143 | context ".secrets" do 144 | let(:path) { movefile_path_for('with_secrets') } 145 | 146 | it "returns all the secrets found in movefile" do 147 | movefile = described_class.new('with_secrets', path) 148 | expect(movefile.secrets).to eq( 149 | %w[ 150 | local_database_password 151 | local_database_host 152 | http://secrets.local 153 | ~/dev/sites/your_site 154 | remote_database_password 155 | remote_database_host 156 | http://secrets.example.com 157 | ssh_password 158 | ssh_host 159 | ftp_password 160 | ftp_host 161 | /var/www/your_site 162 | https://foo.bar 163 | ] 164 | ) 165 | end 166 | 167 | it "returns all the secrets found in movefile excluding empty string values" do 168 | movefile = described_class.new('with_secrets_with_empty_local_db_password', path) 169 | expect(movefile.secrets).to eq( 170 | %w[ 171 | local_database_host 172 | http://secrets.local 173 | ~/dev/sites/your_site 174 | remote_database_password 175 | remote_database_host 176 | http://secrets.example.com 177 | ssh_password 178 | ssh_host 179 | ftp_password 180 | ftp_host 181 | /var/www/your_site 182 | ] 183 | ) 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 2 | 3 | require "tempfile" 4 | require "pry-byebug" 5 | require "priscilla" 6 | 7 | require "simplecov" 8 | SimpleCov.start do 9 | add_filter "/spec/" 10 | end 11 | 12 | require "wordmove" 13 | 14 | Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |f| require f } 15 | 16 | # I don't know from where this method was imported, 17 | # but since last updates it was lost. I looked about 18 | # it and discovered in Rails 5 was removed becouse not 19 | # thread safe. So I'm copying it from the new Rails 5 20 | # implementation. 21 | # @see https://github.com/rails/rails/commit/481e49c64f790e46f4aff3ed539ed227d2eb46cb 22 | def silence_stream(stream) 23 | old_stream = stream.dup 24 | stream.reopen(RbConfig::CONFIG['host_os'].match?(/mswin|mingw/) ? 'NUL:' : '/dev/null') 25 | stream.sync = true 26 | yield 27 | ensure 28 | stream.reopen(old_stream) 29 | old_stream.close 30 | end 31 | 32 | RSpec.configure do |config| 33 | config.expect_with :rspec do |expectations| 34 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 35 | end 36 | 37 | config.mock_with :rspec do |mocks| 38 | mocks.verify_partial_doubles = true 39 | end 40 | 41 | config.example_status_persistence_file_path = "./spec/examples.txt" 42 | 43 | config.formatter = :documentation 44 | end 45 | -------------------------------------------------------------------------------- /spec/sql_adapter/default_spec.rb: -------------------------------------------------------------------------------- 1 | describe Wordmove::SqlAdapter::Default do 2 | let(:sql_path) { double } 3 | let(:source_config) { double } 4 | let(:dest_config) { double } 5 | let(:adapter) do 6 | Wordmove::SqlAdapter::Default.new( 7 | sql_path, 8 | source_config, 9 | dest_config 10 | ) 11 | end 12 | 13 | context ".initialize" do 14 | it "should assign variables correctly on initialization" do 15 | expect(adapter.sql_path).to eq(sql_path) 16 | expect(adapter.source_config).to eq(source_config) 17 | expect(adapter.dest_config).to eq(dest_config) 18 | end 19 | end 20 | 21 | context ".sql_content" do 22 | let(:sql) do 23 | Tempfile.new('sql').tap do |d| 24 | d.write('DUMP') 25 | d.close 26 | end 27 | end 28 | let(:sql_path) { sql.path } 29 | 30 | it "should read the sql file content" do 31 | expect(adapter.sql_content).to eq('DUMP') 32 | end 33 | end 34 | 35 | context ".adapt!" do 36 | it "should replace host, path and write to sql" do 37 | expect(adapter).to receive(:replace_vhost!).and_return(true) 38 | expect(adapter).to receive(:replace_wordpress_path!).and_return(true) 39 | expect(adapter).to receive(:write_sql!).and_return(true) 40 | adapter.adapt! 41 | end 42 | end 43 | 44 | context ".replace_vhost!" do 45 | let(:sql) do 46 | Tempfile.new('sql').tap do |d| 47 | d.write(File.read(fixture_path_for('dump.sql'))) 48 | d.close 49 | end 50 | end 51 | let(:sql_path) { sql.path } 52 | 53 | context "with port" do 54 | let(:source_config) { { vhost: 'localhost:8080' } } 55 | let(:dest_config) { { vhost: 'foo.bar:8181' } } 56 | 57 | it "should replace domain and port" do 58 | adapter.replace_vhost! 59 | adapter.write_sql! 60 | 61 | expect(File.read(sql)).to match('foo.bar:8181') 62 | expect(File.read(sql)).to_not match('localhost:8080') 63 | end 64 | end 65 | 66 | context "without port" do 67 | let(:source_config) { { vhost: 'localhost' } } 68 | let(:dest_config) { { vhost: 'foo.bar' } } 69 | 70 | it "should replace domain leving port unaltered" do 71 | adapter.replace_vhost! 72 | adapter.write_sql! 73 | 74 | expect(File.read(sql)).to match('foo.bar:8080') 75 | expect(File.read(sql)).to_not match('localhost:8080') 76 | end 77 | end 78 | end 79 | 80 | describe "replace single fields" do 81 | context ".replace_vhost!" do 82 | let(:source_config) { { vhost: "DUMP" } } 83 | let(:dest_config) { { vhost: "FUNK" } } 84 | 85 | it "should replace source vhost with dest vhost" do 86 | expect(adapter).to receive(:replace_field!).with("DUMP", "FUNK").and_return(true) 87 | adapter.replace_vhost! 88 | end 89 | end 90 | 91 | context ".replace_wordpress_path!" do 92 | let(:source_config) { { wordpress_path: "DUMP" } } 93 | let(:dest_config) { { wordpress_path: "FUNK" } } 94 | 95 | it "should replace source vhost with dest wordpress paths" do 96 | expect(adapter).to receive(:replace_field!).with("DUMP", "FUNK").and_return(true) 97 | adapter.replace_wordpress_path! 98 | end 99 | 100 | context "given an absolute path" do 101 | let(:source_config) { { wordpress_absolute_path: "ABSOLUTE_DUMP", wordpress_path: "DUMP" } } 102 | 103 | it "should replace the absolute path instead" do 104 | expect(adapter).to receive(:replace_field!).with("ABSOLUTE_DUMP", "FUNK").and_return(true) 105 | adapter.replace_wordpress_path! 106 | end 107 | end 108 | end 109 | end 110 | 111 | context ".replace_field!" do 112 | it "should replace source vhost with dest vhost" do 113 | expect(adapter).to receive(:serialized_replace!).ordered.with("DUMP", "FUNK").and_return(true) 114 | expect(adapter).to receive(:simple_replace!).ordered.with("DUMP", "FUNK").and_return(true) 115 | adapter.replace_field!("DUMP", "FUNK") 116 | end 117 | end 118 | 119 | context ".serialized_replace!" do 120 | let(:content) do 121 | 'a:3:{i:0;s:20:"http://dump.com/spam";i:1;s:6:"foobar";i:2;s:22:"http://dump.com/foobar";}' 122 | end 123 | let(:sql) do 124 | Tempfile.new('sql').tap do |d| 125 | d.write(content) 126 | d.close 127 | end 128 | end 129 | let(:sql_path) { sql.path } 130 | 131 | it "should replace source vhost with dest vhost" do 132 | adapter.serialized_replace!('http://dump.com', 'http://shrubbery.com') 133 | expect(adapter.sql_content).to eq( 134 | [ 135 | 'a:3:{i:0;s:25:"http://shrubbery.com/spam";i:1;s:6:"foobar";', 136 | 'i:2;s:27:"http://shrubbery.com/foobar";}' 137 | ].join 138 | ) 139 | end 140 | 141 | context "given empty strings" do 142 | let(:content) { 's:0:"";s:3:"foo";s:0:"";' } 143 | 144 | it "should leave them untouched" do 145 | adapter.serialized_replace!('foo', 'sausage') 146 | expect(adapter.sql_content).to eq('s:0:"";s:7:"sausage";s:0:"";') 147 | end 148 | 149 | context "considering escaping" do 150 | let(:content) { 's:0:\"\";s:3:\"foo\";s:0:\"\";' } 151 | 152 | it "should leave them untouched" do 153 | adapter.serialized_replace!('foo', 'sausage') 154 | expect(adapter.sql_content).to eq('s:0:\"\";s:7:\"sausage\";s:0:\"\";') 155 | end 156 | end 157 | end 158 | 159 | context "given strings with escaped content" do 160 | let(:content) { 's:6:"dump\"\"";' } 161 | 162 | it "should calculate the correct final length" do 163 | adapter.serialized_replace!('dump', 'sausage') 164 | expect(adapter.sql_content).to eq('s:9:"sausage\"\"";') 165 | end 166 | end 167 | 168 | context "given multiple types of string quoting" do 169 | let(:content) do 170 | [ 171 | "a:3:{s:20:\\\"http://dump.com/spam\\\";s:6:'foobar';", 172 | "s:22:'http://dump.com/foobar';s:8:'sausages';}" 173 | ].join 174 | end 175 | 176 | it "should handle replacing just as well" do 177 | adapter.serialized_replace!('http://dump.com', 'http://shrubbery.com') 178 | expect(adapter.sql_content).to eq( 179 | [ 180 | "a:3:{s:25:\\\"http://shrubbery.com/spam\\\";s:6:'foobar';", 181 | "s:27:'http://shrubbery.com/foobar';s:8:'sausages';}" 182 | ].join 183 | ) 184 | end 185 | end 186 | 187 | context "given multiple occurences in the same string" do 188 | let(:content) { 'a:1:{i:0;s:52:"ni http://dump.com/spam ni http://dump.com/foobar ni";}' } 189 | 190 | it "should replace all occurences" do 191 | adapter.serialized_replace!('http://dump.com', 'http://shrubbery.com') 192 | expect(adapter.sql_content).to eq( 193 | 'a:1:{i:0;s:62:"ni http://shrubbery.com/spam ni http://shrubbery.com/foobar ni";}' 194 | ) 195 | end 196 | end 197 | end 198 | 199 | context ".simple_replace!" do 200 | let(:content) { "THE DUMP!" } 201 | let(:sql) do 202 | Tempfile.new('sql').tap do |d| 203 | d.write(content) 204 | d.close 205 | end 206 | end 207 | let(:sql_path) { sql.path } 208 | 209 | it "should replace source vhost with dest vhost" do 210 | adapter.simple_replace!("DUMP", "FUNK") 211 | expect(adapter.sql_content).to eq("THE FUNK!") 212 | end 213 | end 214 | 215 | context ".write_sql!" do 216 | let(:content) { "THE DUMP!" } 217 | let(:sql) do 218 | Tempfile.new('sql').tap do |d| 219 | d.write(content) 220 | d.close 221 | end 222 | end 223 | let(:sql_path) { sql.path } 224 | let(:the_funk) { "THE FUNK THE FUNK THE FUNK" } 225 | 226 | it "should write content to file" do 227 | adapter.sql_content = the_funk 228 | adapter.write_sql! 229 | File.open(sql_path).read == the_funk 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /spec/sql_adapter/wpcli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Wordmove::SqlAdapter::Wpcli do 4 | let(:config_key) { :vhost } 5 | let(:source_config) { { vhost: 'sausage' } } 6 | let(:dest_config) { { vhost: 'bacon' } } 7 | let(:local_path) { '/path/to/ham' } 8 | let(:adapter) do 9 | Wordmove::SqlAdapter::Wpcli.new( 10 | source_config, 11 | dest_config, 12 | config_key, 13 | local_path 14 | ) 15 | end 16 | 17 | before do 18 | allow(adapter).to receive(:wp_in_path?).and_return(true) 19 | allow(adapter) 20 | .to receive(:`) 21 | .with('wp cli param-dump --with-values') 22 | .and_return("{}") 23 | end 24 | 25 | context "#command" do 26 | context "having wp-cli.yml in local_path" do 27 | let(:local_path) { fixture_folder_root_relative_path } 28 | 29 | it "returns the right command as a string" do 30 | expect(adapter.command) 31 | .to eq("wp search-replace --path=/path/to/steak sausage bacon --quiet "\ 32 | "--skip-columns=guid --all-tables --allow-root") 33 | end 34 | end 35 | 36 | context "without wp-cli.yml in local_path" do 37 | before do 38 | allow(adapter) 39 | .to receive(:`) 40 | .with('wp cli param-dump --with-values') 41 | .and_return("{\"path\":{\"current\":\"\/path\/to\/pudding\"}}") 42 | end 43 | context "but still reachable by wp-cli" do 44 | it "returns the right command as a string" do 45 | expect(adapter.command) 46 | .to eq("wp search-replace --path=/path/to/pudding sausage bacon --quiet "\ 47 | "--skip-columns=guid --all-tables --allow-root") 48 | end 49 | end 50 | end 51 | 52 | context "without any wp-cli configuration" do 53 | it "returns the right command with '--path' flag set to local_path" do 54 | expect(adapter.command) 55 | .to eq("wp search-replace --path=/path/to/ham sausage bacon --quiet "\ 56 | "--skip-columns=guid --all-tables --allow-root") 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/support/fixture_helpers.rb: -------------------------------------------------------------------------------- 1 | module FixtureHelpers 2 | def fixture_folder_path 3 | File.join(__dir__, '..', 'fixtures') 4 | end 5 | 6 | def fixture_folder_root_relative_path 7 | File.join('spec', 'fixtures') 8 | end 9 | 10 | def fixture_path_for(filename) 11 | File.join(__dir__, '..', 'fixtures', filename) 12 | end 13 | 14 | def fixture_root_relative_path_for(filename) 15 | File.join('spec', 'fixtures', filename) 16 | end 17 | 18 | def movefile_path_for(filename) 19 | fixture_root_relative_path_for("movefiles/#{filename}") 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.include FixtureHelpers 25 | end 26 | -------------------------------------------------------------------------------- /wordmove.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'wordmove/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "wordmove" 7 | spec.version = Wordmove::VERSION 8 | spec.authors = [ 9 | "Stefano Verna", "Ju Liu", "Fabrizio Monti", "Alessandro Fazzi", "Filippo Gangi Dino" 10 | ] 11 | spec.email = [ 12 | "stefano.verna@welaika.com", 13 | "ju.liu@welaika.com", 14 | "fabrizio.monti@welaika.com", 15 | "alessandro.fazzi@welaika.com", 16 | "filippo.gangidino@welaika.com" 17 | ] 18 | 19 | spec.summary = "Wordmove, Capistrano for Wordpress" 20 | spec.description = "Wordmove deploys your WordPress websites at the speed of light." 21 | spec.homepage = "https://github.com/welaika/wordmove" 22 | spec.license = "MIT" 23 | 24 | spec.files = `git ls-files -z` 25 | .split("\x0") 26 | .reject { |f| f.match(%r{^(test|spec|features)/}) } 27 | 28 | spec.bindir = "exe" 29 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 30 | spec.require_paths = ["lib"] 31 | 32 | spec.add_runtime_dependency "activesupport", '~> 6.1' 33 | spec.add_runtime_dependency "colorize", "~> 0.8.1" 34 | spec.add_runtime_dependency "dotenv", "~> 2.7.5" 35 | spec.add_runtime_dependency "kwalify", "~> 0" 36 | spec.add_runtime_dependency "photocopier", "~> 1.4", ">= 1.4.0" 37 | spec.add_runtime_dependency "thor", "~> 0.20.3" 38 | 39 | spec.required_ruby_version = ">= 2.6.0" 40 | 41 | spec.add_development_dependency "bundler", "~> 2.0" 42 | spec.add_development_dependency "priscilla", "~> 1.0" 43 | spec.add_development_dependency "pry-byebug", "~> 3.1" 44 | spec.add_development_dependency "rake", "~> 13.0.1" 45 | spec.add_development_dependency "rspec", "~> 3.9" 46 | spec.add_development_dependency "rubocop", "~> 0.76.0" 47 | spec.add_development_dependency "simplecov", "~> 0.17.1" 48 | 49 | spec.post_install_message = <<-RAINBOW 50 | Starting from version 3.0.0 `database.charset` option is no longer accepted. 51 | Pass the '--default-charecter-set' flag into `database.mysqldump_options` or to 52 | `database.mysql_options` instead, if you need to set the same option. 53 | 54 | Starting from version 3.0.0 the default `global.sql_adapter` is "wpcli". 55 | Therefor `WP-CLI` becomes a required peer dependency, unless you'll 56 | change to the "default" adapter. 57 | RAINBOW 58 | end 59 | --------------------------------------------------------------------------------