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
163
164
165
166
167
168
169
# 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)
  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, "+ #{cmd}")
  else
    log.print("+ #{cmd}\n")
  end
  buf = ''
  e = 1
  start = Time.now
  Open3.popen2e(env, "/bin/bash -c #{Shellwords.escape(cmd)}") do |sin, sout, ctrl|
    sin.write(stdin)
    sin.close
    if block_given?
      closed = false
      watch =
        Thread.new do
          until closed
            begin
              ln = sout.gets
            rescue IOError => e
              ln = Backtrace.new(e).to_s
            end
            next if ln.nil?
            next if ln.empty?
            ln = "##{ctrl.pid}: #{ln}"
            if log.nil?
              # no logging
            elsif log.respond_to?(mtd)
              log.__send__(mtd, ln)
            else
              log.print(ln)
            end
            buf += ln
          end
        end
      pid = ctrl.pid
      yield pid
      begin
        Process.kill('TERM', pid)
      rescue Errno::ESRCH
        # simply ignore it
      end
      closed = true
      watch.join
    else
      until sout.eof?
        begin
          ln = sout.gets
        rescue IOError => e
          ln = Backtrace.new(e).to_s
        end
        if log.nil?
          # no logging
        elsif log.respond_to?(mtd)
          log.__send__(mtd, ln)
        else
          log.print(ln)
        end
        buf += ln
      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
  end
  return [buf, e] if both
  buf
end