r10k 3.7.0 → 3.9.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.github/pull_request_template.md +1 -1
  3. data/.github/workflows/docker.yml +4 -1
  4. data/.github/workflows/release.yml +3 -2
  5. data/.github/workflows/rspec_tests.yml +1 -1
  6. data/.github/workflows/stale.yml +19 -0
  7. data/.travis.yml +8 -1
  8. data/CHANGELOG.mkd +32 -0
  9. data/CODEOWNERS +2 -2
  10. data/doc/common-patterns.mkd +1 -0
  11. data/doc/dynamic-environments/configuration.mkd +114 -42
  12. data/doc/dynamic-environments/usage.mkd +12 -11
  13. data/doc/puppetfile.mkd +23 -3
  14. data/docker/Gemfile +1 -1
  15. data/docker/Makefile +4 -3
  16. data/docker/docker-compose.yml +18 -0
  17. data/docker/r10k/Dockerfile +1 -1
  18. data/docker/r10k/docker-entrypoint.sh +0 -1
  19. data/docker/r10k/release.Dockerfile +1 -1
  20. data/docker/spec/dockerfile_spec.rb +26 -32
  21. data/integration/tests/git_source/git_source_repeated_remote.rb +2 -2
  22. data/integration/tests/user_scenario/basic_workflow/multi_env_custom_forge_git_module.rb +2 -1
  23. data/integration/tests/user_scenario/basic_workflow/multi_env_custom_forge_git_module_static.rb +2 -1
  24. data/integration/tests/user_scenario/basic_workflow/multi_source_custom_forge_git_module.rb +1 -1
  25. data/integration/tests/user_scenario/basic_workflow/single_env_custom_forge_git_module.rb +2 -1
  26. data/lib/r10k/action/base.rb +10 -0
  27. data/lib/r10k/action/deploy/display.rb +49 -10
  28. data/lib/r10k/action/deploy/environment.rb +101 -51
  29. data/lib/r10k/action/deploy/module.rb +54 -30
  30. data/lib/r10k/action/puppetfile/check.rb +3 -1
  31. data/lib/r10k/action/puppetfile/install.rb +20 -23
  32. data/lib/r10k/action/puppetfile/purge.rb +8 -2
  33. data/lib/r10k/action/runner.rb +33 -0
  34. data/lib/r10k/cli/deploy.rb +13 -7
  35. data/lib/r10k/cli/puppetfile.rb +5 -5
  36. data/lib/r10k/content_synchronizer.rb +83 -0
  37. data/lib/r10k/deployment.rb +1 -1
  38. data/lib/r10k/environment/base.rb +29 -2
  39. data/lib/r10k/environment/git.rb +17 -5
  40. data/lib/r10k/environment/name.rb +22 -4
  41. data/lib/r10k/environment/svn.rb +11 -4
  42. data/lib/r10k/environment/with_modules.rb +46 -30
  43. data/lib/r10k/git.rb +1 -0
  44. data/lib/r10k/git/rugged/credentials.rb +39 -2
  45. data/lib/r10k/initializers.rb +1 -0
  46. data/lib/r10k/module.rb +1 -1
  47. data/lib/r10k/module/base.rb +17 -1
  48. data/lib/r10k/module/forge.rb +29 -11
  49. data/lib/r10k/module/git.rb +50 -27
  50. data/lib/r10k/module/local.rb +2 -1
  51. data/lib/r10k/module/svn.rb +24 -18
  52. data/lib/r10k/puppetfile.rb +66 -83
  53. data/lib/r10k/settings.rb +18 -2
  54. data/lib/r10k/source/base.rb +9 -0
  55. data/lib/r10k/source/git.rb +18 -7
  56. data/lib/r10k/source/hash.rb +5 -5
  57. data/lib/r10k/source/svn.rb +5 -3
  58. data/lib/r10k/util/cleaner.rb +21 -0
  59. data/lib/r10k/util/setopts.rb +33 -12
  60. data/lib/r10k/version.rb +1 -1
  61. data/locales/r10k.pot +98 -82
  62. data/r10k.gemspec +1 -1
  63. data/spec/fixtures/unit/action/r10k_creds.yaml +9 -0
  64. data/spec/r10k-mocks/mock_source.rb +1 -1
  65. data/spec/shared-examples/puppetfile-action.rb +7 -7
  66. data/spec/unit/action/deploy/display_spec.rb +35 -5
  67. data/spec/unit/action/deploy/environment_spec.rb +199 -38
  68. data/spec/unit/action/deploy/module_spec.rb +162 -28
  69. data/spec/unit/action/puppetfile/check_spec.rb +2 -2
  70. data/spec/unit/action/puppetfile/install_spec.rb +31 -10
  71. data/spec/unit/action/puppetfile/purge_spec.rb +25 -5
  72. data/spec/unit/action/runner_spec.rb +48 -1
  73. data/spec/unit/environment/git_spec.rb +19 -2
  74. data/spec/unit/environment/name_spec.rb +28 -0
  75. data/spec/unit/environment/svn_spec.rb +12 -0
  76. data/spec/unit/environment/with_modules_spec.rb +74 -0
  77. data/spec/unit/git/rugged/credentials_spec.rb +78 -1
  78. data/spec/unit/module/forge_spec.rb +21 -13
  79. data/spec/unit/module/git_spec.rb +63 -8
  80. data/spec/unit/module_spec.rb +77 -10
  81. data/spec/unit/puppetfile_spec.rb +63 -60
  82. data/spec/unit/util/purgeable_spec.rb +2 -8
  83. data/spec/unit/util/setopts_spec.rb +25 -1
  84. metadata +11 -12
  85. data/azure-pipelines.yml +0 -87
