aspera-cli 4.26.2 → 4.27.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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/cli/extended_value'
4
+ require 'aspera/cli/parser'
4
5
  require 'aspera/assert'
5
6
  require 'aspera/cli/result'
7
+ require 'aspera/cli/command_registry'
8
+ require 'aspera/schema/registry'
6
9
 
7
10
  module Aspera
8
11
  module Cli
@@ -20,25 +23,168 @@ module Aspera
20
23
  ALL = (GLOBAL + INSTANCE).freeze
21
24
  end
22
25
  class << self
23
- def declare_options(options)
24
- options.declare(:query, 'Additional filter for for some commands (list/delete)', allowed: [Hash, Array, NilClass])
25
- options.declare(:bulk, 'Bulk operation (only some)', allowed: Allowed::TYPES_BOOLEAN, default: false)
26
- options.declare(:bfail, 'Bulk operation error handling', allowed: Allowed::TYPES_BOOLEAN, default: true)
26
+ # Per-class DSL registry (not inherited: each subclass gets its own instance).
27
+ # @return [CommandRegistry]
28
+ def command_registry
29
+ @command_registry ||= CommandRegistry.new
30
+ end
31
+
32
+ # DSL class method: register a command in this plugin's registry.
33
+ # Inherits parent from the enclosing commands_under block when parent: is omitted.
34
+ # @param id [Symbol]
35
+ # @param kwargs [Hash] forwarded to CommandSpec
36
+ def command(id, **kwargs)
37
+ kwargs[:parent] = @current_parent if kwargs[:parent].nil? && @current_parent
38
+ command_registry.register(CommandSpec.new(id: id, **kwargs))
39
+ end
40
+
41
+ # DSL class method: shorthand for a command whose sole action is Base#entity_execute.
42
+ # The api: value is a Symbol resolved at runtime: if it starts with '@' it is treated
43
+ # as an instance variable name; otherwise it is sent as a method call.
44
+ # description: defaults to "Manage <last segment of entity path>" when omitted.
45
+ # @param id [Symbol] Command identifier
46
+ # @param api [Symbol] Method name or :@ivar returning the REST API at runtime
47
+ # @param entity [String] API sub-path (e.g. 'admin/dropboxes')
48
+ # @param description [String, nil] User-facing help text; derived from entity when nil
49
+ # @param kwargs [Hash] Any other entity_execute params (display_fields:, command:, is_singleton:, etc.)
50
+ def entity_command(id, api:, entity:, description: nil, **kwargs)
51
+ description ||= "Manage #{entity.split('/').last}"
52
+ command(id, description: description, entity_execute: {api: api, entity: entity, **kwargs})
53
+ end
54
+
55
+ # DSL class method: define an instance method whose name is derived from a path array.
56
+ # Equivalent to: define_method(CommandSpec.action_method(path), &block)
57
+ # @param path [Array<Symbol>] command path segments, e.g. [:admin, :user, :list]
58
+ # @yieldparam [Hash] keyword context forwarded from dispatch
59
+ def define_action_method(path, &block)
60
+ define_method(CommandSpec.action_method(path), &block)
61
+ end
62
+
63
+ # DSL class method: scope block that sets a default parent for nested command() calls.
64
+ # Fully re-entrant: blocks may be nested for multi-level parent paths.
65
+ # @param parent [Symbol, Array<Symbol>] parent path applied to every command() inside
66
+ # @yieldreturn [void]
67
+ def commands_under(parent)
68
+ previous = @current_parent
69
+ @current_parent = parent
70
+ yield
71
+ ensure
72
+ @current_parent = previous
73
+ end
74
+
75
+ # DSL class method: declare an option in this plugin's registry.
76
+ # Metadata is stored as an OptionSpec at class-load time; the actual
77
+ # options.declare call happens in Base#initialize once the instance exists.
78
+ #
79
+ # Raises ArgumentError at class-load time if the same option name is already
80
+ # declared by any ancestor class, preventing silent shadowing.
81
+ #
82
+ # @param name [Symbol] Option name
83
+ # @param description [String, nil] User-facing description; if nil, derived from schema: title/description
84
+ # @param short [String, nil] Single-character short form (without leading '-')
85
+ # @param allowed [Object, nil] Allowed values (see OptionValue)
86
+ # @param default [Object, nil] Default value
87
+ # @param handler [Symbol, Hash, nil]
88
+ # - Symbol: resolved to {o: <plugin instance>, m: <symbol>} at runtime (Category B)
89
+ # - Hash: {o: <object>, m: <method>} used as-is (Category A: singletons / constants)
90
+ # - nil: option stores its value locally (no delegation)
91
+ # @param deprecation [String, nil] Deprecation message forwarded to options.declare
92
+ # @param schema [String, nil] Schema reference (e.g. "opts:components.schemas.Foo");
93
+ # when description: is nil, the schema title or first description line is used
94
+ def option(name, description: nil,
95
+ short: nil, allowed: nil, default: nil,
96
+ handler: nil, deprecation: nil, schema: nil)
97
+ ancestor_owner = ancestors.drop(1).find do |klass|
98
+ klass.is_a?(Class) && klass <= Base &&
99
+ klass.instance_variable_defined?(:@command_registry) &&
100
+ klass.command_registry.option_specs.key?(name)
101
+ end
102
+ raise ArgumentError, "#{self}: option :#{name} already declared in ancestor #{ancestor_owner}" if ancestor_owner
103
+ command_registry.register_option(
104
+ OptionSpec.new(
105
+ name: name,
106
+ description: description,
107
+ short: short,
108
+ allowed: allowed,
109
+ default: default,
110
+ handler: handler,
111
+ deprecation: deprecation,
112
+ schema: schema
113
+ )
114
+ )
115
+ end
116
+
117
+ # DSL class method: declare a setup method to run once before root dispatch.
118
+ # The method is called before any command is consumed, and its return value
119
+ # (a Hash) is merged into the initial ctx. This is useful when conditions
120
+ # on root commands depend on state built during setup (e.g. @connection_type).
121
+ # @param method_name [Symbol]
122
+ def root_setup(method_name)
123
+ @root_setup_method = method_name
124
+ end
125
+
126
+ # @return [Symbol, nil]
127
+ attr_reader :root_setup_method
128
+
129
+ # DSL class method: declare the human-readable application name shown in wizards.
130
+ # When called with an argument, sets the name. When called with no argument, returns it.
131
+ # Falls back to the last component of the class name if never set.
132
+ # @param name [String, nil]
133
+ # @return [String]
134
+ def application_name(name = nil)
135
+ @application_name = name unless name.nil?
136
+ @application_name || self.name.split('::').last
27
137
  end
