aspera-cli 4.27.1 → 4.27.3

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 (133) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +67 -1
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +805 -747
  6. data/lib/aspera/agent/connect.rb +6 -4
  7. data/lib/aspera/agent/desktop.rb +2 -2
  8. data/lib/aspera/agent/direct.rb +3 -1
  9. data/lib/aspera/agent/node.rb +3 -3
  10. data/lib/aspera/api/alee.rb +1 -1
  11. data/lib/aspera/api/aoc.rb +14 -12
  12. data/lib/aspera/api/ats.rb +1 -1
  13. data/lib/aspera/api/cos_node.rb +2 -2
  14. data/lib/aspera/api/faspex.rb +9 -7
  15. data/lib/aspera/api/httpgw.rb +37 -33
  16. data/lib/aspera/api/node.rb +38 -33
  17. data/lib/aspera/ascmd.rb +3 -1
  18. data/lib/aspera/ascp/installation.rb +62 -27
  19. data/lib/aspera/ascp/management.rb +1 -0
  20. data/lib/aspera/assert.rb +4 -0
  21. data/lib/aspera/cli/ascp_actions.rb +20 -41
  22. data/lib/aspera/cli/async_transfer_store.rb +2 -2
  23. data/lib/aspera/cli/bootstrapper.rb +11 -15
  24. data/lib/aspera/cli/command_line.rb +252 -0
  25. data/lib/aspera/cli/command_registry.rb +149 -33
  26. data/lib/aspera/cli/command_spec.rb +103 -14
  27. data/lib/aspera/cli/completion/ascli.bash +12 -0
  28. data/lib/aspera/cli/completion/ascli.fish +16 -0
  29. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  30. data/lib/aspera/cli/context.rb +3 -0
  31. data/lib/aspera/cli/deprecation.rb +37 -0
  32. data/lib/aspera/cli/extended_value.rb +2 -0
  33. data/lib/aspera/cli/formatter.rb +87 -75
  34. data/lib/aspera/cli/gem_checker.rb +1 -1
  35. data/lib/aspera/cli/hints.rb +7 -6
  36. data/lib/aspera/cli/http.rb +21 -21
  37. data/lib/aspera/cli/info.rb +3 -0
  38. data/lib/aspera/cli/mcp_tool.rb +47 -83
  39. data/lib/aspera/cli/option_declarator.rb +33 -42
  40. data/lib/aspera/cli/option_registry.rb +69 -0
  41. data/lib/aspera/cli/option_types.rb +103 -0
  42. data/lib/aspera/cli/option_value.rb +281 -0
  43. data/lib/aspera/cli/options.schema.yaml +38 -5
  44. data/lib/aspera/cli/parser.rb +307 -848
  45. data/lib/aspera/cli/plugins/alee.rb +7 -4
  46. data/lib/aspera/cli/plugins/aoc.rb +435 -380
  47. data/lib/aspera/cli/plugins/ats.rb +58 -73
  48. data/lib/aspera/cli/plugins/base.rb +190 -240
  49. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  50. data/lib/aspera/cli/plugins/config.rb +244 -178
  51. data/lib/aspera/cli/plugins/console.rb +102 -38
  52. data/lib/aspera/cli/plugins/cos.rb +6 -23
  53. data/lib/aspera/cli/plugins/factory.rb +3 -0
  54. data/lib/aspera/cli/plugins/faspex5.rb +176 -173
  55. data/lib/aspera/cli/plugins/faspio.rb +5 -10
  56. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  57. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  58. data/lib/aspera/cli/plugins/node.rb +277 -311
  59. data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
  60. data/lib/aspera/cli/plugins/preview.rb +79 -90
  61. data/lib/aspera/cli/plugins/server.rb +76 -50
  62. data/lib/aspera/cli/plugins/shares.rb +68 -116
  63. data/lib/aspera/cli/preset_actions.rb +17 -10
  64. data/lib/aspera/cli/preset_manager.rb +12 -2
  65. data/lib/aspera/cli/prompt.rb +35 -0
  66. data/lib/aspera/cli/result.rb +13 -18
  67. data/lib/aspera/cli/runner.rb +31 -54
  68. data/lib/aspera/cli/special_values.rb +5 -0
  69. data/lib/aspera/cli/sync_actions.rb +41 -37
  70. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  71. data/lib/aspera/cli/transfer_actions.rb +0 -6
  72. data/lib/aspera/cli/transfer_agent.rb +29 -35
  73. data/lib/aspera/cli/vault_manager.rb +0 -17
  74. data/lib/aspera/cli/version.rb +1 -1
  75. data/lib/aspera/cli/wizard.rb +4 -2
  76. data/lib/aspera/command_line_builder.rb +1 -0
  77. data/lib/aspera/coverage.rb +1 -0
  78. data/lib/aspera/environment.rb +7 -1
  79. data/lib/aspera/faspex_gw.rb +2 -1
  80. data/lib/aspera/faspex_postproc.rb +1 -0
  81. data/lib/aspera/graphql.rb +5 -5
  82. data/lib/aspera/json_rpc/client.rb +5 -5
  83. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  84. data/lib/aspera/keychain/one_password_api.rb +1 -1
  85. data/lib/aspera/link_header.rb +2 -2
  86. data/lib/aspera/log.rb +22 -25
  87. data/lib/aspera/markdown.rb +2 -0
  88. data/lib/aspera/mime.rb +25 -0
  89. data/lib/aspera/node_simulator.rb +1 -0
  90. data/lib/aspera/oauth/base.rb +35 -25
  91. data/lib/aspera/oauth/factory.rb +1 -0
  92. data/lib/aspera/oauth/generic.rb +1 -1
  93. data/lib/aspera/oauth/jwt.rb +1 -1
  94. data/lib/aspera/oauth/web.rb +9 -8
  95. data/lib/aspera/preview/file_types.rb +4 -4
  96. data/lib/aspera/preview/generator.rb +7 -0
  97. data/lib/aspera/preview/options.rb +4 -4
  98. data/lib/aspera/preview/terminal.rb +4 -3
  99. data/lib/aspera/preview/utils.rb +9 -6
  100. data/lib/aspera/products/connect.rb +1 -1
  101. data/lib/aspera/rainbow.rb +7 -0
  102. data/lib/aspera/rest/aspera_errors.rb +60 -0
  103. data/lib/aspera/rest/call_error.rb +27 -0
  104. data/lib/aspera/rest/client.rb +514 -0
  105. data/lib/aspera/rest/error_analyzer.rb +113 -0
  106. data/lib/aspera/rest/list.rb +143 -0
  107. data/lib/aspera/rest/parameters.rb +55 -0
  108. data/lib/aspera/rest/util.rb +176 -0
  109. data/lib/aspera/rest.rb +7 -621
  110. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  111. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  113. data/lib/aspera/schema/documentation.rb +13 -3
  114. data/lib/aspera/schema/registry.rb +18 -1
  115. data/lib/aspera/schema/validator.rb +92 -0
  116. data/lib/aspera/secret_hider.rb +36 -25
  117. data/lib/aspera/string_ext.rb +15 -0
  118. data/lib/aspera/temp_file_manager.rb +6 -5
  119. data/lib/aspera/transfer/parameters.rb +2 -0
  120. data/lib/aspera/transfer/spec.rb +1 -0
  121. data/lib/aspera/transfer/spec.schema.yaml +1 -0
  122. data/lib/aspera/uri_reader.rb +11 -11
  123. data/lib/aspera/web_auth/index.html +147 -0
  124. data/lib/aspera/web_auth/server.rb +81 -0
  125. data.tar.gz.sig +0 -0
  126. metadata +39 -7
  127. metadata.gz.sig +0 -0
  128. data/lib/aspera/colors.rb +0 -79
  129. data/lib/aspera/rest_call_error.rb +0 -25
  130. data/lib/aspera/rest_error_analyzer.rb +0 -111
  131. data/lib/aspera/rest_errors_aspera.rb +0 -58
  132. data/lib/aspera/rest_list.rb +0 -136
  133. data/lib/aspera/web_auth.rb +0 -211
