aspera-cli 4.27.0 → 4.27.2

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 (142) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +46 -1
  4. data/CONTRIBUTING.md +1 -7
  5. data/TODO.md +50 -0
  6. data/docs/README.md +709 -489
  7. data/lib/aspera/agent/base.rb +10 -2
  8. data/lib/aspera/agent/connect.rb +7 -7
  9. data/lib/aspera/agent/desktop.rb +3 -3
  10. data/lib/aspera/agent/direct.rb +48 -19
  11. data/lib/aspera/agent/factory.rb +3 -3
  12. data/lib/aspera/agent/httpgw.rb +55 -14
  13. data/lib/aspera/agent/node.rb +2 -2
  14. data/lib/aspera/agent/transferd.rb +12 -12
  15. data/lib/aspera/api/aoc.rb +28 -28
  16. data/lib/aspera/api/cos_node.rb +10 -9
  17. data/lib/aspera/api/faspex.rb +2 -2
  18. data/lib/aspera/api/httpgw.rb +20 -20
  19. data/lib/aspera/api/node.rb +31 -53
  20. data/lib/aspera/ascmd.rb +13 -13
  21. data/lib/aspera/ascp/installation.rb +43 -36
  22. data/lib/aspera/ascp/management.rb +6 -6
  23. data/lib/aspera/assert.rb +12 -13
  24. data/lib/aspera/cli/ascp_actions.rb +7 -7
  25. data/lib/aspera/cli/async_transfer_store.rb +49 -5
  26. data/lib/aspera/cli/bootstrapper.rb +9 -9
  27. data/lib/aspera/cli/command_registry.rb +1 -1
  28. data/lib/aspera/cli/command_spec.rb +6 -3
  29. data/lib/aspera/cli/context.rb +3 -3
  30. data/lib/aspera/cli/extended_value.rb +26 -26
  31. data/lib/aspera/cli/formatter.rb +76 -54
  32. data/lib/aspera/cli/gem_checker.rb +1 -1
  33. data/lib/aspera/cli/hints.rb +2 -2
  34. data/lib/aspera/cli/http.rb +22 -29
  35. data/lib/aspera/cli/mailer.rb +7 -7
  36. data/lib/aspera/cli/mcp_tool.rb +31 -4
  37. data/lib/aspera/cli/option_declarator.rb +77 -0
  38. data/lib/aspera/cli/options.schema.yaml +273 -3
  39. data/lib/aspera/cli/parser.rb +539 -280
  40. data/lib/aspera/cli/plugins/alee.rb +1 -1
  41. data/lib/aspera/cli/plugins/aoc.rb +303 -314
  42. data/lib/aspera/cli/plugins/ats.rb +25 -21
  43. data/lib/aspera/cli/plugins/base.rb +357 -186
  44. data/lib/aspera/cli/plugins/basic_auth.rb +0 -12
  45. data/lib/aspera/cli/plugins/config.rb +76 -73
  46. data/lib/aspera/cli/plugins/console.rb +8 -8
  47. data/lib/aspera/cli/plugins/cos.rb +4 -3
  48. data/lib/aspera/cli/plugins/factory.rb +2 -2
  49. data/lib/aspera/cli/plugins/faspex5.rb +259 -248
  50. data/lib/aspera/cli/plugins/faspio.rb +7 -2
  51. data/lib/aspera/cli/plugins/httpgw.rb +1 -1
  52. data/lib/aspera/cli/plugins/mcp.rb +11 -11
  53. data/lib/aspera/cli/plugins/node.rb +227 -283
  54. data/lib/aspera/cli/plugins/orchestrator.rb +23 -23
  55. data/lib/aspera/cli/plugins/preview.rb +61 -64
  56. data/lib/aspera/cli/plugins/server.rb +36 -31
  57. data/lib/aspera/cli/plugins/shares.rb +174 -135
  58. data/lib/aspera/cli/preset_actions.rb +39 -19
  59. data/lib/aspera/cli/preset_manager.rb +74 -32
  60. data/lib/aspera/cli/result.rb +18 -16
  61. data/lib/aspera/cli/runner.rb +75 -39
  62. data/lib/aspera/cli/secret_finder.rb +1 -1
  63. data/lib/aspera/cli/sync_actions.rb +36 -35
  64. data/lib/aspera/cli/terminal_formatter.rb +2 -2
  65. data/lib/aspera/cli/transfer_actions.rb +17 -10
  66. data/lib/aspera/cli/transfer_agent.rb +44 -44
  67. data/lib/aspera/cli/transfer_progress.rb +7 -7
  68. data/lib/aspera/cli/vault_manager.rb +34 -12
  69. data/lib/aspera/cli/version.rb +1 -1
  70. data/lib/aspera/cli/wizard.rb +17 -20
  71. data/lib/aspera/colors.rb +3 -3
  72. data/lib/aspera/command_line_builder.rb +17 -16
  73. data/lib/aspera/command_line_converter.rb +1 -1
  74. data/lib/aspera/coverage.rb +1 -2
  75. data/lib/aspera/data_repository.rb +1 -1
  76. data/lib/aspera/dot_container.rb +8 -8
  77. data/lib/aspera/environment.rb +13 -13
  78. data/lib/aspera/faspex_gw.rb +4 -4
  79. data/lib/aspera/faspex_postproc.rb +14 -9
  80. data/lib/aspera/graphql.rb +4 -2
  81. data/lib/aspera/hash_ext.rb +2 -2
  82. data/lib/aspera/json_rpc/client.rb +1 -1
  83. data/lib/aspera/keychain/base.rb +10 -3
  84. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  85. data/lib/aspera/keychain/factory.rb +17 -5
  86. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  87. data/lib/aspera/keychain/macos_security.rb +23 -25
  88. data/lib/aspera/keychain/one_password_api.rb +86 -0
  89. data/lib/aspera/keychain/one_password_base.rb +34 -0
  90. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  91. data/lib/aspera/link_header.rb +2 -2
  92. data/lib/aspera/log.rb +13 -13
  93. data/lib/aspera/markdown.rb +5 -5
  94. data/lib/aspera/nagios.rb +8 -8
  95. data/lib/aspera/node_simulator.rb +33 -27
  96. data/lib/aspera/oauth/base.rb +4 -5
  97. data/lib/aspera/oauth/boot.rb +3 -3
  98. data/lib/aspera/oauth/factory.rb +5 -5
  99. data/lib/aspera/oauth/jwt.rb +5 -7
  100. data/lib/aspera/oauth/web.rb +4 -4
  101. data/lib/aspera/persistency_action_once.rb +4 -4
  102. data/lib/aspera/persistency_folder.rb +8 -8
  103. data/lib/aspera/preview/file_types.rb +4 -4
  104. data/lib/aspera/preview/generator.rb +49 -46
  105. data/lib/aspera/preview/options.rb +3 -2
  106. data/lib/aspera/preview/terminal.rb +5 -5
  107. data/lib/aspera/preview/utils.rb +65 -18
  108. data/lib/aspera/products/connect.rb +2 -2
  109. data/lib/aspera/products/desktop.rb +1 -1
  110. data/lib/aspera/products/other.rb +2 -2
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -10
  113. data/lib/aspera/rest.rb +149 -115
  114. data/lib/aspera/rest_error_analyzer.rb +3 -3
  115. data/lib/aspera/rest_list.rb +10 -9
  116. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  117. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
  119. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  120. data/lib/aspera/schema/documentation.rb +17 -11
  121. data/lib/aspera/schema/reader.rb +78 -4
  122. data/lib/aspera/schema/registry.rb +26 -7
  123. data/lib/aspera/secret_hider.rb +5 -5
  124. data/lib/aspera/ssh.rb +64 -31
  125. data/lib/aspera/ssl.rb +3 -3
  126. data/lib/aspera/sync/database.rb +2 -2
  127. data/lib/aspera/sync/operations.rb +13 -13
  128. data/lib/aspera/temp_file_manager.rb +24 -4
  129. data/lib/aspera/transfer/faux_file.rb +2 -2
  130. data/lib/aspera/transfer/parameters.rb +15 -15
  131. data/lib/aspera/transfer/resumer.rb +9 -9
  132. data/lib/aspera/transfer/spec.rb +1 -1
  133. data/lib/aspera/transfer/spec.schema.yaml +1 -0
  134. data/lib/aspera/uri_reader.rb +52 -15
  135. data/lib/aspera/web_auth.rb +6 -5
  136. data/lib/aspera/web_server_simple.rb +9 -9
  137. data/lib/aspera/yaml.rb +4 -4
  138. data.tar.gz.sig +0 -0
  139. metadata +10 -4
  140. metadata.gz.sig +0 -0
  141. data/lib/aspera/cli/plugins/faspex.rb +0 -576
  142. data/lib/aspera/transfer/uri.rb +0 -56
