lono 2.1.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +16 -284
  4. data/lib/lono.rb +6 -0
  5. data/lib/lono/cfn.rb +5 -0
  6. data/lib/lono/cfn/base.rb +86 -9
  7. data/lib/lono/cfn/create.rb +27 -2
  8. data/lib/lono/cfn/help.rb +12 -12
  9. data/lib/lono/cfn/preview.rb +12 -11
  10. data/lib/lono/cfn/update.rb +3 -2
  11. data/lib/lono/default/settings.yml +13 -0
  12. data/lib/lono/env.rb +11 -0
  13. data/lib/lono/param.rb +1 -1
  14. data/lib/lono/param/generator.rb +144 -22
  15. data/lib/lono/settings.rb +28 -0
  16. data/lib/lono/template.rb +13 -2
  17. data/lib/lono/template/aws_services.rb +7 -0
  18. data/lib/lono/template/dsl.rb +24 -33
  19. data/lib/lono/template/helpers.rb +144 -0
  20. data/lib/lono/template/template.rb +69 -68
  21. data/lib/lono/template/upload.rb +75 -0
  22. data/lib/lono/version.rb +1 -1
  23. data/lib/starter_projects/json_project/config/{lono.rb → templates/base/blog.rb} +3 -3
  24. data/lib/starter_projects/{yaml_project/config/lono/api.rb → json_project/config/templates/base/stacks.rb} +9 -9
  25. data/lib/starter_projects/json_project/templates/{db.json.erb → db.json} +1 -1
  26. data/lib/starter_projects/json_project/templates/partial/{host_record.json.erb → host_record.json} +0 -0
  27. data/lib/starter_projects/json_project/templates/partial/{server.json.erb → server.json} +2 -2
  28. data/lib/starter_projects/json_project/templates/user_data/{app.sh.erb → app.sh} +0 -0
  29. data/lib/starter_projects/json_project/templates/user_data/{db.sh.erb → db.sh} +0 -0
  30. data/lib/starter_projects/json_project/templates/user_data/{db2.sh.erb → db2.sh} +0 -0
  31. data/lib/starter_projects/json_project/templates/user_data/{ruby_script.rb.erb → ruby_script.rb} +0 -0
  32. data/lib/starter_projects/json_project/templates/{web.json.erb → web.json} +2 -2
  33. data/lib/starter_projects/yaml_project/config/{lono.rb → templates/base/blog.rb} +4 -8
  34. data/lib/starter_projects/{json_project/config/lono/api.rb → yaml_project/config/templates/base/stacks.rb} +11 -13
  35. data/lib/starter_projects/yaml_project/config/templates/prod/stacks.rb +1 -0
  36. data/lib/starter_projects/yaml_project/config/templates/stag/stacks.rb +1 -0
  37. data/lib/starter_projects/yaml_project/config/variables/base/variables.rb +4 -0
  38. data/lib/starter_projects/yaml_project/config/variables/prod/variables.rb +1 -0
  39. data/lib/starter_projects/yaml_project/config/variables/stag/variables.rb +1 -0
  40. data/lib/starter_projects/yaml_project/helpers/my_custom_helper.rb +17 -0
  41. data/lib/starter_projects/yaml_project/params/{api-web-prod.txt → base/api-web-prod.txt} +0 -0
  42. data/lib/starter_projects/yaml_project/params/{example.txt → base/example.txt} +0 -0
  43. data/lib/starter_projects/yaml_project/params/prod/example.txt +1 -0
  44. data/lib/starter_projects/yaml_project/params/stag/example.txt +1 -0
  45. data/lib/starter_projects/yaml_project/templates/{db.yml.erb → db.yml} +1 -1
  46. data/lib/starter_projects/yaml_project/templates/{example.yml.erb → example.yml} +0 -0
  47. data/lib/starter_projects/yaml_project/templates/partial/{host_record.yml.erb → host_record.yml} +0 -0
  48. data/lib/starter_projects/yaml_project/templates/partial/{server.yml.erb → server.yml} +0 -0
  49. data/lib/starter_projects/yaml_project/templates/partial/user_data/{bootstrap.sh.erb → bootstrap.sh} +0 -0
  50. data/lib/starter_projects/yaml_project/templates/{web.yml.erb → web.yml} +2 -2
  51. data/lono.gemspec +1 -0
  52. data/spec/fixtures/params/baseonly/params/base/network.txt +1 -0
  53. data/spec/fixtures/params/envonly/params/prod/network.txt +1 -0
  54. data/spec/fixtures/params/overlay/params/base/network.txt +1 -0
  55. data/spec/fixtures/params/overlay/params/prod/network.txt +1 -0
  56. data/spec/lib/lono/new_spec.rb +1 -1
  57. data/spec/lib/lono/param/generator_spec.rb +34 -0
  58. data/spec/lib/lono/template/dsl_spec.rb +1 -1
  59. data/spec/lib/lono/template_spec.rb +5 -0
  60. metadata +60 -22
