├── .ruby-version ├── CHANGELOG.md ├── lib ├── backticks │ ├── version.rb │ ├── ext.rb │ ├── cli.rb │ ├── runner.rb │ └── command.rb └── backticks.rb ├── .rspec ├── .gitignore ├── Rakefile ├── bin ├── setup └── console ├── Gemfile ├── spec ├── cli_spec.rb ├── spec_helper.rb ├── backticks_spec.rb └── backticks │ ├── runner_spec.rb │ └── command_spec.rb ├── .travis.yml ├── backticks.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.0 2 | === 3 | -------------------------------------------------------------------------------- /lib/backticks/version.rb: -------------------------------------------------------------------------------- 1 | module Backticks 2 | VERSION = "1.0.5" 3 | end 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order random 4 | --warnings 5 | --require spec_helper 6 | -------------------------------------------------------------------------------- /lib/backticks/ext.rb: -------------------------------------------------------------------------------- 1 | module Backticks 2 | module Ext 3 | def `(cmd) 4 | Backticks.run(cmd) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in backticks.gemspec 4 | gemspec 5 | 6 | group :development do 7 | gem 'pry' 8 | gem 'pry-byebug' 9 | end 10 | 11 | group :test do 12 | gem 'coveralls', require: false 13 | end 14 | -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Backticks::CLI do 4 | describe Backticks::CLI::Getopt do 5 | describe "self.options" do 6 | it "converts hash arguments" do 7 | expect(Backticks::CLI::Getopt.options(X: "V")).to eq(["-X", "V"]) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "backticks" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.0 5 | - 2.1 6 | - 2.2 7 | - 2.3 8 | - 2.4 9 | - 2.7.1 10 | - 3.0.0 11 | 12 | jobs: 13 | include: 14 | - rvm: 2.0 15 | before_install: gem install bundler -v 1.17.3 16 | - rvm: 2.1 17 | before_install: gem install bundler -v 1.17.3 18 | - rvm: 2.2 19 | before_install: gem install bundler -v 1.17.3 20 | - rvm: 2.3 21 | before_install: gem install bundler 22 | - rvm: 2.4 23 | before_install: gem install bundler 24 | - rvm: 2.6 25 | before_install: gem install bundler 26 | - rvm: 2.7 27 | before_install: gem install bundler 28 | - rvm: 3.0 29 | before_install: gem install bundler 30 | dist: focal # Workaround for: https://github.com/rvm/rvm/issues/5133 31 | 32 | script: bundle exec rake spec 33 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'pry' 2 | 3 | require 'coveralls' 4 | Coveralls.wear! 5 | 6 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 7 | require 'backticks' 8 | 9 | RSpec.configure do |config| 10 | config.filter_run focus: true 11 | config.run_all_when_everything_filtered = true 12 | 13 | RSpec::Expectations.configuration.on_potential_false_positives = :raise 14 | end 15 | 16 | # Expect a Backticks::Command to succeed. 17 | RSpec::Matchers.define :succeed do 18 | match do |actual| 19 | actual = actual.join if actual.respond_to?(:join) 20 | expect(actual.status).to be_success 21 | end 22 | end 23 | 24 | # Expect a Backticks::Command to fail. 25 | RSpec::Matchers.define :fail do 26 | match do |actual| 27 | actual = actual.join if actual.respond_to?(:join) 28 | expect(actual.status).not_to be_success 29 | end 30 | end 31 | 32 | # Expect a Backticks::Command to have a certain pid 33 | RSpec::Matchers.define :have_pid do |pid| 34 | match do |actual| 35 | expect(actual).to respond_to(:pid) 36 | expect(actual.pid).to equal(pid) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/backticks_spec.rb: -------------------------------------------------------------------------------- 1 | describe Backticks do 2 | it 'has a version number' do 3 | expect(Backticks::VERSION).not_to eq(nil) 4 | end 5 | 6 | describe '.new' do 7 | it 'returns a Command' do 8 | expect(subject.new('ls')).to be_a(Backticks::Command) 9 | end 10 | 11 | it 'accepts all-in-one commands' do 12 | expect(subject.new('ls -lR')).to succeed 13 | end 14 | 15 | it 'accepts one-word commands' do 16 | expect(subject.new('ls')).to succeed 17 | end 18 | 19 | it 'accepts multi-word commands' do 20 | expect(subject.new('ls', '-lR')).to succeed 21 | end 22 | 23 | it 'accepts sugared commands' do 24 | expect(subject.new('ls', l:true, R:true)).to succeed 25 | end 26 | end 27 | 28 | describe '.run' do 29 | it "returns the command's output" do 30 | expect(subject.run('echo hi').strip).to eq("hi") 31 | end 32 | end 33 | 34 | describe '.system' do 35 | it "returns the command's success status" do 36 | expect(subject.system('false')).to eq(false) 37 | expect(subject.system('true')).to eq(true) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /backticks.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'backticks/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'backticks' 8 | spec.version = Backticks::VERSION 9 | spec.authors = ['Tony Spataro'] 10 | spec.email = ['xeger@xeger.net'] 11 | 12 | spec.summary = %q{Intuitive OOP wrapper for command-line processes} 13 | spec.description = %q{Captures stdout, stderr and (optionally) stdin; uses PTY to avoid buffering.} 14 | spec.homepage = 'https://github.com/xeger/backticks' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = 'exe' 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ['lib'] 21 | 22 | spec.required_ruby_version = Gem::Requirement.new('>= 2.0', '< 4.0') 23 | 24 | spec.add_development_dependency 'bundler' 25 | spec.add_development_dependency 'rake' 26 | spec.add_development_dependency 'rspec' 27 | end 28 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /lib/backticks.rb: -------------------------------------------------------------------------------- 1 | require_relative 'backticks/version' 2 | require_relative 'backticks/cli' 3 | require_relative 'backticks/command' 4 | require_relative 'backticks/runner' 5 | require_relative 'backticks/ext' 6 | 7 | module Backticks 8 | # Run a command with default invocation options; return a Command object that 9 | # can be used to interact with the running process. 10 | # 11 | # @param [Array] sugar list of command words and options 12 | # @return [Backticks::Command] a running command 13 | # @see Backticks::Runner#run for a better description of sugar 14 | # @see Backticks::Runner for more control over process invocation 15 | def self.new(*sugar) 16 | Backticks::Runner.new.run(*sugar) 17 | end 18 | 19 | # Run a command with default invocation options; wait for to exit, then return 20 | # its output. Populate $? with the command's status before returning. 21 | # 22 | # @param [Array] sugar list of command words and options 23 | # @return [String] the command's output 24 | # @see Backticks::Runner#run for a better description of sugar 25 | # @see Backticks::Runner for more control over process invocation 26 | def self.run(*sugar) 27 | command = self.new(*sugar) 28 | command.join 29 | command.captured_output 30 | end 31 | 32 | # Run a command; return whether it succeeded or failed. 33 | # 34 | # @param [Array] sugar list of command words and options 35 | # @return [Boolean] true if the command succeeded; false otherwise 36 | # @see Backticks::Runner#run for a better description of sugar 37 | # @see Backticks::Runner for more control over process invocation 38 | def self.system(*sugar) 39 | command = self.new(*sugar) 40 | command.join 41 | $?.success? 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/backticks/runner_spec.rb: -------------------------------------------------------------------------------- 1 | # Unit test of process runner; actual subprocess invocations are mocked out. 2 | # @see command_spec.rb for functional tests of this class 3 | describe Backticks::Runner do 4 | let(:pid) { 123 } 5 | [:master, :slave, :reader, :writer].each do |fd| 6 | let(fd) { double(fd, close:true) } 7 | end 8 | 9 | before do 10 | # Use fake PTY to avoid MacOS resource exhaustion 11 | allow(PTY).to receive(:open).and_return([master, slave]) 12 | allow(IO).to receive(:pipe).and_return([reader, writer]) 13 | allow(subject).to receive(:spawn).and_return(pid) 14 | end 15 | 16 | describe '#run' do 17 | context 'given chdir' do 18 | it 'spawns with new pwd' do 19 | subject.chdir='/tmp/banana' 20 | expect(subject).to receive(:spawn).with('ls', hash_including(chdir:'/tmp/banana')) 21 | subject.run('ls') 22 | end 23 | 24 | it 'defaults to PWD' do 25 | subject.chdir=nil 26 | expect(subject).to receive(:spawn).with('ls', hash_including(chdir:Dir.pwd)) 27 | subject.run('ls') 28 | end 29 | end 30 | 31 | it 'works when unbuffered' do 32 | subject.buffered = false 33 | expect(PTY).to receive(:open).exactly(3).times 34 | expect(IO).to receive(:pipe).never 35 | cmd = subject.run('ls', recursive:true, long:true) 36 | expect(cmd).to have_pid(pid) 37 | end 38 | 39 | it 'works when buffered' do 40 | subject.interactive = false 41 | subject.buffered = true 42 | expect(PTY).to receive(:open).never 43 | expect(IO).to receive(:pipe).exactly(3).times 44 | cmd = subject.run('ls', recursive:true, long:true) 45 | expect(cmd).to have_pid(pid) 46 | end 47 | 48 | it 'works when partially buffered' do 49 | expect(PTY).to receive(:open).once 50 | expect(IO).to receive(:pipe).twice 51 | subject.buffered = [:stdin, :stderr] 52 | cmd = subject.run('ls', recursive:true, long:true) 53 | expect(cmd).to have_pid(pid) 54 | end 55 | 56 | it 'works when interactive' do 57 | expect(PTY).to receive(:open).twice 58 | expect(IO).to receive(:pipe).once 59 | subject.buffered = true 60 | subject.interactive = true 61 | cmd = subject.run('ls', recursive:true, long:true) 62 | expect(cmd).to have_pid(pid) 63 | end 64 | end 65 | 66 | [:command].each do |deprecated| 67 | describe format('#%s',deprecated) do 68 | it 'does not exist' do 69 | pending('major version 1.0') if Backticks::VERSION < '1' 70 | expect(subject.respond_to?(deprecated)).to eq(false) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/backticks/cli.rb: -------------------------------------------------------------------------------- 1 | module Backticks 2 | module CLI 3 | # Command-line parameter generator that relies on traditional *nix getopt 4 | # conventions. Getopt doesn't know about GNU conventions such as short and 5 | # long options; it doesn't know about abbreviations; it doesn't know about 6 | # conventions such as `--X` vs. `--no-X` or `-d` vs. `-D`. 7 | # 8 | # Although Getopt is simple, it has the tremendous advantage of being 9 | # compatible with a wide range of other schemes including GNU getopt-long, 10 | # golang flags, and most Java utilities. It's a great choice of default 11 | # CLI. 12 | module Getopt 13 | # Translate a series Ruby positional and keyword arguments into command- 14 | # parameters consisting of words and options. 15 | # 16 | # Each positional argument can be a Hash, an Array, or another object. 17 | # They are handled as follows: 18 | # - Hash is translated to a sequence of options; see #options 19 | # - Array is appended to the command line as a sequence of words 20 | # - other objects are turned into a string with #to_s and appended to the command line as a single word 21 | # 22 | # @return [Array] list of String words and options 23 | # 24 | # @example recursively find all text files 25 | # parameters('ls', l:true, R:true, '*.txt') => 'ls -l -R *.txt 26 | # 27 | # @example install your favorite gem 28 | # parameters('gem', 'install', no_document:true, 'backticks') 29 | def self.parameters(*sugar) 30 | argv = [] 31 | 32 | sugar.each do |item| 33 | case item 34 | when Array 35 | # list of words to append to argv 36 | argv.concat(item.map { |e| e.to_s }) 37 | when Hash 38 | # list of options to convert to CLI parameters 39 | argv.concat(options(item)) 40 | else 41 | # single word to append to argv 42 | argv << item.to_s 43 | end 44 | end 45 | 46 | argv 47 | end 48 | 49 | # Translate Ruby keyword arguments into command-line parameters using a 50 | # notation that is compatible with traditional Unix getopt. Command lines 51 | # generated by this method are also mostly compatible with the following: 52 | # - GNU getopt 53 | # - Ruby trollop gem 54 | # - Golang flags package 55 | # 56 | # This method accepts an unbounded set of keyword arguments (i.e. you can 57 | # pass it _any_ valid Ruby symbol as a kwarg). Each kwarg has a 58 | # value; the key/value pair is translated into a CLI option using the 59 | # following heuristic: 60 | # 1) Snake-case keys are hyphenated, e.g. :no_foo => "--no-foo" 61 | # 2) boolean values indicate a CLI flag; true includes the flag, false or nil omits it 62 | # 3) all other values indicate a CLI option that has a value. 63 | # 4) single character keys are passed as short options; {X: V} becomes "-X V" 64 | # 5) multi-character keys are passed as long options; {Xxx: V} becomes "--XXX=V" 65 | # 66 | # The generic translator doesn't know about short vs. long option names, 67 | # abbreviations, or the GNU "X vs. no-X" convention, so it does not 68 | # produce the most idiomatic or compact command line for a given program; 69 | # its output is, however, almost always valid for utilities that use 70 | # Unix-like parameters. 71 | # 72 | # @return [Array] list of String command-line options 73 | def self.options(kwargs={}) 74 | flags = [] 75 | 76 | # Transform opts into golang flags-style command line parameters; 77 | # append them to the command. 78 | kwargs.each do |kw, arg| 79 | if kw.length == 1 80 | if arg == true 81 | # true: boolean flag 82 | flags << "-#{kw}" 83 | elsif arg 84 | # truthey: option that has a value 85 | flags << "-#{kw}" << arg.to_s 86 | else 87 | # falsey: omit boolean flag 88 | end 89 | else 90 | kw = kw.to_s.gsub('_','-') 91 | if arg == true 92 | # true: boolean flag 93 | flags << "--#{kw}" 94 | elsif arg 95 | # truthey: option that has a value 96 | flags << "--#{kw}=#{arg}" 97 | else 98 | # falsey: omit boolean flag 99 | end 100 | end 101 | end 102 | 103 | flags 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/backticks/command_spec.rb: -------------------------------------------------------------------------------- 1 | # Unit test of the running-command object. Doubles as a functional test 2 | # for Runner and Command; actual subprocesses are invoked in some test cases. 3 | describe Backticks::Command do 4 | let(:pid) { 123 } 5 | let(:stdin) { double('stdin') } 6 | let(:stdout) { double('stdout') } 7 | let(:stderr) { double('stderr') } 8 | subject { Backticks::Command.new(pid, stdin, stdout, stderr) } 9 | 10 | # Avoid unnecessary PTY allocation, but still allow some functional tests 11 | # (i.e. real processes are invoked). 12 | let(:runner) { Backticks::Runner.new(:buffered => true) } 13 | 14 | describe '#success?' do 15 | it 'returns true when command succeeded' do 16 | expect(runner.run('true').success?).to eq(true) 17 | end 18 | 19 | it 'returns false when command failed' do 20 | expect(runner.run('false').success?).to eq(false) 21 | end 22 | end 23 | 24 | describe '#tap' do 25 | subject { runner.run('echo the quick red fox jumped over the lazy brown dog') } 26 | 27 | it 'can discard output' do 28 | subject.tap { |stream, data| nil } 29 | subject.join 30 | expect(subject.captured_output).to eq('') 31 | end 32 | 33 | it 'can transform output' do 34 | subject.tap { |stream, data| data.reverse } 35 | subject.join 36 | expect(subject.captured_output.strip).to eq('god nworb yzal eht revo depmuj xof der kciuq eht') 37 | end 38 | 39 | it 'idempotently allows one block' do 40 | blk = lambda { |s, d| d.reverse } 41 | 42 | subject.tap(&blk) 43 | 44 | expect do 45 | subject.tap { |s, d| d * 2 } 46 | end.to raise_error(StandardError) 47 | end 48 | end 49 | 50 | describe '#join' do 51 | let(:chunky) { described_class::CHUNK*2 } 52 | subject { runner.run("seq 1 #{chunky}") } 53 | 54 | it 'is idempotent' do 55 | subject.join 56 | expect {subject.join}.not_to raise_error 57 | expect(subject).to succeed 58 | end 59 | 60 | it 'exhausts the output stream' do 61 | subject.join 62 | expect(subject.captured_output).to end_with("#{chunky}\n") 63 | end 64 | 65 | context 'given a time limit' do 66 | before { 67 | allow(IO).to receive(:select).and_return([]) 68 | allow(subject).to receive(:eof?).and_return(true) 69 | } 70 | 71 | it 'waits forever when limit is nil' do 72 | expect(IO).to receive(:select).with( 73 | anything, anything, anything, 74 | be_within(0.5).of(Backticks::Command::FOREVER) 75 | ) 76 | subject.join 77 | end 78 | 79 | it 'returns early when limit is provided' do 80 | expect(IO).to receive(:select).with( 81 | anything, anything, anything, 82 | be_within(0.5).of(3) 83 | ) 84 | subject.join(3) 85 | end 86 | end 87 | 88 | context 'given interactive is true' do 89 | let(:runner) { Backticks::Runner.new(:interactive => true) } 90 | subject { Backticks::Runner.new(:interactive => true).run('ls') } 91 | 92 | it 'gracefully handles empty STDIN' do 93 | allow(IO).to receive(:select).and_return([[STDIN], nil, nil]) 94 | allow(STDIN).to receive(:readpartial).and_raise("Boom!") 95 | 96 | expect { subject.join }.not_to raise_error 97 | end 98 | 99 | it 'gracefully handles closed STDIN' do 100 | allow(IO).to receive(:select).and_return([[STDIN], nil, nil]) 101 | allow(STDIN).to receive(:readpartial).and_return(nil) 102 | 103 | expect { subject.join }.not_to raise_error 104 | end 105 | end 106 | end 107 | 108 | describe '#capture' do 109 | subject { runner.run('sh', '-c', 'sleep 1 ; echo hi') } 110 | 111 | it 'waits forever when limit is nil' do 112 | t0 = Time.now 113 | subject.capture 114 | t1 = Time.now 115 | expect(t1 - t0).to be_within(0.5).of(1) 116 | end 117 | 118 | it 'returns early when limit is present' do 119 | t0 = Time.now 120 | subject.capture(0.1) 121 | t1 = Time.now 122 | expect(t1 - t0).to be_within(0.05).of(0.1) 123 | end 124 | end 125 | 126 | describe '#eof?' do 127 | context 'platform-dependent PTY behavior' do 128 | it 'on stdout' do 129 | expect(stdout).to receive(:eof?).and_raise(Errno::EIO) 130 | expect(subject.eof?).to eq(true) 131 | end 132 | 133 | it 'on stderr' do 134 | expect(stdout).to receive(:eof?).and_return(true) 135 | expect(stderr).to receive(:eof?).and_raise(Errno::EIO) 136 | expect(subject.eof?).to eq(true) 137 | end 138 | end 139 | end 140 | 141 | it 'has attribute readers for captured I/O' do 142 | [:captured_input, :captured_output, :captured_error].each do |d| 143 | expect(subject).to be_a(Backticks::Command) 144 | expect(subject.respond_to?(d)).to eq(true) 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/backticks/runner.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'pty' 3 | rescue LoadError 4 | # for Windows support, tolerate a missing PTY module 5 | end 6 | 7 | require 'open3' 8 | 9 | module Backticks 10 | # An easy-to-use interface for invoking commands and capturing their output. 11 | # Instances of Runner can be interactive, which prints the command's output 12 | # to the terminal and also allows the user to interact with the command. 13 | # By default commands are unbuffered, using a pseudoterminal to capture 14 | # the output with no delay. 15 | class Runner 16 | # Default streams to buffer if someone calls bufferered= with Boolean. 17 | BUFFERED = [:stdin, :stdout, :stderr].freeze 18 | 19 | # If true, commands will have their stdio streams tied to the parent 20 | # process so the user can view their output and send input to them. 21 | # Commands' output is still captured normally when they are interactive. 22 | # 23 | # Note: if you set `interactive` to true, then stdin and stdout will be 24 | # unbuffered regardless of how you have set `buffered`! 25 | # 26 | # @return [Boolean] 27 | attr_accessor :interactive 28 | 29 | # List of I/O streams that should be captured using a pipe instead of 30 | # a pseudoterminal. 31 | # 32 | # When read, this attribute is always an Array of stream names from the 33 | # set `[:stdin, :stdout, :stderr]`. 34 | # 35 | # **Note**: if you set `interactive` to true, then stdin and stdout are 36 | # unbuffered regardless of how you have set `buffered`! 37 | # 38 | # @return [Array] list of symbolic stream names 39 | attr_reader :buffered 40 | 41 | # @return [String,nil] PWD for new child processes, default is Dir.pwd 42 | attr_accessor :chdir 43 | 44 | # @return [#parameters] the CLI-translation object used by this runner 45 | attr_reader :cli 46 | 47 | # Create an instance of Runner. 48 | # 49 | # @option [#include?,Boolean] buffered list of names; true/false for all/none 50 | # @option [#parameters] cli command-line parameter translator 51 | # @option [Boolean] interactive true to tie parent stdout/stdin to child 52 | # 53 | # @example buffer stdout 54 | # Runner.new(buffered:[:stdout]) 55 | def initialize(options={}) 56 | options = { 57 | :buffered => false, 58 | :cli => Backticks::CLI::Getopt, 59 | :interactive => false, 60 | }.merge(options) 61 | 62 | @cli = options[:cli] 63 | @chdir = nil 64 | self.buffered = options[:buffered] 65 | self.interactive = options[:interactive] 66 | end 67 | 68 | # Determine whether buffering is enabled. 69 | # 70 | # @return [true,false] 71 | def buffered? 72 | !!buffered 73 | end 74 | 75 | # Control which streams are buffered (i.e. use a pipe) and which are 76 | # unbuffered (i.e. use a pseudo-TTY). 77 | # 78 | # If you pass a Boolean argument, it is converted to an Array; therefore, 79 | # the reader for this attribute always returns a list even if you wrote 80 | # a boolean value. 81 | # 82 | # @param [Array,Boolean] buffered list of symbolic stream names; true/false for all/none 83 | def buffered=(b) 84 | @buffered = case b 85 | when true then BUFFERED 86 | when false, nil then [] 87 | else 88 | b 89 | end 90 | end 91 | 92 | # Run a command whose parameters are expressed using some Rubyish sugar. 93 | # This method accepts an arbitrary number of positional parameters; each 94 | # parameter can be a Hash, an array, or a simple Object. Arrays and simple 95 | # objects are appended to argv as words of the command; Hashes are 96 | # translated to command-line options and then appended to argv. 97 | # 98 | # Hashes are processed by @cli, defaulting to Backticks::CLI::Getopt and 99 | # easily overridden by passing the `cli` option to #initialize. 100 | # 101 | # @see Backticks::CLI::Getopt for option-Hash format information 102 | # 103 | # @param [Array] sugar list of command words and options 104 | # 105 | # @return [Command] the running command 106 | # 107 | # @example Run docker-compose with complex parameters 108 | # run('docker-compose', {file: 'joe.yml'}, 'up', {d:true}, 'mysvc') 109 | def run(*sugar) 110 | run_without_sugar(@cli.parameters(*sugar)) 111 | end 112 | 113 | # Run a command whose argv is specified in the same manner as Kernel#exec, 114 | # with no Rubyish sugar. 115 | # 116 | # @param [Array] argv command to run; argv[0] is program name and the 117 | # remaining elements are parameters and flags 118 | # @return [Command] the running command 119 | def run_without_sugar(argv) 120 | nopty = !defined?(PTY) 121 | 122 | stdin_r, stdin = if nopty || (buffered.include?(:stdin) && !interactive) 123 | IO.pipe 124 | else 125 | PTY.open 126 | end 127 | 128 | stdout, stdout_w = if nopty || (buffered.include?(:stdout) && !interactive) 129 | IO.pipe 130 | else 131 | PTY.open 132 | end 133 | 134 | stderr, stderr_w = if nopty || buffered.include?(:stderr) 135 | IO.pipe 136 | else 137 | PTY.open 138 | end 139 | 140 | dir = @chdir || Dir.pwd 141 | pid = spawn(*argv, in: stdin_r, out: stdout_w, err: stderr_w, chdir: dir) 142 | stdin_r.close 143 | stdout_w.close 144 | stderr_w.close 145 | unless interactive 146 | stdin.close 147 | stdin = nil 148 | end 149 | 150 | Command.new(pid, stdin, stdout, stderr, interactive:interactive) 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://travis-ci.org/xeger/backticks.svg) [![Coverage Status](https://coveralls.io/repos/xeger/backticks/badge.svg?branch=master&service=github)](https://coveralls.io/github/xeger/backticks?branch=master) [![Docs](https://img.shields.io/badge/docs-rubydoc-blue.svg)](http://www.rubydoc.info/gems/backticks) 2 | 3 | Backticks is a powerful, intuitive OOP wrapper for invoking command-line processes and 4 | interacting with them. 5 | 6 | "Powerful" comes from features that make Backticks especially well suited for time-sensitive 7 | or record/playback applications: 8 | - Uses [pseudoterminals](https://en.wikipedia.org/wiki/Pseudoterminal) for realtime stdout/stdin 9 | - Captures input as well as output 10 | - Separates stdout from stderr 11 | - Allows realtime monitoring and transformation of input/output 12 | 13 | "Intuitive" comes from a DSL that lets you provide command-line arguments as if they were 14 | Ruby method arguments: 15 | 16 | ```ruby 17 | Backticks.run 'ls', R:true, ignore_backups:true, hide:'.git' 18 | Backticks.run 'cp' {f:true}, '*.rb', '/mnt/awesome' 19 | ``` 20 | 21 | If you want to write a record/playback application for the terminal, or write 22 | functional tests that verify your program's output in real time, Backticks is 23 | exactly what you've been looking for! 24 | 25 | ## Installation 26 | 27 | Add this line to your application's Gemfile: 28 | 29 | ```ruby 30 | gem 'backticks' 31 | ``` 32 | 33 | And then execute: 34 | 35 | $ bundle 36 | 37 | Or install it yourself as: 38 | 39 | $ gem install backticks 40 | 41 | ## Usage 42 | 43 | ```ruby 44 | require 'backticks' 45 | 46 | # The lazy way; provides no CLI sugar, but benefits from unbuffered output, 47 | # and allows you to override Ruby's built-in backticks method. 48 | shell = Object.new ; shell.extend(Backticks::Ext) 49 | shell.instance_eval do 50 | puts `ls -l` 51 | raise 'Oh no!' unless $?.success? 52 | end 53 | # The just-as-lazy but less-magical way. 54 | Backticks.system('ls -l') || raise('Oh no!') 55 | 56 | # The easy way. Uses default options; returns the command's output as a String. 57 | output = Backticks.run('ls', R:true, '*.rb') 58 | puts "Exit status #{$?.to_i}. Output:" 59 | puts output 60 | 61 | # The hard way. Allows customized behavior; returns a Command object that 62 | # allows you to interact with the running command. 63 | command = Backticks::Runner.new(interactive:true).run('ls', R:true, '*.rb') 64 | command.join 65 | puts "Exit status: #{command.status.to_i}. Output:" 66 | puts command.captured_output 67 | ``` 68 | 69 | ### Buffering 70 | 71 | By default, Backticks allocates a pseudo-TTY for stdin/stdout and a Unix pipe 72 | for stderr; this captures the program's output and the user's input in realtime, 73 | but stderr is buffered according to the whim of the kernel's pipe subsystem. 74 | 75 | To use pipes for all I/O streams, enable buffering on the Runner: 76 | 77 | ```ruby 78 | # at initialize-time 79 | r = Backticks::Runner.new(buffered:true) 80 | 81 | # or later on, to change the bahvior 82 | r.buffered = false 83 | 84 | # you can also specify invididual stream names to buffer 85 | r.buffered = [:stderr] 86 | ``` 87 | 88 | When you read the `buffered` attribute of a Runner, it returns the list of 89 | stream names that are buffered; this means that even if you _write_ a boolean 90 | to this attribute, you will _read_ an Array of Symbol. You can call `buffered?` 91 | to get a boolean result instead. 92 | 93 | ### Interactivity and Real-Time Capture 94 | 95 | If you set `interactive:true` on the Runner, the console of the calling (Ruby) 96 | process is "tied" to the child's I/O streams, allowing the user to interact 97 | with the child process even as its input and output are captured for later use. 98 | 99 | If the child process will use raw input, you need to set the parent's console 100 | accordingly: 101 | 102 | ```ruby 103 | require 'io/console' 104 | # In IRB, call raw! on same line as command; IRB prompt uses raw I/O 105 | STDOUT.raw! ; Backticks::Runner.new(interactive:true).run('vi').join 106 | ``` 107 | 108 | ### Intercepting and Modifying I/O 109 | 110 | You can use the `Command#tap` method to intercept I/O streams in real time, 111 | with or without interactivity. To start the tap, pass a block to the method. 112 | Your block should accept two parameters: a Symbol stream name (:stdin, 113 | :stdout, :stderr) and a String with binary encoding that contains fresh 114 | input or output. 115 | 116 | The result of your block is used to decide what to do with the input/output: 117 | nil means "discard," any String means "use this in place of the original input 118 | or output."" 119 | 120 | Try loading `README.md` in an editor with all of the vowels scrambled. Scroll 121 | around and notice how words change when they leave and enter the screen! 122 | 123 | ```ruby 124 | vowels = 'aeiou' 125 | STDOUT.raw! ; cmd = Backticks::Runner.new(interactive:true).run('vi', 'README.md') ; cmd.tap do |io, bytes| 126 | bytes.gsub!(/[#{vowels}]/) { vowels[rand(vowels.size)] } if io == :stdout 127 | bytes 128 | end ; cmd.join ; nil 129 | ``` 130 | 131 | ### Literally Overriding Ruby's Backticks 132 | 133 | It's a terrible idea, but you can use this gem to change the behavior of 134 | backticks system-wide by mixing it into Kernel. 135 | 136 | ```ruby 137 | require 'backticks' 138 | include Backticks::Ext 139 | `echo Ruby lets me shoot myself in the foot` 140 | ``` 141 | 142 | If you do this, I will hunt you down and scoff at you. You have been warned! 143 | 144 | ## Security 145 | 146 | Backticks avoids using your OS shell, which helps prevent security bugs. 147 | This also means that you can't pass strings such as "$HOME" to commands; 148 | Backticks does not perform shell substitution. Pass ENV['HOME'] instead. 149 | 150 | Be careful about the commands you pass to Backticks! Never run commands that 151 | you read from an untrusted source, e.g. the network. 152 | 153 | In the future, Backticks may integrate with Ruby's $SAFE level to provide smart 154 | escaping and shell safety. 155 | 156 | ## Development 157 | 158 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 159 | 160 | To install this gem onto your local machine, run `bundle exec rake install`. 161 | 162 | To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 163 | 164 | ## Contributing 165 | 166 | Bug reports and pull requests are welcome on GitHub at https://github.com/xeger/backticks. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. 167 | -------------------------------------------------------------------------------- /lib/backticks/command.rb: -------------------------------------------------------------------------------- 1 | module Backticks 2 | # Represents a running process; provides mechanisms for capturing the process's 3 | # output, passing input, waiting for the process to end, and learning its 4 | # exitstatus. 5 | # 6 | # Interactive commands print their output to Ruby's STDOUT and STDERR 7 | # in realtime, and also pass input from Ruby's STDIN to the command's stdin. 8 | class Command 9 | # Duration that we use when a caller is willing to wait "forever" for 10 | # a command to finish. This means that `#join` is buggy when used with 11 | # commands that take longer than a year to complete. You have been 12 | # warned! 13 | FOREVER = 86_400 * 365 14 | 15 | # Number of bytes to read from the command in one "chunk". 16 | CHUNK = 1_024 17 | 18 | # @return [Integer] child process ID 19 | attr_reader :pid 20 | 21 | # @return [nil,Process::Status] result of command if it has ended; nil if still running 22 | attr_reader :status 23 | 24 | # @return [String] all input that has been captured so far 25 | attr_reader :captured_input 26 | 27 | # @return [String] all output that has been captured so far 28 | attr_reader :captured_output 29 | 30 | # @return [String] all output to stderr that has been captured so far 31 | attr_reader :captured_error 32 | 33 | # Watch a running command by taking ownership of the IO objects that 34 | # are passed in. 35 | # 36 | # @param [Integer] pid 37 | # @param [IO] stdin 38 | # @param [IO] stdout 39 | # @param [IO] stderr 40 | def initialize(pid, stdin, stdout, stderr, interactive:false) 41 | @pid = pid 42 | @stdin = stdin 43 | @stdout = stdout 44 | @stderr = stderr 45 | @interactive = !!interactive 46 | @tap = nil 47 | @status = nil 48 | 49 | @captured_input = String.new.force_encoding(Encoding::BINARY) 50 | @captured_output = String.new.force_encoding(Encoding::BINARY) 51 | @captured_error = String.new.force_encoding(Encoding::BINARY) 52 | end 53 | 54 | # @return [String] a basic string representation of this command 55 | def to_s 56 | "#" 57 | end 58 | 59 | # @return [Boolean] true if this command is tied to STDIN/STDOUT 60 | def interactive? 61 | @interactive 62 | end 63 | 64 | # Block until the command completes; return true if its status 65 | # was zero, false if nonzero. 66 | # 67 | # @return [Boolean] 68 | def success? 69 | join 70 | status.success? 71 | end 72 | 73 | # Determine whether output has been exhausted. 74 | def eof? 75 | @stdout.eof? && @stderr.eof? 76 | rescue Errno::EIO 77 | # The result of read operation when pty slave is closed is platform 78 | # dependent. 79 | # @see https://stackoverflow.com/questions/10238298/ruby-on-linux-pty-goes-away-without-eof-raises-errnoeio 80 | true 81 | end 82 | 83 | # Provide a callback to monitor input and output in real time. This method 84 | # saves a reference to block for later use; whenever the command generates 85 | # output or receives input, the block is called back with the name of the 86 | # stream on which I/O occurred and the actual data that was read or written. 87 | # @yield 88 | # @yieldparam [Symbol] stream one of :stdin, :stdout or :stderr 89 | # @yieldparam [String] data fresh input from the designated stream 90 | def tap(&block) 91 | raise StandardError.new("Tap is already set (#{@tap}); cannot set twice") if @tap && @tap != block 92 | @tap = block 93 | end 94 | 95 | # Block until the command exits, or until limit seconds have passed. If 96 | # interactive is true, proxy STDIN to the command and print its output 97 | # to STDOUT. If the time limit expires, return `nil`; otherwise, return 98 | # self. 99 | # 100 | # If the command has already exited when this method is called, return 101 | # self immediately. 102 | # 103 | # @param [Float,Integer] limit number of seconds to wait before returning 104 | def join(limit=FOREVER) 105 | return self if @status 106 | 107 | tf = Time.now + limit 108 | until (t = Time.now) >= tf 109 | capture(tf-t) 110 | res = Process.waitpid(@pid, Process::WNOHANG) 111 | if res 112 | @status = $? 113 | capture(nil) until eof? 114 | return self 115 | end 116 | end 117 | 118 | return nil 119 | end 120 | 121 | # Block until one of the following happens: 122 | # - the command produces fresh output on stdout or stderr 123 | # - the user passes some input to the command (if interactive) 124 | # - the process exits 125 | # - the time limit elapses (if provided) OR 60 seconds pass 126 | # 127 | # Return up to CHUNK bytes of fresh output from the process, or return nil 128 | # if no fresh output was produced 129 | # 130 | # @param [Float,Integer] number of seconds to wait before returning nil 131 | # @return [String,nil] fresh bytes from stdout/stderr, or nil if no output 132 | def capture(limit=nil) 133 | streams = [@stdout, @stderr] 134 | streams << STDIN if STDIN.tty? && interactive? 135 | 136 | ready, _, _ = IO.select(streams, [], [], limit) 137 | 138 | # proxy STDIN to child's stdin 139 | if ready && ready.include?(STDIN) 140 | data = STDIN.readpartial(CHUNK) rescue nil 141 | if data 142 | data = @tap.call(:stdin, data) if @tap 143 | if data 144 | @captured_input << data 145 | @stdin.write(data) 146 | end 147 | else 148 | @tap.call(:stdin, nil) if @tap 149 | # our own STDIN got closed; proxy this fact to the child 150 | @stdin.close unless @stdin.closed? 151 | end 152 | end 153 | 154 | # capture child's stdout and maybe proxy to STDOUT 155 | if ready && ready.include?(@stdout) 156 | data = @stdout.readpartial(CHUNK) rescue nil 157 | if data 158 | data = @tap.call(:stdout, data) if @tap 159 | if data 160 | @captured_output << data 161 | STDOUT.write(data) if interactive? 162 | fresh_output = data 163 | end 164 | end 165 | end 166 | 167 | # capture child's stderr and maybe proxy to STDERR 168 | if ready && ready.include?(@stderr) 169 | data = @stderr.readpartial(CHUNK) rescue nil 170 | if data 171 | data = @tap.call(:stderr, data) if @tap 172 | if data 173 | @captured_error << data 174 | STDERR.write(data) if interactive? 175 | end 176 | end 177 | end 178 | fresh_output 179 | rescue Interrupt 180 | # Proxy Ctrl+C to the child 181 | (Process.kill('INT', @pid) rescue nil) if @interactive 182 | raise 183 | end 184 | end 185 | end 186 | --------------------------------------------------------------------------------