aspera-cli 4.26.2 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -1,8 +1,12 @@
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/cli/option_declarator'
9
+ require 'aspera/schema/registry'
6
10
 
7
11
  module Aspera
8
12
  module Cli
@@ -20,25 +24,323 @@ module Aspera
20
24
  ALL = (GLOBAL + INSTANCE).freeze
21
25
  end
22
26
  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)
27
+ # Per-class DSL registry (not inherited: each subclass gets its own instance).
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
+
39
+ def command_registry
40
+ @command_registry ||= CommandRegistry.new
41
+ end
42
+
43
+ # DSL class method: register a command in this plugin's registry.
44
+ # Inherits parent from the enclosing commands_under block when parent: is omitted.
45
+ # @param id [Symbol]
46
+ # @param kwargs [Hash] forwarded to CommandSpec
47
+ def command(id, **kwargs)
48
+ kwargs[:parent] = @current_parent if kwargs[:parent].nil? && @current_parent
49
+ command_registry.register(CommandSpec.new(id: id, **kwargs))
50
+ end
51
+
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
116
+ end
117
+
118
+ # DSL class method: define an instance method whose name is derived from a path array.
119
+ # Equivalent to: define_method(CommandSpec.action_method(path), &block)
120
+ # @param path [Array<Symbol>] command path segments, e.g. [:admin, :user, :list]
121
+ # @yieldparam [Hash] keyword context forwarded from dispatch
122
+ def define_action_method(path, &block)
123
+ define_method(CommandSpec.action_method(path), &block)
124
+ end
125
+
126
+ # DSL class method: scope block that sets a default parent for nested command() calls.
127
+ # Fully re-entrant: blocks may be nested for multi-level parent paths.
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
136
+ # @yieldreturn [void]
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
149
+ previous = @current_parent
150
+ @current_parent = path
151
+ yield
152
+ ensure
153
+ @current_parent = previous
154
+ end
155
+
156
+ # DSL class method: declare an option in this plugin's registry.
157
+ # Metadata is stored as an OptionSpec at class-load time; the actual
158
+ # options.declare call happens in Base#initialize once the instance exists.
159
+ #
160
+ # Raises ArgumentError at class-load time if the same option name is already
161
+ # 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)
178
+ ancestor_owner = ancestors.drop(1).find do |klass|
179
+ klass.is_a?(Class) && klass <= Base &&
180
+ klass.instance_variable_defined?(:@command_registry) &&
181
+ klass.command_registry.option_specs.key?(name)
182
+ 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
+ )
196
+ end
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
+
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
+ # DSL class method: declare the human-readable application name shown in wizards.
252
+ # When called with an argument, sets the name. When called with no argument, returns it.
253
+ # Falls back to the last component of the class name if never set.
254
+ # @param name [String, nil]
255
+ # @return [String]
256
+ def application_name(name = nil)
257
+ @application_name = name unless name.nil?
258
+ @application_name || self.name.split('::').last
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
27
272
  end
28
273
  end
29
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
+
280
+ option :query, description: 'Additional filter for for some commands (list/delete)', allowed: [Hash, Array, NilClass]
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
283
+
30
284
  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}"}
285
+ Aspera.assert_type(context, Context) { 'context' }
286
+ Aspera.assert_type(context.man_header, TrueClass, FalseClass) { 'context.man_header' }
34
287
  @context = context
35
- add_manual_header if @context.man_header
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
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
36
335
  end
37
336
 
38
337
  # Global objects
39
338
  attr_reader :context
339
+ # Path reached in the command tree at the moment --help was intercepted.
340
+ # Nil until set by dispatch_from_registry.
341
+ attr_reader :help_path
40
342
 
41
- # @return [Aspera::Cli::Manager]
343
+ # @return [Aspera::Cli::Parser]
42
344
  def options; @context.options; end
43
345
  # @return [Aspera::Cli::TransferAgent]
44
346
  def transfer; @context.transfer; end
@@ -52,160 +354,414 @@ module Aspera
52
354
  def presets; @context.presets; end
53
355
  # @return [Aspera::Cli::Http]
54
356
  def http_config; @context.http_config; end
