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.
data/gen/lib/gen.rb CHANGED
@@ -8,7 +8,10 @@ module Gen
8
8
 
9
9
  TOOL_NAME = 'cli-kit'
10
10
  ROOT = File.expand_path('../../..', __FILE__)
11
- LOG_FILE = '/tmp/cli-kit.log'
11
+
12
+ TOOL_CONFIG_PATH = File.expand_path(File.join('~', '.config', TOOL_NAME))
13
+ LOG_FILE = File.join(TOOL_CONFIG_PATH, 'logs', 'log.log')
14
+ DEBUG_LOG_FILE = File.join(TOOL_CONFIG_PATH, 'logs', 'debug.log')
12
15
 
13
16
  autoload(:Generator, 'gen/generator')
14
17
 
@@ -17,6 +20,7 @@ module Gen
17
20
 
18
21
  autocall(:Config) { CLI::Kit::Config.new(tool_name: TOOL_NAME) }
19
22
  autocall(:Command) { CLI::Kit::BaseCommand }
23
+ autocall(:Logger) { CLI::Kit::Logger.new(debug_log_file: DEBUG_LOG_FILE) }
20
24
 
21
25
  autocall(:Executor) { CLI::Kit::Executor.new(log_file: LOG_FILE) }
22
26
  autocall(:Resolver) do
data/gen/template/Gemfile CHANGED
@@ -2,3 +2,9 @@ source 'https://rubygems.org'
2
2
 
3
3
  gem 'cli-kit', '~> __cli-kit-version__'
4
4
  gem 'cli-ui', '~> __cli-ui-version__'
5
+
6
+ group :test do
7
+ gem 'mocha', '~> 1.5.0', require: false
8
+ gem 'minitest', '>= 5.0.0', require: false
9
+ gem 'minitest-reporters', require: false
10
+ end
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'bundler/setup'
5
+
6
+ root = File.expand_path('../..', __FILE__)
7
+ TEST_ROOT = root + '/test'
8
+
9
+ $LOAD_PATH.unshift(TEST_ROOT)
10
+
11
+ def test_files
12
+ Dir.glob(TEST_ROOT + "/**/*_test.rb")
13
+ end
14
+
15
+ if ARGV.empty?
16
+ test_files.each { |f| require(f) }
17
+ exit 0
18
+ end
19
+
20
+ # A list of files is presumed to be specified
21
+ ARGV.each do |a|
22
+ require a.sub(%r{^test/}, '')
23
+ end
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env ruby --disable-gems
1
+ #!/usr/bin/env ruby
2
2
 
3
3
  $LOAD_PATH.unshift(File.expand_path("../../vendor/deps/cli-ui/lib", __FILE__))
4
4
  require 'open3'
@@ -39,6 +39,7 @@ deps.each do |dep|
39
39
  bail(
40
40
  "dependency is not checked out: {{yellow:#{dep}}}.\n" \
41
41
  " This repo {{bold_blue:(github.com/shopify/#{dep})}} must be cloned at {{bold_blue:#{path}}} for this script to succeed.\n" \
42
+ " Currently, SOURCE_ROOT is set to {{bold_blue:#{source_path}}}.\n" \
42
43
  " Alternatively, you can set {{bold_blue:SOURCE_ROOT}} to a directory containing {{yellow:#{dep}}}.\n" \
43
44
  " {{bold_blue:SOURCE_ROOT}} defaults to {{bold_blue:../}}."
44
45
  )
@@ -1,5 +1,4 @@
1
1
  require '__app__'
2
- require 'json'
3
2
 
4
3
  module __App__
5
4
  module Commands
