Module: RunKit::Shell

Defined in:
lib/run_kit/shell.rb

Class Method Summary collapse

Class Method Details

._cache_read(cache:, compress: false, expires_in: nil, format: :json) ⇒ Object



260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/run_kit/shell.rb', line 260

def _cache_read(cache:, compress: false, expires_in: nil, format: :json)
  data = cache.binread
  data = gunzip(data) if compress
  case format
  when :bin then data.force_encoding("ascii-8bit")
  when :json then JSON.parse(data)
  when :jsonl then data.split("\n").map { JSON.parse(_1) }
  when :marshal then Marshal.load(data)
  when :str, :string then data.force_encoding("utf-8")
  else; raise "unknown format #{format.inspect}"
  end
end

._cache_write(cache:, compress: false, expires_in: nil, format: :json) ⇒ Object



273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/run_kit/shell.rb', line 273

def _cache_write(cache:, compress: false, expires_in: nil, format: :json, &)
  yield.tap do
    data = case format
    when :bin, :str, :string then _1.to_s
    when :json then _1.to_json
    when :jsonl then _1.map(&:to_json).join("\n")
    when :marshal then Marshal.dump(_1)
    else; raise "unknown format #{format.inspect}"
    end
    data = gzip(data) if compress
    cache.binwrite(data)
  end
end

._csv_write0(csv, rows, headers: nil) ⇒ Object

low-level helper for writing csv <= rows w/ headers



211
212
213
214
215
216
217
218
# File 'lib/run_kit/shell.rb', line 211

def _csv_write0(csv, rows, headers: nil)
  headers ||= rows.first.to_h.keys
  csv << headers
  rows.each do |row|
    row = row.to_h
    csv << headers.map { row[_1] }
  end
end

._infer_csv(str) ⇒ Object

infer int/float from str



221
222
223
224
225
226
227
# File 'lib/run_kit/shell.rb', line 221

def _infer_csv(str)
  case str
  when /\A-?\d+\z/ then return str.to_i
  when /\A-?\d+[.\d]+\z/ then return str.to_f
  end
  str
end

._nowObject

we don't want activesupport, force getlocal



305
# File 'lib/run_kit/shell.rb', line 305

def _now = Time.now.getlocal

._shell(*cmd, vars: nil) ⇒ Object

shell helper



230
231
232
233
234
235
236
237
238
239
# File 'lib/run_kit/shell.rb', line 230

def _shell(*cmd, vars: nil)
  begin
    cmd = _shell_cmd(cmd, vars:)
    output, status = Open3.capture2e(*cmd)
    status = status.exitstatus
  rescue Errno::ENOENT => ex
    output, status = ex.message, 127
  end
  [output.strip, status, cmd]
end

._shell_cmd(cmd, vars: nil) ⇒ Object



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/run_kit/shell.rb', line 241

def _shell_cmd(cmd, vars: nil)
  cmd = cmd.first if cmd.one? && (cmd.first.is_a?(Array) || cmd.first.is_a?(String))
  if vars
    raise ArgumentError, "cmd must be string with vars: {...}" if !cmd.is_a?(String)
    cmd = vars.reduce(cmd) do |memo, (k, v)|
      k = "{{#{k}}}"
      raise ArgumentError, "#{cmd.inspect} does not contain #{k}" if !memo.include?(k)

      v = case v
      when Array then v.shelljoin
      when Pathname then v.escape
      else; v.to_s
      end
      memo.gsub(k, v)
    end
  end
  Array(cmd).map(&:to_s)
end

._symbolize_keys(obj) ⇒ Object

note: no activesupport dependency



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/run_kit/shell.rb', line 288

def _symbolize_keys(obj)
  case obj
  when Hash
    obj.to_h do |k, v|
      k = begin
        k.to_sym
      rescue
        k
      end
      [k, _symbolize_keys(v)]
    end
  when Array then obj.map { _symbolize_keys(_1) }
  else; obj
  end
end

.atomic_write(path, &block) ⇒ Object

Atomically replace a file by writing to a temporary path first.



197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/run_kit/shell.rb', line 197

def atomic_write(path, &block)
  tmp = nil
  Pathname(path).tap do |path|
    path.dirname.mkdir
    tmp = Pathname("#{path}.tmp").tap(&:rm)
    yield(tmp)
    # Temp lives beside path, so rename replaces it atomically; do not use mv.
    tmp.rename(path)
  end