28
138
  end
29
139
 
140
+ option :query, description: 'Additional filter for for some commands (list/delete)', allowed: [Hash, Array, NilClass]
141
+ option :bulk, description: 'Bulk operation (only some)', allowed: Allowed::TYPES_BOOLEAN, default: false
142
+ option :bfail, description: 'Bulk operation error handling', allowed: Allowed::TYPES_BOOLEAN, default: true
143
+
30
144
  def initialize(context:)
31
- # Check presence in descendant of mandatory method and constant
32
- Aspera.assert(respond_to?(:execute_action), type: InternalError){"Missing method 'execute_action' in #{self.class}"}
33
- Aspera.assert(self.class.const_defined?(:ACTIONS), type: InternalError){"Missing constant 'ACTIONS' in #{self.class}"}
145
+ Aspera.assert_type(context, Context){'context'}
146
+ Aspera.assert_type(context.man_header, TrueClass, FalseClass){'context.man_header'}
34
147
  @context = context
148
+ # Auto-declare all options registered via the DSL `option` class method.
149
+ # Walk the ancestor chain so that options declared on parent plugin classes
150
+ # (e.g. Oauth, BasicAuth) are also registered for sub-classes (e.g. Aoc).
151
+ # The options object is shared across all plugins in a run; skip options already
152
+ # declared by an earlier plugin (Base.option prevents duplicates within one hierarchy).
153
+ # Each OptionSpec is translated to an options.declare call, resolving the
154
+ # handler: shorthand:
155
+ # Symbol handler: {o: self, m: <symbol>} (Category B - plugin instance methods)
156
+ # Hash handler: used as-is (Category A - singletons / class constants)
157
+ self.class.ancestors.each do |klass|
158
+ next unless klass.is_a?(Class) && klass <= Base && klass.instance_variable_defined?(:@command_registry)
159
+ klass.command_registry.option_specs.each_value do |spec|
160
+ next if options.option_declared?(spec.name)
161
+ resolved_handler =
162
+ case spec.handler
163
+ when Symbol then {o: self, m: spec.handler}
164
+ when Hash then spec.handler
165
+ end
166
+ options.declare(
167
+ spec.name,
168
+ description: spec.description,
169
+ short: spec.short,
170
+ allowed: spec.allowed,
171
+ default: spec.default,
172
+ handler: resolved_handler,
173
+ deprecation: spec.deprecation,
174
+ schema: spec.schema
175
+ )
176
+ end
177
+ end
35
178
  add_manual_header if @context.man_header