@@ -13,7 +13,12 @@ class R10K::Module::Forge < R10K::Module::Base
13
13
  R10K::Module.register(self)
14
14
 
15
15
  def self.implement?(name, args)
16
- !!(name.match %r[\w+[/-]\w+]) && valid_version?(args)
16
+ args[:type].to_s == 'forge' ||
17
+ (!!
18
+ ((args.keys & %i{git svn type}).empty? &&
19
+ args.has_key?(:version) &&
20
+ name.match(%r[\w+[/-]\w+]) &&
21
+ valid_version?(args[:version])))
17
22
  end
18
23
 
19
24
  def self.valid_version?(expected_version)
@@ -32,24 +37,37 @@ class R10K::Module::Forge < R10K::Module::Base
32
37
 
33
38
  include R10K::Logging
34
39
 
35
- def initialize(title, dirname, expected_version, environment=nil)
40
+ include R10K::Util::Setopts
41
+
42
+ def initialize(title, dirname, opts, environment=nil)
36
43
  super
37
44
 
38
45
  @metadata_file = R10K::Module::MetadataFile.new(path + 'metadata.json')
39
46
  @metadata = @metadata_file.read
40
47
 
41
- @expected_version = expected_version || current_version || :latest
48
+ setopts(opts, {
49
+ # Standard option interface
50
+ :version => :expected_version,
51
+ :source => ::R10K::Util::Setopts::Ignore,
52
+ :type => ::R10K::Util::Setopts::Ignore,
53
+ }, :raise_on_unhandled => false)
54
+
55
+ @expected_version ||= current_version || :latest
56
+
42
57
  @v3_module = PuppetForge::V3::Module.new(:slug => @title)
43
58
  end
44
59
 
60
+ # @param [Hash] opts Deprecated
45
61
  def sync(opts={})
46
- case status
47
- when :absent
48
- install
49
- when :outdated
50
- upgrade
51
- when :mismatched
52
- reinstall
62
+ if should_sync?
63
+ case status
64
+ when :absent
65
+ install
66
+ when :outdated
67
+ upgrade
68
+ when :mismatched
69
+ reinstall
70
+ end
53
71
  end
54
72
  end
55
73
 
@@ -171,7 +189,7 @@ class R10K::Module::Forge < R10K::Module::Base
171
189
  if (match = title.match(/\A(\w+)[-\/](\w+)\Z/))
172
190
  [match[1], match[2]]
173
191
  else
174
- raise ArgumentError, _("Forge module names must match 'owner/modulename'")
192
+ raise ArgumentError, _("Forge module names must match 'owner/modulename', instead got #{title}")
175
193
  end
176
194
  end
177
195
  end
@@ -8,7 +8,7 @@ class R10K::Module::Git < R10K::Module::Base
8
8
  R10K::Module.register(self)
9
9
 
10
10
  def self.implement?(name, args)
