tomo 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (135) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +221 -0
  4. data/exe/tomo +4 -0
  5. data/lib/tomo/cli/command.rb +36 -0
  6. data/lib/tomo/cli/common_options.rb +48 -0
  7. data/lib/tomo/cli/completions.rb +70 -0
  8. data/lib/tomo/cli/deploy_options.rb +59 -0
  9. data/lib/tomo/cli/error.rb +16 -0
  10. data/lib/tomo/cli/interrupted_error.rb +9 -0
  11. data/lib/tomo/cli/options.rb +38 -0
  12. data/lib/tomo/cli/parser.rb +92 -0
  13. data/lib/tomo/cli/project_options.rb +47 -0
  14. data/lib/tomo/cli/rules/argument.rb +42 -0
  15. data/lib/tomo/cli/rules/switch.rb +43 -0
  16. data/lib/tomo/cli/rules/value_switch.rb +58 -0
  17. data/lib/tomo/cli/rules.rb +98 -0
  18. data/lib/tomo/cli/rules_evaluator.rb +71 -0
  19. data/lib/tomo/cli/state.rb +29 -0
  20. data/lib/tomo/cli/usage.rb +42 -0
  21. data/lib/tomo/cli.rb +75 -0
  22. data/lib/tomo/colors.rb +46 -0
  23. data/lib/tomo/commands/completion_script.rb +46 -0
  24. data/lib/tomo/commands/default.rb +72 -0
  25. data/lib/tomo/commands/deploy.rb +67 -0
  26. data/lib/tomo/commands/help.rb +9 -0
  27. data/lib/tomo/commands/init.rb +92 -0
  28. data/lib/tomo/commands/run.rb +76 -0
  29. data/lib/tomo/commands/setup.rb +54 -0
  30. data/lib/tomo/commands/tasks.rb +32 -0
  31. data/lib/tomo/commands/version.rb +23 -0
  32. data/lib/tomo/commands.rb +13 -0
  33. data/lib/tomo/configuration/dsl/batch_block.rb +17 -0
  34. data/lib/tomo/configuration/dsl/config_file.rb +39 -0
  35. data/lib/tomo/configuration/dsl/environment_block.rb +13 -0
  36. data/lib/tomo/configuration/dsl/error_formatter.rb +75 -0
  37. data/lib/tomo/configuration/dsl/hosts_and_settings.rb +24 -0
  38. data/lib/tomo/configuration/dsl/tasks_block.rb +24 -0
  39. data/lib/tomo/configuration/dsl.rb +12 -0
  40. data/lib/tomo/configuration/environment.rb +12 -0
  41. data/lib/tomo/configuration/glob.rb +26 -0
  42. data/lib/tomo/configuration/plugin_file_not_found_error.rb +14 -0
  43. data/lib/tomo/configuration/plugin_resolver.rb +63 -0
  44. data/lib/tomo/configuration/plugins_registry/file_resolver.rb +43 -0
  45. data/lib/tomo/configuration/plugins_registry/gem_resolver.rb +63 -0
  46. data/lib/tomo/configuration/plugins_registry.rb +67 -0
  47. data/lib/tomo/configuration/project_not_found_error.rb +28 -0
  48. data/lib/tomo/configuration/role_based_task_filter.rb +42 -0
  49. data/lib/tomo/configuration/unknown_environment_error.rb +46 -0
  50. data/lib/tomo/configuration/unknown_plugin_error.rb +28 -0
  51. data/lib/tomo/configuration/unspecified_environment_error.rb +28 -0
  52. data/lib/tomo/configuration.rb +124 -0
  53. data/lib/tomo/console/key_reader.rb +51 -0
  54. data/lib/tomo/console/menu.rb +109 -0
  55. data/lib/tomo/console.rb +33 -0
  56. data/lib/tomo/error/suggestions.rb +44 -0
  57. data/lib/tomo/error.rb +22 -0
  58. data/lib/tomo/host.rb +57 -0
  59. data/lib/tomo/logger/tagged_io.rb +38 -0
  60. data/lib/tomo/logger.rb +70 -0
  61. data/lib/tomo/path.rb +19 -0
  62. data/lib/tomo/paths.rb +36 -0
  63. data/lib/tomo/plugin/bundler/helpers.rb +14 -0
  64. data/lib/tomo/plugin/bundler/tasks.rb +57 -0
  65. data/lib/tomo/plugin/bundler.rb +17 -0
  66. data/lib/tomo/plugin/core/helpers.rb +65 -0
  67. data/lib/tomo/plugin/core/tasks.rb +138 -0
  68. data/lib/tomo/plugin/core.rb +31 -0
  69. data/lib/tomo/plugin/env/tasks.rb +113 -0
  70. data/lib/tomo/plugin/env.rb +13 -0
  71. data/lib/tomo/plugin/git/helpers.rb +11 -0
  72. data/lib/tomo/plugin/git/tasks.rb +78 -0
  73. data/lib/tomo/plugin/git.rb +19 -0
  74. data/lib/tomo/plugin/nvm/tasks.rb +61 -0
  75. data/lib/tomo/plugin/nvm.rb +14 -0
  76. data/lib/tomo/plugin/puma/tasks.rb +38 -0
  77. data/lib/tomo/plugin/puma.rb +12 -0
  78. data/lib/tomo/plugin/rails/helpers.rb +20 -0
  79. data/lib/tomo/plugin/rails/tasks.rb +79 -0
  80. data/lib/tomo/plugin/rails.rb +11 -0
  81. data/lib/tomo/plugin/rbenv/tasks.rb +55 -0
  82. data/lib/tomo/plugin/rbenv.rb +12 -0
  83. data/lib/tomo/plugin/testing.rb +16 -0
  84. data/lib/tomo/plugin.rb +4 -0
  85. data/lib/tomo/plugin_dsl.rb +23 -0
  86. data/lib/tomo/remote.rb +55 -0
  87. data/lib/tomo/result.rb +28 -0
  88. data/lib/tomo/runtime/concurrent_ruby_load_error.rb +26 -0
  89. data/lib/tomo/runtime/concurrent_ruby_thread_pool.rb +50 -0
  90. data/lib/tomo/runtime/context.rb +21 -0
  91. data/lib/tomo/runtime/current.rb +41 -0
  92. data/lib/tomo/runtime/execution_plan.rb +107 -0
  93. data/lib/tomo/runtime/host_execution_step.rb +49 -0
  94. data/lib/tomo/runtime/inline_thread_pool.rb +27 -0
  95. data/lib/tomo/runtime/privileged_task.rb +6 -0
  96. data/lib/tomo/runtime/settings_interpolation.rb +55 -0
  97. data/lib/tomo/runtime/settings_required_error.rb +33 -0
  98. data/lib/tomo/runtime/task_aborted_error.rb +15 -0
  99. data/lib/tomo/runtime/task_runner.rb +56 -0
  100. data/lib/tomo/runtime/unknown_task_error.rb +23 -0
  101. data/lib/tomo/runtime.rb +82 -0
  102. data/lib/tomo/script.rb +44 -0
  103. data/lib/tomo/shell_builder.rb +108 -0
  104. data/lib/tomo/ssh/child_process.rb +64 -0
  105. data/lib/tomo/ssh/connection.rb +82 -0
  106. data/lib/tomo/ssh/connection_error.rb +16 -0
  107. data/lib/tomo/ssh/connection_validator.rb +87 -0
  108. data/lib/tomo/ssh/error.rb +11 -0
  109. data/lib/tomo/ssh/executable_error.rb +21 -0
  110. data/lib/tomo/ssh/options.rb +67 -0
  111. data/lib/tomo/ssh/permission_error.rb +18 -0
  112. data/lib/tomo/ssh/script_error.rb +23 -0
  113. data/lib/tomo/ssh/unknown_error.rb +13 -0
  114. data/lib/tomo/ssh/unsupported_version_error.rb +15 -0
  115. data/lib/tomo/ssh.rb +36 -0
  116. data/lib/tomo/task_library.rb +51 -0
  117. data/lib/tomo/templates/config.rb.erb +66 -0
  118. data/lib/tomo/testing/Dockerfile +10 -0
  119. data/lib/tomo/testing/connection.rb +34 -0
  120. data/lib/tomo/testing/docker_image.rb +115 -0
  121. data/lib/tomo/testing/docker_plugin_tester.rb +39 -0
  122. data/lib/tomo/testing/host_extensions.rb +27 -0
  123. data/lib/tomo/testing/local.rb +75 -0
  124. data/lib/tomo/testing/mock_plugin_tester.rb +26 -0
  125. data/lib/tomo/testing/mocked_exec_error.rb +6 -0
  126. data/lib/tomo/testing/plugin_tester.rb +49 -0
  127. data/lib/tomo/testing/remote_extensions.rb +10 -0
  128. data/lib/tomo/testing/ssh_extensions.rb +13 -0
  129. data/lib/tomo/testing/tomo_test_ed25519 +7 -0
  130. data/lib/tomo/testing/tomo_test_ed25519.pub +1 -0
  131. data/lib/tomo/testing/ubuntu_setup.sh +33 -0
  132. data/lib/tomo/testing.rb +39 -0
  133. data/lib/tomo/version.rb +3 -0
  134. data/lib/tomo.rb +45 -0
  135. metadata +308 -0
