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,134 @@
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
+ rescue Wordmove::MovefileNotFound => e
19
+ logger.error(e.message)
20
+ exit 1
21
+ rescue Psych::SyntaxError => e
22
+ logger.error("Your movefile is not parsable due to a syntax error: #{e.message}")
23
+ exit 1
24
+ end
25
+
26
+ shared_options = {
27
+ wordpress: { aliases: "-w", type: :boolean },
28
+ uploads: { aliases: "-u", type: :boolean },
29
+ themes: { aliases: "-t", type: :boolean },
30
+ plugins: { aliases: "-p", type: :boolean },
31
+ mu_plugins: { aliases: "-m", type: :boolean },
32
+ languages: { aliases: "-l", type: :boolean },
33
+ db: { aliases: "-d", type: :boolean },
34
+ verbose: { aliases: "-v", type: :boolean },
35
+ simulate: { aliases: "-s", type: :boolean },
36
+ environment: { aliases: "-e" },
37
+ config: { aliases: "-c" },
38
+ debug: { type: :boolean },
39
+ no_adapt: { type: :boolean },
40
+ all: { type: :boolean }
41
+ }
42
+
43
+ no_tasks do
44
+ def handle_options(options)
45
+ wordpress_options.each do |task|
46
+ yield task if options[task] || (options["all"] && options[task] != false)
47
+ end
48
+ end
49
+
50
+ def wordpress_options
51
+ %w[wordpress uploads themes plugins mu_plugins languages db]
52
+ end
53
+
54
+ def ensure_wordpress_options_presence!(options)
55
+ return if (options.keys & (wordpress_options + ["all"])).present?
56
+
57
+ puts "No options given. See wordmove --help"
58
+ exit 1
59
+ end
60
+
61
+ def logger
62
+ Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG }
63
+ end
64
+ end
65
+
66
+ desc "list", "List all environments and vhosts"
67
+ shared_options.each do |option, args|
68
+ method_option option, args
69
+ end
70
+ def list
71
+ Wordmove::EnvironmentsList.print(options)
72
+ rescue Wordmove::MovefileNotFound => e
73
+ logger.error(e.message)
74
+ exit 1
75
+ rescue Psych::SyntaxError => e
76
+ logger.error("Your movefile is not parsable due to a syntax error: #{e.message}")
77
+ exit 1
78
+ end
79
+
80
+ desc "pull", "Pulls WP data from remote host to the local machine"
81
+ shared_options.each do |option, args|
82
+ method_option option, args
83
+ end
84
+ def pull
85
+ ensure_wordpress_options_presence!(options)
86
+ begin
87
+ deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys)
88
+ rescue MovefileNotFound => e
89
+ logger.error(e.message)
90
+ exit 1
91
+ rescue Psych::SyntaxError => e
92
+ logger.error("Your movefile is not parsable due to a syntax error: #{e.message}")
93
+ exit 1
94
+ end
95
+
96
+ Wordmove::Hook.run(:pull, :before, options)
97
+
98
+ guardian = Wordmove::Guardian.new(options: options, action: :pull)
99
+
100
+ handle_options(options) do |task|
101
+ deployer.send("pull_#{task}") if guardian.allows(task.to_sym)
102
+ end
103
+
104
+ Wordmove::Hook.run(:pull, :after, options)
105
+ end
106
+
107
+ desc "push", "Pushes WP data from local machine to remote host"
108
+ shared_options.each do |option, args|
109
+ method_option option, args
110
+ end
111
+ def push
112
+ ensure_wordpress_options_presence!(options)
113
+ begin
114
+ deployer = Wordmove::Deployer::Base.deployer_for(options.deep_symbolize_keys)
115
+ rescue MovefileNotFound => e
116
+ logger.error(e.message)
117
+ exit 1
118
+ rescue Psych::SyntaxError => e
119
+ logger.error("Your movefile is not parsable due to a syntax error: #{e.message}")
120
+ exit 1
121
+ end
122
+
123
+ Wordmove::Hook.run(:push, :before, options)
124
+
125
+ guardian = Wordmove::Guardian.new(options: options, action: :push)
126
+
127
+ handle_options(options) do |task|
128
+ deployer.send("push_#{task}") if guardian.allows(task.to_sym)
129
+ end
130
+
131
+ Wordmove::Hook.run(:push, :after, options)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,356 @@
1
+ require 'tempfile'
2
+ require 'fileutils'
3
+
4
+ module Wordmove
5
+ module Deployer
6
+ class Base
7
+ attr_reader :options, :logger, :environment
8
+
9
+ SANDBOX_MAGIC_COMMENT = '/*!999999- enable the sandbox mode */'.freeze
10
+
11
+ class << self
12
+ def deployer_for(cli_options)
13
+ movefile = Wordmove::Movefile.new(cli_options[:config])
14
+ movefile.load_dotenv(cli_options)
15
+
16
+ options = movefile.fetch.merge! cli_options
17
+ environment = movefile.environment(cli_options)
18
+
19
+ # localとenvironmentのdatabaseにoriginを追加
20
+ options[:local][:database][:origin] = 'local'
21
+ options[environment][:database][:origin] = 'remote'
22
+
23
+ if options[environment][:ftp]
24
+ raise NoAdapterFound,
25
+ "FTP support was removed in wordmove-ng 6.0, but the \"#{environment}\" " \
26
+ "environment is configured with an `ftp` block. Switch it to `ssh`, or " \
27
+ "keep using the legacy `wordmove` 5.x gem for FTP-only hosts."
28
+ end
29
+
30
+ warn_about_removed_sql_adapter(options, movefile)
31
+
32
+ return SSH.new(environment, options) if options[environment][:ssh]
33
+
34
+ raise NoAdapterFound, "No valid adapter found."
35
+ end
36
+
37
+ # `global.sql_adapter` no longer selects anything: URL/path adaptation is
38
+ # always done with wp-cli on the target. Warn once so people notice.
39
+ def warn_about_removed_sql_adapter(options, movefile)
40
+ adapter = options.dig(:global, :sql_adapter)
41
+ return if adapter.nil? || adapter.to_s == 'wpcli'
42
+
43
+ logger(movefile.secrets).warn(
44
+ "`global.sql_adapter: #{adapter}` is ignored since wordmove-ng 6.0; database " \
45
+ "adaptation always uses wp-cli on the target. Remove the key from your movefile."
46
+ )
47
+ end
48
+
49
+ def current_dir
50
+ '.'
51
+ end
52
+
53
+ def logger(secrets)
54
+ Logger.new(STDOUT, secrets).tap { |l| l.level = Logger::DEBUG }
55
+ end
56
+ end
57
+
58
+ def initialize(environment, options = {})
59
+ @environment = environment.to_sym
60
+ @options = options
61
+
62
+ movefile_secrets = Wordmove::Movefile.new(options[:config]).secrets
63
+ @logger = self.class.logger(movefile_secrets)
64
+ end
65
+
66
+ def push_db
67
+ logger.task "Pushing Database"
68
+ end
69
+
70
+ def pull_db
71
+ logger.task "Pulling Database"
72
+ end
73
+
74
+ def exclude_dir_contents(path)
75
+ "#{path}/*"
76
+ end
77
+
78
+ def push_wordpress
79
+ logger.task "Pushing wordpress core"
80
+
81
+ local_path = local_options[:wordpress_path]
82
+ remote_path = remote_options[:wordpress_path]
83
+ exclude_wp_content = exclude_dir_contents(local_wp_content_dir.relative_path)
84
+ exclude_paths = paths_to_exclude.push(exclude_wp_content)
85
+
86
+ remote_put_directory(local_path, remote_path, exclude_paths)
87
+ end
88
+
89
+ def pull_wordpress
90
+ logger.task "Pulling wordpress core"
91
+
92
+ local_path = local_options[:wordpress_path]
93
+ remote_path = remote_options[:wordpress_path]
94
+ exclude_wp_content = exclude_dir_contents(remote_wp_content_dir.relative_path)
95
+ exclude_paths = paths_to_exclude.push(exclude_wp_content)
96
+
97
+ remote_get_directory(remote_path, local_path, exclude_paths)
98
+ end
99
+
100
+ protected
101
+
102
+ def paths_to_exclude
103
+ remote_options[:exclude] || []
104
+ end
105
+
106
+ def run(command)
107
+ logger.task_step true, command
108
+ return true if simulate?
109
+
110
+ system(command)
111
+ raise ShellCommandError, "Return code reports an error" unless $CHILD_STATUS.success?
112
+ end
113
+
114
+ def simulate?
115
+ options[:simulate]
116
+ end
117
+
118
+ [
119
+ WordpressDirectory::Path::WP_CONTENT,
120
+ WordpressDirectory::Path::PLUGINS,
121
+ WordpressDirectory::Path::MU_PLUGINS,
122
+ WordpressDirectory::Path::THEMES,
123
+ WordpressDirectory::Path::UPLOADS,
124
+ WordpressDirectory::Path::LANGUAGES
125
+ ].each do |type|
126
+ %i[remote local].each do |location|
127
+ define_method "#{location}_#{type}_dir" do
128
+ options = send("#{location}_options")
129
+ WordpressDirectory.new(type, options)
130
+ end
131
+ end
132
+ end
133
+
134
+ def mysql_dump_command(options, save_to_path)
135
+ command = [mysql_dump_binary]
136
+ command << "--host=#{Shellwords.escape(options[:host])}" if options[:host].present?
137
+ command << "--port=#{Shellwords.escape(options[:port])}" if options[:port].present?
138
+ if (socket = mysql_socket_option(options, :mysqldump_options))
139
+ command << "--socket=#{Shellwords.escape(socket)}"
140
+ end
141
+ command << "--user=#{Shellwords.escape(options[:user])}" if options[:user].present?
142
+ if options[:password].present?
143
+ command << "--password=#{Shellwords.escape(options[:password])}"
144
+ end
145
+ command << "--result-file=#{Shellwords.escape(save_to_path)}"
146
+ if options[:mysqldump_options].present?
147
+ command << Shellwords.split(options[:mysqldump_options])
148
+ end
149
+ command << Shellwords.escape(options[:name])
150
+ command.join(" ")
151
+ end
152
+
153
+ def mysql_import_command(dump_path, options)
154
+ mysql_command = mysql_client_command(options, import: true)
155
+ escaped_dump_path = Shellwords.escape(dump_path)
156
+
157
+ init_commands = [
158
+ "SET autocommit=0",
159
+ "SET FOREIGN_KEY_CHECKS=0"
160
+ ].join('; ')
161
+
162
+ [
163
+ "first_line=$(head -n 1 #{escaped_dump_path} 2>/dev/null || true)",
164
+ 'tmp_dump="$(mktemp)"',
165
+ %(if [ "$first_line" = '#{SANDBOX_MAGIC_COMMENT}' ]; then),
166
+ "tail -n +2 #{escaped_dump_path} > \"$tmp_dump\"",
167
+ 'else',
168
+ "cat #{escaped_dump_path} > \"$tmp_dump\"",
169
+ 'fi',
170
+ 'printf "\\\\nCOMMIT;\\\\n" >> "$tmp_dump"',
171
+ "#{mysql_command} --init-command=\"#{init_commands}\" < \"$tmp_dump\"",
172
+ 'import_status=$?',
173
+ 'rm -f "$tmp_dump"',
174
+ 'exit $import_status'
175
+ ].join("\n")
176
+ end
177
+
178
+ def compress_command(path)
179
+ "gzip -9 -f #{Shellwords.escape(path)}"
180
+ end
181
+
182
+ def uncompress_command(path)
183
+ "gzip -d -f #{Shellwords.escape(path)}"
184
+ end
185
+
186
+ def local_delete(path)
187
+ logger.task_step true, "delete: '#{path}'"
188
+ File.delete(path) unless simulate?
189
+ end
190
+
191
+ def save_local_db(local_dump_path)
192
+ # dump local mysql into file
193
+ run mysql_dump_command(local_options[:database], local_dump_path)
194
+ end
195
+
196
+ def remote_options
197
+ options[environment].clone
198
+ end
199
+
200
+ def local_options
201
+ options[:local].clone
202
+ end
203
+
204
+ def mysql_client_binary
205
+ '$(command -v mariadb >/dev/null 2>&1 && echo mariadb || echo mysql)'
206
+ end
207
+
208
+ def mysql_dump_binary
209
+ '$(command -v mariadb-dump >/dev/null 2>&1 && echo mariadb-dump || echo mysqldump)'
210
+ end
211
+
212
+ def mysql_client_command(options, import: false)
213
+ mysql_options = options[:mysql_options]
214
+ command = [mysql_client_binary]
215
+ command << "--host=#{Shellwords.escape(options[:host])}" if options[:host].present?
216
+ command << "--port=#{Shellwords.escape(options[:port])}" if options[:port].present?
217
+ if (socket = mysql_socket_option(options, :mysql_options))
218
+ command << "--socket=#{Shellwords.escape(socket)}"
219
+ end
220
+ command << "--user=#{Shellwords.escape(options[:user])}" if options[:user].present?
221
+ if options[:password].present?
222
+ command << "--password=#{Shellwords.escape(options[:password])}"
223
+ end
224
+ command << "--database=#{Shellwords.escape(options[:name])}"
225
+ command << "--force" if import
226
+ if !mysql_options.to_s.match?(/--(?:skip-)?binary-mode\b/) && import
227
+ command << "--binary-mode"
228
+ end
229
+ command << Shellwords.split(mysql_options) if mysql_options.present?
230
+ command.join(" ")
231
+ end
232
+
233
+ def normalize_collations!(dump_path)
234
+ return if simulate?
235
+
236
+ mappings = collation_fallbacks
237
+ charset_map = charset_fallbacks
238
+ return if mappings.empty? && charset_map.empty?
239
+
240
+ collation_matcher = Regexp.union(mappings.keys) unless mappings.empty?
241
+ temp_dump = Tempfile.new(['wordmove-collation', '.sql'])
242
+ begin
243
+ File.open(dump_path, 'rb') do |input|
244
+ File.open(temp_dump.path, 'wb') do |output|
245
+ input.each_line do |line|
246
+ unless collation_matcher && line.match?(collation_matcher)
247
+ output.write(line)
248
+ next
249
+ end
250
+
251
+ # Ensure we do not explode on invalid byte sequences coming from dumps
252
+ safe_line = begin
253
+ line.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
254
+ rescue StandardError
255
+ line.dup.force_encoding(Encoding::UTF_8)
256
+ end
257
+
258
+ replaced_collation = false
259
+ mappings.each do |pattern, replacement|
260
+ next unless safe_line.match?(pattern)
261
+
262
+ if replacement.is_a?(Hash)
263
+ safe_line = safe_line.gsub(pattern, replacement[:collation])
264
+ replaced_collation = true
265
+ if replacement[:charset]
266
+ safe_line = safe_line.gsub(/utf8mb3\b/, replacement[:charset])
267
+ end
268
+ else
269
+ safe_line = safe_line.gsub(pattern, replacement)
270
+ replaced_collation = true
271
+ end
272
+ end
273
+
274
+ # If we replaced collations but still see utf8mb3, upgrade charset to avoid mismatches
275
+ if replaced_collation
276
+ charset_map.each do |pattern, replacement|
277
+ safe_line = safe_line.gsub(pattern, replacement)
278
+ end
279
+ end
280
+ output.write(safe_line)
281
+ end
282
+ end
283
+ end
284
+
285
+ FileUtils.mv(temp_dump.path, dump_path)
286
+ ensure
287
+ temp_dump.close!
288
+ end
289
+ end
290
+
291
+ def collation_fallbacks
292
+ raw_mappings = options.dig(:global, :collation_fallbacks) || default_collation_fallbacks
293
+ return {} unless raw_mappings
294
+
295
+ raw_mappings.each_with_object({}) do |(pattern, replacement), memo|
296
+ normalized_pattern = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern.to_s))
297
+ memo[normalized_pattern] = normalize_replacement(replacement)
298
+ end
299
+ end
300
+
301
+ def default_collation_fallbacks
302
+ {
303
+ /utf8mb3_uca\d+_ai_ci/ => { collation: 'utf8mb4_unicode_ci', charset: 'utf8mb4' },
304
+ /utf8mb3_uca\d+_as_cs/ => { collation: 'utf8mb4_unicode_ci', charset: 'utf8mb4' },
305
+ /utf8mb3_unicode_520_ci/ => { collation: 'utf8mb4_unicode_ci', charset: 'utf8mb4' }
306
+ }
307
+ end
308
+
309
+ def charset_fallbacks
310
+ raw_mappings = options.dig(:global, :charset_fallbacks) || default_charset_fallbacks
311
+ return {} unless raw_mappings
312
+
313
+ raw_mappings.each_with_object({}) do |(pattern, replacement), memo|
314
+ normalized_pattern = pattern.is_a?(Regexp) ? pattern : Regexp.new(Regexp.escape(pattern.to_s))
315
+ memo[normalized_pattern] = replacement
316
+ end
317
+ end
318
+
319
+ def default_charset_fallbacks
320
+ {
321
+ /utf8mb3\b/ => 'utf8mb4'
322
+ }
323
+ end
324
+
325
+ def normalize_replacement(replacement)
326
+ return replacement if replacement.is_a?(Hash)
327
+
328
+ { collation: replacement, charset: nil }
329
+ end
330
+
331
+ def mysql_socket_option(options, legacy_options_key)
332
+ return if mysql_option_includes_socket?(options[legacy_options_key])
333
+
334
+ options[:socket].presence
335
+ end
336
+
337
+ def mysql_option_includes_socket?(option_string)
338
+ mysql_socket_from_options(option_string).present?
339
+ end
340
+
341
+ def mysql_socket_from_options(option_string)
342
+ return if option_string.blank?
343
+
344
+ args = Shellwords.split(option_string)
345
+ socket_index = args.index('--socket')
346
+ return args[socket_index + 1] if socket_index && args[socket_index + 1]
347
+
348
+ args.each do |arg|
349
+ return Regexp.last_match(1) if arg.match(/\A--socket=(.+)\z/)
350
+ end
351
+
352
+ nil
353
+ end
354
+ end
355
+ end
356
+ end