@@ -5,6 +5,7 @@ require 'aspera/cli/parser'
5
5
  require 'aspera/assert'
6
6
  require 'aspera/cli/result'
7
7
  require 'aspera/cli/command_registry'
8
+ require 'aspera/cli/option_declarator'
8
9
  require 'aspera/schema/registry'
9
10
 
10
11
  module Aspera
@@ -25,6 +26,16 @@ module Aspera
25
26
  class << self
26
27
  # Per-class DSL registry (not inherited: each subclass gets its own instance).
27
28
  # @return [CommandRegistry]
29
+ def used_option_sources
30
+ @used_option_sources ||= []
31
+ end
32
+
33
+ # Include options from another plugin or OptionDeclarator module.
34
+ # @param source [Class, Module]
35
+ def use_options(source)
36
+ used_option_sources << source unless used_option_sources.include?(source)
37
+ end
38
+
28
39
  def command_registry
29
40
  @command_registry ||= CommandRegistry.new
30
41
  end
@@ -38,18 +49,70 @@ module Aspera
38
49
  command_registry.register(CommandSpec.new(id: id, **kwargs))
39
50
  end
40
51
 
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})
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
57
+ end
58
+
59
+ # DSL class method: declare CRUD commands for a REST entity.
60
+ #
61
+ # 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
64
+ # (:show, :modify, :delete) when not a singleton; none for global verbs
65
+ # - action: calls entity_<verb>(api:, entity:, **shared_kwargs, **ctx)
66
+ #
67
+ # api: is resolved at runtime: :@ivar -> instance_variable_get, else -> send.
68
+ # entity: may also be a Symbol — resolved at runtime as a ctx key (e.g. :sf_entity).
69
+ # This covers cases where the entity path is injected by a parent setup: method.
70
+ #
71
+ # @param api [Symbol, String] Runtime API ref (:@ivar or method name) or literal string
72
+ # @param entity [String, Symbol] REST sub-path, or ctx key Symbol resolved at runtime
73
+ # @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)
75
+ # @param lookup [Symbol, nil] Instance method for percent-selector resolution
76
+ # @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)
79
+ operations ||= Operations::ALL
80
+ operations.each do |verb|
81
+ id_arg = ({name: :id, type: :identifier, lookup: lookup} if Operations::INSTANCE.include?(verb) && !kwargs[:is_singleton])
82
+ schema_val =
83
+ if kwargs[:body_component] && entity.is_a?(String)
84
+ case verb
85
+ when :create then Schema::Registry.req_body(kwargs[:body_component], "#{entity}.post")
86
+ when :modify then Schema::Registry.req_body(kwargs[:body_component], "#{entity}/{id}.put")
87
+ end
88
+ end
89
+ args =
90
+ case verb
91
+ when :create
92
+ [{name: :data, type: Hash, bulk: true, schema: schema_val}]
93
+ when :modify
94
+ [id_arg, {name: :data, type: Hash, schema: schema_val}].compact
95
+ when :delete
96
+ id_arg ? [id_arg.merge(bulk: true)] : nil
97
+ else
98
+ id_arg ? [id_arg] : nil
99
+ end
100
+ action_proc = lambda do |**ctx|
101
+ resolved_api =
102
+ if api.is_a?(Symbol)
103
+ api.to_s.start_with?('@') ? instance_variable_get(api) : send(api)
104
+ elsif api.is_a?(Proc)
105
+ instance_exec(&api)
106
+ else
107
+ api
108
+ end
109
+ resolved_entity = entity.is_a?(Symbol) ? ctx.fetch(entity) : entity
110
+ send(:"entity_#{verb}", api: resolved_api, entity: resolved_entity, **kwargs, **ctx)
111
+ end
112
+ cmd_attrs = {description: "#{verb.capitalize} #{name || entity.inspect}", action: action_proc}
113
+ cmd_attrs[:arguments] = args if args
114
+ command(verb, **cmd_attrs)
115
+ end
53
116
  end