@@ -0,0 +1,63 @@
1
+ module Tomo
2
+ class Configuration
3
+ class PluginsRegistry::GemResolver
4
+ PLUGIN_PREFIX = "tomo/plugin".freeze
5
+ private_constant :PLUGIN_PREFIX
6
+
7
+ def self.resolve(name)
8
+ new(name).plugin_module
9
+ end
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ end
14
+
15
+ def plugin_module
16
+ plugin_path = [PLUGIN_PREFIX, name.tr("-", "/")].join("/")
17
+ require plugin_path
18
+
19
+ plugin = constantize(plugin_path)
20
+ assert_compatible_api(plugin)
21
+
22
+ plugin
23
+ rescue LoadError => e
24
+ raise unless e.message.match?(/\s#{Regexp.quote(plugin_path)}$/)
25
+
26
+ raise_unknown_plugin_error(e)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :name
32
+
33
+ def assert_compatible_api(plugin)
34
+ return if plugin.is_a?(::Tomo::PluginDSL)
35
+
36
+ raise "#{plugin} does not extend Tomo::PluginDSL"
37
+ end
38
+
39
+ def constantize(path)
40
+ parts = path.split("/")
41
+ parts.reduce(Object) do |parent, part|
42
+ child = part.gsub(/^[a-z]|_[a-z]/) { |str| str.chars.last.upcase }
43
+ parent.const_get(child, false)
44
+ end
45
+ end
46
+
47
+ def raise_unknown_plugin_error(error)
48
+ UnknownPluginError.raise_with(
49
+ error.message,
50
+ name: name,
51
+ gem_name: "#{PLUGIN_PREFIX}/#{name}".tr("/", "-"),
52
+ known_plugins: scan_for_plugins
53
+ )
54
+ end
55
+
56
+ def scan_for_plugins
57
+ Gem.find_latest_files("#{PLUGIN_PREFIX}/*.rb").map do |file|
58
+ file[%r{#{PLUGIN_PREFIX}/(.+).rb$}, 1].tr("/", "-")
59
+ end.uniq.sort
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,67 @@
1
+ module Tomo
2
+ class Configuration
3
+ class PluginsRegistry
4
+ autoload :FileResolver,
5
+ "tomo/configuration/plugins_registry/file_resolver"
6
+ autoload :GemResolver, "tomo/configuration/plugins_registry/gem_resolver"
7
+
8
+ attr_reader :helper_modules, :settings
9
+
10
+ def initialize
11
+ @settings = {}
12
+ @helper_modules = []
13
+ @namespaced_classes = []
14
+ end
15
+
16
+ def task_names
17
+ bind_tasks(nil).keys
18
+ end
19
+
20
+ def bind_tasks(context)
21
+ namespaced_classes.each_with_object({}) do |(namespace, klass), result|
22
+ library = klass.new(context)
23
+
24
+ klass.public_instance_methods(false).each do |name|
25
+ qualified = [namespace, name].compact.join(":")
26
+ result[qualified] = library.public_method(name)
27
+ end
28
+ end
29
+ end
30
+
31
+ def load_plugin_by_name(name)
32
+ plugin = GemResolver.resolve(name)
33
+ load_plugin(name, plugin)
34
+ end
35
+
36
+ def load_plugin_from_path(path)
37
+ name = File.basename(path).sub(/\.rb$/i, "")
38
+ plugin = FileResolver.resolve(path)
39
+ load_plugin(name, plugin)
40
+ end
41
+
42
+ def load_plugin(namespace, plugin_class)
43
+ Tomo.logger.debug("Loading plugin #{plugin_class}")
44
+
45
+ helper_modules.push(*plugin_class.helper_modules)
46
+ settings.merge!(plugin_class.default_settings) { |_, exist, _| exist }
47
+ register_task_libraries(namespace, *plugin_class.tasks_classes)
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :namespaced_classes
53
+
54
+ def register_task_libraries(namespace, *library_classes)
55
+ library_classes.each { |cls| register_task_library(namespace, cls) }
56
+ end
57
+
58
+ def register_task_library(namespace, library_class)
59
+ Tomo.logger.debug(
60
+ "Registering task library #{library_class}"\
61
+ " (#{namespace.inspect} namespace)"
62
+ )
63
+ namespaced_classes << [namespace, library_class]
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,28 @@
1
+ module Tomo
2
+ class Configuration
3
+ class ProjectNotFoundError < Tomo::Error
4
+ attr_accessor :path
5
+
6
+ def to_console
7
+ path == DEFAULT_CONFIG_PATH ? default_message : custom_message
8
+ end
9
+
10
+ private
11
+
12
+ def default_message
13
+ <<~ERROR
14
+ A #{yellow(path)} configuration file is required to run this command.
15
+ Are you in the right directory?
16
+
17
+ To create a new #{yellow(path)} file, run #{blue('tomo init')}.
18
+ ERROR
19
+ end
20
+
21
+ def custom_message
22
+ <<~ERROR
23
+ #{yellow(path)} does not exist.
24
+ ERROR
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ module Tomo
2
+ class Configuration
3
+ class RoleBasedTaskFilter
4
+ def initialize
5
+ @globs = {}
6
+ end
7
+
8
+ def freeze
9
+ globs.freeze
10
+ super
11
+ end
12
+
13
+ def add_role(name, task_specs)
14
+ name = name.to_s
15
+ task_globs = Array(task_specs).flatten.map { |spec| Glob.new(spec) }
16
+ task_globs.each do |task_glob|
17
+ (globs[task_glob] ||= []) << name
18
+ end
19
+ end
20
+
21
+ def filter(tasks, host:)
22
+ roles = host.roles
23
+ roles = roles.empty? ? [""] : roles
24
+ tasks.select do |task|
25
+ roles.any? { |role| match?(task, role) }
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :globs
32
+
33
+ def match?(task, role)
34
+ task_globs = globs.keys.select { |glob| glob.match?(task) }
35
+ return true if task_globs.empty?
36
+
37
+ roles = globs.values_at(*task_globs).flatten
38
+ roles.include?(role)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,46 @@
1
+ module Tomo
2
+ class Configuration
3
+ class UnknownEnvironmentError < Tomo::Error
4
+ attr_accessor :name, :known_environments
5
+
6
+ def to_console
7
+ known_environments.empty? ? no_envs : wrong_envs
8
+ end
9
+
10
+ private
11
+
12
+ def no_envs
13
+ <<~ERROR
14
+ This project does not have distinct environments.
15
+
16
+ Run tomo again without the #{yellow("-e #{name}")} option.
17
+ ERROR
18
+ end
19
+
20
+ def wrong_envs
21
+ error = <<~ERROR
22
+ #{yellow(name)} is not a recognized environment for this project.
23
+ ERROR
24
+
25
+ if suggestions.any?
26
+ error << suggestions.to_console
27
+ else
28
+ envs = known_environments.map { |env| blue(" #{env}") }
29
+ error << <<~ENVS
30
+
31
+ The following environments are available:
32
+
33
+ #{envs.join("\n")}
34
+ ENVS
35
+ end
36
+ end
37
+
38
+ def suggestions
39
+ @_suggestions ||= Error::Suggestions.new(
40
+ dictionary: known_environments,
41
+ word: name
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ module Tomo
2
+ class Configuration
3
+ class UnknownPluginError < Tomo::Error
4
+ attr_accessor :name, :known_plugins, :gem_name
5
+
6
+ def to_console
7
+ error = <<~ERROR
8
+ #{yellow(name)} is not a recognized plugin.
9
+ ERROR
10
+
11
+ sugg = Error::Suggestions.new(dictionary: known_plugins, word: name)
12
+ error << sugg.to_console if sugg.any?
13
+
14
+ error << gem_suggestion
15
+ end
16
+
17
+ private
18
+
19
+ def gem_suggestion
20
+ if Tomo.bundled?
21
+ "\nYou may need to add #{yellow(gem_name)} to your Gemfile."
22
+ else
23
+ "\nYou may need to install the #{yellow(gem_name)} gem."
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ module Tomo
2
+ class Configuration
3
+ class UnspecifiedEnvironmentError < Tomo::Error
4
+ attr_accessor :environments
5
+
6
+ def to_console
7
+ <<~ERROR
8
+ No environment specified.
9
+
10
+ This is a multi-environment project. To run a remote task you must specify
11
+ which environment to use by including the #{blue('-e')} option.
12
+
13
+ Run tomo again with one of these options to specify the environment:
14
+
15
+ #{env_options}
16
+ ERROR
17
+ end
18
+
19
+ private
20
+
21
+ def env_options
22
+ environments.each_with_object([]) do |env, options|
23
+ options << blue(" -e #{env}")
24
+ end.join("\n")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,124 @@
1
+ module Tomo
2
+ class Configuration
3
+ autoload :DSL, "tomo/configuration/dsl"
4
+ autoload :Environment, "tomo/configuration/environment"
5
+ autoload :Glob, "tomo/configuration/glob"
6
+ autoload :PluginFileNotFoundError,
7
+ "tomo/configuration/plugin_file_not_found_error"
8
+ autoload :PluginsRegistry, "tomo/configuration/plugins_registry"
9
+ autoload :ProjectNotFoundError, "tomo/configuration/project_not_found_error"
10
+ autoload :RoleBasedTaskFilter, "tomo/configuration/role_based_task_filter"
11
+ autoload :UnknownEnvironmentError,
12
+ "tomo/configuration/unknown_environment_error"
13
+ autoload :UnknownPluginError, "tomo/configuration/unknown_plugin_error"
14
+ autoload :UnspecifiedEnvironmentError,
15
+ "tomo/configuration/unspecified_environment_error"
16
+
17
+ def self.from_config_rb(path=DEFAULT_CONFIG_PATH)
18
+ ProjectNotFoundError.raise_with(path: path) unless File.file?(path)
19
+ Tomo.logger.debug("Loading configuration from #{path.inspect}")
20
+ config_rb = IO.read(path)
21
+
22
+ new.tap do |config|
23
+ config.working_dir = File.dirname(path)
24
+ DSL::ConfigFile.new(config).instance_eval(config_rb, path.to_s, 1)
25
+ end
26
+ rescue StandardError => e
27
+ raise DSL::ErrorFormatter.decorate(e, path, config_rb&.lines)
28
+ end
29
+
30
+ attr_accessor :environments, :deploy_tasks, :setup_tasks, :hosts, :plugins,
31
+ :settings, :task_filter, :working_dir
32
+
33
+ def initialize
34
+ @environments = {}
35
+ @hosts = []
36
+ @plugins = []
37
+ @settings = {}
38
+ @deploy_tasks = []
39
+ @setup_tasks = []
40
+ @task_filter = RoleBasedTaskFilter.new
41
+ end
42
+
43
+ def for_environment(environment)
44
+ validate_environment!(environment)
45
+
46
+ dup.tap do |copy|
47
+ copy.environments = {}
48
+ copy.hosts = hosts_for(environment)
49
+ copy.settings = settings_with_env_overrides(environment)
50
+ end
51
+ end
52
+
53
+ def build_runtime
54
+ validate_environment!(nil)
55
+ plugins_registry = register_plugins
56
+
57
+ Runtime.new(
58
+ deploy_tasks: deploy_tasks,
59
+ setup_tasks: setup_tasks,
60
+ plugins_registry: plugins_registry,
61
+ hosts: add_log_prefixes(hosts),
62
+ settings: settings,
63
+ task_filter: task_filter
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def validate_environment!(name)
70
+ if name.nil?
71
+ raise_no_environment_specified unless environments.empty?
72
+ else
73
+ raise_unknown_environment(name) unless environments.key?(name)
74
+ end
75
+ end
76
+
77
+ def hosts_for(environ)
78
+ return hosts.dup unless environments.key?(environ)
79
+
80
+ environments[environ].hosts
81
+ end
82
+
83
+ def add_log_prefixes(host_arr)
84
+ return host_arr if host_arr.length == 1
85
+ return host_arr unless host_arr.all? { |h| h.log_prefix.nil? }
86
+
87
+ width = host_arr.length.to_s.length
88
+ host_arr.map.with_index do |host, i|
89
+ host.with_log_prefix((i + 1).to_s.rjust(width, "0"))
90
+ end
91
+ end
92
+
93
+ def register_plugins
94
+ plugins_registry = PluginsRegistry.new
95
+
96
+ (["core"] + plugins.uniq).each do |plug|
97
+ if %w[. /].include?(plug[0])
98
+ plug = File.expand_path(plug, working_dir) unless working_dir.nil?
99
+ plugins_registry.load_plugin_from_path(plug)
100
+ else
101
+ plugins_registry.load_plugin_by_name(plug)
102
+ end
103
+ end
104
+
105
+ plugins_registry
106
+ end
107
+
108
+ def settings_with_env_overrides(environ)
109
+ return settings.dup unless environments.key?(environ)
110
+
111
+ settings.merge(environments[environ].settings)
112
+ end
113
+
114
+ def raise_no_environment_specified
115
+ UnspecifiedEnvironmentError.raise_with(environments: environments.keys)
116
+ end
117
+
118
+ def raise_unknown_environment(environ)
119
+ UnknownEnvironmentError.raise_with(
120
+ name: environ, known_environments: environments.keys
121
+ )
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,51 @@
1
+ require "forwardable"
2
+ require "io/console"
3
+ require "time"
4
+
5
+ module Tomo
6
+ module Console
7
+ class KeyReader
8
+ extend Forwardable
9
+
10
+ def initialize(input=$stdin)
11
+ @buffer = ""
12
+ @input = input
13
+ end
14
+
15
+ def next
16
+ pressed = raw { getc }
17
+ pressed << read_chars_nonblock if pressed == "\e"
18
+ raise Interrupt if pressed == ?\C-c
19
+
20
+ clear if !pressed.match?(/\A\w+\z/) || seconds_since_last_press > 0.75
21
+ buffer << pressed
22
+ end
23
+
24
+ private
25
+
26
+ def_delegators :@input, :getc, :raw, :read_nonblock
27
+ def_delegators :buffer, :clear
28
+
29
+ attr_reader :buffer
30
+
31
+ def seconds_since_last_press
32
+ start = @last_press_at || 0
33
+ @last_press_at = Time.now.to_f
34
+ @last_press_at - start
35
+ end
36
+
37
+ def read_chars_nonblock
38
+ chars = ""
39
+ loop do
40
+ next_char = raw { read_nonblock(1) }
41
+ break if next_char.nil?
42
+
43
+ chars << next_char
44
+ end
45
+ chars
46
+ rescue IO::WaitReadable
47
+ chars
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,109 @@
1
+ require "forwardable"
2
+ require "io/console"
3
+
4
+ module Tomo
5
+ module Console
6
+ class Menu
7
+ ARROW_UP = "\e[A".freeze
8
+ ARROW_DOWN = "\e[B".freeze
9
+ RETURN = "\r".freeze
10
+ ENTER = "\n".freeze
11
+
12
+ extend Forwardable
13
+ include Colors
14
+
15
+ def initialize(question, options, key_reader: KeyReader.new,
16
+ output: $stdout)
17
+ @question = question
18
+ @options = options
19
+ @position = 0
20
+ @key_reader = key_reader
21
+ @output = output
22
+ end
23
+
24
+ def selected_option
25
+ options[position]
26
+ end
27
+
28
+ def prompt_for_selection
29
+ render_loop do |key|
30
+ case key
31
+ when RETURN, ENTER then break
32
+ when ARROW_UP then move(-1)
33
+ when ARROW_DOWN then move(1)
34
+ else self.position = find_match_index(key)
35
+ end
36
+ end
37
+ print "#{yellow(question)} #{blue(selected_option)}\n"
38
+ selected_option
39
+ end
40
+
41
+ private
42
+
43
+ def_delegators :@output, :flush, :print
44
+
45
+ attr_reader :key_reader, :question, :options
46
+ attr_accessor :position
47
+
48
+ def render_loop
49
+ loop do
50
+ render
51
+ key = key_reader.next
52
+ clear
53
+ yield key
54
+ end
55
+ end
56
+
57
+ def move(amount)
58
+ new_position = position + amount
59
+ return if new_position.negative? || new_position >= options.length
60
+
61
+ self.position = new_position
62
+ end
63
+
64
+ def find_match_index(string)
65
+ exact = options.find_index do |opt|
66
+ opt.match?(/^#{Regexp.quote(string)}/i)
67
+ end
68
+ substring = options.find_index do |opt|
69
+ opt.match?(/#{Regexp.quote(string)}/i)
70
+ end
71
+
72
+ exact || substring || position
73
+ end
74
+
75
+ def render
76
+ print "#{yellow(question)} #{gray(hint)}\n"
77
+ visible_options.each do |option, selected|
78
+ print selected ? blue("❯ #{option}\n") : " #{option}\n"
79
+ end
80
+ flush
81
+ end
82
+
83
+ def hint
84
+ return unless options.length > visible_options.length
85
+
86
+ "(press up/down to reveal more options)"
87
+ end
88
+
89
+ def clear
90
+ height = 2 + visible_options.length
91
+ esc_codes = Array.new(height) { "\e[2K\e[1G" }.join("\e[1A")
92
+ print esc_codes
93
+ end
94
+
95
+ def visible_options
96
+ options.map.with_index { |opt, i| [opt, i == position] }[visible_range]
97
+ end
98
+
99
+ def visible_range
100
+ max_visible = [8, options.length].min
101
+
102
+ offset = [0, position - max_visible / 2].max
103
+ adjusted_offset = [offset, options.length - max_visible].min
104
+
105
+ adjusted_offset...(adjusted_offset + max_visible)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,33 @@
1
+ require "io/console"
2
+
3
+ module Tomo
4
+ module Console
5
+ autoload :KeyReader, "tomo/console/key_reader"
6
+ autoload :Menu, "tomo/console/menu"
7
+
8
+ class << self
9
+ def interactive?(input=$stdin)
10
+ input.respond_to?(:raw) && input.respond_to?(:tty?) && input.tty?
11
+ end
12
+
13
+ def prompt(question)
14
+ assert_interactive
15
+
16
+ print question
17
+ $stdin.gets.chomp
18
+ end
19
+
20
+ def menu(question, choices:)
21
+ assert_interactive
22
+
23
+ Menu.new(question, choices).prompt_for_selection
24
+ end
25
+
26
+ private
27
+
28
+ def assert_interactive
29
+ raise "An interactive console is required" unless interactive?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ module Tomo
2
+ class Error
3
+ class Suggestions
4
+ def initialize(dictionary:, word:)
5
+ @dictionary = dictionary
6
+ @word = word
7
+ end
8
+
9
+ def any?
10
+ to_a.any?
11
+ end
12
+
13
+ def to_a
14
+ @_suggestions ||= begin
15
+ if defined?(DidYouMean::SpellChecker)
16
+ checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
17
+ suggestions = checker.correct(word)
18
+ suggestions || []
19
+ else
20
+ []
21
+ end
22
+ end
23
+ end
24
+
25
+ def to_console
26
+ return unless any?
27
+
28
+ sentence = to_sentence(to_a.map { |word| Colors.blue(word) })
29
+ "\nDid you mean #{sentence}?\n"
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :dictionary, :word
35
+
36
+ def to_sentence(words)
37
+ return words.first if words.length == 1
38
+ return words.join(" or ") if words.length == 2
39
+
40
+ words[0...-1].join(", ") + ", or " + words.last
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/tomo/error.rb ADDED
@@ -0,0 +1,22 @@
1
+ module Tomo
2
+ class Error < StandardError
3
+ autoload :Suggestions, "tomo/error/suggestions"
4
+
5
+ include Colors
6
+
7
+ def self.raise_with(message=nil, attributes)
8
+ err = new(message)
9
+ attributes.each { |attr, value| err.public_send("#{attr}=", value) }
10
+ raise err
11
+ end
12
+
13
+ private
14
+
15
+ def debug_suggestion
16
+ return if Tomo.debug?
17
+
18
+ "For more troubleshooting info, run tomo again using the "\
19
+ "#{blue('--debug')} option."
20
+ end
21
+ end
22
+ end