@@ -24,8 +24,10 @@ module Aspera
24
24
  ALL = (GLOBAL + INSTANCE).freeze
25
25
  end
26
26
  class << self
27
- # Per-class DSL registry (not inherited: each subclass gets its own instance).
28
- # @return [CommandRegistry]
27
+ include OptionDeclarator
28
+
29
+ # Option sources added with `use_options`.
30
+ # @return [Array<Class, Module>]
29
31
  def used_option_sources
30
32
  @used_option_sources ||= []
31
33
  end
@@ -36,6 +38,8 @@ module Aspera
36
38
  used_option_sources << source unless used_option_sources.include?(source)
37
39
  end
38
40
 
41
+ # Per-class DSL registry (not inherited: each subclass gets its own instance).
42
+ # @return [CommandRegistry]
39
43
  def command_registry
40
44
  @command_registry ||= CommandRegistry.new
41
45
  end
@@ -43,25 +47,50 @@ module Aspera
43
47
  # DSL class method: register a command in this plugin's registry.
44
48
  # Inherits parent from the enclosing commands_under block when parent: is omitted.
45
49
  # @param id [Symbol]
46
- # @param kwargs [Hash] forwarded to CommandSpec
50
+ # @param kwargs [Hash] forwarded to [CommandSpec]
47
51
  def command(id, **kwargs)
48
52
  kwargs[:parent] = @current_parent if kwargs[:parent].nil? && @current_parent
49
53
  command_registry.register(CommandSpec.new(id: id, **kwargs))
50
54
  end
51
55
 
52
- # Derive a display name from an entity path:
53
- # last segment after '/', underscores replaced by spaces, first letter capitalized.
54
- # e.g. 'data/smtp_server' -> 'Smtp server', 'data/transfer_settings' -> 'Transfer settings'
55
- def entity_display_name(entity)
56
- entity.to_s.split('/').last.tr('_', ' ').capitalize
56
+ # Words displayed with specific case in descriptions
57
+ NOUN_WORDS = {'smtp' => 'SMTP', 'ldap' => 'LDAP', 'saml' => 'SAML', 'oauth' => 'OAuth', 'kms' => 'KMS', 'api' => 'API'}.freeze
58
+ private_constant :NOUN_WORDS
59
+
60
+ # Derive a lowercase noun from an entity path, singular unless told otherwise.
61
+ # e.g. 'access_keys' -> 'access key', 'data/smtp_server' -> 'SMTP server'
62
+ # @param entity [String, Symbol] REST path or entity name
63
+ # @param singular [Boolean] Singularize the last word
64
+ # @return [String]
65
+ def entity_noun(entity, singular: true)
66
+ words = entity.to_s.split('/').last.split('_').map { |w| NOUN_WORDS.fetch(w, w) }
67
+ words[-1] = words[-1].sub(/ies\z/, 'y').sub(/(ss|x|sh|ch)es\z/, '\1').sub(/(?<!s)s\z/, '') if singular
68
+ words.join(' ')
69
+ end
70
+
71
+ # Standard description of a CRUD operation on an entity.
72
+ # e.g. (:list, 'access key') -> 'List access keys', (:show, 'access key') -> 'Show access key'
73
+ # @param verb [Symbol] Operation
74
+ # @param noun [String] Singular noun of entity
75
+ # @return [String]
76
+ def operation_description(verb, noun)
77
+ return "#{verb.capitalize} #{noun}" unless verb.eql?(:list)
78
+ plural =
79
+ case noun
80
+ when /[^aeiou]y\z/ then noun.sub(/y\z/, 'ies')
81
+ when /(s|x|sh|ch)\z/ then "#{noun}es"
82
+ else "#{noun}s"
83
+ end
84
+ "List #{plural}"
57
85
  end
