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,128 @@
1
+ require 'shellwords'
2
+
3
+ module Wordmove
4
+ class Hook
5
+ class << self
6
+ attr_writer :logger
7
+
8
+ def logger
9
+ @logger ||= Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG }
10
+ end
11
+ end
12
+
13
+ # rubocop:disable-next Metrics/MethodLength
14
+ def self.run(action, step, cli_options)
15
+ movefile = Wordmove::Movefile.new(cli_options[:config])
16
+ options = movefile.fetch(false)
17
+ environment = movefile.environment(cli_options)
18
+ self.logger = Logger.new(STDOUT, movefile.secrets).tap { |l| l.level = Logger::DEBUG }
19
+
20
+ hooks = Wordmove::Hook::Config.new(
21
+ options[environment][:hooks],
22
+ action,
23
+ step
24
+ )
25
+
26
+ return if hooks.empty?
27
+
28
+ logger.task "Running #{action}/#{step} hooks"
29
+
30
+ hooks.all_commands.each do |command|
31
+ case command[:where]
32
+ when 'local'
33
+ Wordmove::Hook::Local.run(command, options[:local], cli_options[:simulate])
34
+ when 'remote'
35
+ Wordmove::Hook::Remote.run(command, options[environment], cli_options[:simulate])
36
+ else
37
+ next
38
+ end
39
+ end
40
+ end
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 = Shellwords.escape(options[:wordpress_path].to_s)
86
+ logger.task_step true, "Exec command: #{command_hash[:command]}"
87
+
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
+ wordpress_path = Shellwords.escape(options[:wordpress_path].to_s)
109
+ logger.task_step false, "Exec command: #{command_hash[:command]}"
110
+
111
+ return true if simulate
112
+
113
+ runner = Wordmove::SshRunner.new(options[:ssh])
114
+ stdout, stderr, exit_code =
115
+ runner.run("cd #{wordpress_path} && #{command_hash[:command]}")
116
+
117
+ if exit_code.zero?
118
+ logger.task_step false, "Output: #{stdout}"
119
+ logger.success ""
120
+ else
121
+ logger.task_step false, "Output: #{stderr}"
122
+ logger.error "Error code #{exit_code}"
123
+ raise Wordmove::RemoteHookException unless command_hash[:raise].eql? false
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,165 @@
1
+ require 'shellwords'
2
+
3
+ module Wordmove
4
+ class Logger < ::Logger
5
+ MAX_LINE = 70
6
+
7
+ def initialize(device, strings_to_hide = [])
8
+ super(device, formatter: proc { |_severity, _datetime, _progname, message|
9
+ formatted_message = if strings_to_hide.empty?
10
+ message
11
+ else
12
+ message.gsub(
13
+ Regexp.new(
14
+ strings_to_hide.map { |string| Regexp.escape(string) }.join('|')
15
+ ),
16
+ '[secret]'
17
+ )
18
+ end
19
+
20
+ "\n#{formatted_message}\n"
21
+ })
22
+ end
23
+
24
+ def task(title)
25
+ prefix = "▬" * 2
26
+ title = " #{title} "
27
+ padding = "▬" * padding_length(title)
28
+ add(INFO, prefix + title.light_white + padding)
29
+ end
30
+
31
+ def task_step(local_step, title)
32
+ if local_step
33
+ add(INFO, " local".cyan + " | ".black + format_task_step(title))
34
+ else
35
+ add(INFO, " remote".yellow + " | ".black + format_task_step(title))
36
+ end
37
+ end
38
+
39
+ def error(message)
40
+ add(ERROR, " ❌ error".red + " | ".black + message.to_s)
41
+ end
42
+
43
+ def success(message)
44
+ add(INFO, " ✅ success".green + " | ".black + message.to_s)
45
+ end
46
+
47
+ def debug(message)
48
+ add(DEBUG, " 🛠 debug".magenta + " | ".black + message.to_s)
49
+ end
50
+
51
+ def warn(message)
52
+ add(WARN, " ⚠️ warning".yellow + " | ".black + message.to_s)
53
+ end
54
+
55
+ def info(message)
56
+ add(INFO, " ℹ️ info".yellow + " | ".black + message.to_s)
57
+ end
58
+
59
+ def plain(message)
60
+ add(INFO, message.to_s)
61
+ end
62
+
63
+ private
64
+
65
+ def padding_length(line)
66
+ result = MAX_LINE - line.length
67
+ result.positive? ? result : 0
68
+ end
69
+
70
+ def format_task_step(title)
71
+ message = title.to_s
72
+ return message if passthrough_task_step?(message)
73
+
74
+ lines = message.lines(chomp: true).reject(&:empty?)
75
+ return summarized_single_line_command(message) if lines.size <= 1
76
+ return mysql_import_summary(message) if mysql_import_script?(message)
77
+
78
+ "run shell script"
79
+ end
80
+
81
+ def passthrough_task_step?(message)
82
+ prefixes = [
83
+ "Exec command:",
84
+ "Output:",
85
+ "download ",
86
+ "delete:",
87
+ "get:",
88
+ "put:",
89
+ "get_directory:",
90
+ "put_directory:"
91
+ ]
92
+
93
+ return true if prefixes.any? { |prefix| message.start_with?(prefix) }
94
+ return false if message.include?("\n")
95
+
96
+ summarized_single_line_command(message) == message
97
+ end
98
+
99
+ def mysql_import_script?(message)
100
+ message.include?('tmp_dump="$(mktemp)"') &&
101
+ message.include?('COMMIT;') &&
102
+ message.include?('--init-command=')
103
+ end
104
+
105
+ def mysql_import_summary(message)
106
+ dump_path = message[%r{head -n 1 (.+?) 2>/dev/null \|\| true}, 1]
107
+ database = message[/--database=([^\s]+)/, 1]
108
+
109
+ summary = +"import SQL dump"
110
+ summary << " #{dump_path}" if dump_path
111
+ summary << " into database #{database}" if database
112
+ summary << " (strip sandbox header, append COMMIT)"
113
+ summary
114
+ end
115
+
116
+ def summarized_single_line_command(message)
117
+ mysql_dump_summary(message) ||
118
+ gzip_summary(message) ||
119
+ wp_search_replace_summary(message) ||
120
+ message
121
+ end
122
+
123
+ def mysql_dump_summary(message)
124
+ return unless message.include?('--result-file=')
125
+ return unless message.include?('mariadb-dump') || message.include?('mysqldump')
126
+
127
+ args = Shellwords.split(message)
128
+ dump_path = args.find { |arg| arg.start_with?('--result-file=') }&.split('=', 2)&.last
129
+ database = args.last
130
+ return unless dump_path && database
131
+
132
+ "dump database #{database} to #{dump_path}"
133
+ end
134
+
135
+ def gzip_summary(message)
136
+ return unless message.start_with?('gzip ')
137
+
138
+ args = Shellwords.split(message)
139
+ return unless args.length == 4 && args[2] == '-f'
140
+
141
+ case args[1]
142
+ when '-9' then "compress #{args[3]}"
143
+ when '-d' then "decompress #{args[3]}"
144
+ end
145
+ end
146
+
147
+ def wp_search_replace_summary(message)
148
+ return unless message.start_with?('wp search-replace ')
149
+
150
+ args = Shellwords.split(message)
151
+ return if args.length < 4
152
+
153
+ path = args.find { |arg| arg.start_with?('--path=') }&.split('=', 2)&.last
154
+ positional = args.drop(2).reject { |arg| arg.start_with?('--') }
155
+ return if positional.length < 2
156
+
157
+ search = positional[0]
158
+ replace = positional[1]
159
+
160
+ summary = "wp search-replace #{search} -> #{replace}"
161
+ summary << " in #{path}" if path
162
+ summary
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,129 @@
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
+ safe_load_yaml(ERB.new(File.read(found)).result).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? && !available_enviroments.include?(options[:environment].to_sym)
59
+ raise UndefinedEnvironment, "No environment found for \"#{options[:environment]}\". " \
60
+ "Available Environments: #{available_enviroments.join(' ')}"
61
+ end
62
+ end
63
+
64
+ (options[:environment] || available_enviroments.first).to_sym
65
+ end
66
+
67
+ # The four values wp search-replace rewrites when syncing +environment+.
68
+ # If one of them is a proper prefix of another, the replacement of the
69
+ # shorter one also rewrites every occurrence of the longer one, e.g.
70
+ # replacing "https://site.test" turns "https://site.test.backup" into
71
+ # "https://example.com.backup". Returns [[short, long], ...] pairs.
72
+ def prefix_collisions(environment)
73
+ options = fetch(false)
74
+ terms = %i[vhost wordpress_path].flat_map do |key|
75
+ [options.dig(:local, key), options.dig(environment.to_sym, key)]
76
+ end
77
+ terms = terms.compact.map(&:to_s).reject(&:empty?).uniq
78
+
79
+ terms.product(terms).select do |short, long|
80
+ short != long && long.start_with?(short)
81
+ end
82
+ end
83
+
84
+ def secrets
85
+ options = fetch(false)
86
+
87
+ secrets = []
88
+ options.each_key do |env|
89
+ secrets << options.dig(env, :database, :password)
90
+ secrets << options.dig(env, :database, :host)
91
+ secrets << options.dig(env, :vhost)
92
+ secrets << options.dig(env, :ssh, :password)
93
+ secrets << options.dig(env, :ssh, :host)
94
+ secrets << options.dig(env, :wordpress_path)
95
+ end
96
+
97
+ secrets.compact.delete_if(&:empty?)
98
+ end
99
+
100
+ private
101
+
102
+ def extract_available_envs(options)
103
+ options.keys.map(&:to_sym) - %i[local global]
104
+ end
105
+
106
+ def last_dir?(directory)
107
+ directory == "/" || File.exist?(File.join(directory, 'wp-config.php'))
108
+ end
109
+
110
+ def upper_dir(directory)
111
+ File.expand_path(File.join(directory, '..'))
112
+ end
113
+
114
+ def current_dir
115
+ '.'
116
+ end
117
+
118
+ def safe_load_yaml(content)
119
+ YAML.safe_load(
120
+ content,
121
+ permitted_classes: [],
122
+ permitted_symbols: [],
123
+ aliases: true
124
+ )
125
+ rescue ArgumentError
126
+ YAML.safe_load(content, [], [], true)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,57 @@
1
+ require 'open3'
2
+ require 'shellwords'
3
+
4
+ module Wordmove
5
+ # Checks that the external binaries a database sync relies on exist, either
6
+ # on this machine or on a remote host reached through an SshRunner.
7
+ #
8
+ # A requirement is a binary name, or an array of alternatives of which at
9
+ # least one must exist (e.g. mysql or mariadb).
10
+ module Prerequisites
11
+ # Needed where the dump is produced.
12
+ DB_SOURCE = ['gzip', %w[mysqldump mariadb-dump]].freeze
13
+ # Needed where the dump is imported and adapted.
14
+ DB_TARGET = ['gzip', %w[mysql mariadb], 'wp'].freeze
15
+ # Everything a remote may be asked for, used by the doctor.
16
+ REMOTE_ALL = ['rsync', 'gzip', %w[mysql mariadb], %w[mysqldump mariadb-dump], 'wp'].freeze
17
+
18
+ class << self
19
+ # POSIX sh snippet printing, one per line, the requirements that are not
20
+ # satisfied. Alternatives are printed joined with "|".
21
+ def probe_command(requirements)
22
+ requirements.map do |requirement|
23
+ names = Array(requirement)
24
+ test = names.map { |n| "command -v #{Shellwords.escape(n)} >/dev/null 2>&1" }.join(' || ')
25
+ "#{test} || echo #{Shellwords.escape(names.join('|'))}"
26
+ end.join('; ')
27
+ end
28
+
29
+ def missing_locally(requirements)
30
+ stdout, _stderr, _status = Open3.capture3('sh', '-c', probe_command(requirements))
31
+ parse(stdout)
32
+ end
33
+
34
+ # Returns the missing requirements, or raises ShellCommandError when the
35
+ # probe itself could not run (e.g. authentication failed).
36
+ def missing_remotely(runner, requirements)
37
+ stdout, stderr, exit_code = runner.run(probe_command(requirements))
38
+ unless exit_code.zero?
39
+ raise ShellCommandError,
40
+ "Could not check the remote prerequisites (exit code #{exit_code}): #{stderr}"
41
+ end
42
+
43
+ parse(stdout)
44
+ end
45
+
46
+ def describe(missing)
47
+ missing.map { |names| names.join(' or ') }.join(', ')
48
+ end
49
+
50
+ private
51
+
52
+ def parse(stdout)
53
+ stdout.lines.map(&:strip).reject(&:empty?).map { |line| line.split('|') }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,72 @@
1
+ require 'shellwords'
2
+
3
+ module Wordmove
4
+ module SqlAdapter
5
+ class Wpcli
6
+ attr_accessor :sql_content
7
+ attr_reader :from, :to, :local_path, :remote
8
+
9
+ # When +remote+ is true the command is meant to be executed on the remote
10
+ # host: +local_path+ is then the remote wordpress_path and is used as-is,
11
+ # and no local wp-cli availability or configuration probing is done.
12
+ def initialize(source_config, dest_config, config_key, local_path, remote: false)
13
+ @from = source_config[config_key]
14
+ @to = dest_config[config_key]
15
+ @local_path = local_path
16
+ @remote = remote
17
+ end
18
+
19
+ # `wp maintenance-mode activate|deactivate` for the install at +path+.
20
+ def self.maintenance_mode_command(action, path)
21
+ unless %i[activate deactivate].include?(action)
22
+ raise ArgumentError, "unknown maintenance mode action #{action.inspect}"
23
+ end
24
+
25
+ "wp maintenance-mode #{action} --path=#{Shellwords.escape(path)} --allow-root"
26
+ end
27
+
28
+ def command
29
+ unless remote || wp_in_path?
30
+ raise UnmetPeerDependencyError, "WP-CLI is not installed or not in your $PATH"
31
+ end
32
+
33
+ opts = [
34
+ "--path=#{Shellwords.escape(cli_config_path)}",
35
+ Shellwords.escape(from.to_s),
36
+ Shellwords.escape(to.to_s),
37
+ "--quiet",
38
+ "--skip-columns=guid",
39
+ "--all-tables",
40
+ "--allow-root"
41
+ ]
42
+
43
+ "wp search-replace #{opts.join(' ')}"
44
+ end
45
+
46
+ private
47
+
48
+ def wp_in_path?
49
+ system('which wp > /dev/null 2>&1')
50
+ end
51
+
52
+ def cli_config_path
53
+ return local_path if remote
54
+
55
+ load_from_yml || load_from_cli || local_path
56
+ end
57
+
58
+ def load_from_yml
59
+ cli_config_path = File.join(local_path, "wp-cli.yml")
60
+ return unless File.exist?(cli_config_path)
61
+
62
+ YAML.load_file(cli_config_path).with_indifferent_access["path"]
63
+ end
64
+
65
+ def load_from_cli
66
+ raw = `wp cli param-dump --allow-root --with-values`
67
+ cli_config = JSON.parse(raw, symbolize_names: true)
68
+ cli_config.dig(:path, :current)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,110 @@
1
+ require 'open3'
2
+ require 'shellwords'
3
+
4
+ module Wordmove
5
+ # Runs remote commands and file transfers through the system `ssh` and `scp`
6
+ # binaries, so that authentication behaves exactly like the rsync transfers:
7
+ # ssh-agent, ~/.ssh/config, ProxyJump and modern signature algorithms all work
8
+ # without any Ruby-side SSH implementation.
9
+ #
10
+ # When no password is configured the connection runs in BatchMode, so a failed
11
+ # key authentication is reported as an error instead of an interactive prompt.
12
+ class SshRunner
13
+ attr_reader :options
14
+
15
+ def initialize(ssh_options)
16
+ @options = (ssh_options || {}).dup
17
+ end
18
+
19
+ # Runs +command+ on the remote host with a POSIX sh, regardless of the
20
+ # remote user's login shell, and returns [stdout, stderr, exit_code].
21
+ def run(command)
22
+ execute(ssh_argv + [self.class.shell_wrap(command)])
23
+ end
24
+
25
+ # Every command wordmove generates assumes POSIX sh syntax ($(...), &&,
26
+ # multi-line scripts), which a fish or csh login shell would mangle.
27
+ def self.shell_wrap(command)
28
+ # Block form on purpose: in a replacement string \' means "post-match".
29
+ quoted = command.gsub("'") { %q('\'') }
30
+ "sh -c '#{quoted}'"
31
+ end
32
+
33
+ def get(remote_path, local_path)
34
+ execute(scp_argv + [remote_file(remote_path), local_path])
35
+ end
36
+
37
+ def put(local_path, remote_path)
38
+ execute(scp_argv + [local_path, remote_file(remote_path)])
39
+ end
40
+
41
+ def delete(remote_path)
42
+ run("rm -rf #{Shellwords.escape(remote_path)}")
43
+ end
44
+
45
+ def ssh_argv
46
+ wrap_password(['ssh'] + common_arguments + port_arguments('-p') + [target])
47
+ end
48
+
49
+ def scp_argv
50
+ wrap_password(['scp', '-q'] + common_arguments + port_arguments('-P'))
51
+ end
52
+
53
+ def password?
54
+ options[:password].present?
55
+ end
56
+
57
+ private
58
+
59
+ def execute(argv)
60
+ stdout, stderr, status = Open3.capture3(*argv)
61
+ [stdout, stderr, status.exitstatus]
62
+ rescue Errno::ENOENT
63
+ binary = argv.first
64
+ raise UnmetPeerDependencyError,
65
+ "`#{binary}` is not installed or not in your $PATH; it is required for SSH " \
66
+ "database operations and remote hooks"
67
+ end
68
+
69
+ def target
70
+ user = options[:user]
71
+ user.present? ? "#{user}@#{options[:host]}" : options[:host].to_s
72
+ end
73
+
74
+ def remote_file(path)
75
+ # scp hands the remote path to the remote shell, so it needs shell escaping.
76
+ "#{target}:#{Shellwords.escape(path)}"
77
+ end
78
+
79
+ def common_arguments
80
+ arguments = []
81
+ arguments.push("-o", "BatchMode=yes") unless password?
82
+ arguments.push('-J', jump_host) if gateway?
83
+ arguments
84
+ end
85
+
86
+ def port_arguments(flag)
87
+ return [] unless options[:port].present?
88
+
89
+ [flag, options[:port].to_s]
90
+ end
91
+
92
+ def wrap_password(argv)
93
+ return argv unless password?
94
+
95
+ ['sshpass', '-p', options[:password].to_s] + argv
96
+ end
97
+
98
+ def gateway?
99
+ options[:gateway].is_a?(Hash) && options[:gateway][:host].present?
100
+ end
101
+
102
+ def jump_host
103
+ gateway = options[:gateway]
104
+ spec = gateway[:host].to_s
105
+ spec = "#{gateway[:user]}@#{spec}" if gateway[:user].present?
106
+ spec = "#{spec}:#{gateway[:port]}" if gateway[:port].present?
107
+ spec
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ module Wordmove
2
+ VERSION = "6.0.0".freeze
3
+ end
@@ -0,0 +1,11 @@
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