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,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/cli/extended_value'
4
+ require 'aspera/dot_container'
4
5
  require 'aspera/cli/version'
5
6
  require 'aspera/cli/info'
6
7
  require 'aspera/log'
7
8
  require 'aspera/assert'
9
+ require 'aspera/yaml'
8
10
  require 'digest'
9
11
  require 'yaml'
10
12
 
@@ -16,10 +18,12 @@ module Aspera
16
18
  class PresetManager
17
19
  # Reserved keys in the YAML config file
18
20
  module Key
19
- CONFIG = 'config'
20
- VERSION = 'version'
21
- DEFAULTS = 'default'
22
- GLOBAL = 'global_common_defaults'
21
+ CONFIG = 'config'
22
+ VERSION = 'version'
23
+ DEFAULTS = 'default'
24
+ GLOBAL = 'global_common_defaults'
25
+ # Prefix for comment/meta keys ignored during preset parsing (e.g. _comment)
26
+ META_PREFIX = '_'
23
27
  end
24
28
 
25
29
  GLOBAL_DEFAULT_KEYWORD = 'GLOBAL'
@@ -39,7 +43,7 @@ module Aspera
39
43
  read_config_file
40
44
  end
41
45
 
42
- attr_reader :config_presets, :use_plugin_defaults
46
+ attr_reader :config_file, :config_presets, :use_plugin_defaults
43
47
  attr_writer :use_plugin_defaults
44
48
 
45
49
  # ------------------------------------------------------------------
@@ -54,26 +58,26 @@ module Aspera
54
58
  # Read and validate the YAML config file.
55
59
  # Sets @config_presets and @checksum_on_disk.
56
60
  def read_config_file
57
- Log.log.debug{"config file is: #{@config_file}".red}
61
+ Log.log.debug { "config file is: #{@config_file}".red }
58
62
  if File.exist?(@config_file)
59
- Log.log.debug{"loading #{@config_file}"}
60
- @config_presets = YAML.load_file(@config_file)
63
+ Log.log.debug { "loading #{@config_file}" }
64
+ @config_presets = Yaml.safe_load(File.read(@config_file))
61
65
  @checksum_on_disk = checksum
62
66
  else
63
- Log.log.warn{"No config file found. New configuration file: #{@config_file}"}
67
+ Log.log.warn { "No config file found. New configuration file: #{@config_file}" }
64
68
  @config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
65
- # @checksum_on_disk remains nil will be saved on first write
69
+ # @checksum_on_disk remains nil: will be saved on first write
66
70
  end
67
71
  validate_config_presets!
68
72
  rescue Psych::SyntaxError => e
69
73
  Log.log.error('YAML error in config file')
70
74
  raise e
71
75
  rescue StandardError => e
72
- Log.log.debug{"-> #{e.class.name} : #{e}"}
76
+ Log.log.debug { "-> #{e.class.name} : #{e}" }
73
77
  if File.exist?(@config_file)
74
78
  new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
75
79
  File.rename(@config_file, new_name)
76
- Log.log.warn{"Renamed config file to #{new_name}."}
80
+ Log.log.warn { "Renamed config file to #{new_name}." }
77
81
  Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
78
82
  end
79
83
  raise Cli::Error, e.to_s
@@ -82,13 +86,13 @@ module Aspera
82
86
  # Save to disk only if content changed since last load/save.
83
87
  # @return [Boolean] true if actually written
84
88
  def save_if_needed
85
- raise Cli::Error, 'no configuration loaded' if @config_presets.nil?
89
+ Aspera.assert(!@config_presets.nil?, type: Cli::Error) { 'no configuration loaded' }
86
90
  current = checksum
87
91
  return false if @checksum_on_disk.eql?(current)
88
92
  FileUtils.mkdir_p(File.dirname(@config_file))
89
93
  Environment.restrict_file_access(File.dirname(@config_file))
90
- Log.log.info{"Saving config file: #{@config_file}"}
91
- Environment.write_file_restricted(@config_file, force: true){ @config_presets.to_yaml }
94
+ Log.log.info { "Saving config file: #{@config_file}" }
95
+ Environment.write_file_restricted(@config_file, force: true) { @config_presets.to_yaml }
92
96
  @checksum_on_disk = current
93
97
  true
94
98
  end
@@ -100,10 +104,10 @@ module Aspera
100
104
  # @return [String, nil] name of the default preset for a plugin, or nil
101
105
  def plugin_default_name(plugin_name_sym)
102
106
  Aspera.assert(!@config_presets.nil?, 'config_presets shall be defined')
