Class: Cmds

Inherits:
Object
  • Object
show all
Includes:
NRSER::Log::Mixin
Defined in:
lib/cmds.rb,
lib/cmds/pipe.rb,
lib/cmds/util.rb,
lib/cmds/debug.rb,
lib/cmds/spawn.rb,
lib/cmds/sugar.rb,
lib/cmds/result.rb,
lib/cmds/stream.rb,
lib/cmds/capture.rb,
lib/cmds/version.rb,
lib/cmds/io_handler.rb,
lib/cmds/erb_context.rb,
lib/cmds/shell_eruby.rb,
lib/cmds/util/params.rb,
lib/cmds/util/defaults.rb,
lib/cmds/util/shell_escape.rb,
lib/cmds/util/tokenize_value.rb,
lib/cmds/util/tokenize_option.rb,
lib/cmds/util/tokenize_options.rb

Overview

Definitions

Defined Under Namespace

Modules: Debug Classes: ERBContext, IOHandler, Params, Pipe, Result, ShellEruby

Shell Quoting and Escaping Methods collapse

QUOTE_TYPES =

Quote "name" keys :single and :double mapped to their character.

{
  single: %{'},
  double: %{"},
}.freeze
QUOTE_VALUES =

List containing just ' and ".

QUOTE_TYPES.values.freeze

Constant Summary collapse

ROOT =

Absolute, expanded path to the gem's root directory.

(Pathname.new( __FILE__ ).dirname / '..' / '..').expand_path
VERSION =

Library version string.

'0.2.11'
DEFAULTS =

hash of common default values used in method options.

don't use them directly -- use defaults.

the values themselves are frozen so we don't have to worry about cloning them before providing them for use.

the constant Hash itself is not frozen -- you can mutate this to change the default options for ALL Cmds method calls... just be aware of what you're doing. not recommended outside of quick hacks and small scripts since other pieces and parts you don't even know about may depend on said behavior.

{
  # Alphabetical...
  
  # positional arguments for a command
  args: [],
  
  # what to join array option values with when using `array_mode = :join`
  array_join_string: ',',
  
  # what to do with array option values
  array_mode: :join,
  
  # Don't asset (raise error if exit code is not 0)
  assert: false,
  
  # Don't change directories
  chdir: nil,
  
  # Commands often use dash-separated option names, but it's a lot more
  # convenient in Ruby to use underscored when using {Symbol}. This option
  # will convert the underscores to dashes.
  dash_opt_names: false,
  
  # No additional environment
  env: {},
  
  # Stick ENV var defs inline at beginning of command
  env_mode: :inline,
  
  # What to do with `false` *option* values (not `false` values as regular
  # values or inside collections)
  # 
  # Just leave them out all-together
  false_mode: :omit,
  
  # Flatten nested array values to a single array.
  # 
  # Many CLI commands accept arrays in some form or another, but I'm hard
  # pressed to think of one that accepts nested arrays. Flattening can make
  # it simpler to generate values.
  # 
  flatten_array_values: true,
  
  # how to format a command string for execution
  format: :squish,
  
  hash_mode: :join,
  
  # Join hash keys and values with `:`
  hash_join_string: ':',
  
  # No input
  input: nil,
  
  # keyword arguments for a command
  kwds: {},

  # What to use to separate "long" opt names (more than one character) from
  # their values. I've commonly seen '=' (`--name=VALUE`)
  # and ' ' (`--name VALUE`).
  long_opt_separator: '=',
  
  # What to use to separate "short" opt names (single character) from their
  # values. I've commonly seen ' ' (`-x VALUE`) and '' (`-xVALUE`).
  short_opt_separator: ' ',
  
}.map { |k, v| [k, v.freeze] }.to_h.freeze
TOKENIZE_OPT_KEYS =
[
  :array_mode,
  :array_join_string,
  :dash_opt_names,
  :false_mode,
  :flatten_array_values,
  :hash_mode,
  :hash_join_string,
  :long_opt_separator,
  :short_opt_separator,
].freeze

Instance Attribute Summary collapse

Execution Instance Methods collapse

Spawn Methods collapse

Shell Quoting and Escaping Methods collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(template, **options) ⇒ Cmds

Construct a Cmds instance.



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/cmds.rb', line 237

def initialize  template, **options
  options = defaults options
  
  if options.key? :opts
    options[:kwds][:opts] = options.delete :opts
  end
  
  logger.trace "Cmd constructing...",
    template: template,
    options: options

  @template = template
  
  # Assign options to instance variables
  options.each { |key, value|
    instance_variable_set "@#{ key }", value
  }
  
  # An internal cache of the last result of calling {#prepare}, or `nil` if
  # {#prepare} has never been called. Kinda funky but ends up being useful.
  @last_prepared_cmd = nil
end

Instance Attribute Details

#argsArray<Object> (readonly)

base/common positional parameters to render into the command template.

defaults to [].

#prepare and the methods that invoke it (like #capture, #stream, etc.) accept *args, which will be appended to these values to create the final array for rendering.



60
61
62
# File 'lib/cmds.rb', line 60

def args
  @args
end

#assertBoolean (readonly)

if true, will execution will raise an error on non-zero exit code.

defaults to false.



94
95
96
# File 'lib/cmds.rb', line 94

def assert
  @assert
end

#chdirnil, String | Pathname (readonly)

Optional directory to run the command in, set by the :chdir option in #initialize.



138
139
140
# File 'lib/cmds.rb', line 138

def chdir
  @chdir
end

#envHash{String | Symbol => String} (readonly)

Environment variables to set for command execution.

defaults to {}.



103
104
105
# File 'lib/cmds.rb', line 103

def env
  @env
end

#env_mode:inline, :spawn_arg (readonly)

How environment variables will be set for command execution - inline at the top of the command, or passed to Process.spawn as an argument.

See the inline



113
114
115
# File 'lib/cmds.rb', line 113

def env_mode
  @env_mode
end

#format:squish | :pretty (readonly)

format specifier symbol:

  • :squish
    • collapse rendered command string to one line.
  • :pretty
    • clean up and backslash suffix line endings.

defaults to :squish.



126
127
128
# File 'lib/cmds.rb', line 126

def format
  @format
end

#inputString | #read (readonly)

string or readable IO-like object to use as default input to the command.

#prepare and the methods that invoke it (like #capture, #stream, etc.) accept an optional block that will override this value if present.



85
86
87
# File 'lib/cmds.rb', line 85

def input
  @input
end

#kwdsHash{Symbol => Object} (readonly)

base/common keyword parameters to render into the command template.

defaults to {}.

#prepare and the methods that invoke it (like #capture, #stream, etc.) accept **kwds, which will be merged on top of these values to create the final hash for rendering.



73
74
75
# File 'lib/cmds.rb', line 73

def kwds
  @kwds
end

#last_prepared_cmdnil, String (readonly)

The results of the last time #prepare was called on the instance.

A little bit funky, I know, but it turns out to be quite useful.



151
152
153
# File 'lib/cmds.rb', line 151

def last_prepared_cmd
  @last_prepared_cmd
end

#templateString (readonly)

ERB stirng template (with Cmds-specific extensions) for the command.



46
47
48
# File 'lib/cmds.rb', line 46

def template
  @template
end

Class Method Details

.assert(template, *args, **kwds, &io_block) ⇒ Object

create a new Cmds and



92
93
94
# File 'lib/cmds/sugar.rb', line 92

def self.assert template, *args, **kwds, &io_block
  Cmds.new(template).capture(*args, **kwds, &io_block).assert
end

.capture(template, *args, **kwds, &input_block) ⇒ Result

create a new Cmds from template with parameters and call #capture on it.



65
66
67
# File 'lib/cmds/sugar.rb', line 65

def self.capture template, *args, **kwds, &input_block
  Cmds.new(template).capture *args, **kwds, &input_block
end

.check_status(cmd, status, err = nil) ⇒ nil

raise an error unless the exit status is 0.

Raises:

  • (SystemCallError)

    if exit status is not 0.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/cmds/util.rb', line 128

def self.check_status cmd, status, err = nil
  unless status.equal? 0
    msg = NRSER.squish "      command `\#{ cmd }` exited with status \#{ status }\n    END\n    \n    if err\n      msg += \" and stderr:\\n\\n\" + err\n    end\n    \n    # Remove NULL bytes (not sure how they get in there...)\n    msg = msg.delete(\"\\000\")\n    \n    raise SystemCallError.new msg, status\n  end\nend\n"

.chomp(template, *args, **kwds, &input_block) ⇒ String

captures a new Cmds, captures and chomps stdout (sugar for Cmds.out(template, *args, **kwds, &input_block).chomp).

See Also:



159
160
161
# File 'lib/cmds/sugar.rb', line 159

def self.chomp template, *args, **kwds, &input_block
  out(template, *args, **kwds, &input_block).chomp
end

.chomp!(template, *args, **kwds, &input_block) ⇒ String

captures and chomps stdout, raising an error if the command fails. (sugar for Cmds.out!(template, *args, **kwds, &input_block).chomp).

Raises:

  • (SystemCallError)

    if the command fails (non-zero exit status).

See Also:



180
181
182
# File 'lib/cmds/sugar.rb', line 180

def self.chomp! template, *args, **kwds, &input_block
  out!(template, *args, **kwds, &input_block).chomp
end

.debug(msg, values = {}) ⇒ Object

log a debug message along with an optional hash of values.



96
97
98
99
100
# File 'lib/cmds/debug.rb', line 96

def self.debug msg, values = {}
  # don't even bother unless debug logging is turned on
  return unless Debug.on?
  Debug.logger.debug Debug.format(msg, values)
end

.defaults(opts, keys = '*', extras = {}) ⇒ Hash<Symbol, Object>

merge an method call options hash with common defaults for the module.

this makes it easy to use the same defaults in many different methods without repeating the declarations everywhere.



106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/cmds/util/defaults.rb', line 106

def self.defaults opts, keys = '*', extras = {}
  if keys == '*'
    DEFAULTS.deep_dup
  else
    keys.
      map {|key|
        [key, DEFAULTS.fetch(key)]
      }.
      to_h
  end.
    merge!( extras ).
    merge!( opts )
end

.err(template, *args, **kwds, &input_block) ⇒ String

captures and returns stderr (sugar for Cmds.capture(template, *args, **kwds, &input_block).err).

See Also:



198
199
200
# File 'lib/cmds/sugar.rb', line 198

def self.err template, *args, **kwds, &input_block
  capture(template, *args, **kwds, &input_block).err
end

.error?(template, *args, **kwds, &io_block) ⇒ Boolean



86
87
88
# File 'lib/cmds/sugar.rb', line 86

def self.error? template, *args, **kwds, &io_block
  Cmds.new(template).error? *args, **kwds, &io_block
end

.esc(str) ⇒ String

Shortcut for Shellwords.escape

Also makes it easier to change or customize or whatever.



38
39
40
# File 'lib/cmds/util/shell_escape.rb', line 38

def self.esc str
  Shellwords.escape str
end

.format(string, with = :squish) ⇒ Object

Formats a command string.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/cmds/util.rb', line 46

def self.format string, with = :squish
  case with
  when nil
    string
    
  when :squish
    NRSER.squish string
    
  when :pretty
    pretty_format string
  
  else
    with.call string
  end
end

.ok?(template, *args, **kwds, &io_block) ⇒ Result

create a new Cmds from template with parameters and call Cmd#ok? on it.



81
82
83
# File 'lib/cmds/sugar.rb', line 81

def self.ok? template, *args, **kwds, &io_block
  Cmds.new(template).ok? *args, **kwds, &io_block
end

.out(template, *args, **kwds, &input_block) ⇒ String

creates a new Cmds, captures and returns stdout (sugar for Cmds.capture(template, *args, **kwds, &input_block).out).

See Also:

  • Cmd.out


120
121
122
# File 'lib/cmds/sugar.rb', line 120

def self.out template, *args, **kwds, &input_block
  Cmds.new(template).out *args, **kwds, &input_block
end

.out!(template, *args, **kwds, &input_block) ⇒ String

creates a new Cmds, captures and returns stdout. raises an error if the command fails.

Raises:

  • (SystemCallError)

    if the command fails (non-zero exit status).

See Also:

  • Cmd.out!


141
142
143
# File 'lib/cmds/sugar.rb', line 141

def self.out! template, *args, **kwds, &input_block
  Cmds.new(template).out! *args, **kwds, &input_block
end

.prepare(template, *args, **kwds, &options_block) ⇒ String

create a new Cmds instance with the template and parameters and calls #prepare.



40
41
42
43
44
45
46
47
48
# File 'lib/cmds/sugar.rb', line 40

def self.prepare template, *args, **kwds, &options_block
  options = if options_block
    options_block.call
  else
    {}
  end
  
  Cmds.new(template, **options).prepare *args, **kwds
end

.pretty_format(string) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/cmds/util.rb', line 63

def self.pretty_format string
  string = string.gsub(/\n(\s*\n)+\n/, "\n\n")
  
  string.lines.map {|line|
    line = line.rstrip
    
    if line.end_with? '\\'
      line
    elsif line == ''
      '\\'
    elsif line =~ /\s$/
      line + '\\'
    else
      line + ' \\'
    end
  }.join("\n")
end

.quote_dance(string, quote_type) ⇒ return_type

Format a string to be a shell token by wrapping it in either single or double quotes and replacing instances of that quote with what I'm calling a "quote dance":

  1. Closing the type of quote in use
  2. Quoting the type of quote in use with the other type of quote
  3. Then opening up the type in use again and keeping going.

WARNING: Does NOT escape anything except the quotes! So if you double-quote a string with shell-expansion terms in it and pass it to the shell THEY WILL BE EVALUATED

Examples:

Single quoting string containing single quotes


Cmds.quote_dance %{you're}, :single
# => %{'you'"'"'re'}

Double quoting string containing double quotes


Cmds.quote_dance %{hey "ho" let's go}, :double
# => %{"hey "'"'"ho"'"'" let's go"}


72
73
74
75
76
77
78
79
80
81
82
# File 'lib/cmds/util/shell_escape.rb', line 72

def self.quote_dance string, quote_type
  outside = QUOTE_TYPES.fetch quote_type
  inside = QUOTE_VALUES[QUOTE_VALUES[0] == outside ? 1 : 0]
  
  outside +
  string.gsub(
    outside,
    outside + inside + outside + inside + outside
  ) +
  outside
end

.replace_shortcuts(template) ⇒ Object



81
82
83
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
# File 'lib/cmds/util.rb', line 81

def self.replace_shortcuts template
  template
    .gsub(
      # %s => <%= arg %>
      /(?<=\A|\=|[[:space:]])\%s(?=\Z|[[:space:]])/,
      '<%= arg %>'
    )
    .gsub(
      # %%s => %s (escaping)
      /(?<=\A|[[:space:]])(\%+)\%s(?=\Z|[[:space:]])/,
      '\1s'
    )
    .gsub(
      # %{key} => <%= key %>, %{key?} => <%= key? %>
      /(?<=\A|\=|[[:space:]])\%\{([a-zA-Z_]+\??)\}(?=\Z|[[:space:]])/,
      '<%= \1 %>'
    )
    .gsub(
      # %%{key} => %{key}, %%{key?} => %{key?} (escaping)
      /(?<=\A|[[:space:]])(\%+)\%\{([a-zA-Z_]+\??)\}(?=\Z|[[:space:]])/,
      '\1{\2}\3'
    )
    .gsub(
      # %<key>s => <%= key %>, %<key?>s => <%= key? %>
      /(?<=\A|\=|[[:space:]])\%\<([a-zA-Z_]+\??)\>s(?=\Z|[[:space:]])/,
      '<%= \1 %>'
    )
    .gsub(
      # %%<key>s => %<key>s, %%<key?>s => %<key?>s (escaping)
      /(?<=\A|[[:space:]])(\%+)\%\<([a-zA-Z_]+\??)\>s(?=\Z|[[:space:]])/,
      '\1<\2>s'
    )
end

.single_quote(string) ⇒ String

Single quote a string for use in the shell.



93
94
95
# File 'lib/cmds/util/shell_escape.rb', line 93

def self.single_quote string
  quote_dance string, :single
end

.spawn(cmd, env: {}, input: nil, **spawn_opts, &io_block) ⇒ Fixnum

Low-level static method to spawn and stream inputs and/or outputs using threads.

This is the core execution functionality of the whole library - everything ends up here.

WARNING - This method runs the cmd string AS IS - no escaping, formatting, interpolation, etc. are done at this point.

The whole rest of the library is built on top of this method to provide that stuff, and if you're using this library, you probably want to use that stuff.

You should not need to use this method directly unless you are extending the library's functionality.

Originally inspired by

https://nickcharlton.net/posts/ruby-subprocesses-with-stdout-stderr-streams.html

with major modifications from looking at Ruby's open3 module.

At the end of the day ends up calling Process.spawn.

Raises:

  • (ArgumentError)

    If &io_block has arity greater than 1.

  • (ArgumentError)

    If input is provided via the input keyword arg and the io_block.



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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/cmds/spawn.rb', line 116

def self.spawn  cmd,
                env: {},
                input: nil,
                **spawn_opts,
                &io_block
  logger.trace "entering Cmds#spawn",
    cmd: cmd,
    env: env,
    input: input,
    spawn_opts: spawn_opts,
    io_block: io_block
  
  # Process.spawn doesn't like a `nil` chdir
  if spawn_opts.key?( :chdir ) && spawn_opts[:chdir].nil?
    spawn_opts.delete :chdir
  end
  
  # create the handler that will be yielded to the input block
  handler = Cmds::IOHandler.new

  # handle input
  # 
  # if a block was provided it overrides the `input` argument.
  # 
  if io_block
    case io_block.arity
    when 0
      # when the input block takes no arguments it returns the input
      
      # Check that `:input` kwd wasn't provided.
      unless input.nil?
        raise ArgumentError,
          "Don't call Cmds.spawn with `:input` keyword arg and a block"
      end
      
      input = io_block.call
      
    when 1
      # when the input block takes one argument, give it the handler and
      # ignore the return value
      io_block.call handler

      # if input was assigned to the handler in the block, use it as input
      unless handler.in.nil?
        
        # Check that `:input` kwd wasn't provided.
        unless input.nil?
          raise ArgumentError,
            "Don't call Cmds.spawn with `:input` keyword arg and a block"
        end
        
        input = handler.in
      end
      
    else
      # bad block provided
      raise ArgumentError.new NRSER.squish "        provided input block must have arity 0 or 1\n      BLOCK\n    end # case io_block.arity\n  end # if io_block\n\n  logger.trace \"looking at input...\",\n    input: input\n\n  # (possibly) create the input pipe... this will be nil if the provided\n  # input is io-like. in this case it will be used directly in the\n  # `spawn` options.\n  in_pipe = case input\n  when nil, String\n    logger.trace \"input is a String or nil, creating pipe...\"\n\n    in_pipe = Cmds::Pipe.new \"INPUT\", :in\n    spawn_opts[:in] = in_pipe.r\n\n    # don't buffer input\n    in_pipe.w.sync = true\n    in_pipe\n\n  else\n    logger.trace \"input should be io-like, setting spawn opt.\",\n      input: input\n    if input == $stdin\n      logger.trace \"input is $stdin.\"\n    end\n    spawn_opts[:in] = input\n    nil\n\n  end # case input\n\n  # (possibly) create the output pipes.\n  # \n  # `stream` can be told to send it's output to either:\n  # \n  # 1.  a Proc that will invoked with each line.\n  # 2.  an io-like object that can be provided as `spawn`'s `:out` or\n  #     `:err` options.\n  # \n  # in case (1) a `Cmds::Pipe` wrapping read and write piped `IO` instances\n  # will be created and assigned to the relevant of `out_pipe` or `err_pipe`.\n  # \n  # in case (2) the io-like object will be sent directly to `spawn` and\n  # the relevant `out_pipe` or `err_pipe` will be `nil`.\n  #\n  out_pipe, err_pipe = [\n    [\"ERROR\", :err],\n    [\"OUTPUT\", :out],\n  ].map do |name, sym|\n    logger.trace \"looking at \#{ name }...\"\n    \n    dest = handler.public_send sym\n    \n    # see if hanlder.out or hanlder.err is a Proc\n    if dest.is_a? Proc\n      logger.trace \"\#{ name } is a Proc, creating pipe...\"\n      pipe = Cmds::Pipe.new name, sym\n      # the corresponding :out or :err option for spawn needs to be\n      # the pipe's write handle\n      spawn_opts[sym] = pipe.w\n      # return the pipe\n      pipe\n\n    else\n      logger.trace \"\#{ name } should be io-like, setting spawn opt.\",\n        output: dest\n      spawn_opts[sym] = dest\n      # the pipe is nil!\n      nil\n    end\n  end # map outputs\n\n  logger.trace \"spawning...\",\n    env: env,\n    cmd: cmd,\n    opts: spawn_opts\n\n  pid = Process.spawn env.map {|k, v| [k.to_s, v]}.to_h,\n                      cmd,\n                      spawn_opts\n\n  logger.trace \"spawned.\",\n    pid: pid\n\n  wait_thread = Process.detach pid\n  wait_thread[:name] = \"WAIT\"\n\n  logger.trace \"wait thread created.\",\n    thread: wait_thread\n\n  # close child ios if created\n  # the spawned process will read from in_pipe.r so we don't need it\n  in_pipe.r.close if in_pipe\n  # and we don't need to write to the output pipes, that will also happen\n  # in the spawned process\n  [out_pipe, err_pipe].each {|pipe| pipe.w.close if pipe}\n\n  # create threads to handle any pipes that were created\n\n  in_thread = if in_pipe\n    Thread.new do\n      Thread.current[:name] = in_pipe.name\n      logger.trace \"thread started, writing input...\"\n\n      in_pipe.w.write input unless input.nil?\n\n      logger.trace \"write done, closing in_pipe.w...\"\n      in_pipe.w.close\n\n      logger.trace \"thread done.\"\n    end # Thread\n  end\n\n  out_thread, err_thread = [out_pipe, err_pipe].map do |pipe|\n    if pipe\n      Thread.new do\n        Thread.current[:name] = pipe.name\n        logger.trace \"thread started\"\n\n        loop do\n          logger.trace \"blocking on gets...\"\n          line = pipe.r.gets\n          if line.nil?\n            logger.trace \"received nil, output done.\"\n          else\n            logger.trace \\\n              \"received \#{ line.bytesize } bytes, passing to handler.\"\n          end\n          handler.thread_send_line pipe.sym, line\n          break if line.nil?\n        end\n\n        logger.trace \\\n          \"reading done, closing pipe.r (unless already closed)...\"\n        pipe.r.close unless pipe.r.closed?\n\n        logger.trace \"thread done.\"\n      end # thread\n    end # if pipe\n  end # map threads\n\n  logger.trace \"handing off main thread control to the handler...\"\n  begin\n    handler.start\n\n    logger.trace \"handler done.\"\n\n  ensure\n    # wait for the threads to complete\n    logger.trace \"joining threads...\"\n\n    [in_thread, out_thread, err_thread, wait_thread].each do |thread|\n      if thread\n        logger.trace \"joining \#{ thread[:name] } thread...\"\n        thread.join\n      end\n    end\n\n    logger.trace \"all threads done.\"\n  end\n\n  status = wait_thread.value.exitstatus\n  logger.trace \"exit status: \#{ status.inspect }\"\n\n  logger.trace \"checking @assert and exit status...\"\n  if @assert && status != 0\n    # we don't necessarily have the err output, so we can't include it\n    # in the error message\n    msg = NRSER.squish <<-BLOCK\n      streamed command `\#{ cmd }` exited with status \#{ status }\n    BLOCK\n\n    raise SystemCallError.new msg, status\n  end\n\n  logger.trace \"streaming completed.\"\n\n  return status\nend\n"

.stream(template, *subs, &input_block) ⇒ Object



97
98
99
# File 'lib/cmds/sugar.rb', line 97

def self.stream template, *subs, &input_block
  Cmds.new(template).stream *subs, &input_block
end

.stream!(template, *args, **kwds, &io_block) ⇒ Object



102
103
104
# File 'lib/cmds/sugar.rb', line 102

def self.stream! template, *args, **kwds, &io_block
  Cmds.new(template).stream! *args, **kwds, &io_block
end

.tokenize(*values, **opts) ⇒ String

tokenize values for the shell. each values is tokenized individually and the results are joined with a space.



26
27
28
29
30
31
32
33
34
35
# File 'lib/cmds/util.rb', line 26

def self.tokenize *values, **opts
  values.map {|value|
    case value
    when Hash
      tokenize_options value, **opts
    else
      tokenize_value value, **opts
    end
  }.flatten.join ' '
end

.tokenize_option(name, value, **opts) ⇒ Array<String>

Turn an option name and value into an array of shell-escaped string tokens suitable for use in a command.

Raises:

  • (ArgumentError)
    1. If name is the wrong type or empty.
    2. If any options have bad values.


43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/cmds/util/tokenize_option.rb', line 43

def self.tokenize_option name, value, **opts
  # Set defaults for any options not passed
  opts = defaults opts, TOKENIZE_OPT_KEYS
  
  # Validate `name`
  unless name.is_a?(String) && name.length > 0
    raise ArgumentError.new NRSER.squish "      `name` must be a String of length greater than zero,\n      found \#{ name.inspect }\n    END\n  end\n  \n  name = name.gsub( '_', '-' ) if opts[:dash_opt_names]\n  \n  # Set type (`:short` or `:long`) prefix and name/value separator depending\n  # on if name is \"short\" (single character) or \"long\" (anything else)\n  # \n  type, prefix, separator = if name.length == 1\n    # -b <value> style (short)\n    [ :short, '-', opts[:short_opt_separator] ]\n  else\n    # --blah=<value> style (long)\n    [ :long, '--', opts[:long_opt_separator] ]\n  end\n  \n  case value\n  \n  # Special cases (booleans), where we may want to emit an option name but\n  # no value (depending on options)\n  # \n  when true\n    # `-b` or `--blah` style token\n    [prefix + esc(name)]\n    \n  when false\n    case opts[:false_mode]\n    when :omit, :ignore\n      # Don't emit any token for a false boolean\n      []\n    \n    when :negate, :no\n      # Emit `--no-blah` style token\n      # \n      if type == :long\n        # Easy one\n        [\"--no-\#{ esc(name) }\"]\n      \n      else\n        # Short option... there seems to be little general consensus on how\n        # to handle these guys; I feel like the most common is to invert the\n        # case, which only makes sense for languages that have lower and\n        # upper case :/\n        case opts[:false_short_opt_mode]\n        \n        when :capitalize, :cap, :upper, :upcase\n          # Capitalize the name\n          # \n          # {x: false} => [\"-X\"]\n          # \n          # This only really makes sense for lower case a-z, so raise if it's\n          # not in there\n          unless \"a\" <= name <= \"z\"\n            raise ArgumentError.new binding.erb <<-END\n              Can't negate CLI option `<%= name %>` by capitalizing name.\n              \n              Trying to tokenize option `<%= name %>` with `false` value and:\n              \n              1.  `:false_mode` is set to `<%= opts[:false_mode] %>`, which\n                  tells {Cmds.tokenize_option} to emit a \"negating\" name with\n                  no value like\n                  \n                      {update: false} => --no-update\n                  \n              2.  `:false_short_opt_mode` is set to `<%= opts[:false_short_opt_mode] %>`,\n                  which means negate through capitalizing the name character,\n                  like:\n                  \n                      {u: false} => -U\n              \n              3.  But this is only implemented for names in `a-z`\n              \n              Either change the {Cmds} instance configuration or provide a\n              different CLI option name or value.\n            END\n          end\n          \n          # Emit {x: false} => ['-X'] style\n          [\"-\#{ name.upcase }\"]\n        \n        when :long\n          # Treat it the same as a long option,\n          # emit {x: false} => ['--no-x'] style\n          # \n          # Yeah, I've never seen it anywhere else, but it seems reasonable I\n          # guess..?\n          #\n          [\"--no-\#{ esc(name) }\"]\n        \n        when :string\n          # Use the string 'false' as a value\n          [prefix + esc( name ) + separator + 'false']\n        \n        when String\n          # It's some custom string to use\n          [prefix + esc( name ) + separator + esc( string )]\n          \n        else\n          raise ArgumentError.new binding.erb <<-END\n            Bad `:false_short_opt_mode` value:\n            \n                <%= opts[:false_short_opt_mode].pretty_inspect %>\n            \n            Should be\n            \n            1.  :capitalize (or :cap, :upper, :upcase)\n            2.  :long\n            3.  :string\n            4.  any String\n            \n          END\n          \n        end # case opts[:false_short_opt_mode]\n      end # if :long else\n    else\n      raise ArgumentError.new NRSER.squish <<-END\n        bad :false_mode option: \#{ opts[:false_mode] },\n        should be :omit or :no\n      END\n    end\n  \n  # General case\n  else\n    # Tokenize the value, which may\n    # \n    # 1.  Result in more than one token, like when `:array_mode` is `:repeat`\n    #     (in which case we want to emit multiple option tokens)\n    # \n    # 2.  Result in zero tokens, like when `value` is `nil`\n    #     (in which case we want to emit no option tokens)\n    # \n    # and map the resulting tokens into option tokens\n    # \n    tokenize_value( value, **opts ).map { |token|\n      prefix + esc(name) + separator + token\n    }\n  \n  end # case value\nend\n"

.tokenize_options(hash, **opts) ⇒ Object

escape option hash.

this is only useful for the two common option styles:

  • single character keys become -<char> <value>

    {x: 1}    => "-x 1"
    
  • longer keys become --<key>=<value> options

    {blah: 2} => "--blah=2"
    

if you have something else, you're going to have to just put it in the cmd itself, like:

Cmds "blah -assholeOptionOn:%{s}", "ok"

or whatever similar shit said command requires.

however, if the value is an Array, it will repeat the option for each value:

{x:     [1, 2, 3]} => "-x 1 -x 2 -x 3"
{blah:  [1, 2, 3]} => "--blah=1 --blah=2 --blah=3"

i can't think of any right now, but i swear i've seen commands that take opts that way.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/cmds/util/tokenize_options.rb', line 32

def self.tokenize_options hash, **opts
  opts = defaults opts, TOKENIZE_OPT_KEYS
  
  hash.map {|key, value|
    # keys need to be strings
    key = key.to_s unless key.is_a? String

    [key, value]

  }.sort {|(key_a, value_a), (key_b, value_b)|
    # sort by the (now string) keys
    key_a <=> key_b

  }.map {|key, value|
    tokenize_option key, value, **opts
    
  }.flatten.join ' '
end

.tokenize_value(value, **opts) ⇒ Array<String>

turn an option name and value into an array of shell-escaped string token suitable for use in a command.

Raises:

  • (ArgumentError)

    If options are set to bad values.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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
# File 'lib/cmds/util/tokenize_value.rb', line 60

def self.tokenize_value value, **opts
  opts = defaults opts, TOKENIZE_OPT_KEYS
    
  case value
  when nil
    # `nil` values produces no tokens
    []
    
  when Array
    # The PITA one...
    # 
    # May produce one or multiple tokens.
    # 
    
    # Flatten the array value if option is set
    value = value.flatten if opts[:flatten_array_values]
    
    case opts[:array_mode]
    when :repeat
      # Encode each entry as it's own token
      # 
      # [1, 2, 3] => ["1", "2", "3"]
      # 
      
      # Pass entries back through for individual tokenization and flatten
      # so we are sure to return a single-depth array
      value.map { |entry| tokenize_value entry, **opts }.flatten
      
    when :join
      # Encode all entries as one joined string token
      # 
      # [1, 2, 3] => ["1,2,3"]
      # 
      
      [esc( value.join opts[:array_join_string] )]
      
    when :json
      # Encode JSON dump as single token, single-quoted
      # 
      # [1, 2, 3] => ["'[1,2,3]'"]
      
      [single_quote( JSON.dump value )]
      
    else
      # SOL
      raise ArgumentError.new binding.erb "        Bad `:array_mode` option:\n        \n            <%= opts[:array_mode].pretty_inspect %>\n        \n        Should be :join, :repeat or :json\n        \n      END\n      \n    end # case opts[:array_mode]\n  \n  when Hash\n    # Much the same as array\n    # \n    # May produce one or multiple tokens.\n    # \n    \n    case opts[:hash_mode]\n    when :join\n      # Join the key and value using the option and pass the resulting array\n      # back through to be handled as configured\n      tokenize_value \\\n        value.map { |k, v| [k, v].join opts[:hash_join_string] },\n        **opts\n    \n    when :json\n      # Encode JSON dump as single token, single-quoted\n      # \n      # [1, 2, 3] => [%{'{\"a\":1,\"b\":2,\"c\":3}'}]\n      \n      [single_quote( JSON.dump value )]\n      \n    else\n      # SOL\n      raise ArgumentError.new binding.erb <<-END\n        Bad `:hash_mode` option:\n        \n            <%= opts[:hash_mode].pretty_inspect %>\n        \n        Should be :join, or :json\n        \n      END\n    end\n  \n  else\n    # We let {Cmds.esc} handle it, and return that as a single token\n    [esc(value)]\n    \n  end\nend\n"

Instance Method Details

#capture(*args, **kwds, &input_block) ⇒ Cmds::Result Also known as: call

executes the command and returns a Result with the captured outputs.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/cmds/capture.rb', line 21

def capture *args, **kwds, &input_block
  logger.trace "entering Cmds#capture",
    args: args,
    kwds: kwds,
    input: input
  
  # extract input from block via `call` if one is provided,
  # otherwise default to instance variable (which may be `nil`)
  input = input_block.nil? ? input : input_block.call
  
  logger.trace "configured input",
    input: input
  
  # strings output will be concatenated onto
  out = ''
  err = ''

  logger.trace "calling Cmds.spawn..."
  
  status = spawn(*args, **kwds) do |io|
    # send the input to stream, which sends it to spawn
    io.in = input

    # and concat the output lines as they come in
    io.on_out do |line|
      out += line
    end

    io.on_err do |line|
      err += line
    end
  end
  
  logger.trace "Cmds.spawn completed",
    status: status

  # build a Result
  # result = Cmds::Result.new cmd, status, out_reader.value, err_reader.value
  result = Cmds::Result.new last_prepared_cmd, status, out, err

  # tell the Result to assert if the Cmds has been told to, which will
  # raise a SystemCallError with the exit status if it was non-zero
  result.assert if assert

  return result
end

#chomp(*args, **kwds, &input_block) ⇒ String

captures and chomps stdout (sugar for #out(*subs, &input_block).chomp).

See Also:



427
428
429
# File 'lib/cmds.rb', line 427

def chomp *args, **kwds, &input_block
  out(*args, **kwds, &input_block).chomp
end

#chomp!(*args, **kwds, &input) ⇒ String

captures and chomps stdout, raising an error if the command failed. (sugar for #out!(*subs, &input_block).chomp).

Raises:

  • (SystemCallError)

    if the command fails (non-zero exit status).

See Also:



448
449
450
# File 'lib/cmds.rb', line 448

def chomp! *args, **kwds, &input
  out!(*args, **kwds, &input).chomp
end

#curry(*args, **kwds, &input_block) ⇒ Object

returns a new Cmds with the parameters and input merged in



269
270
271
272
273
274
275
276
277
278
279
# File 'lib/cmds.rb', line 269

def curry *args, **kwds, &input_block
  self.class.new template, {
    args: (self.args + args),
    kwds: (self.kwds.merge kwds),
    input: (input_block ? input_block.call : self.input),
    assert: self.assert,
    env: self.env,
    format: self.format,
    chdir: self.chdir,
  }
end

#defaults(opts, keys = '*', extras = {}) ⇒ Object

proxy through to class method defaults.



123
124
125
# File 'lib/cmds/util/defaults.rb', line 123

def defaults opts, keys = '*', extras = {}
  self.class.defaults opts, keys, extras
end

#err(*args, **kwds, &input_block) ⇒ String

captures and returns stdout (sugar for #capture(*subs, &input_block).err).



466
467
468
# File 'lib/cmds.rb', line 466

def err *args, **kwds, &input_block
  capture(*args, **kwds, &input_block).err
end

#error?(*args, **kwds, &io_block) ⇒ Boolean

execute command and return true if it failed.



367
368
369
# File 'lib/cmds.rb', line 367

def error? *args, **kwds, &io_block
  stream(*args, **kwds, &io_block) != 0
end

#ok?(*args, **kwds, &io_block) ⇒ Boolean

execute command and return true if it exited successfully.



353
354
355
# File 'lib/cmds.rb', line 353

def ok? *args, **kwds, &io_block
  stream(*args, **kwds, &io_block) == 0
end

#out(*args, **kwds, &input_block) ⇒ String

captures and returns stdout (sugar for #capture(*args, **kwds, &input_block).out).



392
393
394
# File 'lib/cmds.rb', line 392

def out *args, **kwds, &input_block
  capture(*args, **kwds, &input_block).out
end

#out!(*args, **kwds, &input) ⇒ String

captures and returns stdout (sugar for #capture(*args, **kwds, &input_block).out).

Raises:

  • (SystemCallError)

    if the command fails (non-zero exit status).

See Also:



410
411
412
# File 'lib/cmds.rb', line 410

def out! *args, **kwds, &input
  capture(*args, **kwds, &input).assert.out
end

#prepare(*args, **kwds) ⇒ String

prepare a shell-safe command string for execution.



333
334
335
# File 'lib/cmds.rb', line 333

def prepare *args, **kwds
  @last_prepared_cmd = Cmds.format render(*args, **kwds), self.format
end

#proxyObject



372
373
374
375
376
# File 'lib/cmds.rb', line 372

def proxy
  stream do |io|
    io.in = $stdin
  end
end

#render(*args, **kwds) ⇒ String

Note:

the returned string is not formatted for shell execution. Cmds passes this string through format before execution, which addresses newlines in the rendered string through "squishing" everything down to one line or adding \ to line ends.

render parameters into @template.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/cmds.rb', line 295

def render *args, **kwds
  # Create the context for ERB
  context = Cmds::ERBContext.new(
    (self.args + args),
    
    self.kwds.merge( kwds ),
    
    tokenize_options_opts: TOKENIZE_OPT_KEYS.
      each_with_object( {} ) { |key, hash|
        value = instance_variable_get "@#{ key}"
        hash[key] = value unless value.nil?
      }
  )
  
  erb = Cmds::ShellEruby.new Cmds.replace_shortcuts( self.template )
  
  rendered = NRSER.dedent erb.result(context.get_binding)
  
  if self.env_mode == :inline && !self.env.empty?
    rendered = self.env.sort_by {|name, value|
      name
    }.map {|name, value|
      "#{ name }=#{ Cmds.esc value }"
    }.join("\n\n") + "\n\n" + rendered
  end
  
  rendered
end

#stream(*args, **kwds, &io_block) ⇒ Fixnum

stream a command.



13
14
15
16
17
18
19
20
# File 'lib/cmds/stream.rb', line 13

def stream *args, **kwds, &io_block
  logger.trace "entering Cmds#stream",
    args: args,
    kwds: kwds,
    io_block: io_block
  
  spawn *args, **kwds, &io_block
end

#stream!(*args, **kwds, &io_block) ⇒ Object

stream and raise an error if exit code is not 0.

Raises:

  • (SystemCallError)

    if exit status is not 0.



32
33
34
35
36
37
38
# File 'lib/cmds/stream.rb', line 32

def stream! *args, **kwds, &io_block
  status = stream *args, **kwds, &io_block
  
  Cmds.check_status last_prepared_cmd, status
  
  status
end