ensure
  tmp&.rm
end

banner/warning/fatal



158
159
160
# File 'lib/run_kit/shell.rb', line 158

def banner(str, color: :green)
  puts Term.paint_banner("[#{_now.strftime("%H:%M:%S")}] #{str.ljust(72)} ", color)
end

.cache_fetch(cache:, compress: false, expires_in: nil, force: false, format: :json, symbolize: true) ⇒ Object

Fetches data from cache file. If there is data in the cache with the given key, then that data is returned.



180
181
182
183
184
185
186
187
188
189
190
# File 'lib/run_kit/shell.rb', line 180

def cache_fetch(cache:, compress: false, expires_in: nil, force: false, format: :json, symbolize: true, &)
  cache = Pathname(cache)
  stale = cache.exist? && expires_in && (_now - cache.mtime > expires_in.to_i)
  data = if !cache.exist? || stale || force
    _cache_write(cache:, compress:, expires_in:, format:, &)
  else
    _cache_read(cache:, compress:, expires_in:, format:)
  end
  data = _symbolize_keys(data) if symbolize
  data
end

.cp_metadata(src, dst) ⇒ Object

copy mtime and perms from src => dst



132
133
134
135
136
137
138
# File 'lib/run_kit/shell.rb', line 132

def (src, dst)
  src, dst = Pathname(src), Pathname(dst)
  stat = src.stat
  dst.chmod(stat.mode)
  dst.chown(stat.uid, stat.gid)
  dst.touch(mtime: stat.mtime)
end

.csv_read(path, infer: false) ⇒ Object

CSV read/write



58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/run_kit/shell.rb', line 58

def csv_read(path, infer: false)
  io = StringIO.new(file_read(path))
  rows = CSV.read(io, encoding: "bom|utf-8")

  headers = rows.shift.map(&:to_sym)
  klass = Struct.new(*headers)

  rows.map do |row|
    row = row.map { _infer_csv(_1) } if infer
    klass.new(*row)
  end
end

.csv_write(path, rows, headers: nil) ⇒ Object



71
72
73
74
75
# File 'lib/run_kit/shell.rb', line 71

def csv_write(path, rows, headers: nil)
  atomic_write(path) do |tmp|
    CSV.open(tmp, "wb") { _csv_write0(_1, rows, headers:) }
  end
end

.csv_write_stdout(rows, headers: nil) ⇒ Object



77
78
79
# File 'lib/run_kit/shell.rb', line 77

def csv_write_stdout(rows, headers: nil)
  CSV($stdout) { _csv_write0(_1, rows, headers:) }
end

.fatal(str) ⇒ Object



166
167
168
169
# File 'lib/run_kit/shell.rb', line 166

def fatal(str)
  banner(str, color: :red)
  exit(1)
end

.file_read(path) ⇒ Object

file read/write, including gz



12
13
14
15
16
17
18
# File 'lib/run_kit/shell.rb', line 12

def file_read(path)
  Pathname(path).then do |path|
    data = path.read
    data = gunzip(data) if path.extname == ".gz"
    data
  end
end

.file_write(path, str) ⇒ Object



20
21
22
23
24
25
26
# File 'lib/run_kit/shell.rb', line 20

def file_write(path, str)
  path = Pathname(path)
  atomic_write(path) do |tmp|
    str = gzip(str) if path.extname == ".gz"
    tmp.write(str)
  end
end

.glob(pats) ⇒ Object

one-liners



147
# File 'lib/run_kit/shell.rb', line 147

def glob(pats) = Pathname.glob(pats).uniq.sort

.gunzip(str_gz) ⇒ Object



47
48
49
50
51
52
# File 'lib/run_kit/shell.rb', line 47

def gunzip(str_gz)
  gz = Zlib::GzipReader.new(StringIO.new(str_gz))
  gz.read
ensure
  gz&.close
end

.gzip(str) ⇒ Object

gzip/gunzip data



41
42
43
44
45
# File 'lib/run_kit/shell.rb', line 41

def gzip(str)
  Zlib::GzipWriter.new(StringIO.new).tap do
    _1.write(str)
  end.close.string
end

.installed?(cmd) ⇒ Boolean

Returns:

  • (Boolean)


148
# File 'lib/run_kit/shell.rb', line 148

def installed?(cmd) = shell("sh", "-c", "command -v #{cmd.shellescape}")[1] == 0

.json_read(path, symbolize_names: true) ⇒ Object