357
+ # @return [Aspera::Cli::TransferProgress, nil]
358
+ def progress_bar; @context.progress_bar; end
359
+
360
+ def add_manual_header(_has_options = true)
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.
363
+ end
364
+
365
+ # Entry point for all DSL-based plugins.
366
+ def execute_action
367
+ @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)
382
+ end
55
383
 
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
384
+ # Two-phase dispatcher: run setup on the current node (Phase A), then either
385
+ # execute a leaf directly or consume the next argument and recurse (Phase B).
386
+ # @param current_path [Array<Symbol>] path of the node currently being dispatched
387
+ # @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
+ # @return [Object] result suitable for CLI output
390
+ def dispatch_from_registry(current_path, ctx = {}, skip_setup: false)
391
+ registry = self.class.command_registry
392
+ spec = registry[current_path]
393
+ is_leaf = spec && registry.children_of(current_path).empty?
394
+
395
+ if options.help_requested || skip_setup
396
+ # help_requested on an intermediate node: drain positional args without validation
397
+ # 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|
400
+ next if ctx.key?(arg_spec.name)
401
+ options.get_next_argument(arg_spec.name.to_s, mandatory: false)
402
+ end
103
403
  end
104
- result_list.push(result)
404
+ else
405
+ # Phase A - for intermediate nodes only: resolve all ArgumentSpec declared on this node
406
+ # 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
425
+ ctx = ctx.merge(send(spec.setup, **ctx)) if spec&.setup
426
+ end
427
+
428
+ # Phase B - leaf fast-path or child dispatch
429
+ if is_leaf
430
+ dispatch_leaf(current_path, spec, ctx)
431
+ else
432
+ dispatch_child(current_path, registry, ctx)
433
+ end
434
+ end
435
+
436
+ # Phase B, leaf branch: execute a spec that is already a leaf (no children).
437
+ # Intercepts --help before calling execute_leaf.
438
+ # @param current_path [Array<Symbol>]
439
+ # @param spec [CommandSpec]
440
+ # @param ctx [Hash]
441
+ # @return [Object]
442
+ def dispatch_leaf(current_path, spec, ctx)
443
+ if options.help_requested
444
+ @help_path = current_path
445
+ raise Cli::HelpRequest, self
446
+ end
447
+ execute_leaf(spec, ctx)
448
+ end
449
+
450
+ # Phase B, child branch: consume the next command argument, resolve the matching
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`).
458
+ # @param current_path [Array<Symbol>]
459
+ # @param registry [CommandRegistry]
460
+ # @param ctx [Hash]
461
+ # @return [Object]
462
+ def dispatch_child(current_path, registry, ctx)
463
+ children = registry.children_of(current_path)
464
+ available = children.reject { |_, c| c.condition && !send(c.condition) }
465
+ aliases = children.values.each_with_object({}) do |c, h|
466
+ Array(c.aliases).each { |a| h[a] = c.id } if c.aliases
105
467
  end
106
- display_fields = [id_result, 'status']
107
- if is_bulk
108
- return Result::ObjectList.new(result_list, fields: display_fields)
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
474
+ end
475
+
476
+ command = options.get_next_command(available.keys, aliases: aliases.empty? ? nil : aliases)
477
+ child = available[command]
478
+
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.
481
+ if options.help_requested && options.command_or_arg_empty?
482
+ @help_path = current_path + [command]
483
+ raise Cli::HelpRequest, self
484
+ end
485
+
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
492
+
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)
495
+ end
496
+
497
+ # Resolve the action for a leaf CommandSpec.
498
+ # Returns spec.action (Symbol or Proc) if explicitly set; otherwise derives a Symbol
499
+ # from the full path as :action_<path_segment_1>_<path_segment_2>_...
500
+ # (e.g. [:access_key, :list] -> :action_access_key_list).
501
+ # @param spec [CommandSpec]
502
+ # @return [Symbol, Proc]
503
+ def action_for(spec)
504
+ spec.action || spec.action_method_name
505
+ end
506
+
507
+ # Invoke an action (Symbol method or Proc block) with the given positional
508
+ # arguments and keyword context.
509
+ # Procs are executed via instance_exec so they share the plugin's `self`.
510
+ # @param action [Symbol, Proc]
511
+ # @param args [Array] positional arguments
512
+ # @param ctx [Hash] keyword context
513
+ # @return [Object]
514
+ def invoke_action(action, args, ctx)
515
+ if action.is_a?(Proc)
516
+ instance_exec(*args, **ctx, &action)
109
517
  else
110
- display_fields = fields unless fields.eql?(:default)
111
- return Result::SingleObject.new(result_list.first, fields: display_fields)
112
- end
113
- end
114
-
115
- # Operations: Create, Delete, Show, List, Modify
116
- # @param api [Aspera::Rest] API to use
117
- # @param entity [String] Sub path in URL to resource relative to base url
118
- # @param command [Symbol, nil] Command to execute: :create, :show, :list, :modify, :delete
119
- # @param display_fields [Array, nil] Fields to display by default
120
- # @param items_key [String, nil] Result is in a sub key of the JSON
121
- # @param delete_style [String, nil] If set, the delete operation by array in payload
122
- # @param id_as_arg [Boolean, String] If set, the id is provided as url argument ?<id_as_arg>=<id>
123
- # @param is_singleton [Boolean] If `true`, entity is the full path to the resource
124
- # @param list_query [Hash, nil] Query parameters for list operation
125
- # @yieldparam value [String] Value to search for identifier
126
- # @yieldreturn [String] The identifier
127
- # @return [Hash] Result suitable for CLI result
128
- def entity_execute(
129
- api:,
130
- entity:,
131
- command: nil,
132
- display_fields: nil,
133
- items_key: nil,
134
- delete_style: nil,
135
- id_as_arg: false,
136
- is_singleton: false,
137
- list_query: nil,
138
- schema: nil,
139
- &block
140
- )
141
- command = options.get_next_command(Operations::ALL) if command.nil?
142
- if is_singleton
143
- one_res_path = entity
144
- elsif Operations::INSTANCE.include?(command)
145
- one_res_id = options.instance_identifier(&block)
146
- one_res_path = "#{entity}/#{one_res_id}"
147
- one_res_path = "#{entity}?#{id_as_arg}=#{one_res_id}" if id_as_arg
148
- end
149
-
150
- case command
151
- 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|
154
- api.create(entity, params)
518
+ send(action, *args, **ctx)
519
+ end
520
+ end
521
+
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.
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.
527
+ # @param spec [CommandSpec] a leaf node (no children)
528
+ # @param ctx [Hash] accumulated context (pre-resolved keys are not re-consumed)
529
+ # @return [Object]
530
+ def execute_leaf(spec, ctx)
531
+ a = action_for(spec)
532
+ # Always resolve declared arguments (even when transfer_paths is set — those arguments
533
+ # are consumed first; ts_source_paths then reads whatever remains in the queue).
534
+ (spec.arguments || []).each do |arg_spec|
535
+ 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))
155
546
  end
156
- when :delete
157
- raise BadArgument, 'cannot delete singleton' if is_singleton
158
- if !delete_style.nil?
159
- one_res_id = [one_res_id] unless one_res_id.is_a?(Array)
160
- Aspera.assert_type(one_res_id, Array, type: Cli::BadArgument)
161
- api.delete(
162
- entity,
163
- nil,
164
- content_type: Mime::JSON,
165
- body: {delete_style => one_res_id}
547
+ end
548
+ invoke_action(a, [], ctx)
549
+ end
550
+
551
+ # Resolve a single positional argument from the CLI argument stream.
552
+ # When arg_spec.bulk is true, always returns an Array (normalized to [value] when non-bulk).
553
+ # For type: :identifier, an optional block provides the percent-selector lookup.
554
+ # @param arg_spec [ArgumentSpec]
555
+ # @yieldparam field [String] field name from a percent-selector (%field:value)
556
+ # @yieldparam value [String] value from a percent-selector
557
+ # @yieldreturn [String] resolved identifier
558
+ # @return [Object] the resolved value, or Array when arg_spec.bulk is true
559
+ def resolve_argument(arg_spec, &block)
560
+ if arg_spec.bulk
561
+ is_bulk = options.get_option(:bulk)
562
+ if arg_spec.type.eql?(:identifier)
563
+ val = options.instance_identifier(description: arg_spec.name.to_s, &block)
564
+ else
565
+ val = options.get_next_argument(
566
+ arg_spec.name.to_s,
567
+ mandatory: arg_spec.mandatory,
568
+ validation: is_bulk ? Array : arg_spec.type,
569
+ default: arg_spec.default,
570
+ schema: arg_spec.schema
166
571
  )
167
- return Result::Status.new('deleted')
168
- end
169
- return do_bulk_operation(command: command, values: one_res_id) do |one_id|
170
- api.delete("#{entity}/#{one_id}", query_read_delete)
171
- {'id' => one_id}
572
+ if is_bulk
573
+ Aspera.assert_array_all(val, arg_spec.type, type: Cli::BadArgument) { 'type' } unless arg_spec.type.nil?
574
+ end
172
575
  end
173
- when :show
174
- return Result::SingleObject.new(api.read(one_res_path), fields: display_fields)
175
- when :list
176
- data, http = api.read(entity, query_read_delete, ret: :both)
177
- return Result::Empty.new if http.code == '204'
178
- # TODO: not generic : which application is this for ?
179
- if http['Content-Type'].start_with?('application/vnd.api+json')
180
- Log.log.debug('is vnd.api')
181
- data = data[entity]
182
- end
183
- data = data[items_key] if items_key
184
- case data
185
- when Hash
186
- return Result::SingleObject.new(data, fields: display_fields)
187
- when Array
188
- return Result::ObjectList.new(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
189
- return Result::ValueList.new(data)
576
+ # Always return an Array when bulk: true
577
+ is_bulk ? val : [val]
578
+ else
579
+ case arg_spec.type
580
+ when :identifier
581
+ options.instance_identifier(description: arg_spec.name.to_s, &block)
190
582
  else
191
- Aspera.error_unexpected_value(data.class.name){'list type'}
583
+ # Class or Array<Class> -> pass as validation type
584
+ # When interactive: true, set ask_missing_mandatory so that get_interactive is triggered
585
+ # when no CLI arguments are provided (mandatory is forced to true for the same reason:
586
+ # a non-nil default would short-circuit get_interactive before it is ever called).
587
+ options.ask_missing_mandatory = true if arg_spec.interactive
588
+ options.get_next_argument(
589
+ arg_spec.name.to_s,
590
+ mandatory: arg_spec.interactive ? true : arg_spec.mandatory,
591
+ multiple: arg_spec.multiple || false,
592
+ validation: arg_spec.type,
593
+ accept_list: arg_spec.allowed,
594
+ default: arg_spec.interactive ? nil : arg_spec.default,
595
+ schema: arg_spec.schema
596
+ )
192
597
  end
193
- when :modify
194
- parameters = value_create_modify(command: command, schema: schema)
195
- api.update(one_res_path, parameters)
196
- return Result::Status.new('modified')
197
- else
198
- Aspera.error_unexpected_value(command){'command'}
199
598
  end
200
599
  end
201
600
 
601
+ # Build a nested Hash tree of the registered command tree for help display.
602
+ # Conditional commands are included with a '[condition_name]' annotation.
603
+ # @param path [Array<Symbol>] starting path ([] for the full tree)
604
+ # @return [Hash] { command_id => { description:, condition:, children: } }
605
+ def generate_help(path = [])
606
+ self.class.command_registry.children_of(path).transform_values do |child_spec|
607
+ annotation = child_spec.condition ? " [#{child_spec.condition}]" : ''
608
+ {
609
+ description: "#{child_spec.description}#{annotation}",
610
+ condition: child_spec.condition,
611
+ children: generate_help(child_spec.full_path)
612
+ }
613
+ end
614
+ end
615
+
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, **)
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
672
+
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)
702
+ end
703
+ end
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
+
202
757
  # Query parameters in URL suitable for REST: list/`GET` and delete/`DELETE`
203
758
  # @param default [Hash, nil] Default query parameters
759
+ # @param schema [String, nil] Contextual schema path for --query help display
204
760
  # @return [Hash, nil] Query parameters
205
- def query_read_delete(default: nil)
761
+ def query_read_delete(default: nil, schema: nil)
206
762
  # Dup default, as it could be frozen
207
- query = options.get_option(:query) || default.dup
208
- Log.log.debug{"query_read_delete=#{query}".bg_red}
763
+ query = options.get_option(:query, schema: schema) || default&.dup
764
+ Log.dump(:query_read_delete, query)
209
765
  begin
210
766
  # Check it is suitable
211
767
  URI.encode_www_form(query) unless query.nil?
@@ -214,34 +770,6 @@ module Aspera
214
770
  end
215
771
  return query
216
772
  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
773
  end
246
774
  end
247
775
  end