103
- return nil unless @use_plugin_defaults
104
- return nil unless @config_presets.key?(Key::DEFAULTS)
105
- Aspera.assert_type(@config_presets[Key::DEFAULTS], Hash){'default section'}
106
- return nil unless @config_presets[Key::DEFAULTS].key?(plugin_name_sym.to_s)
107
+ return unless @use_plugin_defaults
108
+ return unless @config_presets.key?(Key::DEFAULTS)
109
+ Aspera.assert_type(@config_presets[Key::DEFAULTS], Hash) { 'default section' }
110
+ return unless @config_presets[Key::DEFAULTS].key?(plugin_name_sym.to_s)
107
111
  default_name = @config_presets[Key::DEFAULTS][plugin_name_sym.to_s]
108
112
  unless @config_presets.key?(default_name)
109
113
  Log.log.error do
@@ -114,7 +118,7 @@ module Aspera
114
118
  end
115
119
  raise Cli::Error, "No such preset: #{default_name}"
116
120
  end
117
- Aspera.assert_type(@config_presets[default_name], Hash, type: Cli::Error){'preset type'}
121
+ Aspera.assert_type(@config_presets[default_name], Hash, type: Cli::Error) { 'preset type' }
118
122
  default_name
119
123
  end
120
124
 
@@ -123,38 +127,62 @@ module Aspera
123
127
  # @param config_name [String]
124
128
  # @param include_path [Array] guard against include loops
125
129
  def by_name(config_name, include_path = [])
126
- raise Cli::Error, 'loop in include' if include_path.include?(config_name)
130
+ Aspera.assert(!include_path.include?(config_name), type: Cli::Error) { 'loop in include' }
127
131
  include_path = include_path.clone
128
132
  current = @config_presets
129
133
  config_name.split(PRESET_DIG_SEPARATOR).each do |name|
130
- Aspera.assert_type(current, Hash, type: Cli::Error){"sub key: #{include_path}"}
134
+ Aspera.assert_type(current, Hash, type: Cli::Error) { "sub key: #{include_path}" }
131
135
  include_path.push(name)
132
136
  current = current[name]
133
- raise Cli::Error, "Unknown config preset: #{include_path}" if current.nil?
137
+ Aspera.assert(!current.nil?, type: Cli::Error) { "Unknown config preset: #{include_path}" }
134
138
  end
135
139
  current = self.class.deep_clone(current) unless current.is_a?(String)
140
+ current.delete_if { |k, _| k.to_s.start_with?(Key::META_PREFIX) } if current.is_a?(Hash)
136
141
  ExtendedValue.instance.evaluate(current, context: 'preset')
137
142
  end
138
143
 
139
144
  # Set a single key in a preset hash (creates the preset if absent).
145
+ # If param_name contains dots, it is treated as a dot-path and deep-merged into the preset.
146
+ # If param_value is nil, the key is deleted instead.
140
147
  def set_key(preset, param_name, param_value)
141
- Aspera.assert_type(param_name, String, Symbol){'parameter'}
148
+ Aspera.assert_type(param_name, String, Symbol) { 'parameter' }
142
149
  param_name = param_name.to_s
143
150
  selected = @config_presets[preset]
144
151
  if selected.nil?
145
- Log.log.debug{"Unknown preset name: #{preset}, initializing"}
152
+ Log.log.debug { "Unknown preset name: #{preset}, initializing" }
146
153
  selected = @config_presets[preset] = {}
147
154
  end
148
- Aspera.assert_type(selected, Hash){"#{preset}.#{param_name}"}
149
- if selected.key?(param_name)
150
- if selected[param_name].eql?(param_value)
151
- Log.log.warn{"keeping same value for #{preset}: #{param_name}: #{param_value}"}
152
- return
155
+ Aspera.assert_type(selected, Hash) { "#{preset}.#{param_name}" }
156
+ if param_name.include?(DotContainer::SEPARATOR)
157
+ keys = param_name.split(DotContainer::SEPARATOR)
158
+ # Navigate to the parent hash
159
+ parent = keys[0..-2].reduce(selected) do |h, k|
160
+ break nil unless h.is_a?(Hash)
161
+ h[k]
153
162
  end