58
86
 
59
87
  # DSL class method: declare CRUD commands for a REST entity.
60
88
  #
61
89
  # For each verb in operations:, registers one CommandSpec with:
62
- # - description: "#{verb.capitalize} #{name}"
63
- # - arguments: [{name: :id, type: :identifier, lookup: lookup}] for instance verbs
90
+ # - description: operation_description(verb, name)
91
+ # - arguments: [{name: id_name, type: :identifier, lookup: lookup}] for instance verbs
64
92
  # (:show, :modify, :delete) when not a singleton; none for global verbs
93
+ # body of :create and :modify is named after the entity (e.g. <access_key>), passed as data:
65
94
  # - action: calls entity_<verb>(api:, entity:, **shared_kwargs, **ctx)
66
95
  #
67
96
  # api: is resolved at runtime: :@ivar -> instance_variable_get, else -> send.
@@ -71,14 +100,23 @@ module Aspera
71
100
  # @param api [Symbol, String] Runtime API ref (:@ivar or method name) or literal string
72
101
  # @param entity [String, Symbol] REST sub-path, or ctx key Symbol resolved at runtime
73
102
  # @param operations [Array<Symbol>] Verbs to expose; defaults to Operations::ALL
74
- # @param name [String, nil] Display name; defaults to last segment of entity (static only)
103
+ # @param name [String, nil] Singular display name; defaults to last segment of entity (static only)
75
104
  # @param lookup [Symbol, nil] Instance method for percent-selector resolution
105
+ # @param id_name [Symbol, nil] Name of identifier argument; defaults to <name>_id, or id_as_arg field, or :id
76
106
  # @param kwargs [Hash] Shared params forwarded to every per-verb method
77
- def crud_commands(api:, entity:, operations: nil, name: nil, lookup: nil, **kwargs)
78
- name ||= entity_display_name(entity) unless entity.is_a?(Symbol)
107
+ def crud_commands(api:, entity:, operations: nil, name: nil, lookup: nil, id_name: nil, **kwargs)
108
+ name ||= entity_noun(entity, singular: !kwargs[:is_singleton]) unless entity.is_a?(Symbol)
79
109
  operations ||= Operations::ALL
110
+ # Body argument is named after the entity, e.g. <access_key>, and passed as data: to entity_<verb>
111
+ data_name = name ? name.downcase.tr(' ', '_').to_sym : :data
112
+ # Identifier argument is named after the entity, e.g. <access_key_id>, and passed as id: to entity_<verb>
113
+ id_name ||=
114
+ if kwargs[:id_as_arg].is_a?(String) then kwargs[:id_as_arg].to_sym
115
+ elsif name then :"#{data_name}_id"
116
+ else :id
117
+ end
80
118
  operations.each do |verb|
81
- id_arg = ({name: :id, type: :identifier, lookup: lookup} if Operations::INSTANCE.include?(verb) && !kwargs[:is_singleton])
119
+ id_arg = ({name: id_name, type: :identifier, lookup: lookup} if Operations::INSTANCE.include?(verb) && !kwargs[:is_singleton])
82
120
  schema_val =
83
121
  if kwargs[:body_component] && entity.is_a?(String)
84
122
  case verb
@@ -89,9 +127,9 @@ module Aspera
89
127
  args =
90
128
  case verb
91
129
  when :create
92
- [{name: :data, type: Hash, bulk: true, schema: schema_val}]
130
+ [{name: data_name, type: Hash, bulk: true, schema: schema_val}]
93
131
  when :modify
94
- [id_arg, {name: :data, type: Hash, schema: schema_val}].compact
132
+ [id_arg, {name: data_name, type: Hash, schema: schema_val}].compact
95
133
  when :delete
96
134
  id_arg ? [id_arg.merge(bulk: true)] : nil
97
135
  else
@@ -107,10 +145,13 @@ module Aspera
107
145
  api
108
146
  end
109
147
  resolved_entity = entity.is_a?(Symbol) ? ctx.fetch(entity) : entity
148
+ ctx = ctx.merge(data: ctx[data_name]) if ctx.key?(data_name)
149
+ ctx = ctx.merge(id: ctx[id_name]) if ctx.key?(id_name)
110
150
  send(:"entity_#{verb}", api: resolved_api, entity: resolved_entity, **kwargs, **ctx)
111
151
  end
112
- cmd_attrs = {description: "#{verb.capitalize} #{name || entity.inspect}", action: action_proc}
152
+ cmd_attrs = {description: operation_description(verb, name || entity.inspect), action: action_proc}
113
153
  cmd_attrs[:arguments] = args if args
154
+ cmd_attrs[:query_schema] = Schema::Registry.query_params(kwargs[:query_component], entity) if verb.eql?(:list) && kwargs[:query_component] && entity.is_a?(String)
114
155
  command(verb, **cmd_attrs)
115
156
  end
