outstand-tty-command 0.10.0.pre

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.
@@ -0,0 +1 @@
1
+ require 'tty/command'
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ require_relative 'command/cmd'
6
+ require_relative 'command/exit_error'
7
+ require_relative 'command/dry_runner'
8
+ require_relative 'command/process_runner'
9
+ require_relative 'command/printers/null'
10
+ require_relative 'command/printers/pretty'
11
+ require_relative 'command/printers/progress'
12
+ require_relative 'command/printers/quiet'
13
+ require_relative 'command/version'
14
+
15
+ module TTY
16
+ class Command
17
+ ExecuteError = Class.new(StandardError)
18
+
19
+ TimeoutExceeded = Class.new(StandardError)
20
+
21
+ # Path to the current Ruby
22
+ RUBY = ENV['RUBY'] || ::File.join(
23
+ RbConfig::CONFIG['bindir'],
24
+ RbConfig::CONFIG['ruby_install_name'] + RbConfig::CONFIG['EXEEXT']
25
+ )
26
+
27
+ WIN_PLATFORMS = /cygwin|mswin|mingw|bccwin|wince|emx/.freeze
28
+
29
+ def self.record_separator
30
+ @record_separator ||= $/
31
+ end
32
+
33
+ def self.record_separator=(sep)
34
+ @record_separator = sep
35
+ end
36
+
37
+ def self.windows?
38
+ !!(RbConfig::CONFIG['host_os'] =~ WIN_PLATFORMS)
39
+ end
40
+
41
+ attr_reader :printer
42
+
43
+ # Initialize a Command object
44
+ #
45
+ # @param [Hash] options
46
+ # @option options [IO] :output
47
+ # the stream to which printer prints, defaults to stdout
48
+ # @option options [Symbol] :printer
49
+ # the printer to use for output logging, defaults to :pretty
50
+ # @option options [Symbol] :dry_run
51
+ # the mode for executing command
52
+ #
53
+ # @api public
54
+ def initialize(**options)
55
+ @output = options.fetch(:output) { $stdout }
56
+ @color = options.fetch(:color) { true }
57
+ @uuid = options.fetch(:uuid) { true }
58
+ @printer_name = options.fetch(:printer) { :pretty }
59
+ @dry_run = options.fetch(:dry_run) { false }
60
+ @printer = use_printer(@printer_name, color: @color, uuid: @uuid)
61
+ @cmd_options = {}
62
+ @cmd_options[:verbose] = options.fetch(:verbose, true)
63
+ @cmd_options[:pty] = true if options[:pty]
64
+ @cmd_options[:binmode] = true if options[:binmode]
65
+ @cmd_options[:timeout] = options[:timeout] if options[:timeout]
66
+ end
67
+
68
+ # Start external executable in a child process
69
+ #
70
+ # @example
71
+ # cmd.run(command, [argv1, ..., argvN], [options])
72
+ #
73
+ # @example
74
+ # cmd.run(command, ...) do |result|
75
+ # ...
76
+ # end
77
+ #
78
+ # @param [String] command
79
+ # the command to run
80
+ #
81
+ # @param [Array[String]] argv
82
+ # an array of string arguments
83
+ #
84
+ # @param [Hash] options
85
+ # hash of operations to perform
86
+ # @option options [String] :chdir
87
+ # The current directory.
88
+ # @option options [Integer] :timeout
89
+ # Maximum number of seconds to allow the process
90
+ # to run before aborting with a TimeoutExceeded
91
+ # exception.
92
+ # @option options [Symbol] :signal
93
+ # Signal used on timeout, SIGKILL by default
94
+ #
95
+ # @yield [out, err]
96
+ # Yields stdout and stderr output whenever available
97
+ #
98
+ # @raise [ExitError]
99
+ # raised when command exits with non-zero code
100
+ #
101
+ # @api public
102
+ def run(*args, &block)
103
+ cmd = command(*args)
104
+ result = execute_command(cmd, &block)
105
+ if result && result.failure?
106
+ raise ExitError.new(cmd.to_command, result)
107
+ end
108
+ result
109
+ end
110
+
111
+ # Start external executable without raising ExitError
112
+ #
113
+ # @example
114
+ # cmd.run!(command, [argv1, ..., argvN], [options])
115
+ #
116
+ # @api public
117
+ def run!(*args, &block)
118
+ cmd = command(*args)
119
+ execute_command(cmd, &block)
120
+ end
121
+
122
+ # Wait on long running script until output matches a specific pattern
123
+ #
124
+ # @example
125
+ # cmd.wait 'tail -f /var/log/php.log', /something happened/
126
+ #
127
+ # @api public
128
+ def wait(*args)
129
+ pattern = args.pop
130
+ unless pattern
131
+ raise ArgumentError, 'Please provide condition to wait for'
132
+ end
133
+
134
+ run(*args) do |out, _|
135
+ raise if out =~ /#{pattern}/
136
+ end
137
+ rescue ExitError
138
+ # noop
139
+ end
140
+
141
+ # Execute shell test command
142
+ #
143
+ # @api public
144
+ def test(*args)
145
+ run!(:test, *args).success?
146
+ end
147
+
148
+ # Run Ruby interperter with the given arguments
149
+ #
150
+ # @example
151
+ # ruby %q{-e "puts 'Hello world'"}
152
+ #
153
+ # @api public
154
+ def ruby(*args, &block)
155
+ options = args.last.is_a?(Hash) ? args.pop : {}
156
+ if args.length > 1
157
+ run(*([RUBY] + args + [options]), &block)
158
+ else
159
+ run("#{RUBY} #{args.first}", options, &block)
160
+ end
161
+ end
162
+
163
+ # Check if in dry mode
164
+ #
165
+ # @return [Boolean]
166
+ #
167
+ # @public
168
+ def dry_run?
169
+ @dry_run
170
+ end
171
+
172
+ private
173
+
174
+ # @api private
175
+ def command(*args)
176
+ cmd = Cmd.new(*args)
177
+ cmd.update(**@cmd_options)
178
+ cmd
179
+ end
180
+
181
+ # @api private
182
+ def execute_command(cmd, &block)
183
+ dry_run = @dry_run || cmd.options[:dry_run] || false
184
+ @runner = select_runner(dry_run).new(cmd, @printer, &block)
185
+ @runner.run!
186
+ end
187
+
188
+ # @api private
189
+ def use_printer(class_or_name, options)
190
+ if class_or_name.is_a?(TTY::Command::Printers::Abstract)
191
+ return class_or_name
192
+ end
193
+
194
+ if class_or_name.is_a?(Class)
195
+ class_or_name
196
+ else
197
+ find_printer_class(class_or_name)
198
+ end.new(@output, options)
199
+ end
200
+
201
+ # Find printer class or fail
202
+ #
203
+ # @raise [ArgumentError]
204
+ #
205
+ # @api private
206
+ def find_printer_class(name)
207
+ const_name = name.to_s.split('_').map(&:capitalize).join.to_sym
208
+ if const_name.empty? || !TTY::Command::Printers.const_defined?(const_name)
209
+ raise ArgumentError, %(Unknown printer type "#{name}")
210
+ end
211
+ TTY::Command::Printers.const_get(const_name)
212
+ end
213
+
214
+ # @api private
215
+ def select_runner(dry_run)
216
+ if dry_run
217
+ DryRunner
218
+ else
219
+ ProcessRunner
220
+ end
221
+ end
222
+ end # Command
223
+ end # TTY
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'securerandom'
5
+ require 'io/console'
6
+
7
+ module TTY
8
+ class Command
9
+ module ChildProcess
10
+ # Execute command in a child process with all IO streams piped
11
+ # in and out. The interface is similar to Process.spawn
12
+ #
13
+ # The caller should ensure that all IO objects are closed
14
+ # when the child process is finished. However, when block
15
+ # is provided this will be taken care of automatically.
16
+ #
17
+ # @param [Cmd] cmd
18
+ # the command to spawn
19
+ #
20
+ # @return [pid, stdin, stdout, stderr]
21
+ #
22
+ # @api public
23
+ def spawn(cmd)
24
+ process_opts = normalize_redirect_options(cmd.options)
25
+ binmode = cmd.options[:binmode] || false
26
+ pty = cmd.options[:pty] || false
27
+ verbose = cmd.options[:verbose]
28
+
29
+ pty = try_loading_pty(verbose) if pty
30
+ require('pty') if pty # load within this scope
31
+
32
+ # Create pipes
33
+ in_rd, in_wr = pty ? PTY.open : IO.pipe('utf-8') # reading
34
+ out_rd, out_wr = pty ? PTY.open : IO.pipe('utf-8') # writing
35
+ err_rd, err_wr = pty ? PTY.open : IO.pipe('utf-8') # error
36
+ in_wr.sync = true
37
+
38
+ if binmode
39
+ in_wr.binmode
40
+ out_rd.binmode
41
+ err_rd.binmode
42
+ end
43
+
44
+ if pty
45
+ in_wr.raw!
46
+ out_wr.raw!
47
+ err_wr.raw!
48
+ end
49
+
50
+ # redirect fds
51
+ opts = {
52
+ in: in_rd,
53
+ out: out_wr,
54
+ err: err_wr
55
+ }
56
+ unless TTY::Command.windows?
57
+ close_child_fds = {
58
+ in_wr => :close,
59
+ out_rd => :close,
60
+ err_rd => :close
61
+ }
62
+ opts.merge!(close_child_fds)
63
+ end
64
+ opts.merge!(process_opts)
65
+
66
+ pid = Process.spawn(cmd.to_command, opts)
67
+
68
+ # close streams in parent process talking to the child
69
+ close_fds(in_rd, out_wr, err_wr)
70
+
71
+ tuple = [pid, in_wr, out_rd, err_rd]
72
+
73
+ if block_given?
74
+ begin
75
+ return yield(*tuple)
76
+ ensure
77
+ # ensure parent pipes are closed
78
+ close_fds(in_wr, out_rd, err_rd)
79
+ end
80
+ else
81
+ tuple
82
+ end
83
+ end
84
+ module_function :spawn
85
+
86
+ # Close all streams
87
+ # @api private
88
+ def close_fds(*fds)
89
+ fds.each { |fd| fd && !fd.closed? && fd.close }
90
+ end
91
+ module_function :close_fds
92
+
93
+ # Try loading pty module
94
+ #
95
+ # @return [Boolean]
96
+ #
97
+ # @api private
98
+ def try_loading_pty(verbose = false)
99
+ require 'pty'
100
+ true
101
+ rescue LoadError
102
+ warn("Requested PTY device but the system doesn't support it.") if verbose
103
+ false
104
+ end
105
+ module_function :try_loading_pty
106
+
107
+ # Normalize spawn fd into :in, :out, :err keys.
108
+ #
109
+ # @return [Hash]
110
+ #
111
+ # @api private
112
+ def normalize_redirect_options(options)
113
+ options.reduce({}) do |opts, (key, value)|
114
+ if fd?(key)
115
+ spawn_key, spawn_value = convert(key, value)
116
+ opts[spawn_key] = spawn_value
117
+ elsif key.is_a?(Array) && key.all?(&method(:fd?))
118
+ key.each do |k|
119
+ spawn_key, spawn_value = convert(k, value)
120
+ opts[spawn_key] = spawn_value
121
+ end
122
+ end
123
+ opts
124
+ end
125
+ end
126
+ module_function :normalize_redirect_options
127
+
128
+ # Convert option pari to recognized spawn option pair
129
+ #
130
+ # @api private
131
+ def convert(spawn_key, spawn_value)
132
+ key = fd_to_process_key(spawn_key)
133
+ value = spawn_value
134
+
135
+ if key.to_s == 'in'
136
+ value = convert_to_fd(spawn_value)
137
+ end
138
+
139
+ if fd?(spawn_value)
140
+ value = fd_to_process_key(spawn_value)
141
+ value = [:child, value] # redirect in child process
142
+ end
143
+ [key, value]
144
+ end
145
+ module_function :convert
146
+
147
+ # Determine if object is a fd
148
+ #
149
+ # @return [Boolean]
150
+ #
151
+ # @api private
152
+ def fd?(object)
153
+ case object
154
+ when :stdin, :stdout, :stderr, :in, :out, :err,
155
+ STDIN, STDOUT, STDERR, $stdin, $stdout, $stderr, ::IO
156
+ true
157
+ when ::Integer
158
+ object >= 0
159
+ else
160
+ respond_to?(:to_i) && !object.to_io.nil?
161
+ end
162
+ end
163
+ module_function :fd?
164
+
165
+ # Convert fd to name :in, :out, :err
166
+ #
167
+ # @api private
168
+ def fd_to_process_key(object)
169
+ case object
170
+ when STDIN, $stdin, :in, :stdin, 0
171
+ :in
172
+ when STDOUT, $stdout, :out, :stdout, 1
173
+ :out
174
+ when STDERR, $stderr, :err, :stderr, 2
175
+ :err
176
+ when Integer
177
+ object >= 0 ? IO.for_fd(object) : nil
178
+ when IO
179
+ object
180
+ when respond_to?(:to_io)
181
+ object.to_io
182
+ else
183
+ raise ExecuteError, "Wrong execute redirect: #{object.inspect}"
184
+ end
185
+ end
186
+ module_function :fd_to_process_key
187
+
188
+ # Convert file name to file handle
189
+ #
190
+ # @api private
191
+ def convert_to_fd(object)
192
+ return object if fd?(object)
193
+
194
+ if object.is_a?(::String) && ::File.exist?(object)
195
+ return object
196
+ end
197
+
198
+ tmp = ::Tempfile.new(::SecureRandom.uuid.split('-')[0])
199
+ content = try_reading(object)
200
+ tmp.write(content)
201
+ tmp.rewind
202
+ tmp
203
+ end
204
+ module_function :convert_to_fd
205
+
206
+ # Attempts to read object content
207
+ #
208
+ # @api private
209
+ def try_reading(object)
210
+ if object.respond_to?(:read)
211
+ object.read
212
+ elsif object.respond_to?(:to_s)
213
+ object.to_s
214
+ else
215
+ object
216
+ end
217
+ end
218
+ module_function :try_reading
219
+ end # ChildProcess
220
+ end # Command
221
+ end # TTY
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'shellwords'
5
+
6
+ module TTY
7
+ class Command
8
+ class Cmd
9
+ # A string command name, or shell program
10
+ # @api public
11
+ attr_reader :command
12
+
13
+ # A string arguments
14
+ # @api public
15
+ attr_reader :argv
16
+
17
+ # Hash of operations to peform
18
+ # @api public
19
+ attr_reader :options
20
+
21
+ # Unique identifier
22
+ # @api public
23
+ attr_reader :uuid
24
+
25
+ # Flag that controls whether to print the output only on error or not
26
+ attr_reader :only_output_on_error
27
+
28
+ # Initialize a new Cmd object
29
+ #
30
+ # @api private
31
+ def initialize(env_or_cmd, *args)
32
+ opts = args.last.respond_to?(:to_hash) ? args.pop : {}
33
+ if env_or_cmd.respond_to?(:to_hash)
34
+ @env = env_or_cmd
35
+ unless command = args.shift
36
+ raise ArgumentError, 'Cmd requires command argument'
37
+ end
38
+ else
39
+ command = env_or_cmd
40
+ end
41
+
42
+ if args.empty? && cmd = command.to_s
43
+ raise ArgumentError, 'No command provided' if cmd.empty?
44
+ @command = sanitize(cmd)
45
+ @argv = []
46
+ else
47
+ if command.respond_to?(:to_ary)
48
+ @command = sanitize(command[0])
49
+ args.unshift(*command[1..-1])
50
+ else
51
+ @command = sanitize(command)
52
+ end
53
+ @argv = args.map { |i| Shellwords.escape(i) }
54
+ end
55
+ @env ||= {}
56
+ @options = opts
57
+
58
+ @uuid = SecureRandom.uuid.split('-')[0]
59
+ @only_output_on_error = opts.fetch(:only_output_on_error) { false }
60
+ freeze
61
+ end
62
+
63
+ # Extend command options if keys don't already exist
64
+ #
65
+ # @api public
66
+ def update(options)
67
+ @options.update(options.update(@options))
68
+ end
69
+
70
+ # The shell environment variables
71
+ #
72
+ # @api public
73
+ def environment
74
+ @env.merge(options.fetch(:env, {}))
75
+ end
76
+
77
+ def environment_string
78
+ environment.map do |key, val|
79
+ converted_key = key.is_a?(Symbol) ? key.to_s.upcase : key.to_s
80
+ escaped_val = val.to_s.gsub(/"/, '\"')
81
+ %(#{converted_key}="#{escaped_val}")
82
+ end.join(' ')
83
+ end
84
+
85
+ def evars(value, &block)
86
+ return (value || block) unless environment.any?
87
+ "( export #{environment_string} ; #{value || block.call} )"
88
+ end
89
+
90
+ def umask(value)
91
+ return value unless options[:umask]
92
+ %(umask #{options[:umask]} && %s) % [value]
93
+ end
94
+
95
+ def chdir(value)
96
+ return value unless options[:chdir]
97
+ %(cd #{Shellwords.escape(options[:chdir])} && #{value})
98
+ end
99
+
100
+ def user(value)
101
+ return value unless options[:user]
102
+ vars = environment.any? ? "#{environment_string} " : ''
103
+ %(sudo -u #{options[:user]} #{vars}-- sh -c '%s') % [value]
104
+ end
105
+
106
+ def group(value)
107
+ return value unless options[:group]
108
+ %(sg #{options[:group]} -c \\\"%s\\\") % [value]
109
+ end
110
+
111
+ # Clear environment variables except specified by env
112
+ #
113
+ # @api public
114
+ def with_clean_env
115
+ end
116
+
117
+ # Assemble full command
118
+ #
119
+ # @api public
120
+ def to_command
121
+ chdir(umask(evars(user(group(to_s)))))
122
+ end
123
+
124
+ # @api public
125
+ def to_s
126
+ [command.to_s, *Array(argv)].join(' ')
127
+ end
128
+
129
+ # @api public
130
+ def to_hash
131
+ {
132
+ command: command,
133
+ argv: argv,
134
+ uuid: uuid
135
+ }
136
+ end
137
+
138
+ private
139
+
140
+ # Coerce to string
141
+ #
142
+ # @api private
143
+ def sanitize(value)
144
+ value.to_s.dup
145
+ end
146
+ end # Cmd
147
+ end # Command
148
+ end # TTY