154
- Log.log.warn{"overwriting value for #{param_name}: #{selected[param_name]}"}
163
+ if param_value.nil?
164
+ parent&.delete(keys.last)
165
+ Log.log.info("Deleted: #{preset}: #{param_name}")
166
+ else
167
+ # dot-path: build nested hash and deep-merge into existing preset
168
+ nested = DotContainer.dotted_to_container(keys, param_value)
169
+ self.class.deep_merge!(selected, nested)
170
+ Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
171
+ end
172
+ elsif param_value.nil?
173
+ selected.delete(param_name)
174
+ Log.log.info("Deleted: #{preset}: #{param_name}")
175
+ else
176
+ if selected.key?(param_name)
177
+ if selected[param_name].eql?(param_value)
178
+ Log.log.warn { "keeping same value for #{preset}: #{param_name}: #{param_value}" }
179
+ return
180
+ end
181
+ Log.log.warn { "overwriting value for #{param_name}: #{selected[param_name]}" }
182
+ end
183
+ selected[param_name] = param_value
184
+ Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
155
185
  end
156
- selected[param_name] = param_value
157
- Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
158
186
  nil
159
187
  end
160
188
 
@@ -191,7 +219,7 @@ module Aspera
191
219
  # @return [Hash, nil]
192
220
  def lookup_preset(url:, username:)
193
221
  url = canonical_url(url)
194
- Log.log.debug{"Lookup preset for #{username}@#{url}"}
222
+ Log.log.debug { "Lookup preset for #{username}@#{url}" }
195
223
  @config_presets.each_value do |v|
196
224
  next unless v.is_a?(Hash)
197
225
  conf_url = v['url'].is_a?(String) ? canonical_url(v['url']) : nil
@@ -208,11 +236,26 @@ module Aspera
208
236
  def deep_clone(val)
209
237
  Marshal.load(Marshal.dump(val))
210
238
  end
239
+
240
+ # Recursively merge src into dst in place (dst wins on scalar conflicts).
241
+ # @param dst [Hash] destination (modified in place)
242
+ # @param src [Hash] source
243
+ # @return [Hash] dst
244
+ def deep_merge!(dst, src)
245
+ src.each do |k, v|
246
+ if dst.key?(k) && dst[k].is_a?(Hash) && v.is_a?(Hash)
247
+ deep_merge!(dst[k], v)
248
+ else
249
+ dst[k] = v
250
+ end
251
+ end
252
+ dst
253
+ end
211
254
  end
212
255
 
213
256
  private
214
257
 
215
- # Normalise a URL for comparison
258
+ # Normalize a URL for comparison
216
259
  def canonical_url(url)