54
117
 
55
118
  # DSL class method: define an instance method whose name is derived from a path array.
@@ -62,11 +125,29 @@ module Aspera
62
125
 
63
126
  # DSL class method: scope block that sets a default parent for nested command() calls.
64
127
  # 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
128
+ # If the terminal node of `parent` has not been declared yet, it is auto-declared
129
+ # as an intermediate command with description: "Manage <name>" (or the given description:).
130
+ #
131
+ # `parent` is always resolved relative to the current scope:
132
+ # Array(@current_parent) + Array(parent)
133
+ #
134
+ # @param parent [Symbol, Array<Symbol>] one or more path segments, relative to current scope
135
+ # @param description [String, nil] Description of entity for the auto-declared node
66
136
  # @yieldreturn [void]
67
- def commands_under(parent)
137
+ def commands_under(parent, description: nil)
138
+ # Always relative: append the given segments to the current scope.
139
+ path = Array(@current_parent) + Array(parent)
140
+ unless command_registry[path]
141
+ id = path.last
142
+ desc = description || "Manage #{entity_display_name(id)}"
143
+ parent_path = path[0..-2]
144
+ saved = @current_parent
145
+ @current_parent = parent_path.empty? ? nil : parent_path
146
+ command(id, description: desc)
147
+ @current_parent = saved
148
+ end
68
149
  previous = @current_parent
