tomo 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +221 -0
- data/exe/tomo +4 -0
- data/lib/tomo/cli/command.rb +36 -0
- data/lib/tomo/cli/common_options.rb +48 -0
- data/lib/tomo/cli/completions.rb +70 -0
- data/lib/tomo/cli/deploy_options.rb +59 -0
- data/lib/tomo/cli/error.rb +16 -0
- data/lib/tomo/cli/interrupted_error.rb +9 -0
- data/lib/tomo/cli/options.rb +38 -0
- data/lib/tomo/cli/parser.rb +92 -0
- data/lib/tomo/cli/project_options.rb +47 -0
- data/lib/tomo/cli/rules/argument.rb +42 -0
- data/lib/tomo/cli/rules/switch.rb +43 -0
- data/lib/tomo/cli/rules/value_switch.rb +58 -0
- data/lib/tomo/cli/rules.rb +98 -0
- data/lib/tomo/cli/rules_evaluator.rb +71 -0
- data/lib/tomo/cli/state.rb +29 -0
- data/lib/tomo/cli/usage.rb +42 -0
- data/lib/tomo/cli.rb +75 -0
- data/lib/tomo/colors.rb +46 -0
- data/lib/tomo/commands/completion_script.rb +46 -0
- data/lib/tomo/commands/default.rb +72 -0
- data/lib/tomo/commands/deploy.rb +67 -0
- data/lib/tomo/commands/help.rb +9 -0
- data/lib/tomo/commands/init.rb +92 -0
- data/lib/tomo/commands/run.rb +76 -0
- data/lib/tomo/commands/setup.rb +54 -0
- data/lib/tomo/commands/tasks.rb +32 -0
- data/lib/tomo/commands/version.rb +23 -0
- data/lib/tomo/commands.rb +13 -0
- data/lib/tomo/configuration/dsl/batch_block.rb +17 -0
- data/lib/tomo/configuration/dsl/config_file.rb +39 -0
- data/lib/tomo/configuration/dsl/environment_block.rb +13 -0
- data/lib/tomo/configuration/dsl/error_formatter.rb +75 -0
- data/lib/tomo/configuration/dsl/hosts_and_settings.rb +24 -0
- data/lib/tomo/configuration/dsl/tasks_block.rb +24 -0
- data/lib/tomo/configuration/dsl.rb +12 -0
- data/lib/tomo/configuration/environment.rb +12 -0
- data/lib/tomo/configuration/glob.rb +26 -0
- data/lib/tomo/configuration/plugin_file_not_found_error.rb +14 -0
- data/lib/tomo/configuration/plugin_resolver.rb +63 -0
- data/lib/tomo/configuration/plugins_registry/file_resolver.rb +43 -0
- data/lib/tomo/configuration/plugins_registry/gem_resolver.rb +63 -0
- data/lib/tomo/configuration/plugins_registry.rb +67 -0
- data/lib/tomo/configuration/project_not_found_error.rb +28 -0
- data/lib/tomo/configuration/role_based_task_filter.rb +42 -0
- data/lib/tomo/configuration/unknown_environment_error.rb +46 -0
- data/lib/tomo/configuration/unknown_plugin_error.rb +28 -0
- data/lib/tomo/configuration/unspecified_environment_error.rb +28 -0
- data/lib/tomo/configuration.rb +124 -0
- data/lib/tomo/console/key_reader.rb +51 -0
- data/lib/tomo/console/menu.rb +109 -0
- data/lib/tomo/console.rb +33 -0
- data/lib/tomo/error/suggestions.rb +44 -0
- data/lib/tomo/error.rb +22 -0
- data/lib/tomo/host.rb +57 -0
- data/lib/tomo/logger/tagged_io.rb +38 -0
- data/lib/tomo/logger.rb +70 -0
- data/lib/tomo/path.rb +19 -0
- data/lib/tomo/paths.rb +36 -0
- data/lib/tomo/plugin/bundler/helpers.rb +14 -0
- data/lib/tomo/plugin/bundler/tasks.rb +57 -0
- data/lib/tomo/plugin/bundler.rb +17 -0
- data/lib/tomo/plugin/core/helpers.rb +65 -0
- data/lib/tomo/plugin/core/tasks.rb +138 -0
- data/lib/tomo/plugin/core.rb +31 -0
- data/lib/tomo/plugin/env/tasks.rb +113 -0
- data/lib/tomo/plugin/env.rb +13 -0
- data/lib/tomo/plugin/git/helpers.rb +11 -0
- data/lib/tomo/plugin/git/tasks.rb +78 -0
- data/lib/tomo/plugin/git.rb +19 -0
- data/lib/tomo/plugin/nvm/tasks.rb +61 -0
- data/lib/tomo/plugin/nvm.rb +14 -0
- data/lib/tomo/plugin/puma/tasks.rb +38 -0
- data/lib/tomo/plugin/puma.rb +12 -0
- data/lib/tomo/plugin/rails/helpers.rb +20 -0
- data/lib/tomo/plugin/rails/tasks.rb +79 -0
- data/lib/tomo/plugin/rails.rb +11 -0
- data/lib/tomo/plugin/rbenv/tasks.rb +55 -0
- data/lib/tomo/plugin/rbenv.rb +12 -0
- data/lib/tomo/plugin/testing.rb +16 -0
- data/lib/tomo/plugin.rb +4 -0
- data/lib/tomo/plugin_dsl.rb +23 -0
- data/lib/tomo/remote.rb +55 -0
- data/lib/tomo/result.rb +28 -0
- data/lib/tomo/runtime/concurrent_ruby_load_error.rb +26 -0
- data/lib/tomo/runtime/concurrent_ruby_thread_pool.rb +50 -0
- data/lib/tomo/runtime/context.rb +21 -0
- data/lib/tomo/runtime/current.rb +41 -0
- data/lib/tomo/runtime/execution_plan.rb +107 -0
- data/lib/tomo/runtime/host_execution_step.rb +49 -0
- data/lib/tomo/runtime/inline_thread_pool.rb +27 -0
- data/lib/tomo/runtime/privileged_task.rb +6 -0
- data/lib/tomo/runtime/settings_interpolation.rb +55 -0
- data/lib/tomo/runtime/settings_required_error.rb +33 -0
- data/lib/tomo/runtime/task_aborted_error.rb +15 -0
- data/lib/tomo/runtime/task_runner.rb +56 -0
- data/lib/tomo/runtime/unknown_task_error.rb +23 -0
- data/lib/tomo/runtime.rb +82 -0
- data/lib/tomo/script.rb +44 -0
- data/lib/tomo/shell_builder.rb +108 -0
- data/lib/tomo/ssh/child_process.rb +64 -0
- data/lib/tomo/ssh/connection.rb +82 -0
- data/lib/tomo/ssh/connection_error.rb +16 -0
- data/lib/tomo/ssh/connection_validator.rb +87 -0
- data/lib/tomo/ssh/error.rb +11 -0
- data/lib/tomo/ssh/executable_error.rb +21 -0
- data/lib/tomo/ssh/options.rb +67 -0
- data/lib/tomo/ssh/permission_error.rb +18 -0
- data/lib/tomo/ssh/script_error.rb +23 -0
- data/lib/tomo/ssh/unknown_error.rb +13 -0
- data/lib/tomo/ssh/unsupported_version_error.rb +15 -0
- data/lib/tomo/ssh.rb +36 -0
- data/lib/tomo/task_library.rb +51 -0
- data/lib/tomo/templates/config.rb.erb +66 -0
- data/lib/tomo/testing/Dockerfile +10 -0
- data/lib/tomo/testing/connection.rb +34 -0
- data/lib/tomo/testing/docker_image.rb +115 -0
- data/lib/tomo/testing/docker_plugin_tester.rb +39 -0
- data/lib/tomo/testing/host_extensions.rb +27 -0
- data/lib/tomo/testing/local.rb +75 -0
- data/lib/tomo/testing/mock_plugin_tester.rb +26 -0
- data/lib/tomo/testing/mocked_exec_error.rb +6 -0
- data/lib/tomo/testing/plugin_tester.rb +49 -0
- data/lib/tomo/testing/remote_extensions.rb +10 -0
- data/lib/tomo/testing/ssh_extensions.rb +13 -0
- data/lib/tomo/testing/tomo_test_ed25519 +7 -0
- data/lib/tomo/testing/tomo_test_ed25519.pub +1 -0
- data/lib/tomo/testing/ubuntu_setup.sh +33 -0
- data/lib/tomo/testing.rb +39 -0
- data/lib/tomo/version.rb +3 -0
- data/lib/tomo.rb +45 -0
- 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
|
data/lib/tomo/console.rb
ADDED
@@ -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
|