116
157
  end
@@ -139,7 +180,7 @@ module Aspera
139
180
  path = Array(@current_parent) + Array(parent)
140
181
  unless command_registry[path]
141
182
  id = path.last
142
- desc = description || "Manage #{entity_display_name(id)}"
183
+ desc = description || "Manage #{entity_noun(id, singular: false)}"
143
184
  parent_path = path[0..-2]
144
185
  saved = @current_parent
145
186
  @current_parent = parent_path.empty? ? nil : parent_path
@@ -153,101 +194,55 @@ module Aspera
153
194
  @current_parent = previous
154
195
  end
155
196
 
156
- # DSL class method: declare an option in this plugin's registry.
197
+ # Options of this plugin class (`option` DSL, see OptionDeclarator).
157
198
  # Metadata is stored as an OptionSpec at class-load time; the actual
158
199
  # options.declare call happens in Base#initialize once the instance exists.
159
- #
200
+ # @return [Hash{Symbol => OptionSpec}]
201
+ def option_specs
202
+ command_registry.option_specs
203
+ end
204
+
205
+ # Store an OptionSpec in the command registry.
160
206
  # Raises ArgumentError at class-load time if the same option name is already
161
207
  # declared by any ancestor class, preventing silent shadowing.
162
- #
163
- # @param name [Symbol] Option name
164
- # @param description [String, nil] User-facing description; if nil, derived from schema: title/description
165
- # @param short [String, nil] Single-character short form (without leading '-')
166
- # @param allowed [Object, nil] Allowed values (see OptionValue)
167
- # @param default [Object, nil] Default value
168
- # @param handler [Symbol, Hash, nil]
169
- # - Symbol: resolved to {o: <plugin instance>, m: <symbol>} at runtime (Category B)
170
- # - Hash: {o: <object>, m: <method>} used as-is (Category A: singletons / constants)
171
- # - nil: option stores its value locally (no delegation)
172
- # @param deprecation [String, nil] Deprecation message forwarded to options.declare
173
- # @param schema [String, nil] Schema reference (e.g. "opts:components.schemas.Foo");
174
- # when description: is nil, the schema title or first description line is used
175
- def option(name, description: nil,
176
- short: nil, allowed: nil, default: nil,
177
- handler: nil, deprecation: nil, schema: nil)
208
+ # @param spec [OptionSpec]
209
+ def register_option_spec(spec)
178
210
  ancestor_owner = ancestors.drop(1).find do |klass|
179
211
  klass.is_a?(Class) && klass <= Base &&
180
212
  klass.instance_variable_defined?(:@command_registry) &&
181
- klass.command_registry.option_specs.key?(name)
213
+ klass.command_registry.option_specs.key?(spec.name)
182
214
  end
183
- raise ArgumentError, "#{self}: option :#{name} already declared in ancestor #{ancestor_owner}" if ancestor_owner
184
- command_registry.register_option(
185
- OptionSpec.new(
186
- name: name,
187
- description: description,
188
- short: short,
189
- allowed: allowed,
190
- default: default,
191
- handler: handler,
192
- deprecation: deprecation,
193
- schema: schema
194
- )
195
- )
215
+ raise ArgumentError, "#{self}: option :#{spec.name} already declared in ancestor #{ancestor_owner}" if ancestor_owner
216
+ command_registry.register_option(spec)
196
217
  end
197
218
 
198
- # Declare all options registered on this plugin class onto a Parser instance.
199
- # Walks inherited options and any sources added via `use_options`.
200
- # @param options [Aspera::Cli::Parser]
201
- # @param parse [Boolean] whether to call parse_options! after declaring
202
- def declare_options(options, parse: false)
219
+ # Classes and modules whose options apply to this plugin:
220
+ # this class, its plugin ancestors and sources added via `use_options`.
221
+ # @return [Array<Class, Module>] each responds to `option_specs`
222
+ def option_sources
203
223
  sources = []
204
224
  ancestors.each do |klass|
205
225
  next unless klass.is_a?(Class) && klass <= Base
206
226
  sources << klass if klass.instance_variable_defined?(:@command_registry)
207
- sources.concat(klass.used_option_sources) if klass.respond_to?(:used_option_sources)
227
+ sources.concat(klass.used_option_sources)
208
228
  end
209
- sources.uniq.each do |src|
210
- specs =
211
- if src.respond_to?(:command_registry)
212
- src.command_registry.option_specs
213
- elsif src.respond_to?(:option_specs)
214
- src.option_specs
215
- else
216
- {}
217
- end
218
- specs.each_value do |spec|
219
- next if options.option_declared?(spec.name)
220
- resolved_handler =
221
- case spec.handler
222
- when Hash then spec.handler
223
- end
224
- options.declare(
225
- spec.name,
226
- description: spec.description,
227
- short: spec.short,
228
- allowed: spec.allowed,
229
- default: spec.default,
230
- handler: resolved_handler,
231
- deprecation: spec.deprecation,
232
- schema: spec.schema
233
- )
229
+ sources.uniq
230
+ end
231
+
232
+ # Declare all options of `option_sources` onto a Parser instance.
233
+ # Skips options already declared on the parser: it is shared across all plugins in a run.
234
+ # @param options [Aspera::Cli::Parser]
235
+ # @param target [Base, nil] plugin instance for Symbol and Proc `on_set` callbacks; nil: such callbacks are not bound
236
+ # @param parse [Boolean] whether to call parse_options! after declaring
237
+ def declare_options(options, target: nil, parse: false)
238
+ option_sources.each do |src|
239
+ src.option_specs.each_value do |spec|
240
+ spec.declare_on(options, target: target) unless options.option_declared?(spec.name)
234
241
  end