69
- @current_parent = parent
150
+ @current_parent = path
70
151
  yield
71
152
  ensure
72
153
  @current_parent = previous
@@ -114,6 +195,47 @@ module Aspera
114
195
  )
115
196
  end
116
197
 
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)
203
+ sources = []
204
+ ancestors.each do |klass|
205
+ next unless klass.is_a?(Class) && klass <= Base
206
+ sources << klass if klass.instance_variable_defined?(:@command_registry)
207
+ sources.concat(klass.used_option_sources) if klass.respond_to?(:used_option_sources)
208
+ 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
+ )
234
+ end
235
+ end
236
+ options.parse_options! if parse
237
+ end
238
+
117
239
  # DSL class method: declare a setup method to run once before root dispatch.
118
240
  # The method is called before any command is consumed, and its return value
119
241
  # (a Hash) is merged into the initial ctx. This is useful when conditions
@@ -135,16 +257,38 @@ module Aspera
135
257
  @application_name = name unless name.nil?
136
258
  @application_name || self.name.split('::').last
137
259
  end
260
+
261
+ # Build a filter lambda from a match expression (String glob, Regexp, Proc, or nil).
262
+ # @param match_expression [String, Regexp, Proc, NilClass] as in FILTER_ARGS
263
+ # @return [Proc] lambda(entry) -> Boolean
264
+ def file_matcher(match_expression)
265
+ case match_expression
266
+ when Proc then match_expression
267
+ when Regexp then ->(f) { f['name'].match?(match_expression) }
268
+ when String then ->(f) { File.fnmatch(match_expression, f['name'], File::FNM_DOTMATCH) }
269
+ when NilClass then ->(_) { true }
270
+ else Aspera.error_unexpected_value(match_expression.class.name, type: ParameterError)
271
+ end
272
+ end
138
273
  end
139
274
 
275
+ # Shared positional argument for commands that accept an optional file name filter.
276
+ # Accepted types: String (shell glob matched against entry name), Regexp, or Proc.
277
+ # Used by node files find, and preview scan/events/trevents.
278
+ FILTER_ARGS = [{name: :filter, type: [String, Regexp, Proc], description: 'File name filter: String (glob), Regexp, or Proc', mandatory: false, default: nil}].freeze
279
+
140
280
  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
281
+ option :bulk, description: 'Bulk operation (only some)', allowed: Type::BOOLEAN, default: false
282
+ option :bfail, description: 'Bulk operation error handling', allowed: Type::BOOLEAN, default: true
143
283
 
144
284
  def initialize(context:)
145
- Aspera.assert_type(context, Context){'context'}
146
- Aspera.assert_type(context.man_header, TrueClass, FalseClass){'context.man_header'}
285
+ Aspera.assert_type(context, Context) { 'context' }
286
+ Aspera.assert_type(context.man_header, TrueClass, FalseClass) { 'context.man_header' }
147
287
  @context = context