@@ -0,0 +1,17 @@
1
+ require 'test_helper'
2
+
3
+ module __App__
4
+ class ExampleTest < MiniTest::Test
5
+ include CLI::Kit::Support::TestHelper
6
+
7
+ def test_example
8
+ CLI::Kit::System.fake("ls -al", stdout: "a\nb", success: true)
9
+
10
+ out, = CLI::Kit::System.capture2('ls', '-al')
11
+ assert_equal %w(a b), out.split("\n")
12
+
13
+ errors = assert_all_commands_run(should_raise: false)
14
+ assert_nil errors, "expected command to run successfully"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ begin
2
+ addpath = lambda do |p|
3
+ path = File.expand_path("../../#{p}", __FILE__)
4
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
5
+ end
6
+ addpath.call("lib")
7
+ end
8
+
9
+ require 'cli/kit'
10
+
11
+ require 'fileutils'
12
+ require 'tmpdir'
13
+ require 'tempfile'
14
+
15
+ require 'rubygems'
16
+ require 'bundler/setup'
17
+
18
+ CLI::UI::StdoutRouter.enable
19
+
20
+ require 'minitest/autorun'
21
+ require "minitest/unit"
22
+ require 'mocha/minitest'
@@ -17,22 +17,28 @@ module CLI
17
17
 
18
18
  def self.call(args, command_name)
19
19
  cmd = new
20
- stats_tags = ["task:#{cmd.class}"]
21
- stats_tags << "subcommand:#{args.first}" if args && args.first && cmd.has_subcommands?
20
+ stats_tags = cmd.stats_tags(args, command_name)
22
21
  begin
23
- statsd_increment("cli.command.invoked", tags: stats_tags)
24
- statsd_time("cli.command.time", tags: stats_tags) do
22
+ statsd_increment('cli.command.invoked', tags: stats_tags)
23
+ statsd_time('cli.command.time', tags: stats_tags) do
25
24
  cmd.call(args, command_name)
26
25
  end
27
- statsd_increment("cli.command.success", tags: stats_tags)
28
- rescue => e
29
- statsd_increment("cli.command.exception", tags: stats_tags + ["exception:#{e.class}"])
26
+ statsd_increment('cli.command.success', tags: stats_tags)
27
+ rescue Exception => e # rubocop:disable Lint/RescueException
28
+ statsd_increment('cli.command.exception', tags: stats_tags + ["exception:#{e.class}"])
30
29
  raise e
31
30
  end
32
31
  end
33
32
 
33
+ def stats_tags(args, command_name)
34
+ tags = ["task:#{self.class}"]
35
+ tags << "command:#{command_name}" if command_name
36
+ tags << "subcommand:#{args.first}" if args&.first && has_subcommands?
37
+ tags
38
+ end
39
+
34
40
  def call(_args, _command_name)
35
- raise NotImplementedError
41
+ raise NotImplementedError, "#{self.class.name} must implement #{__method__}"
36
42
  end
37
43
 
38
44
  def has_subcommands?
@@ -68,7 +68,7 @@ module CLI
68
68
 
69
69
  def resolve_global_command(name)
70
70
  klass = resolve_class(commands.fetch(name, nil))
71
- return nil unless klass
71
+ return nil unless klass&.defined?
72
72
  [klass, name]
73
73
  rescue NameError
74
74
  nil
@@ -18,13 +18,27 @@ module CLI
18
18
  # `name` : the name of the config value you are looking for
19
19
  #
20
20
  # #### Returns
21
- # `value` : the value of the config variable (false if none)
21
+ # `value` : the value of the config variable (nil if none)
22
22
  #
23
23
  # #### Example Usage
24
24
  # `config.get('name.of.config')`
25
25
  #
26
- def get(section, name)
27
- all_configs.dig("[#{section}]", name) || false
26
+ def get(section, name, default: nil)
27
+ all_configs.dig("[#{section}]", name) || default
28
+ end
29
+
30
+ # Coalesce and enforce the value of a config to a boolean
31
+ def get_bool(section, name, default: false)
32
+ case get(section, name, default: default)
33
+ when 'true'
34
+ true
35
+ when 'false'
36
+ false
37
+ when default
38
+ default
39
+ else
40
+ raise CLI::Kit::Abort, "Invalid config: #{section}.#{name} is expected to be true or false"
41
+ end
28
42
  end