11
- args.is_a? Hash and args.has_key?(:git)
11
+ args.has_key?(:git) || args[:type].to_s == 'git'
12
12
  rescue
13
13
  false
14
14
  end
@@ -28,16 +28,50 @@ class R10K::Module::Git < R10K::Module::Base
28
28
  # @return [String]
29
29
  attr_reader :default_ref
30
30
 
31
- def initialize(title, dirname, args, environment=nil)
32
- super
31
+ # @!attribute [r] default_override_ref
32
+ # @api private
33
+ # @return [String]
34
+ attr_reader :default_override_ref
35
+
36
+ include R10K::Util::Setopts
37
+
38
+ def initialize(title, dirname, opts, environment=nil)
33
39
 
34
- parse_options(@args)
40
+ super
41
+ setopts(opts, {
42
+ # Standard option interface
43
+ :version => :desired_ref,
44
+ :source => :remote,
45
+ :type => ::R10K::Util::Setopts::Ignore,
46
+
47
+ # Type-specific options
48
+ :branch => :desired_ref,
49
+ :tag => :desired_ref,
50
+ :commit => :desired_ref,
51
+ :ref => :desired_ref,
52
+ :git => :remote,
53
+ :default_branch => :default_ref,
54
+ :default_branch_override => :default_override_ref,
55
+ }, :raise_on_unhandled => false)
56
+
57
+ force = @overrides.dig(:modules, :force)
58
+ @force = force == false ? false : true
59
+
60
+ @desired_ref ||= 'master'
61
+
62
+ if @desired_ref == :control_branch
63
+ if @environment && @environment.respond_to?(:ref)
64
+ @desired_ref = @environment.ref
65
+ else
66
+ logger.warn _("Cannot track control repo branch for content '%{name}' when not part of a git-backed environment, will use default if available." % {name: name})
67
+ end
68
+ end
35
69
 
36
70
  @repo = R10K::Git::StatefulRepository.new(@remote, @dirname, @name)
37
71
  end
38
72
 
39
73
  def version
40
- validate_ref(@desired_ref, @default_ref)
74
+ validate_ref(@desired_ref, @default_ref, @default_override_ref)
41
75
  end
42
76
 
43
77
  def properties
@@ -48,9 +82,10 @@ class R10K::Module::Git < R10K::Module::Base
48
82
  }
49
83
  end
50
84
 
85
+ # @param [Hash] opts Deprecated
51
86
  def sync(opts={})
52
- force = opts && opts.fetch(:force, true)
53
- @repo.sync(version, force)
87
+ force = opts[:force] || @force
88
+ @repo.sync(version, force) if should_sync?
54
89
  end
55
90
 
56
91
  def status
@@ -63,9 +98,11 @@ class R10K::Module::Git < R10K::Module::Base
63
98
 
64
99
  private
65
100
 
66
- def validate_ref(desired, default)
101
+ def validate_ref(desired, default, default_override)
67
102
  if desired && desired != :control_branch && @repo.resolve(desired)
68
103
  return desired
104
+ elsif default_override && @repo.resolve(default_override)
105
+ return default_override
69
106
  elsif default && @repo.resolve(default)
70
107
  return default
71
108
  else
@@ -81,6 +118,11 @@ class R10K::Module::Git < R10K::Module::Base
81
118
  msg << "Could not determine desired ref"
82
119
  end
83
120
 
121
+ if default_override
122
+ msg << "or resolve the default branch override '%{default_override}',"
123
+ vars[:default_override] = default_override
124
+ end
125
+
84
126
  if default
85
127
  msg << "or resolve default ref '%{default}'"
86
128
  vars[:default] = default
@@ -91,23 +133,4 @@ class R10K::Module::Git < R10K::Module::Base
91
133
  raise ArgumentError, _(msg.join(' ')) % vars
92
134
  end
93
135
  end
94
-
95
- def parse_options(options)
96
- ref_opts = [:branch, :tag, :commit, :ref]
97
- known_opts = [:git, :default_branch] + ref_opts
98
-
99
- unhandled = options.keys - known_opts
100
- unless unhandled.empty?
101
- raise ArgumentError, _("Unhandled options %{unhandled} specified for %{class}") % {unhandled: unhandled, class: self.class}
102
- end
103
-
104
- @remote = options[:git]
105
-
106
- @desired_ref = ref_opts.find { |key| break options[key] if options.has_key?(key) } || 'master'
107
- @default_ref = options[:default_branch]
108
-
109
- if @desired_ref == :control_branch && @environment && @environment.respond_to?(:ref)
110
- @desired_ref = @environment.ref
111
- end
112
- end
113
136
  end