@@ -0,0 +1,7 @@
1
+ require "aws-sdk"
2
+
3
+ module Lono::Template::AwsServices
4
+ def s3
5
+ @s3 ||= Aws::S3::Client.new
6
+ end
7
+ end
@@ -2,7 +2,7 @@ class Lono::Template::DSL
2
2
  def initialize(options={})
3
3
  @options = options
4
4
  @project_root = @options[:project_root] || '.'
5
- @path = "#{@project_root}/config/lono.rb"
5
+ @config_path = "#{@project_root}/config"
6
6
  Lono::ProjectChecker.check(@project_root)
7
7
  @templates = []
8
8
  @results = {}
@@ -15,12 +15,23 @@ class Lono::Template::DSL
15
15
  write_output
16
16
  end
17
17
 
18
+ # Instance eval's all the files within each folder under
19
+ # config/lono/base and config/lono/[LONO_ENV]
20
+ # Base gets base first and then the LONO_ENV configs get evaluate second.
21
+ # This means the env specific configs override the base configs.
18
22
  def evaluate_templates
19
- evaluate_template(@path)
20
- load_subfolder
23
+ evaluate_folder("base")
24
+ evaluate_folder(LONO_ENV)
21
25
  @detected_format = detect_format
22
26
  end
23
27
 
28
+ def evaluate_folder(folder)
29
+ paths = Dir.glob("#{@config_path}/templates/#{folder}/**/*")
30
+ paths.select{ |e| File.file?(e) }.each do |path|
31
+ evaluate_template(path)
32
+ end
33
+ end
34
+
24
35
  def evaluate_template(path)
25
36
  begin
26
37
  instance_eval(File.read(path), path)
@@ -55,35 +66,14 @@ class Lono::Template::DSL
55
66
  end
56
67
  end
57
68
 
58
- # Detects the format of the templates. Simply checks the extension of all the
69
+ # Detects the format of the templates. Checks the extension of all the
59
70
  # templates files.
60
71
  # All the templates must be of the same format, either all json or all yaml.
61
72
  def detect_format
62
- # @templates contains Array of Hashes. Example:
63
- # [{name: ""blog-web-prod.json", block: ...},
64
- # {name: ""api-web-prod.json", block: ...}]
65
- formats = @templates.map{ |t| File.extname(t[:name]) }.uniq
66
- if formats.size > 1
67
- puts "ERROR: Detected multiple formats: #{formats.join(", ")}".colorize(:red)
68
- puts "All the source values in the template blocks in the config folder must have the same format extension."
69
- exit 1
70
- else
71
- found_format = formats.first
72
- if found_format
73
- detected_format = found_format.sub(/^\./,'')
74
- detected_format = "yaml" if detected_format == "yml"
75
- else # empty templates, no templates defined yet
76
- detected_format = "yaml" # defaults to yaml
77
- end
78
- end
79
- detected_format
80
- end
81
-
82
- # load any templates defined in project/config/lono/*
83
- def load_subfolder
84
- Dir.glob("#{File.dirname(@path)}/lono/**/*").select{ |e| File.file? e }.each do |path|
85
- evaluate_template(path)
86
- end
73
+ extensions = Dir.glob("#{@project_root}/templates/**/*").map do |path|
74
+ File.extname(path).sub(/^\./,'')
75
+ end.reject(&:empty?).uniq
76
+ extensions.include?('yml') ? 'yml' : 'json' # defaults to yml - falls back to json
87
77
  end
88
78
 
89
79
  def template(name, &block)
@@ -91,8 +81,9 @@ class Lono::Template::DSL
91
81
  end
