aspera-cli 4.23.0 → 4.24.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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +37 -1
  4. data/CONTRIBUTING.md +86 -29
  5. data/README.md +2109 -1300
  6. data/bin/ascli +2 -1
  7. data/bin/asession +4 -4
  8. data/lib/aspera/agent/base.rb +4 -0
  9. data/lib/aspera/agent/connect.rb +20 -18
  10. data/lib/aspera/agent/desktop.rb +14 -11
  11. data/lib/aspera/agent/direct.rb +39 -31
  12. data/lib/aspera/agent/httpgw.rb +2 -2
  13. data/lib/aspera/agent/node.rb +9 -11
  14. data/lib/aspera/agent/transferd.rb +18 -11
  15. data/lib/aspera/api/aoc.rb +44 -31
  16. data/lib/aspera/api/cos_node.rb +7 -5
  17. data/lib/aspera/api/httpgw.rb +15 -18
  18. data/lib/aspera/api/node.rb +104 -22
  19. data/lib/aspera/ascmd.rb +22 -16
  20. data/lib/aspera/ascp/installation.rb +37 -40
  21. data/lib/aspera/ascp/management.rb +5 -4
  22. data/lib/aspera/assert.rb +54 -23
  23. data/lib/aspera/cli/basic_auth_plugin.rb +8 -7
  24. data/lib/aspera/cli/error.rb +1 -1
  25. data/lib/aspera/cli/extended_value.rb +28 -29
  26. data/lib/aspera/cli/formatter.rb +191 -168
  27. data/lib/aspera/cli/hints.rb +29 -3
  28. data/lib/aspera/cli/main.rb +138 -107
  29. data/lib/aspera/cli/manager.rb +50 -30
  30. data/lib/aspera/cli/plugin.rb +148 -77
  31. data/lib/aspera/cli/plugin_factory.rb +2 -2
  32. data/lib/aspera/cli/plugins/aoc.rb +189 -70
  33. data/lib/aspera/cli/plugins/ats.rb +15 -13
  34. data/lib/aspera/cli/plugins/config.rb +100 -214
  35. data/lib/aspera/cli/plugins/console.rb +49 -18
  36. data/lib/aspera/cli/plugins/cos.rb +4 -4
  37. data/lib/aspera/cli/plugins/faspex.rb +45 -51
  38. data/lib/aspera/cli/plugins/faspex5.rb +164 -165
  39. data/lib/aspera/cli/plugins/faspio.rb +6 -5
  40. data/lib/aspera/cli/plugins/httpgw.rb +2 -2
  41. data/lib/aspera/cli/plugins/node.rb +144 -162
  42. data/lib/aspera/cli/plugins/orchestrator.rb +10 -14
  43. data/lib/aspera/cli/plugins/preview.rb +26 -29
  44. data/lib/aspera/cli/plugins/server.rb +28 -28
  45. data/lib/aspera/cli/plugins/shares.rb +40 -28
  46. data/lib/aspera/cli/sync_actions.rb +101 -80
  47. data/lib/aspera/cli/transfer_agent.rb +51 -50
  48. data/lib/aspera/cli/transfer_progress.rb +29 -20
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +157 -0
  51. data/lib/aspera/colors.rb +13 -8
  52. data/lib/aspera/command_line_builder.rb +28 -22
  53. data/lib/aspera/command_line_converter.rb +31 -0
  54. data/lib/aspera/environment.rb +145 -101
  55. data/lib/aspera/faspex_gw.rb +1 -1
  56. data/lib/aspera/faspex_postproc.rb +3 -2
  57. data/lib/aspera/hash_ext.rb +1 -1
  58. data/lib/aspera/id_generator.rb +10 -10
  59. data/lib/aspera/keychain/base.rb +18 -0
  60. data/lib/aspera/keychain/encrypted_hash.rb +6 -12
  61. data/lib/aspera/keychain/factory.rb +9 -3
  62. data/lib/aspera/keychain/hashicorp_vault.rb +9 -6
  63. data/lib/aspera/keychain/macos_security.rb +13 -13
  64. data/lib/aspera/log.rb +91 -19
  65. data/lib/aspera/nagios.rb +5 -6
  66. data/lib/aspera/node_simulator.rb +12 -7
  67. data/lib/aspera/oauth/base.rb +5 -3
  68. data/lib/aspera/oauth/factory.rb +24 -18
  69. data/lib/aspera/oauth/jwt.rb +13 -1
  70. data/lib/aspera/oauth/url_json.rb +3 -3
  71. data/lib/aspera/oauth/web.rb +5 -3
  72. data/lib/aspera/persistency_folder.rb +2 -2
  73. data/lib/aspera/preview/file_types.rb +4 -3
  74. data/lib/aspera/preview/generator.rb +25 -12
  75. data/lib/aspera/preview/terminal.rb +10 -7
  76. data/lib/aspera/preview/utils.rb +11 -9
  77. data/lib/aspera/products/connect.rb +1 -1
  78. data/lib/aspera/products/desktop.rb +1 -1
  79. data/lib/aspera/products/other.rb +2 -2
  80. data/lib/aspera/products/transferd.rb +8 -6
  81. data/lib/aspera/proxy_auto_config.rb +1 -1
  82. data/lib/aspera/rest.rb +29 -22
  83. data/lib/aspera/rest_call_error.rb +1 -1
  84. data/lib/aspera/resumer.rb +1 -1
  85. data/lib/aspera/secret_hider.rb +46 -40
  86. data/lib/aspera/ssh.rb +13 -3
  87. data/lib/aspera/sync/args.schema.yaml +102 -0
  88. data/lib/aspera/sync/conf.schema.yaml +701 -0
  89. data/lib/aspera/sync/database.rb +83 -0
  90. data/lib/aspera/sync/operations.rb +296 -0
  91. data/lib/aspera/temp_file_manager.rb +3 -2
  92. data/lib/aspera/transfer/error.rb +1 -1
  93. data/lib/aspera/transfer/error_info.rb +1 -2
  94. data/lib/aspera/transfer/faux_file.rb +11 -10
  95. data/lib/aspera/transfer/parameters.rb +6 -5
  96. data/lib/aspera/transfer/spec.rb +15 -1
  97. data/lib/aspera/transfer/spec.schema.yaml +316 -293
  98. data/lib/aspera/transfer/spec_doc.rb +34 -16
  99. data/lib/aspera/transfer/uri.rb +5 -5
  100. data/lib/aspera/uri_reader.rb +14 -10
  101. data/lib/aspera/web_auth.rb +2 -2
  102. data/lib/aspera/web_server_simple.rb +2 -2
  103. data.tar.gz.sig +0 -0
  104. metadata +15 -13
  105. metadata.gz.sig +0 -0
  106. data/lib/aspera/transfer/async_conf.schema.yaml +0 -716
  107. data/lib/aspera/transfer/convert.rb +0 -29
  108. data/lib/aspera/transfer/sync.rb +0 -232
  109. data/lib/aspera/transfer/sync_instance.schema.yaml +0 -20
  110. data/lib/aspera/transfer/sync_session.schema.yaml +0 -86