36
179
  end
37
180
 
38
181
  # Global objects
39
182
  attr_reader :context
183
+ # Path reached in the command tree at the moment --help was intercepted.
184
+ # Nil until set by dispatch_from_registry.
185
+ attr_reader :help_path
40
186
 
41
- # @return [Aspera::Cli::Manager]
187
+ # @return [Aspera::Cli::Parser]
42
188
  def options; @context.options; end
43
189
  # @return [Aspera::Cli::TransferAgent]
44
190
  def transfer; @context.transfer; end
@@ -52,63 +198,270 @@ module Aspera
52
198
  def presets; @context.presets; end
53
199
  # @return [Aspera::Cli::Http]
54
200
  def http_config; @context.http_config; end
201
+ # @return [Aspera::Cli::TransferProgress, nil]
202
+ def progress_bar; @context.progress_bar; end
55
203
 
56
- def add_manual_header(has_options = true)
57
- # Manual header for all plugins
58
- options.parser.separator('')
59
- options.parser.separator("COMMAND: #{self.class.name.split('::').last.downcase}")
60
- options.parser.separator("SUBCOMMANDS: #{self.class.const_get(:ACTIONS).map(&:to_s).sort.join(' ')}")
61
- options.parser.separator('OPTIONS:') if has_options
62
- end
63
-
64
- # For create and delete operations: execute one action or multiple if bulk is yes
65
- # @param command [Symbol] Operation: :create, :delete, ...
66
- # @param descr [String, nil] Description of the value
67
- # @param values [Class, Array, Symbol] Type, or list of values, or :identifier, result is given to the block in loop
68
- # @param id_result [String] Key in result Hash to use as identifier
69
- # @param fields [Symbol, Array] Fields to display
70
- # @param schema [Hash, nil] JSON schema for validation
71
- # @yieldparam param [Object] The parameter value to process
72
- # @yieldreturn [Hash, nil] Result hash for the operation (optional)
73
- # @return [Hash] Result suitable for CLI output
74
- def do_bulk_operation(command:, descr: nil, values: Hash, id_result: 'id', fields: :default, schema: nil, &block)
75
- Aspera.assert(block_given?, 'missing block')
76
- is_bulk = options.get_option(:bulk)
77
- case values
78
- when :identifier
79
- values = options.instance_identifier(description: descr)
80
- when Class
81
- values = value_create_modify(command: command, description: descr, type: values, bulk: is_bulk, schema: schema)
82
- end
83
- # If not bulk, there is a single value
84
- params = is_bulk ? values : [values]
85
- Log.log.warn('Empty list given for bulk operation') if params.empty?
86
- Log.dump(:bulk_operation, params)
87
- result_list = []
88
- params.each do |param|
89
- # Init for delete
90
- result = {id_result => param}
91
- begin
92
- # Execute custom code
93
- res = yield(param)
94
- # If block returns a hash, let's use this (create)
95
- result = res if res.is_a?(Hash)
96
- # TODO: remove when faspio gw api fixes this
97
- result = res.first if res.is_a?(Array) && res.first.is_a?(Hash)
98
- # Create -> created
99
- result['status'] = "#{command}#{'e' unless command.to_s.end_with?('e')}d".gsub(/yed$/, 'ied')
100
- rescue StandardError => e
101
- raise e if options.get_option(:bfail)
102
- result['status'] = e.to_s
204
+ def add_manual_header(_has_options = true)
205
+ options.rename_current_group(self.class.name.split('::').last.downcase)
206
+ end
207
+
208
+ # Entry point for all DSL-based plugins.
209
+ def execute_action
210
+ @help_path = nil
211
+ # Validate the registry once per class (memoised by the ivar check).
212
+ # Passes the plugin class so implicit handler methods can be verified.
213
+ unless self.class.instance_variable_defined?(:@registry_validated)
214
+ self.class.command_registry.validate!(plugin_class: self.class)
215
+ self.class.instance_variable_set(:@registry_validated, true)
216
+ end
217
+ # Run the root setup (if declared) before consuming any argument.
218
+ # This ensures condition methods on root commands can read instance variables
219
+ # populated by the setup (e.g. @connection_type in server.rb).
220
+ init_ctx = {}
221
+ if (rsm = self.class.root_setup_method)
222
+ init_ctx = send(rsm) || {}
223
+ end
224
+ dispatch_from_registry([], init_ctx)
225
+ end
226
+
227
+ # Two-phase dispatcher: run setup on the current node (Phase A), then either
228
+ # execute a leaf directly or consume the next argument and recurse (Phase B).
229
+ # @param current_path [Array<Symbol>] path of the node currently being dispatched
230
+ # @param ctx [Hash] accumulated context passed down from parent nodes
231
+ # @param skip_setup [Boolean] when true, skip Phase A (setup already done by caller)
232
+ # @return [Object] result suitable for CLI output
233
+ def dispatch_from_registry(current_path, ctx = {}, skip_setup: false)
234
+ registry = self.class.command_registry
235
+ spec = registry[current_path]
236
+ is_leaf = spec && registry.children_of(current_path).empty?
237
+
238
+ if options.help_requested || skip_setup
239
+ # help_requested on an intermediate node: drain positional args without validation
240
+ # so that dispatch_child can still consume the correct sub-command token
241
+ if !is_leaf && !skip_setup && spec&.arguments
242
+ spec.arguments.each do |arg_spec|
243
+ next if ctx.key?(arg_spec.name)
244
+ options.get_next_argument(arg_spec.name.to_s, mandatory: false)
245
+ end
103
246
  end