92
82
 
93
83
  def build_templates
84
+ options = @options.merge(detected_format: @detected_format)
94
85
  @templates.each do |t|
95
- @results[t[:name]] = Lono::Template::Template.new(t[:name], t[:block], @options).build
86
+ @results[t[:name]] = Lono::Template::Template.new(t[:name], t[:block], options).build
96
87
  end
97
88
  end
98
89
 
@@ -102,7 +93,8 @@ class Lono::Template::DSL
102
93
  FileUtils.mkdir(output_path) unless File.exist?(output_path)
103
94
  puts "Generating CloudFormation templates:" unless @options[:quiet]
104
95
  @results.each do |name,text|
105
- path = "#{output_path}/#{name}".sub(/^\.\//,'')
96
+ path = "#{output_path}/#{name}".sub(/^\.\//,'') # strip leading '.'
97
+ path += ".#{@detected_format}"
106
98
  puts " #{path}" unless @options[:quiet]
107
99
  ensure_parent_dir(path)
108
100
  validate(text, path)
@@ -112,7 +104,6 @@ class Lono::Template::DSL
112
104
  end
113
105
  end
114
106
 
115
- # TODO: set @detected_format upon DSL.new
116
107
  def validate(text, path)
117
108
  if @detected_format == "json"
118
109
  validate_json(text, path)
@@ -156,7 +147,7 @@ class Lono::Template::DSL
156
147
  def yaml_format(text)
157
148
  comment =<<~EOS
158
149
  # This file was generated with lono. Do not edit directly, the changes will be lost.
159
- # More info: https://github.com/tongueroo/lono
150
+ # More info: http://lono.cloud
160
151
  EOS
161
152
  "#{comment}#{remove_blank_lines(text)}"
162
153
  end
@@ -0,0 +1,144 @@
1
+ module Lono::Template::Helpers
2
+ def template_s3_path(template_name)
3
+ format = @_detected_format.sub('yaml','yml')
4
+ template_path = "#{template_name}.#{format}"
5
+
6
+ # must have settings.s3_path for this to owrk
7
+ settings = Lono::Settings.new(@project_root)
8
+ if settings.s3_path
9
+ # high jacking Upload for useful s3_https_url method
10
+ upload = Lono::Template::Upload.new(@_options)
11
+ upload.s3_https_url(template_path)
12
+ else
13
+ message = "template_s3_path helper called but s3.path not configured in lono/settings.yml"
14
+ puts "WARN: #{message}".colorize(:yellow)
15
+ message
16
+ end
17
+ end
18
+
19
+ def template_params(param_name)
20
+ param_path = "params/#{LONO_ENV}/#{param_name}.txt"
21
+ generator_options = {
22
+ project_root: @_project_root,
23
+ path: param_path,
24
+ allow_no_file: true
25
+ }.merge(@_options)
26
+ generator = Lono::Param::Generator.new(param_name, generator_options)
27
+ # do not generate because lono cfn calling logic already generated it we only need the values
28
+ generator.params # Returns Array in underscore keys format
29
+ end
30
+
31
+ def user_data(path, vars={})
32
+ path = "#{@_project_root}/templates/user_data/#{path}"
33
+ template = IO.read(path)
34
+ variables(vars)
35
+ result = erb_result(path, template)
36
+ output = []
37
+ result.split("\n").each do |line|
38
+ output += transform(line)
39
+ end
40
+ json = output.to_json
41
+ json[0] = '' # remove first char: [
42
+ json.chop! # remove last char: ]
43
+ end
44
+
45
+ def ref(name)
46
+ %Q|{"Ref"=>"#{name}"}|
47
+ end
48
+
49
+ def find_in_map(*args)
50
+ %Q|{"Fn::FindInMap" => [ #{transform_array(args)} ]}|
51
+ end
52
+
53
+ def base64(value)
54
+ %Q|{"Fn::Base64"=>"#{value}"}|
55
+ end
56
+
57
+ def get_att(*args)
58
+ %Q|{"Fn::GetAtt" => [ #{transform_array(args)} ]}|
59
+ end
60
+
61
+ def get_azs(region="AWS::Region")
62
+ %Q|{"Fn::GetAZs"=>"#{region}"}|
63
+ end
64
+
65
+ def join(delimiter, values)
66
+ %Q|{"Fn::Join" => ["#{delimiter}", [ #{transform_array(values)} ]]}|
67
+ end
68
+
69
+ def select(index, list)
70
+ %Q|{"Fn::Select" => ["#{index}", [ #{transform_array(list)} ]]}|
71
+ end
72
+
73
+ def partial_exist?(path)
74
+ path = partial_path_for(path)
75
+ path = auto_add_format(path)
76
+ path && File.exist?(path)
77
+ end
78
+
79
+ # The partial's path is a relative path given without the extension and
80
+ #
81
+ # Example:
82
+ # Given: file in templates/partial/iam/docker.yml
83
+ # The path should be: iam/docker
84
+ #
85
+ # If the user specifies the extension then use that instead of auto-adding
86
+ # the detected format.
87
+ def partial(path,vars={}, options={})
88
+ path = partial_path_for(path)
89
+ path = auto_add_format(path)
90
+
91
+ template = IO.read(path)
92
+ variables(vars)
93
+ result = erb_result(path, template)
94
+ result = indent(result, options[:indent]) if options[:indent]
95
+ if options[:indent]
96
+ # Add empty line at beginning because empty lines gets stripped during
97
+ # processing anyway. This allows the user to call partial without having
98
+ # to put the partial call at very beginning of the line.
99
+ # This only should happen if user is using indent option.
100
+ ["\n", result].join("\n")
101
+ else
102
+ result
103
+ end
104
+ end
105
+
106
+ # add indentation
107
+ def indent(text, indentation_amount)
108
+ text.split("\n").map do |line|
109
+ " " * indentation_amount + line
110
+ end.join("\n")
111
+ end
112
+
113
+ private
114
+ def partial_path_for(path)
115
+ "#{@_project_root}/templates/partial/#{path}"
116
+ end
117
+
118
+ def auto_add_format(path)
119
+ # Return immediately if user provided explicit extension
120
+ extension = File.extname(path) # current extension
121
+ return path if !extension.empty?
122
+
123
+ # Else let's auto detect
124
+ paths = Dir.glob("#{path}.*")
125
+
126
+ if paths.size == 1 # non-ambiguous match
127
+ return paths.first
128
+ end
129
+
130
+ if paths.size > 1 # ambiguous match
131
+ puts "ERROR: Multiple possible partials found:".colorize(:red)
132
+ paths.each do |path|
133
+ puts " #{path}"
134
+ end
135
+ puts "Please specify an extension in the name to remove the ambiguity.".colorize(:green)
136
+ exit 1
137
+ end
138
+
139
+ # Account for case when user wants to include a file with no extension at all
140
+ return path if File.exist?(path) && !File.directory?(path)
141
+
142
+ path # original path if this point is reached
143
+ end
144
+ end
@@ -3,45 +3,83 @@ require 'json'
3
3
  require 'base64'
4
4
 
5
5
  class Lono::Template::Template
6
+ include Lono::Template::Helpers
6
7
  include ERB::Util
7
8
 
8
- attr_reader :name
9
- def initialize(name, block, options={})
10
- @name = name
11
- @block = block
12
- @options = options
9
+ def initialize(name, block=nil, options={})
10
+ # Taking care to name instance variables with _ in front because we load the
11
+ # variables from config/variables and those instance variables can clobber these
12
+ # instance variables
13
+ @_name = name
14
+ @_options = options
15
+ @_detected_format = options[:detected_format]
16
+ @_block = block
17
+ @_project_root = options[:project_root] || '.'
18
+ @_config_path = "#{@_project_root}/config"
19
+ @_source = default_source(name)
20
+ end
21
+
22
+ def default_source(name)
23
+ "#{@_project_root}/templates/#{name}.#{@_detected_format}" # defaults to name, source method overrides
13
24
  end
14
25
 
15
26
  def build
16
- instance_eval(&@block)
17
- template = IO.read(@source)
18
- erb_result(@source, template)
27
+ load_variables
28
+ load_custom_helpers
29
+ instance_eval(&@_block) if @_block
30
+ template = IO.read(@_source)
31
+ erb_result(@_source, template)
19
32
  end
20
33
 
21
- def source(path)
22
- @source = path[0..0] == '/' ? path : "#{@options[:project_root]}/templates/#{path}"
34
+ def load_variables
35
+ load_variables_folder("base")
36
+ load_variables_folder(LONO_ENV)
23
37
  end
24
38
 
25
- def variables(vars={})
26
- vars.each do |var,value|
27
- instance_variable_set("@#{var}", value)
39
+ # Load the variables defined in config/variables/* to make available in the
40
+ # template blocks in config/templates/*.
41
+ #
42
+ # Example:
43
+ #
44
+ # `config/variables/base/variables.rb`:
45
+ # @foo = 123
46
+ #
47
+ # `config/templates/base/resources.rb`:
48
+ # template "mytemplate.yml" do
49
+ # source "mytemplate.yml.erb"
50
+ # variables(foo: @foo)
51
+ # end
52
+ #
53
+ # NOTE: Only able to make instance variables avaialble with instance_eval
54
+ # Wasnt able to make local variables available.
55
+ def load_variables_folder(folder)
56
+ paths = Dir.glob("#{@_config_path}/variables/#{folder}/**/*")
57
+ paths.select{ |e| File.file? e }.each do |path|
58
+ instance_eval(IO.read(path))
28
59
  end
29
60
  end
30
61
 
31
- def partial(path,vars={}, options={})
32
- path = "#{@options[:project_root]}/templates/partial/#{path}"
33
- template = IO.read(path)
34
- variables(vars)
35
- result = erb_result(path, template)
36
- result = indent(result, options[:indent]) if options[:indent]
37
- result
62
+ # Load custom helper methods from the user's infra repo
63
+ def load_custom_helpers
64
+ Dir.glob("#{@_project_root}/helpers/**/*_helper.rb").each do |path|
65
+ filename = path.sub(%r{.*/},'').sub('.rb','')
66
+ module_name = filename.classify
67
+
68
+ require path
69
+ self.class.send :include, module_name.constantize
70
+ end
71
+
38
72
  end
39
73
 
40
- # add indentation
41
- def indent(result, indentation_amount)
42
- result.split("\n").map do |line|
43
- " " * indentation_amount + line
44
- end.join("\n")
74
+ def source(path)
75
+ @_source = path[0..0] == '/' ? path : "#{@_project_root}/templates/#{path}"
76
+ @_source += ".#{@_detected_format}"
77
+ end
78
+
79
+ def variables(vars={})
80
+ vars.each do |var,value|
81
+ instance_variable_set("@#{var}", value)
82
+ end
45
83
  end
46
84
 
47
85
  def erb_result(path, template)
@@ -49,6 +87,7 @@ class Lono::Template::Template
49
87
  ERB.new(template, nil, "-").result(binding)
50
88
  rescue Exception => e
51
89
  puts e
90
+ puts e.backtrace if ENV['DEBUG']
52
91
 
53
92
  # how to know where ERB stopped? - https://www.ruby-forum.com/topic/182051
54
93
  # syntax errors have the (erb):xxx info in e.message
@@ -57,7 +96,7 @@ class Lono::Template::Template
57
96
  error_info ||= e.backtrace.grep(/\(erb\)/)[0]
58
97
  raise unless error_info # unable to find the (erb):xxx: error line
59
98
  line = error_info.split(':')[1].to_i
60
- puts "Error evaluating ERB template on line #{line.to_s.colorize(:red)} of: #{path.sub(/^\.\//, '')}"
99
+ puts "Error evaluating ERB template on line #{line.to_s.colorize(:red)} of: #{path.sub(/^\.\//, '').colorize(:green)}"
61
100
 
62
101
  template_lines = template.split("\n")
63
102
  context = 5 # lines of context
@@ -75,48 +114,6 @@ class Lono::Template::Template
75
114
  end
76
115
  end
77
116
 
78
- def user_data(path, vars={})
79
- path = "#{@options[:project_root]}/templates/user_data/#{path}"
80
- template = IO.read(path)
81
- variables(vars)
82
- result = erb_result(path, template)
83
- output = []
84
- result.split("\n").each do |line|
85
- output += transform(line)
86
- end
87
- json = output.to_json
88
- json[0] = '' # remove first char: [
89
- json.chop! # remove last char: ]
90
- end
91
-
92
- def ref(name)
93
- %Q|{"Ref"=>"#{name}"}|
94
- end
95
-
96
- def find_in_map(*args)
97
- %Q|{"Fn::FindInMap" => [ #{transform_array(args)} ]}|
98
- end
99
-
100
- def base64(value)
101
- %Q|{"Fn::Base64"=>"#{value}"}|
102
- end
103
-
104
- def get_att(*args)
105
- %Q|{"Fn::GetAtt" => [ #{transform_array(args)} ]}|
106
- end
107
-
108
- def get_azs(region="AWS::Region")
109
- %Q|{"Fn::GetAZs"=>"#{region}"}|
110
- end
111
-
112
- def join(delimiter, values)
113
- %Q|{"Fn::Join" => ["#{delimiter}", [ #{transform_array(values)} ]]}|
114
- end
115
-
116
- def select(index, list)
117
- %Q|{"Fn::Select" => ["#{index}", [ #{transform_array(list)} ]]}|
118
- end
119
-
120
117
  def transform_array(arr)
121
118
  arr.map! {|x| x =~ /=>/ ? x : x.inspect }
122
119
  arr.join(',')
@@ -248,4 +245,8 @@ class Lono::Template::Template
248
245
  def encode_base64(text)
249
246
  Base64.strict_encode64(text).strip
250
247
  end
248
+
249
+ def name
250
+ @_name
251
+ end
251
252
  end
@@ -0,0 +1,75 @@
1
+ require 'erb'
2
+ require 'json'
3
+ require 'base64'
4
+
5
+ class Lono::Template::Upload
6
+ include Lono::Template::AwsServices
7
+
8
+ def initialize(options={})
9
+ @options = options
10
+ @project_root = options[:project_root] || '.'
11
+ end
12
+
13
+ def run
14
+ ensure_s3_setup!
15
+ paths = Dir.glob("#{@project_root}/output/**/*")
16
+ paths.reject { |p| p =~ %r{output/params} }.
17
+ select { |p| File.file?(p) }.each do |path|
18
+ upload(path)
19
+ end
20
+ say "Templates uploaded to s3."
21
+ end
22
+
23
+ def upload(path)
24
+ pretty_path = path.sub(/^\.\//, '')
25
+ key = "#{s3_path}/#{LONO_ENV}/#{pretty_path.sub(/^output\//,'')}"
26
+ s3_full_path = "s3://#{s3_bucket}/#{key}"
27
+
28
+ resp = s3.put_object(
29
+ body: IO.read(path),
30
+ bucket: s3_bucket,
31
+ key: key,
32
+ storage_class: "REDUCED_REDUNDANCY"
33
+ ) unless @options[:noop]
34
+
35
+ message = "Uploaded: #{pretty_path} to #{s3_full_path}"
36
+ message = "NOOP: #{message}" if @options[:noop]
37
+ say message
38
+ end
39
+
40
+ # https://s3.amazonaws.com/mybucket/cloudformation-templates/prod/parent.yml
41
+ def s3_https_url(template_path)
42
+ ensure_s3_setup!
43
+ "https://s3.amazonaws.com/#{s3_bucket}/#{s3_path}/#{LONO_ENV}/#{template_path}"
44
+ end
45
+
46
+ # Example:
47
+ # s3_bucket('s3://mybucket/templates/storage/path') => mybucket
48
+ def s3_bucket
49
+ @s3_full_path.sub('s3://','').split('/').first
50
+ end
51
+
52
+ # Example:
53
+ # s3_bucket('s3://mybucket/templates/storage/path') => templates/storage/path
54
+ def s3_path
55
+ @s3_full_path.sub('s3://','').split('/')[1..-1].join('/')
56
+ end
57
+
58
+ # nice warning if the s3 path not found
59
+ def ensure_s3_setup!
60
+ return if @options[:noop]
61
+
62
+ settings = Lono::Settings.new(@project_root)
63
+ if settings.s3_path
64
+ @s3_full_path = settings.s3_path
65
+ else
66
+ say "Unable to upload templates to s3 because you have not configured the s3.path option in .lono/settings.yml.".colorize(:red)
67
+ say "Please configure .lono/settings.yml with s3.path. Refer to http://lono.cloud/docs/settings/ for more help.".colorize(:red)
68
+ exit 1
69
+ end
70
+ end
71
+
72
+ def say(message)
73
+ puts message unless @options[:quiet]
74
+ end
75
+ end