29
43
 
30
44
  # Sets the config value in the config file
@@ -43,6 +57,27 @@ module CLI
43
57
  write_config
44
58
  end
45
59
 
60
+ # Unsets a config value in the config file
61
+ #
62
+ # #### Parameters
63
+ # `section` : the section of the config you are deleting
64
+ # `name` : the name of the config you are deleting
65
+ #
66
+ # #### Example Usage
67
+ # `config.unset('section', 'name.of.config')`
68
+ #
69
+ def unset(section, name)
70
+ set(section, name, nil)
71
+ end
72
+
73
+ # Gets the hash for the entire section
74
+ #
75
+ # #### Parameters
76
+ # `section` : the section of the config you are getting
77
+ #
78
+ # #### Example Usage
79
+ # `config.get_section('section')`
80
+ #
46
81
  def get_section(section)
47
82
  (all_configs["[#{section}]"] || {}).dup
48
83
  end
@@ -83,7 +118,7 @@ module CLI
83
118
 
84
119
  def ini
85
120
  @ini ||= CLI::Kit::Ini
86
- .new(file, default_section: "[global]", convert_types: false)
121
+ .new(file, default_section: '[global]', convert_types: false)
87
122
  .tap(&:parse)
88
123
  end
89
124
 
@@ -4,9 +4,10 @@ require 'English'
4
4
  module CLI
5
5
  module Kit
6
6
  class ErrorHandler
7
- def initialize(log_file:, exception_reporter:)
7
+ def initialize(log_file:, exception_reporter:, tool_name: nil)
8
8
  @log_file = log_file
9
9
  @exception_reporter_or_proc = exception_reporter || NullExceptionReporter
10
+ @tool_name = tool_name
10
11
  end
11
12
 
12
13
  module NullExceptionReporter
@@ -20,65 +21,84 @@ module CLI
20
21
  handle_abort(&block)
21
22
  end
22
23
 
23
- private
24
-
25
- def install!
26
- at_exit { handle_final_exception(@exception || $ERROR_INFO) }
24
+ def handle_exception(error)
25
+ if (notify_with = exception_for_submission(error))
26
+ logs = begin
27
+ File.read(@log_file)
28
+ rescue => e
29
+ "(#{e.class}: #{e.message})"
30
+ end
31
+ exception_reporter.report(notify_with, logs)
32
+ end
27
33
  end
28
34
 
29
- def handle_abort
30
- yield
31
- CLI::Kit::EXIT_SUCCESS
32
- rescue CLI::Kit::GenericAbort => e
33
- is_bug = e.is_a?(CLI::Kit::Bug) || e.is_a?(CLI::Kit::BugSilent)
34
- is_silent = e.is_a?(CLI::Kit::AbortSilent) || e.is_a?(CLI::Kit::BugSilent)
35
-
36
- print_error_message(e) unless is_silent
37
- (@exception = e) if is_bug
38
-
39
- CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
40
- rescue Interrupt
41
- $stderr.puts(format_error_message("Interrupt"))
42
- return CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
43
- end
35
+ # maybe we can get rid of this.
36
+ attr_writer :exception
44
37
 
45
- def handle_final_exception(error)
46
- notify_with = nil
38
+ private
47
39
 
40
+ def exception_for_submission(error)
48
41
  case error
49
42
  when nil # normal, non-error termination
43
+ nil
50
44
  when Interrupt # ctrl-c
45
+ nil
51
46
  when CLI::Kit::Abort, CLI::Kit::AbortSilent # Not a bug
47
+ nil
52
48
  when SignalException
53
- skip = %w(SIGTERM SIGHUP SIGINT)
54
- unless skip.include?(error.message)
55
- notify_with = error
56
- end
49
+ skip = ['SIGTERM', 'SIGHUP', 'SIGINT']
50
+ skip.include?(error.message) ? nil : error
57
51
  when SystemExit # "exit N" called
