aspera-cli 4.26.1 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/extended_value'
4
+ require 'aspera/cli/version'
5
+ require 'aspera/cli/info'
6
+ require 'aspera/log'
7
+ require 'aspera/assert'
8
+ require 'aspera/yaml'
9
+ require 'digest'
10
+ require 'yaml'
11
+
12
+ module Aspera
13
+ module Cli
14
+ # Manages the YAML config file and all preset-related operations.
15
+ # Extracted from Plugins::Config so it can be referenced independently
16
+ # via Context#presets without coupling to the plugin machinery.
17
+ class PresetManager
18
+ # Reserved keys in the YAML config file
19
+ module Key
20
+ CONFIG = 'config'
21
+ VERSION = 'version'
22
+ DEFAULTS = 'default'
23
+ GLOBAL = 'global_common_defaults'
24
+ end
25
+
26
+ GLOBAL_DEFAULT_KEYWORD = 'GLOBAL'
27
+ CONF_GLOBAL_SYM = :config
28
+ # Separator for dot-notation digging into presets
29
+ PRESET_DIG_SEPARATOR = '.'
30
+
31
+ private_constant :PRESET_DIG_SEPARATOR
32
+
33
+ # @param config_file [String] absolute path to the YAML config file
34
+ # @param use_plugin_defaults [Boolean] if false, skip default preset lookup
35
+ def initialize(config_file:, use_plugin_defaults: true)
36
+ @config_file = config_file
37
+ @use_plugin_defaults = use_plugin_defaults
38
+ @config_presets = {}
39
+ @checksum_on_disk = nil
40
+ read_config_file
41
+ end
42
+
43
+ attr_reader :config_file, :config_presets, :use_plugin_defaults
44
+ attr_writer :use_plugin_defaults
45
+
46
+ # ------------------------------------------------------------------
47
+ # File I/O
48
+ # ------------------------------------------------------------------
49
+
50
+ # @return [String] SHA1 of current in-memory presets
51
+ def checksum
52
+ Digest::SHA1.hexdigest(JSON.generate(@config_presets))
53
+ end
54
+
55
+ # Read and validate the YAML config file.
56
+ # Sets @config_presets and @checksum_on_disk.
57
+ def read_config_file
58
+ Log.log.debug{"config file is: #{@config_file}".red}
59
+ if File.exist?(@config_file)
60
+ Log.log.debug{"loading #{@config_file}"}
61
+ @config_presets = Yaml.safe_load(File.read(@config_file))
62
+ @checksum_on_disk = checksum
63
+ else
64
+ Log.log.warn{"No config file found. New configuration file: #{@config_file}"}
65
+ @config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
66
+ # @checksum_on_disk remains nil: will be saved on first write
67
+ end
68
+ validate_config_presets!
69
+ rescue Psych::SyntaxError => e
70
+ Log.log.error('YAML error in config file')
71
+ raise e
72
+ rescue StandardError => e
73
+ Log.log.debug{"-> #{e.class.name} : #{e}"}
74
+ if File.exist?(@config_file)
75
+ new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
76
+ File.rename(@config_file, new_name)
77
+ Log.log.warn{"Renamed config file to #{new_name}."}
78
+ Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
79
+ end
80
+ raise Cli::Error, e.to_s
81
+ end
82
+
83
+ # Save to disk only if content changed since last load/save.
84
+ # @return [Boolean] true if actually written
85
+ def save_if_needed
86
+ Aspera.assert(!@config_presets.nil?, type: Cli::Error){'no configuration loaded'}
87
+ current = checksum
88
+ return false if @checksum_on_disk.eql?(current)
89
+ FileUtils.mkdir_p(File.dirname(@config_file))
90
+ Environment.restrict_file_access(File.dirname(@config_file))
91
+ Log.log.info{"Saving config file: #{@config_file}"}
92
+ Environment.write_file_restricted(@config_file, force: true){@config_presets.to_yaml}
93
+ @checksum_on_disk = current
94
+ true
95
+ end
96
+
97
+ # ------------------------------------------------------------------
98
+ # Preset read/write helpers
99
+ # ------------------------------------------------------------------
100
+
101
+ # @return [String, nil] name of the default preset for a plugin, or nil
102
+ def plugin_default_name(plugin_name_sym)
103
+ Aspera.assert(!@config_presets.nil?, 'config_presets shall be defined')
104
+ return unless @use_plugin_defaults
105
+ return unless @config_presets.key?(Key::DEFAULTS)
106
+ Aspera.assert_type(@config_presets[Key::DEFAULTS], Hash){'default section'}
107
+ return unless @config_presets[Key::DEFAULTS].key?(plugin_name_sym.to_s)
108
+ default_name = @config_presets[Key::DEFAULTS][plugin_name_sym.to_s]
109
+ unless @config_presets.key?(default_name)
110
+ Log.log.error do
111
+ "Default config name [#{default_name}] specified for plugin [#{plugin_name_sym}], but it does not exist in config file.\n" \
112
+ "Please fix: either create preset:\n" \
113
+ "#{Info::CMD_NAME} config id #{default_name} init @json:'{}'\n" \
114
+ "or remove default:\n#{Info::CMD_NAME} config id default remove #{plugin_name_sym}"
115
+ end
116
+ raise Cli::Error, "No such preset: #{default_name}"
117
+ end
118
+ Aspera.assert_type(@config_presets[default_name], Hash, type: Cli::Error){'preset type'}
119
+ default_name
120
+ end
121
+
122
+ # Resolve and return a deep-cloned, extended copy of a named preset.
123
+ # Supports dot-notation (e.g. "outer.inner").
124
+ # @param config_name [String]
125
+ # @param include_path [Array] guard against include loops
126
+ def by_name(config_name, include_path = [])
127
+ Aspera.assert(!include_path.include?(config_name), type: Cli::Error){'loop in include'}
128
+ include_path = include_path.clone
129
+ current = @config_presets
130
+ config_name.split(PRESET_DIG_SEPARATOR).each do |name|
131
+ Aspera.assert_type(current, Hash, type: Cli::Error){"sub key: #{include_path}"}
132
+ include_path.push(name)
133
+ current = current[name]
134
+ Aspera.assert(!current.nil?, type: Cli::Error){"Unknown config preset: #{include_path}"}
135
+ end
136
+ current = self.class.deep_clone(current) unless current.is_a?(String)
137
+ ExtendedValue.instance.evaluate(current, context: 'preset')
138
+ end
139
+
140
+ # Set a single key in a preset hash (creates the preset if absent).
141
+ def set_key(preset, param_name, param_value)
142
+ Aspera.assert_type(param_name, String, Symbol){'parameter'}
143
+ param_name = param_name.to_s
144
+ selected = @config_presets[preset]
145
+ if selected.nil?
146
+ Log.log.debug{"Unknown preset name: #{preset}, initializing"}
147
+ selected = @config_presets[preset] = {}
148
+ end
149
+ Aspera.assert_type(selected, Hash){"#{preset}.#{param_name}"}
150
+ if selected.key?(param_name)
151
+ if selected[param_name].eql?(param_value)
152
+ Log.log.warn{"keeping same value for #{preset}: #{param_name}: #{param_value}"}
153
+ return
154
+ end
155
+ Log.log.warn{"overwriting value for #{param_name}: #{selected[param_name]}"}
156
+ end
157
+ selected[param_name] = param_value
158
+ Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
159
+ nil
160
+ end
161
+
162
+ # @return [String] name of the global default preset, creating it if needed
163
+ def global_default_preset
164
+ result = plugin_default_name(CONF_GLOBAL_SYM)
165
+ if result.nil?
166
+ result = Key::GLOBAL
167
+ set_key(Key::DEFAULTS, CONF_GLOBAL_SYM, result)
168
+ end
169
+ result
170
+ end
171
+
172
+ # Set param in the global defaults preset
173
+ def set_global_default(key, value)
174
+ set_key(global_default_preset, key, value)
175
+ end
176
+
177
+ # Create / overwrite a preset and optionally set it as plugin default.
178
+ def defaults_set(plugin_name, preset_name, preset_values, option_default, option_override)
179
+ @config_presets[Key::DEFAULTS] ||= {}
180
+ raise Cli::Error, "A default configuration already exists for plugin '#{plugin_name}' (use --override=yes or --default=no)" \
181
+ if !option_override && option_default && @config_presets[Key::DEFAULTS].key?(plugin_name)
182
+ raise Cli::Error, "Preset already exists: #{preset_name} (use --override=yes or provide alternate name on command line)" \
183
+ if !option_override && @config_presets.key?(preset_name)
184
+ if option_default
185
+ Log.log.info("Setting config preset as default for #{plugin_name}")
186
+ @config_presets[Key::DEFAULTS][plugin_name.to_s] = preset_name
187
+ end
188
+ @config_presets[preset_name] = preset_values
189
+ end
190
+
191
+ # Find the first preset whose url+username match
192
+ # @return [Hash, nil]
193
+ def lookup_preset(url:, username:)
194
+ url = canonical_url(url)
195
+ Log.log.debug{"Lookup preset for #{username}@#{url}"}
196
+ @config_presets.each_value do |v|
197
+ next unless v.is_a?(Hash)
198
+ conf_url = v['url'].is_a?(String) ? canonical_url(v['url']) : nil
199
+ return self.class.deep_clone(v) if conf_url.eql?(url) && v['username'].eql?(username)
200
+ end
201
+ nil
202
+ end
203
+
204
+ # ------------------------------------------------------------------
205
+ # Class helpers
206
+ # ------------------------------------------------------------------
207
+
208
+ class << self
209
+ def deep_clone(val)
210
+ Marshal.load(Marshal.dump(val))
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ # Normalize a URL for comparison
217
+ def canonical_url(url)
218
+ url.chomp('/').sub(%r{^(https://[^/]+):443$}, '\1')
219
+ end
220
+
221
+ # Structural validation + compatibility fixes on @config_presets
222
+ def validate_config_presets!
223
+ Log.dump(:available_presets, @config_presets, level: :trace1)
224
+ Aspera.assert_type(@config_presets, Hash){'config file YAML'}
225
+ Aspera.assert(@config_presets.key?(Key::CONFIG)){"Cannot find key: #{Key::CONFIG}"}
226
+ version = @config_presets[Key::CONFIG][Key::VERSION]
227
+ Aspera.assert(!version.nil?, type: Cli::Error){'No version found in config section.'}
228
+ Log.log.debug{"conf version: #{version}"}
229
+ # Fix bug in 4.4 (creating key "true" in "default" preset)
230
+ @config_presets[Key::DEFAULTS].delete(true) if @config_presets[Key::DEFAULTS].is_a?(Hash)
231
+ # Stamp with current version
232
+ @config_presets[Key::CONFIG][Key::VERSION] = Cli::VERSION
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/assert'
4
+ require 'aspera/nagios'
5
+ require 'aspera/log'
6
+ require 'aspera/environment'
7
+ require 'aspera/uri_reader'
8
+ require 'aspera/preview/terminal'
9
+ require 'aspera/dot_container'
10
+ require 'base64'
11
+ require 'uri'
12
+ require 'pp'
13
+ require 'json'
14
+ require 'yaml'
15
+
16
+ module Aspera
17
+ module Cli
18
+ # Base class for all result types
19
+ # Each result type is now a separate class instead of using a type field
20
+ class Result
21
+ attr_reader :data, :fields
22
+
23
+ # @param data [Object,nil] The result data
24
+ # @param fields [Object] Specification of fields to include
25
+ def initialize(data: nil, fields: nil)
26
+ @data = data
27
+ @fields = fields
28
+ end
29
+
30
+ # Format this result using the provided formatter
31
+ # This method implements the Visitor pattern, allowing each Result subclass
32
+ # to define how it should be formatted without the Formatter needing to know
33
+ # about all Result types.
34
+ # Base implementation handles common formats: text, nagios, ruby, json, jsonpp, yaml
35
+ # Subclasses should call super first, then handle their specific formats
36
+ # @param formatter [Formatter] The formatter to use
37
+ # @return [nil]
38
+ def format(formatter)
39
+ # Apply field filtering once for formats that need it
40
+ filtered_data = formatter.filter_list_on_fields(@data)
41
+ case formatter.format_type
42
+ when :text
43
+ formatter.display_message(:data, @data.to_s)
44
+ when :nagios
45
+ Nagios.process(@data)
46
+ when :ruby
47
+ formatter.display_message(:data, PP.pp(filtered_data, +''))
48
+ when :json
49
+ formatter.display_message(:data, JSON.generate(filtered_data))
50
+ when :jsonpp
51
+ formatter.display_message(:data, JSON.pretty_generate(filtered_data))
52
+ when :yaml
53
+ formatter.display_message(:data, YAML.dump(filtered_data))
54
+ when :image
55
+ if @data.nil?
56
+ formatter.display_message(:data, formatter.special_format('null (no image)'))
57
+ return
58
+ end
59
+ Aspera.assert_type(@data, String){'image: URL or blob'}
60
+ # Check if URL
61
+ data =
62
+ begin
63
+ # just validate
64
+ URI.parse(@data)
65
+ if Environment.instance.url_method.eql?(:text)
66
+ UriReader.read(@data)
67
+ else
68
+ Environment.instance.open_uri(@data)
69
+ formatter.display_message(:info, "Opened URL in browser: #{@data}")
70
+ :done
71
+ end
72
+ rescue URI::InvalidURIError
73
+ @data
74
+ end
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))
84
+ end
85
+ else
86
+ Aspera.error_unexpected_value(formatter.format_type){'format'}
87
+ end
88
+ end
89
+ end
90
+
91
+ # Special result types - each has its own class
92
+ class Result
93
+ # Base class for special results
94
+ # The type is automatically derived from the class name
95
+ class Special < Result
96
+ def initialize
97
+ # Convert class name to symbol: Empty -> :empty, Nothing -> :nothing
98
+ type = self.class.name.split('::').last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
99
+ super(data: type)
100
+ end
101
+
102
+ def format(formatter)
103
+ case formatter.format_type
104
+ when :text, :nagios, :ruby, :json, :jsonpp, :yaml
105
+ formatter.display_message(:data, @data.to_s)
106
+ when :table, :csv
107
+ if @data.eql?(:nothing)
108
+ Log.log.debug('no result expected')
109
+ return
110
+ end
111
+ formatter.display_message(:info, formatter.special_format(@data.to_s))
112
+ else
113
+ super
114
+ end
115
+ end
116
+ end
117
+
118
+ # Empty list result
119
+ class Empty < Special
120
+ end
121
+
122
+ # Nothing expected result
123
+ class Nothing < Special
124
+ end
125
+
126
+ # Null result
127
+ class Null < Special
128
+ end
129
+
130
+ # Status result (success, complete, etc.)
131
+ class Status < Result
132
+ def initialize(data)
133
+ Aspera.assert_type(data, String){'status result data'}
134
+ super(data: data)
135
+ end
136
+
137
+ def format(formatter)
138
+ data = formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
139
+ case formatter.format_type
140
+ when :text, :nagios, :ruby, :json, :jsonpp, :yaml
141
+ formatter.display_message(:data, data)
142
+ when :table, :csv
143
+ formatter.display_message(:info, data)
144
+ else
145
+ super
146
+ end
147
+ end
148
+ end
149
+
150
+ # Success status result
151
+ class Success < Status
152
+ def initialize
153
+ super('complete')
154
+ end
155
+ end
156
+
157
+ # Text result
158
+ class Text < Result
159
+ def initialize(data)
160
+ Aspera.assert_type(data, String, Integer, Symbol, type: ArgumentError){'text result data'}
161
+ super(data: data)
162
+ end
163
+
164
+ def format(formatter)
165
+ data = @data.is_a?(String) && formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
166
+ case formatter.format_type
167
+ when :text, :nagios
168
+ formatter.display_message(:data, data.to_s)
169
+ when :ruby
170
+ formatter.display_message(:data, PP.pp(data, +''))
171
+ when :json
172
+ formatter.display_message(:data, JSON.generate(data))
173
+ when :jsonpp
174
+ formatter.display_message(:data, JSON.pretty_generate(data))
175
+ when :yaml
176
+ formatter.display_message(:data, YAML.dump(data))
177
+ when :table, :csv
178
+ formatter.display_message(:data, data)
179
+ else
180
+ super
181
+ end
182
+ end
183
+ end
184
+
185
+ # Image result (URL or blob)
186
+ class Image < Result
187
+ def initialize(data)
188
+ Aspera.assert_type(data, String, NilClass){'image result data'}
189
+ super(data: data)
190
+ end
191
+
192
+ def format(formatter)
193
+ # Force image format for Image results
194
+ formatter.format_type = :image
195
+ super
196
+ end
197
+ end
198
+
199
+ # Single object result (Hash)
200
+ class SingleObject < Result
201
+ def initialize(data, fields: nil)
202
+ Aspera.assert_type(data, Hash){'single object result data'}
203
+ super(data: data, fields: fields)
204
+ end
205
+
206
+ def format(formatter)
207
+ case formatter.format_type
208
+ when :image
209
+ # Extract single field for image display
210
+ data_array = [@data]
211
+ fields = formatter.compute_fields(data_array, @fields)
212
+ Aspera.assert(fields.length == 1, 'select a single field to display', type: Cli::BadArgument)
213
+ Aspera.assert(@data.key?(fields.first), 'no such field', type: Cli::BadArgument)
214
+ # Create an Image result and format it
215
+ Image.new(@data[fields.first]).format(formatter)
216
+ when :table, :csv
217
+ Aspera.assert_type(@data, Hash){'result'}
218
+ if @data.empty?
219
+ formatter.display_message(:data, formatter.special_format('empty dict'))
220
+ else
221
+ data = formatter.flat_hash? ? DotContainer.new(@data).to_dotted : @data
222
+ formatter.display_table([data], formatter.compute_fields([data], @fields), single: true)
223
+ end
224
+ else
225
+ super
226
+ end
227
+ end
228
+ end
229
+
230
+ # Object list result (Array of Hash)
231
+ # @note The +total+ parameter is used to display pagination information (e.g., "Items: 10/100")
232
+ class ObjectList < Result
233
+ attr_reader :total
234
+
235
+ # @param data [Array<Hash>] Array of hash objects to display
236
+ # @param fields [Array<String>, Proc, nil] Fields to display in table/csv format
237
+ # @param total [Integer, nil] Total number of items available (for pagination display)
238
+ def initialize(data, fields: nil, total: nil)
239
+ Aspera.assert_array_all(data, Hash, type: ArgumentError){'object list result data'}
240
+ Aspera.assert_type(total, Integer, NilClass){'total'}
241
+ super(data: data, fields: fields)
242
+ @total = total
243
+ end
244
+
245
+ def format(formatter)
246
+ formatter.display_item_count(@data.length, @total)
247
+ case formatter.format_type
248
+ when :image
249
+ # Extract single field for image display
250
+ Aspera.assert(@data.length == 1, 'image display requires a single result', type: Cli::BadArgument)
251
+ SingleObject.new(@data.first).format(formatter)
252
+ when :table, :csv
253
+ Aspera.assert_array_all(@data, Hash){'result'}
254
+ data = formatter.flat_hash? ? @data.map{ |obj| DotContainer.new(obj).to_dotted} : @data
255
+ formatter.display_table(data, formatter.compute_fields(data, @fields), single: false)
256
+ else
257
+ super
258
+ end
259
+ end
260
+ end
261
+
262
+ # Value list result (Array of values with a name)
263
+ class ValueList < Result
264
+ attr_reader :name
265
+
266
+ def initialize(data, name: 'id')
267
+ Aspera.assert_type(data, Array){'value list result data'}
268
+ Aspera.assert_type(name, String){'value list name'}
269
+ super(data: data)
270
+ @name = name
271
+ end
272
+
273
+ def format(formatter)
274
+ case formatter.format_type
275
+ when :table, :csv
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))
287
+ else
288
+ super
289
+ end
290
+ end
291
+ end
292
+
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
+ result['status'] = "#{command}#{'e' unless command.to_s.end_with?('e')}d".gsub(/yed$/, 'ied')
318
+ rescue StandardError => e
319
+ raise e if bfail
320
+ result['status'] = e.to_s
321
+ end
322
+ result_list.push(result)
323
+ end
324
+ display_fields = [id_result, 'status']
325
+ if is_bulk
326
+ return ObjectList.new(result_list, fields: display_fields)
327
+ else
328
+ display_fields = fields unless fields.eql?(:default)
329
+ return SingleObject.new(result_list.first, fields: display_fields)
330
+ end
331
+ end
332
+
333
+ # Class method to automatically determine result type from data
334
+ # @param data [Object] the data to analyze and format
335
+ # @return [Result]
336
+ def auto(data)
337
+ case data
338
+ when NilClass
339
+ Null.new
340
+ when Hash
341
+ SingleObject.new(data)
342
+ when Array
343
+ all_types = data.map(&:class).uniq
344
+ return ObjectList.new(data) if all_types.eql?([Hash])
345
+
346
+ scalar_types = [String, Integer, Symbol]
347
+ unsupported_types = all_types - scalar_types
348
+ return ValueList.new(data, name: 'list') if unsupported_types.empty?
349
+
350
+ Aspera.error_unexpected_value(unsupported_types){'list item types'}
351
+ when String, Integer, Symbol
352
+ Text.new(data)
353
+ else
354
+ Aspera.error_unexpected_value(data.class.name){'result type'}
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end