doable 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.
- 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:
|