cli-kit 3.0.0 → 4.0.0

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.
@@ -0,0 +1,244 @@
1
+ module CLI
2
+ module Kit
3
+ module Support
4
+ module TestHelper
5
+ def setup
6
+ super
7
+ CLI::Kit::System.reset!
8
+ end
9
+
10
+ def assert_all_commands_run(should_raise: true)
11
+ errors = CLI::Kit::System.error_message
12
+ CLI::Kit::System.reset!
13
+ assert(false, errors) if should_raise && !errors.nil?
14
+ errors
15
+ end
16
+
17
+ def teardown
18
+ super
19
+ assert_all_commands_run
20
+ end
21
+
22
+ module FakeConfig
23
+ require 'tmpdir'
24
+ require 'fileutils'
25
+
26
+ def setup
27
+ super
28
+ @tmpdir = Dir.mktmpdir
29
+ @prev_xdg = ENV['XDG_CONFIG_HOME']
30
+ ENV['XDG_CONFIG_HOME'] = @tmpdir
31
+ end
32
+
33
+ def teardown
34
+ FileUtils.rm_rf(@tmpdir)
35
+ ENV['XDG_CONFIG_HOME'] = @prev_xdg
36
+ super
37
+ end
38
+ end
39
+
40
+ class FakeSuccess
41
+ def initialize(success)
42
+ @success = success
43
+ end
44
+
45
+ def success?
46
+ @success
47
+ end
48
+ end
49
+
50
+ module ::CLI
51
+ module Kit
52
+ module System
53
+ class << self
54
+ alias_method :original_system, :system
55
+ def system(*a, sudo: false, env: {}, **kwargs)
56
+ expected_command = expected_command(*a, sudo: sudo, env: env)
57
+
58
+ # In the case of an unexpected command, expected_command will be nil
59
+ return FakeSuccess.new(false) if expected_command.nil?
60
+
61
+ # Otherwise handle the command
62
+ if expected_command[:allow]
63
+ original_system(*a, sudo: sudo, env: env, **kwargs)
64
+ else
65
+ FakeSuccess.new(expected_command[:success])
66
+ end
67
+ end
68
+
69
+ alias_method :original_capture2, :capture2
70
+ def capture2(*a, sudo: false, env: {}, **kwargs)
71
+ expected_command = expected_command(*a, sudo: sudo, env: env)
72
+
73
+ # In the case of an unexpected command, expected_command will be nil
74
+ return [nil, FakeSuccess.new(false)] if expected_command.nil?
75
+
76
+ # Otherwise handle the command
77
+ if expected_command[:allow]
78
+ original_capture2(*a, sudo: sudo, env: env, **kwargs)
79
+ else
80
+ [
81
+ expected_command[:stdout],
82
+ FakeSuccess.new(expected_command[:success]),
83
+ ]
84
+ end
85
+ end
86
+
87
+ alias_method :original_capture2e, :capture2e
88
+ def capture2e(*a, sudo: false, env: {}, **kwargs)
89
+ expected_command = expected_command(*a, sudo: sudo, env: env)
90
+
91
+ # In the case of an unexpected command, expected_command will be nil
92
+ return [nil, FakeSuccess.new(false)] if expected_command.nil?
93
+
94
+ # Otherwise handle the command
95
+ if expected_command[:allow]
96
+ original_capture2ecapture2e(*a, sudo: sudo, env: env, **kwargs)
97
+ else
98
+ [
99
+ expected_command[:stdout],
100
+ FakeSuccess.new(expected_command[:success]),
101
+ ]
102
+ end
103
+ end
104
+
105
+ alias_method :original_capture3, :capture3
106
+ def capture3(*a, sudo: false, env: {}, **kwargs)
107
+ expected_command = expected_command(*a, sudo: sudo, env: env)
108
+
109
+ # In the case of an unexpected command, expected_command will be nil
110
+ return [nil, nil, FakeSuccess.new(false)] if expected_command.nil?
111
+
112
+ # Otherwise handle the command
113
+ if expected_command[:allow]
114
+ original_capture3(*a, sudo: sudo, env: env, **kwargs)
115
+ else
116
+ [
117
+ expected_command[:stdout],
118
+ expected_command[:stderr],
119
+ FakeSuccess.new(expected_command[:success]),
120
+ ]
121
+ end
122
+ end
123
+
124
+ # Sets up an expectation for a command and stubs out the call (unless allow is true)
125
+ #
126
+ # #### Parameters
127
+ # `*a` : the command, represented as a splat
128
+ # `stdout` : stdout to stub the command with (defaults to empty string)
129
+ # `stderr` : stderr to stub the command with (defaults to empty string)
130
+ # `allow` : allow determines if the command will be actually run, or stubbed. Defaults to nil (stub)
131
+ # `success` : success status to stub the command with (Defaults to nil)
132
+ # `sudo` : expectation of sudo being set or not (defaults to false)
133
+ # `env` : expectation of env being set or not (defaults to {})
134
+ #
135
+ # Note: Must set allow or success
136
+ #
137
+ def fake(*a, stdout: '', stderr: '', allow: nil, success: nil, sudo: false, env: {})
138
+ raise ArgumentError, 'success or allow must be set' if success.nil? && allow.nil?
139
+
140
+ @delegate_open3 ||= {}
141
+ @delegate_open3[a.join(' ')] = {
142
+ expected: {
143
+ sudo: sudo,
144
+ env: env,
145
+ },
146
+ actual: {
147
+ sudo: nil,
148
+ env: nil,
149
+ },
150
+ stdout: stdout,
151
+ stderr: stderr,
152
+ allow: allow,
153
+ success: success,
154
+ run: false,
155
+ }
156
+ end
157
+
158
+ # Resets the faked commands
159
+ #
160
+ def reset!
161
+ @delegate_open3 = {}
162
+ end
163
+
164
+ # Returns the errors associated to a test run
165
+ #
166
+ # #### Returns
167
+ # `errors` (String) a string representing errors found on this run, nil if none
168
+ def error_message
169
+ errors = {
170
+ unexpected: [],
171
+ not_run: [],
172
+ other: {},
173
+ }
174
+
175
+ @delegate_open3.each do |cmd, opts|
176
+ if opts[:unexpected]
177
+ errors[:unexpected] << cmd
178
+ elsif opts[:run]
179
+ error = []
180
+
181
+ if opts[:expected][:sudo] != opts[:actual][:sudo]
182
+ error << "- sudo was supposed to be #{opts[:expected][:sudo]} but was #{opts[:actual][:sudo]}"
183
+ end
184
+
185
+ if opts[:expected][:env] != opts[:actual][:env]
186
+ error << "- env was supposed to be #{opts[:expected][:env]} but was #{opts[:actual][:env]}"
187
+ end
188
+
189
+ errors[:other][cmd] = error.join("\n") unless error.empty?
190
+ else
191
+ errors[:not_run] << cmd
192
+ end
193
+ end
194
+
195
+ final_error = []
196
+
197
+ unless errors[:unexpected].empty?
198
+ final_error << CLI::UI.fmt(<<~EOF)
199
+ {{bold:Unexpected command invocations:}}
200
+ {{command:#{errors[:unexpected].join("\n")}}}
201
+ EOF
202
+ end
203
+
204
+ unless errors[:not_run].empty?
205
+ final_error << CLI::UI.fmt(<<~EOF)
206
+ {{bold:Expected commands were not run:}}
207
+ {{command:#{errors[:not_run].join("\n")}}}
208
+ EOF
209
+ end
210
+
211
+ unless errors[:other].empty?
212
+ final_error << CLI::UI.fmt(<<~EOF)
213
+ {{bold:Commands were not run as expected:}}
214
+ #{errors[:other].map { |cmd, msg| "{{command:#{cmd}}}\n#{msg}" }.join("\n\n")}
215
+ EOF
216
+ end
217
+
218
+ return nil if final_error.empty?
219
+ "\n" + final_error.join("\n") # Initial new line for formatting reasons
220
+ end
221
+
222
+ private
223
+
224
+ def expected_command(*a, sudo: raise, env: raise)
225
+ expected_cmd = @delegate_open3[a.join(' ')]
226
+
227
+ if expected_cmd.nil?
228
+ @delegate_open3[a.join(' ')] = { unexpected: true }
229
+ return nil
230
+ end
231
+
232
+ expected_cmd[:run] = true
233
+ expected_cmd[:actual][:sudo] = sudo
234
+ expected_cmd[:actual][:env] = env
235
+ expected_cmd
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,9 @@
1
+ require 'cli/kit'
2
+
3
+ module CLI
4
+ module Kit
5
+ module Support
6
+ autoload :TestHelper, 'cli/kit/support/test_helper'
7
+ end
8
+ end
9
+ end
@@ -6,7 +6,7 @@ require 'English'
6
6
  module CLI
7
7
  module Kit
8
8
  module System
9
- SUDO_PROMPT = CLI::UI.fmt("{{info:(sudo)}} Password: ")
9
+ SUDO_PROMPT = CLI::UI.fmt('{{info:(sudo)}} Password: ')
10
10
  class << self
11
11
  # Ask for sudo access with a message explaning the need for it
12
12
  # Will make subsequent commands capable of running with sudo for a period of time
@@ -19,7 +19,7 @@ module CLI
19
19
  #
20
20
  def sudo_reason(msg)
21
21
  # See if sudo has a cached password
22
- `env SUDO_ASKPASS=/usr/bin/false sudo -A true`
22
+ %x(env SUDO_ASKPASS=/usr/bin/false sudo -A true)
23
23
  return if $CHILD_STATUS.success?
24
24
  CLI::UI.with_frame_color(:blue) do
25
25
  puts(CLI::UI.fmt("{{i}} #{msg}"))
@@ -90,6 +90,18 @@ module CLI
90
90
  delegate_open3(*a, sudo: sudo, env: env, method: :capture3, **kwargs)
91
91
  end
92
92
 
93
+ def popen2(*a, sudo: false, env: ENV, **kwargs, &block)
94
+ delegate_open3(*a, sudo: sudo, env: env, method: :popen2, **kwargs, &block)
95
+ end
96
+
97
+ def popen2e(*a, sudo: false, env: ENV, **kwargs, &block)
98
+ delegate_open3(*a, sudo: sudo, env: env, method: :popen2e, **kwargs, &block)
99
+ end
100
+
101
+ def popen3(*a, sudo: false, env: ENV, **kwargs, &block)
102
+ delegate_open3(*a, sudo: sudo, env: env, method: :popen3, **kwargs, &block)
103
+ end
104
+
93
105
  # Execute a command in the user's environment
94
106
  # Outputs result of the command without capturing it
95
107
  #
@@ -100,7 +112,7 @@ module CLI
100
112
  # - `**kwargs`: additional keyword arguments to pass to Process.spawn
101
113
  #
102
114
  # #### Returns
103
- # - `status`: boolean success status of the command execution
115
+ # - `status`: The `Process:Status` result for the command execution
104
116
  #
105
117
  # #### Usage
106
118
  # `stat = CLI::Kit::System.system('ls', 'a_folder')`
@@ -116,11 +128,15 @@ module CLI
116
128
  err_w.close
117
129
 
118
130
  handlers = if block_given?
119
- { out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
120
- err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) }, }
131
+ {
132
+ out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
133
+ err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) },
134
+ }
121
135
  else