104
- result_list.push(result)
247
+ else
248
+ # Phase A - for intermediate nodes only: resolve all ArgumentSpec declared on this node
249
+ # before dispatching to children (leaf nodes resolve their arguments inside execute_leaf).
250
+ if !is_leaf
251
+ (spec&.arguments || []).each do |arg_spec|
252
+ next if ctx.key?(arg_spec.name)
253
+ if arg_spec.type.eql?(:identifier)
254
+ lookup_method = arg_spec.lookup
255
+ res_id = if lookup_method
256
+ options.instance_identifier(description: arg_spec.name.to_s){ |f, v| send(lookup_method, f, v, **ctx)}
257
+ else
258
+ options.instance_identifier(description: arg_spec.name.to_s)
259
+ end
260
+ ctx = ctx.merge(arg_spec.name => res_id)
261
+ else
262
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
263
+ end
264
+ end
265
+ end
266
+ ctx = ctx.merge(send(spec.setup, **ctx)) if spec&.setup
105
267
  end
106
- display_fields = [id_result, 'status']
107
- if is_bulk
108
- return Result::ObjectList.new(result_list, fields: display_fields)
268
+
269
+ # Phase B - leaf fast-path or child dispatch
270
+ if is_leaf
271
+ dispatch_leaf(current_path, spec, ctx)
109
272
  else