@@ -18,7 +18,9 @@ module Aspera
18
18
  # special query parameter: max number of pages for list command
19
19
  MAX_PAGES = 'pmax'
20
20
  # special identifier format: look for this name to find where supported
21
- REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/.freeze
21
+ REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
22
+ PER_PAGE_DEFAULT = 1000
23
+ private_constant :PER_PAGE_DEFAULT
22
24
 
23
25
  class << self
24
26
  def declare_generic_options(options)
@@ -29,20 +31,23 @@ module Aspera
29
31
  end
30
32
  end
31
33
 
32
- def options; @broker.options; end
33
- def transfer; @broker.transfer; end
34
- def config; @broker.config; end
35
- def formatter; @broker.formatter; end
36
- def persistency; @broker.persistency; end
37
-
38
- def initialize(broker:, man_header: true)
34
+ def initialize(context:)
39
35
  # check presence in descendant of mandatory method and constant
40
- Aspera.assert(respond_to?(:execute_action)){"Missing method 'execute_action' in #{self.class}"}
41
- Aspera.assert(self.class.constants.include?(:ACTIONS)){"Missing constant 'ACTIONS' in #{self.class}"}
42
- @broker = broker
43
- add_manual_header if man_header
36
+ Aspera.assert(respond_to?(:execute_action), type: InternalError){"Missing method 'execute_action' in #{self.class}"}
37
+ Aspera.assert(self.class.constants.include?(:ACTIONS), type: InternalError){"Missing constant 'ACTIONS' in #{self.class}"}
38
+ @context = context
39
+ add_manual_header if @context.man_header
44
40
  end