235
242
  end
236
243
  options.parse_options! if parse
237
244
  end
238
245
 
239
- # DSL class method: declare a setup method to run once before root dispatch.
240
- # The method is called before any command is consumed, and its return value
241
- # (a Hash) is merged into the initial ctx. This is useful when conditions
242
- # on root commands depend on state built during setup (e.g. @connection_type).
243
- # @param method_name [Symbol]
244
- def root_setup(method_name)
245
- @root_setup_method = method_name
246
- end
247
-
248
- # @return [Symbol, nil]
249
- attr_reader :root_setup_method
250
-
251
246
  # DSL class method: declare the human-readable application name shown in wizards.
252
247
  # When called with an argument, sets the name. When called with no argument, returns it.
253
248
  # Falls back to the last component of the class name if never set.
@@ -289,49 +284,9 @@ module Aspera
289
284
  # below (DSL-registered and imperative) appear under the plugin section in
290
285
  # --help output, separate from the global options.
291
286
  options.group(self.class.name.split('::').last.downcase) if @context.man_header
292
- # Auto-declare all options registered via the DSL `option` class method.
293
- # Walk the ancestor chain so that options declared on parent plugin classes
294
- # (e.g. Oauth, BasicAuth) are also registered for sub-classes (e.g. Aoc).
295
- # The options object is shared across all plugins in a run; skip options already
296
- # declared by an earlier plugin (Base.option prevents duplicates within one hierarchy).
297
- # Each OptionSpec is translated to an options.declare call, resolving the
298
- # handler: shorthand:
299
- # Symbol handler: {o: self, m: <symbol>} (Category B - plugin instance methods)
300
- # Hash handler: used as-is (Category A - singletons / class constants)
301
- sources = []
302
- self.class.ancestors.each do |klass|
303
- next unless klass.is_a?(Class) && klass <= Base
304
- sources << klass if klass.instance_variable_defined?(:@command_registry)
305
- sources.concat(klass.used_option_sources) if klass.respond_to?(:used_option_sources)
306
- end
307
- sources.uniq.each do |src|
308
- specs =
309
- if src.respond_to?(:command_registry)
310
- src.command_registry.option_specs
311
- elsif src.respond_to?(:option_specs)
312
- src.option_specs
313
- else
314
- {}
315
- end
316
- specs.each_value do |spec|
317
- next if options.option_declared?(spec.name)
318
- resolved_handler =
319
- case spec.handler
320
- when Symbol then {o: self, m: spec.handler}
321
- when Hash then spec.handler
322
- end
323
- options.declare(
324
- spec.name,
325
- description: spec.description,
326
- short: spec.short,
327
- allowed: spec.allowed,
328
- default: spec.default,
329
- handler: resolved_handler,
330
- deprecation: spec.deprecation,
331
- schema: spec.schema
332
- )
333
- end
334
- end
287
+ # Auto-declare all options registered via the DSL `option` class method,
288
+ # including those of parent plugin classes (e.g. Oauth, BasicAuth) and `use_options` sources.
289
+ self.class.declare_options(options, target: self)
335
290
  end
336
291
 
337
292
  # Global objects
@@ -365,38 +320,25 @@ module Aspera
365
320
  # Entry point for all DSL-based plugins.
366
321
  def execute_action
367
322
  @help_path = nil
368
- # Validate the registry once per class (memoised by the ivar check).
369
- # Passes the plugin class so implicit handler methods can be verified.
370
- unless self.class.instance_variable_defined?(:@registry_validated)
371
- self.class.command_registry.validate!(plugin_class: self.class)
372
- self.class.instance_variable_set(:@registry_validated, true)
373
- end
374
- # Run the root setup (if declared) before consuming any argument.
375
- # This ensures condition methods on root commands can read instance variables
376
- # populated by the setup (e.g. @connection_type in server.rb).
377
- init_ctx = {}
378
- if (rsm = self.class.root_setup_method)
379
- init_ctx = send(rsm) || {}
380
- end
381
- dispatch_from_registry([], init_ctx)
323
+ validate_registry
324
+ dispatch_from_registry([])
382
325
  end
383
326
 
384
327
  # Two-phase dispatcher: run setup on the current node (Phase A), then either
385
328
  # execute a leaf directly or consume the next argument and recurse (Phase B).
386
329
  # @param current_path [Array<Symbol>] path of the node currently being dispatched
387
330
  # @param ctx [Hash] accumulated context passed down from parent nodes
388
- # @param skip_setup [Boolean] when true, skip Phase A (setup already done by caller)
389
331
  # @return [Object] result suitable for CLI output
390
- def dispatch_from_registry(current_path, ctx = {}, skip_setup: false)
332
+ def dispatch_from_registry(current_path, ctx = {})
391
333
  registry = self.class.command_registry
392
334
  spec = registry[current_path]
393
335
  is_leaf = spec && registry.children_of(current_path).empty?
394
336
 
395
- if options.help_requested || skip_setup
337
+ if @context.help_requested
396
338
  # help_requested on an intermediate node: drain positional args without validation
397
339
  # so that dispatch_child can still consume the correct sub-command token
398
- if !is_leaf && !skip_setup && spec&.arguments
399
- spec.arguments.each do |arg_spec|
340
+ if !is_leaf
341
+ registry.arguments_at(current_path).each do |arg_spec|
400
342
  next if ctx.key?(arg_spec.name)
401
343
  options.get_next_argument(arg_spec.name.to_s, mandatory: false)