288
+ # Switch to the plugin-specific options group so that all options declared
289
+ # below (DSL-registered and imperative) appear under the plugin section in
290
+ # --help output, separate from the global options.
291
+ options.group(self.class.name.split('::').last.downcase) if @context.man_header
148
292
  # Auto-declare all options registered via the DSL `option` class method.
149
293
  # Walk the ancestor chain so that options declared on parent plugin classes
150
294
  # (e.g. Oauth, BasicAuth) are also registered for sub-classes (e.g. Aoc).
@@ -154,9 +298,22 @@ module Aspera
154
298
  # handler: shorthand:
155
299
  # Symbol handler: {o: self, m: <symbol>} (Category B - plugin instance methods)
156
300
  # Hash handler: used as-is (Category A - singletons / class constants)
301
+ sources = []
157
302
  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|
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|
160
317
  next if options.option_declared?(spec.name)
161
318
  resolved_handler =
162
319
  case spec.handler
@@ -175,7 +332,6 @@ module Aspera
175
332
  )
176
333
  end
177
334
  end
178
- add_manual_header if @context.man_header
179
335
  end
180
336
 
181
337
  # Global objects
@@ -202,7 +358,8 @@ module Aspera
202
358
  def progress_bar; @context.progress_bar; end
203
359
 
204
360
  def add_manual_header(_has_options = true)
205
- options.rename_current_group(self.class.name.split('::').last.downcase)
361
+ # No-op: the group is set at the start of initialize.
362
+ # Kept for compatibility with Config, which calls add_manual_header(false) from Runner.
206
363
  end
207
364
 
208
365
  # Entry point for all DSL-based plugins.
@@ -251,11 +408,13 @@ module Aspera
251
408
  (spec&.arguments || []).each do |arg_spec|
252
409
  next if ctx.key?(arg_spec.name)
253
410
  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
411
+ lookup_cb = arg_spec.lookup
412
+ res_id = if lookup_cb.nil?
258
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) }
259
418
  end
260
419
  ctx = ctx.merge(arg_spec.name => res_id)
261
420
  else
@@ -289,22 +448,36 @@ module Aspera
289
448
  end
290
449
 
291
450
  # Phase B, child branch: consume the next command argument, resolve the matching
292
- # child spec, handle delegation / entity_execute shorthands, and recurse or execute.
451
+ # child spec, handle delegation, and recurse or execute.
452
+ # --help is intercepted at two points:
453
+ # 1. Before get_next_command when no positional arg is pending: raises HelpRequest
454
+ # immediately so the subcommand list with descriptions is shown rather than a
455
+ # MissingArgument error.
456
+ # 2. After get_next_command when no further args remain: raises HelpRequest scoped
457
+ # to the consumed command (e.g. `aoc files find -h`).
293
458
  # @param current_path [Array<Symbol>]
294
- # @param registry [CommandRegistry]
295
- # @param ctx [Hash]
459
+ # @param registry [CommandRegistry]
460
+ # @param ctx [Hash]
296
461
  # @return [Object]
297
462
  def dispatch_child(current_path, registry, ctx)
298
463
  children = registry.children_of(current_path)
299
- available = children.reject{ |_, c| c.condition && !send(c.condition)}
464
+ available = children.reject { |_, c| c.condition && !send(c.condition) }
300
465
  aliases = children.values.each_with_object({}) do |c, h|
301
- Array(c.aliases).each{ |a| h[a] = c.id} if c.aliases
466
+ Array(c.aliases).each { |a| h[a] = c.id } if c.aliases
467
+ end
468
+
469
+ # Intercept --help before consuming the command token when no arg is pending.
470
+ # This avoids MissingArgument being raised by get_next_command before HelpRequest.
471
+ if options.help_requested && options.command_or_arg_empty?
472
+ @help_path = current_path
473
+ raise Cli::HelpRequest, self
302
474
  end
475
+
303
476
  command = options.get_next_command(available.keys, aliases: aliases.empty? ? nil : aliases)
304
477
  child = available[command]
305
478
 
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.
479
+ # Intercept --help after a command was consumed but no further args remain.
480
+ # (e.g. `aoc files find -h`). When further args remain, keep recursing.
308
481
  if options.help_requested && options.command_or_arg_empty?
309
482
  @help_path = current_path + [command]
310
483
  raise Cli::HelpRequest, self
@@ -317,9 +490,6 @@ module Aspera
317
490
  end