json file read/write, including gz



32
# File 'lib/run_kit/shell.rb', line 32

def json_read(path, symbolize_names: true) = JSON.parse(file_read(path), symbolize_names:)

.json_write(path, json) ⇒ Object



33
# File 'lib/run_kit/shell.rb', line 33

def json_write(path, json) = file_write(path, JSON.pretty_generate(json))

.jsonl_read(path, symbolize_names: true) ⇒ Object



34
# File 'lib/run_kit/shell.rb', line 34

def jsonl_read(path, symbolize_names: true) = file_read(path).split("\n").map { JSON.parse(_1, symbolize_names:) }

.jsonl_write(path, json) ⇒ Object



35
# File 'lib/run_kit/shell.rb', line 35

def jsonl_write(path, json) = file_write(path, json.map { JSON.generate(_1) }.join("\n"))

.kill_process(pid) ⇒ Object

Kill a process, ignore failure



141
142
143
144
# File 'lib/run_kit/shell.rb', line 141

def kill_process(pid)
  Process.kill("KILL", pid)
rescue Errno::ESRCH
end

.lines_in_file(path) ⇒ Object



149
# File 'lib/run_kit/shell.rb', line 149

def lines_in_file(path) = shell!("wc", "-l", path).strip.split.first.to_i

.md5(str) ⇒ Object



150
# File 'lib/run_kit/shell.rb', line 150

def md5(str) = Digest::MD5.hexdigest(str)

.program_nameObject



151
# File 'lib/run_kit/shell.rb', line 151

def program_name = Pathname($PROGRAM_NAME).basename

.prompt?(prompt = "Proceed?") ⇒ Boolean

Ask the user a question via stderr, then return true if they enter YES, yes, y, etc.

Returns:

  • (Boolean)


172
173
174
175
176
177
# File 'lib/run_kit/shell.rb', line 172

def prompt?(prompt = "Proceed?")
  $stderr.write("#{prompt} (y/n) ")
  $stderr.flush
  ch = $stdin.gets || "no"
  ch.match?(/^y/i)
end

.sha256(str) ⇒ Object



152
# File 'lib/run_kit/shell.rb', line 152

def sha256(str) = Digest::SHA256.hexdigest(str)

.shell(*cmd, vars: nil) ⇒ Object

Run a command via Open3.capture2e. cmd can be passed as:

  • shell("git status") # a single string
  • shell("git", "status") # varargs strings
  • shell(["git", "status"]) # an array of strings

Prefer arrays so escaping stays explicit and Ruby handles argument boundaries for you. Single strings are convenient but put escaping responsibility on the caller. vars: lets you interpolate {{ hi }} into the command before it runs. Pathname values get shell-escaped, which is real nice here.

Returns [stdout_and_stderr, exit_code]



98
99
100
101
# File 'lib/run_kit/shell.rb', line 98

def shell(*cmd, vars: nil)
  output, status, _ = _shell(*cmd, vars:)
  [output, status]
end

.shell!(*cmd, vars: nil) ⇒ Object

like shell, but raises on non zero exit code. returns stdout_and_stderr otherwise



104
105
106
107
108
# File 'lib/run_kit/shell.rb', line 104

def shell!(*cmd, vars: nil)
  output, status, cmd = _shell(*cmd, vars:)
  raise "#{cmd.inspect} failed #{status}\noutput: #{output}" if status != 0
  output
end

.shell_transform!(*cmd, src:, dst:, force: false) ⇒ Object

Atomically transform src into dst.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/run_kit/shell.rb', line 111

def shell_transform!(*cmd, src:, dst:, force: false)
  src, dst = Pathname(src), Pathname(dst)
  in_place = src.abs == dst.abs
  raise Errno::EEXIST, dst.to_s if dst.exist? && !force && !in_place

  dst.dirname.mkdir
  tmp = nil
  Tempfile.create([".tmp-", dst.extname], dst.dirname.to_s) do |tmpfile|
    tmp = Pathname(tmpfile.path)
    tmpfile.close
    shell!(*cmd, vars: {src:, dst: tmp})
    (src, tmp)
    # Temp lives beside dst, so rename replaces it atomically; do not use mv.
    tmp.rename(dst)
  end
  dst
ensure
  tmp&.rm
end

.warning(str) ⇒ Object



162
163
164
# File 'lib/run_kit/shell.rb', line 162

def warning(str)
  banner(str, color: :peach)
end