402
344
  end
@@ -404,24 +346,7 @@ module Aspera
404
346
  else
405
347
  # Phase A - for intermediate nodes only: resolve all ArgumentSpec declared on this node
406
348
  # before dispatching to children (leaf nodes resolve their arguments inside execute_leaf).
407
- if !is_leaf
408
- (spec&.arguments || []).each do |arg_spec|
409
- next if ctx.key?(arg_spec.name)
410
- if arg_spec.type.eql?(:identifier)
411
- lookup_cb = arg_spec.lookup
412
- res_id = if lookup_cb.nil?
413
- options.instance_identifier(description: arg_spec.name.to_s)
414
- elsif lookup_cb.is_a?(Symbol)
415
- options.instance_identifier(description: arg_spec.name.to_s) { |f, v| send(lookup_cb, f, v, **ctx) }
416
- else
417
- options.instance_identifier(description: arg_spec.name.to_s) { |f, v| instance_exec(f, v, **ctx, &lookup_cb) }
418
- end
419
- ctx = ctx.merge(arg_spec.name => res_id)
420
- else
421
- ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
422
- end
423
- end
424
- end
349
+ ctx = resolve_arguments(spec.arguments, ctx) if !is_leaf && spec&.arguments
425
350
  ctx = ctx.merge(send(spec.setup, **ctx)) if spec&.setup
426
351
  end
427
352
 
@@ -440,15 +365,15 @@ module Aspera
440
365
  # @param ctx [Hash]
441
366
  # @return [Object]
442
367
  def dispatch_leaf(current_path, spec, ctx)
443
- if options.help_requested
368
+ if @context.help_requested
444
369
  @help_path = current_path
445
370
  raise Cli::HelpRequest, self
446
371
  end
447
372
  execute_leaf(spec, ctx)
448
373
  end
449
374
 
450
- # Phase B, child branch: consume the next command argument, resolve the matching
451
- # child spec, handle delegation, and recurse or execute.
375
+ # Phase B, child branch: consume the next command argument, then either continue
376
+ # on a mounted plugin instance, or recurse into the child.
452
377
  # --help is intercepted at two points:
453
378
  # 1. Before get_next_command when no positional arg is pending: raises HelpRequest
454
379
  # immediately so the subcommand list with descriptions is shown rather than a
@@ -461,37 +386,63 @@ module Aspera
461
386
  # @return [Object]
462
387
  def dispatch_child(current_path, registry, ctx)
463
388
  children = registry.children_of(current_path)
464
- available = children.reject { |_, c| c.condition && !send(c.condition) }
389
+ # condition: methods belong to the class declaring the spec: only evaluate local ones
390
+ # (mounted children are only walked here for --help, see below).
391
+ # With --help, conditions are not evaluated: they may need the API, which is not built for help.
392
+ available = children.reject { |id, c| c.condition && !@context.help_requested && registry.local?(current_path + [id]) && !send(c.condition) }
465
393
  aliases = children.values.each_with_object({}) do |c, h|
466
394
  Array(c.aliases).each { |a| h[a] = c.id } if c.aliases
467
395
  end
468
396
 
469
397
  # Intercept --help before consuming the command token when no arg is pending.
470
398
  # This avoids MissingArgument being raised by get_next_command before HelpRequest.
471
- if options.help_requested && options.command_or_arg_empty?
399
+ if @context.help_requested && options.command_or_arg_empty?
472
400
  @help_path = current_path
473
401
  raise Cli::HelpRequest, self
474
402
  end
475
403
 
476
404
  command = options.get_next_command(available.keys, aliases: aliases.empty? ? nil : aliases)
477
- child = available[command]
478
405
 
479
406
  # Intercept --help after a command was consumed but no further args remain.
480
407
  # (e.g. `aoc files find -h`). When further args remain, keep recursing.
481
- if options.help_requested && options.command_or_arg_empty?
408
+ if @context.help_requested && options.command_or_arg_empty?
482
409
  @help_path = current_path + [command]
483
410
  raise Cli::HelpRequest, self
484
411
  end
485
412
 
486
- # Instance delegation: hand off to a different plugin object
487
- if child.delegate_instance
488
- target = send(child.delegate_instance)
489
- return target.dispatch_from_registry(Array(child.delegates_to), {})
490
- end
491
- return dispatch_from_registry(Array(child.delegates_to), ctx) if child.delegates_to
413
+ # Mounted child: continue on the target plugin instance, in its own namespace.
414
+ # For --help, keep walking the (mount-aware) registry of this class instead, so that
415
+ # no target instance (and thus no API connection) is needed.
416
+ child_path = current_path + [command]
417
+ return dispatch_mount(registry.mount_of(current_path), command, ctx) unless @context.help_requested || registry.local?(child_path)
492
418
 