58
52
  case error.status
59
53
  when CLI::Kit::EXIT_SUCCESS # submit nothing if it was `exit 0`
54
+ nil
60
55
  when CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
61
56
  # if it was `exit 30`, translate the exit code to 1, and submit nothing.
62
57
  # 30 is used to signal normal failures that are not indicative of bugs.
63
58
  # However, users should see it presented as 1.
64
- exit 1
59
+ exit(1)
65
60
  else
66
61
  # A weird termination status happened. `error.exception "message"` will maintain backtrace
67
62
  # but allow us to set a message
68
- notify_with = error.exception "abnormal termination status: #{error.status}"
63
+ error.exception("abnormal termination status: #{error.status}")
69
64
  end
70
65
  else
71
- notify_with = error
66
+ error
72
67
  end
68
+ end
73
69
 
74
- if notify_with
75
- logs = begin
76
- File.read(@log_file)
77
- rescue => e
78
- "(#{e.class}: #{e.message})"
79
- end
80
- exception_reporter.report(notify_with, logs)
70
+ def install!
71
+ at_exit { handle_exception(@exception || $ERROR_INFO) }
72
+ end
73
+
74
+ def handle_abort
75
+ yield
76
+ CLI::Kit::EXIT_SUCCESS
77
+ rescue CLI::Kit::GenericAbort => e
78
+ is_bug = e.is_a?(CLI::Kit::Bug) || e.is_a?(CLI::Kit::BugSilent)
79
+ is_silent = e.is_a?(CLI::Kit::AbortSilent) || e.is_a?(CLI::Kit::BugSilent)
80
+
81
+ print_error_message(e) unless is_silent
82
+ (@exception = e) if is_bug
83
+
84
+ CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
85
+ rescue Interrupt
86
+ stderr_puts_message('Interrupt')
87
+ CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
88
+ rescue Errno::ENOSPC
89
+ message = if @tool_name
90
+ "Your disk is full - {{command:#{@tool_name}}} requires free space to operate"
91
+ else
92
+ 'Your disk is full - free space is required to operate'
81
93
  end
94
+ stderr_puts_message(message)
95
+ CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
96
+ end
97
+
98
+ def stderr_puts_message(message)
99
+ $stderr.puts(format_error_message(message))
100
+ rescue Errno::EPIPE
101
+ nil
82
102
  end
83
103
 
84
104
  def exception_reporter
@@ -1,37 +1,66 @@
1
1
  require 'cli/kit'
2
2
  require 'English'
3
+ require 'fileutils'
3
4
 
4
5
  module CLI
5
6
  module Kit
6
7
  class Executor
7
8
  def initialize(log_file:)
9
+ FileUtils.mkpath(File.dirname(log_file))
8
10
  @log_file = log_file
9
11
  end
10
12
 
11
13
  def call(command, command_name, args)
12
- with_traps { with_logging { command.call(args, command_name) } }
14
+ with_traps do
15
+ with_logging do |id|
16
+ command.call(args, command_name)
17
+ rescue => e
18
+ begin
19
+ $stderr.puts "This command ran with ID: #{id}"
20
+ $stderr.puts 'Please include this information in any issues/report along with relevant logs'
21
+ rescue SystemCallError
22
+ # Outputting to stderr is best-effort. Avoid raising another error when outputting debug info so that
23
+ # we can detect and log the original error, which may even be the source of this error.
24
+ nil
25
+ end
26
+ raise e
27
+ end
28
+ end
13
29
  end
14
30
 
15
31
  private
16
32
 
17
33
  def with_logging(&block)
18
34
  return yield unless @log_file
19
- CLI::UI.log_output_to(@log_file, &block)
35
+ CLI::UI.log_output_to(@log_file) do
36
+ CLI::UI::StdoutRouter.with_id(on_streams: [CLI::UI::StdoutRouter.duplicate_output_to]) do |id|
37
+ block.call(id)
38
+ end
39
+ end
20
40
  end