110
- display_fields = fields unless fields.eql?(:default)
111
- return Result::SingleObject.new(result_list.first, fields: display_fields)
273
+ dispatch_child(current_path, registry, ctx)
274
+ end
275
+ end
276
+
277
+ # Phase B, leaf branch: execute a spec that is already a leaf (no children).
278
+ # Intercepts --help before calling execute_leaf.
279
+ # @param current_path [Array<Symbol>]
280
+ # @param spec [CommandSpec]
281
+ # @param ctx [Hash]
282
+ # @return [Object]
283
+ def dispatch_leaf(current_path, spec, ctx)
284
+ if options.help_requested
285
+ @help_path = current_path
286
+ raise Cli::HelpRequest, self
287
+ end
288
+ execute_leaf(spec, ctx)
289
+ end
290
+
291
+ # Phase B, child branch: consume the next command argument, resolve the matching
292
+ # child spec, handle delegation / entity_execute shorthands, and recurse or execute.
293
+ # @param current_path [Array<Symbol>]
294
+ # @param registry [CommandRegistry]
295
+ # @param ctx [Hash]
296
+ # @return [Object]
297
+ def dispatch_child(current_path, registry, ctx)
298
+ children = registry.children_of(current_path)
299
+ available = children.reject{ |_, c| c.condition && !send(c.condition)}
300
+ aliases = children.values.each_with_object({}) do |c, h|
301
+ Array(c.aliases).each{ |a| h[a] = c.id} if c.aliases
302
+ end
303
+ command = options.get_next_command(available.keys, aliases: aliases.empty? ? nil : aliases)
304
+ child = available[command]
305
+
306
+ # Intercept --help when no more positional args remain after consuming this command.
307
+ # (e.g. `aoc files -h` or `aoc files find -h`). When args remain, keep recursing.
308
+ if options.help_requested && options.command_or_arg_empty?
309
+ @help_path = current_path + [command]
310
+ raise Cli::HelpRequest, self
311
+ end
312
+
313
+ # Instance delegation: hand off to a different plugin object
314
+ if child.delegate_instance
315
+ target = send(child.delegate_instance)
316
+ return target.dispatch_from_registry(Array(child.delegates_to), {})
317
+ end
318
+ return dispatch_from_registry(Array(child.delegates_to), ctx) if child.delegates_to
319
+
320
+ # entity_execute shorthand
321
+ return run_entity_execute(child, ctx) if child.entity_execute
322
+
323
+ # Both intermediate and leaf: instance_arg + setup are handled by Phase A of the next call
324
+ dispatch_from_registry(current_path + [command], ctx)
325
+ end
326
+
327
+ # Resolve the action for a leaf CommandSpec.
328
+ # Returns spec.action (Symbol or Proc) if explicitly set; otherwise derives a Symbol
329
+ # from the full path as :action_<path_segment_1>_<path_segment_2>_...
330
+ # (e.g. [:access_key, :list] -> :action_access_key_list).
331
+ # @param spec [CommandSpec]
332
+ # @return [Symbol, Proc]
333
+ def action_for(spec)
334
+ spec.action || spec.action_method_name
335
+ end
336
+
337
+ # Invoke an action (Symbol method or Proc block) with the given positional
338
+ # arguments and keyword context.
339
+ # Procs are executed via instance_exec so they share the plugin's `self`.
340
+ # @param action [Symbol, Proc]
341
+ # @param args [Array] positional arguments
342
+ # @param ctx [Hash] keyword context
343
+ # @return [Object]
344
+ def invoke_action(action, args, ctx)
345
+ if action.is_a?(Proc)
346
+ instance_exec(*args, **ctx, &action)
347
+ else
348
+ send(action, *args, **ctx)
349
+ end
350
+ end
351
+
352
+ # Execute a leaf CommandSpec: resolve arguments (or skip for transfer_paths) and call action.
353
+ # instance_arg (if any) is resolved here as an ArgumentSpec(type: :identifier) and merged
354
+ # into ctx, exactly like any other keyword argument received by the action.
355
+ # @param spec [CommandSpec] a leaf node (no children)
356
+ # @param ctx [Hash] accumulated context
357
+ # @return [Object]
358
+ def execute_leaf(spec, ctx)
359
+ a = action_for(spec)
360
+ # Always resolve declared arguments (even when transfer_paths is set — those arguments
361
+ # are consumed first; ts_source_paths then reads whatever remains in the queue).
362
+ (spec.arguments || []).each do |arg_spec|
363
+ if arg_spec.type.eql?(:identifier)
364
+ unless ctx.key?(arg_spec.name)
365
+ lookup_method = arg_spec.lookup
366
+ block = lookup_method ? ->(f, v){send(lookup_method, f, v, **ctx)} : nil
367
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec, &block))
368
+ end
369
+ else
370
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
371
+ end
372
+ end
373
+ invoke_action(a, [], ctx)
374
+ end
375
+
376
+ # Expand an entity_execute shorthand from a CommandSpec.
377
+ # Calls Base#entity_execute with the parameters from spec.entity_execute merged
378
+ # with the context hash (context entries are low-priority: spec params win).
379
+ # @param spec [CommandSpec] the command spec carrying entity_execute: Hash
380
+ # @param ctx [Hash] accumulated context (e.g. api:, lookup block)
381
+ # @return [Object]
382
+ def run_entity_execute(spec, ctx)
383
+ ee_params = spec.entity_execute.dup
384
+ # Resolve api: Symbol at runtime: :@ivar -> instance_variable_get, :method -> send
385
+ if (api_ref = ee_params[:api]).is_a?(Symbol)
386
+ ee_params[:api] = if api_ref.to_s.start_with?('@')
387
+ instance_variable_get(api_ref)
388
+ else
389
+ send(api_ref)
390
+ end
391
+ end
392
+ # Merge context into params (spec wins on key collision)
393
+ merged = ctx.merge(ee_params)
394
+ # Extract lookup_block before passing to entity_execute (it is not a kwarg of entity_execute)
395
+ block = merged.delete(:lookup_block)
396
+ if block
397
+ entity_execute(**merged, &block)
398
+ else
399
+ entity_execute(**merged)
400
+ end
401
+ end
402
+
403
+ # Resolve a single positional argument from the CLI argument stream.
404
+ # When arg_spec.bulk is true, always returns an Array (normalized to [value] when non-bulk).
405
+ # For type: :identifier, an optional block provides the percent-selector lookup.
406
+ # @param arg_spec [ArgumentSpec]
407
+ # @yieldparam field [String] field name from a percent-selector (%field:value)
408
+ # @yieldparam value [String] value from a percent-selector
409
+ # @yieldreturn [String] resolved identifier
410
+ # @return [Object] the resolved value, or Array when arg_spec.bulk is true
411
+ def resolve_argument(arg_spec, &block)
412
+ if arg_spec.bulk
413
+ is_bulk = options.get_option(:bulk)
414
+ if arg_spec.type.eql?(:identifier)
415
+ val = options.instance_identifier(description: arg_spec.name.to_s, &block)
416
+ else
417
+ val = options.get_next_argument(
418
+ arg_spec.name.to_s,
419
+ mandatory: arg_spec.mandatory,
420
+ validation: is_bulk ? Array : arg_spec.type,
421
+ default: arg_spec.default,
422
+ schema: arg_spec.schema
423
+ )
424
+ if is_bulk
425
+ Aspera.assert_array_all(val, arg_spec.type, type: Cli::BadArgument){'type'} unless arg_spec.type.nil?
426
+ end
427
+ end
428
+ # Always return an Array when bulk: true
429
+ is_bulk ? val : [val]
430
+ else
431
+ case arg_spec.type
432
+ when :identifier
433
+ options.instance_identifier(description: arg_spec.name.to_s, &block)
434
+ else
435
+ # Class or Array<Class> -> pass as validation type
436
+ # When interactive: true, set ask_missing_mandatory so that get_interactive is triggered
437
+ # when no CLI arguments are provided (mandatory is forced to true for the same reason:
438
+ # a non-nil default would short-circuit get_interactive before it is ever called).
439
+ options.ask_missing_mandatory = true if arg_spec.interactive
440
+ options.get_next_argument(
441
+ arg_spec.name.to_s,
442
+ mandatory: arg_spec.interactive ? true : arg_spec.mandatory,
443
+ multiple: arg_spec.multiple || false,
444
+ validation: arg_spec.type,
445
+ accept_list: arg_spec.allowed,
446
+ default: arg_spec.interactive ? nil : arg_spec.default,
447
+ schema: arg_spec.schema
448
+ )
449
+ end
450
+ end
451
+ end
452
+
453
+ # Build a nested Hash tree of the registered command tree for help display.
454
+ # Conditional commands are included with a '[condition_name]' annotation.
455
+ # @param path [Array<Symbol>] starting path ([] for the full tree)
456
+ # @return [Hash] { command_id => { description:, condition:, children: } }
457
+ def generate_help(path = [])
458
+ self.class.command_registry.children_of(path).transform_values do |child_spec|
459
+ annotation = child_spec.condition ? " [#{child_spec.condition}]" : ''
460
+ {
461
+ description: "#{child_spec.description}#{annotation}",
462
+ condition: child_spec.condition,
463
+ children: generate_help(child_spec.full_path)
464
+ }
112
465
  end