45
41
 
42
+ # Global objects
43
+ attr_reader :context
44
+
45
+ def options; @context.options; end
46
+ def transfer; @context.transfer; end
47
+ def config; @context.config; end
48
+ def formatter; @context.formatter; end
49
+ def persistency; @context.persistency; end
50
+
46
51
  def add_manual_header(has_options = true)
47
52
  # manual header for all plugins
48
53
  options.parser.separator('')
@@ -51,16 +56,13 @@ module Aspera
51
56
  options.parser.separator('OPTIONS:') if has_options
52
57
  end
53
58
 
54
- # @return a hash of instance variables
55
- def init_params
56
- return {broker: @broker}
57
- end
58
-
59
- # must be called AFTER the instance action, ... folder browse <call instance_identifier>
59
+ # Must be called AFTER the instance action:
60
+ # ... folder browse _call_instance_identifier
61
+ #
60
62
  # @param description [String] description of the identifier
61
- # @param as_option [Symbol] option name to use if identifier is an option
62
- # @param block [Proc] block to search for identifier based on attribute value
63
- # @return [String, Array] identifier or list of ids
63
+ # @param as_option [Symbol] option name to use if identifier is an option
64
+ # @param block [Proc] block to search for identifier based on attribute value
65
+ # @return [String, Array] identifier or list of ids
64
66
  def instance_identifier(description: 'identifier', as_option: nil, &block)
65
67
  if as_option.nil?
66
68
  res_id = options.get_next_argument(description, multiple: options.get_option(:bulk)) if res_id.nil?
@@ -79,24 +81,25 @@ module Aspera
79
81
  end
80
82
 
81
83
  # For create and delete operations: execute one actin or multiple if bulk is yes
82
- # @param command [Symbol] operation: :create, :delete, ...
83
- # @param descr [String] description of the value
84
- # @param values [Object] the value(s), or the type of value to get from user
84
+ # @param command [Symbol] operation: :create, :delete, ...
85
+ # @param descr [String] description of the value
86
+ # @param values [Object] the value(s), or the type of value to get from user
85
87
  # @param id_result [String] key in result hash to use as identifier
86
- # @param fields [Array] fields to display
87
- def do_bulk_operation(command:, descr:, values: Hash, id_result: 'id', fields: :default)
88
+ # @param fields [Array] fields to display
89
+ # @param &block [Proc] block to execute for each value
90
+ def do_bulk_operation(command:, descr: nil, values: Hash, id_result: 'id', fields: :default)
88
91
  Aspera.assert(block_given?){'missing block'}
89
92
  is_bulk = options.get_option(:bulk)
90
93
  case values
91
94
  when :identifier
92
- values = instance_identifier
95
+ values = instance_identifier(description: descr)
93
96
  when Class
94
- values = value_create_modify(command: command, type: values, bulk: is_bulk)
97
+ values = value_create_modify(command: command, description: descr, type: values, bulk: is_bulk)
95
98
  end
96
99
  # if not bulk, there is a single value
97
100
  params = is_bulk ? values : [values]
98
101
  Log.log.warn('Empty list given for bulk operation') if params.empty?
99
- Log.log.debug{Log.dump(:bulk_operation, params)}
102
+ Log.dump(:bulk_operation, params)
100
103
  result_list = []
101
104
  params.each do |param|
102
105
  # init for delete
@@ -125,78 +128,86 @@ module Aspera
125
128
  end
126
129
  end
127
130
 
