minestrone 0.0.1

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.
Files changed (96) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +32 -0
  3. data/.gitignore +5 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +35 -0
  7. data/Rakefile +10 -0
  8. data/bin/capify +89 -0
  9. data/bin/min +5 -0
  10. data/docs/lib-codebase-map.md +162 -0
  11. data/docs/lib-dependency-graph.svg +129 -0
  12. data/lib/minestrone/callback.rb +45 -0
  13. data/lib/minestrone/cli/help.rb +131 -0
  14. data/lib/minestrone/cli/help.txt +72 -0
  15. data/lib/minestrone/cli/options.rb +232 -0
  16. data/lib/minestrone/cli.rb +159 -0
  17. data/lib/minestrone/command.rb +177 -0
  18. data/lib/minestrone/configuration/actions/file_transfer.rb +53 -0
  19. data/lib/minestrone/configuration/actions/inspect.rb +46 -0
  20. data/lib/minestrone/configuration/actions/invocation.rb +202 -0
  21. data/lib/minestrone/configuration/alias_task.rb +29 -0
  22. data/lib/minestrone/configuration/callbacks.rb +129 -0
  23. data/lib/minestrone/configuration/connections.rb +66 -0
  24. data/lib/minestrone/configuration/execution.rb +139 -0
  25. data/lib/minestrone/configuration/loading.rb +207 -0
  26. data/lib/minestrone/configuration/log_formatters.rb +75 -0
  27. data/lib/minestrone/configuration/namespaces.rb +225 -0
  28. data/lib/minestrone/configuration/servers.rb +70 -0
  29. data/lib/minestrone/configuration/variables.rb +115 -0
  30. data/lib/minestrone/configuration.rb +69 -0
  31. data/lib/minestrone/errors.rb +17 -0
  32. data/lib/minestrone/ext/string.rb +7 -0
  33. data/lib/minestrone/extensions.rb +56 -0
  34. data/lib/minestrone/logger.rb +171 -0
  35. data/lib/minestrone/processable.rb +50 -0
  36. data/lib/minestrone/recipes/deploy/assets.rb +194 -0
  37. data/lib/minestrone/recipes/deploy/bundler.rb +81 -0
  38. data/lib/minestrone/recipes/deploy/dependencies.rb +44 -0
  39. data/lib/minestrone/recipes/deploy/local_dependency.rb +45 -0
  40. data/lib/minestrone/recipes/deploy/remote_dependency.rb +119 -0
  41. data/lib/minestrone/recipes/deploy/scm/base.rb +204 -0
  42. data/lib/minestrone/recipes/deploy/scm/git.rb +284 -0
  43. data/lib/minestrone/recipes/deploy/scm/none.rb +54 -0
  44. data/lib/minestrone/recipes/deploy/scm.rb +22 -0
  45. data/lib/minestrone/recipes/deploy/strategy/base.rb +87 -0
  46. data/lib/minestrone/recipes/deploy/strategy/copy.rb +353 -0
  47. data/lib/minestrone/recipes/deploy/strategy/remote_cache.rb +80 -0
  48. data/lib/minestrone/recipes/deploy/strategy.rb +22 -0
  49. data/lib/minestrone/recipes/deploy.rb +639 -0
  50. data/lib/minestrone/recipes/standard.rb +23 -0
  51. data/lib/minestrone/recipes/templates/maintenance.rhtml +53 -0
  52. data/lib/minestrone/server_definition.rb +56 -0
  53. data/lib/minestrone/ssh.rb +81 -0
  54. data/lib/minestrone/task_definition.rb +82 -0
  55. data/lib/minestrone/transfer.rb +205 -0
  56. data/lib/minestrone/version.rb +11 -0
  57. data/lib/minestrone.rb +3 -0
  58. data/minestrone.gemspec +32 -0
  59. data/test/cli/execute_test.rb +130 -0
  60. data/test/cli/help_test.rb +178 -0
  61. data/test/cli/options_test.rb +315 -0
  62. data/test/cli/ui_test.rb +26 -0
  63. data/test/cli_test.rb +17 -0
  64. data/test/command_test.rb +305 -0
  65. data/test/configuration/actions/file_transfer_test.rb +61 -0
  66. data/test/configuration/actions/inspect_test.rb +76 -0
  67. data/test/configuration/actions/invocation_test.rb +258 -0
  68. data/test/configuration/alias_task_test.rb +110 -0
  69. data/test/configuration/callbacks_test.rb +201 -0
  70. data/test/configuration/connections_test.rb +192 -0
  71. data/test/configuration/execution_test.rb +176 -0
  72. data/test/configuration/loading_test.rb +149 -0
  73. data/test/configuration/namespace_dsl_test.rb +325 -0
  74. data/test/configuration/servers_test.rb +100 -0
  75. data/test/configuration/variables_test.rb +191 -0
  76. data/test/configuration_test.rb +77 -0
  77. data/test/deploy/local_dependency_test.rb +61 -0
  78. data/test/deploy/remote_dependency_test.rb +146 -0
  79. data/test/deploy/scm/base_test.rb +55 -0
  80. data/test/deploy/scm/git_test.rb +260 -0
  81. data/test/deploy/scm/none_test.rb +26 -0
  82. data/test/deploy/strategy/copy_test.rb +360 -0
  83. data/test/extensions_test.rb +69 -0
  84. data/test/fixtures/cli_integration.rb +5 -0
  85. data/test/fixtures/config.rb +4 -0
  86. data/test/fixtures/custom.rb +3 -0
  87. data/test/logger_formatting_test.rb +149 -0
  88. data/test/logger_test.rb +134 -0
  89. data/test/recipes_test.rb +26 -0
  90. data/test/server_definition_test.rb +121 -0
  91. data/test/ssh_test.rb +99 -0
  92. data/test/task_definition_test.rb +117 -0
  93. data/test/transfer_test.rb +172 -0
  94. data/test/utils.rb +28 -0
  95. data/test/version_test.rb +11 -0
  96. metadata +258 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/ssh'
