Module: Kernel

Defined in:
lib/qbash.rb

Overview

Execute one bash command.

Author

Yegor Bugayenko ([email protected])

Copyright

Copyright © 2024-2025 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) ⇒ 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

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.

Parameters:

  • cmd (String, Array)

    The command to run (String or Array of arguments)

  • stdin (String) (defaults to: '')

    The stdin to provide to the command

  • opts (Array) (defaults to: [])

    List of bash options, like “–login” and “–noprofile”

  • env (Hash) (defaults to: {})

    Hash of environment variables

  • log (Loog|IO) (defaults to: Loog::NULL)

    Logging facility with .debug() method (or $stdout, or nil if should go to /dev/null)

  • accept (Array) (defaults to: [0])

    List of accepted exit codes (accepts all if the list is nil)

  • both (Boolean) (defaults to: false)

    If set to TRUE, the function returns an array (stdout, code)

  • level (Integer) (defaults to: Logger::DEBUG)

    Logging level (use Logger::DEBUG, Logger::INFO, Logger::WARN, or Logger::ERROR)

Returns:

  • (String)

    Everything that was printed to the stdout by the command



84
85
86
87
88
89
90
91
92
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
# File 'lib/qbash.rb', line 84

def qbash(cmd, opts: [], stdin: '', env: {}, log: Loog::NULL, accept: [0], both: false, level: Logger::DEBUG)
  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(' ') if cmd.is_a?(Array)
  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]
  Open3.popen2e(env, *bash) 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