doable 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +22 -0
- data/lib/doable/exceptions/framework_exceptions.rb +34 -0
- data/lib/doable/helpers/framework_helpers.rb +11 -0
- data/lib/doable/helpers/logging_helpers.rb +46 -0
- data/lib/doable/helpers/password_helpers.rb +15 -0
- data/lib/doable/job.rb +224 -0
- data/lib/doable/step.rb +75 -0
- data/lib/doable.rb +17 -0
- metadata +52 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 56e965ddda372a9869ce205446246564c6ccff61
|
4
|
+
data.tar.gz: 2b7a330755f47d29ab09ea8534573b5091ce277a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c0f5a9c98bf55b5f0a6e68283605df03ee2c643f43b395d5ec25f387934289fab656128bb828b13cec3c6ed0d07486187110fd40d970b6bab6daaaae542cb1ce
|
7
|
+
data.tar.gz: 0475b2cc4859475763911c159eec725881b9a7297e045b2657d96bb111af45e8011ef1223aa2c1ef57d2cee859799d2b069ea60bdb66e52e4fbb1274c0ec5795
|
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Jonathan Gnagy <jonathan.gnagy@gmail.com>
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person
|
4
|
+
obtaining a copy of this software and associated documentation
|
5
|
+
files (the "Software"), to deal in the Software without
|
6
|
+
restriction, including without limitation the rights to use,
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
8
|
+
copies of the Software, and to permit persons to whom the
|
9
|
+
Software is furnished to do so, subject to the following
|
10
|
+
conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be
|
13
|
+
included in all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
17
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
19
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
20
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
21
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Doable
|
2
|
+
module Exceptions
|
3
|
+
module FrameworkExceptions
|
4
|
+
class GenericFrameworkError < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
class InvalidInput < GenericFrameworkError
|
8
|
+
end
|
9
|
+
|
10
|
+
class NotApplicable < InvalidInput
|
11
|
+
end
|
12
|
+
|
13
|
+
class InvalidAction < GenericFrameworkError
|
14
|
+
end
|
15
|
+
|
16
|
+
class MissingDefinitionsDirectory < GenericFrameworkError
|
17
|
+
end
|
18
|
+
|
19
|
+
class MissingParameter < InvalidInput
|
20
|
+
end
|
21
|
+
|
22
|
+
# This exception should be used by those rare features that aren't cross-platform
|
23
|
+
class UnsupportedPlatformFeature < GenericFrameworkError
|
24
|
+
end
|
25
|
+
|
26
|
+
# !These exceptions should never be caught by anything!
|
27
|
+
class SkipStep < StandardError
|
28
|
+
end
|
29
|
+
|
30
|
+
class RolledBack < StandardError
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Doable
|
2
|
+
module Helpers
|
3
|
+
module FrameworkHelpers
|
4
|
+
# raises a special skip exception and __must__ only be run in job steps
|
5
|
+
# @raise [SkipStep] Do __not__ handle this exception!
|
6
|
+
def skip(message = "Skipping Step")
|
7
|
+
raise SkipStep, message
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Doable
|
2
|
+
module Helpers
|
3
|
+
module LoggingHelpers
|
4
|
+
# Create a mutex to manage logging to STDOUT
|
5
|
+
LOGGING_MUTEX = Mutex.new
|
6
|
+
LOGGING_MUTEX.freeze
|
7
|
+
|
8
|
+
# Applies a color (with optional addition settings) to some text
|
9
|
+
# @return [String]
|
10
|
+
# @param text [String] The String to be colorized
|
11
|
+
# @param color [Symbol] The color to use. Must be one of [ :gray, :red, :green, :yellow, :blue, :magenta, :cyan, :white ]
|
12
|
+
def colorize(text, color, options = {})
|
13
|
+
background = options[:background] || options[:bg] || false
|
14
|
+
style = options[:style].to_sym if options[:style]
|
15
|
+
offsets = [ :gray, :red, :green, :yellow, :blue, :magenta, :cyan, :white ]
|
16
|
+
styles = [ :normal, :bold, :dark, :italic, :underline, :xx, :xx, :underline, :xx, :strikethrough ]
|
17
|
+
start = background ? 40 : 30
|
18
|
+
color_code = start + (offsets.index(color) || 8)
|
19
|
+
style_code = styles.index(style) || 0
|
20
|
+
"\e[#{style_code};#{color_code}m#{text}\e[0m"
|
21
|
+
end
|
22
|
+
|
23
|
+
# All logging or writing to STDOUT should happen here (to be threadsafe)
|
24
|
+
def log(text, level = :info)
|
25
|
+
level = level.to_sym
|
26
|
+
|
27
|
+
color, options = case level
|
28
|
+
when :info
|
29
|
+
[:white, {}]
|
30
|
+
when :warn
|
31
|
+
[:yellow, {}]
|
32
|
+
when :error
|
33
|
+
[:red, {:bg => true}]
|
34
|
+
when :success
|
35
|
+
[:green, {}]
|
36
|
+
else
|
37
|
+
[:gray, {}]
|
38
|
+
end
|
39
|
+
|
40
|
+
LOGGING_MUTEX.synchronize {
|
41
|
+
puts "[#{Time.now.strftime('%Y/%m/%d %H:%M:%S')}] #{colorize(text, color, options)}"
|
42
|
+
}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Doable
|
2
|
+
module Helpers
|
3
|
+
module PasswordHelpers
|
4
|
+
# Generates a password
|
5
|
+
# @param length [Fixnum] Length of the password
|
6
|
+
# @param options [Hash] Options hash for specifying password details
|
7
|
+
# @return [String]
|
8
|
+
def generate_password(length = 8, options = {})
|
9
|
+
options[:characters] ||= [*(2..9), *('a'..'z'), *('A'..'Z')] - %w(i l O)
|
10
|
+
|
11
|
+
(1..length).collect{|a| options[:characters][rand(options[:characters].size)] }.join
|
12
|
+
end # generate_password()
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/doable/job.rb
ADDED
@@ -0,0 +1,224 @@
|
|
1
|
+
module Doable
|
2
|
+
# The Job class is responsible for describing the process of running some set of steps.
|
3
|
+
# It utilizes a very specific DSL for defining what steps need executing, along with their order. It
|
4
|
+
# can also describe how to recover when things break and provides hooks and triggers to make more flexible
|
5
|
+
# scripts for varying environments.
|
6
|
+
class Job
|
7
|
+
include Helpers::FrameworkHelpers
|
8
|
+
include Helpers::LoggingHelpers
|
9
|
+
attr_reader :steps, :hooks, :handlers, :threads
|
10
|
+
|
11
|
+
def self.plan(&block)
|
12
|
+
self.new(&block)
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@hooks = {}
|
17
|
+
@steps = []
|
18
|
+
@handlers = {}
|
19
|
+
@threads = []
|
20
|
+
yield self
|
21
|
+
end
|
22
|
+
|
23
|
+
# Registers a hook action to be performed when the hook is triggered
|
24
|
+
# @param hook [Symbol] Name of the hook to register the action with
|
25
|
+
# @param options [Hash]
|
26
|
+
# @param block [Proc]
|
27
|
+
# @return [Step]
|
28
|
+
def on(hook, options = {}, &block)
|
29
|
+
@hooks[hook] ||= []
|
30
|
+
@hooks[hook] << Step.new(self, options, &block)
|
31
|
+
end # on()
|
32
|
+
|
33
|
+
# Adds a step to the queue
|
34
|
+
# @param options [Hash]
|
35
|
+
# @param block [Proc]
|
36
|
+
# @return [Step]
|
37
|
+
def step(options = {}, &block)
|
38
|
+
@steps << Step.new(self, options, &block)
|
39
|
+
end # step()
|
40
|
+
|
41
|
+
# Registers an action to be performed before normal step execution
|
42
|
+
# @param options [Hash]
|
43
|
+
# @param block [Proc]
|
44
|
+
# @return [Step]
|
45
|
+
def before(options = {}, &block)
|
46
|
+
on(:before, options, &block)
|
47
|
+
end # before()
|
48
|
+
|
49
|
+
# Registers an action to be performed after normal execution completes
|
50
|
+
# @param options [Hash]
|
51
|
+
# @param block [Proc]
|
52
|
+
# @return [Boolean]
|
53
|
+
def after(options = {}, &block)
|
54
|
+
on(:after, options, &block)
|
55
|
+
end # after()
|
56
|
+
|
57
|
+
# Add a step to the queue, but first wrap it in a begin..rescue
|
58
|
+
# WARNING! Exception handlers are __not__ used with these steps, as they never actually raise exceptions
|
59
|
+
# @param options [Hash]
|
60
|
+
# @param block [Proc]
|
61
|
+
# @return [Boolean]
|
62
|
+
def attempt(options = {}, &block)
|
63
|
+
@steps << Step.new(self, options) do
|
64
|
+
begin
|
65
|
+
block.call
|
66
|
+
rescue SkipStep => e
|
67
|
+
raise e # We'll rescue this somewhere higher up the stack
|
68
|
+
rescue => e
|
69
|
+
log "Ignoring Exception in attempted step: #{colorize("#{e.class}: (#{e.message})", :red)}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
return true
|
73
|
+
end # attempt()
|
74
|
+
|
75
|
+
# Allow running steps in the background
|
76
|
+
# @param options [Hash]
|
77
|
+
# @param block [Proc]
|
78
|
+
# @return [Step]
|
79
|
+
def background(options = {}, &block)
|
80
|
+
@steps << Step.new(self, options) do
|
81
|
+
@threads << Thread.new { block.call }
|
82
|
+
end
|
83
|
+
end # background()
|
84
|
+
|
85
|
+
# Check if background steps are running
|
86
|
+
# @return [Boolean]
|
87
|
+
def multitasking?
|
88
|
+
return @threads.collect {|t| t if t.alive? }.compact.empty? ? false : true
|
89
|
+
end # multitasking?()
|
90
|
+
|
91
|
+
# Trigger a rollback of the entire Job, based on calls to #rollback!() on each eligible Step
|
92
|
+
def rollback!
|
93
|
+
log "Rolling Back...", :warn
|
94
|
+
@hooks[:after].reverse.each {|s| s.rollback! if s.rollbackable? }
|
95
|
+
@steps.reverse.each {|s| s.rollback! if s.rollbackable? }
|
96
|
+
@hooks[:before].reverse.each {|s| s.rollback! if s.rollbackable? }
|
97
|
+
log "Rollback complete!", :warn
|
98
|
+
raise RolledBack
|
99
|
+
end # rollback!()
|
100
|
+
|
101
|
+
# Returns the binding context of the Job
|
102
|
+
# @return [Binding]
|
103
|
+
def context
|
104
|
+
binding
|
105
|
+
end # context()
|
106
|
+
|
107
|
+
# Register a handler for named exception
|
108
|
+
# @param exception [String,StandardError] Exception to register handler for
|
109
|
+
# @param block [Proc]
|
110
|
+
def handle(exception, &block)
|
111
|
+
@handlers[exception] = Step.new(self, &block)
|
112
|
+
end # handle()
|
113
|
+
|
114
|
+
# This triggers a block associated with a hook
|
115
|
+
# @param hook [Symbol] Hook to trigger
|
116
|
+
def trigger(hook)
|
117
|
+
@hooks[hook].each_with_index do |step, index|
|
118
|
+
begin
|
119
|
+
step.call
|
120
|
+
rescue SkipStep => e
|
121
|
+
step.skip
|
122
|
+
log e.message, :warn
|
123
|
+
rescue => e
|
124
|
+
if @handlers[e.message]
|
125
|
+
log "Handling #{e.class}: (#{e.message})", :warn
|
126
|
+
@handlers[e.message].call(e, step)
|
127
|
+
step.handled unless step.status == :skipped # Don't mark the step as "handled" if it was skipped
|
128
|
+
elsif @handlers[e.class]
|
129
|
+
log "Handling #{e.class}: (#{e.message})", :warn
|
130
|
+
@handlers[e.class].call(e, step)
|
131
|
+
step.handled unless step.status == :skipped # Don't mark the step as "handled" if it was skipped
|
132
|
+
else
|
133
|
+
# Check the ancestry of the exception to see if any lower level Exception classes are caught
|
134
|
+
e.class.ancestors[1..-4].each do |ancestor|
|
135
|
+
if @handlers[ancestor]
|
136
|
+
log "Handling #{e.class}: (#{e.message}) via handler for #{ancestor}", :warn
|
137
|
+
@handlers[ancestor].call(e, step)
|
138
|
+
step.handled unless step.status == :skipped # Don't mark the step as "handled" if it was skipped
|
139
|
+
end # if @@handlers[ancestor]
|
140
|
+
end
|
141
|
+
|
142
|
+
unless step.successful?
|
143
|
+
message = "\n\nUnhandled Exception in #{colorize("hooks##{hook}[#{index}]", :yellow)}: #{colorize("#{e.class}: (#{e.message})", :red)}\n\n"
|
144
|
+
if @config.auto_rollback
|
145
|
+
log message
|
146
|
+
rollback!
|
147
|
+
else
|
148
|
+
raise message
|
149
|
+
end
|
150
|
+
end # unless
|
151
|
+
end
|
152
|
+
end # begin()
|
153
|
+
end if @hooks[hook] # each_with_index()
|
154
|
+
end # trigger()
|
155
|
+
|
156
|
+
# Here we actually trigger the execution of a Job
|
157
|
+
def run
|
158
|
+
#merge_config FILE_CONFIG
|
159
|
+
#merge_config CLI_CONFIG
|
160
|
+
## Run our defaults Proc to merge in any default configs
|
161
|
+
#@defaults.call(@@config)
|
162
|
+
|
163
|
+
# before hooks
|
164
|
+
trigger(:before)
|
165
|
+
|
166
|
+
# Actual installer steps
|
167
|
+
@steps.each_with_index do |step, index|
|
168
|
+
begin
|
169
|
+
step.call
|
170
|
+
rescue SkipStep => e
|
171
|
+
step.skip
|
172
|
+
log e.message, :warn
|
173
|
+
rescue => e
|
174
|
+
if @handlers[e.message]
|
175
|
+
log "Handling #{e.class}: (#{e.message})", :warn
|
176
|
+
@handlers[e.message].call(e, step)
|
177
|
+
step.handled
|
178
|
+
elsif @handlers[e.class]
|
179
|
+
log "Handling #{e.class}: (#{e.message})", :warn
|
180
|
+
@handlers[e.class].call(e, step)
|
181
|
+
step.handled
|
182
|
+
else
|
183
|
+
# Check the ancestry of the exception to see if any lower level Exception classes are caught
|
184
|
+
e.class.ancestors[1..-4].each do |ancestor|
|
185
|
+
if @handlers[ancestor]
|
186
|
+
log "Handling #{e.class}: (#{e.message}) via handler for #{ancestor}", :warn
|
187
|
+
@handlers[ancestor].call(e, step)
|
188
|
+
step.handled
|
189
|
+
end # if @handlers[ancestor]
|
190
|
+
end
|
191
|
+
|
192
|
+
unless step.successful?
|
193
|
+
message = "\n\nUnhandled Exception in #{colorize("steps[#{index}]", :yellow)}: #{colorize("#{e.class}: (#{e.message})", :red)}\n\n"
|
194
|
+
#if @config.auto_rollback
|
195
|
+
# log message
|
196
|
+
# rollback!
|
197
|
+
#else
|
198
|
+
raise message
|
199
|
+
#end
|
200
|
+
end # unless
|
201
|
+
end # if @handlers...
|
202
|
+
end # rescue
|
203
|
+
end # @steps.each_with_index
|
204
|
+
|
205
|
+
# after hooks
|
206
|
+
trigger(:after)
|
207
|
+
|
208
|
+
# bring together all background threads
|
209
|
+
unless @threads.empty?
|
210
|
+
log "Cleaning up background tasks..."
|
211
|
+
@threads.each do |t|
|
212
|
+
begin
|
213
|
+
t.join
|
214
|
+
rescue => e
|
215
|
+
# We don't really need to do anything here,
|
216
|
+
# we've already handled or died from aborted Threads
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
log "All Job steps completed successfully!", :success # This should only happen if everything goes well
|
222
|
+
end # run()
|
223
|
+
end
|
224
|
+
end
|
data/lib/doable/step.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
module Doable
|
2
|
+
# This class contains the actual work to be done
|
3
|
+
class Step
|
4
|
+
attr_reader :success, :status, :task, :name
|
5
|
+
|
6
|
+
# @param options [Hash]
|
7
|
+
# @param block [Proc]
|
8
|
+
def initialize(context, options = {}, &block)
|
9
|
+
@context = context
|
10
|
+
@success = false
|
11
|
+
@status = :unattempted
|
12
|
+
@task = block
|
13
|
+
@name = options.key?(:name) ? options[:key] : block.to_s
|
14
|
+
@rollback = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
# Call our step, passing any arguments to the block (task) itself
|
18
|
+
def call(*args)
|
19
|
+
@status = :failed # first assume our attempt fails
|
20
|
+
@context.instance_exec(*args, &@task) # try to execute the Step's task
|
21
|
+
@success = true # we'll only get here if no exceptions were raised
|
22
|
+
@status = :completed # if we're here, the Step is complete
|
23
|
+
return self
|
24
|
+
end # call()
|
25
|
+
|
26
|
+
# Set the Step's status to 'handled'
|
27
|
+
def handled
|
28
|
+
@status = :handled
|
29
|
+
@success = true # Might want to change this to false...
|
30
|
+
end # handled
|
31
|
+
|
32
|
+
# Boolean way of requesting success
|
33
|
+
# @return [Boolean]
|
34
|
+
def successful?
|
35
|
+
return @success
|
36
|
+
end
|
37
|
+
|
38
|
+
# Wrapper around call used for retrying a step. Recommended approach to retrying as this may be
|
39
|
+
# enhanced in the future.
|
40
|
+
def retry(*args)
|
41
|
+
call(*args)
|
42
|
+
end
|
43
|
+
|
44
|
+
# allow skipping steps (needs to be called externally)
|
45
|
+
def skip
|
46
|
+
@status = :skipped
|
47
|
+
@success = false
|
48
|
+
end
|
49
|
+
|
50
|
+
# Sets up a block to call when rolling back this step
|
51
|
+
# @return [Boolean]
|
52
|
+
def rollback(&block)
|
53
|
+
@rollback = block
|
54
|
+
return true
|
55
|
+
end
|
56
|
+
|
57
|
+
# Query a step to see if it can be rolled back
|
58
|
+
# @return [Boolean]
|
59
|
+
def rollbackable?
|
60
|
+
if @rollback and [:completed, :handled, :failed].include?(@status)
|
61
|
+
return true
|
62
|
+
else
|
63
|
+
return false
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Actually rollback this step
|
68
|
+
def rollback!
|
69
|
+
@rollback.call
|
70
|
+
@status = :rolledback
|
71
|
+
@success = false
|
72
|
+
end
|
73
|
+
|
74
|
+
end # class Step
|
75
|
+
end
|
data/lib/doable.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# Standard Library includes
|
2
|
+
require 'digest'
|
3
|
+
require 'base64'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'tempfile'
|
6
|
+
require 'ostruct'
|
7
|
+
require 'erb'
|
8
|
+
require 'thread'
|
9
|
+
require 'pathname'
|
10
|
+
require 'yaml'
|
11
|
+
# Framework requirements
|
12
|
+
require 'doable/exceptions/framework_exceptions'
|
13
|
+
include Doable::Exceptions::FrameworkExceptions
|
14
|
+
require 'doable/helpers/logging_helpers'
|
15
|
+
require 'doable/helpers/framework_helpers'
|
16
|
+
require 'doable/step'
|
17
|
+
require 'doable/job'
|
metadata
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: doable
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jonathan Gnagy
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-01-29 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: A framework for automating tasks with ease
|
14
|
+
email: jonathan.gnagy@gmail.com
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- LICENSE
|
20
|
+
- lib/doable.rb
|
21
|
+
- lib/doable/exceptions/framework_exceptions.rb
|
22
|
+
- lib/doable/helpers/framework_helpers.rb
|
23
|
+
- lib/doable/helpers/logging_helpers.rb
|
24
|
+
- lib/doable/helpers/password_helpers.rb
|
25
|
+
- lib/doable/job.rb
|
26
|
+
- lib/doable/step.rb
|
27
|
+
homepage:
|
28
|
+
licenses:
|
29
|
+
- MIT
|
30
|
+
metadata: {}
|
31
|
+
post_install_message:
|
32
|
+
rdoc_options: []
|
33
|
+
require_paths:
|
34
|
+
- lib
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0'
|
45
|
+
requirements: []
|
46
|
+
rubyforge_project:
|
47
|
+
rubygems_version: 2.2.0
|
48
|
+
signing_key:
|
49
|
+
specification_version: 4
|
50
|
+
summary: Ruby Doable Framework
|
51
|
+
test_files: []
|
52
|
+
has_rdoc:
|