tty-command 0.1.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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +3 -0
- data/.travis.yml +25 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +392 -0
- data/Rakefile +10 -0
- data/bin/console +6 -0
- data/bin/setup +6 -0
- data/examples/bash.rb +12 -0
- data/examples/basic.rb +9 -0
- data/examples/env.rb +9 -0
- data/examples/logger.rb +10 -0
- data/examples/redirect_stderr.rb +10 -0
- data/examples/redirect_stdout.rb +7 -0
- data/examples/timeout.rb +7 -0
- data/lib/tty-command.rb +1 -0
- data/lib/tty/command.rb +160 -0
- data/lib/tty/command/cmd.rb +153 -0
- data/lib/tty/command/dry_runner.rb +19 -0
- data/lib/tty/command/execute.rb +134 -0
- data/lib/tty/command/exit_error.rb +28 -0
- data/lib/tty/command/printers/abstract.rb +50 -0
- data/lib/tty/command/printers/null.rb +15 -0
- data/lib/tty/command/printers/pretty.rb +67 -0
- data/lib/tty/command/printers/progress.rb +31 -0
- data/lib/tty/command/printers/quiet.rb +25 -0
- data/lib/tty/command/process_runner.rb +101 -0
- data/lib/tty/command/result.rb +72 -0
- data/lib/tty/command/truncator.rb +109 -0
- data/lib/tty/command/version.rb +7 -0
- data/tasks/console.rake +10 -0
- data/tasks/coverage.rake +11 -0
- data/tasks/spec.rake +29 -0
- data/tty-command.gemspec +27 -0
- metadata +146 -0
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/examples/bash.rb
ADDED
data/examples/basic.rb
ADDED
data/examples/env.rb
ADDED
data/examples/logger.rb
ADDED
data/examples/timeout.rb
ADDED
data/lib/tty-command.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'tty/command'
|
data/lib/tty/command.rb
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'thread'
|
4
|
+
require 'tty/command/version'
|
5
|
+
require 'tty/command/cmd'
|
6
|
+
require 'tty/command/exit_error'
|
7
|
+
require 'tty/command/dry_runner'
|
8
|
+
require 'tty/command/process_runner'
|
9
|
+
require 'tty/command/printers/null'
|
10
|
+
require 'tty/command/printers/pretty'
|
11
|
+
require 'tty/command/printers/progress'
|
12
|
+
require 'tty/command/printers/quiet'
|
13
|
+
|
14
|
+
module TTY
|
15
|
+
class Command
|
16
|
+
ExecuteError = Class.new(StandardError)
|
17
|
+
|
18
|
+
TimeoutExceeded = Class.new(StandardError)
|
19
|
+
|
20
|
+
attr_reader :printer
|
21
|
+
|
22
|
+
# Initialize a Command object
|
23
|
+
#
|
24
|
+
# @param [Hash] options
|
25
|
+
# @option options [IO] :output
|
26
|
+
# the stream to which printer prints, defaults to stdout
|
27
|
+
# @option options [Symbol] :printer
|
28
|
+
# the printer to use for output logging, defaults to :pretty
|
29
|
+
# @option options [Symbol] :dry_run
|
30
|
+
# the mode for executing command
|
31
|
+
#
|
32
|
+
# @api public
|
33
|
+
def initialize(options = {})
|
34
|
+
@output = options.fetch(:output) { $stdout }
|
35
|
+
@color = options.fetch(:color) { true }
|
36
|
+
@uuid = options.fetch(:uuid) { true }
|
37
|
+
@printer_name = options.fetch(:printer) { :pretty }
|
38
|
+
@dry_run = options.fetch(:dry_run) { false }
|
39
|
+
@printer = use_printer(@printer_name, color: @color, uuid: @uuid)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Start external executable in a child process
|
43
|
+
#
|
44
|
+
# @example
|
45
|
+
# cmd.run(command, [argv1, ..., argvN], [options])
|
46
|
+
#
|
47
|
+
# @param [String] command
|
48
|
+
# the command to run
|
49
|
+
#
|
50
|
+
# @param [Array[String]] argv
|
51
|
+
# an array of string arguments
|
52
|
+
#
|
53
|
+
# @param [Hash] options
|
54
|
+
# hash of operations to perform
|
55
|
+
#
|
56
|
+
# @option options [String] :chdir
|
57
|
+
# The current directory.
|
58
|
+
#
|
59
|
+
# @option options [Integer] :timeout
|
60
|
+
# Maximum number of seconds to allow the process
|
61
|
+
# to run before aborting with a TimeoutExceeded
|
62
|
+
# exception.
|
63
|
+
#
|
64
|
+
# @raise [ExitError]
|
65
|
+
# raised when command exits with non-zero code
|
66
|
+
#
|
67
|
+
# @api public
|
68
|
+
def run(*args)
|
69
|
+
cmd = command(*args)
|
70
|
+
yield(cmd) if block_given?
|
71
|
+
result = execute_command(cmd)
|
72
|
+
if result && result.failure?
|
73
|
+
raise ExitError.new(cmd.to_command, result)
|
74
|
+
end
|
75
|
+
result
|
76
|
+
end
|
77
|
+
|
78
|
+
# Start external executable without raising ExitError
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# cmd.run!(command, [argv1, ..., argvN], [options])
|
82
|
+
#
|
83
|
+
# @api public
|
84
|
+
def run!(*args)
|
85
|
+
cmd = command(*args)
|
86
|
+
yield(cmd) if block_given?
|
87
|
+
execute_command(cmd)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Execute shell test command
|
91
|
+
#
|
92
|
+
# @api public
|
93
|
+
def test(*args)
|
94
|
+
run!(:test, *args).success?
|
95
|
+
end
|
96
|
+
|
97
|
+
# Check if in dry mode
|
98
|
+
#
|
99
|
+
# @return [Boolean]
|
100
|
+
#
|
101
|
+
# @public
|
102
|
+
def dry_run?
|
103
|
+
@dry_run
|
104
|
+
end
|
105
|
+
|
106
|
+
def printer
|
107
|
+
use_printer(@printer_name, color: @color, uuid: @uuid)
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
# @api private
|
113
|
+
def command(*args)
|
114
|
+
Cmd.new(*args)
|
115
|
+
end
|
116
|
+
|
117
|
+
# @api private
|
118
|
+
def execute_command(cmd)
|
119
|
+
mutex = Mutex.new
|
120
|
+
dry_run = @dry_run || cmd.options[:dry_run] || false
|
121
|
+
@runner = select_runner(@printer, dry_run)
|
122
|
+
mutex.synchronize { @runner.run(cmd) }
|
123
|
+
end
|
124
|
+
|
125
|
+
# @api private
|
126
|
+
def use_printer(class_or_name, options)
|
127
|
+
if class_or_name.is_a?(TTY::Command::Printers::Abstract)
|
128
|
+
return class_or_name
|
129
|
+
end
|
130
|
+
|
131
|
+
if class_or_name.is_a?(Class)
|
132
|
+
class_or_name
|
133
|
+
else
|
134
|
+
find_printer_class(class_or_name)
|
135
|
+
end.new(@output, options)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Find printer class or fail
|
139
|
+
#
|
140
|
+
# @raise [ArgumentError]
|
141
|
+
#
|
142
|
+
# @api private
|
143
|
+
def find_printer_class(name)
|
144
|
+
const_name = name.to_s.capitalize.to_sym
|
145
|
+
if const_name.empty? || !TTY::Command::Printers.const_defined?(const_name)
|
146
|
+
fail ArgumentError, %(Unknown printer type "#{name}")
|
147
|
+
end
|
148
|
+
TTY::Command::Printers.const_get(const_name)
|
149
|
+
end
|
150
|
+
|
151
|
+
# @api private
|
152
|
+
def select_runner(printer, dry_run)
|
153
|
+
if dry_run
|
154
|
+
DryRunner.new(printer)
|
155
|
+
else
|
156
|
+
ProcessRunner.new(printer)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end # Command
|
160
|
+
end # TTY
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
module TTY
|
6
|
+
class Command
|
7
|
+
class Cmd
|
8
|
+
# A string command name, or shell program
|
9
|
+
# @api public
|
10
|
+
attr_reader :command
|
11
|
+
|
12
|
+
# A string arguments
|
13
|
+
# @api public
|
14
|
+
attr_reader :argv
|
15
|
+
|
16
|
+
# Hash of operations to peform
|
17
|
+
# @api public
|
18
|
+
attr_reader :options
|
19
|
+
|
20
|
+
# Initialize a new Cmd object
|
21
|
+
#
|
22
|
+
# @api private
|
23
|
+
def initialize(env_or_cmd, *args)
|
24
|
+
opts = args.last.respond_to?(:to_hash) ? args.pop : {}
|
25
|
+
if env_or_cmd.respond_to?(:to_hash)
|
26
|
+
@env = env_or_cmd
|
27
|
+
unless command = args.shift
|
28
|
+
raise ArgumentError, 'Cmd requires command argument'
|
29
|
+
end
|
30
|
+
else
|
31
|
+
command = env_or_cmd
|
32
|
+
end
|
33
|
+
|
34
|
+
if args.empty? && cmd = command.to_s
|
35
|
+
raise ArgumentError, 'No command provided' if cmd.empty?
|
36
|
+
@command = sanitize(cmd)
|
37
|
+
@argv = []
|
38
|
+
else
|
39
|
+
if command.respond_to?(:to_ary)
|
40
|
+
@command = sanitize(command[0])
|
41
|
+
args.unshift(*command[1..-1])
|
42
|
+
else
|
43
|
+
@command = sanitize(command)
|
44
|
+
end
|
45
|
+
@argv = args.map { |i| shell_escape(i) }
|
46
|
+
end
|
47
|
+
@env ||= {}
|
48
|
+
@options = opts
|
49
|
+
end
|
50
|
+
|
51
|
+
# Unique identifier
|
52
|
+
#
|
53
|
+
# @api public
|
54
|
+
def uuid
|
55
|
+
@uuid ||= SecureRandom.uuid.split('-')[0]
|
56
|
+
end
|
57
|
+
|
58
|
+
# The shell environment variables
|
59
|
+
#
|
60
|
+
# @api public
|
61
|
+
def environment
|
62
|
+
@env.merge(options.fetch(:env, {}))
|
63
|
+
end
|
64
|
+
|
65
|
+
def environment_string
|
66
|
+
environment.map do |key, val|
|
67
|
+
converted_key = key.is_a?(Symbol) ? key.to_s.upcase : key.to_s
|
68
|
+
escaped_val = val.to_s.gsub(/"/, '\"')
|
69
|
+
%(#{converted_key}="#{escaped_val}")
|
70
|
+
end.join(' ')
|
71
|
+
end
|
72
|
+
|
73
|
+
def evars(value, &block)
|
74
|
+
return (value || block) unless environment.any?
|
75
|
+
%(( export #{environment_string} ; %s )) % [value || block.call]
|
76
|
+
end
|
77
|
+
|
78
|
+
def umask(value)
|
79
|
+
return value unless options[:umask]
|
80
|
+
%(umask #{options[:umask]} && %s) % [value]
|
81
|
+
end
|
82
|
+
|
83
|
+
def chdir(value)
|
84
|
+
return value unless options[:chdir]
|
85
|
+
%(cd #{options[:chdir]} && %s) % [value]
|
86
|
+
end
|
87
|
+
|
88
|
+
def user(value)
|
89
|
+
return value unless options[:user]
|
90
|
+
vars = environment.any? ? "#{environment_string} " : ''
|
91
|
+
%(sudo -u #{options[:user]} #{vars}-- sh -c '%s') % [value]
|
92
|
+
end
|
93
|
+
|
94
|
+
def group(value)
|
95
|
+
return value unless options[:group]
|
96
|
+
%(sg #{options[:group]} -c \\\"%s\\\") % [value]
|
97
|
+
end
|
98
|
+
|
99
|
+
# Clear environment variables except specified by env
|
100
|
+
#
|
101
|
+
# @api public
|
102
|
+
def with_clean_env
|
103
|
+
end
|
104
|
+
|
105
|
+
# Assemble full command
|
106
|
+
#
|
107
|
+
# @api public
|
108
|
+
def to_command
|
109
|
+
chdir(umask(evars(user(group(to_s)))))
|
110
|
+
end
|
111
|
+
|
112
|
+
# @api public
|
113
|
+
def to_s
|
114
|
+
[command.to_s, *Array(argv)].join(' ')
|
115
|
+
end
|
116
|
+
|
117
|
+
# @api public
|
118
|
+
def to_hash
|
119
|
+
{
|
120
|
+
command: command,
|
121
|
+
argv: argv,
|
122
|
+
uuid: uuid
|
123
|
+
}
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
# @api private
|
129
|
+
def sanitize(value)
|
130
|
+
cmd = value.to_s.dup
|
131
|
+
lines = cmd.lines.to_a
|
132
|
+
lines.each_with_index.reduce('') do |acc, (line, index)|
|
133
|
+
acc << line.strip
|
134
|
+
acc << '; ' if (index + 1) != lines.size
|
135
|
+
acc
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Enclose s in quotes if it contains characters that require escaping
|
140
|
+
#
|
141
|
+
# @param [String] arg
|
142
|
+
# the argument to escape
|
143
|
+
#
|
144
|
+
# @api private
|
145
|
+
def shell_escape(arg)
|
146
|
+
str = arg.to_s.dup
|
147
|
+
return str if str =~ /^[0-9A-Za-z+,.\/:=@_-]+$/
|
148
|
+
str.gsub!("'", "'\\''")
|
149
|
+
"'#{str}'"
|
150
|
+
end
|
151
|
+
end # Cmd
|
152
|
+
end # Command
|
153
|
+
end # TTY
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module TTY
|
4
|
+
class Command
|
5
|
+
class DryRunner
|
6
|
+
def initialize(printer)
|
7
|
+
@printer = printer
|
8
|
+
end
|
9
|
+
|
10
|
+
def run(cmd)
|
11
|
+
cmd.to_command
|
12
|
+
message = "#{@printer.decorate('(dry run)', :blue)} "
|
13
|
+
message << @printer.decorate(cmd.to_command, :yellow, :bold)
|
14
|
+
@printer.write(message, cmd.uuid)
|
15
|
+
Result.new(0, '', '')
|
16
|
+
end
|
17
|
+
end # DryRunner
|
18
|
+
end # Command
|
19
|
+
end # TTY
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'tempfile'
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
module TTY
|
7
|
+
class Command
|
8
|
+
module Execute
|
9
|
+
extend self
|
10
|
+
|
11
|
+
# Execute command in a child process
|
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 execute
|
19
|
+
#
|
20
|
+
# @return [pid, stdin, stdout, stderr]
|
21
|
+
#
|
22
|
+
# @api public
|
23
|
+
def spawn(cmd)
|
24
|
+
@process_options = normalize_redirect_options(cmd.options)
|
25
|
+
|
26
|
+
# Create pipes
|
27
|
+
in_rd, in_wr = IO.pipe # reading
|
28
|
+
out_rd, out_wr = IO.pipe # writing
|
29
|
+
err_rd, err_wr = IO.pipe # error
|
30
|
+
|
31
|
+
# redirect fds
|
32
|
+
opts = ({
|
33
|
+
:in => in_rd, # in_wr => :close,
|
34
|
+
:out => out_wr,# out_rd => :close,
|
35
|
+
:err => err_wr,# err_rd => :close
|
36
|
+
}).merge(@process_options)
|
37
|
+
|
38
|
+
pid = Process.spawn(cmd.to_command, opts)
|
39
|
+
|
40
|
+
# close in parent process
|
41
|
+
[out_wr, err_wr].each { |fd| fd.close if fd }
|
42
|
+
|
43
|
+
tuple = [pid, in_wr, out_rd, err_rd]
|
44
|
+
|
45
|
+
if block_given?
|
46
|
+
begin
|
47
|
+
return yield(*tuple)
|
48
|
+
ensure
|
49
|
+
[in_wr, out_rd, err_rd].each { |fd| fd.close if fd && !fd.closed? }
|
50
|
+
end
|
51
|
+
else
|
52
|
+
tuple
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def normalize_redirect_options(options)
|
59
|
+
options.reduce({}) do |opts, (key, value)|
|
60
|
+
if fd?(key)
|
61
|
+
process_key = fd_to_process_key(key)
|
62
|
+
if process_key.to_s == 'in'
|
63
|
+
value = convert_to_fd(value)
|
64
|
+
end
|
65
|
+
opts[process_key]= value
|
66
|
+
end
|
67
|
+
opts
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# @api private
|
72
|
+
def fd?(object)
|
73
|
+
case object
|
74
|
+
when :stdin, :stdout, :stderr, :in, :out, :err
|
75
|
+
true
|
76
|
+
when STDIN, STDOUT, STDERR, $stdin, $stdout, $stderr, ::IO
|
77
|
+
true
|
78
|
+
when ::IO
|
79
|
+
true
|
80
|
+
when ::Fixnum
|
81
|
+
object >= 0
|
82
|
+
when respond_to?(:to_i) && !object.to_io.nil?
|
83
|
+
true
|
84
|
+
else
|
85
|
+
false
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def try_reading(object)
|
90
|
+
if object.respond_to?(:read)
|
91
|
+
object.read
|
92
|
+
elsif object.respond_to?(:to_s)
|
93
|
+
object.to_s
|
94
|
+
else
|
95
|
+
object
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def convert_to_fd(object)
|
100
|
+
return object if fd?(object)
|
101
|
+
|
102
|
+
if object.is_a?(::String) && File.exists?(object)
|
103
|
+
return object
|
104
|
+
end
|
105
|
+
|
106
|
+
tmp = Tempfile.new(SecureRandom.uuid.split('-')[0])
|
107
|
+
|
108
|
+
content = try_reading(object)
|
109
|
+
tmp.write(content)
|
110
|
+
tmp.rewind
|
111
|
+
tmp
|
112
|
+
end
|
113
|
+
|
114
|
+
def fd_to_process_key(object)
|
115
|
+
case object
|
116
|
+
when STDIN, $stdin, :in, :stdin, 0
|
117
|
+
:in
|
118
|
+
when STDOUT, $stdout, :out, :stdout, 1
|
119
|
+
:out
|
120
|
+
when STDERR, $stderr, :err, :stderr, 2
|
121
|
+
:err
|
122
|
+
when Fixnum
|
123
|
+
object >= 0 ? IO.for_fd(object) : nil
|
124
|
+
when IO
|
125
|
+
object
|
126
|
+
when respond_to?(:to_io)
|
127
|
+
object.to_io
|
128
|
+
else
|
129
|
+
raise ExecuteError, "Wrong execute redirect: #{object.inspect}"
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end # Execute
|
133
|
+
end # Command
|
134
|
+
end # TTY
|