128
- # @param command [Symbol] command to execute: create show list modify delete
129
- # @param rest_api [Rest] api to use
130
- # @param res_class_path [String] sub path in URL to resource relative to base url
131
- # @param display_fields [Array] fields to display by default
132
- # @param item_list_key [String] result is in a sub key of the json
133
- # @param id_as_arg [String] if set, the id is provided as url argument ?<id_as_arg>=<id>
134
- # @param is_singleton [Boolean] if true, res_class_path is the full path to the resource
135
- # @param delete_style [String] if set, the delete operation by array in payload
136
- # @param block [Proc] block to search for identifier based on attribute value
131
+ # Operations: Create, Delete, Show, List, Modify
132
+ # @param api [Rest] api to use
133
+ # @param entity [String] sub path in URL to resource relative to base url
134
+ # @param command [Symbol] command to execute: create show list modify delete
135
+ # @param display_fields [Array] fields to display by default
136
+ # @param items_key [String] result is in a sub key of the json
137
+ # @param delete_style [String] if set, the delete operation by array in payload
138
+ # @param id_as_arg [String] if set, the id is provided as url argument ?<id_as_arg>=<id>
139
+ # @param is_singleton [Boolean] if true, entity is the full path to the resource
140
+ # @param tclo [Bool] if set, :list use paging with total_count, limit, offset
141
+ # @param block [Proc] block to search for identifier based on attribute value
137
142
  # @return result suitable for CLI result
138
- def entity_command(command, rest_api, res_class_path,
143
+ def entity_execute(
144
+ api:,
145
+ entity:,
146
+ command: nil,
139
147
  display_fields: nil,
140
- item_list_key: false,
148
+ items_key: nil,
149
+ delete_style: nil,
141
150
  id_as_arg: false,
142
151
  is_singleton: false,
143
- delete_style: nil,
144
- &block)
152
+ list_query: nil,
153
+ tclo: false,
154
+ &block
155
+ )
156
+ command = options.get_next_command(ALL_OPS) if command.nil?
145
157
  if is_singleton
146
- one_res_path = res_class_path
158
+ one_res_path = entity
147
159
  elsif INSTANCE_OPS.include?(command)
148
160
  one_res_id = instance_identifier(&block)
149
- one_res_path = "#{res_class_path}/#{one_res_id}"
150
- one_res_path = "#{res_class_path}?#{id_as_arg}=#{one_res_id}" if id_as_arg
161
+ one_res_path = "#{entity}/#{one_res_id}"
162
+ one_res_path = "#{entity}?#{id_as_arg}=#{one_res_id}" if id_as_arg
151
163
  end
152
164
 
153
165
  case command
154
166
  when :create
155
- raise 'cannot create singleton' if is_singleton
167
+ raise BadArgument, 'cannot create singleton' if is_singleton
156
168
  return do_bulk_operation(command: command, descr: 'data', fields: display_fields) do |params|
157
- rest_api.create(res_class_path, params)
169
+ api.create(entity, params)
158
170
  end
159
171
  when :delete
160
- raise 'cannot delete singleton' if is_singleton
172
+ raise BadArgument, 'cannot delete singleton' if is_singleton
161
173
  if !delete_style.nil?
162
174
  one_res_id = [one_res_id] unless one_res_id.is_a?(Array)
