wordmove-ng 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  3. data/.github/ISSUE_TEMPLATE/config.yml +11 -0
  4. data/.github/stale.yml +18 -0
  5. data/.github/workflows/release.yml +100 -0
  6. data/.github/workflows/ruby.yml +41 -0
  7. data/.gitignore +21 -0
  8. data/.release-please-manifest.json +3 -0
  9. data/.rspec +2 -0
  10. data/.rubocop.yml +50 -0
  11. data/.rubocop_todo.yml +89 -0
  12. data/.ruby-gemset +1 -0
  13. data/.ruby-version +1 -0
  14. data/.vscode/launch.json +73 -0
  15. data/CHANGELOG.md +78 -0
  16. data/CLAUDE.md +114 -0
  17. data/CONTRIBUTING.md +114 -0
  18. data/Gemfile +14 -0
  19. data/LICENSE +22 -0
  20. data/README.md +340 -0
  21. data/Rakefile +7 -0
  22. data/assets/images/wordmove-ng.png +0 -0
  23. data/bin/bundle +105 -0
  24. data/bin/bundler +17 -0
  25. data/bin/byebug +29 -0
  26. data/bin/coderay +29 -0
  27. data/bin/console +16 -0
  28. data/bin/htmldiff +29 -0
  29. data/bin/kwalify +29 -0
  30. data/bin/ldiff +29 -0
  31. data/bin/pry +29 -0
  32. data/bin/rake +29 -0
  33. data/bin/rspec +29 -0
  34. data/bin/rubocop +29 -0
  35. data/bin/ruby-parse +29 -0
  36. data/bin/ruby-rewrite +29 -0
  37. data/bin/rumoji +29 -0
  38. data/bin/setup +7 -0
  39. data/bin/thor +29 -0
  40. data/bin/wordmove-ng +10 -0
  41. data/deploy/deploy.sh +3 -0
  42. data/exe/wordmove-ng +6 -0
  43. data/lib/wordmove/assets/wordmove_schema_global.yml +16 -0
  44. data/lib/wordmove/assets/wordmove_schema_local.yml +31 -0
  45. data/lib/wordmove/assets/wordmove_schema_remote.yml +167 -0
  46. data/lib/wordmove/cli.rb +134 -0
  47. data/lib/wordmove/deployer/base.rb +356 -0
  48. data/lib/wordmove/deployer/ssh.rb +339 -0
  49. data/lib/wordmove/doctor/movefile.rb +112 -0
  50. data/lib/wordmove/doctor/mysql.rb +136 -0
  51. data/lib/wordmove/doctor/rsync.rb +27 -0
  52. data/lib/wordmove/doctor/ssh.rb +90 -0
  53. data/lib/wordmove/doctor/wpcli.rb +43 -0
  54. data/lib/wordmove/doctor.rb +67 -0
  55. data/lib/wordmove/environments_list.rb +68 -0
  56. data/lib/wordmove/exceptions.rb +10 -0
  57. data/lib/wordmove/generators/movefile.rb +50 -0
  58. data/lib/wordmove/generators/movefile.yml +104 -0
  59. data/lib/wordmove/generators/movefile_adapter.rb +188 -0
  60. data/lib/wordmove/guardian.rb +37 -0
  61. data/lib/wordmove/hook.rb +128 -0
  62. data/lib/wordmove/logger.rb +165 -0
  63. data/lib/wordmove/movefile.rb +129 -0
  64. data/lib/wordmove/prerequisites.rb +57 -0
  65. data/lib/wordmove/sql_adapter/wpcli.rb +72 -0
  66. data/lib/wordmove/ssh_runner.rb +110 -0
  67. data/lib/wordmove/version.rb +3 -0
  68. data/lib/wordmove/wordpress_directory/path.rb +11 -0
  69. data/lib/wordmove/wordpress_directory.rb +41 -0
  70. data/lib/wordmove-ng.rb +4 -0
  71. data/lib/wordmove.rb +51 -0
  72. data/release-please-config.json +25 -0
  73. data/wordmove-ng.gemspec +62 -0
  74. metadata +318 -0
