aspera-cli 4.26.0 → 4.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +36 -4
  4. data/CONTRIBUTING.md +2 -5
  5. data/bin/ascli +2 -2
  6. data/lib/aspera/agent/direct.rb +1 -1
  7. data/lib/aspera/agent/factory.rb +4 -0
  8. data/lib/aspera/agent/httpgw.rb +1 -1
  9. data/lib/aspera/agent/node.rb +2 -2
  10. data/lib/aspera/api/aoc.rb +45 -37
  11. data/lib/aspera/api/cos_node.rb +1 -1
  12. data/lib/aspera/api/faspex.rb +7 -6
  13. data/lib/aspera/api/node.rb +9 -9
  14. data/lib/aspera/ascmd.rb +4 -4
  15. data/lib/aspera/ascp/installation.rb +13 -6
  16. data/lib/aspera/assert.rb +17 -13
  17. data/lib/aspera/cli/context.rb +58 -0
  18. data/lib/aspera/cli/extended_value.rb +9 -4
  19. data/lib/aspera/cli/formatter.rb +91 -187
  20. data/lib/aspera/cli/http.rb +167 -0
  21. data/lib/aspera/cli/manager.rb +132 -77
  22. data/lib/aspera/cli/options.schema.yaml +82 -0
  23. data/lib/aspera/cli/plugins/alee.rb +2 -2
  24. data/lib/aspera/cli/plugins/aoc.rb +113 -77
  25. data/lib/aspera/cli/plugins/ats.rb +16 -16
  26. data/lib/aspera/cli/plugins/base.rb +64 -49
  27. data/lib/aspera/cli/plugins/config.rb +172 -444
  28. data/lib/aspera/cli/plugins/console.rb +7 -7
  29. data/lib/aspera/cli/plugins/cos.rb +2 -2
  30. data/lib/aspera/cli/plugins/factory.rb +3 -0
  31. data/lib/aspera/cli/plugins/faspex.rb +22 -22
  32. data/lib/aspera/cli/plugins/faspex5.rb +68 -49
  33. data/lib/aspera/cli/plugins/faspio.rb +1 -1
  34. data/lib/aspera/cli/plugins/httpgw.rb +2 -2
  35. data/lib/aspera/cli/plugins/node.rb +80 -80
  36. data/lib/aspera/cli/plugins/orchestrator.rb +21 -26
  37. data/lib/aspera/cli/plugins/preview.rb +9 -9
  38. data/lib/aspera/cli/plugins/server.rb +7 -7
  39. data/lib/aspera/cli/plugins/shares.rb +2 -2
  40. data/lib/aspera/cli/preset_manager.rb +235 -0
  41. data/lib/aspera/cli/result.rb +310 -0
  42. data/lib/aspera/cli/{main.rb → runner.rb} +48 -130
  43. data/lib/aspera/cli/sync_actions.rb +15 -11
  44. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  45. data/lib/aspera/cli/transfer_agent.rb +17 -15
  46. data/lib/aspera/cli/transfer_progress.rb +6 -6
  47. data/lib/aspera/cli/version.rb +1 -1
  48. data/lib/aspera/cli/wizard.rb +5 -6
  49. data/lib/aspera/command_line_builder.rb +22 -18
  50. data/lib/aspera/data_repository.rb +4 -0
  51. data/lib/aspera/dot_container.rb +1 -1
  52. data/lib/aspera/environment.rb +25 -6
  53. data/lib/aspera/faspex_gw.rb +1 -1
  54. data/lib/aspera/formatter_interface.rb +14 -0
  55. data/lib/aspera/hash_ext.rb +6 -0
  56. data/lib/aspera/json_rpc.rb +6 -5
  57. data/lib/aspera/keychain/base.rb +1 -1
  58. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  59. data/lib/aspera/keychain/factory.rb +1 -1
  60. data/lib/aspera/keychain/macos_security.rb +1 -1
  61. data/lib/aspera/log.rb +10 -5
  62. data/lib/aspera/markdown.rb +4 -1
  63. data/lib/aspera/nagios.rb +2 -2
  64. data/lib/aspera/oauth/base.rb +5 -5
  65. data/lib/aspera/oauth/boot.rb +43 -0
  66. data/lib/aspera/oauth/factory.rb +38 -14
  67. data/lib/aspera/oauth/web.rb +2 -2
  68. data/lib/aspera/oauth.rb +1 -0
  69. data/lib/aspera/persistency_action_once.rb +2 -2
  70. data/lib/aspera/preview/file_types.rb +4 -0
  71. data/lib/aspera/products/connect.rb +3 -0
  72. data/lib/aspera/products/transferd.rb +2 -2
  73. data/lib/aspera/proxy_auto_config.rb +6 -3
  74. data/lib/aspera/rest.rb +9 -5
  75. data/lib/aspera/rest_error_analyzer.rb +4 -0
  76. data/lib/aspera/rest_list.rb +10 -3
  77. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +62811 -0
  78. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +9637 -0
  79. data/lib/aspera/schema/async_tables.yaml +361 -0
  80. data/lib/aspera/schema/documentation.rb +107 -0
  81. data/lib/aspera/schema/reader.rb +75 -0
  82. data/lib/aspera/schema/registry.rb +70 -0
  83. data/lib/aspera/secret_hider.rb +4 -0
  84. data/lib/aspera/sync/conf.schema.yaml +0 -26
  85. data/lib/aspera/sync/database.rb +14 -10
  86. data/lib/aspera/sync/operations.rb +14 -10
  87. data/lib/aspera/temp_file_manager.rb +4 -0
  88. data/lib/aspera/transfer/faux_file.rb +1 -1
  89. data/lib/aspera/transfer/parameters.rb +4 -4
  90. data/lib/aspera/transfer/resumer.rb +2 -2
  91. data/lib/aspera/transfer/spec.rb +3 -3
  92. data/lib/aspera/transfer/spec.schema.yaml +18 -9
  93. data/lib/aspera/transfer/uri.rb +1 -1
  94. data/lib/aspera/uri_reader.rb +1 -1
  95. data/lib/aspera/yaml.rb +4 -2
  96. data.tar.gz.sig +0 -0
  97. metadata +28 -15
  98. metadata.gz.sig +1 -1
  99. data/lib/aspera/transfer/spec_doc.rb +0 -76