318
491
  return dispatch_from_registry(Array(child.delegates_to), ctx) if child.delegates_to
319
492
 
320
- # entity_execute shorthand
321
- return run_entity_execute(child, ctx) if child.entity_execute
322
-
323
493
  # Both intermediate and leaf: instance_arg + setup are handled by Phase A of the next call
324
494
  dispatch_from_registry(current_path + [command], ctx)
325
495
  end
@@ -349,23 +519,28 @@ module Aspera
349
519
  end
350
520
  end
351
521
 
352
- # Execute a leaf CommandSpec: resolve arguments (or skip for transfer_paths) and call action.
522
+ # 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.
353
525
  # instance_arg (if any) is resolved here as an ArgumentSpec(type: :identifier) and merged
354
526
  # into ctx, exactly like any other keyword argument received by the action.
355
527
  # @param spec [CommandSpec] a leaf node (no children)
356
- # @param ctx [Hash] accumulated context
528
+ # @param ctx [Hash] accumulated context (pre-resolved keys are not re-consumed)
357
529
  # @return [Object]
358
530
  def execute_leaf(spec, ctx)
359
531
  a = action_for(spec)
360
532
  # Always resolve declared arguments (even when transfer_paths is set — those arguments
361
533
  # are consumed first; ts_source_paths then reads whatever remains in the queue).
362
534
  (spec.arguments || []).each do |arg_spec|
535
+ next if ctx.key?(arg_spec.name)
363
536
  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
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))
369
544
  else
370
545
  ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
371
546
  end
@@ -373,33 +548,6 @@ module Aspera
373
548
  invoke_action(a, [], ctx)
374
549
  end
375
550
 
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
551
  # Resolve a single positional argument from the CLI argument stream.
404
552
  # When arg_spec.bulk is true, always returns an Array (normalized to [value] when non-bulk).
405
553
  # For type: :identifier, an optional block provides the percent-selector lookup.
@@ -422,7 +570,7 @@ module Aspera
422
570
  schema: arg_spec.schema
423
571
  )
424
572
  if is_bulk
425
- Aspera.assert_array_all(val, arg_spec.type, type: Cli::BadArgument){'type'} unless arg_spec.type.nil?
573
+ Aspera.assert_array_all(val, arg_spec.type, type: Cli::BadArgument) { 'type' } unless arg_spec.type.nil?
426
574
  end
427
575
  end
428
576
  # Always return an Array when bulk: true
@@ -465,124 +613,147 @@ module Aspera
465
613
  end
466
614
  end
467
615
 