122
- { out_r => ->(data) { STDOUT.write(data) },
123
- err_r => ->(data) { STDOUT.write(data) }, }
136
+ {
137
+ out_r => ->(data) { STDOUT.write(data) },
138
+ err_r => ->(data) { STDOUT.write(data) },
139
+ }
124
140
  end
125
141
 
126
142
  previous_trailing = Hash.new('')
@@ -130,13 +146,11 @@ module CLI
130
146
 
131
147
  readers, = IO.select(ios)
132
148
  readers.each do |io|
133
- begin
134
- data, trailing = split_partial_characters(io.readpartial(4096))
135
- handlers[io].call(previous_trailing[io] + data)
136
- previous_trailing[io] = trailing
137
- rescue IOError
138
- io.close
139
- end
149
+ data, trailing = split_partial_characters(io.readpartial(4096))
150
+ handlers[io].call(previous_trailing[io] + data)
151
+ previous_trailing[io] = trailing
152
+ rescue IOError
153
+ io.close
140
154
  end
141
155
  end
142
156
 
@@ -163,6 +177,14 @@ module CLI
163
177
  [data.byteslice(0...partial_character_index), data.byteslice(partial_character_index..-1)]
164
178
  end
165
179
 
180
+ def os
181
+ return :mac if /darwin/.match(RUBY_PLATFORM)
182
+ return :linux if /linux/.match(RUBY_PLATFORM)
183
+ return :windows if /mingw32/.match(RUBY_PLATFORM)
184
+
185
+ raise "Could not determine OS from platform #{RUBY_PLATFORM}"
186
+ end
187
+
166
188
  private