21
41
 
22
- def with_traps
42
+ def with_traps(&block)
23
43
  twrap('QUIT', method(:quit_handler)) do
24
- twrap('INFO', method(:info_handler)) do
25
- yield
26
- end
44
+ twrap('INFO', method(:info_handler), &block)
27
45
  end
28
46
  end
29
47
 
30
48
  def twrap(signal, handler)
31
- prev_handler = trap(signal, handler)
32
- yield
33
- ensure
34
- trap(signal, prev_handler)
49
+ return yield unless Signal.list.key?(signal)
50
+
51
+ begin
52
+ begin
53
+ prev_handler = trap(signal, handler)
54
+ installed = true
55
+ rescue ArgumentError
56
+ # If we couldn't install a signal handler because the signal is
57
+ # reserved, remember not to uninstall it later.
58
+ installed = false
59
+ end
60
+ yield
61
+ ensure
62
+ trap(signal, prev_handler) if installed
63
+ end
35
64
  end
36
65
 
37
66
  def quit_handler(_sig)
data/lib/cli/kit/ini.rb CHANGED
@@ -15,8 +15,12 @@ module CLI
15
15
  class Ini
16
16
  attr_accessor :ini
17
17
 
18
- def initialize(path = nil, default_section: nil, convert_types: true)
19
- @config = File.readlines(path) if path && File.exist?(path)
18
+ def initialize(path = nil, config: nil, default_section: nil, convert_types: true)
19
+ @config = if path && File.exist?(path)
20
+ File.readlines(path)
21
+ elsif config
22
+ config.lines
23
+ end
20
24
  @ini = {}
21
25
  @current_key = nil
22
26
  @default_section = default_section
@@ -39,28 +43,33 @@ module CLI
39
43
 
40
44
  # Otherwise set the values
41
45
  else
42
- k, v = l.split('=').map(&:strip)
46
+ k, v = l.split('=', 2).map(&:strip)
43
47
  set_val(k, v)
44
48
  end
45
49
  end
46
50
  @ini
47
51
  end
48
52
 
53
+ def git_format
54
+ to_ini(@ini, git_format: true).flatten.join("\n")
55
+ end
56
+
49
57
  def to_s
50
58
  to_ini(@ini).flatten.join("\n")
51
59
  end
52
60
 
53
61
  private
54
62
 
55
- def to_ini(h)
63
+ def to_ini(h, git_format: false)
64
+ optional_tab = git_format ? "\t" : ''
56
65
  str = []
57
66
  h.each do |k, v|
58
67
  if section_designator?(k)
59
- str << "" unless str.empty?
68
+ str << '' unless str.empty? || git_format
60
69
  str << k
61
- str << to_ini(v)
70
+ str << to_ini(v, git_format: git_format)
62
71
  else
63
- str << "#{k} = #{v}"
72
+ str << "#{optional_tab}#{k} = #{v}"
64
73
  end
65
74
  end
66
75
  str
