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, 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

  • 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
# File 'lib/qbash.rb', line 84

def qbash(cmd, 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|
      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)
      end
    end
  logit["+ #{cmd}"]
  buf = ''
  e = 1
  start = Time.now
  Open3.popen2e(env, "/bin/bash -c #{Shellwords.escape(cmd)}") do |sin, sout, ctrl|
    consume =
      lambda do
        loop do
          break if sout.eof?
          ln = sout.gets
          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
      pid = ctrl.pid
      yield pid
      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} gracefully reacted to SIGTERM"] if attempt > 1
        break
      end
    else
      consume.call
    end
    e = ctrl.value.to_i
    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