Module: Kernel

Defined in:
lib/qbash.rb

Overview

Execute one bash command.

Author

Yegor Bugayenko ([email protected])

Copyright

Copyright © 2024-2026 Yegor Bugayenko

License

MIT

Instance Method Summary collapse

Instance Method Details

#qbash(*cmd, opts: [], stdin: '', env: {}, log: Loog::NULL, accept: [0], both: false, level: Logger::DEBUG, chdir: nil) ⇒ String

Execute a single bash command safely with proper error handling.

QBash provides a safe way to execute shell commands with proper error handling, logging, and stdin/stdout management. It’s designed to be simple for basic use cases while offering powerful options for advanced scenarios.

Basic Usage

# Execute a command and get its output
year = qbash('date +%Y')
puts "Current year: #{year}"

# Execute a command that might fail
files = qbash('find /tmp -name "*.log"')

Working with Exit Codes

# Get both output and exit code
output, code = qbash('grep "error" /var/log/system.log', both: true)
puts "Command succeeded" if code.zero?

# Accept multiple exit codes as valid
result = qbash('grep "pattern" file.txt', accept: [0, 1])

Providing Input via STDIN

# Pass data to command's stdin
result = qbash('wc -l', stdin: "line 1\nline 2\nline 3")

Environment Variables

# Set environment variables for the command
output = qbash('echo $NAME', env: { 'NAME' => 'Ruby' })

Logging

# Enable detailed logging to stdout
qbash('ls -la', log: $stdout)

# Use custom logger with specific level
logger = Logger.new($stdout)
qbash('make all', log: logger, level: Logger::INFO)

Process Control

# Get control over long-running process
qbash('sleep 30') do |pid|
  puts "Process #{pid} is running..."
  # Do something while process is running
  # Process will be terminated when block exits
end

Changing Working Directory

# Execute command in a specific directory
files = qbash('ls -la', chdir: '/tmp')

# Useful for commands that operate on the current directory
qbash('git status', chdir: '/path/to/repo')

For command with multiple arguments, you can use Shellwords.escape() to properly escape each argument. Stderr automatically merges with stdout.

Read this <a href=“github.com/yegor256/qbash”>README</a> file for more details.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/qbash.rb', line 93

def qbash(*cmd, opts: [], stdin: '', env: {}, log: Loog::NULL, accept: [0], both: false, level: Logger::DEBUG,
          chdir: nil)
  env.each { |k, v| raise "env[#{k}] is nil" if v.nil? }
  cmd = cmd.reject { |a| a.nil? || (a.is_a?(String) && a.empty?) }.join(' ')
  logit =
    lambda do |msg|
      msg = msg.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?').gsub(/\n$/, '')
      mtd =
        case level
        when Logger::DEBUG
          :debug
        when Logger::INFO
          :info
        when Logger::WARN
          :warn
        when Logger::ERROR
          :error
        else
          raise "Unknown log level #{level}"
        end
      if log.nil?
        # nothing to print
      elsif log.respond_to?(mtd)
        log.__send__(mtd, msg)
      else
        log.print("#{msg}\n")
      end
    end
  buf = ''
  e = 1
  start = Time.now
  bash = ['/bin/bash'] + opts + ['-c', cmd]
  popen = chdir.nil? ? [env, *bash] : [env, *bash, { chdir: }]
  Open3.popen2e(*popen) do |sin, sout, ctrl|
    pid = ctrl.pid
    logit["+ #{cmd} /##{pid}"]
    consume =
      lambda do
        loop do
          sleep 0.001
          break if sout.closed? || sout.eof?
          ln = sout.gets # together with the \n at the end
          next if ln.nil?
          next if ln.empty?
          buf += ln
          ln = "##{ctrl.pid}: #{ln}"
          logit[ln]
        rescue IOError => e
          logit[e.message]
          break
        end
      end
    sin.write(stdin)
    sin.close
    if block_given?
      watch = Thread.new { consume.call }
      watch.abort_on_exception = true
      begin
        yield pid
      ensure
        sout.close
        watch.join(0.01)
        watch.kill if watch.alive?
        attempt = 1
        since = Time.now
        loop do
          Process.kill(0, pid) # should be dead already (raising Errno::ESRCH)
          Process.kill('TERM', pid) # let's try to kill it
          logit["Tried to stop ##{pid} with SIGTERM (attempt no.#{attempt}, #{since.ago}): #{cmd}"]
          sleep(0.1)
          attempt += 1
        rescue Errno::ESRCH
          logit["Process ##{pid} reacted to SIGTERM, after #{attempt} attempts and #{since.ago}"] if attempt > 1
          break
        end
      end
    else
      consume.call
    end
    e = ctrl.value.exitstatus
    if !accept.nil? && !accept.include?(e)
      raise "The command '#{cmd}' failed with exit code ##{e} in #{start.ago}\n#{buf}"
    end
  end
  return [buf, e] if both
  buf
end