@@ -9,7 +9,7 @@ class R10K::Module::Local < R10K::Module::Base
9
9
  R10K::Module.register(self)
10
10
 
11
11
  def self.implement?(name, args)
12
- args.is_a?(Hash) && args[:local]
12
+ args.is_a?(Hash) && (args[:local] || args[:type].to_s == 'local')
13
13
  end
14
14
 
15
15
  include R10K::Logging
@@ -30,6 +30,7 @@ class R10K::Module::Local < R10K::Module::Base
30
30
  :insync
31
31
  end
32
32
 
33
+ # @param [Hash] opts Deprecated
33
34
  def sync(opts={})
34
35
  logger.debug1 _("Module %{title} is a local module, always indicating synced.") % {title: title}
35
36
  end
@@ -7,7 +7,7 @@ class R10K::Module::SVN < R10K::Module::Base
7
7
  R10K::Module.register(self)
8
8
 
9
9
  def self.implement?(name, args)
10
- args.is_a? Hash and args.has_key? :svn
10
+ args.has_key?(:svn) || args[:type].to_s == 'svn'
11
11
  end
12
12
 
13
13
  # @!attribute [r] expected_revision
@@ -36,18 +36,21 @@ class R10K::Module::SVN < R10K::Module::Base
36
36
 
37
37
  include R10K::Util::Setopts
38
38
 
39
- INITIALIZE_OPTS = {
40
- :svn => :url,
41
- :rev => :expected_revision,
42
- :revision => :expected_revision,
43
- :username => :self,
44
- :password => :self
45
- }
46
-
47
39
  def initialize(name, dirname, opts, environment=nil)
48
40
  super
49
-
50
- setopts(opts, INITIALIZE_OPTS)
41
+ setopts(opts, {
42
+ # Standard option interface
43
+ :source => :url,
44
+ :version => :expected_revision,
45
+ :type => ::R10K::Util::Setopts::Ignore,
46
+
47
+ # Type-specific options
48
+ :svn => :url,
49
+ :rev => :expected_revision,
50
+ :revision => :expected_revision,
51
+ :username => :self,
52
+ :password => :self
53
+ }, :raise_on_unhandled => false)
51
54
 
52
55
  @working_dir = R10K::SVN::WorkingDir.new(@path, :username => @username, :password => @password)
53
56
  end
@@ -66,14 +69,17 @@ class R10K::Module::SVN < R10K::Module::Base
66
69
  end
67
70
  end
68
71
 
72
+ # @param [Hash] opts Deprecated
69
73
  def sync(opts={})
70
- case status
71
- when :absent
72
- install
73
- when :mismatched
74
- reinstall
75
- when :outdated
76
- update
74
+ if should_sync?
75
+ case status
76
+ when :absent
77
+ install
78
+ when :mismatched
79
+ reinstall
80
+ when :outdated
81
+ update
82
+ end
77
83
  end
78
84
  end
79
85
 
@@ -3,6 +3,7 @@ require 'pathname'
3
3
  require 'r10k/module'
4
4
  require 'r10k/util/purgeable'
5
5
  require 'r10k/errors'
6
+ require 'r10k/content_synchronizer'
6
7
 
7
8
  module R10K
8
9
  class Puppetfile
@@ -42,28 +43,37 @@ class Puppetfile
42
43
  # @return [Boolean] Overwrite any locally made changes
43
44
  attr_accessor :force
44
45
 
45
- # @!attribute [r] modules_by_vcs_cachedir
46
- # @api private Only exposed for testing purposes
47
- # @return [Hash{:none, String => Array<R10K::Module>}]
48
- attr_reader :modules_by_vcs_cachedir
46
+ # @!attribute [r] overrides
47
+ # @return [Hash] Various settings overridden from normal configs
48
+ attr_reader :overrides
49
49
 
50
50
  # @param [String] basedir