113
466
  end
114
467
 
@@ -116,13 +469,24 @@ module Aspera
116
469
  # @param api [Aspera::Rest] API to use
117
470
  # @param entity [String] Sub path in URL to resource relative to base url
118
471
  # @param command [Symbol, nil] Command to execute: :create, :show, :list, :modify, :delete
472
+ # When nil, reads the next command token from the CLI (fallback, deprecated).
119
473
  # @param display_fields [Array, nil] Fields to display by default
120
474
  # @param items_key [String, nil] Result is in a sub key of the JSON
121
475
  # @param delete_style [String, nil] If set, the delete operation by array in payload
122
476
  # @param id_as_arg [Boolean, String] If set, the id is provided as url argument ?<id_as_arg>=<id>
123
477
  # @param is_singleton [Boolean] If `true`, entity is the full path to the resource
124
478
  # @param list_query [Hash, nil] Query parameters for list operation
125
- # @yieldparam value [String] Value to search for identifier
479
+ # @param schema [String, nil] JSON schema name for create/modify validation
480
+ # @param query_component [String, nil] Registry component key (e.g. 'faspex', 'aoc') used to derive
481
+ # the query parameters schema path from the entity name. When set, `--query=help` on list/delete
482
+ # commands displays the available filter parameters from the OpenAPI spec.
483
+ # @param input_data [Array, Hash, nil] Pre-resolved data for :create (Array) or :modify (Hash).
484
+ # When nil, data is read from the CLI (fallback, deprecated).
485
+ # @param res_id [String, Array, nil] Pre-resolved resource identifier(s) for instance commands.
486
+ # When nil, identifier is read from the CLI (fallback, deprecated).
487
+ # @param is_bulk [Boolean] Whether operation runs in bulk mode (used when data: is provided)
488
+ # @param bfail [Boolean] When false, errors are captured as status strings instead of re-raised
489
+ # @yieldparam value [String] Value to search for identifier (lookup block)
126
490
  # @yieldreturn [String] The identifier
