cmds 0.2.4 → 0.2.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|