outstand-tty-command 0.10.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- 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
|