167
189
 
168
190
  def apply_sudo(*a, sudo)
@@ -171,11 +193,11 @@ module CLI
171
193
  a
172
194
  end
173
195
 
174
- def delegate_open3(*a, sudo: raise, env: raise, method: raise, **kwargs)
196
+ def delegate_open3(*a, sudo: raise, env: raise, method: raise, **kwargs, &block)
175
197
  a = apply_sudo(*a, sudo)
176
- Open3.send(method, env, *resolve_path(a, env), **kwargs)
198
+ Open3.send(method, env, *resolve_path(a, env), **kwargs, &block)
177
199
  rescue Errno::EINTR
178
- raise(Errno::EINTR, "command interrupted: #{a.join(' ')}")
200
+ raise(Errno::EINTR, "command interrupted: #{a.join(" ")}")
179
201
  end
180
202
 
181
203
  # Ruby resolves the program to execute using its own PATH, but we want it to
@@ -189,14 +211,31 @@ module CLI
189
211
  # See https://github.com/Shopify/dev/pull/625 for more details.
190
212
  def resolve_path(a, env)
191
213
  # If only one argument was provided, make sure it's interpreted by a shell.
192
- return ["true ; " + a[0]] if a.size == 1
193
- return a if a.first.include?('/')
194
- item = env.fetch('PATH', '').split(':').detect do |f|
195
- File.exist?("#{f}/#{a.first}")
214
+ if a.size == 1
215
+ if os == :windows
216
+ return ['break && ' + a[0]]
217
+ else
218
+ return ['true ; ' + a[0]]
219
+ end
196
220
  end