@@ -0,0 +1,339 @@
1
+ require 'pathname'
2
+
3
+ module Wordmove
4
+ module Deployer
5
+ # Syncs files with rsync and the database with mysqldump/mysql over SSH.
6
+ #
7
+ # Database sync follows the same shape in both directions: dump the
8
+ # source, import the dump on the target, then run `wp search-replace` on
9
+ # the target for vhost and wordpress_path. The source database is only
10
+ # ever read, and wp-cli is required on whichever side is the target
11
+ # (remote for push, local for pull).
12
+ class SSH < Base
13
+ attr_reader :local_dump_path,
14
+ :local_backup_path,
15
+ :local_gzipped_dump_path,
16
+ :local_gzipped_backup_path
17
+
18
+ def initialize(environment, options)
19
+ super
20
+
21
+ @runner = SshRunner.new(remote_options[:ssh])
22
+ @copier = Photocopier::SSH.new(photocopier_options).tap { |c| c.logger = logger }
23
+
24
+ @local_dump_path = local_wp_content_dir.path("dump.sql")
25
+ @local_backup_path = local_wp_content_dir.path("local-backup-#{Time.now.to_i}.sql")
26
+ @local_gzipped_dump_path = local_dump_path + '.gz'
27
+ @local_gzipped_backup_path = local_wp_content_dir
28
+ .path("#{environment}-backup-#{Time.now.to_i}.sql.gz")
29
+ end
30
+
31
+ private
32
+
33
+ # Photocopier deletes :gateway and :rsync_options from the hash it is given
34
+ # and we append --dry-run when simulating, so hand it a copy rather than the
35
+ # hash shared with the movefile options.
36
+ def photocopier_options
37
+ ssh_options = remote_options[:ssh].dup
38
+ return ssh_options unless simulate?
39
+
40
+ ssh_options[:rsync_options] = [ssh_options[:rsync_options], '--dry-run'].compact.join(' ')
41
+ ssh_options
42
+ end
43
+
44
+ def push_db
45
+ super
46
+
47
+ return true if simulate?
48
+
49
+ check_push_db_prerequisites!
50
+ backup_remote_db!
51
+ adapt_local_db!
52
+ after_push_cleanup!
53
+ end
54
+
55
+ def pull_db
56
+ super
57
+
58
+ return true if simulate?
59
+
60
+ check_pull_db_prerequisites!
61
+ backup_local_db!
62
+ adapt_remote_db!
63
+ after_pull_cleanup!
64
+ end
65
+
66
+ # Both checks run before any backup or dump so a missing dependency has
67
+ # no side effects.
68
+ def check_push_db_prerequisites!
69
+ check_prerequisites!(local: Prerequisites::DB_SOURCE, remote: Prerequisites::DB_TARGET)
70
+ end
71
+
72
+ def check_pull_db_prerequisites!
73
+ check_prerequisites!(local: Prerequisites::DB_TARGET, remote: Prerequisites::DB_SOURCE)
74
+ end
75
+
76
+ def check_prerequisites!(local:, remote:)
77
+ logger.task_step true, "checking prerequisites"
78
+ warn_about_prefix_collisions
79
+ missing_local = Prerequisites.missing_locally(local)
80
+ missing_remote = Prerequisites.missing_remotely(@runner, remote)
81
+ return true if missing_local.empty? && missing_remote.empty?
82
+
83
+ problems = []
84
+ problems << "locally: #{Prerequisites.describe(missing_local)}" if missing_local.any?
85
+ if missing_remote.any?
86
+ problems << "on \"#{environment}\": #{Prerequisites.describe(missing_remote)}"
87
+ end
88
+ raise UnmetPeerDependencyError,
89
+ "Missing programs required for the database sync (#{problems.join('; ')}). " \
90
+ "Install them, or make sure they are in the login shell $PATH."
91
+ end
92
+
93
+ def warn_about_prefix_collisions
94
+ Wordmove::Movefile.new(options[:config]).prefix_collisions(environment).each do |short, long|
95
+ logger.warn "\"#{short}\" is a prefix of \"#{long}\": the search-replace of the " \
96
+ "shorter value will also rewrite the longer one. Check the result."
97
+ end
98
+ end
99
+
100
+ def backup_remote_db!
101
+ download_remote_db(local_gzipped_backup_path)
102
+ end
103
+
104
+ def adapt_local_db!
105
+ save_local_db(local_dump_path)
106
+ normalize_collations!(local_dump_path)
107
+ run compress_command(local_dump_path)
108
+ with_maintenance_mode(remote: true) do
109
+ import_remote_dump(local_gzipped_dump_path)
110
+ adapt_remote_db_after_import!
111
+ end
112
+ end
113
+
114
+ def after_push_cleanup!
115
+ local_delete(local_gzipped_dump_path)
116
+ end
117
+
118
+ def backup_local_db!
119
+ save_local_db(local_backup_path)
120
+ run compress_command(local_backup_path)
121
+ end
122
+
123
+ def adapt_remote_db!
124
+ download_remote_db(local_gzipped_dump_path)
125
+ run uncompress_command(local_gzipped_dump_path)
126
+ normalize_collations!(local_dump_path)
127
+ with_maintenance_mode(remote: false) do
128
+ run mysql_import_command(local_dump_path, local_options[:database])
129
+ run_wpcli_search_replace(remote_options, local_options, :vhost)
130
+ run_wpcli_search_replace(remote_options, local_options, :wordpress_path)
131
+ end
132
+ end
133
+
134
+ # Wraps the block in `wp maintenance-mode activate` / `deactivate` on the
135
+ # target when global.maintenance_mode (or WORDMOVE_MAINTENANCE_MODE) is
136
+ # set, so visitors see a maintenance page instead of a half-adapted site.
137
+ # Deactivation always runs, even when the block raises.
138
+ def with_maintenance_mode(remote:)
139
+ return yield unless maintenance_mode?
140
+
141
+ side = remote ? remote_options : local_options
142
+ runner = remote ? method(:remote_run) : method(:run)
143
+ runner.call(SqlAdapter::Wpcli.maintenance_mode_command(:activate, side[:wordpress_path]))
144
+ begin
145
+ yield
146
+ ensure
147
+ runner.call(SqlAdapter::Wpcli.maintenance_mode_command(:deactivate,
148
+ side[:wordpress_path]))
149
+ end
150
+ end
151
+
152
+ def maintenance_mode?
153
+ env = ENV.fetch('WORDMOVE_MAINTENANCE_MODE', nil)
154
+ return %w[1 true yes on].include?(env.strip.downcase) unless env.nil? || env.strip.empty?
155
+
156
+ options.dig(:global, :maintenance_mode) == true
157
+ end
158
+
159
+ def after_pull_cleanup!
160
+ local_delete(local_dump_path)
161
+ end
162
+
163
+ def adapt_remote_db_after_import!
164
+ remote_run_wpcli_search_replace(local_options, remote_options, :vhost)
165
+ remote_run_wpcli_search_replace(local_options, remote_options, :wordpress_path)
166
+ rescue ShellCommandError => e
167
+ logger.error "The remote database was imported but could not be adapted: #{e.message}"
168
+ logger.error "The remote site may now reference the local vhost or path. " \
169
+ "A backup of the remote database taken before the import is at: " \
170
+ "#{local_gzipped_backup_path}"
171
+ raise
172
+ end
173
+
174
+ def wpcli_search_replace(from, to, config_key, path:, remote:)
175
+ return if options[:no_adapt]
176
+
177
+ logger.task_step !remote, "adapt dump for #{config_key}"
178
+ return if simulate?
179
+
180
+ SqlAdapter::Wpcli.new(from, to, config_key, path, remote: remote).command
181
+ end
182
+
183
+ def run_wpcli_search_replace(from, to, config_key)
184
+ command = wpcli_search_replace(
185
+ from, to, config_key, path: local_options[:wordpress_path], remote: false
186
+ )
187
+ run(command) if command
188
+ end
189
+
190
+ def remote_run_wpcli_search_replace(from, to, config_key)
191
+ command = wpcli_search_replace(
192
+ from, to, config_key, path: remote_options[:wordpress_path], remote: true
193
+ )
194
+ remote_run(command) if command
195
+ end
196
+
197
+ # Directory transfers go through rsync (Photocopier), which honours the
198
+ # --dry-run option set in #initialize when simulating.
199
+ %w[get_directory put_directory].each do |command|
200
+ define_method "remote_#{command}" do |*args|
201
+ logger.task_step false, "#{command}: #{args.join(' ')}"
202
+ @copier.send(command, *args)
203
+ end
204
+ end
205
+
206
+ # Single file transfers and deletes go through the system scp/ssh binaries.
207
+ def remote_get(remote_path, local_path)
208
+ logger.task_step false, "get: #{remote_path} #{local_path}"
209
+ return true if simulate?
210
+
211
+ ensure_success!(@runner.get(remote_path, local_path), "get #{remote_path}")
212
+ end
213
+
214
+ def remote_put(local_path, remote_path)
215
+ logger.task_step false, "put: #{local_path} #{remote_path}"
216
+ return true if simulate?
217
+
218
+ ensure_success!(@runner.put(local_path, remote_path), "put #{remote_path}")
219
+ end
220
+
221
+ def remote_delete(remote_path)
222
+ logger.task_step false, "delete: #{remote_path}"
223
+ return true if simulate?
224
+
225
+ ensure_success!(@runner.delete(remote_path), "delete #{remote_path}")
226
+ end
227
+
228
+ def remote_run(command)
229
+ logger.task_step false, command
230
+ return true if simulate?
231
+
232
+ ensure_success!(@runner.run(command), command)
233
+ end
234
+
235
+ def ensure_success!(result, description)
236
+ _stdout, stderr, exit_code = result
237
+ return true if exit_code.zero?
238
+
239
+ raise(
240
+ ShellCommandError,
241
+ "Error code #{exit_code} returned by command \"#{description}\": #{stderr}"
242
+ )
243
+ end
244
+
245
+ def download_remote_db(local_gizipped_dump_path)
246
+ remote_dump_path = remote_wp_content_dir.path("dump.sql")
247
+ # dump remote db into file
248
+ remote_run mysql_dump_command(remote_options[:database], remote_dump_path)
249
+ remote_run compress_command(remote_dump_path)
250
+ remote_dump_path += '.gz'
251
+ # download remote dump
252
+ remote_get(remote_dump_path, local_gizipped_dump_path)
253
+ remote_delete(remote_dump_path)
254
+ end
255
+
256
+ def import_remote_dump(local_gizipped_dump_path)
257
+ remote_dump_path = remote_wp_content_dir.path("dump.sql")
258
+ remote_gizipped_dump_path = remote_dump_path + '.gz'
259
+
260
+ remote_put(local_gizipped_dump_path, remote_gizipped_dump_path)
261
+ remote_run uncompress_command(remote_gizipped_dump_path)
262
+ remote_run mysql_import_command(remote_dump_path, remote_options[:database])
263
+ remote_delete(remote_dump_path)
264
+ end
265
+
266
+ %w[uploads themes plugins mu_plugins languages].each do |task|
267
+ define_method "push_#{task}" do
268
+ logger.task "Pushing #{task.titleize}"
269
+ local_path = local_options[:wordpress_path]
270
+ remote_path = remote_options[:wordpress_path]
271
+
272
+ remote_put_directory(local_path, remote_path,
273
+ push_exclude_paths(task), push_inlcude_paths(task))
274
+ end
275
+
276
+ define_method "pull_#{task}" do
277
+ logger.task "Pulling #{task.titleize}"
278
+ local_path = local_options[:wordpress_path]
279
+ remote_path = remote_options[:wordpress_path]
280
+
281
+ remote_get_directory(remote_path, local_path,
282
+ pull_exclude_paths(task), pull_include_paths(task))
283
+ end
284
+ end
285
+
286
+ def push_inlcude_paths(task)
287
+ Pathname.new(send(:"local_#{task}_dir").relative_path)
288
+ .ascend
289
+ .each_with_object([]) do |directory, array|
290
+ path = directory.to_path
291
+ path.prepend('/') unless path.match? %r{^/}
292
+ path.concat('/') unless path.match? %r{/$}
293
+ array << path
294
+ end
295
+ end
296
+
297
+ def push_exclude_paths(task)
298
+ Pathname.new(send(:"local_#{task}_dir").relative_path)
299
+ .dirname
300
+ .ascend
301
+ .each_with_object([]) do |directory, array|
302
+ path = directory.to_path
303
+ path.prepend('/') unless path.match? %r{^/}
304
+ path.concat('/') unless path.match? %r{/$}
305
+ path.concat('*')
306
+ array << path
307
+ end
308
+ .concat(paths_to_exclude)
309
+ .push('/*')
310
+ end
311
+
312
+ def pull_include_paths(task)
313
+ Pathname.new(send(:"remote_#{task}_dir").relative_path)
314
+ .ascend
315
+ .each_with_object([]) do |directory, array|
316
+ path = directory.to_path
317
+ path.prepend('/') unless path.match? %r{^/}
318
+ path.concat('/') unless path.match? %r{/$}
319
+ array << path
320
+ end
321
+ end
322
+
323
+ def pull_exclude_paths(task)
324
+ Pathname.new(send(:"remote_#{task}_dir").relative_path)
325
+ .dirname
326
+ .ascend
327
+ .each_with_object([]) do |directory, array|
328
+ path = directory.to_path
329
+ path.prepend('/') unless path.match? %r{^/}
330
+ path.concat('/') unless path.match? %r{/$}
331
+ path.concat('*')
332
+ array << path
333
+ end
334
+ .concat(paths_to_exclude)
335
+ .push('/*')
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,112 @@
1
+ module Wordmove
2
+ class Doctor
3
+ class Movefile
4
+ MANDATORY_SECTIONS = %i[local].freeze
5
+ OPTIONAL_SECTIONS = %i[global].freeze
6
+ attr_reader :movefile, :contents, :root_keys
7
+
8
+ def initialize(name = nil, dir = '.')
9
+ @movefile = Wordmove::Movefile.new(name, dir)
10
+
11
+ begin
12
+ @contents = movefile.fetch
13
+ @root_keys = contents.keys
14
+ rescue Psych::SyntaxError
15
+ movefile.logger.error "Your movefile is not parsable due to a syntax error" \
16
+ "so we can't continue to validate it."
17
+ movefile.logger.debug "You could try to use https://yamlvalidator.com/ to" \
18
+ "get a clue about the problem."
19
+ end
20
+ end
21
+
22
+ def validate!
23
+ return false unless root_keys
24
+
25
+ MANDATORY_SECTIONS.each do |key|
26
+ movefile.logger.task "Validating movefile section: #{key}"
27
+ validate_mandatory_section(key)
28
+ end
29
+
30
+ OPTIONAL_SECTIONS.each do |key|
31
+ next unless root_keys.delete(key)
32
+
33
+ movefile.logger.task "Validating movefile section: #{key}"
34
+ validate_section(key)
35
+ end
36
+
37
+ root_keys.each do |remote|
38
+ movefile.logger.task "Validating movefile section: #{remote}"
39
+ validate_remote_section(remote)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def validate_section(key)
46
+ validator = validator_for(key)
47
+
48
+ errors = validator.validate(contents[key].deep_stringify_keys)
49
+
50
+ if errors && errors.empty?
51
+ movefile.logger.success "Formal validation passed"
52
+
53
+ return true
54
+ end
55
+
56
+ errors.each do |e|
57
+ movefile.logger.error "[#{e.path}] #{e.message}"
58
+ end
59
+ end
60
+
61
+ def validate_mandatory_section(key)
62
+ return false unless root_keys.delete(key) do
63
+ movefile.logger.error "#{key} section not present"
64
+
65
+ false
66
+ end
67
+
68
+ validate_section(key)
69
+ end
70
+
71
+ def validate_remote_section(key)
72
+ return false unless validate_protocol_presence(contents[key].keys)
73
+
74
+ validate_prefix_collisions(key)
75
+ validate_section(key)
76
+ end
77
+
78
+ def validate_prefix_collisions(key)
79
+ movefile.prefix_collisions(key).each do |short, long|
80
+ movefile.logger.error "\"#{short}\" is a prefix of \"#{long}\": wp search-replace " \
81
+ "would rewrite the longer value too. Use distinct vhosts " \
82
+ "and wordpress_paths for local and #{key}."
83
+ end
84
+ end
85
+
86
+ def validate_protocol_presence(keys)
87
+ if keys.include?(:ftp)
88
+ movefile.logger.error "This remote is configured with `ftp`, but FTP support was " \
89
+ "removed in wordmove-ng 6.0. Switch it to `ssh`."
90
+ return false
91
+ end
92
+ return true if keys.include?(:ssh)
93
+
94
+ movefile.logger.error "This remote has no ssh protocol defined"
95
+
96
+ false
97
+ end
98
+
99
+ def validator_for(key)
100
+ suffix = if (MANDATORY_SECTIONS + OPTIONAL_SECTIONS).include? key
101
+ key
102
+ else
103
+ 'remote'
104
+ end
105
+
106
+ schema = Kwalify::Yaml.load_file("#{__dir__}/../assets/wordmove_schema_#{suffix}.yml")
107
+
108
+ Kwalify::Validator.new(schema)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,136 @@
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
+ nil
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 mysql_client_in_path?
30
+ logger.success "`mysql`/`mariadb` command is in $PATH"
31
+ else
32
+ logger.error "`mysql`/`mariadb` command is not in $PATH"
33
+ end
34
+ end
35
+
36
+ def mysqldump_doctor
37
+ if mysqldump_in_path?
38
+ logger.success "`mysqldump`/`mariadb-dump` command is in $PATH"
39
+ else
40
+ logger.error "`mysqldump`/`mariadb-dump` 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
+ mysql_options = config[:mysql_options]
83
+ command = [mysql_client_binary]
84
+ command << "--host=#{Shellwords.escape(config[:host])}" if config[:host].present?
85
+ command << "--port=#{Shellwords.escape(config[:port])}" if config[:port].present?
86
+ if (socket = mysql_socket_option)
87
+ command << "--socket=#{Shellwords.escape(socket)}"
88
+ end
89
+ command << "--user=#{Shellwords.escape(config[:user])}" if config[:user].present?
90
+ if config[:password].present?
91
+ command << "--password=#{Shellwords.escape(config[:password])}"
92
+ end
93
+ command << Shellwords.split(mysql_options) if mysql_options.present?
94
+ command << database if database.present?
95
+ command << "-e'QUIT'"
96
+ command.join(" ")
97
+ end
98
+
99
+ def mysql_client_binary
100
+ '$(command -v mariadb >/dev/null 2>&1 && echo mariadb || echo mysql)'
101
+ end
102
+
103
+ def mysql_client_in_path?
104
+ system("which mysql", out: File::NULL) || system("which mariadb", out: File::NULL)
105
+ end
106
+
107
+ def mysqldump_in_path?
108
+ system("which mysqldump", out: File::NULL) || system("which mariadb-dump", out: File::NULL)
109
+ end
110
+
111
+ def mysql_socket_option
112
+ return if mysql_option_includes_socket?(config[:mysql_options])
113
+
114
+ config[:socket].presence || mysql_socket_from_options(config[:mysqldump_options])
115
+ end
116
+
117
+ def mysql_option_includes_socket?(option_string)
118
+ mysql_socket_from_options(option_string).present?
119
+ end
120
+
121
+ def mysql_socket_from_options(option_string)
122
+ return if option_string.to_s.empty?
123
+
124
+ args = Shellwords.split(option_string)
125
+ socket_index = args.index('--socket')
126
+ return args[socket_index + 1] if socket_index && args[socket_index + 1]
127
+
128
+ args.each do |arg|
129
+ return Regexp.last_match(1) if arg.match(/\A--socket=(.+)\z/)
130
+ end
131
+
132
+ nil
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,27 @@
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
+ version_output = `rsync --version | head -n1 2>&1`
14
+
15
+ if version_output.downcase.include?("openrsync")
16
+ protocol_version = version_output[/protocol version (\d+)/i, 1]
17
+ logger.success "openrsync detected (protocol version #{protocol_version || 'unknown'})"
18
+ elsif (match = /\d+\.\d+\.\d+/.match(version_output))
19
+ logger.success "rsync is installed at version #{match[0]}"
20
+ else
21
+ logger.error "rsync not found or the version could not be detected. " \
22
+ "Output was: #{version_output.strip}"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end