468
- # Operations: Create, Delete, Show, List, Modify
469
- # @param api [Aspera::Rest] API to use
470
- # @param entity [String] Sub path in URL to resource relative to base url
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).
473
- # @param display_fields [Array, nil] Fields to display by default
474
- # @param items_key [String, nil] Result is in a sub key of the JSON
475
- # @param delete_style [String, nil] If set, the delete operation by array in payload
476
- # @param id_as_arg [Boolean, String] If set, the id is provided as url argument ?<id_as_arg>=<id>
477
- # @param is_singleton [Boolean] If `true`, entity is the full path to the resource
478
- # @param list_query [Hash, nil] Query parameters for list operation
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)
490
- # @yieldreturn [String] The identifier
491
- # @return [Hash] Result suitable for CLI result
492
- def entity_execute(
493
- api:,
494
- entity:,
495
- command: nil,
496
- display_fields: nil,
497
- items_key: nil,
498
- delete_style: nil,
499
- id_as_arg: false,
500
- is_singleton: false,
501
- list_query: nil,
502
- schema: nil,
503
- query_component: nil,
504
- input_data: nil,
505
- res_id: nil,
506
- is_bulk: false,
507
- bfail: true,
508
- &block
509
- )
510
- # Fallback: read command from CLI when not provided (entity_command shorthand without command:)
511
- command = options.get_next_command(Operations::ALL) if command.nil?
512
- # Derive query schema path from component + entity when component is given
616
+ # Convenience wrapper: reads :bulk and :bfail from options, normalises +items+
617
+ # to an Array, then delegates to Result.bulk.
618
+ # Use this in action methods instead of the three-line boilerplate:
619
+ # is_bulk = options.get_option(:bulk)
620
+ # items = x.is_a?(Array) ? x : [x]
621
+ # Result.bulk(items, is_bulk: is_bulk, ...)
622
+ # @param items [Object, Array] Single item or Array; wrapped in Array when needed
623
+ # @param command [Symbol] Operation name (:create, :delete, ...)
624
+ # @param id_result [String] Key used as item identifier in the result row
625
+ # @param fields [Object] Fields hint passed to Result constructor (non-bulk only)
626
+ # @yieldparam item [Object] Each item in +items+
627
+ # @return [Result::ObjectList, Result::SingleObject]
628
+ def bulk_result(items, command:, id_result: 'id', fields: :default, &block)
629
+ items = items.is_a?(Array) ? items : [items]
630
+ Result.bulk(
631
+ items,
632
+ is_bulk: options.get_option(:bulk),
633
+ command: command,
634
+ id_result: id_result,
635
+ fields: fields,
636
+ bfail: options.get_option(:bfail),
637
+ &block
638
+ )
639
+ end
640
+
641
+ # --- Per-verb entity action methods ---
642
+ # Each method handles exactly one CRUD verb.
643
+ # The resource id (when needed) is received as `id:` from ctx — it must be
644
+ # resolved upstream via an ArgumentSpec(type: :identifier) on the command,
645
+ # NOT read from the CLI queue inside the method.
646
+
647
+ # List all instances of an entity.
648
+ # @param api [Aspera::Rest] REST API object
649
+ # @param entity [String] API sub-path
650
+ # @param display_fields [Array, nil] Fields to display
651
+ # @param items_key [String, nil] Sub-key in response containing the array
652
+ # @param list_query [Hash, nil] Default query parameters
653
+ # @param query_component [String, nil] Registry key for --query=help schema
654
+ def entity_list(api:, entity:, display_fields: nil, items_key: nil, list_query: nil, query_component: nil, **)
513
655
  qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