127
491
  # @return [Hash] Result suitable for CLI result
128
492
  def entity_execute(
@@ -136,25 +500,43 @@ module Aspera
136
500
  is_singleton: false,
137
501
  list_query: nil,
138
502
  schema: nil,
503
+ query_component: nil,
504
+ input_data: nil,
505
+ res_id: nil,
506
+ is_bulk: false,
507
+ bfail: true,
139
508
  &block
140
509
  )
510
+ # Fallback: read command from CLI when not provided (entity_command shorthand without command:)
141
511
  command = options.get_next_command(Operations::ALL) if command.nil?
512
+ # Derive query schema path from component + entity when component is given
513
+ qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
514
+
142
515
  if is_singleton
143
516
  one_res_path = entity
144
517
  elsif Operations::INSTANCE.include?(command)
145
- one_res_id = options.instance_identifier(&block)
518
+ # Use pre-resolved identifier if provided; otherwise fall back to CLI read (entity_command shorthand)
519
+ one_res_id = res_id || options.instance_identifier(&block)
146
520
  one_res_path = "#{entity}/#{one_res_id}"
147
521
  one_res_path = "#{entity}?#{id_as_arg}=#{one_res_id}" if id_as_arg
148
522
  end
149
523
 
150
524
  case command
151
525
  when :create
152
- raise BadArgument, 'cannot create singleton' if is_singleton
153
- return do_bulk_operation(command: command, descr: 'data', fields: display_fields, schema: schema) do |params|
526
+ Aspera.assert(!is_singleton, type: BadArgument){'cannot create singleton'}
527
+ unless input_data
528
+ # No pre-resolved data: read from CLI
529
+ cli_is_bulk = options.get_option(:bulk)
530
+ raw = options.get_next_argument('data', validation: cli_is_bulk ? Array : Hash, schema: schema)
531
+ input_data = cli_is_bulk ? raw : [raw]
532
+ is_bulk = cli_is_bulk
533
+ bfail = options.get_option(:bfail)
534
+ end
535
+ return Result.bulk(input_data, is_bulk: is_bulk, command: command, fields: display_fields, bfail: bfail) do |params|
154
536
  api.create(entity, params)
155
537
  end
