configmonkey_cli 1.0.0
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/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +87 -0
- data/Rakefile +1 -0
- data/VERSION +1 -0
- data/bin/configmonkey +9 -0
- data/bin/configmonkey.sh +14 -0
- data/configmonkey_cli.gemspec +27 -0
- data/lib/configmonkey_cli.rb +43 -0
- data/lib/configmonkey_cli/application.rb +159 -0
- data/lib/configmonkey_cli/application/colorize.rb +22 -0
- data/lib/configmonkey_cli/application/configuration.rb +38 -0
- data/lib/configmonkey_cli/application/configuration.tpl +0 -0
- data/lib/configmonkey_cli/application/core.rb +78 -0
- data/lib/configmonkey_cli/application/dispatch.rb +81 -0
- data/lib/configmonkey_cli/application/manifest.rb +316 -0
- data/lib/configmonkey_cli/application/manifest_actions/base.rb +90 -0
- data/lib/configmonkey_cli/application/manifest_actions/chmod.rb +40 -0
- data/lib/configmonkey_cli/application/manifest_actions/copy.rb +38 -0
- data/lib/configmonkey_cli/application/manifest_actions/custom.rb +52 -0
- data/lib/configmonkey_cli/application/manifest_actions/inplace.rb +28 -0
- data/lib/configmonkey_cli/application/manifest_actions/invoke.rb +45 -0
- data/lib/configmonkey_cli/application/manifest_actions/link.rb +46 -0
- data/lib/configmonkey_cli/application/manifest_actions/mkdir.rb +41 -0
- data/lib/configmonkey_cli/application/manifest_actions/remove.rb +46 -0
- data/lib/configmonkey_cli/application/manifest_actions/rsync.rb +112 -0
- data/lib/configmonkey_cli/application/manifest_actions/rtfm.rb +32 -0
- data/lib/configmonkey_cli/application/manifest_actions/sync_links.rb +60 -0
- data/lib/configmonkey_cli/application/manifest_actions/template.rb +38 -0
- data/lib/configmonkey_cli/application/output_helper.rb +30 -0
- data/lib/configmonkey_cli/helper.rb +62 -0
- data/lib/configmonkey_cli/version.rb +4 -0
- metadata +162 -0
File without changes
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module ConfigmonkeyCli
|
2
|
+
class Application
|
3
|
+
module Core
|
4
|
+
# ===================
|
5
|
+
# = Signal trapping =
|
6
|
+
# ===================
|
7
|
+
def trap_signals
|
8
|
+
debug "Trapping INT signal..."
|
9
|
+
$interruptable_threads = []
|
10
|
+
Signal.trap("INT") do
|
11
|
+
$cm_runtime_exiting = true
|
12
|
+
$interruptable_threads.each{|thr| thr.raise(Interrupt) if thr.alive? }
|
13
|
+
Kernel.puts "Interrupting..."
|
14
|
+
end
|
15
|
+
# Signal.trap("TERM") do
|
16
|
+
# $cm_runtime_exiting = true
|
17
|
+
# Kernel.puts "Terminating..."
|
18
|
+
# end
|
19
|
+
end
|
20
|
+
|
21
|
+
def release_signals
|
22
|
+
debug "Releasing INT signal..."
|
23
|
+
Signal.trap("INT", "DEFAULT")
|
24
|
+
# Signal.trap("TERM", "DEFAULT")
|
25
|
+
end
|
26
|
+
|
27
|
+
def haltpoint thr = Thread.current
|
28
|
+
thr.raise Interrupt if $cm_runtime_exiting
|
29
|
+
end
|
30
|
+
|
31
|
+
def interruptable &block
|
32
|
+
Thread.new do
|
33
|
+
begin
|
34
|
+
thr = Thread.current
|
35
|
+
$interruptable_threads << Thread.current
|
36
|
+
thr[:return_value] = block.call(thr)
|
37
|
+
ensure
|
38
|
+
$interruptable_threads.delete(Thread.current)
|
39
|
+
end
|
40
|
+
end.join[:return_value]
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
# ==========
|
45
|
+
# = Events =
|
46
|
+
# ==========
|
47
|
+
def hook *which, &hook_block
|
48
|
+
which.each do |w|
|
49
|
+
@hooks[w.to_sym] ||= []
|
50
|
+
@hooks[w.to_sym] << hook_block
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def fire which, *args
|
55
|
+
return if @disable_event_firing
|
56
|
+
sync { debug "[Event] Firing #{which} (#{@hooks[which].try(:length) || 0} handlers) #{args.map(&:class)}", 99 }
|
57
|
+
@hooks[which] && @hooks[which].each{|h| h.call(*args) }
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
# ==========
|
62
|
+
# = Logger =
|
63
|
+
# ==========
|
64
|
+
def logger_filename
|
65
|
+
"#{cm_cfg_path}/logs/configmonkey.log"
|
66
|
+
end
|
67
|
+
|
68
|
+
def logger
|
69
|
+
sync do
|
70
|
+
@logger ||= begin
|
71
|
+
FileUtils.mkdir_p(File.dirname(@opts[:logfile]))
|
72
|
+
Logger.new(@opts[:logfile], 10, 1024000)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module ConfigmonkeyCli
|
2
|
+
class Application
|
3
|
+
module Dispatch
|
4
|
+
def dispatch action = (@opts[:dispatch] || :help)
|
5
|
+
if respond_to?("dispatch_#{action}")
|
6
|
+
send("dispatch_#{action}")
|
7
|
+
else
|
8
|
+
abort("unknown action #{action}", 1)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def dispatch_help
|
13
|
+
puts @optparse.to_s
|
14
|
+
end
|
15
|
+
|
16
|
+
def dispatch_generate_manifest
|
17
|
+
puts c("Not implemented", :red)
|
18
|
+
# puts c("Generating example config `#{cfg_name}'")
|
19
|
+
# if File.exist?(cfg_file)
|
20
|
+
# abort "Conflict, file already exists: #{cfg_file}", 1
|
21
|
+
# else
|
22
|
+
# generate_config(cfg_name)
|
23
|
+
# puts c("Writing #{cfg_file}...", :green)
|
24
|
+
# end
|
25
|
+
end
|
26
|
+
|
27
|
+
def dispatch_index
|
28
|
+
Thread.abort_on_exception = true
|
29
|
+
trap_signals
|
30
|
+
|
31
|
+
@running = true
|
32
|
+
load_and_execute_manifest
|
33
|
+
rescue Manifest::ExecutionError => ex
|
34
|
+
error "\nTraceback (most recent call last):"
|
35
|
+
ex.backtrace.reverse.each_with_index {|l, i| error "\t#{"#{ex.backtrace.length - i}:".rjust(4)} #{l}" }
|
36
|
+
error "\n" << "[#{ex.class}] #{ex.message}".strip
|
37
|
+
ensure
|
38
|
+
@running = false
|
39
|
+
release_signals
|
40
|
+
end
|
41
|
+
|
42
|
+
def dispatch_info
|
43
|
+
your_version = Gem::Version.new(ConfigmonkeyCli::VERSION)
|
44
|
+
puts c ""
|
45
|
+
puts c(" Your version: ", :yellow) << c("#{your_version}", :magenta)
|
46
|
+
|
47
|
+
print c(" Current version: ", :yellow)
|
48
|
+
if @opts[:check_for_updates]
|
49
|
+
require "net/http"
|
50
|
+
print c("checking...", :blue)
|
51
|
+
|
52
|
+
begin
|
53
|
+
current_version = Gem::Version.new Net::HTTP.get_response(URI.parse(ConfigmonkeyCli::UPDATE_URL)).body.strip
|
54
|
+
|
55
|
+
if current_version > your_version
|
56
|
+
status = c("#{current_version} (consider update)", :red)
|
57
|
+
elsif current_version < your_version
|
58
|
+
status = c("#{current_version} (ahead, beta)", :green)
|
59
|
+
else
|
60
|
+
status = c("#{current_version} (up2date)", :green)
|
61
|
+
end
|
62
|
+
rescue
|
63
|
+
status = c("failed (#{$!.message})", :red)
|
64
|
+
end
|
65
|
+
|
66
|
+
print "#{"\b" * 11}#{" " * 11}#{"\b" * 11}" # reset line
|
67
|
+
puts status
|
68
|
+
else
|
69
|
+
puts c("check disabled", :red)
|
70
|
+
end
|
71
|
+
|
72
|
+
# more info
|
73
|
+
puts c ""
|
74
|
+
puts c " Configmonkey CLI is brought to you by #{c "bmonkeys.net", :green}"
|
75
|
+
puts c " Contribute @ #{c "github.com/2called-chaos/configmonkey_cli", :cyan}"
|
76
|
+
puts c " Eat bananas every day!"
|
77
|
+
puts c ""
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,316 @@
|
|
1
|
+
module ConfigmonkeyCli
|
2
|
+
class Application
|
3
|
+
class Manifest
|
4
|
+
MANIFEST_ACTIONS = [:chmod, :copy, :custom, :inplace, :invoke, :link, :mkdir, :rsync, :remove, :rtfm, :sync_links, :template]
|
5
|
+
|
6
|
+
class ExecutionError < ::RuntimeError
|
7
|
+
def initialize file, original_exception
|
8
|
+
@file = file
|
9
|
+
@original_exception = original_exception
|
10
|
+
end
|
11
|
+
|
12
|
+
def message
|
13
|
+
ln = ex.message[@file] && ex.message.match(/#{Regexp.escape(@file)}:([0-9]+)/)&.to_a&.second
|
14
|
+
ln ||= backtrace.reverse.detect{|l| l[@file] }&.split(":")&.second
|
15
|
+
"#{@file}#{":#{ln}" if ln}\n --- #{ex.message.gsub(@file, "<manifest>")}"
|
16
|
+
end
|
17
|
+
|
18
|
+
def backtrace
|
19
|
+
ex.backtrace
|
20
|
+
end
|
21
|
+
|
22
|
+
def original_exception
|
23
|
+
@original_exception
|
24
|
+
end
|
25
|
+
alias_method :ex, :original_exception
|
26
|
+
end
|
27
|
+
|
28
|
+
class Invalid < ExecutionError
|
29
|
+
end
|
30
|
+
|
31
|
+
class ThorHelperApp < Thor
|
32
|
+
include Thor::Actions
|
33
|
+
end
|
34
|
+
|
35
|
+
attr_reader :app, :directory, :manifest_file, :actions, :thor, :padding
|
36
|
+
|
37
|
+
def initialize app, directory, manifest_file = "manifest.rb"
|
38
|
+
@app = app
|
39
|
+
@directory = directory
|
40
|
+
init_thor!
|
41
|
+
@padding = 26
|
42
|
+
@manifest_file = File.join(directory, manifest_file || "manifest.rb")
|
43
|
+
@actions = []
|
44
|
+
@host_constraint = []
|
45
|
+
@target_directory = app.opts[:target_directory]
|
46
|
+
all do
|
47
|
+
begin
|
48
|
+
eval File.read(@manifest_file, encoding: "utf-8"), binding, @manifest_file
|
49
|
+
rescue Exception => ex
|
50
|
+
raise Invalid.new(@manifest_file, ex)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
app.debug "§constraint-final:#{@host_constraint}", 120
|
54
|
+
end
|
55
|
+
|
56
|
+
def checksum *args
|
57
|
+
opts = args.extract_options!
|
58
|
+
if opts[:soft]
|
59
|
+
@_checksum_soft ||= begin
|
60
|
+
to_c = args.map(&:to_s)
|
61
|
+
to_c.unshift @manifest_file
|
62
|
+
Digest::SHA1.hexdigest(to_c.to_s)
|
63
|
+
end
|
64
|
+
else
|
65
|
+
@_checksum_hard ||= begin
|
66
|
+
to_c = args.map(&:to_s)
|
67
|
+
to_c.unshift Digest::SHA1.file(@manifest_file)
|
68
|
+
Digest::SHA1.hexdigest(to_c.to_s)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def to_s
|
74
|
+
"#<…::Manifest @directory=#{@directory} @actions=#{@actions.length}>"
|
75
|
+
end
|
76
|
+
|
77
|
+
def init_thor!
|
78
|
+
ThorHelperApp.source_root(directory)
|
79
|
+
@thor = ThorHelperApp.new([], { pretend: app.opts[:simulation] })
|
80
|
+
|
81
|
+
@thor.shell.class_eval do
|
82
|
+
def say_status(status, message, log_status = true)
|
83
|
+
return if quiet? || log_status == false
|
84
|
+
spaces = " " * (padding + 1)
|
85
|
+
color = log_status.is_a?(Symbol) ? log_status : :green
|
86
|
+
|
87
|
+
status = status.to_s.rjust(12)
|
88
|
+
status = set_color status, color, true if color
|
89
|
+
|
90
|
+
if($cm_current_action_name)
|
91
|
+
cm_action = $cm_current_action_name.to_s.rjust(12)
|
92
|
+
cm_action = set_color cm_action, $cm_current_action_color, true if $cm_current_action_color
|
93
|
+
end
|
94
|
+
|
95
|
+
buffer = "#{cm_action}#{status}#{spaces}#{message}"
|
96
|
+
buffer = "#{buffer}\n" unless buffer.end_with?("\n")
|
97
|
+
|
98
|
+
stdout.print(buffer)
|
99
|
+
stdout.flush
|
100
|
+
end
|
101
|
+
|
102
|
+
def ask q, *args, &block
|
103
|
+
ConfigmonkeyCli::Application.instance_method(:interruptable).bind(Object.new).call do
|
104
|
+
begin
|
105
|
+
q = "\a" << q if ENV["THOR_ASK_BELL"]
|
106
|
+
super(q, *args, &block)
|
107
|
+
rescue Interrupt => ex
|
108
|
+
Thread.main.raise(ex)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def _dump!
|
116
|
+
@actions.each do |constraint, action, instance|
|
117
|
+
begin
|
118
|
+
$cm_current_action_name = action
|
119
|
+
$cm_current_action_color = :magenta
|
120
|
+
thor.say_status :dump, instance, :black
|
121
|
+
ensure
|
122
|
+
$cm_current_action_name = $cm_current_action_color = nil
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def _simulate!
|
128
|
+
_execute!(true)
|
129
|
+
end
|
130
|
+
|
131
|
+
def _execute! simulate = false
|
132
|
+
set_destination_root(@target_directory, false)
|
133
|
+
|
134
|
+
if simulate
|
135
|
+
thor.say_status :info, thor.set_color("---> !!! SIMULATION ONLY !!! <---", :green), :cyan
|
136
|
+
else
|
137
|
+
thor.say_status :info, thor.set_color("---> !!! HOT HOT HOT !!! <---", :red), :cyan
|
138
|
+
end
|
139
|
+
thor.say_status :info, (thor.set_color("Source Dir: ", :magenta) << thor.set_color(directory, :blue)), :cyan
|
140
|
+
thor.say_status :info, (thor.set_color(" Dest Root: ", :magenta) << thor.set_color(thor.destination_root, :blue)), :cyan
|
141
|
+
@actions.each_with_index do |(constraint, action, instance), index|
|
142
|
+
begin
|
143
|
+
$cm_current_action_index = index
|
144
|
+
$cm_current_action_name = action
|
145
|
+
$cm_current_action_color = :magenta
|
146
|
+
instance.prepare
|
147
|
+
simulate ? instance.simulate : instance.destructive
|
148
|
+
ensure
|
149
|
+
$cm_current_action_index = $cm_current_action_name = $cm_current_action_color = nil
|
150
|
+
app.haltpoint
|
151
|
+
end
|
152
|
+
end
|
153
|
+
rescue Interrupt, SystemExit
|
154
|
+
raise
|
155
|
+
rescue Exception => ex
|
156
|
+
raise(ExecutionError.new(@manifest_file, ex))
|
157
|
+
end
|
158
|
+
|
159
|
+
def _with_constraint *constraint
|
160
|
+
if @host_constraint.last == constraint
|
161
|
+
return yield if block_given?
|
162
|
+
end
|
163
|
+
if _breached_constraint?(constraint)
|
164
|
+
app.debug "§constraint-ignore:#{constraint}", 119
|
165
|
+
return
|
166
|
+
end
|
167
|
+
begin
|
168
|
+
@host_constraint << constraint
|
169
|
+
app.debug "§constraint-push:#{constraint}", 120
|
170
|
+
app.debug "§constraint-now:#{@host_constraint}", 121
|
171
|
+
yield if block_given?
|
172
|
+
ensure
|
173
|
+
app.debug "§constraint-pop:#{@host_constraint.pop}", 120
|
174
|
+
app.debug "§constraint-now:#{@host_constraint}", 121
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def _breached_constraint? constraint = nil
|
179
|
+
in_constraint = catch :return_value do
|
180
|
+
[@host_constraint, [constraint || []]].each do |list|
|
181
|
+
list.each do |act, args|
|
182
|
+
case act
|
183
|
+
when :any then next
|
184
|
+
when :on
|
185
|
+
args.include?(app.opts[:hostname]) ? next : throw(:return_value, false)
|
186
|
+
when :not_on
|
187
|
+
args.include?(app.opts[:hostname]) ? throw(:return_value, false) : next
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
true
|
192
|
+
end
|
193
|
+
!in_constraint
|
194
|
+
end
|
195
|
+
|
196
|
+
def push_action *args
|
197
|
+
if $cm_current_action_index
|
198
|
+
@actions.insert $cm_current_action_index + 1, [@host_constraint.dup] + args
|
199
|
+
else
|
200
|
+
@actions.push [@host_constraint.dup] + args
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def set_destination_root drpath, from_manifest = true
|
205
|
+
if from_manifest
|
206
|
+
base = File.realpath(File.expand_path(@directory))
|
207
|
+
xpath = File.expand_path(drpath[0] == "/" ? drpath : File.join(base, drpath))
|
208
|
+
if app.opts[:target_directory] != "/"
|
209
|
+
thor.say_status :warn, (thor.set_color(" Dest Root: ", :magenta) << thor.set_color(xpath, :blue) << thor.set_color(" IGNORED! -o parameter will take precedence", :red)), :red
|
210
|
+
else
|
211
|
+
@target_directory = xpath
|
212
|
+
end
|
213
|
+
else
|
214
|
+
thor.destination_root = File.realpath(File.expand_path(drpath))
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
|
219
|
+
# =======
|
220
|
+
# = DSL =
|
221
|
+
# =======
|
222
|
+
|
223
|
+
def padded str, *color
|
224
|
+
"".rjust(padding, " ") << (color.any? ? c(str.to_s, *color) : str.to_s)
|
225
|
+
end
|
226
|
+
|
227
|
+
def c str, *color
|
228
|
+
thor.set_color(str, *color)
|
229
|
+
end
|
230
|
+
|
231
|
+
def say str, *color
|
232
|
+
thor.say((color.any? ? c(str.to_s, *color) : str.to_s))
|
233
|
+
end
|
234
|
+
|
235
|
+
# do block no matter the `hostname`
|
236
|
+
def all &block
|
237
|
+
_with_constraint(:any, &block)
|
238
|
+
end
|
239
|
+
|
240
|
+
# do block only if `hostname` is in *hosts
|
241
|
+
def on *hosts, &block
|
242
|
+
_with_constraint(:on, hosts.flatten.map(&:to_s), &block)
|
243
|
+
end
|
244
|
+
|
245
|
+
# do block except if `hostname` is in *hosts
|
246
|
+
def not_on *hosts, &block
|
247
|
+
_with_constraint(:not_on, hosts.flatten.map(&:to_s), &block)
|
248
|
+
end
|
249
|
+
|
250
|
+
MANIFEST_ACTIONS.each do |meth|
|
251
|
+
define_method(meth) do |*args, &block|
|
252
|
+
push_action(meth, "ConfigmonkeyCli::Application::ManifestAction::#{meth.to_s.camelize}".constantize.new(app, self, *args, &block))
|
253
|
+
app.haltpoint
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
def ask question, opts = {}
|
258
|
+
if opts[:use_thread] != false
|
259
|
+
return app.interruptable { ask(question, opts.merge(use_thread: false)) }
|
260
|
+
end
|
261
|
+
opts[:limited_to] = opts.delete(:choose) if opts[:choose]
|
262
|
+
opts[:add_to_history] = true unless opts.key?(:add_to_history)
|
263
|
+
color = opts.delete(:color)
|
264
|
+
spaces = "".ljust(opts[:padding]) if opts[:padding]
|
265
|
+
begin
|
266
|
+
@thor.ask("#{spaces}#{question}", color, opts).presence
|
267
|
+
rescue Interrupt
|
268
|
+
app.haltpoint(Thread.main)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def yes? question, opts = {}
|
273
|
+
opts[:quit] = true unless opts.key?(:quit)
|
274
|
+
opts[:default] = true unless opts.key?(:default)
|
275
|
+
opts[:padding] = @padding unless opts.key?(:padding)
|
276
|
+
return true if app.opts[:default_yes]
|
277
|
+
return opts[:default] if app.opts[:default_accept]
|
278
|
+
o = "#{opts[:default] ? :Yn : :yN}"
|
279
|
+
o << "h" if opts[:help]
|
280
|
+
o << "q" if opts[:quit]
|
281
|
+
q = "#{question} [#{o}]"
|
282
|
+
c = opts[:color].presence || (opts[:default] ? :red : :yellow)
|
283
|
+
askopts = opts.slice(:padding, :limited_to, :choose, :add_to_history).merge(color: c, use_thread: false)
|
284
|
+
if askopts[:padding] && askopts[:padding] > 10
|
285
|
+
qq = thor.set_color(q, askopts[:color]) if askopts[:color]
|
286
|
+
q = "#{thor.set_color("?", :red)} #{qq || q}"
|
287
|
+
askopts[:padding] -= 3
|
288
|
+
end
|
289
|
+
|
290
|
+
app.interruptable do
|
291
|
+
catch :return_value do
|
292
|
+
loop {
|
293
|
+
x = (ask(q, askopts) || (opts[:default] ? :y : :n)).to_s.downcase.strip
|
294
|
+
|
295
|
+
if ["y", "yes", "1", "t", "true"].include?(x)
|
296
|
+
throw :return_value, true
|
297
|
+
elsif ["n", "no", "0", "f", "false"].include?(x)
|
298
|
+
throw :return_value, false
|
299
|
+
elsif ["h", "help", "?"].include?(x)
|
300
|
+
@thor.say_status :help, "#{opts[:help]}", :cyan
|
301
|
+
elsif ["q", "quit", "exit"].include?(x)
|
302
|
+
raise SystemExit
|
303
|
+
else
|
304
|
+
@thor.say_status :warn, "choose one of y|yes|1|t|true|n|no|0|f|false#{"|q|quit|exit" if opts[:quit]}#{"|?|h|help" if opts[:help]}", :red
|
305
|
+
end
|
306
|
+
}
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
def no? question, opts = {}
|
312
|
+
!yes?(question, opts.merge(default: false))
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|