163
- Aspera.assert_type(one_res_id, Array, exception_class: Cli::BadArgument)
164
- rest_api.call(
175
+ Aspera.assert_type(one_res_id, Array, type: Cli::BadArgument)
176
+ api.call(
165
177
  operation: 'DELETE',
166
- subpath: res_class_path,
178
+ subpath: entity,
167
179
  content_type: Rest::MIME_JSON,
168
180
  body: {delete_style => one_res_id},
169
181
  headers: {'Accept' => Rest::MIME_JSON}
170
182
  )
171
183
  return Main.result_status('deleted')
172
184
  end
173
- return do_bulk_operation(command: command, descr: 'identifier', values: one_res_id) do |one_id|
174
- rest_api.delete("#{res_class_path}/#{one_id}", query_read_delete)
185
+ return do_bulk_operation(command: command, values: one_res_id) do |one_id|
186
+ api.delete("#{entity}/#{one_id}", query_read_delete)
175
187
  {'id' => one_id}
176
188
  end
177
189
  when :show
178
- return Main.result_single_object(rest_api.read(one_res_path), fields: display_fields)
190
+ return Main.result_single_object(api.read(one_res_path), fields: display_fields)
179
191
  when :list
180
- resp = rest_api.call(operation: 'GET', subpath: res_class_path, headers: {'Accept' => Rest::MIME_JSON}, query: query_read_delete)
192
+ if tclo
193
+ data, total = list_entities_limit_offset_total_count(api: api, entity:, items_key: items_key, query: list_query)
194
+ return Main.result_object_list(data, total: total, fields: display_fields)
195
+ end
196
+ resp = api.call(operation: 'GET', subpath: entity, headers: {'Accept' => Rest::MIME_JSON}, query: query_read_delete)
181
197
  return Main.result_empty if resp[:http].code == '204'
182
198
  data = resp[:data]
183
199
  # TODO: not generic : which application is this for ?
184
200
  if resp[:http]['Content-Type'].start_with?('application/vnd.api+json')
185
- Log.log.debug{'is vnd.api'}
186
- data = data[res_class_path]
187
- end
188
- if item_list_key
189
- item_list = data[item_list_key]
190
- total_count = data['total_count']
191
- formatter.display_item_count(item_list.length, total_count) unless total_count.nil?
192
- data = item_list
201
+ Log.log.debug('is vnd.api')
202
+ data = data[entity]
193
203
  end
204
+ data = data[items_key] if items_key
194
205
  case data
195
206
  when Hash
196
207
  return Main.result_single_object(data, fields: display_fields)
197
208
  when Array
198
209
  return Main.result_object_list(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
199
- return Main.result_value_list(data, name: 'id')
210
+ return Main.result_value_list(data)
200
211
  else
201
212
  raise "An error occurred: unexpected result type for list: #{data.class}"
202
213
  end
@@ -204,21 +215,14 @@ module Aspera
204
215
  parameters = value_create_modify(command: command)
205
216
  property = options.get_option(:property)
206
217
  parameters = {property => parameters} unless property.nil?
207
- rest_api.update(one_res_path, parameters)
218
+ api.update(one_res_path, parameters)
208
219
  return Main.result_status('modified')
209
220
  else
210
221
  raise "unknown action: #{command}"
211
222
  end
212
223
  end
213
224
 
214
- # implement generic rest operations on given resource path
215
- def entity_action(rest_api, res_class_path, **opts, &block)
216
- # res_name=res_class_path.gsub(%r{^.*/},'').gsub(%r{s$},'').gsub('_',' ')
217
- command = options.get_next_command(ALL_OPS)
218
- return entity_command(command, rest_api, res_class_path, **opts, &block)
219
- end
220
-
221
- # query parameters in URL suitable for REST: list/GET and delete/DELETE
225
+ # Query parameters in URL suitable for REST: list/GET and delete/DELETE
222
226
  def query_read_delete(default: nil)
223
227
  query = options.get_option(:query)
224
228
  # dup default, as it could be frozen
@@ -241,22 +245,89 @@ module Aspera
241
245
  def value_create_modify(command:, description: nil, type: Hash, bulk: false, default: nil)
242
246
  value = options.get_next_argument(
243
247
  "parameters for #{command}#{" (#{description})" unless description.nil?}", mandatory: default.nil?,
244
- validation: bulk ? Array : type)
248
+ validation: bulk ? Array : type
249
+ )
245
250
  value = default if value.nil?
246
251
  unless type.nil?
247
252
  type = [type] unless type.is_a?(Array)
248
253
  Aspera.assert(type.all?(Class)){"check types must be a Class, not #{type.map(&:class).join(',')}"}
249
254
  if bulk
250
- Aspera.assert_type(value, Array, exception_class: Cli::BadArgument)
255
+ Aspera.assert_type(value, Array, type: Cli::BadArgument)
251
256
  value.each do |v|
252
- Aspera.assert_values(v.class, type, exception_class: Cli::BadArgument)
257
+ Aspera.assert_values(v.class, type, type: Cli::BadArgument)
253
258
  end
254
259
  else
255
- Aspera.assert_values(value.class, type, exception_class: Cli::BadArgument)
260
+ Aspera.assert_values(value.class, type, type: Cli::BadArgument)
256
261
  end
257
262
  end
258
263
  return value
259
264
  end
265
+
266
+ # Get a (full or partial) list of all entities of a given type with query: offset/limit
267
+ # @param `api` [Rest] the API object
268
+ # @param `entity` [String,Symbol] the API endpoint of entity to list
269
+ # @param `items_key` [String] key in the result to get the list of items
270
+ # @param `query` [Hash,nil] additional query parameters
271
+ # @return [Array] items, total_count
272
+ def list_entities_limit_offset_total_count(
273
+ api:,
274
+ entity:,
275
+ items_key: nil,
276
+ query: nil
277
+ )
278
+ entity = entity.to_s if entity.is_a?(Symbol)
279
+ items_key = entity.split('/').last if items_key.nil?
280
+ query = {} if query.nil?
281
+ Aspera.assert_type(entity, String)
282
+ Aspera.assert_type(items_key, String)
283
+ Aspera.assert_type(query, Hash)
284
+ Log.log.debug{"list_entities t=#{entity} k=#{items_key} q=#{query}"}
285
+ result = []
286
+ offset = 0
287
+ max_items = query.delete(MAX_ITEMS)
288
+ remain_pages = query.delete(MAX_PAGES)
289
+ # merge default parameters, by default 100 per page
290
+ query = {'limit'=> PER_PAGE_DEFAULT}.merge(query)
291
+ total_count = nil
292
+ loop do
293
+ query['offset'] = offset
294
+ page_result = api.read(entity, query)
295
+ Aspera.assert_type(page_result[items_key], Array)
296
+ result.concat(page_result[items_key])
297
+ # reach the limit set by user ?
298
+ if !max_items.nil? && (result.length >= max_items)
299
+ result = result.slice(0, max_items)
300
+ break
301
+ end
302
+ total_count ||= page_result['total_count']
303
+ break if result.length >= total_count
304
+ remain_pages -= 1 unless remain_pages.nil?
305
+ break if remain_pages == 0
306
+ offset += page_result[items_key].length
307
+ formatter.long_operation_running
308
+ end
309
+ formatter.long_operation_terminated
310
+ return result, total_count
311
+ end
312
+
313
+ # Lookup an entity id from its name
314
+ # @param entity [String] the type of entity to lookup, by default it is the path, and it is also the field name in result
315
+ # @param value [String] the value to lookup
316
+ # @param field [String] the field to match, by default it is 'name'
317
+ # @param items_key [String] key in the result to get the list of items (override entity)
318
+ # @param query [Hash] additional query parameters
319
+ def lookup_entity_by_field(api:, entity:, value:, field: 'name', items_key: nil, query: :default)
320
+ if query.eql?(:default)
321
+ Aspera.assert(field.eql?('name')){'Default query is on name only'}
322
+ query = {'q'=> value}
323
+ end
324
+ found = list_entities_limit_offset_total_count(api: api, entity: entity, items_key: items_key, query: query).first.select{ |i| i[field].eql?(value)}
325
+ case found.length
326
+ when 0 then raise "No #{entity} with #{field} = #{value}"
327
+ when 1 then return found.first
328
+ else raise "Found #{found.length} #{entity} with #{field} = #{value}"
329
+ end
330
+ end
260
331
  end
261
332
  end
262
333
  end
@@ -48,7 +48,7 @@ module Aspera
48
48
 
49
49
  # @return Class object for plugin
50
50
  def plugin_class(plugin_name_sym)
51
- raise "ERROR: plugin not found: #{plugin_name_sym}" unless @plugins.key?(plugin_name_sym)
51
+ raise NoSuchElement, "plugin not found: #{plugin_name_sym}" unless @plugins.key?(plugin_name_sym)
52
52
  require @plugins[plugin_name_sym][:require_stanza]
53
53
  # Module.nesting[1] is Aspera::Cli
54
54
  return Object.const_get("#{Module.nesting[1]}::#{PLUGINS_MODULE}::#{plugin_name_sym.to_s.capitalize}")
@@ -67,7 +67,7 @@ module Aspera
67
67
  # add plugin information to list
68
68
  # @param path [String] path to plugin source file
69
69
  def add_plugin_info(path)
70
- raise "ERROR: plugin path must end with #{RUBY_FILE_EXT}" if !path.end_with?(RUBY_FILE_EXT)
70
+ raise Error, "plugin path must end with #{RUBY_FILE_EXT}" if !path.end_with?(RUBY_FILE_EXT)
71
71
  plugin_symbol = File.basename(path, RUBY_FILE_EXT).to_sym
72
72
  req = path.sub(/#{RUBY_FILE_EXT}$/o, '')
73
73
  if @plugins.key?(plugin_symbol)