656
+ data, http = api.read(entity, query_read_delete(default: list_query, schema: qs_path), ret: :both)
657
+ 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')
661
+ data = data[entity]
662
+ end
663
+ data = data[items_key] if items_key
664
+ case data
665
+ when Hash then Result::SingleObject.new(data, fields: display_fields)
666
+ when Array
667
+ return Result::ObjectList.new(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
668
+ Result::ValueList.new(data)
669
+ else Aspera.error_unexpected_value(data.class.name) { 'list type' }
670
+ end
671
+ end
514
672
 
515
- if is_singleton
516
- one_res_path = entity
517
- elsif Operations::INSTANCE.include?(command)
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)
520
- one_res_path = "#{entity}/#{one_res_id}"
521
- one_res_path = "#{entity}?#{id_as_arg}=#{one_res_id}" if id_as_arg
522
- end
523
-
524
- case command
525
- when :create
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|
536
- api.create(entity, params)
537
- end
538
- when :delete
539
- Aspera.assert(!is_singleton, type: BadArgument){'cannot delete singleton'}
540
- if !delete_style.nil?
541
- one_res_id = [one_res_id] unless one_res_id.is_a?(Array)
542
- Aspera.assert_type(one_res_id, Array, type: Cli::BadArgument)
543
- api.delete(
544
- entity,
545
- nil,
546
- content_type: Mime::JSON,
547
- body: {delete_style => one_res_id}
548
- )
549
- return Result::Status.new('deleted')
550
- end
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))
554
- {'id' => one_id}
555
- end
556
- when :show
557
- return Result::SingleObject.new(api.read(one_res_path), fields: display_fields)
558
- when :list
559
- data, http = api.read(entity, query_read_delete(default: list_query, schema: qs_path), ret: :both)
560
- return Result::Empty.new if http.code == '204'
561
- # TODO: not generic : which application is this for ?
562
- if http['Content-Type'].start_with?('application/vnd.api+json')
563
- Log.log.debug('is vnd.api')
564
- data = data[entity]
565
- end
566
- data = data[items_key] if items_key
567
- case data
568
- when Hash
569
- return Result::SingleObject.new(data, fields: display_fields)
570
- when Array
571
- return Result::ObjectList.new(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
572
- return Result::ValueList.new(data)
573
- else
574
- Aspera.error_unexpected_value(data.class.name){'list type'}
575
- end
576
- when :modify
577
- # Use pre-resolved data if provided; otherwise read from CLI
578
- parameters = input_data || options.get_next_argument('data', validation: Hash, schema: schema)
579
- api.update(one_res_path, parameters)
580
- return Result::Status.new('modified')
581
- else
582
- Aspera.error_unexpected_value(command){'command'}
673
+ # Show one instance of an entity.
674
+ # @param api [Aspera::Rest] REST API object
675
+ # @param entity [String] API sub-path
676
+ # @param id [String, nil] Resource identifier; nil when is_singleton: true
677
+ # @param display_fields [Array, nil] Fields to display
678
+ # @param is_singleton [Boolean] When true, entity is the full path (no id appended)
679
+ # @param id_as_arg [Boolean, String] When set, id is appended as ?<id_as_arg>=<id>
680
+ def entity_show(api:, entity:, id: nil, display_fields: nil, is_singleton: false, id_as_arg: false, **)
681
+ path = entity_res_path(entity, id, is_singleton: is_singleton, id_as_arg: id_as_arg)
682
+ Result::SingleObject.new(api.read(path), fields: display_fields)
683
+ end
684
+
685
+ # 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|
701
+ api.create(entity, params)
583
702
  end
584
703
  end
585
704
 
705
+ # Modify an existing instance of an entity.
706
+ # @param api [Aspera::Rest] REST API object
707
+ # @param entity [String] API sub-path
708
+ # @param id [String, nil] Resource identifier; nil when is_singleton: true
709
+ # @param is_singleton [Boolean] When true, entity is the full path (no id appended)
710
+ # @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
715
+ 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)
718
+ Result::Status.new('modified')
719
+ end
720
+
721
+ # Delete one or more instances of an entity (supports bulk).
722
+ # @param api [Aspera::Rest] REST API object
723
+ # @param entity [String] API sub-path
724
+ # @param id [String, Array, nil] Resource identifier(s)
725
+ # @param id_as_arg [Boolean, String] When set, id is appended as ?<id_as_arg>=<id>
726
+ # @param delete_style [String, nil] When set, deletes by sending id array in payload
727
+ # @param query_component [String, nil] Registry key for --query=help schema
728
+ def entity_delete(api:, entity:, id: nil, id_as_arg: false, delete_style: nil, query_component: nil, **)
729
+ qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
730
+ if !delete_style.nil?
731
+ ids = id.is_a?(Array) ? id : [id]
732
+ Aspera.assert_type(ids, Array, type: Cli::BadArgument)
733
+ api.delete(entity, nil, content_type: Mime::JSON, body: {delete_style => ids})
734
+ return Result::Status.new('deleted')
735
+ end
736
+ bulk_result(id, command: :delete) do |one_id|
737
+ api.delete(
738
+ id_as_arg ? "#{entity}?#{id_as_arg}=#{one_id}" : "#{entity}/#{one_id}",
739
+ query_read_delete(schema: qs_path)
740
+ )
741
+ {'id' => one_id}
742
+ end
743
+ end
744
+
745
+ # Build the resource path for an instance operation.
746
+ # @param entity [String] API sub-path
747
+ # @param id [String, nil] Resource identifier
748
+ # @param is_singleton [Boolean] When true, entity IS the full path
749
+ # @param id_as_arg [Boolean, String] When set, id appended as ?<id_as_arg>=<id>
750
+ # @return [String]
751
+ def entity_res_path(entity, id, is_singleton: false, id_as_arg: false)
752
+ return entity if is_singleton
753
+ return "#{entity}?#{id_as_arg}=#{id}" if id_as_arg
754
+ "#{entity}/#{id}"
755
+ end
756
+
586
757
  # Query parameters in URL suitable for REST: list/`GET` and delete/`DELETE`
587
758
  # @param default [Hash, nil] Default query parameters
588
759
  # @param schema [String, nil] Contextual schema path for --query help display
@@ -590,7 +761,7 @@ module Aspera
590
761
  def query_read_delete(default: nil, schema: nil)
591
762
  # Dup default, as it could be frozen
592
763
  query = options.get_option(:query, schema: schema) || default&.dup
593
- Log.log.debug{"query_read_delete=#{query}".bg_red}
764
+ Log.dump(:query_read_delete, query)
594
765
  begin
595
766
  # Check it is suitable
596
767
  URI.encode_www_form(query) unless query.nil?