require 'fcntl' require 'forward_to' include ForwardTo module Command class Error < RuntimeError attr_reader :cmd attr_reader :status attr_reader :stdin attr_reader :stdout attr_reader :stderr def initialize(cmd, status, stdin, stdout, stderr) super(stderr.join("\n")) @cmd = cmd @status = status @stdin = stdin @stdout = stdout @stderr = stderr end end class Pipe attr_reader :cmd attr_reader :status def writer() @pw[1] end def reader() @pr[0] end def error() @pe[0] end forward_to :reader, :read, :gets forward_to :writer, :write, :puts # Remaining output not consumed by #get/#read or through #reader or #error attr_reader :stdout attr_reader :stdin def initialize(cmd, stderr: nil, fail: true) @cmd = "set -o errexit\nset -o pipefail\n#{cmd}" @stderr = stderr @fail = fail @pw = IO::pipe # pipe[0] for read, pipe[1] for write @pr = IO::pipe @pe = IO::pipe STDOUT.flush @pid = fork { @pw[1].close @pr[0].close @pe[0].close STDIN.reopen(@pw[0]) @pw[0].close STDOUT.reopen(@pr[1]) @pr[1].close STDERR.reopen(@pe[1]) @pe[1].close exec(cmd) } @pw[0].close @pr[1].close @pe[1].close end def wait @pw[1].close @status = Process.waitpid2(@pid)[1].exitstatus out = @pr[0].readlines.map(&:chomp) err = @pe[0].readlines.map(&:chomp) @pr[0].close @pe[0].close raise Command::Error.new(@cmd, @status, nil, out, err) if @status != 0 && @fail == true case @stderr when true; [out, err] when false; out when nil $stderr.puts err out end end end # Execute the shell command 'cmd' and return standard-output as an array of # strings. If :stdin is a string or an array of lines if will be fed to the # command on standard-input, if it is a IO object that IO object is piped to # the command # # By default #command pass through standard-error but if :stderr is true, # #command will return a tuple of standard-output, standard-error lines. If # :stderr is false, standard-error is ignored and is the same as adding # "2>/dev/null" to the command # # #command raises a Command::Error exception if the command return with an # exit code != 0 unless :fail is false. In that case the the exit code can be # fetched from Command::status # def command(cmd, stdin: nil, stderr: nil, fail: true) cmd = "set -o errexit\nset -o pipefail\n#{cmd}" pw = IO::pipe # pipe[0] for read, pipe[1] for write pr = IO::pipe pe = IO::pipe STDOUT.flush pid = fork { pw[1].close pr[0].close pe[0].close STDIN.reopen(pw[0]) pw[0].close STDOUT.reopen(pr[1]) pr[1].close STDERR.reopen(pe[1]) pe[1].close exec(cmd) } pw[0].close pr[1].close pe[1].close if stdin case stdin when IO; pw[1].write(stdin.read) when String; pw[1].write(stdin) when Array; pw[1].write(stdin.join("\n") + "\n") end pw[1].flush pw[1].close end @status = Process.waitpid2(pid)[1].exitstatus out = pr[0].readlines.map(&:chomp) err = pe[0].readlines.map(&:chomp) pw[1].close if !stdin pr[0].close pe[0].close if @status != 0 @exception = Command::Error.new(cmd, @status, stdin, out, err) raise @exception if fail else @exception = nil end case stderr when true; [out, err] when false; out when nil $stderr.puts err out end end # Exit status of the last command. FIXME: This is not usable because the # methods raise an exception if the command exited with anything but 0 def status() @status end # Stored exception when #command is called with :fail true def exception() @exception end # Like command but returns true if the command exited with the expected # status. Note that it suppresses standard-error by default # def command?(cmd, expect: 0, stdin: nil, stderr: false) command(cmd, stdin: stdin, stderr: stderr, fail: false) @status == expect end module_function :command module_function :status module_function :exception module_function :command? end