156
538
  when :delete
157
- raise BadArgument, 'cannot delete singleton' if is_singleton
539
+ Aspera.assert(!is_singleton, type: BadArgument){'cannot delete singleton'}
158
540
  if !delete_style.nil?
159
541
  one_res_id = [one_res_id] unless one_res_id.is_a?(Array)
160
542
  Aspera.assert_type(one_res_id, Array, type: Cli::BadArgument)
@@ -166,14 +548,15 @@ module Aspera
166
548
  )
167
549
  return Result::Status.new('deleted')
168
550
  end
169
- return do_bulk_operation(command: command, values: one_res_id) do |one_id|
170
- api.delete("#{entity}/#{one_id}", query_read_delete)
551
+ items = one_res_id.is_a?(Array) ? one_res_id : [one_res_id]
552
+ return Result.bulk(items, is_bulk: is_bulk, command: command, bfail: bfail) do |one_id|
553
+ api.delete("#{entity}/#{one_id}", query_read_delete(schema: qs_path))
171
554
  {'id' => one_id}
172
555
  end
173
556
  when :show
174
557
  return Result::SingleObject.new(api.read(one_res_path), fields: display_fields)
175
558
  when :list
176
- data, http = api.read(entity, query_read_delete, ret: :both)
559
+ data, http = api.read(entity, query_read_delete(default: list_query, schema: qs_path), ret: :both)
177
560
  return Result::Empty.new if http.code == '204'
178
561
  # TODO: not generic : which application is this for ?
179
562
  if http['Content-Type'].start_with?('application/vnd.api+json')
@@ -191,7 +574,8 @@ module Aspera
191
574
  Aspera.error_unexpected_value(data.class.name){'list type'}
192
575
  end
193
576
  when :modify
194
- parameters = value_create_modify(command: command, schema: schema)
577
+ # Use pre-resolved data if provided; otherwise read from CLI
578
+ parameters = input_data || options.get_next_argument('data', validation: Hash, schema: schema)
195
579
  api.update(one_res_path, parameters)
196
580
  return Result::Status.new('modified')
197
581
  else
@@ -201,10 +585,11 @@ module Aspera
201
585
 
202
586
  # Query parameters in URL suitable for REST: list/`GET` and delete/`DELETE`
203
587
  # @param default [Hash, nil] Default query parameters
588
+ # @param schema [String, nil] Contextual schema path for --query help display
204
589
  # @return [Hash, nil] Query parameters
205
- def query_read_delete(default: nil)
590
+ def query_read_delete(default: nil, schema: nil)
206
591
  # Dup default, as it could be frozen
207
- query = options.get_option(:query) || default.dup
592
+ query = options.get_option(:query, schema: schema) || default&.dup
208
593
  Log.log.debug{"query_read_delete=#{query}".bg_red}
209
594
  begin
210
595
  # Check it is suitable
@@ -214,34 +599,6 @@ module Aspera
214
599
  end
215
600
  return query
216
601
  end
217
-
218
- # Retrieves an extended value from command line.
219
- # Used for creation or modification of entities.
220
- # @param command [Symbol] Command name for error message
221
- # @param description [String, nil] Description of the value
222
- # @param type [Class, nil] Expected type of value
223
- # @param bulk [Boolean] If `true`, value must be an Array of `type`
224
- # @param default [Object, nil] Default value if not provided
225
- # @param schema [Hash, nil] JSON schema for validation
226
- # @return [Hash, Array<Hash>] The value(s) to create object(s)
227
- def value_create_modify(command:, description: nil, type: Hash, bulk: false, default: nil, schema: nil)
228
- value = options.get_next_argument(
229
- "parameters for #{command}#{" (#{description})" unless description.nil?}",
230
- mandatory: default.nil?,
231
- validation: bulk ? Array : type,
232
- schema: schema
233
- )
234
- value = default if value.nil?
235
- unless type.nil?
236
- Aspera.assert_type(type, Class){'type'}
237
- if bulk
238
- Aspera.assert_array_all(value, type, type: Cli::BadArgument){'type'}
239
- else
240
- Aspera.assert_type(value, type, type: Cli::BadArgument){'type'}
241
- end
242
- end
243
- return value
244
- end
245
602
  end
246
603
  end
247
604
  end