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: {}, stdout: Loog::NULL, stderr: nil, 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 console
qbash('ls -la', stdout: $stdout)

# Use custom logger with specific level
logger = Logger.new($stdout)
qbash('make all', stdout: 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 Handling

By default, stderr merges with stdout. You can redirect it elsewhere:

# Merge stderr with stdout (default)
output = qbash('cmd', stderr: :stdout)

# Redirect stderr to a separate logger
err_log = Loog::Buffer.new
output = qbash('cmd', stderr: err_log)

# Discard stderr completely
output = qbash('cmd', stderr: nil)

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

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

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

  • stderr (Loog|IO) (defaults to: nil)

    Where to send stderr

  • 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)

  • chdir (String) (defaults to: nil)

    Directory to change to before running the command (or nil to use current directory)

Returns:

  • (String)

    Everything that was printed to the stdout by the command



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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/qbash.rb', line 108

def qbash(*cmd, opts: [], stdin: '', env: {}, stdout: Loog::NULL, stderr: nil, accept: [0], both: false,
          level: Logger::DEBUG, chdir: nil)
  stderr ||= stdout
  env.each { |k, v| raise "env[#{k}] is nil" if v.nil? }
  env = env.transform_values(&:to_s)
  cmd = cmd.reject { |a| a.nil? || (a.is_a?(String) && a.empty?) }.join(' ')
  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
  printer =
    lambda do |target, msg|
      msg = msg.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?').gsub(/\n$/, '')
      if target.nil?
        # nothing to print
      elsif target.respond_to?(mtd)
        target.__send__(mtd, msg)
      else
        target.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.send(:popen3, *popen) do |sin, sout, serr, ctrl|
    pid = ctrl.pid
    printer[stderr, "+ #{cmd} /##{pid}"]
    consume =
      lambda do |stream, target, buffer|
        loop do
          sleep 0.001
          break if stream.closed? || stream.eof?
          ln = stream.gets
          next if ln.nil?
          next if ln.empty?
          buffer << ln if buffer
          printer[target, "##{pid}: #{ln}"]
        rescue IOError => e
          printer[stderr, e.message]
          break
        end
      end
    sin.write(stdin)
    sin.close
    if block_given?
      watch = Thread.new { consume.call(sout, stdout, buf) }
      watch.abort_on_exception = true
      begin
        yield pid
      ensure
        sout.close
        serr&.close
        watch.join(0.01)
        watch.kill if watch.alive?
        attempt = 1
        since = Time.now
        loop do
          Process.kill(0, pid)
          Process.kill('TERM', pid)
          printer[stderr, "Tried to stop ##{pid} with SIGTERM (attempt no.#{attempt}, #{since.ago}): #{cmd}"]
          sleep(0.1)
          attempt += 1
        rescue Errno::ESRCH
          if attempt > 1
            printer[stderr,
                    "Process ##{pid} reacted to SIGTERM, after #{attempt} attempts and #{since.ago}"]
          end
          break
        end
      end
    else
      thread = Thread.new { consume.call(serr, stderr, nil) }
      thread.abort_on_exception = true
      consume.call(sout, stdout, buf)
      thread.join
    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