@@ -198,7 +198,7 @@ module Aspera
198
198
  # @param destination [String, nil] local destination root for receive operations
199
199
  # @return [Object] transfer result returned by [`Main.result_transfer`](lib/aspera/cli/main.rb)
200
200
  def do_transfer(direction, folder_id, source_filename, destination = '/')
201
- Aspera.assert(!(destination.nil? && direction.eql?(Transfer::Spec::DIRECTION_RECEIVE)))
201
+ Aspera.assert(!(destination.nil? && direction.eql?(Transfer::Spec::DIRECTION_RECEIVE)), 'destination must be set for receive direction')
202
202
  t_spec = @api_node.transfer_spec_gen4(folder_id, direction, {
203
203
  'paths' => [{'source' => source_filename}],
204
204
  'tags' => {Transfer::Spec::TAG_RESERVED => {PREV_GEN_TAG => true}}
@@ -206,7 +206,7 @@ module Aspera
206
206
  # Force the destination on the transfer agent object.
207
207
  # Setting `t_spec['destination_root']` directly would later be overwritten.
208
208
  transfer.user_transfer_spec['destination_root'] = destination
209
- Main.result_transfer(transfer.start(t_spec))
209
+ Runner.result_transfer(transfer.start(t_spec))
210
210
  end
211
211
 
212
212
  # Populate generation metadata for a source file available on the local filesystem.
@@ -323,7 +323,7 @@ module Aspera
323
323
  # create folder if needed
324
324
  FileUtils.mkdir_p(local_entry_preview_dir)
325
325
  if @access_remote
326
- Aspera.assert(!entry['parent_file_id'].nil?){'missing parent_file_id in entry'}
326
+ Aspera.assert(!entry['parent_file_id'].nil?, 'missing parent_file_id in entry')
327
327
  # download original file to temp folder
328
328
  do_transfer(Transfer::Spec::DIRECTION_RECEIVE, entry['parent_file_id'], entry['name'], @tmp_folder)
329
329
  end
@@ -471,7 +471,7 @@ module Aspera
471
471
  end
472
472
  @filter_block = Api::Node.file_matcher_from_argument(options)
473
473
  scan_folder_files(folder_info, scan_path)
474
- return Main.result_status('scan finished')
474
+ return Result::Status.new('scan finished')
475
475
  when :events, :trevents
476
476
  @filter_block = Api::Node.file_matcher_from_argument(options)
477
477
  iteration_persistency = nil
@@ -489,15 +489,15 @@ module Aspera
489
489
  end
490
490
  # call processing method specified by command line command
491
491
  send(:"process_#{command}", iteration_persistency)
492
- return Main.result_status("#{command} finished")
492
+ return Result::Status.new("#{command} finished")
493
493
  when :check
494
- return Main.result_status('Tools validated')
494
+ return Result::Status.new('Tools validated')
495
495
  when :test
496
496
  source = options.get_next_argument('source file')
497
497
  format = options.get_next_argument('format', accept_list: Aspera::Preview::Generator::PREVIEW_FORMATS, default: :png)
498
498
  generated_file_path = preview_filename(format, options.get_option(:base))
499
499
  Aspera::Preview::Generator.new(source, generated_file_path, @gen_options, @tmp_folder).generate
500
- return Main.result_status("generated: #{generated_file_path}")
500
+ return Result::Status.new("generated: #{generated_file_path}")
501
501
  when :show
502
502
  source = options.get_next_argument('source file')
503
503
  # terminal_options = options.get_next_argument('options', validation: Hash, default: {}).symbolize_keys
@@ -505,8 +505,8 @@ module Aspera
505
505
  Aspera::Preview::Generator.new(source, generated_file_path, @gen_options, @tmp_folder).generate
506
506
  formatter.display_status("generated: #{generated_file_path}")
507
507
  # formatter.display_status(Aspera::Preview::Terminal.build(File.read(generated_file_path), **terminal_options))
508
- # return Main.result_status("generated: #{generated_file_path}")
509
- return Main.result_image(UriReader.file_url(generated_file_path))
508
+ # return Result::Status.new("generated: #{generated_file_path}")
509
+ return Result::Image.new(UriReader.file_url(generated_file_path))
510
510
  else Aspera.error_unexpected_value(command)
511
511
  end
512
512
  ensure
@@ -165,7 +165,7 @@ module Aspera
165
165
  end
166
166
  # if user provided transfer spec has a token, we will use bypass keys
167
167
  cred_set = true if transfer.user_transfer_spec['token'].is_a?(String)
168
- Aspera.assert(cred_set, type: BadArgument){'Either password, key , or transfer spec token must be provided'}
168
+ Aspera.assert(cred_set, 'Either password, key , or transfer spec token must be provided', type: BadArgument)
169
169
  return server_transfer_spec
170
170
  end
171
171
 
@@ -173,7 +173,7 @@ module Aspera
173
173
  case command
174
174
  when :upload, :download
175
175
  transfer_spec['direction'] = Transfer::Spec.transfer_type_to_direction(command)
176
- return Main.result_transfer(transfer.start(transfer_spec))
176
+ return Runner.result_transfer(transfer.start(transfer_spec))
177
177
  when :sync
178
178
  # lets ignore the arguments provided by execute_sync_action, we just give the transfer spec
179
179
  return execute_sync_action{transfer_spec}
@@ -218,7 +218,7 @@ module Aspera
218
218
  end
219
219
  else Aspera.error_unexpected_value(command_nagios)
220
220
  end
221
- Main.result_object_list(nagios.status_list)
221
+ Result::ObjectList.new(nagios.status_list)
222
222
  when *TRANSFER_COMMANDS
223
223
  return execute_transfer(command, server_transfer_spec)
224
224
  when *AsCmd::OPERATIONS
@@ -228,13 +228,13 @@ module Aspera
228
228
  result = ascmd.execute_single(command, command_arguments)
229
229
  case command
230
230
  when :mkdir, :mv, :cp, :rm
231
- return Main.result_success
231
+ return Result::Success.new
232
232
  when :ls
233
- return Main.result_object_list(result.map(&:stringify_keys), fields: %w[zmode zuid zgid size mtime name])
233
+ return Result::ObjectList.new(result.map(&:stringify_keys), fields: %w[zmode zuid zgid size mtime name])
234
234
  when :df
235
- return Main.result_object_list(result.map(&:stringify_keys))
235
+ return Result::ObjectList.new(result.map(&:stringify_keys))
236
236
  when :du, :md5sum, :info
237
- return Main.result_single_object(result.stringify_keys)
237
+ return Result::SingleObject.new(result.stringify_keys)
238
238
  end
239
239
  rescue AsCmd::Error => e
240
240
  raise Cli::BadArgument, e.extended_message
@@ -118,9 +118,9 @@ module Aspera
118
118
  else
119
119
  nagios.add_critical('API', health[:api].to_s)
120
120
  end
121
- Main.result_object_list(nagios.status_list)
121
+ Result::ObjectList.new(nagios.status_list)
122
122
  when :info
123
- return Main.result_single_object(basic_auth_api(NODE_API_PATH).read('info', headers: {'Content-Type'=>'application/json'}))
123
+ return Result::SingleObject.new(basic_auth_api(NODE_API_PATH).read('info', headers: {'Content-Type'=>'application/json'}))
124
124
  when :files
125
125
  api_shares_node = basic_auth_api(NODE_API_PATH)
126
126
  repo_command = options.get_next_command(Node::COMMANDS_SHARES)
@@ -0,0 +1,235 @@
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 'digest'
9
+ require 'yaml'
10
+
11
+ module Aspera
12
+ module Cli
13
+ # Manages the YAML config file and all preset-related operations.
14
+ # Extracted from Plugins::Config so it can be referenced independently
15
+ # via Context#presets without coupling to the plugin machinery.
16
+ class PresetManager
17
+ # Reserved keys in the YAML config file
18
+ module Key
19
+ CONFIG = 'config'
20
+ VERSION = 'version'
21
+ DEFAULTS = 'default'
22
+ GLOBAL = 'global_common_defaults'
23
+ end
24
+
25
+ GLOBAL_DEFAULT_KEYWORD = 'GLOBAL'
26
+ CONF_GLOBAL_SYM = :config
27
+ # Separator for dot-notation digging into presets
28
+ PRESET_DIG_SEPARATOR = '.'
29
+
30
+ private_constant :PRESET_DIG_SEPARATOR
31
+
32
+ # @param config_file [String] absolute path to the YAML config file
33
+ # @param use_plugin_defaults [Boolean] if false, skip default preset lookup
34
+ def initialize(config_file:, use_plugin_defaults: true)
35
+ @config_file = config_file
36
+ @use_plugin_defaults = use_plugin_defaults
37
+ @config_presets = {}
38
+ @checksum_on_disk = nil
39
+ read_config_file
40
+ end
41
+
42
+ attr_reader :config_presets, :use_plugin_defaults
43
+ attr_writer :use_plugin_defaults
44
+
45
+ # ------------------------------------------------------------------
46
+ # File I/O
47
+ # ------------------------------------------------------------------
48
+
49
+ # @return [String] SHA1 of current in-memory presets
50
+ def checksum
51
+ Digest::SHA1.hexdigest(JSON.generate(@config_presets))
52
+ end
53
+
54
+ # Read and validate the YAML config file.
55
+ # Sets @config_presets and @checksum_on_disk.
56
+ def read_config_file
57
+ Log.log.debug{"config file is: #{@config_file}".red}
58
+ if File.exist?(@config_file)
59
+ Log.log.debug{"loading #{@config_file}"}
60
+ @config_presets = YAML.load_file(@config_file)
61
+ @checksum_on_disk = checksum
62
+ else
63
+ Log.log.warn{"No config file found. New configuration file: #{@config_file}"}
64
+ @config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
65
+ # @checksum_on_disk remains nil → will be saved on first write
66
+ end
67
+ validate_config_presets!
68
+ rescue Psych::SyntaxError => e
69
+ Log.log.error('YAML error in config file')
70
+ raise e
71
+ rescue StandardError => e
72
+ Log.log.debug{"-> #{e.class.name} : #{e}"}
73
+ if File.exist?(@config_file)
74
+ new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
75
+ File.rename(@config_file, new_name)
76
+ Log.log.warn{"Renamed config file to #{new_name}."}
77
+ Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
78
+ end
79
+ raise Cli::Error, e.to_s
80
+ end
81
+
82
+ # Save to disk only if content changed since last load/save.
83
+ # @return [Boolean] true if actually written
84
+ def save_if_needed
85
+ raise Cli::Error, 'no configuration loaded' if @config_presets.nil?
86
+ current = checksum
87
+ return false if @checksum_on_disk.eql?(current)
88
+ FileUtils.mkdir_p(File.dirname(@config_file))
89
+ 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 }
92
+ @checksum_on_disk = current
93
+ true
94
+ end
95
+
96
+ # ------------------------------------------------------------------
97
+ # Preset read/write helpers
98
+ # ------------------------------------------------------------------
99
+
100
+ # @return [String, nil] name of the default preset for a plugin, or nil
101
+ def plugin_default_name(plugin_name_sym)
102
+ 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
+ default_name = @config_presets[Key::DEFAULTS][plugin_name_sym.to_s]
108
+ unless @config_presets.key?(default_name)
109
+ Log.log.error do
110
+ "Default config name [#{default_name}] specified for plugin [#{plugin_name_sym}], but it does not exist in config file.\n" \
111
+ "Please fix: either create preset:\n" \
112
+ "#{Info::CMD_NAME} config id #{default_name} init @json:'{}'\n" \
113
+ "or remove default:\n#{Info::CMD_NAME} config id default remove #{plugin_name_sym}"
114
+ end
115
+ raise Cli::Error, "No such preset: #{default_name}"
116
+ end
117
+ Aspera.assert_type(@config_presets[default_name], Hash, type: Cli::Error){'preset type'}
118
+ default_name
119
+ end
120
+
121
+ # Resolve and return a deep-cloned, extended copy of a named preset.
122
+ # Supports dot-notation (e.g. "outer.inner").
123
+ # @param config_name [String]
124
+ # @param include_path [Array] guard against include loops
125
+ def by_name(config_name, include_path = [])
126
+ raise Cli::Error, 'loop in include' if include_path.include?(config_name)
127
+ include_path = include_path.clone
128
+ current = @config_presets
129
+ config_name.split(PRESET_DIG_SEPARATOR).each do |name|
130
+ Aspera.assert_type(current, Hash, type: Cli::Error){"sub key: #{include_path}"}
131
+ include_path.push(name)
132
+ current = current[name]
133
+ raise Cli::Error, "Unknown config preset: #{include_path}" if current.nil?
134
+ end
135
+ current = self.class.deep_clone(current) unless current.is_a?(String)
136
+ ExtendedValue.instance.evaluate(current, context: 'preset')
137
+ end
138
+
139
+ # Set a single key in a preset hash (creates the preset if absent).
140
+ def set_key(preset, param_name, param_value)
141
+ Aspera.assert_type(param_name, String, Symbol){'parameter'}
142
+ param_name = param_name.to_s
143
+ selected = @config_presets[preset]
144
+ if selected.nil?
145
+ Log.log.debug{"Unknown preset name: #{preset}, initializing"}
146
+ selected = @config_presets[preset] = {}
147
+ 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
153
+ end
154
+ Log.log.warn{"overwriting value for #{param_name}: #{selected[param_name]}"}
155
+ end
156
+ selected[param_name] = param_value
157
+ Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
158
+ nil
159
+ end
160
+
161
+ # @return [String] name of the global default preset, creating it if needed
162
+ def global_default_preset
163
+ result = plugin_default_name(CONF_GLOBAL_SYM)
164
+ if result.nil?
165
+ result = Key::GLOBAL
166
+ set_key(Key::DEFAULTS, CONF_GLOBAL_SYM, result)
167
+ end
168
+ result
169
+ end
170
+
171
+ # Set param in the global defaults preset
172
+ def set_global_default(key, value)
173
+ set_key(global_default_preset, key, value)
174
+ end
175
+
176
+ # Create / overwrite a preset and optionally set it as plugin default.
177
+ def defaults_set(plugin_name, preset_name, preset_values, option_default, option_override)
178
+ @config_presets[Key::DEFAULTS] ||= {}
179
+ raise Cli::Error, "A default configuration already exists for plugin '#{plugin_name}' (use --override=yes or --default=no)" \
180
+ if !option_override && option_default && @config_presets[Key::DEFAULTS].key?(plugin_name)
181
+ raise Cli::Error, "Preset already exists: #{preset_name} (use --override=yes or provide alternate name on command line)" \
182
+ if !option_override && @config_presets.key?(preset_name)
183
+ if option_default
184
+ Log.log.info("Setting config preset as default for #{plugin_name}")
185
+ @config_presets[Key::DEFAULTS][plugin_name.to_s] = preset_name
186
+ end
187
+ @config_presets[preset_name] = preset_values
188
+ end
189
+
190
+ # Find the first preset whose url+username match
191
+ # @return [Hash, nil]
192
+ def lookup_preset(url:, username:)
193
+ url = canonical_url(url)
194
+ Log.log.debug{"Lookup preset for #{username}@#{url}"}
195
+ @config_presets.each_value do |v|
196
+ next unless v.is_a?(Hash)
197
+ conf_url = v['url'].is_a?(String) ? canonical_url(v['url']) : nil
198
+ return self.class.deep_clone(v) if conf_url.eql?(url) && v['username'].eql?(username)
199
+ end
200
+ nil
201
+ end
202
+
203
+ # ------------------------------------------------------------------
204
+ # Class helpers
205
+ # ------------------------------------------------------------------
206
+
207
+ class << self
208
+ def deep_clone(val)
209
+ Marshal.load(Marshal.dump(val))
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ # Normalise a URL for comparison
216
+ def canonical_url(url)
217
+ url.chomp('/').sub(%r{^(https://[^/]+):443$}, '\1')
218
+ end
219
+
220
+ # Structural validation + compatibility fixes on @config_presets
221
+ def validate_config_presets!
222
+ 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}"}
225
+ 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}"}
228
+ # Fix bug in 4.4 (creating key "true" in "default" preset)
229
+ @config_presets[Key::DEFAULTS].delete(true) if @config_presets[Key::DEFAULTS].is_a?(Hash)
230
+ # Stamp with current version
231
+ @config_presets[Key::CONFIG][Key::VERSION] = Cli::VERSION
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,310 @@
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 [void]
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
+ # try base64
76
+ data = begin
77
+ Base64.strict_decode64(data)
78
+ rescue
79
+ data
80
+ 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
+ else
84
+ Aspera.error_unexpected_value(formatter.format_type){'format'}
85
+ end
86
+ end
87
+ end
88
+
89
+ # Special result types - each has its own class
90
+ class Result
91
+ # Base class for special results
92
+ # The type is automatically derived from the class name
93
+ class Special < Result
94
+ def initialize
95
+ # Convert class name to symbol: Empty -> :empty, Nothing -> :nothing
96
+ type = self.class.name.split('::').last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
97
+ super(data: type)
98
+ end
99
+
100
+ def format(formatter)
101
+ case formatter.format_type
102
+ when :text, :nagios, :ruby, :json, :jsonpp, :yaml
103
+ formatter.display_message(:data, @data.to_s)
104
+ when :table, :csv
105
+ if @data.eql?(:nothing)
106
+ Log.log.debug('no result expected')
107
+ return
108
+ end
109
+ formatter.display_message(:info, formatter.special_format(@data.to_s))
110
+ else
111
+ super
112
+ end
113
+ end
114
+ end
115
+
116
+ # Empty list result
117
+ class Empty < Special
118
+ end
119
+
120
+ # Nothing expected result
121
+ class Nothing < Special
122
+ end
123
+
124
+ # Null result
125
+ class Null < Special
126
+ end
127
+
128
+ # Status result (success, complete, etc.)
129
+ class Status < Result
130
+ def initialize(data)
131
+ Aspera.assert_type(data, String){'status result data'}
132
+ super(data: data)
133
+ end
134
+
135
+ def format(formatter)
136
+ data = formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
137
+ case formatter.format_type
138
+ when :text, :nagios, :ruby, :json, :jsonpp, :yaml
139
+ formatter.display_message(:data, data)
140
+ when :table, :csv
141
+ formatter.display_message(:info, data)
142
+ else
143
+ super
144
+ end
145
+ end
146
+ end
147
+
148
+ # Success status result
149
+ class Success < Status
150
+ def initialize
151
+ super('complete')
152
+ end
153
+ end
154
+
155
+ # Text result
156
+ class Text < Result
157
+ def initialize(data)
158
+ Aspera.assert_type(data, String, Integer, Symbol, type: ArgumentError){'text result data'}
159
+ super(data: data)
160
+ end
161
+
162
+ def format(formatter)
163
+ data = @data.is_a?(String) && formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
164
+ case formatter.format_type
165
+ when :text, :nagios
166
+ formatter.display_message(:data, data.to_s)
167
+ when :ruby
168
+ formatter.display_message(:data, PP.pp(data, +''))
169
+ when :json
170
+ formatter.display_message(:data, JSON.generate(data))
171
+ when :jsonpp
172
+ formatter.display_message(:data, JSON.pretty_generate(data))
173
+ when :yaml
174
+ formatter.display_message(:data, YAML.dump(data))
175
+ when :table, :csv
176
+ formatter.display_message(:data, data)
177
+ else
178
+ super
179
+ end
180
+ end
181
+ end
182
+
183
+ # Image result (URL or blob)
184
+ class Image < Result
185
+ def initialize(data)
186
+ Aspera.assert_type(data, String, NilClass){'image result data'}
187
+ super(data: data)
188
+ end
189
+
190
+ def format(formatter)
191
+ # Force image format for Image results
192
+ formatter.set_format_type(:image)
193
+ super
194
+ end
195
+ end
196
+
197
+ # Single object result (Hash)
198
+ class SingleObject < Result
199
+ def initialize(data, fields: nil)
200
+ Aspera.assert_type(data, Hash){'single object result data'}
201
+ super(data: data, fields: fields)
202
+ end
203
+
204
+ def format(formatter)
205
+ case formatter.format_type
206
+ when :image
207
+ # Extract single field for image display
208
+ data_array = [@data]
209
+ fields = formatter.compute_fields(data_array, @fields)
210
+ Aspera.assert(fields.length == 1, 'select a single field to display', type: Cli::BadArgument)
211
+ Aspera.assert(@data.key?(fields.first), 'no such field', type: Cli::BadArgument)
212
+ # Create an Image result and format it
213
+ Image.new(@data[fields.first]).format(formatter)
214
+ when :table, :csv
215
+ Aspera.assert_type(@data, Hash){'result'}
216
+ if @data.empty?
217
+ formatter.display_message(:data, formatter.special_format('empty dict'))
218
+ else
219
+ data = formatter.flat_hash? ? DotContainer.new(@data).to_dotted : @data
220
+ formatter.display_table([data], formatter.compute_fields([data], @fields), single: true)
221
+ end
222
+ else
223
+ super
224
+ end
225
+ end
226
+ end
227
+
228
+ # Object list result (Array of Hash)
229
+ # @note The +total+ parameter is used to display pagination information (e.g., "Items: 10/100")
230
+ class ObjectList < Result
231
+ attr_reader :total
232
+
233
+ # @param data [Array<Hash>] Array of hash objects to display
234
+ # @param fields [Array<String>, Proc, nil] Fields to display in table/csv format
235
+ # @param total [Integer, nil] Total number of items available (for pagination display)
236
+ 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'}
240
+ super(data: data, fields: fields)
241
+ @total = total
242
+ end
243
+
244
+ def format(formatter)
245
+ formatter.display_item_count(@data.length, @total)
246
+ case formatter.format_type
247
+ when :image
248
+ # Extract single field for image display
249
+ Aspera.assert(@data.length == 1, 'image display requires a single result', type: Cli::BadArgument)
250
+ SingleObject.new(@data.first).format(formatter)
251
+ 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
254
+ formatter.display_table(data, formatter.compute_fields(data, @fields), single: false)
255
+ else
256
+ super
257
+ end
258
+ end
259
+ end
260
+
261
+ # Value list result (Array of values with a name)
262
+ class ValueList < Result
263
+ attr_reader :name
264
+
265
+ def initialize(data, name: 'id')
266
+ Aspera.assert_type(data, Array){'value list result data'}
267
+ Aspera.assert_type(name, String){'value list name'}
268
+ super(data: data)
269
+ @name = name
270
+ end
271
+
272
+ def format(formatter)
273
+ case formatter.format_type
274
+ when :table, :csv
275
+ formatter.display_table(@data.map{ |i| {@name => i}}, [@name])
276
+ else
277
+ super
278
+ end
279
+ end
280
+ end
281
+
282
+ class << self
283
+ # Class method to automatically determine result type from data
284
+ # @param data [Object] the data to analyze and format
285
+ # @return [Result]
286
+ def auto(data)
287
+ case data
288
+ when NilClass
289
+ Null.new
290
+ when Hash
291
+ SingleObject.new(data)
292
+ when Array
293
+ all_types = data.map(&:class).uniq
294
+ return ObjectList.new(data) if all_types.eql?([Hash])
295
+
296
+ scalar_types = [String, Integer, Symbol]
297
+ unsupported_types = all_types - scalar_types
298
+ return ValueList.new(data, name: 'list') if unsupported_types.empty?
299
+
300
+ Aspera.error_unexpected_value(unsupported_types){'list item types'}
301
+ when String, Integer, Symbol
302
+ Text.new(data)
303
+ else
304
+ Aspera.error_unexpected_value(data.class.name){'result type'}
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end