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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +156 -0
- data/LICENSE.txt +21 -0
- data/README.md +661 -0
- data/lib/tty-command.rb +1 -0
- data/lib/tty/command.rb +223 -0
- data/lib/tty/command/child_process.rb +221 -0
- data/lib/tty/command/cmd.rb +148 -0
- data/lib/tty/command/dry_runner.rb +27 -0
- data/lib/tty/command/exit_error.rb +31 -0
- data/lib/tty/command/printers/abstract.rb +54 -0
- data/lib/tty/command/printers/null.rb +16 -0
- data/lib/tty/command/printers/pretty.rb +83 -0
- data/lib/tty/command/printers/progress.rb +32 -0
- data/lib/tty/command/printers/quiet.rb +39 -0
- data/lib/tty/command/process_runner.rb +196 -0
- data/lib/tty/command/result.rb +89 -0
- data/lib/tty/command/truncator.rb +106 -0
- data/lib/tty/command/version.rb +7 -0
- metadata +129 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'result'
|
4
|
+
|
5
|
+
module TTY
|
6
|
+
class Command
|
7
|
+
class DryRunner
|
8
|
+
attr_reader :cmd
|
9
|
+
|
10
|
+
def initialize(cmd, printer)
|
11
|
+
@cmd = cmd
|
12
|
+
@printer = printer
|
13
|
+
end
|
14
|
+
|
15
|
+
# Show command without running
|
16
|
+
#
|
17
|
+
# @api public
|
18
|
+
def run!(*)
|
19
|
+
cmd.to_command
|
20
|
+
message = "#{@printer.decorate('(dry run)', :blue)} " +
|
21
|
+
@printer.decorate(cmd.to_command, :yellow, :bold)
|
22
|
+
@printer.write(cmd, message, cmd.uuid)
|
23
|
+
Result.new(0, '', '')
|
24
|
+
end
|
25
|
+
end # DryRunner
|
26
|
+
end # Command
|
27
|
+
end # TTY
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TTY
|
4
|
+
class Command
|
5
|
+
# An ExitError reports an unsuccessful exit by command.
|
6
|
+
#
|
7
|
+
# The error message includes:
|
8
|
+
# * the name of command executed
|
9
|
+
# * the exit status
|
10
|
+
# * stdout bytes
|
11
|
+
# * stderr bytes
|
12
|
+
#
|
13
|
+
# @api private
|
14
|
+
class ExitError < RuntimeError
|
15
|
+
def initialize(cmd_name, result)
|
16
|
+
super(info(cmd_name, result))
|
17
|
+
end
|
18
|
+
|
19
|
+
def info(cmd_name, result)
|
20
|
+
"Running `#{cmd_name}` failed with\n" \
|
21
|
+
" exit status: #{result.exit_status}\n" \
|
22
|
+
" stdout: #{extract_output(result.out)}\n" \
|
23
|
+
" stderr: #{extract_output(result.err)}\n"
|
24
|
+
end
|
25
|
+
|
26
|
+
def extract_output(value)
|
27
|
+
(value || '').strip.empty? ? 'Nothing written' : value.strip
|
28
|
+
end
|
29
|
+
end # ExitError
|
30
|
+
end # Command
|
31
|
+
end # TTY
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'pastel'
|
4
|
+
|
5
|
+
module TTY
|
6
|
+
class Command
|
7
|
+
module Printers
|
8
|
+
class Abstract
|
9
|
+
extend Forwardable
|
10
|
+
|
11
|
+
def_delegators :@color, :decorate
|
12
|
+
|
13
|
+
attr_reader :output, :options
|
14
|
+
attr_accessor :out_data, :err_data
|
15
|
+
|
16
|
+
# Initialize a Printer object
|
17
|
+
#
|
18
|
+
# @param [IO] output
|
19
|
+
# the printer output
|
20
|
+
#
|
21
|
+
# @api public
|
22
|
+
def initialize(output, options = {})
|
23
|
+
@output = output
|
24
|
+
@options = options
|
25
|
+
@enabled = options.fetch(:color) { true }
|
26
|
+
@color = ::Pastel.new(enabled: @enabled)
|
27
|
+
|
28
|
+
@out_data = ''
|
29
|
+
@err_data = ''
|
30
|
+
end
|
31
|
+
|
32
|
+
def print_command_start(cmd, *args)
|
33
|
+
write(cmd.to_command + "#{args.join}")
|
34
|
+
end
|
35
|
+
|
36
|
+
def print_command_out_data(cmd, *args)
|
37
|
+
write(args.join(' '))
|
38
|
+
end
|
39
|
+
|
40
|
+
def print_command_err_data(cmd, *args)
|
41
|
+
write(args.join(' '))
|
42
|
+
end
|
43
|
+
|
44
|
+
def print_command_exit(cmd, *args)
|
45
|
+
write(args.join(' '))
|
46
|
+
end
|
47
|
+
|
48
|
+
def write(cmd, message)
|
49
|
+
raise NotImplemented, "Abstract printer cannot be used"
|
50
|
+
end
|
51
|
+
end # Abstract
|
52
|
+
end # Printers
|
53
|
+
end # Command
|
54
|
+
end # TTY
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative 'abstract'
|
5
|
+
|
6
|
+
module TTY
|
7
|
+
class Command
|
8
|
+
module Printers
|
9
|
+
class Null < Abstract
|
10
|
+
def write(*)
|
11
|
+
# Do nothing
|
12
|
+
end
|
13
|
+
end # Null
|
14
|
+
end # Printers
|
15
|
+
end # Command
|
16
|
+
end # TTY
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'pastel'
|
5
|
+
|
6
|
+
require_relative 'abstract'
|
7
|
+
|
8
|
+
module TTY
|
9
|
+
class Command
|
10
|
+
module Printers
|
11
|
+
class Pretty < Abstract
|
12
|
+
TIME_FORMAT = "%5.3f %s".freeze
|
13
|
+
|
14
|
+
def initialize(*)
|
15
|
+
super
|
16
|
+
@uuid = options.fetch(:uuid) { true }
|
17
|
+
end
|
18
|
+
|
19
|
+
def print_command_start(cmd, *args)
|
20
|
+
message = ["Running #{decorate(cmd.to_command, :yellow, :bold)}"]
|
21
|
+
message << args.map(&:chomp).join(' ') unless args.empty?
|
22
|
+
write(cmd, message.join)
|
23
|
+
end
|
24
|
+
|
25
|
+
def print_command_out_data(cmd, *args)
|
26
|
+
message = args.map(&:chomp).join(' ')
|
27
|
+
write(cmd, "\t#{message}", out_data)
|
28
|
+
end
|
29
|
+
|
30
|
+
def print_command_err_data(cmd, *args)
|
31
|
+
message = args.map(&:chomp).join(' ')
|
32
|
+
write(cmd, "\t" + decorate(message, :red), err_data)
|
33
|
+
end
|
34
|
+
|
35
|
+
def print_command_exit(cmd, status, runtime, *args)
|
36
|
+
if cmd.only_output_on_error && !status.zero?
|
37
|
+
output << out_data
|
38
|
+
output << err_data
|
39
|
+
end
|
40
|
+
|
41
|
+
runtime = TIME_FORMAT % [runtime, pluralize(runtime, 'second')]
|
42
|
+
message = ["Finished in #{runtime}"]
|
43
|
+
message << " with exit status #{status}" if status
|
44
|
+
message << " (#{success_or_failure(status)})"
|
45
|
+
write(cmd, message.join)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Write message out to output
|
49
|
+
#
|
50
|
+
# @api private
|
51
|
+
def write(cmd, message, data = nil)
|
52
|
+
cmd_set_uuid = cmd.options.fetch(:uuid, true)
|
53
|
+
uuid_needed = cmd.options[:uuid].nil? ? @uuid : cmd_set_uuid
|
54
|
+
out = []
|
55
|
+
if uuid_needed
|
56
|
+
out << "[#{decorate(cmd.uuid, :green)}] " unless cmd.uuid.nil?
|
57
|
+
end
|
58
|
+
out << "#{message}\n"
|
59
|
+
target = (cmd.only_output_on_error && !data.nil?) ? data : output
|
60
|
+
target << out.join
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# Pluralize word based on a count
|
66
|
+
#
|
67
|
+
# @api private
|
68
|
+
def pluralize(count, word)
|
69
|
+
"#{word}#{'s' unless count.to_f == 1}"
|
70
|
+
end
|
71
|
+
|
72
|
+
# @api private
|
73
|
+
def success_or_failure(status)
|
74
|
+
if status == 0
|
75
|
+
decorate('successful', :green, :bold)
|
76
|
+
else
|
77
|
+
decorate('failed', :red, :bold)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end # Pretty
|
81
|
+
end # Printers
|
82
|
+
end # Command
|
83
|
+
end # TTY
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'pastel'
|
5
|
+
require_relative 'abstract'
|
6
|
+
|
7
|
+
module TTY
|
8
|
+
class Command
|
9
|
+
module Printers
|
10
|
+
class Progress < Abstract
|
11
|
+
|
12
|
+
def print_command_exit(cmd, status, runtime, *args)
|
13
|
+
output.print(success_or_failure(status))
|
14
|
+
end
|
15
|
+
|
16
|
+
def write(*)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
# @api private
|
22
|
+
def success_or_failure(status)
|
23
|
+
if status == 0
|
24
|
+
decorate('.', :green)
|
25
|
+
else
|
26
|
+
decorate('F', :red)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end # Progress
|
30
|
+
end # Printers
|
31
|
+
end # Command
|
32
|
+
end # TTY
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require_relative 'abstract'
|
4
|
+
|
5
|
+
module TTY
|
6
|
+
class Command
|
7
|
+
module Printers
|
8
|
+
class Quiet < Abstract
|
9
|
+
attr_reader :output, :options
|
10
|
+
|
11
|
+
def print_command_start(cmd)
|
12
|
+
# quiet
|
13
|
+
end
|
14
|
+
|
15
|
+
def print_command_out_data(cmd, *args)
|
16
|
+
write(cmd, args.join(' '), out_data)
|
17
|
+
end
|
18
|
+
|
19
|
+
def print_command_err_data(cmd, *args)
|
20
|
+
write(cmd, args.join(' '), err_data)
|
21
|
+
end
|
22
|
+
|
23
|
+
def print_command_exit(cmd, status, *args)
|
24
|
+
unless !cmd.only_output_on_error || status.zero?
|
25
|
+
output << out_data
|
26
|
+
output << err_data
|
27
|
+
end
|
28
|
+
|
29
|
+
# quiet
|
30
|
+
end
|
31
|
+
|
32
|
+
def write(cmd, message, data = nil)
|
33
|
+
target = (cmd.only_output_on_error && !data.nil?) ? data : output
|
34
|
+
target << message
|
35
|
+
end
|
36
|
+
end # Progress
|
37
|
+
end # Printers
|
38
|
+
end # Command
|
39
|
+
end # TTY
|
@@ -0,0 +1,196 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thread'
|
4
|
+
|
5
|
+
require_relative 'child_process'
|
6
|
+
require_relative 'result'
|
7
|
+
require_relative 'truncator'
|
8
|
+
|
9
|
+
module TTY
|
10
|
+
class Command
|
11
|
+
class ProcessRunner
|
12
|
+
# the command to be spawned
|
13
|
+
attr_reader :cmd
|
14
|
+
|
15
|
+
# Initialize a Runner object
|
16
|
+
#
|
17
|
+
# @param [Printer] printer
|
18
|
+
# the printer to use for logging
|
19
|
+
#
|
20
|
+
# @api private
|
21
|
+
def initialize(cmd, printer, &block)
|
22
|
+
@cmd = cmd
|
23
|
+
@timeout = cmd.options[:timeout]
|
24
|
+
@input = cmd.options[:input]
|
25
|
+
@signal = cmd.options[:signal] || "SIGKILL"
|
26
|
+
@binmode = cmd.options[:binmode]
|
27
|
+
@printer = printer
|
28
|
+
@block = block
|
29
|
+
end
|
30
|
+
|
31
|
+
# Execute child process
|
32
|
+
#
|
33
|
+
# Write the input if provided to the child's stdin and read
|
34
|
+
# the contents of both the stdout and stderr.
|
35
|
+
#
|
36
|
+
# If a block is provided then yield the stdout and stderr content
|
37
|
+
# as its being read.
|
38
|
+
#
|
39
|
+
# @api public
|
40
|
+
def run!
|
41
|
+
@printer.print_command_start(cmd)
|
42
|
+
start = Time.now
|
43
|
+
|
44
|
+
pid, stdin, stdout, stderr = ChildProcess.spawn(cmd)
|
45
|
+
|
46
|
+
write_stream(stdin, @input)
|
47
|
+
|
48
|
+
stdout_data, stderr_data = read_streams(stdout, stderr)
|
49
|
+
|
50
|
+
status = waitpid(pid)
|
51
|
+
runtime = Time.now - start
|
52
|
+
|
53
|
+
@printer.print_command_exit(cmd, status, runtime)
|
54
|
+
|
55
|
+
Result.new(status, stdout_data, stderr_data, runtime)
|
56
|
+
ensure
|
57
|
+
[stdin, stdout, stderr].each { |fd| fd.close if fd && !fd.closed? }
|
58
|
+
if pid # Ensure no zombie processes
|
59
|
+
::Process.detach(pid)
|
60
|
+
terminate(pid)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Stop a process marked by pid
|
65
|
+
#
|
66
|
+
# @param [Integer] pid
|
67
|
+
#
|
68
|
+
# @api public
|
69
|
+
def terminate(pid)
|
70
|
+
::Process.kill(@signal, pid) rescue nil
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# The buffer size for reading stdout and stderr
|
76
|
+
BUFSIZE = 16 * 1024
|
77
|
+
|
78
|
+
# @api private
|
79
|
+
def handle_timeout(runtime)
|
80
|
+
return unless @timeout
|
81
|
+
|
82
|
+
t = @timeout - runtime
|
83
|
+
raise TimeoutExceeded if t < 0.0
|
84
|
+
end
|
85
|
+
|
86
|
+
# Write the input to the process stdin
|
87
|
+
#
|
88
|
+
# @api private
|
89
|
+
def write_stream(stream, input)
|
90
|
+
start = Time.now
|
91
|
+
writers = [input && stream].compact
|
92
|
+
|
93
|
+
while writers.any?
|
94
|
+
ready = IO.select(nil, writers, writers, @timeout)
|
95
|
+
raise TimeoutExceeded if ready.nil?
|
96
|
+
|
97
|
+
ready[1].each do |writer|
|
98
|
+
begin
|
99
|
+
err = nil
|
100
|
+
size = writer.write(@input)
|
101
|
+
input = input.byteslice(size..-1)
|
102
|
+
rescue IO::WaitWritable
|
103
|
+
rescue Errno::EPIPE => err
|
104
|
+
# The pipe closed before all input written
|
105
|
+
# Probably process exited prematurely
|
106
|
+
writer.close
|
107
|
+
writers.delete(writer)
|
108
|
+
end
|
109
|
+
if err || input.bytesize == 0
|
110
|
+
writer.close
|
111
|
+
writers.delete(writer)
|
112
|
+
end
|
113
|
+
|
114
|
+
# control total time spent writing
|
115
|
+
runtime = Time.now - start
|
116
|
+
handle_timeout(runtime)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Read stdout & stderr streams in the background
|
122
|
+
#
|
123
|
+
# @param [IO] stdout
|
124
|
+
# @param [IO] stderr
|
125
|
+
#
|
126
|
+
# @api private
|
127
|
+
def read_streams(stdout, stderr)
|
128
|
+
stdout_data = []
|
129
|
+
stderr_data = Truncator.new
|
130
|
+
|
131
|
+
out_buffer = ->(line) {
|
132
|
+
stdout_data << line
|
133
|
+
@printer.print_command_out_data(cmd, line)
|
134
|
+
@block.(line, nil) if @block
|
135
|
+
}
|
136
|
+
|
137
|
+
err_buffer = ->(line) {
|
138
|
+
stderr_data << line
|
139
|
+
@printer.print_command_err_data(cmd, line)
|
140
|
+
@block.(nil, line) if @block
|
141
|
+
}
|
142
|
+
|
143
|
+
stdout_thread = read_stream(stdout, out_buffer)
|
144
|
+
stderr_thread = read_stream(stderr, err_buffer)
|
145
|
+
|
146
|
+
stdout_thread.join
|
147
|
+
stderr_thread.join
|
148
|
+
|
149
|
+
encoding = @binmode ? Encoding::BINARY : Encoding::UTF_8
|
150
|
+
|
151
|
+
[
|
152
|
+
stdout_data.join.force_encoding(encoding),
|
153
|
+
stderr_data.read.dup.force_encoding(encoding)
|
154
|
+
]
|
155
|
+
end
|
156
|
+
|
157
|
+
def read_stream(stream, buffer)
|
158
|
+
Thread.new do
|
159
|
+
if Thread.current.respond_to?(:report_on_exception)
|
160
|
+
Thread.current.report_on_exception = false
|
161
|
+
end
|
162
|
+
Thread.current[:cmd_start] = Time.now
|
163
|
+
readers = [stream]
|
164
|
+
|
165
|
+
while readers.any?
|
166
|
+
ready = IO.select(readers, nil, readers, @timeout)
|
167
|
+
raise TimeoutExceeded if ready.nil?
|
168
|
+
|
169
|
+
ready[0].each do |reader|
|
170
|
+
begin
|
171
|
+
line = reader.readpartial(BUFSIZE)
|
172
|
+
buffer.(line)
|
173
|
+
|
174
|
+
# control total time spent reading
|
175
|
+
runtime = Time.now - Thread.current[:cmd_start]
|
176
|
+
handle_timeout(runtime)
|
177
|
+
rescue Errno::EAGAIN, Errno::EINTR
|
178
|
+
rescue EOFError, Errno::EPIPE, Errno::EIO # thrown by PTY
|
179
|
+
readers.delete(reader)
|
180
|
+
reader.close
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# @api private
|
188
|
+
def waitpid(pid)
|
189
|
+
_pid, status = ::Process.waitpid2(pid, ::Process::WUNTRACED)
|
190
|
+
status.exitstatus || status.termsig if _pid
|
191
|
+
rescue Errno::ECHILD
|
192
|
+
# In JRuby, waiting on a finished pid raises.
|
193
|
+
end
|
194
|
+
end # ProcessRunner
|
195
|
+
end # Command
|
196
|
+
end # TTY
|