cmds 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec +1 -0
- data/Gemfile +1 -1
- data/README.md +352 -224
- data/cmds.gemspec +46 -11
- data/lib/cmds.rb +55 -20
- data/lib/cmds/io_handler.rb +15 -2
- data/lib/cmds/spawn.rb +70 -34
- data/lib/cmds/util.rb +3 -12
- data/lib/cmds/util/defaults.rb +45 -16
- data/lib/cmds/util/shell_escape.rb +101 -0
- data/lib/cmds/util/tokenize_option.rb +141 -70
- data/lib/cmds/util/tokenize_value.rb +154 -0
- data/lib/cmds/version.rb +1 -1
- data/spec/cmds/env_spec.rb +71 -1
- data/spec/cmds/prepare_spec.rb +58 -7
- data/spec/cmds/stream/output_spec.rb +69 -0
- data/spec/cmds/util/shell_escape/quote_dance_spec.rb +18 -0
- data/spec/spec_helper.rb +2 -0
- metadata +36 -24
data/lib/cmds/util.rb
CHANGED
@@ -7,19 +7,13 @@ require 'shellwords'
|
|
7
7
|
require 'nrser'
|
8
8
|
|
9
9
|
# package
|
10
|
+
require 'cmds/util/shell_escape'
|
10
11
|
require 'cmds/util/tokenize_options'
|
11
12
|
|
12
13
|
class Cmds
|
13
14
|
# class methods
|
14
15
|
# =============
|
15
16
|
|
16
|
-
# shortcut for Shellwords.escape
|
17
|
-
#
|
18
|
-
# also makes it easier to change or customize or whatever
|
19
|
-
def self.esc str
|
20
|
-
Shellwords.escape str
|
21
|
-
end
|
22
|
-
|
23
17
|
# tokenize values for the shell. each values is tokenized individually
|
24
18
|
# and the results are joined with a space.
|
25
19
|
#
|
@@ -32,15 +26,12 @@ class Cmds
|
|
32
26
|
def self.tokenize *values, **opts
|
33
27
|
values.map {|value|
|
34
28
|
case value
|
35
|
-
when nil
|
36
|
-
# nil is just an empty string, NOT an empty string bash token
|
37
|
-
''
|
38
29
|
when Hash
|
39
30
|
tokenize_options value, **opts
|
40
31
|
else
|
41
|
-
|
32
|
+
tokenize_value value, **opts
|
42
33
|
end
|
43
|
-
}.join ' '
|
34
|
+
}.flatten.join ' '
|
44
35
|
end # .tokenize
|
45
36
|
|
46
37
|
|
data/lib/cmds/util/defaults.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
class Cmds
|
2
4
|
# hash of common default values used in method options.
|
3
5
|
#
|
@@ -13,39 +15,66 @@ class Cmds
|
|
13
15
|
# you don't even know about may depend on said behavior.
|
14
16
|
#
|
15
17
|
DEFAULTS = {
|
18
|
+
# Alphabetical...
|
19
|
+
|
16
20
|
# positional arguments for a command
|
17
21
|
args: [],
|
18
22
|
|
19
|
-
#
|
20
|
-
|
21
|
-
|
22
|
-
# how to format a command string for execution
|
23
|
-
format: :squish,
|
23
|
+
# what to join array option values with when using `array_mode = :join`
|
24
|
+
array_join_string: ',',
|
24
25
|
|
25
26
|
# what to do with array option values
|
26
27
|
array_mode: :join,
|
27
28
|
|
28
|
-
#
|
29
|
-
|
29
|
+
# Don't asset (raise error if exit code is not 0)
|
30
|
+
assert: false,
|
30
31
|
|
31
|
-
#
|
32
|
-
|
32
|
+
# Don't change directories
|
33
|
+
chdir: nil,
|
34
|
+
|
35
|
+
# No additional environment
|
36
|
+
env: {},
|
33
37
|
|
34
38
|
# Stick ENV var defs inline at beginning of command
|
35
39
|
env_mode: :inline,
|
36
40
|
|
37
|
-
#
|
38
|
-
|
41
|
+
# What to do with `false` *option* values (not `false` values as regular
|
42
|
+
# values or inside collections)
|
43
|
+
#
|
44
|
+
# Just leave them out all-together
|
45
|
+
false_mode: :omit,
|
39
46
|
|
40
|
-
#
|
41
|
-
|
47
|
+
# Flatten nested array values to a single array.
|
48
|
+
#
|
49
|
+
# Many CLI commands accept arrays in some form or another, but I'm hard
|
50
|
+
# pressed to think of one that accepts nested arrays. Flattening can make
|
51
|
+
# it simpler to generate values.
|
52
|
+
#
|
53
|
+
flatten_array_values: true,
|
42
54
|
|
43
|
-
#
|
44
|
-
|
55
|
+
# how to format a command string for execution
|
56
|
+
format: :squish,
|
57
|
+
|
58
|
+
hash_mode: :join,
|
59
|
+
|
60
|
+
# Join hash keys and values with `:`
|
61
|
+
hash_join_string: ':',
|
45
62
|
|
46
63
|
# No input
|
47
64
|
input: nil,
|
48
65
|
|
66
|
+
# keyword arguments for a command
|
67
|
+
kwds: {},
|
68
|
+
|
69
|
+
# What to use to separate "long" opt names (more than one character) from
|
70
|
+
# their values. I've commonly seen '=' (`--name=VALUE`)
|
71
|
+
# and ' ' (`--name VALUE`).
|
72
|
+
long_opt_separator: '=',
|
73
|
+
|
74
|
+
# What to use to separate "short" opt names (single character) from their
|
75
|
+
# values. I've commonly seen ' ' (`-x VALUE`) and '' (`-xVALUE`).
|
76
|
+
short_opt_separator: ' ',
|
77
|
+
|
49
78
|
}.map { |k, v| [k, v.freeze] }.to_h.freeze
|
50
79
|
|
51
80
|
|
@@ -69,7 +98,7 @@ class Cmds
|
|
69
98
|
def self.defaults opts, keys = '*', extras = {}
|
70
99
|
if keys == '*'
|
71
100
|
DEFAULTS.dup
|
72
|
-
else
|
101
|
+
else
|
73
102
|
keys.
|
74
103
|
map {|key|
|
75
104
|
[key, DEFAULTS.fetch(key)]
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Cmds
|
4
|
+
# @!group Shell Quoting and Escaping Methods
|
5
|
+
|
6
|
+
# Constants
|
7
|
+
# ======================================================================
|
8
|
+
|
9
|
+
# Quote "name" keys `:single` and `:double` mapped to their character.
|
10
|
+
#
|
11
|
+
# @return [Hash<Symbol, String>]
|
12
|
+
#
|
13
|
+
QUOTE_TYPES = {
|
14
|
+
single: %{'},
|
15
|
+
double: %{"},
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
|
19
|
+
# List containing just `'` and `"`.
|
20
|
+
#
|
21
|
+
# @return [Array<String>]
|
22
|
+
#
|
23
|
+
QUOTE_VALUES = QUOTE_TYPES.values.freeze
|
24
|
+
|
25
|
+
|
26
|
+
# Class Methods
|
27
|
+
# ======================================================================
|
28
|
+
|
29
|
+
# Shortcut for Shellwords.escape
|
30
|
+
#
|
31
|
+
# Also makes it easier to change or customize or whatever.
|
32
|
+
#
|
33
|
+
# @see http://ruby-doc.org/stdlib/libdoc/shellwords/rdoc/Shellwords.html#method-c-escape
|
34
|
+
#
|
35
|
+
# @param [#to_s] str
|
36
|
+
# @return [String]
|
37
|
+
#
|
38
|
+
def self.esc str
|
39
|
+
Shellwords.escape str
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
# Format a string to be a shell token by wrapping it in either single or
|
44
|
+
# double quotes and replacing instances of that quote with what I'm calling
|
45
|
+
# a "quote dance":
|
46
|
+
#
|
47
|
+
# 1. Closing the type of quote in use
|
48
|
+
# 2. Quoting the type of quote in use with the *other* type of quote
|
49
|
+
# 3. Then opening up the type in use again and keeping going.
|
50
|
+
#
|
51
|
+
# @example Single quoting string containing single quotes
|
52
|
+
#
|
53
|
+
# Cmds.quote_dance %{you're}, :single
|
54
|
+
# # => %{'you'"'"'re'}
|
55
|
+
#
|
56
|
+
# @example Double quoting string containing double quotes
|
57
|
+
#
|
58
|
+
# Cmds.quote_dance %{hey "ho" let's go}, :double
|
59
|
+
# # => %{"hey "'"'"ho"'"'" let's go"}
|
60
|
+
#
|
61
|
+
# **_WARNING:
|
62
|
+
# Does NOT escape anything except the quotes! So if you double-quote a
|
63
|
+
# string with shell-expansion terms in it and pass it to the shell
|
64
|
+
# THEY WILL BE EVALUATED_**
|
65
|
+
#
|
66
|
+
# @param [String] string
|
67
|
+
# String to quote.
|
68
|
+
#
|
69
|
+
# @return [return_type]
|
70
|
+
# @todo Document return value.
|
71
|
+
#
|
72
|
+
def self.quote_dance string, quote_type
|
73
|
+
outside = QUOTE_TYPES.fetch quote_type
|
74
|
+
inside = QUOTE_VALUES[QUOTE_VALUES[0] == outside ? 1 : 0]
|
75
|
+
|
76
|
+
outside +
|
77
|
+
string.gsub(
|
78
|
+
outside,
|
79
|
+
outside + inside + outside + inside + outside
|
80
|
+
) +
|
81
|
+
outside
|
82
|
+
end # .quote_dance
|
83
|
+
|
84
|
+
|
85
|
+
# Single quote a string for use in the shell.
|
86
|
+
#
|
87
|
+
# @param [String] string
|
88
|
+
# String to quote.
|
89
|
+
#
|
90
|
+
# @return [String]
|
91
|
+
# Single-quoted string.
|
92
|
+
#
|
93
|
+
def self.single_quote string
|
94
|
+
quote_dance string, :single
|
95
|
+
end # .single_quote
|
96
|
+
|
97
|
+
# Set {.single_quote} up as our quote method.
|
98
|
+
singleton_class.send :alias_method, :quote, :single_quote
|
99
|
+
|
100
|
+
|
101
|
+
end # class Cmds
|
@@ -1,43 +1,50 @@
|
|
1
1
|
require 'json'
|
2
|
-
require 'nrser
|
2
|
+
require 'nrser'
|
3
3
|
|
4
4
|
require_relative "defaults"
|
5
|
+
require_relative "tokenize_value"
|
5
6
|
|
6
7
|
class Cmds
|
7
|
-
TOKENIZE_OPT_KEYS = [:array_mode, :array_join_string, :false_mode]
|
8
8
|
|
9
|
-
#
|
10
|
-
#
|
9
|
+
# Turn an option name and value into an array of shell-escaped string
|
10
|
+
# tokens suitable for use in a command.
|
11
11
|
#
|
12
12
|
# @param [String] name
|
13
|
-
#
|
13
|
+
# String name (one or more characters).
|
14
14
|
#
|
15
15
|
# @param [*] value
|
16
|
-
#
|
16
|
+
# Value of the option.
|
17
17
|
#
|
18
18
|
# @param [Hash] **opts
|
19
|
-
#
|
19
|
+
#
|
20
|
+
# @option [Symbol] :array_mode (:join)
|
20
21
|
# one of:
|
22
|
+
#
|
23
|
+
# 1. `:join` (default) -- join values in one token.
|
24
|
+
#
|
25
|
+
# tokenize_option 'blah', [1, 2, 3], array_mode: :join
|
26
|
+
# => ['--blah=1,2,3']
|
21
27
|
#
|
22
|
-
#
|
28
|
+
# 2. `:repeat` repeat the option for each value.
|
23
29
|
#
|
24
|
-
#
|
30
|
+
# tokenize_option 'blah', [1, 2, 3], array_mode: :repeat
|
25
31
|
# => ['--blah=1', '--blah=2', '--blah=3']
|
26
|
-
#
|
27
|
-
# 2. `:join` -- join values in one token.
|
28
|
-
#
|
29
|
-
# expand_option 'blah', [1, 2, 3], array_mode: :join
|
30
|
-
# => ['--blah=1,2,3']
|
31
32
|
#
|
32
33
|
# @option [String] :array_join_string (',')
|
33
|
-
#
|
34
|
+
# String to join array values with when `:array_mode` is `:join`.
|
34
35
|
#
|
35
36
|
# @return [Array<String>]
|
36
|
-
#
|
37
|
+
# List of individual shell token strings, escaped for use.
|
38
|
+
#
|
39
|
+
# @raise [ArgumentError]
|
40
|
+
# 1. If `name` is the wrong type or empty.
|
41
|
+
# 2. If any options have bad values.
|
37
42
|
#
|
38
43
|
def self.tokenize_option name, value, **opts
|
44
|
+
# Set defaults for any options not passed
|
39
45
|
opts = defaults opts, TOKENIZE_OPT_KEYS
|
40
46
|
|
47
|
+
# Validate `name`
|
41
48
|
unless name.is_a?(String) && name.length > 0
|
42
49
|
raise ArgumentError.new NRSER.squish <<-END
|
43
50
|
`name` must be a String of length greater than zero,
|
@@ -45,74 +52,138 @@ class Cmds
|
|
45
52
|
END
|
46
53
|
end
|
47
54
|
|
48
|
-
|
49
|
-
|
50
|
-
|
55
|
+
# Set type (`:short` or `:long`) prefix and name/value separator depending
|
56
|
+
# on if name is "short" (single character) or "long" (anything else)
|
57
|
+
#
|
58
|
+
type, prefix, separator = if name.length == 1
|
59
|
+
# -b <value> style (short)
|
60
|
+
[ :short, '-', opts[:short_opt_separator] ]
|
51
61
|
else
|
52
|
-
# --blah=<value> style
|
53
|
-
['--',
|
62
|
+
# --blah=<value> style (long)
|
63
|
+
[ :long, '--', opts[:long_opt_separator] ]
|
54
64
|
end
|
55
|
-
|
65
|
+
|
56
66
|
case value
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
# the PITA one
|
62
|
-
case opts[:array_mode]
|
63
|
-
when :repeat
|
64
|
-
# `-b 1 -b 2 -b 3` / `--blah=1 --blah=2 --blah=3` style
|
65
|
-
value.flatten.map {|v|
|
66
|
-
prefix + esc(name) + separator + esc(v)
|
67
|
-
}
|
68
|
-
|
69
|
-
when :join
|
70
|
-
# `-b 1,2,3` / `--blah=1,2,3` style
|
71
|
-
[ prefix +
|
72
|
-
esc(name) +
|
73
|
-
separator +
|
74
|
-
esc(value.join opts[:array_join_string]) ]
|
75
|
-
|
76
|
-
when :json
|
77
|
-
[prefix + esc(name) + separator + "'" + JSON.dump(value).gsub(%{'}, %{'"'"'}) + "'"]
|
78
|
-
|
79
|
-
else
|
80
|
-
# SOL
|
81
|
-
raise ArgumentError.new NRSER.squish <<-END
|
82
|
-
bad array_mode option: #{ opts[:array_mode] },
|
83
|
-
should be :repeat, :join or :json
|
84
|
-
END
|
85
|
-
|
86
|
-
end
|
87
|
-
|
67
|
+
|
68
|
+
# Special cases (booleans), where we may want to emit an option name but
|
69
|
+
# no value (depending on options)
|
70
|
+
#
|
88
71
|
when true
|
89
|
-
# `-b` or `--blah`
|
72
|
+
# `-b` or `--blah` style token
|
90
73
|
[prefix + esc(name)]
|
91
74
|
|
92
75
|
when false
|
93
76
|
case opts[:false_mode]
|
94
|
-
when :omit
|
95
|
-
#
|
77
|
+
when :omit, :ignore
|
78
|
+
# Don't emit any token for a false boolean
|
96
79
|
[]
|
97
|
-
|
98
|
-
|
99
|
-
#
|
100
|
-
# but there's not really a great way to handle short names...
|
101
|
-
# we use `--no-b`
|
80
|
+
|
81
|
+
when :negate, :no
|
82
|
+
# Emit `--no-blah` style token
|
102
83
|
#
|
103
|
-
|
84
|
+
if type == :long
|
85
|
+
# Easy one
|
86
|
+
["--no-#{ esc(name) }"]
|
104
87
|
|
88
|
+
else
|
89
|
+
# Short option... there seems to be little general consensus on how
|
90
|
+
# to handle these guys; I feel like the most common is to invert the
|
91
|
+
# case, which only makes sense for languages that have lower and
|
92
|
+
# upper case :/
|
93
|
+
case opts[:false_short_opt_mode]
|
94
|
+
|
95
|
+
when :capitalize, :cap, :upper, :upcase
|
96
|
+
# Capitalize the name
|
97
|
+
#
|
98
|
+
# {x: false} => ["-X"]
|
99
|
+
#
|
100
|
+
# This only really makes sense for lower case a-z, so raise if it's
|
101
|
+
# not in there
|
102
|
+
unless "a" <= name <= "z"
|
103
|
+
raise ArgumentError.new binding.erb <<-END
|
104
|
+
Can't negate CLI option `<%= name %>` by capitalizing name.
|
105
|
+
|
106
|
+
Trying to tokenize option `<%= name %>` with `false` value and:
|
107
|
+
|
108
|
+
1. `:false_mode` is set to `<%= opts[:false_mode] %>`, which
|
109
|
+
tells {Cmds.tokenize_option} to emit a "negating" name with
|
110
|
+
no value like
|
111
|
+
|
112
|
+
{update: false} => --no-update
|
113
|
+
|
114
|
+
2. `:false_short_opt_mode` is set to `<%= opts[:false_short_opt_mode] %>`,
|
115
|
+
which means negate through capitalizing the name character,
|
116
|
+
like:
|
117
|
+
|
118
|
+
{u: false} => -U
|
119
|
+
|
120
|
+
3. But this is only implemented for names in `a-z`
|
121
|
+
|
122
|
+
Either change the {Cmds} instance configuration or provide a
|
123
|
+
different CLI option name or value.
|
124
|
+
END
|
125
|
+
end
|
126
|
+
|
127
|
+
# Emit {x: false} => ['-X'] style
|
128
|
+
["-#{ name.upcase }"]
|
129
|
+
|
130
|
+
when :long
|
131
|
+
# Treat it the same as a long option,
|
132
|
+
# emit {x: false} => ['--no-x'] style
|
133
|
+
#
|
134
|
+
# Yeah, I've never seen it anywhere else, but it seems reasonable I
|
135
|
+
# guess..?
|
136
|
+
#
|
137
|
+
["--no-#{ esc(name) }"]
|
138
|
+
|
139
|
+
when :string
|
140
|
+
# Use the string 'false' as a value
|
141
|
+
[prefix + esc( name ) + separator + 'false']
|
142
|
+
|
143
|
+
when String
|
144
|
+
# It's some custom string to use
|
145
|
+
[prefix + esc( name ) + separator + esc( string )]
|
146
|
+
|
147
|
+
else
|
148
|
+
raise ArgumentError.new binding.erb <<-END
|
149
|
+
Bad `:false_short_opt_mode` value:
|
150
|
+
|
151
|
+
<%= opts[:false_short_opt_mode].pretty_inspect %>
|
152
|
+
|
153
|
+
Should be
|
154
|
+
|
155
|
+
1. :capitalize (or :cap, :upper, :upcase)
|
156
|
+
2. :long
|
157
|
+
3. :string
|
158
|
+
4. any String
|
159
|
+
|
160
|
+
END
|
161
|
+
|
162
|
+
end # case opts[:false_short_opt_mode]
|
163
|
+
end # if :long else
|
105
164
|
else
|
106
165
|
raise ArgumentError.new NRSER.squish <<-END
|
107
|
-
bad :false_mode option: #{ opts[:false_mode] },
|
166
|
+
bad :false_mode option: #{ opts[:false_mode] },
|
108
167
|
should be :omit or :no
|
109
168
|
END
|
110
169
|
end
|
111
|
-
|
170
|
+
|
171
|
+
# General case
|
112
172
|
else
|
113
|
-
#
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
173
|
+
# Tokenize the value, which may
|
174
|
+
#
|
175
|
+
# 1. Result in more than one token, like when `:array_mode` is `:repeat`
|
176
|
+
# (in which case we want to emit multiple option tokens)
|
177
|
+
#
|
178
|
+
# 2. Result in zero tokens, like when `value` is `nil`
|
179
|
+
# (in which case we want to emit no option tokens)
|
180
|
+
#
|
181
|
+
# and map the resulting tokens into option tokens
|
182
|
+
#
|
183
|
+
tokenize_value( value, **opts ).map { |token|
|
184
|
+
prefix + esc(name) + separator + token
|
185
|
+
}
|
186
|
+
|
187
|
+
end # case value
|
188
|
+
end # .tokenize_option
|
189
|
+
end # class Cmds
|