@@ -0,0 +1,82 @@
1
+ require 'logger'
2
+ require 'fileutils'
3
+
4
+ module CLI
5
+ module Kit
6
+ class Logger
7
+ MAX_LOG_SIZE = 5 * 1024 * 1000 # 5MB
8
+ MAX_NUM_LOGS = 10
9
+
10
+ # Constructor for CLI::Kit::Logger
11
+ #
12
+ # @param debug_log_file [String] path to the file where debug logs should be stored
13
+ def initialize(debug_log_file:, env_debug_name: 'DEBUG')
14
+ FileUtils.mkpath(File.dirname(debug_log_file))
15
+ @debug_logger = ::Logger.new(debug_log_file, MAX_NUM_LOGS, MAX_LOG_SIZE)
16
+ @env_debug_name = env_debug_name
17
+ end
18
+
19
+ # Functionally equivalent to Logger#info
20
+ # Also logs to the debug file, taking into account CLI::UI::StdoutRouter.current_id
21
+ #
22
+ # @param msg [String] the message to log
23
+ # @param debug [Boolean] determines if the debug logger will receive the log (default true)
24
+ def info(msg, debug: true)
25
+ $stdout.puts CLI::UI.fmt(msg)
26
+ @debug_logger.info(format_debug(msg)) if debug
27
+ end
28
+
29
+ # Functionally equivalent to Logger#warn
30
+ # Also logs to the debug file, taking into account CLI::UI::StdoutRouter.current_id
31
+ #
32
+ # @param msg [String] the message to log
33
+ # @param debug [Boolean] determines if the debug logger will receive the log (default true)
34
+ def warn(msg, debug: true)
35
+ $stdout.puts CLI::UI.fmt("{{yellow:#{msg}}}")
36
+ @debug_logger.warn(format_debug(msg)) if debug
37
+ end
38
+
39
+ # Functionally equivalent to Logger#error
40
+ # Also logs to the debug file, taking into account CLI::UI::StdoutRouter.current_id
41
+ #
42
+ # @param msg [String] the message to log
43
+ # @param debug [Boolean] determines if the debug logger will receive the log (default true)
44
+ def error(msg, debug: true)
45
+ $stderr.puts CLI::UI.fmt("{{red:#{msg}}}")
46
+ @debug_logger.error(format_debug(msg)) if debug
47
+ end
48
+
49
+ # Functionally equivalent to Logger#fatal
50
+ # Also logs to the debug file, taking into account CLI::UI::StdoutRouter.current_id
51
+ #
52
+ # @param msg [String] the message to log
53
+ # @param debug [Boolean] determines if the debug logger will receive the log (default true)
54
+ def fatal(msg, debug: true)
55
+ $stderr.puts CLI::UI.fmt("{{red:{{bold:Fatal:}} #{msg}}}")
56
+ @debug_logger.fatal(format_debug(msg)) if debug
57
+ end
58
+
59
+ # Similar to Logger#debug, however will not output to STDOUT unless DEBUG env var is set
60
+ # Logs to the debug file, taking into account CLI::UI::StdoutRouter.current_id
61
+ #
62
+ # @param msg [String] the message to log
63
+ def debug(msg)
64
+ $stdout.puts CLI::UI.fmt(msg) if debug?
65
+ @debug_logger.debug(format_debug(msg))
66
+ end
67
+
68
+ private
69
+
70
+ def format_debug(msg)
71
+ msg = CLI::UI.fmt(msg)
72
+ return msg unless CLI::UI::StdoutRouter.current_id
73
+ "[#{CLI::UI::StdoutRouter.current_id[:id]}] #{msg}"
74
+ end
75
+
76
+ def debug?
77
+ val = ENV[@env_debug_name]
78
+ val && val != '0' && val != ''
79
+ end
80
+ end
81
+ end
82
+ end
@@ -25,7 +25,7 @@ module CLI
25
25
  private
26
26
 
27
27
  def command_not_found(name)
28
- CLI::UI::Frame.open("Command not found", color: :red, timing: false) do
28
+ CLI::UI::Frame.open('Command not found', color: :red, timing: false) do
29
29
  $stderr.puts(CLI::UI.fmt("{{command:#{@tool_name} #{name}}} was not found"))
30
30
  end
31
31
 
@@ -43,7 +43,7 @@ module CLI
43
43
 
44
44
  # If we have any matches left, tell the user
45
45
  if possible_matches.any?
46
- CLI::UI::Frame.open("{{bold:Did you mean?}}", timing: false, color: :blue) do
46
+ CLI::UI::Frame.open('{{bold:Did you mean?}}', timing: false, color: :blue) do
47
47
  possible_matches.each do |possible_match|
48
48
  $stderr.puts CLI::UI.fmt("{{command:#{@tool_name} #{possible_match}}}")
49
49
  end