197
- a[0] = "#{item}/#{a.first}" if item
221
+ return a if a.first.include?('/')
222
+
223
+ item = which(a.first, env)
224
+ a[0] = item if item
198
225
  a
199
226
  end
227
+
228
+ def which(cmd, env)
229
+ exts = os == :windows ? env.fetch('PATHEXT').split(';') : ['']
230
+ env.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |path|
231
+ exts.each do |ext|
232
+ exe = File.join(path, "#{cmd}#{ext}")
233
+ return exe if File.executable?(exe) && !File.directory?(exe)
234
+ end
235
+ end
236
+
237
+ nil
238
+ end
200
239
  end
201
240
  end
202
241
  end
@@ -0,0 +1,189 @@
1
+ module CLI
2
+ module Kit
3
+ module Util
4
+ class << self
5
+ def snake_case(camel_case, seperator = '_')
6
+ camel_case.to_s # MyCoolThing::MyAPIModule
7
+ .gsub(/::/, '/') # MyCoolThing/MyAPIModule
8
+ .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1#{seperator}\\2") # MyCoolThing::MyAPI_Module
9
+ .gsub(/([a-z\d])([A-Z])/, "\\1#{seperator}\\2") # My_Cool_Thing::My_API_Module
10
+ .downcase # my_cool_thing/my_api_module
11
+ end
12
+
13
+ def dash_case(camel_case)
14
+ snake_case(camel_case, '-')
15
+ end
16
+
17
+ # The following methods is taken from activesupport
18
+ # All credit for this method goes to the original authors.
19
+ # https://github.com/rails/rails/blob/d66e7835bea9505f7003e5038aa19b6ea95ceea1/activesupport/lib/active_support/core_ext/string/strip.rb
20
+ #
21
+ # Copyright (c) 2005-2018 David Heinemeier Hansson
22
+ #
23
+ # Permission is hereby granted, free of charge, to any person obtaining
24
+ # a copy of this software and associated documentation files (the
25
+ # "Software"), to deal in the Software without restriction, including
26
+ # without limitation the rights to use, copy, modify, merge, publish,
27
+ # distribute, sublicense, and/or sell copies of the Software, and to
28
+ # permit persons to whom the Software is furnished to do so, subject to
29
+ # the following conditions:
30
+ #
31
+ # The above copyright notice and this permission notice shall be
32
+ # included in all copies or substantial portions of the Software.
33
+ #
34
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
35
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
36
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
37
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
38
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
39
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
40
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
41
+ #
42
+ # Strips indentation by removing the amount of leading whitespace in the least indented
43
+ # non-empty line in the whole string
44
+ #
45
+ def strip_heredoc(str)
46
+ str.gsub(/^#{str.scan(/^[ \t]*(?=\S)/).min}/, ''.freeze)
47
+ end
48
+
49
+ # Joins an array with commas and "and", using the Oxford comma.
50
+ def english_join(array)
51
+ return '' if array.nil?
52
+ return array.join(' and ') if array.length < 3
53
+
54
+ "#{array[0..-2].join(", ")}, and #{array[-1]}"
55
+ end
56
+
57
+ # Execute a block within the context of a variable enviroment
58
+ #
59
+ def with_environment(environment, value)
60
+ return yield unless environment
61
+
62
+ old_env = ENV[environment]
63
+ begin
64
+ ENV[environment] = value
65
+ yield
66
+ ensure
67
+ old_env ? ENV[environment] = old_env : ENV.delete(environment)
68
+ end
69
+ end
70
+
71
+ # Converts an integer representing bytes into a human readable format
72
+ #
73
+ def to_filesize(bytes, precision: 2, space: false)
74
+ to_si_scale(bytes, 'B', precision: precision, space: space, factor: 1024)
75
+ end
76
+
77
+ # Converts a number to a human readable format on the SI scale
78
+ #
79
+ def to_si_scale(number, unit = '', factor: 1000, precision: 2, space: false)
80
+ raise ArgumentError, 'factor should only be 1000 or 1024' unless [1000, 1024].include?(factor)
81
+
82
+ small_scale = ['m', 'µ', 'n', 'p', 'f', 'a', 'z', 'y']
83
+ big_scale = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
84
+ negative = number < 0
85
+ number = number.abs.to_f
86
+
87
+ if number == 0 || number.between?(1, factor)
88
+ prefix = ''
89
+ scale = 0
90
+ else
91
+ scale = Math.log(number, factor).floor
92
+ if number < 1
93
+ index = [-scale - 1, small_scale.length].min
94
+ scale = -(index + 1)
95
+ prefix = small_scale[index]
96
+ else
97
+ index = [scale - 1, big_scale.length].min
98
+ scale = index + 1
99
+ prefix = big_scale[index]
100
+ end
101
+ end
102
+
103
+ divider = (factor**scale)
104
+ fnum = (number / divider).round(precision)
105
+
106
+ # Trim useless decimal
107
+ fnum = fnum.to_i if (fnum.to_i.to_f * divider) == number
108
+
109
+ fnum = -fnum if negative
110
+ prefix = ' ' + prefix if space
111
+
112
+ "#{fnum}#{prefix}#{unit}"
113
+ end
114
+
115
+ # Dir.chdir, when invoked in block form, complains when we call chdir
116
+ # again recursively. There's no apparent good reason for this, so we
117
+ # simply implement our own block form of Dir.chdir here.
118
+ def with_dir(dir)
119
+ prev = Dir.pwd
120
+ Dir.chdir(dir)
121
+ yield
122
+ ensure
123
+ Dir.chdir(prev)
124
+ end
125
+
126
+ def with_tmp_dir
127
+ require 'fileutils'
128
+ dir = Dir.mktmpdir
129
+ with_dir(dir) do
130
+ yield(dir)
131
+ end
132
+ ensure
133
+ FileUtils.remove_entry(dir)
134
+ end
135
+
136
+ # Standard way of checking for CI / Tests
137
+ def testing?
138
+ ci? || ENV['TEST']
139
+ end
140
+
141
+ # Set only in IntegrationTest#session; indicates that the process was
142
+ # called by `session.execute` from an IntegrationTest subclass.
143
+ def integration_test_session?
144
+ ENV['INTEGRATION_TEST_SESSION']
145
+ end
146
+
147
+ # Standard way of checking for CI
148
+ def ci?
149
+ ENV['CI']
150
+ end
151
+
152
+ # Must call retry_after on the result in order to execute the block
153
+ #
154
+ # Example usage:
155
+ #
156
+ # CLI::Kit::Util.begin do
157
+ # might_raise_if_costly_prep_not_done()
158
+ # end.retry_after(ExpectedError) do
159
+ # costly_prep()
160
+ # end
161
+ def begin(&block_that_might_raise)
162
+ Retrier.new(block_that_might_raise)
163
+ end
164
+ end
165
+
166
+ class Retrier
167
+ def initialize(block_that_might_raise)
168
+ @block_that_might_raise = block_that_might_raise
169
+ end
170
+
171
+ def retry_after(exception = StandardError, retries: 1, &before_retry)
172
+ @block_that_might_raise.call
173
+ rescue exception => e
174
+ raise if (retries -= 1) < 0
175
+ if before_retry
176
+ if before_retry.arity == 0
177
+ yield
178
+ else
179
+ yield e
180
+ end
181
+ end
182
+ retry
183
+ end
184
+ end
185
+
186
+ private_constant :Retrier
187
+ end
188
+ end
189
+ end
@@ -1,5 +1,5 @@
1
1
  module CLI
2
2
  module Kit
3
- VERSION = "3.0.0"
3
+ VERSION = '4.0.0'
4
4
  end
5
5
  end
data/lib/cli/kit.rb CHANGED
@@ -11,8 +11,11 @@ module CLI
11
11
  autoload :Executor, 'cli/kit/executor'
12
12
  autoload :Ini, 'cli/kit/ini'
13
13
  autoload :Levenshtein, 'cli/kit/levenshtein'
14
+ autoload :Logger, 'cli/kit/logger'
14
15
  autoload :Resolver, 'cli/kit/resolver'
16
+ autoload :Support, 'cli/kit/support'
15
17
  autoload :System, 'cli/kit/system'
18
+ autoload :Util, 'cli/kit/util'
16
19
 
17
20
  EXIT_FAILURE_BUT_NOT_BUG = 30
18
21
  EXIT_BUG = 1
@@ -48,7 +51,7 @@ module CLI
48
51
  # 1. rescue Abort or Bug
49
52
  # 2. Print a contextualized error message
50
53
  # 3. Re-raise AbortSilent or BugSilent respectively.
51
- GenericAbort = Class.new(Exception)
54
+ GenericAbort = Class.new(Exception) # rubocop:disable Lint/InheritException
52
55
  Abort = Class.new(GenericAbort)
53
56
  Bug = Class.new(GenericAbort)
54
57
  BugSilent = Class.new(GenericAbort)