217
260
  url.chomp('/').sub(%r{^(https://[^/]+):443$}, '\1')
218
261
  end
@@ -220,11 +263,11 @@ module Aspera
220
263
  # Structural validation + compatibility fixes on @config_presets
221
264
  def validate_config_presets!
222
265
  Log.dump(:available_presets, @config_presets, level: :trace1)
223
- Aspera.assert_type(@config_presets, Hash){'config file YAML'}
224
- Aspera.assert(@config_presets.key?(Key::CONFIG)){"Cannot find key: #{Key::CONFIG}"}
266
+ Aspera.assert_type(@config_presets, Hash) { 'config file YAML' }
267
+ Aspera.assert(@config_presets.key?(Key::CONFIG)) { "Cannot find key: #{Key::CONFIG}" }
225
268
  version = @config_presets[Key::CONFIG][Key::VERSION]
226
- raise Cli::Error, 'No version found in config section.' if version.nil?
227
- Log.log.debug{"conf version: #{version}"}
269
+ Aspera.assert(!version.nil?, type: Cli::Error) { 'No version found in config section.' }
270
+ Log.log.debug { "conf version: #{version}" }
228
271
  # Fix bug in 4.4 (creating key "true" in "default" preset)
229
272
  @config_presets[Key::DEFAULTS].delete(true) if @config_presets[Key::DEFAULTS].is_a?(Hash)
230
273
  # Stamp with current version
@@ -34,7 +34,7 @@ module Aspera
34
34
  # Base implementation handles common formats: text, nagios, ruby, json, jsonpp, yaml
35
35
  # Subclasses should call super first, then handle their specific formats
36
36
  # @param formatter [Formatter] The formatter to use
37
- # @return [void]
37
+ # @return [nil]
38
38
  def format(formatter)
39
39
  # Apply field filtering once for formats that need it
40
40
  filtered_data = formatter.filter_list_on_fields(@data)
@@ -56,7 +56,7 @@ module Aspera
56
56
  formatter.display_message(:data, formatter.special_format('null (no image)'))
57
57
  return
58
58
  end
59
- Aspera.assert_type(@data, String){'image: URL or blob'}
59
+ Aspera.assert_type(@data, String) { 'image: URL or blob' }
60
60
  # Check if URL
61
61
  data =
62
62
  begin
@@ -72,16 +72,18 @@ module Aspera
72
72
  rescue URI::InvalidURIError
73
73
  @data
74
74
  end
75
- # try base64
76
- data = begin
77
- Base64.strict_decode64(data)
78
- rescue
79
- data
75
+ unless data.eql?(:done)
76
+ # try base64
77
+ data = begin
78
+ Base64.strict_decode64(data)
79
+ rescue ArgumentError
80
+ data
81
+ end
82
+ # here, data is the image blob
83
+ formatter.display_message(:data, Preview::Terminal.build(data, **formatter.image_options))
80
84
  end
81
- # here, data is the image blob
82
- formatter.display_message(:data, Preview::Terminal.build(data, **formatter.image_options)) unless data.eql?(:done)
83
85
  else
84
- Aspera.error_unexpected_value(formatter.format_type){'format'}
86
+ Aspera.error_unexpected_value(formatter.format_type) { 'format' }
85
87
  end
86
88
  end
87
89
  end
@@ -128,7 +130,7 @@ module Aspera
128
130
  # Status result (success, complete, etc.)
129
131
  class Status < Result
130
132
  def initialize(data)
131
- Aspera.assert_type(data, String){'status result data'}
133
+ Aspera.assert_type(data, String) { 'status result data' }
132
134
  super(data: data)
133
135
  end
134
136
 
@@ -155,7 +157,7 @@ module Aspera
155
157
  # Text result
156
158
  class Text < Result
157
159
  def initialize(data)
158
- Aspera.assert_type(data, String, Integer, Symbol, type: ArgumentError){'text result data'}
160
+ Aspera.assert_type(data, String, Integer, Symbol, type: ArgumentError) { 'text result data' }
159
161
  super(data: data)
160
162
  end
161
163
 
@@ -183,13 +185,13 @@ module Aspera
183
185
  # Image result (URL or blob)
184
186
  class Image < Result
185
187
  def initialize(data)
186
- Aspera.assert_type(data, String, NilClass){'image result data'}
188
+ Aspera.assert_type(data, String, NilClass) { 'image result data' }
187
189
  super(data: data)
188
190
  end
189
191
 
190
192
  def format(formatter)
191
193
  # Force image format for Image results
192
- formatter.set_format_type(:image)
194
+ formatter.format_type = :image
193
195
  super
194
196
  end
195
197
  end
@@ -197,7 +199,7 @@ module Aspera
197
199
  # Single object result (Hash)
198
200
  class SingleObject < Result
199
201
  def initialize(data, fields: nil)
200
- Aspera.assert_type(data, Hash){'single object result data'}
202
+ Aspera.assert_type(data, Hash) { 'single object result data' }
201
203
  super(data: data, fields: fields)
202
204
  end
203
205
 
@@ -212,7 +214,7 @@ module Aspera
212
214
  # Create an Image result and format it
213
215
  Image.new(@data[fields.first]).format(formatter)
214
216
  when :table, :csv
215
- Aspera.assert_type(@data, Hash){'result'}
217
+ Aspera.assert_type(@data, Hash) { 'result' }
216
218
  if @data.empty?
217
219
  formatter.display_message(:data, formatter.special_format('empty dict'))
218
220
  else
@@ -234,9 +236,8 @@ module Aspera
234
236
  # @param fields [Array<String>, Proc, nil] Fields to display in table/csv format
235
237
  # @param total [Integer, nil] Total number of items available (for pagination display)
236
238
  def initialize(data, fields: nil, total: nil)
237
- Aspera.assert_type(data, Array){'object list result data'}
238
- raise ArgumentError, 'Object list result requires Array of Hash' unless data.all?(Hash)
239
- Aspera.assert_type(total, Integer, NilClass){'total'}
239
+ Aspera.assert_array_all(data, Hash, type: ArgumentError) { 'object list result data' }
240
+ Aspera.assert_type(total, Integer, NilClass) { 'total' }
240
241
  super(data: data, fields: fields)
241
242
  @total = total
242
243
  end
@@ -249,8 +250,8 @@ module Aspera
249
250
  Aspera.assert(@data.length == 1, 'image display requires a single result', type: Cli::BadArgument)
250
251
  SingleObject.new(@data.first).format(formatter)
251
252
  when :table, :csv
252
- Aspera.assert_array_all(@data, Hash){'result'}
253
- data = formatter.flat_hash? ? @data.map{ |obj| DotContainer.new(obj).to_dotted} : @data
253
+ Aspera.assert_array_all(@data, Hash) { 'result' }
254
+ data = formatter.flat_hash? ? @data.map { |obj| DotContainer.new(obj).to_dotted } : @data
254
255
  formatter.display_table(data, formatter.compute_fields(data, @fields), single: false)
255
256
  else
256
257
  super
@@ -263,8 +264,8 @@ module Aspera
263
264
  attr_reader :name
264
265
 
265
266
  def initialize(data, name: 'id')
266
- Aspera.assert_type(data, Array){'value list result data'}
267
- Aspera.assert_type(name, String){'value list name'}
267
+ Aspera.assert_type(data, Array) { 'value list result data' }
268
+ Aspera.assert_type(name, String) { 'value list name' }
268
269
  super(data: data)
269
270
  @name = name
270
271
  end
@@ -272,7 +273,17 @@ module Aspera
272
273
  def format(formatter)
273
274
  case formatter.format_type
274
275
  when :table, :csv
275
- formatter.display_table(@data.map{ |i| {@name => i}}, [@name])
276
+ formatter.display_table(@data.map { |i| {@name => i} }, [@name])
277
+ when :text
278
+ formatter.display_message(:data, @data.join("\n"))
279
+ when :ruby
280
+ formatter.display_message(:data, PP.pp(@data, +''))
281
+ when :json
282
+ formatter.display_message(:data, JSON.generate(@data))
283
+ when :jsonpp
284
+ formatter.display_message(:data, JSON.pretty_generate(@data))
285
+ when :yaml
286
+ formatter.display_message(:data, YAML.dump(@data))
276
287
  else
277
288
  super
278
289
  end
@@ -280,6 +291,47 @@ module Aspera
280
291
  end
281
292
 
282
293
  class << self
294
+ # Execute a bulk operation over a list of already-resolved items and return a CLI result.
295
+ # This is a pure helper: it reads nothing from the CLI.
296
+ # @param items [Array] List of items to process (one element when non-bulk)
297
+ # @param is_bulk [Boolean] Whether the caller is in bulk mode
298
+ # @param command [Symbol] Operation name (:create, :delete, …) used to build the past-tense status string
299
+ # @param id_result [String] Key in the result Hash used as item identifier
300
+ # @param fields [Object] Fields hint passed to the Result constructor (non-bulk only, when not :default)
301
+ # @param bfail [Boolean] When true, re-raise errors; when false, capture them as a status string
302
+ # @yieldparam item [Object] Each item in +items+
303
+ # @yieldreturn [Hash, Array, nil] REST response; a Hash replaces the default id-keyed result
304
+ # @return [Result::ObjectList, Result::SingleObject]
305
+ def bulk(items, is_bulk:, command:, id_result: 'id', fields: :default, bfail: true)
306
+ Aspera.assert(block_given?, 'missing block')
307
+ Log.log.warn('Empty list given for bulk operation') if items.empty?
308
+ Log.dump(:bulk_operation, items)
309
+ result_list = []
310
+ items.each do |item|
311
+ result = {id_result => item}
312
+ begin
313
+ res = yield(item)
314
+ result = res if res.is_a?(Hash)
315
+ # TODO: remove when faspio gw api fixes this
316
+ result = res.first if res.is_a?(Array) && res.first.is_a?(Hash)
317
+ # Ensure the id_result key is always present even when the API response uses a different key name
318
+ result[id_result] = item unless result.key?(id_result)
319
+ result['status'] = "#{command}#{'e' unless command.to_s.end_with?('e')}d".gsub(/yed$/, 'ied')
320
+ rescue StandardError => e
321
+ raise e if bfail
322
+ result['status'] = e.to_s
323
+ end
324
+ result_list.push(result)
325
+ end
326
+ display_fields = [id_result, 'status']
327
+ if is_bulk
328
+ return ObjectList.new(result_list, fields: display_fields)
329
+ else
330
+ display_fields = fields unless fields.eql?(:default)
331
+ return SingleObject.new(result_list.first, fields: display_fields)
332
+ end
333
+ end
334
+
283
335
  # Class method to automatically determine result type from data
284
336
  # @param data [Object] the data to analyze and format
285
337
  # @return [Result]
@@ -297,11 +349,11 @@ module Aspera
297
349
  unsupported_types = all_types - scalar_types
298
350
  return ValueList.new(data, name: 'list') if unsupported_types.empty?
299
351
 
300
- Aspera.error_unexpected_value(unsupported_types){'list item types'}
352
+ Aspera.error_unexpected_value(unsupported_types) { 'list item types' }
301
353
  when String, Integer, Symbol
302
354
  Text.new(data)
303
355
  else
304
- Aspera.error_unexpected_value(data.class.name){'result type'}
356
+ Aspera.error_unexpected_value(data.class.name) { 'result type' }
305
357
  end
306
358
  end
307
359
  end