493
- # Both intermediate and leaf: instance_arg + setup are handled by Phase A of the next call
494
- dispatch_from_registry(current_path + [command], ctx)
419
+ # Both intermediate and leaf: arguments + setup are handled by Phase A of the next call
420
+ dispatch_from_registry(child_path, ctx)
421
+ end
422
+
423
+ # Hand over dispatch of a mounted child to the target plugin instance.
424
+ # Setups of the mount point `at` and of its ancestors in the target are not executed:
425
+ # the seed ctx returned by the host's `instance` method replaces them.
426
+ # The mount's own arguments (if any) are read first, and passed to `instance` in ctx.
427
+ # @param mount [MountSpec]
428
+ # @param command [Symbol] mounted child id, already consumed
429
+ # @param ctx [Hash] host context, passed to the `instance` method
430
+ # @return [Object]
431
+ def dispatch_mount(mount, command, ctx)
432
+ ctx = resolve_arguments(mount.arguments, ctx)
433
+ target = send(mount.instance, **ctx)
434
+ target, seed = target if target.is_a?(Array)
435
+ Aspera.assert_type(target, mount.plugin)
436
+ target.validate_registry
437
+ target.dispatch_from_registry(mount.at + [command], seed || {})
438
+ end
439
+
440
+ # Validate the registry once per class (memoised by the ivar check).
441
+ # Passes the plugin class so implicit action methods can be verified.
442
+ def validate_registry
443
+ return if self.class.instance_variable_defined?(:@registry_validated)
444
+ self.class.command_registry.validate!(plugin_class: self.class)
445
+ self.class.instance_variable_set(:@registry_validated, true)
495
446
  end
496
447
 
497
448
  # Resolve the action for a leaf CommandSpec.
@@ -520,10 +471,8 @@ module Aspera
520
471
  end
521
472
 
522
473
  # Execute a leaf CommandSpec: resolve arguments and call action.
523
- # Arguments already present in +ctx+ (pre-resolved by a parent plugin, e.g. aoc.rb forwarding
524
- # path: into execute_nodegen4_command) are skipped — the token has already been consumed.
525
- # instance_arg (if any) is resolved here as an ArgumentSpec(type: :identifier) and merged
526
- # into ctx, exactly like any other keyword argument received by the action.
474
+ # Arguments already present in `ctx` (e.g. provided by a caller or a mount seed) are skipped:
475
+ # they are not read again from the command line.
527
476
  # @param spec [CommandSpec] a leaf node (no children)
528
477
  # @param ctx [Hash] accumulated context (pre-resolved keys are not re-consumed)
529
478
  # @return [Object]
@@ -531,21 +480,30 @@ module Aspera
531
480
  a = action_for(spec)
532
481
  # Always resolve declared arguments (even when transfer_paths is set — those arguments
533
482
  # are consumed first; ts_source_paths then reads whatever remains in the queue).
534
- (spec.arguments || []).each do |arg_spec|
483
+ ctx = resolve_arguments(spec.arguments, ctx) if spec.arguments
484
+ invoke_action(a, [], ctx)
485
+ end
486
+
487
+ # Resolve positional arguments from the CLI argument stream, in order.
488
+ # Arguments already present in `ctx` are not read again.
489
+ # For type: :identifier, the percent-selector lookup receives the ctx accumulated so far.
490
+ # @param arg_specs [Array<ArgumentSpec>]
491
+ # @param ctx [Hash] accumulated context
492
+ # @return [Hash] ctx merged with the resolved arguments
493
+ def resolve_arguments(arg_specs, ctx)
494
+ arg_specs.each do |arg_spec|
535
495
  next if ctx.key?(arg_spec.name)
536
- if arg_spec.type.eql?(:identifier)
537
- lookup_cb = arg_spec.lookup
538
- block =
539
- if lookup_cb.nil? then nil
540
- elsif lookup_cb.is_a?(Symbol) then ->(f, v) { send(lookup_cb, f, v, **ctx) }
541
- else ->(f, v) { instance_exec(f, v, **ctx, &lookup_cb) }
542
- end
543
- ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec, &block))
544
- else
545
- ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
546
- end
496
+ lookup_cb = arg_spec.lookup if arg_spec.type.eql?(:identifier)
497
+ current = ctx
498
+ block =
499
+ case lookup_cb
500
+ when nil then nil
501
+ when Symbol then ->(f, v) { send(lookup_cb, f, v, **current) }
502
+ else ->(f, v) { instance_exec(f, v, **current, &lookup_cb) }
503
+ end
504
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec, &block))
547
505
  end
548
- invoke_action(a, [], ctx)
506
+ ctx
549
507
  end
550
508
 
551
509
  # Resolve a single positional argument from the CLI argument stream.
@@ -603,17 +561,18 @@ module Aspera
603
561
  # @param path [Array<Symbol>] starting path ([] for the full tree)
604
562
  # @return [Hash] { command_id => { description:, condition:, children: } }
605
563
  def generate_help(path = [])
606
- self.class.command_registry.children_of(path).transform_values do |child_spec|
564
+ self.class.command_registry.children_of(path).to_h do |id, child_spec|
607
565
  annotation = child_spec.condition ? " [#{child_spec.condition}]" : ''
608
- {
566
+ # path + [id], not child_spec.full_path: a mounted spec's full_path is in the target namespace
567
+ [id, {
609
568
  description: "#{child_spec.description}#{annotation}",
610
569
  condition: child_spec.condition,
611
- children: generate_help(child_spec.full_path)
612
- }
570
+ children: generate_help(path + [id])
571
+ }]
613
572
  end
614
573
  end
615
574
 
616
- # Convenience wrapper: reads :bulk and :bfail from options, normalises +items+
575
+ # Convenience wrapper: reads :bulk and :bfail from options, normalizes `items`
617
576
  # to an Array, then delegates to Result.bulk.
618
577
  # Use this in action methods instead of the three-line boilerplate:
619
578
  # is_bulk = options.get_option(:bulk)
@@ -623,7 +582,7 @@ module Aspera
623
582
  # @param command [Symbol] Operation name (:create, :delete, ...)
624
583
  # @param id_result [String] Key used as item identifier in the result row
625
584
  # @param fields [Object] Fields hint passed to Result constructor (non-bulk only)
626
- # @yieldparam item [Object] Each item in +items+
585
+ # @yieldparam item [Object] Each item in `items`
627
586
  # @return [Result::ObjectList, Result::SingleObject]