51
- # @param [String] moduledir The directory to install the modules, default to #{basedir}/modules
52
- # @param [String] puppetfile_path The path to the Puppetfile, default to #{basedir}/Puppetfile
53
- # @param [String] puppetfile_name The name of the Puppetfile, default to 'Puppetfile'
54
- # @param [Boolean] force Shall we overwrite locally made changes?
55
- def initialize(basedir, moduledir = nil, puppetfile_path = nil, puppetfile_name = nil, force = nil )
51
+ # @param [Hash, String, nil] options_or_moduledir The directory to install the modules or a Hash of options.
52
+ # Usage as moduledir is deprecated. Only use as options, defaults to nil
53
+ # @param [String, nil] puppetfile_path Deprecated - The path to the Puppetfile, defaults to nil
54
+ # @param [String, nil] puppetfile_name Deprecated - The name of the Puppetfile, defaults to nil
55
+ # @param [Boolean, nil] force Deprecated - Shall we overwrite locally made changes?
56
+ def initialize(basedir, options_or_moduledir = nil, deprecated_path_arg = nil, deprecated_name_arg = nil, deprecated_force_arg = nil)
56
57
  @basedir = basedir
57
- @force = force || false
58
- @moduledir = moduledir || File.join(basedir, 'modules')
59
- @puppetfile_name = puppetfile_name || 'Puppetfile'
60
- @puppetfile_path = puppetfile_path || File.join(basedir, @puppetfile_name)
58
+ if options_or_moduledir.is_a? Hash
59
+ options = options_or_moduledir
60
+ deprecated_moduledir_arg = nil
61
+ else
62
+ options = {}
63
+ deprecated_moduledir_arg = options_or_moduledir
64
+ end
65
+
66
+ @force = deprecated_force_arg || options.delete(:force) || false
67
+ @moduledir = deprecated_moduledir_arg || options.delete(:moduledir) || File.join(basedir, 'modules')
68
+ @puppetfile_name = deprecated_name_arg || options.delete(:puppetfile_name) || 'Puppetfile'
69
+ @puppetfile_path = deprecated_path_arg || options.delete(:puppetfile_path) || File.join(basedir, @puppetfile_name)
70
+
71
+ @overrides = options.delete(:overrides) || {}
61
72
 
62
73
  logger.info _("Using Puppetfile '%{puppetfile}'") % {puppetfile: @puppetfile_path}
63
74
 
64
75
  @modules = []
65
76
  @managed_content = {}
66
- @modules_by_vcs_cachedir = {}
67
77
  @forge = 'forgeapi.puppetlabs.com'
68
78
 
69
79
  @loaded = false
@@ -83,7 +93,7 @@ class Puppetfile
83
93
 
84
94
  dsl = R10K::Puppetfile::DSL.new(self)
85
95
  dsl.instance_eval(puppetfile_contents, @puppetfile_path)
86
-
96
+
87
97
  validate_no_duplicate_names(@modules)
88
98
  @loaded = true
89
99
  rescue SyntaxError, LoadError, ArgumentError, NameError => e
@@ -123,29 +133,44 @@ class Puppetfile
123
133
  end
124
134
 
125
135
  # @param [String] name
126
- # @param [*Object] args
136
+ # @param [Hash, String, Symbol] args Calling with anything but a Hash is
137
+ # deprecated. The DSL will now convert String and Symbol versions to
138
+ # Hashes of the shape
139
+ # { version: <String or Symbol> }
140
+ #
127
141
  def add_module(name, args)
128
- if args.is_a?(Hash) && install_path = args.delete(:install_path)
142
+ if !args.is_a?(Hash)
143
+ args = { version: args }
144
+ end
145
+
146
+ args[:overrides] = @overrides
147
+
148
+ if install_path = args.delete(:install_path)
129
149
  install_path = resolve_install_path(install_path)
130
150
  validate_install_path(install_path, name)
131
151
  else
132
152
  install_path = @moduledir
133
153
  end
134
154
 
135
- if args.is_a?(Hash) && @default_branch_override != nil
136
- args[:default_branch] = @default_branch_override
155
+ if @default_branch_override != nil
156
+ args[:default_branch_override] = @default_branch_override
137
157
  end
138
158
 
139
- # Keep track of all the content this Puppetfile is managing to enable purging.
140
- @managed_content[install_path] = Array.new unless @managed_content.has_key?(install_path)
141
159
 
142
160
  mod = R10K::Module.new(name, install_path, args, @environment)