4
+ require 'minestrone/errors'
5
+
6
+ module Minestrone
7
+ class Configuration
8
+ module Connections
9
+ attr_accessor :session
10
+
11
+ def initialize_connections #:nodoc:
12
+ @session = nil
13
+ @failed = false
14
+ end
15
+
16
+ # Used to force a connection to be made to the current task's server.
17
+ # Connections are normally made lazily in Minestrone--you can use this
18
+ # to force them open before performing some operation that might be
19
+ # time-sensitive.
20
+
21
+ def connect!
22
+ establish_connection_to_server
23
+ end
24
+
25
+ # Ensures that there is an active session for the server.
26
+
27
+ def establish_connection_to_server
28
+ return if @session
29
+
30
+ server = resolved_server
31
+ @session = SSH.connect(server, self)
32
+ rescue Exception => err
33
+ raise err unless server
34
+
35
+ error = ConnectionError.new("connection failed for: #{server} (#{err.class}: #{err.message})")
36
+ error.host = server
37
+ raise error
38
+ end
39
+
40
+ # Determines the configured server, establishes a connection to it, and
41
+ # yields to the command and transfer layers.
42
+
43
+ def execute_on_server
44
+ raise ArgumentError, "expected a block" unless block_given?
45
+
46
+ task = current_task
47
+ return if task && task.continue_on_error? && @failed
48
+
49
+ begin
50
+ establish_connection_to_server
51
+ rescue ConnectionError => error
52
+ raise error unless task && task.continue_on_error?
53
+ @failed = true
54
+ return
55
+ end
56
+
57
+ begin
58
+ yield
59
+ rescue RemoteError => error
60
+ raise error unless task && task.continue_on_error?
61
+ @failed = true
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/errors'
4
+
5
+ module Minestrone
6
+ class Configuration
7
+ module Execution
8
+ # A struct for representing a single instance of an invoked task.
9
+ TaskCallFrame = Struct.new(:task, :rollback)
10
+
11
+ def initialize_execution #:nodoc:
12
+ @task_call_frames = []
13
+ @rollback_requests = nil
14
+ end
15
+
16
+ # Returns true if there is a transaction currently active.
17
+ def transaction?
18
+ !rollback_requests.nil?
19
+ end
20
+
21
+ # The call stack of the tasks. The currently executing task may inspect
22
+ # this to see who its caller was. The current task is always the last
23
+ # element of this stack.
24
+ def task_call_frames
25
+ @task_call_frames
26
+ end
27
+
28
+ # The stack of tasks that have registered rollback handlers within the
29
+ # current transaction. If this is nil, then there is no transaction
30
+ # that is currently active.
31
+ attr_accessor :rollback_requests
32
+
33
+ # Invoke a set of tasks in a transaction. If any task fails (raises an
34
+ # exception), all tasks executed within the transaction are inspected to
35
+ # see if they have an associated on_rollback hook, and if so, that hook
36
+ # is called.
37
+
38
+ def transaction
39
+ raise ArgumentError, "expected a block" unless block_given?
40
+ raise ScriptError, "transaction must be called from within a task" if task_call_frames.empty?
41
+
42
+ return yield if transaction?
43
+
44
+ logger.info "transaction: start"
45
+
46
+ begin
47
+ self.rollback_requests = []
48
+ yield
49
+ logger.info "transaction: commit"
50
+ rescue Object
51
+ rollback!
52
+ raise
53
+ ensure
54
+ self.rollback_requests = nil
55
+ end
56
+ end
57
+
58
+ # Specifies an on_rollback hook for the currently executing task. If this
59
+ # or any subsequent task then fails, and a transaction is active, this
60
+ # hook will be executed.
61
+
62
+ def on_rollback(&block)
63
+ if transaction?
64
+ # don't note a new rollback request if one has already been set
65
+ rollback_requests << task_call_frames.last unless task_call_frames.last.rollback
66
+ task_call_frames.last.rollback = block
67
+ end
68
+ end
69
+
70
+ # Returns the TaskDefinition object for the currently executing task.
71
+ # It returns nil if there is no task being executed.
72
+
73
+ def current_task
74
+ return nil if task_call_frames.empty?
75
+ task_call_frames.last.task
76
+ end
77
+
78
+ # Executes the task with the given name, without invoking any associated callbacks.
79
+
80
+ def execute_task(task)
81
+ logger.debug "executing `#{task.fully_qualified_name}'"
82
+ push_task_call_frame(task)
83
+ invoke_task_directly(task)
84
+ ensure
85
+ pop_task_call_frame
86
+ end
87
+
88
+ # Attempts to locate the task at the given fully-qualified path, and
89
+ # execute it. If no such task exists, a Minestrone::NoSuchTaskError will
90
+ # be raised.
91
+
92
+ def find_and_execute_task(path, hooks = {})
93
+ task = find_task(path) or raise NoSuchTaskError, "the task `#{path}' does not exist"
94
+
95
+ trigger(hooks[:before], task) if hooks[:before]
96
+ result = execute_task(task)
97
+ trigger(hooks[:after], task) if hooks[:after]
98
+
99
+ result
100
+ end
101
+
102
+
103
+ protected
104
+
105
+ def rollback!
106
+ return if rollback_requests.nil?
107
+
108
+ # throw the task back on the stack so that roles are properly
109
+ # interpreted in the scope of the task in question.
110
+
111
+ rollback_requests.reverse.each do |frame|
112
+ begin
113
+ push_task_call_frame(frame.task)
114
+ logger.important "rolling back", frame.task.fully_qualified_name
115
+ frame.rollback.call
116
+ rescue Object => e
117
+ logger.info "exception while rolling back: #{e.class}, #{e.message}", frame.task.fully_qualified_name
118
+ ensure
119
+ pop_task_call_frame
120
+ end
121
+ end
122
+ end
123
+
124
+ def push_task_call_frame(task)
125
+ frame = TaskCallFrame.new(task)
126
+ task_call_frames.push frame
127
+ end
128
+
129
+ def pop_task_call_frame
130
+ task_call_frames.pop
131
+ end
132
+
133
+ # Invokes the task's body directly, without setting up the call frame.
134
+ def invoke_task_directly(task)
135
+ task.namespace.instance_eval(&task.body)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minestrone
4
+ class Configuration
5
+ module Loading
6
+ def self.included(base) #:nodoc:
7
+ base.extend ClassMethods
8
+ end
9
+
10
+ module ClassMethods
11
+
12
+ # Used by third-party task bundles to identify the minestrone
13
+ # configuration that is loading them. Its return value is not reliable
14
+ # in other contexts. If +require_config+ is not false, an exception
15
+ # will be raised if the current configuration is not set.
16
+
17
+ def instance(require_config = false)
18
+ config = @instance
19
+
20
+ if require_config && config.nil?
21
+ raise LoadError, "Please require this file from within a Minestrone recipe"
22
+ end
23
+
24
+ config
25
+ end
26
+
27
+ # Used internally by Minestrone to specify the current configuration
28
+ # before loading a third-party task bundle.
29
+
30
+ def instance=(config)
31
+ @instance = config
32
+ end
33
+
34
+ # Used internally by Minestrone to track which recipes have been loaded
35
+ # via require, so that they may be successfully reloaded when require
36
+ # is called again.
37
+
38
+ def recipes_per_feature
39
+ @recipes_per_feature ||= {}
40
+ end
41
+
42
+ # Used internally to specify the current file being required, so that
43
+ # any recipes loaded by that file can be remembered. This allows
44
+ # recipes loaded via require to be correctly reloaded in different
45
+ # Configuration instances in the same Ruby instance.
46
+
47
+ attr_accessor :current_feature
48
+ end
49
+
50
+ # The load paths used for locating recipe files.
51
+ attr_reader :load_paths
52
+
53
+ def initialize_loading #:nodoc:
54
+ @load_paths = ['.', File.expand_path("../recipes", __dir__)]
55
+ @loaded_features = []
56
+ end
57
+
58
+ # Load a configuration file or string into this configuration.
59
+ #
60
+ # Usage:
61
+ #
62
+ # load("recipe"):
63
+ # Look for and load the contents of 'recipe.rb' into this
64
+ # configuration.
65
+ #
66
+ # load(:file => "recipe"):
67
+ # same as above
68
+ #
69
+ # load(:string => "set :scm, :git"):
70
+ # Load the given string as a configuration specification.
71
+ #
72
+ # load { ... }
73
+ # Load the block in the context of the configuration.
74
+
75
+ def load(*args, &block)
76
+ options = args.last.is_a?(Hash) ? args.pop : {}
77
+
78
+ if block
79
+ raise ArgumentError, "loading a block requires 0 arguments" unless options.empty? && args.empty?
80
+ load(:proc => block)
81
+
82
+ elsif args.any?
83
+ args.each { |arg| load options.merge(:file => arg) }
84
+
85
+ elsif options[:file]
86
+ load_from_file(options[:file], options[:name])
87
+
88
+ elsif options[:string]
89
+ remember_load(options) unless options[:reloading]
90
+ instance_eval(options[:string], options[:name] || "<eval>")
91
+
92
+ elsif options[:proc]
93
+ remember_load(options) unless options[:reloading]
94
+ instance_eval(&options[:proc])
95
+
96
+ else
97
+ raise ArgumentError, "don't know how to load #{options.inspect}"
98
+ end
99
+ end
100
+
101
+ # Require another file. This is identical to the standard require method,
102
+ # with the exception that it sets the receiver as the "current" configuration
103
+ # so that third-party task bundles can include themselves relative to
104
+ # that configuration.
105
+ #
106
+ # This is a bit more complicated than an initial review would seem to
107
+ # necessitate, but the use case that complicates things is this: An
108
+ # advanced user wants to embed minestrone, and needs to instantiate
109
+ # more than one minestrone configuration at a time. They also want each
110
+ # configuration to require a third-party minestrone extension. Using a
111
+ # naive require implementation, this would allow the first configuration
112
+ # to successfully load the third-party extension, but the require would
113
+ # fail for the second configuration because the extension has already
114
+ # been loaded.
115
+ #
116
+ # To work around this, we do a few things:
117
+ #
118
+ # 1. Each time a 'require' is invoked inside of a minestrone recipe,
119
+ # we remember the arguments (see "current_feature").
120
+ # 2. Each time a 'load' is invoked inside of a minestrone recipe, and
121
+ # "current_feature" is not nil (meaning we are inside of a pending
122
+ # require) we remember the options (see "remember_load" and
123
+ # "recipes_per_feature").
124
+ # 3. Each time a 'require' is invoked inside of a minestrone recipe,
125
+ # we check to see if this particular configuration has ever seen these
126
+ # arguments to require (see @loaded_features), and if not, we proceed
127
+ # as if the file had never been required. If the superclass' require
128
+ # returns false (meaning, potentially, that the file has already been
129
+ # required), then we look in the recipes_per_feature collection and
130
+ # load any remembered recipes from there.
131
+ #
132
+ # It's kind of a bear, but it works, and works transparently. Note that
133
+ # a simpler implementation would just muck with $", allowing files to be
134
+ # required multiple times, but that will cause warnings (and possibly
135
+ # errors) if the file to be required contains constant definitions and
136
+ # such, alongside (or instead of) minestrone recipe definitions.
137
+
138
+ def require(*args)
139
+ # look to see if this specific configuration instance has ever seen
140
+ # these arguments to require before
141
+ if @loaded_features.include?(args)
142
+ return false
143
+ end
144
+
145
+ @loaded_features << args
146
+
147
+ begin
148
+ original_instance, self.class.instance = self.class.instance, self
149
+ original_feature, self.class.current_feature = self.class.current_feature, args
150
+
151
+ result = super
152
+
153
+ if !result # file has been required previously, load up the remembered recipes
154
+ list = self.class.recipes_per_feature[args] || []
155
+ list.each { |options| load(options.merge(:reloading => true)) }
156
+ end
157
+
158
+ return result
159
+ ensure
160
+ # restore the original, so that require's can be nested
161
+ self.class.instance = original_instance
162
+ self.class.current_feature = original_feature
163
+ end
164
+ end
165
+
166
+ def file_in_load_path?(file)
167
+ begin
168
+ !!find_file_in_load_path(file)
169
+ rescue LoadError
170
+ false
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ # Load a recipe from the named file. If +name+ is given, the file will
177
+ # be reported using that name.
178
+
179
+ def load_from_file(file, name = nil)
180
+ file = find_file_in_load_path(file) unless File.file?(file)
181
+ load :string => File.read(file), :name => name || file
182
+ end
183
+
184
+ def find_file_in_load_path(file)
185
+ load_paths.each do |path|
186
+ ['', '.rb'].each do |ext|
187
+ name = File.join(path, "#{file}#{ext}")
188
+ return name if File.file?(name)
189
+ end
190
+ end
191
+
192
+ raise LoadError, "no such file to load -- #{file}"
193
+ end
194
+
195
+ # If a file is being required, the options associated with loading a
196
+ # recipe are remembered in the recipes_per_feature archive under the
197
+ # name of the file currently being required.
198
+
199
+ def remember_load(options)
200
+ if self.class.current_feature
201
+ list = (self.class.recipes_per_feature[self.class.current_feature] ||= [])
202
+ list << options
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,75 @@
1
+ # Add custom log formatters
2
+ #
3
+ # Passing a hash or a array of hashes with custom log formatters.
4
+ #
5
+ # Add the following to your deploy.rb or in your ~/.caprc
6
+ #
7
+ # == Example:
8
+ #
9
+ # minestrone_log_formatters = [
10
+ # { :match => /command finished/, :color => :hide, :priority => 10, :prepend => "$$$" },
11
+ # { :match => /executing command/, :color => :blue, :priority => 10, :style => :underscore, :timestamp => true },
12
+ # { :match => /^transaction: commit$/, :color => :magenta, :priority => 10, :style => :blink },
13
+ # { :match => /git/, :color => :white, :priority => 20, :style => :reverse }
14
+ # ]
15
+ #
16
+ # log_formatter minestrone_log_formatters
17
+ #
18
+ # You can call log_formatter multiple times, with either a hash or an array of hashes.
19
+ #
20
+ # == Colors:
21
+ #
22
+ # :color can have the following values:
23
+ #
24
+ # * :hide (hides the row completely)
25
+ # * :none
26
+ # * :black
27
+ # * :red
28
+ # * :green
29
+ # * :yellow
30
+ # * :blue
31
+ # * :magenta
32
+ # * :cyan
33
+ # * :white
34
+ #
35
+ # == Styles:
36
+ #
37
+ # :style can have the following values:
38
+ #
39
+ # * :bright
40
+ # * :dim
41
+ # * :underscore
42
+ # * :blink
43
+ # * :reverse
44
+ # * :hidden
45
+ #
46
+ #
47
+ # == Text alterations
48
+ #
49
+ # :prepend gives static text to be prepended to the output
50
+ # :replace replaces the matched text in the output
51
+ # :timestamp adds the current time before the output
52
+
53
+ module Minestrone
54
+ class Configuration
55
+ module LogFormatters
56
+ def log_formatter(options)
57
+ if options.class == Array
58
+ options.each do |option|
59
+ Minestrone::Logger.add_formatter(option)
60
+ end
61
+ else
62
+ Minestrone::Logger.add_formatter(options)
63
+ end
64
+ end
65
+
66
+ def default_log_formatters(formatters)
67
+ default_formatters = [*formatters]
68
+ end
69
+
70
+ def disable_log_formatters
71
+ @logger.disable_formatters = true
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minestrone/configuration/alias_task'
4
+ require 'minestrone/task_definition'
5
+
6
+ module Minestrone
7
+ class Configuration
8
+ module Namespaces
9
+ DEFAULT_TASK = :default
10
+
11
+ # The name of this namespace. Defaults to +nil+ for the top-level
12
+ # namespace.
13
+ attr_reader :name
14
+
15
+ # The parent namespace of this namespace. Returns +nil+ for the top-level
16
+ # namespace.
17
+ attr_reader :parent
18
+
19
+ # The hash of tasks defined for this namespace.
20
+ attr_reader :tasks
21
+
22
+ # The hash of namespaces defined for this namespace.
23
+ attr_reader :namespaces
24
+
25
+ def initialize_namespaces(name = nil, parent = nil) #:nodoc:
26
+ @name = name
27
+ @parent = parent
28
+ @tasks = {}
29
+ @namespaces = {}
30
+ end
31
+
32
+ # Returns the top-level namespace (the one with no parent).
33
+ def top
34
+ parent ? parent.top : self
35
+ end
36
+
37
+ # Returns the fully-qualified name of this namespace, or nil if the
38
+ # namespace is at the top-level.
39
+ def fully_qualified_name
40
+ if name
41
+ [parent.fully_qualified_name, name].compact.join(':')
42
+ else
43
+ nil
44
+ end
45
+ end
46
+
47
+ # Describe the next task to be defined. The given text will be attached to
48
+ # the next task that is defined and used as its description.
49
+ def desc(text)
50
+ @next_description = text
51
+ end
52
+
53
+ # Returns the value set by the last, pending "desc" call. If +reset+ is
54
+ # not false, the value will be reset immediately afterwards.
55
+ def next_description(reset = false)
56
+ @next_description
57
+ ensure
58
+ @next_description = nil if reset
59
+ end
60
+
61
+ # Open a namespace in which to define new tasks. If the namespace was
62
+ # defined previously, it will be reopened, otherwise a new namespace
63
+ # will be created for the given name.
64
+ def namespace(name, &block)
65
+ name = name.to_sym
66
+ raise ArgumentError, "expected a block" unless block_given?
67
+
68
+ namespace_already_defined = namespaces.key?(name)
69
+
70
+ if all_methods.any? { |m| m.to_sym == name } && !namespace_already_defined
71
+ thing = tasks.key?(name) ? "task" : "method"
72
+ raise ArgumentError, "defining a namespace named `#{name}' would shadow an existing #{thing} with that name"
73
+ end
74
+
75
+ namespaces[name] ||= Namespace.new(name, self)
76
+ namespaces[name].instance_eval(&block)
77
+
78
+ # make sure any open description gets terminated
79
+ namespaces[name].desc(nil)
80
+
81
+ if !namespace_already_defined
82
+ metaclass = class << self; self; end
83
+ metaclass.send(:define_method, name) { namespaces[name] }
84
+ end
85
+ end
86
+
87
+ # Describe a new task. If a description is active (see #desc), it is added
88
+ # to the options under the <tt>:desc</tt> key. The new task is added to
89
+ # the namespace.
90
+ def task(name, options = {}, &block)
91
+ name = name.to_sym
92
+ raise ArgumentError, "expected a block" unless block_given?
93
+
94
+ task_already_defined = tasks.key?(name)
95
+
96
+ if all_methods.any? { |m| m.to_sym == name } && !task_already_defined
97
+ thing = namespaces.key?(name) ? "namespace" : "method"
98
+ raise ArgumentError, "defining a task named `#{name}' would shadow an existing #{thing} with that name"
99
+ end
100
+
101
+ task = TaskDefinition.new(name, self, { :desc => next_description(:reset) }.merge(options), &block)
102
+
103
+ define_task(task)
104
+ end
105
+
106
+ def define_task(task)
107
+ tasks[task.name] = task
108
+
109
+ metaclass = class << self; self; end
110
+ metaclass.send(:define_method, task.name) { execute_task(tasks[task.name]) }
111
+ end
112
+
113
+ # Find the task with the given name, where name is the fully-qualified
114
+ # name of the task. This will search into the namespaces and return
115
+ # the referenced task, or nil if no such task can be found. If the name
116
+ # refers to a namespace, the task in that namespace named "default"
117
+ # will be returned instead, if one exists.
118
+
119
+ def find_task(name)
120
+ parts = name.to_s.split(/:/)
121
+ tail = parts.pop.to_sym
122
+
123
+ ns = self
124
+
125
+ until parts.empty?
126
+ next_part = parts.shift
127
+ ns = next_part.empty? ? nil : ns.namespaces[next_part.to_sym]
128
+ return nil if ns.nil?
129
+ end
130
+
131
+ if ns.namespaces.key?(tail)
132
+ ns = ns.namespaces[tail]
133
+ tail = DEFAULT_TASK
134
+ end
135
+
136
+ ns.tasks[tail]
137
+ end
138
+
139
+ # Given a task name, this will search the current namespace, and all
140
+ # parent namespaces, looking for a task that matches the name, exactly.
141
+ # It returns the task, if found, or nil, if not.
142
+
143
+ def search_task(name)
144
+ name = name.to_sym
145
+ ns = self
146
+
147
+ until ns.nil?
148
+ return ns.tasks[name] if ns.tasks.key?(name)
149
+ ns = ns.parent
150
+ end
151
+
152
+ return nil
153
+ end
154
+
155
+ # Returns the default task for this namespace. This will be +nil+ if
156
+ # the namespace is at the top-level, and will otherwise return the
157
+ # task named "default". If no such task exists, +nil+ will be returned.
158
+
159
+ def default_task
160
+ parent ? tasks[DEFAULT_TASK] : nil
161
+ end
162
+
163
+ # Returns the tasks in this namespace as an array of TaskDefinition
164
+ # objects. If a non-false parameter is given, all tasks in all
165
+ # namespaces under this namespace will be returned as well.
166
+
167
+ def task_list(all = false)
168
+ list = tasks.values
169
+ namespaces.each { |name,space| list.concat(space.task_list(:all)) } if all
170
+ list
171
+ end
172
+
173
+
174
+ private
175
+
176
+ def all_methods
177
+ public_methods.concat(protected_methods).concat(private_methods)
178
+ end
179
+
180
+ class Namespace
181
+ def initialize(name, parent)
182
+ initialize_namespaces(name, parent)
183
+ end
184
+
185
+ def respond_to?(sym, include_priv = false)
186
+ super || parent.respond_to?(sym, include_priv)
187
+ end
188
+
189
+ def method_missing(sym, *args, &block)
190
+ if parent.respond_to?(sym)
191
+ parent.send(sym, *args, &block)
192
+ else
193
+ super
194
+ end
195
+ end
196
+
197
+ include Minestrone::Configuration::AliasTask
198
+ include Minestrone::Configuration::Namespaces
199
+
200
+ undef :desc, :next_description
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ module Kernel
207
+ class << self
208
+ alias_method :method_added_without_minestrone, :method_added
209
+
210
+ # Detect method additions to Kernel and remove them in the Namespace class
211
+
212
+ def method_added(name)
213
+ result = method_added_without_minestrone(name)
214
+ return result if self != Kernel
215
+
216
+ namespace = Minestrone::Configuration::Namespaces::Namespace
217
+
218
+ if namespace.method_defined?(name) && namespace.instance_method(name).owner == Kernel
219
+ namespace.send :undef_method, name
220
+ end
221
+
222
+ result
223
+ end
224
+ end
225
+ end