628
587
  def bulk_result(items, command:, id_result: 'id', fields: :default, &block)
629
588
  items = items.is_a?(Array) ? items : [items]
@@ -645,7 +604,7 @@ module Aspera
645
604
  # NOT read from the CLI queue inside the method.
646
605
 
647
606
  # List all instances of an entity.
648
- # @param api [Aspera::Rest] REST API object
607
+ # @param api [Aspera::Rest::Client] REST API object
649
608
  # @param entity [String] API sub-path
650
609
  # @param display_fields [Array, nil] Fields to display
651
610
  # @param items_key [String, nil] Sub-key in response containing the array
@@ -655,12 +614,14 @@ module Aspera
655
614
  qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
656
615
  data, http = api.read(entity, query_read_delete(default: list_query, schema: qs_path), ret: :both)
657
616
  return Result::Empty.new if http.code == '204'
658
- # TODO: not generic : which application is this for ?
659
- if http['Content-Type'].start_with?('application/vnd.api+json')
660
- Log.log.debug('is vnd.api')
617
+ if !data.is_a?(Hash)
618
+ # already the list
619
+ elsif items_key
620
+ data = data[items_key]
621
+ elsif http['Content-Type'].start_with?(Mime::JSON_API)
622
+ # JSON:API: list is under the entity name
661
623
  data = data[entity]
662
624
  end
663
- data = data[items_key] if items_key
664
625
  case data
665
626
  when Hash then Result::SingleObject.new(data, fields: display_fields)
666
627
  when Array
@@ -671,7 +632,7 @@ module Aspera
671
632
  end
672
633
 
673
634
  # Show one instance of an entity.
674
- # @param api [Aspera::Rest] REST API object
635
+ # @param api [Aspera::Rest::Client] REST API object
675
636
  # @param entity [String] API sub-path
676
637
  # @param id [String, nil] Resource identifier; nil when is_singleton: true
677
638
  # @param display_fields [Array, nil] Fields to display
@@ -683,43 +644,32 @@ module Aspera
683
644
  end
684
645
 
685
646
  # Create one or more instances of an entity (supports bulk).
686
- # @param api [Aspera::Rest] REST API object
687
- # @param entity [String] API sub-path
688
- # @param display_fields [Array, nil] Fields to display
689
- # @param body_component [String, nil] Registry key for request body schema
690
- # @param input_data [Array, nil] Pre-resolved data; when nil, read from CLI
691
- def entity_create(api:, entity:, display_fields: nil, body_component: nil, input_data: nil, data: nil, **)
692
- schema = body_component ? Schema::Registry.req_body(body_component, "#{entity}.post") : nil
693
- input_data ||= data
694
- unless input_data
695
- is_bulk = options.get_option(:bulk)
696
- raw = options.get_next_argument('data', validation: is_bulk ? Array : Hash, schema: schema)
697
- input_data = is_bulk ? raw : [raw]
698
- end
699
- input_data = [input_data] unless input_data.is_a?(Array)
700
- bulk_result(input_data, command: :create, fields: display_fields) do |params|
647
+ # @param api [Aspera::Rest::Client] REST API object
648
+ # @param entity [String] API sub-path
649
+ # @param data [Hash, Array<Hash>] Entity data (Array with bulk), from the command's declared `data` argument
650
+ # @param display_fields [Array, nil] Fields to display
651
+ def entity_create(api:, entity:, data:, display_fields: nil, **)
652
+ data = [data] unless data.is_a?(Array)
653
+ bulk_result(data, command: :create, fields: display_fields) do |params|
701
654
  api.create(entity, params)
702
655
  end
703
656
  end
704
657
 
705
658
  # Modify an existing instance of an entity.
706
- # @param api [Aspera::Rest] REST API object
659
+ # @param api [Aspera::Rest::Client] REST API object
707
660
  # @param entity [String] API sub-path
708
661
  # @param id [String, nil] Resource identifier; nil when is_singleton: true
709
662
  # @param is_singleton [Boolean] When true, entity is the full path (no id appended)
710
663
  # @param id_as_arg [Boolean, String] When set, id is appended as ?<id_as_arg>=<id>
711
- # @param body_component [String, nil] Registry key for request body schema
712
- # @param input_data [Hash, nil] Pre-resolved data; when nil, read from CLI
713
- def entity_modify(api:, entity:, id: nil, is_singleton: false, id_as_arg: false, body_component: nil, input_data: nil, data: nil, **)
714
- schema = body_component ? Schema::Registry.req_body(body_component, "#{entity}/{id}.put") : nil
664
+ # @param data [Hash] Modified fields, from the command's declared `data` argument
665
+ def entity_modify(api:, entity:, data:, id: nil, is_singleton: false, id_as_arg: false, **)
715
666
  path = entity_res_path(entity, id, is_singleton: is_singleton, id_as_arg: id_as_arg)
716
- parameters = input_data || data || options.get_next_argument('data', validation: Hash, schema: schema)
717
- api.update(path, parameters)
667
+ api.update(path, data)
718
668
  Result::Status.new('modified')
719
669
  end
720
670
 
721
671
  # Delete one or more instances of an entity (supports bulk).
722
- # @param api [Aspera::Rest] REST API object
672
+ # @param api [Aspera::Rest::Client] REST API object
723
673
  # @param entity [String] API sub-path
724
674
  # @param id [String, Array, nil] Resource identifier(s)
725
675
  # @param id_as_arg [Boolean, String] When set, id is appended as ?<id_as_arg>=<id>