143
- mod.origin = 'Puppetfile'
161
+ mod.origin = :puppetfile
162
+
163
+ # Do not load modules if they would conflict with the attached
164
+ # environment
165
+ if environment && environment.module_conflicts?(mod)
166
+ mod = nil
167
+ return @modules
168
+ end
144
169
 
170
+ # Keep track of all the content this Puppetfile is managing to enable purging.
171
+ @managed_content[install_path] = Array.new unless @managed_content.has_key?(install_path)
145
172
  @managed_content[install_path] << mod.name
146
- cachedir = mod.cachedir
147
- @modules_by_vcs_cachedir[cachedir] ||= []
148
- @modules_by_vcs_cachedir[cachedir] << mod
173
+
149
174
  @modules << mod
150
175
  end
151
176
 
@@ -183,70 +208,22 @@ class Puppetfile
183
208
  def accept(visitor)
184
209
  pool_size = self.settings[:pool_size]
185
210
  if pool_size > 1
186
- concurrent_accept(visitor, pool_size)
211
+ R10K::ContentSynchronizer.concurrent_accept(modules, visitor, self, pool_size, logger)
187
212
  else
188
- serial_accept(visitor)
189
- end
190
- end
191
-
192
- private
193
-
194
- def serial_accept(visitor)
195
- visitor.visit(:puppetfile, self) do
196
- modules.each do |mod|
197
- mod.accept(visitor)
198
- end
213
+ R10K::ContentSynchronizer.serial_accept(modules, visitor, self)
199
214
  end
200
215
  end
201
216
 
202
- def concurrent_accept(visitor, pool_size)
203
- logger.debug _("Updating modules with %{pool_size} threads") % {pool_size: pool_size}
204
- mods_queue = modules_queue(visitor)
205
- thread_pool = pool_size.times.map { visitor_thread(visitor, mods_queue) }
206
- thread_exception = nil
207
-
208
- # If any threads raise an exception the deployment is considered a failure.
209
- # In that event clear the queue, wait for other threads to finish their
210
- # current work, then re-raise the first exception caught.
211
- begin
212
- thread_pool.each(&:join)
213
- rescue => e
214
- logger.error _("Error during concurrent deploy of a module: %{message}") % {message: e.message}
215
- mods_queue.clear
216
- thread_exception ||= e
217
- retry
218
- ensure
219
- raise thread_exception unless thread_exception.nil?
217
+ def sync
218
+ pool_size = self.settings[:pool_size]
219
+ if pool_size > 1
220
+ R10K::ContentSynchronizer.concurrent_sync(modules, pool_size, logger)
221
+ else
222
+ R10K::ContentSynchronizer.serial_sync(modules)
220
223
  end
221
224
  end
222
225
 
223
- def modules_queue(visitor)
224
- Queue.new.tap do |queue|
225
- visitor.visit(:puppetfile, self) do
226
- modules_by_cachedir = modules_by_vcs_cachedir.clone
227
- modules_without_vcs_cachedir = modules_by_cachedir.delete(:none) || []
228
-
229
- modules_without_vcs_cachedir.each {|mod| queue << Array(mod) }
230
- modules_by_cachedir.values.each {|mods| queue << mods }
231
- end
232
- end
233
- end
234
- public :modules_queue
235
-
236
- def visitor_thread(visitor, mods_queue)
237
- Thread.new do
238
- begin
239
- while mods = mods_queue.pop(true) do
240
- mods.each {|mod| mod.accept(visitor) }
241
- end
242
- rescue ThreadError => e
243
- logger.debug _("Module thread %{id} exiting: %{message}") % {message: e.message, id: Thread.current.object_id}
244
- Thread.exit
245
- rescue => e
246
- Thread.main.raise(e)
247
- end
248
- end
249
- end
226
+ private
250
227
 
251
228
  def puppetfile_contents
252
229
  File.read(@puppetfile_path)
@@ -287,7 +264,13 @@ class Puppetfile
287
264
  end
288
265
 
289
266
  def mod(name, args = nil)
290
- @librarian.add_module(name, args)
267
+ if args.is_a?(Hash)
268
+ opts = args
269
+ else
270
+ opts = { version: args }
271
+ end
272
+
273
+ @librarian.add_module(name, opts)
291
274
  end
292
275
 
293
276
  def forge(location)