aspera-cli 4.27.0 → 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 (140) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +29 -0
  4. data/CONTRIBUTING.md +1 -7
  5. data/TODO.md +50 -0
  6. data/docs/README.md +708 -488
  7. data/lib/aspera/agent/base.rb +10 -2
  8. data/lib/aspera/agent/connect.rb +7 -7
  9. data/lib/aspera/agent/desktop.rb +3 -3
  10. data/lib/aspera/agent/direct.rb +48 -19
  11. data/lib/aspera/agent/factory.rb +3 -3
  12. data/lib/aspera/agent/httpgw.rb +55 -14
  13. data/lib/aspera/agent/node.rb +2 -2
  14. data/lib/aspera/agent/transferd.rb +12 -12
  15. data/lib/aspera/api/aoc.rb +28 -28
  16. data/lib/aspera/api/cos_node.rb +10 -9
  17. data/lib/aspera/api/faspex.rb +2 -2
  18. data/lib/aspera/api/httpgw.rb +20 -20
  19. data/lib/aspera/api/node.rb +31 -53
  20. data/lib/aspera/ascmd.rb +13 -13
  21. data/lib/aspera/ascp/installation.rb +43 -36
  22. data/lib/aspera/ascp/management.rb +6 -6
  23. data/lib/aspera/assert.rb +12 -13
  24. data/lib/aspera/cli/ascp_actions.rb +7 -7
  25. data/lib/aspera/cli/async_transfer_store.rb +49 -5
  26. data/lib/aspera/cli/bootstrapper.rb +9 -9
  27. data/lib/aspera/cli/command_registry.rb +1 -1
  28. data/lib/aspera/cli/command_spec.rb +6 -3
  29. data/lib/aspera/cli/context.rb +3 -3
  30. data/lib/aspera/cli/extended_value.rb +26 -26
  31. data/lib/aspera/cli/formatter.rb +76 -54
  32. data/lib/aspera/cli/gem_checker.rb +1 -1
  33. data/lib/aspera/cli/hints.rb +2 -2
  34. data/lib/aspera/cli/http.rb +22 -29
  35. data/lib/aspera/cli/mailer.rb +7 -7
  36. data/lib/aspera/cli/mcp_tool.rb +31 -4
  37. data/lib/aspera/cli/option_declarator.rb +77 -0
  38. data/lib/aspera/cli/options.schema.yaml +273 -3
  39. data/lib/aspera/cli/parser.rb +539 -280
  40. data/lib/aspera/cli/plugins/alee.rb +1 -1
  41. data/lib/aspera/cli/plugins/aoc.rb +297 -313
  42. data/lib/aspera/cli/plugins/ats.rb +25 -21
  43. data/lib/aspera/cli/plugins/base.rb +357 -186
  44. data/lib/aspera/cli/plugins/basic_auth.rb +0 -12
  45. data/lib/aspera/cli/plugins/config.rb +76 -73
  46. data/lib/aspera/cli/plugins/console.rb +8 -8
  47. data/lib/aspera/cli/plugins/cos.rb +4 -3
  48. data/lib/aspera/cli/plugins/factory.rb +2 -2
  49. data/lib/aspera/cli/plugins/faspex5.rb +259 -248
  50. data/lib/aspera/cli/plugins/faspio.rb +7 -2
  51. data/lib/aspera/cli/plugins/httpgw.rb +1 -1
  52. data/lib/aspera/cli/plugins/mcp.rb +11 -11
  53. data/lib/aspera/cli/plugins/node.rb +227 -283
  54. data/lib/aspera/cli/plugins/orchestrator.rb +23 -23
  55. data/lib/aspera/cli/plugins/preview.rb +61 -64
  56. data/lib/aspera/cli/plugins/server.rb +36 -31
  57. data/lib/aspera/cli/plugins/shares.rb +174 -135
  58. data/lib/aspera/cli/preset_actions.rb +39 -19
  59. data/lib/aspera/cli/preset_manager.rb +74 -32
  60. data/lib/aspera/cli/result.rb +18 -16
  61. data/lib/aspera/cli/runner.rb +75 -39
  62. data/lib/aspera/cli/secret_finder.rb +1 -1
  63. data/lib/aspera/cli/sync_actions.rb +36 -35
  64. data/lib/aspera/cli/terminal_formatter.rb +2 -2
  65. data/lib/aspera/cli/transfer_actions.rb +17 -10
  66. data/lib/aspera/cli/transfer_agent.rb +44 -44
  67. data/lib/aspera/cli/transfer_progress.rb +7 -7
  68. data/lib/aspera/cli/vault_manager.rb +34 -12
  69. data/lib/aspera/cli/version.rb +1 -1
  70. data/lib/aspera/cli/wizard.rb +17 -20
  71. data/lib/aspera/colors.rb +3 -3
  72. data/lib/aspera/command_line_builder.rb +16 -16
  73. data/lib/aspera/command_line_converter.rb +1 -1
  74. data/lib/aspera/coverage.rb +1 -2
  75. data/lib/aspera/data_repository.rb +1 -1
  76. data/lib/aspera/dot_container.rb +8 -8
  77. data/lib/aspera/environment.rb +12 -12
  78. data/lib/aspera/faspex_gw.rb +4 -4
  79. data/lib/aspera/faspex_postproc.rb +14 -9
  80. data/lib/aspera/graphql.rb +4 -2
  81. data/lib/aspera/hash_ext.rb +2 -2
  82. data/lib/aspera/json_rpc/client.rb +1 -1
  83. data/lib/aspera/keychain/base.rb +10 -3
  84. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  85. data/lib/aspera/keychain/factory.rb +17 -5
  86. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  87. data/lib/aspera/keychain/macos_security.rb +23 -25
  88. data/lib/aspera/keychain/one_password_api.rb +86 -0
  89. data/lib/aspera/keychain/one_password_base.rb +34 -0
  90. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  91. data/lib/aspera/link_header.rb +2 -2
  92. data/lib/aspera/log.rb +13 -13
  93. data/lib/aspera/markdown.rb +5 -5
  94. data/lib/aspera/nagios.rb +8 -8
  95. data/lib/aspera/node_simulator.rb +33 -27
  96. data/lib/aspera/oauth/base.rb +4 -5
  97. data/lib/aspera/oauth/boot.rb +3 -3
  98. data/lib/aspera/oauth/factory.rb +5 -5
  99. data/lib/aspera/oauth/jwt.rb +5 -7
  100. data/lib/aspera/oauth/web.rb +4 -4
  101. data/lib/aspera/persistency_action_once.rb +4 -4
  102. data/lib/aspera/persistency_folder.rb +8 -8
  103. data/lib/aspera/preview/file_types.rb +4 -4
  104. data/lib/aspera/preview/generator.rb +49 -46
  105. data/lib/aspera/preview/options.rb +3 -2
  106. data/lib/aspera/preview/terminal.rb +5 -5
  107. data/lib/aspera/preview/utils.rb +65 -18
  108. data/lib/aspera/products/connect.rb +2 -2
  109. data/lib/aspera/products/desktop.rb +1 -1
  110. data/lib/aspera/products/other.rb +2 -2
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -10
  113. data/lib/aspera/rest.rb +149 -115
  114. data/lib/aspera/rest_error_analyzer.rb +3 -3
  115. data/lib/aspera/rest_list.rb +10 -9
  116. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  117. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  118. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  119. data/lib/aspera/schema/documentation.rb +17 -11
  120. data/lib/aspera/schema/reader.rb +78 -4
  121. data/lib/aspera/schema/registry.rb +26 -7
  122. data/lib/aspera/secret_hider.rb +5 -5
  123. data/lib/aspera/ssh.rb +64 -31
  124. data/lib/aspera/ssl.rb +3 -3
  125. data/lib/aspera/sync/database.rb +2 -2
  126. data/lib/aspera/sync/operations.rb +13 -13
  127. data/lib/aspera/temp_file_manager.rb +24 -4
  128. data/lib/aspera/transfer/faux_file.rb +2 -2
  129. data/lib/aspera/transfer/parameters.rb +15 -15
  130. data/lib/aspera/transfer/resumer.rb +9 -9
  131. data/lib/aspera/transfer/spec.rb +1 -1
  132. data/lib/aspera/uri_reader.rb +52 -15
  133. data/lib/aspera/web_auth.rb +6 -5
  134. data/lib/aspera/web_server_simple.rb +9 -9
  135. data/lib/aspera/yaml.rb +4 -4
  136. data.tar.gz.sig +0 -0
  137. metadata +10 -4
  138. metadata.gz.sig +0 -0
  139. data/lib/aspera/cli/plugins/faspex.rb +0 -576
  140. data/lib/aspera/transfer/uri.rb +0 -56
@@ -1,6 +1,7 @@
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'
@@ -17,10 +18,12 @@ module Aspera
17
18
  class PresetManager
18
19
  # Reserved keys in the YAML config file
19
20
  module Key
20
- CONFIG = 'config'
21
- VERSION = 'version'
22
- DEFAULTS = 'default'
23
- 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 = '_'
24
27
  end
25
28
 
26
29
  GLOBAL_DEFAULT_KEYWORD = 'GLOBAL'
@@ -55,13 +58,13 @@ module Aspera
55
58
  # Read and validate the YAML config file.
56
59
  # Sets @config_presets and @checksum_on_disk.
57
60
  def read_config_file
58
- Log.log.debug{"config file is: #{@config_file}".red}
61
+ Log.log.debug { "config file is: #{@config_file}".red }
59
62
  if File.exist?(@config_file)
60
- Log.log.debug{"loading #{@config_file}"}
63
+ Log.log.debug { "loading #{@config_file}" }
61
64
  @config_presets = Yaml.safe_load(File.read(@config_file))
62
65
  @checksum_on_disk = checksum
63
66
  else
64
- 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}" }
65
68
  @config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
66
69
  # @checksum_on_disk remains nil: will be saved on first write
67
70
  end
@@ -70,11 +73,11 @@ module Aspera
70
73
  Log.log.error('YAML error in config file')
71
74
  raise e
72
75
  rescue StandardError => e
73
- Log.log.debug{"-> #{e.class.name} : #{e}"}
76
+ Log.log.debug { "-> #{e.class.name} : #{e}" }
74
77
  if File.exist?(@config_file)
75
78
  new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
76
79
  File.rename(@config_file, new_name)
77
- Log.log.warn{"Renamed config file to #{new_name}."}
80
+ Log.log.warn { "Renamed config file to #{new_name}." }
78
81
  Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
79
82
  end
80
83
  raise Cli::Error, e.to_s
@@ -83,13 +86,13 @@ module Aspera
83
86
  # Save to disk only if content changed since last load/save.
84
87
  # @return [Boolean] true if actually written
85
88
  def save_if_needed
86
- Aspera.assert(!@config_presets.nil?, type: Cli::Error){'no configuration loaded'}
89
+ Aspera.assert(!@config_presets.nil?, type: Cli::Error) { 'no configuration loaded' }
87
90
  current = checksum
88
91
  return false if @checksum_on_disk.eql?(current)
89
92
  FileUtils.mkdir_p(File.dirname(@config_file))
90
93
  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}
94
+ Log.log.info { "Saving config file: #{@config_file}" }
95
+ Environment.write_file_restricted(@config_file, force: true) { @config_presets.to_yaml }
93
96
  @checksum_on_disk = current
94
97
  true
95
98
  end
@@ -103,7 +106,7 @@ module Aspera
103
106
  Aspera.assert(!@config_presets.nil?, 'config_presets shall be defined')
104
107
  return unless @use_plugin_defaults
105
108
  return unless @config_presets.key?(Key::DEFAULTS)
106
- Aspera.assert_type(@config_presets[Key::DEFAULTS], Hash){'default section'}
109
+ Aspera.assert_type(@config_presets[Key::DEFAULTS], Hash) { 'default section' }
107
110
  return unless @config_presets[Key::DEFAULTS].key?(plugin_name_sym.to_s)
108
111
  default_name = @config_presets[Key::DEFAULTS][plugin_name_sym.to_s]
109
112
  unless @config_presets.key?(default_name)
@@ -115,7 +118,7 @@ module Aspera
115
118
  end
116
119
  raise Cli::Error, "No such preset: #{default_name}"
117
120
  end
118
- 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' }
119
122
  default_name
120
123
  end
121
124
 
@@ -124,38 +127,62 @@ module Aspera
124
127
  # @param config_name [String]
125
128
  # @param include_path [Array] guard against include loops
126
129
  def by_name(config_name, include_path = [])
127
- Aspera.assert(!include_path.include?(config_name), type: Cli::Error){'loop in include'}
130
+ Aspera.assert(!include_path.include?(config_name), type: Cli::Error) { 'loop in include' }
128
131
  include_path = include_path.clone
129
132
  current = @config_presets
130
133
  config_name.split(PRESET_DIG_SEPARATOR).each do |name|
131
- 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}" }
132
135
  include_path.push(name)
133
136
  current = current[name]
134
- Aspera.assert(!current.nil?, type: Cli::Error){"Unknown config preset: #{include_path}"}
137
+ Aspera.assert(!current.nil?, type: Cli::Error) { "Unknown config preset: #{include_path}" }
135
138
  end
136
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)
137
141
  ExtendedValue.instance.evaluate(current, context: 'preset')
138
142
  end
139
143
 
140
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.
141
147
  def set_key(preset, param_name, param_value)
142
- Aspera.assert_type(param_name, String, Symbol){'parameter'}
148
+ Aspera.assert_type(param_name, String, Symbol) { 'parameter' }
143
149
  param_name = param_name.to_s
144
150
  selected = @config_presets[preset]
145
151
  if selected.nil?
146
- Log.log.debug{"Unknown preset name: #{preset}, initializing"}
152
+ Log.log.debug { "Unknown preset name: #{preset}, initializing" }
147
153
  selected = @config_presets[preset] = {}
148
154
  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
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]
154
162
  end
155
- 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}")
156
185
  end
157
- selected[param_name] = param_value
158
- Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
159
186
  nil
160
187
  end
161
188
 
@@ -192,7 +219,7 @@ module Aspera
192
219
  # @return [Hash, nil]
193
220
  def lookup_preset(url:, username:)
194
221
  url = canonical_url(url)
195
- Log.log.debug{"Lookup preset for #{username}@#{url}"}
222
+ Log.log.debug { "Lookup preset for #{username}@#{url}" }
196
223
  @config_presets.each_value do |v|
197
224
  next unless v.is_a?(Hash)
198
225
  conf_url = v['url'].is_a?(String) ? canonical_url(v['url']) : nil
@@ -209,6 +236,21 @@ module Aspera
209
236
  def deep_clone(val)
210
237
  Marshal.load(Marshal.dump(val))
211
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
212
254
  end
213
255
 
214
256
  private
@@ -221,11 +263,11 @@ module Aspera
221
263
  # Structural validation + compatibility fixes on @config_presets
222
264
  def validate_config_presets!
223
265
  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}"}
266
+ Aspera.assert_type(@config_presets, Hash) { 'config file YAML' }
267
+ Aspera.assert(@config_presets.key?(Key::CONFIG)) { "Cannot find key: #{Key::CONFIG}" }
226
268
  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}"}
269
+ Aspera.assert(!version.nil?, type: Cli::Error) { 'No version found in config section.' }
270
+ Log.log.debug { "conf version: #{version}" }
229
271
  # Fix bug in 4.4 (creating key "true" in "default" preset)
230
272
  @config_presets[Key::DEFAULTS].delete(true) if @config_presets[Key::DEFAULTS].is_a?(Hash)
231
273
  # Stamp with current version
@@ -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
@@ -83,7 +83,7 @@ module Aspera
83
83
  formatter.display_message(:data, Preview::Terminal.build(data, **formatter.image_options))
84
84
  end
85
85
  else
86
- Aspera.error_unexpected_value(formatter.format_type){'format'}
86
+ Aspera.error_unexpected_value(formatter.format_type) { 'format' }
87
87
  end
88
88
  end
89
89
  end
@@ -130,7 +130,7 @@ module Aspera
130
130
  # Status result (success, complete, etc.)
131
131
  class Status < Result
132
132
  def initialize(data)
133
- Aspera.assert_type(data, String){'status result data'}
133
+ Aspera.assert_type(data, String) { 'status result data' }
134
134
  super(data: data)
135
135
  end
136
136
 
@@ -157,7 +157,7 @@ module Aspera
157
157
  # Text result
158
158
  class Text < Result
159
159
  def initialize(data)
160
- 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' }
161
161
  super(data: data)
162
162
  end
163
163
 
@@ -185,7 +185,7 @@ module Aspera
185
185
  # Image result (URL or blob)
186
186
  class Image < Result
187
187
  def initialize(data)
188
- Aspera.assert_type(data, String, NilClass){'image result data'}
188
+ Aspera.assert_type(data, String, NilClass) { 'image result data' }
189
189
  super(data: data)
190
190
  end
191
191
 
@@ -199,7 +199,7 @@ module Aspera
199
199
  # Single object result (Hash)
200
200
  class SingleObject < Result
201
201
  def initialize(data, fields: nil)
202
- Aspera.assert_type(data, Hash){'single object result data'}
202
+ Aspera.assert_type(data, Hash) { 'single object result data' }
203
203
  super(data: data, fields: fields)
204
204
  end
205
205
 
@@ -214,7 +214,7 @@ module Aspera
214
214
  # Create an Image result and format it
215
215
  Image.new(@data[fields.first]).format(formatter)
216
216
  when :table, :csv
217
- Aspera.assert_type(@data, Hash){'result'}
217
+ Aspera.assert_type(@data, Hash) { 'result' }
218
218
  if @data.empty?
219
219
  formatter.display_message(:data, formatter.special_format('empty dict'))
220
220
  else
@@ -236,8 +236,8 @@ module Aspera
236
236
  # @param fields [Array<String>, Proc, nil] Fields to display in table/csv format
237
237
  # @param total [Integer, nil] Total number of items available (for pagination display)
238
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'}
239
+ Aspera.assert_array_all(data, Hash, type: ArgumentError) { 'object list result data' }
240
+ Aspera.assert_type(total, Integer, NilClass) { 'total' }
241
241
  super(data: data, fields: fields)
242
242
  @total = total
243
243
  end
@@ -250,8 +250,8 @@ module Aspera
250
250
  Aspera.assert(@data.length == 1, 'image display requires a single result', type: Cli::BadArgument)
251
251
  SingleObject.new(@data.first).format(formatter)
252
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
253
+ Aspera.assert_array_all(@data, Hash) { 'result' }
254
+ data = formatter.flat_hash? ? @data.map { |obj| DotContainer.new(obj).to_dotted } : @data
255
255
  formatter.display_table(data, formatter.compute_fields(data, @fields), single: false)
256
256
  else
257
257
  super
@@ -264,8 +264,8 @@ module Aspera
264
264
  attr_reader :name
265
265
 
266
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'}
267
+ Aspera.assert_type(data, Array) { 'value list result data' }
268
+ Aspera.assert_type(name, String) { 'value list name' }
269
269
  super(data: data)
270
270
  @name = name
271
271
  end
@@ -273,7 +273,7 @@ module Aspera
273
273
  def format(formatter)
274
274
  case formatter.format_type
275
275
  when :table, :csv
276
- formatter.display_table(@data.map{ |i| {@name => i}}, [@name])
276
+ formatter.display_table(@data.map { |i| {@name => i} }, [@name])
277
277
  when :text
278
278
  formatter.display_message(:data, @data.join("\n"))
279
279
  when :ruby
@@ -314,6 +314,8 @@ module Aspera
314
314
  result = res if res.is_a?(Hash)
315
315
  # TODO: remove when faspio gw api fixes this
316
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)
317
319
  result['status'] = "#{command}#{'e' unless command.to_s.end_with?('e')}d".gsub(/yed$/, 'ied')
318
320
  rescue StandardError => e
319
321
  raise e if bfail
@@ -347,11 +349,11 @@ module Aspera
347
349
  unsupported_types = all_types - scalar_types
348
350
  return ValueList.new(data, name: 'list') if unsupported_types.empty?
349
351
 
350
- Aspera.error_unexpected_value(unsupported_types){'list item types'}
352
+ Aspera.error_unexpected_value(unsupported_types) { 'list item types' }
351
353
  when String, Integer, Symbol
352
354
  Text.new(data)
353
355
  else
354
- Aspera.error_unexpected_value(data.class.name){'result type'}
356
+ Aspera.error_unexpected_value(data.class.name) { 'result type' }
355
357
  end
356
358
  end
357
359
  end
@@ -126,18 +126,18 @@ module Aspera
126
126
  lock_port = @context.options.get_option(:lock_port)
127
127
  if !lock_port.nil?
128
128
  begin
129
- Log.log.debug{"Opening lock port #{lock_port}"}
129
+ Log.log.debug { "Opening lock port #{lock_port}" }
130
130
  @tcp_server = TCPServer.new('127.0.0.1', lock_port)
131
131
  rescue StandardError => e
132
132
  execute_command = false
133
- Log.log.warn{"Another instance is already running (#{e.message})."}
133
+ Log.log.warn { "Another instance is already running (#{e.message})." }
134
134
  end
135
135
  end
136
136
  pid_file = @context.options.get_option(:pid_file)
137
137
  if !pid_file.nil?
138
138
  File.write(pid_file, Process.pid)
139
- Log.log.debug{"Wrote pid #{Process.pid} to #{pid_file}"}
140
- at_exit{File.delete(pid_file)}
139
+ Log.log.debug { "Wrote pid #{Process.pid} to #{pid_file}" }
140
+ at_exit { File.delete(pid_file) }
141
141
  end
142
142
  begin
143
143
  result = command_plugin.execute_action if execute_command
@@ -174,25 +174,33 @@ module Aspera
174
174
  # 1- processing of error condition
175
175
  unless exception_info.nil?
176
176
  Log.log.warn(exception_info[:e].message) if Log.instance.logger_type.eql?(:syslog) && exception_info[:security]
177
- Log.log.error{"#{exception_info[:t]}: #{exception_info[:e].message}"} unless exception_info[:e].is_a?(Cli::SchemaRequest)
178
- Log.log.debug{(['Backtrace:'] + exception_info[:e].backtrace).join("\n")} if exception_info[:debug]
177
+ Log.log.error { "#{exception_info[:t]}: #{exception_info[:e].message}" } unless exception_info[:e].is_a?(Cli::SchemaRequest)
178
+ Log.log.debug { (['Backtrace:'] + exception_info[:e].backtrace).join("\n") } if exception_info[:debug]
179
179
  @context.formatter.display_message(:error, 'Use option -h to get help.') if exception_info[:usage]
180
180
  Hints.hint_for(exception_info[:e], @context.formatter)
181
181
  if exception_info[:e].is_a?(Cli::SchemaRequest)
182
- Log.log.info{"#{exception_info[:t]}: #{exception_info[:e].message}"}
182
+ Log.log.info { "#{exception_info[:t]}: #{exception_info[:e].message}" }
183
183
  schema_path = exception_info[:e].path
184
184
  if schema_path.nil?
185
- Log.log.warn{'Sorry, no schema provided yet. Please refer to the manual or API.'}
185
+ Log.log.warn { 'Sorry, no schema provided yet. Please refer to the manual or API.' }
186
186
  else
187
- builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(schema_path)).build
188
- @context.formatter.display_results(Result::ObjectList.new(builder.rows, fields: builder.columns))
187
+ reader = Schema::Registry.instance.reader(schema_path)
188
+ rows = reader.to_rows.map do |row|
189
+ if row.key?('enum')
190
+ row = row.dup
191
+ row['type'] = 'enum'
192
+ row['description'] = "#{row['description']}\nAllowed: #{row['enum'].join(', ')}"
193
+ end
194
+ row
195
+ end
196
+ @context.formatter.display_results(Result::ObjectList.new(rows, fields: %w[name type required description]))
189
197
  end
190
198
  end
191
199
  end
192
200
  # 2- processing of unprocessed arguments (skip when help was displayed: sub-commands are not consumed)
193
201
  unless @option_help
194
202
  @context.options&.final_errors&.each do |msg|
195
- Log.log.error{"Argument: #{msg}"}
203
+ Log.log.error { "Argument: #{msg}" }
196
204
  exception_info = {e: Exception.new(msg), t: 'UnusedArg'} if exception_info.nil?
197
205
  end
198
206
  end
@@ -236,7 +244,7 @@ module Aspera
236
244
  when :type then Log.instance.logger_type = v.to_sym
237
245
  when :format then Log.instance.formatter = v
238
246
  when :secrets then SecretHider.instance.log_secrets = BoolValue.true?(v)
239
- else Aspera.error_unexpected_value(k){'log sub-option (level, type, format, secrets)'}
247
+ else Aspera.error_unexpected_value(k) { 'log sub-option (level, type, format, secrets)' }
240
248
  end
241
249
  end
242
250
  when :get
@@ -259,9 +267,9 @@ module Aspera
259
267
  lines = [@context.options.help_text(banner: app_banner)]
260
268
  if plugin.nil?
261
269
  # Top-level: list all available plugins
262
- plugin_names = Plugins::Factory.instance.plugin_list.reject{ |s| s.eql?(COMMAND_CONFIG)}.sort
270
+ plugin_names = Plugins::Factory.instance.plugin_list.reject { |s| s.eql?(COMMAND_CONFIG) }.sort
263
271
  lines << "\nPLUGINS"
264
- col_w = plugin_names.map{ |n| n.to_s.length}.max + 2
272
+ col_w = plugin_names.map { |n| n.to_s.length }.max + 2
265
273
  plugin_names.each do |name|
266
274
  app = Plugins::Factory.instance.plugin_class(name).application_name
267
275
  lines << " #{name.to_s.ljust(col_w)} #{app}"
@@ -283,29 +291,42 @@ module Aspera
283
291
  end
284
292
  end
285
293
  end
294
+ if cmds.none?
295
+ leaf_spec = registry[path]
296
+ leaf_spec&.arguments&.each do |arg_spec|
297
+ argument = arg_spec.mandatory ? "<#{arg_spec.name}>" : "[#{arg_spec.name}]"
298
+ argument += '...' if arg_spec.multiple
299
+ label += " #{argument}"
300
+ end
301
+ end
286
302
  if cmds.any?
287
- # Intermediate node: list subcommands
288
- lines << "\nCOMMANDS: #{label}"
289
- col_w = cmds.keys.map{ |k| k.to_s.length}.max + 2
303
+ # Intermediate node: show current command + description, then list subcommands
304
+ lines << "\nCOMMAND: #{label}"
305
+ node_spec = path.empty? ? nil : registry[path]
306
+ lines << " #{node_spec.description}" if node_spec&.description
307
+ lines << "\n SUBCOMMANDS:"
308
+ col_w = cmds.keys.map { |k| k.to_s.length }.max + 2
290
309
  cmds.each do |id, spec|
291
310
  lines << " #{id.to_s.ljust(col_w)} #{spec.description}"
292
311
  end
293
312
  else
294
- # Leaf node: show description + arguments
313
+ # Leaf node: show description + arguments.
295
314
  spec = registry[path]
296
315
  lines << "\nCOMMAND: #{label}"
297
316
  lines << " #{spec.description}" if spec&.description
298
317
  display_args = spec&.arguments || []
299
318
  # transfer_paths commands use --sources for the file list; default is positional args (@args)
300
319
  if spec&.transfer_paths
301
- file_desc = spec.transfer_paths == :receive \
302
- ? "Remote path(s) to download (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --to-folder)" \
303
- : "Source file(s) to upload (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --src-type, --to-folder)"
320
+ file_desc = if spec.transfer_paths == :receive
321
+ "Remote path(s) to download (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --to-folder)"
322
+ else
323
+ "Source file(s) to upload (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --src-type, --to-folder)"
324
+ end
304
325
  display_args += [ArgumentSpec.new(name: :source_file, description: file_desc, mandatory: false, multiple: true)]
305
326
  end
306
327
  if display_args.any?
307
328
  lines << "\nARGUMENTS:"
308
- col_w = display_args.map{ |a| a.name.to_s.length}.max + 2
329
+ col_w = display_args.map { |a| a.name.to_s.length }.max + 2
309
330
  display_args.each do |arg|
310
331
  flag = arg.mandatory ? arg.name.to_s : "[#{arg.name}]"
311
332
  flag += '...' if arg.multiple
@@ -315,16 +336,31 @@ module Aspera
315
336
  when nil then ''
316
337
  else arg.type.name
317
338
  end
318
- hint = arg.type.eql?(Hash) && arg.schema ? " (use 'help' as value to see schema)" : ''
339
+ hint = Array(arg.type).include?(Hash) && arg.schema ? ' (schema shown below)' : ''
319
340
  lines << " #{flag.ljust(col_w)} #{arg.description || types}#{hint}"
341
+ lines.concat(schema_help_lines(arg)) if Array(arg.type).include?(Hash) && arg.schema
320
342
  end
321
343
  end
322
- lines << "\nTIP: use --query=help to list available query parameters" if spec&.query_schema || spec&.entity_execute&.[](:query_schema)
344
+ lines << "\nTIP: use --query=help to list available query parameters" if spec&.query_schema
323
345
  end
324
346
  end
325
347
  lines.join("\n")
326
348
  end
327
349
 
350
+ # Render the structure of a Hash argument's JSON schema in command help.
351
+ # @param arg [ArgumentSpec]
352
+ # @return [Array<String>]
353
+ def schema_help_lines(arg)
354
+ builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(arg.schema)).build
355
+ rows = builder.rows.reject { |row| row['type'].eql?('&nbsp;') }.map do |row|
356
+ builder.columns.map { |column| row[column].to_s }
357
+ end
358
+ style = {}
359
+ style[:border] = :unicode_round if Environment.terminal_supports_unicode?
360
+ table = Terminal::Table.new(headings: builder.columns, rows: rows, style: style).to_s
361
+ ["\n SCHEMA: #{arg.name}", table.lines.map { |line| " #{line.chomp}" }.join("\n")]
362
+ end
363
+
328
364
  # Initialize agents and options
329
365
  # This can throw exception if there is a problem with the environment, needs to be caught by execute method
330
366
  # @raise [StandardError] if there is a problem with the environment
@@ -335,13 +371,13 @@ module Aspera
335
371
  @context.formatter = Formatter.new
336
372
  # Create command line manager with arguments
337
373
  @context.options = Parser.new(Info::CMD_NAME, @argv)
338
- ExtendedValue.instance.on(EXTEND_ARGS){ |v| @context.options.args_as_extended(v)}
374
+ ExtendedValue.instance.on(EXTEND_ARGS) { |v| @context.options.args_as_extended(v) }
339
375
  # Formatter: declare metadata (class method), then bind to the instance
340
376
  Formatter.declare_options(@context.options)
341
377
  @context.formatter.bind_options(@context.options)
342
378
  # Compare $0 with expected name
343
379
  current_prog_name = File.basename($PROGRAM_NAME)
344
- Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn){"Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'"}
380
+ Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn) { "Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'" }
345
381
  # Declare and parse global options
346
382
  declare_global_options
347
383
  # Bootstrap: populate context services (main_folder, persistency, presets, http_config,
@@ -351,7 +387,7 @@ module Aspera
351
387
  @bootstrapper = Bootstrapper.new(@context)
352
388
  @bootstrapper.run(
353
389
  gem_plugins_folder: Plugins::Config.gem_plugins_folder,
354
- vault_value_cb: ->(v){@context.config.vault_value(v)}
390
+ vault_value_cb: ->(v) { @context.config.vault_value(v) }
355
391
  )
356
392
  # Do not display config commands if help is asked
357
393
  @context.man_header = false
@@ -381,7 +417,7 @@ module Aspera
381
417
  #{t}#{Info::CMD_NAME} -- a command line tool for Aspera Applications (v#{Cli::VERSION})
382
418
 
383
419
  SYNOPSIS
384
- #{t}#{Info::CMD_NAME} COMMANDS [OPTIONS] [ARGS]
420
+ #{t}#{Info::CMD_NAME} [GLOBAL_OPTIONS] <command> [OPTIONS] [ARGS]
385
421
 
386
422
  DESCRIPTION
387
423
  #{t}Use Aspera application to perform operations on command line.
@@ -400,8 +436,8 @@ module Aspera
400
436
  OPTIONS
401
437
  #{t}Options begin with a '-' (minus), and value is provided on command line.
402
438
  #{t}Special values are supported beginning with special prefix @pfx:, where pfx is one of:
403
- #{t}#{ExtendedValue.instance.modifiers.join(', ')}
404
- #{t}Dates format is 'DD-MM-YY HH:MM:SS', or 'now' or '-<num>h'
439
+ #{t}#{ExtendedValue.instance.modifiers.reject(&:empty?).join(', ')}
440
+ #{t}Dates format is 'YYYY-MM-DD HH:MM:SS', or 'now' or '-<num>h'
405
441
 
406
442
  ARGS
407
443
  #{t}Some commands require mandatory arguments, e.g. a path.
@@ -412,12 +448,12 @@ module Aspera
412
448
  # @return [nil]
413
449
  def declare_global_options
414
450
  Log.log.debug('declare_global_options')
415
- @context.options.declare(:help, description: 'Show this message', allowed: Allowed::TYPES_NONE, short: 'h') do
451
+ @context.options.declare(:help, description: 'Show this message', allowed: Type::NONE, short: 'h') do
416
452
  @option_help = true
417
453
  @context.options.help_requested = true
418
454
  end
419
- @context.options.declare(:show_config, description: 'Display parameters used for the provided action', allowed: Allowed::TYPES_NONE){@option_show_config = true}
420
- @context.options.declare(:version, description: 'Display version', allowed: Allowed::TYPES_NONE, short: 'v'){@context.formatter.display_message(:data, Cli::VERSION); Process.exit(0)} # rubocop:disable Style/Semicolon
455
+ @context.options.declare(:show_config, description: 'Display parameters used for the provided action', allowed: Type::NONE) { @option_show_config = true }
456
+ @context.options.declare(:version, description: 'Display version', allowed: Type::NONE, short: 'v') { @context.formatter.display_message(:data, Cli::VERSION); Process.exit(0) } # rubocop:disable Style/Semicolon
421
457
  @context.options.declare(
422
458
  :ui, description: 'Method to start browser',
423
459
  allowed: USER_INTERFACES,
@@ -431,10 +467,10 @@ module Aspera
431
467
  @context.options.declare(:log_format, description: 'Log formatter', allowed: [Proc, Logger::Formatter, String], handler: {o: Log.instance, m: :formatter})
432
468
  @context.options.declare(:logger, description: 'Logging method', allowed: Log::LOG_TYPES, handler: {o: Log.instance, m: :logger_type})
433
469
  @context.options.declare(:log, description: 'Logging options (dot-notation: level, type, format, secrets)', handler: {o: self, m: :option_log}, schema: Schema::Registry::LOG_OPTIONS)
434
- @context.options.declare(:lock_port, description: 'Prevent dual execution of a command, e.g. in cron', allowed: Allowed::TYPES_INTEGER)
435
- @context.options.declare(:once_only, description: 'Process only new items (some commands)', allowed: Allowed::TYPES_BOOLEAN, default: false)
436
- @context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Allowed::TYPES_BOOLEAN, handler: {o: SecretHider.instance, m: :log_secrets})
437
- @context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Allowed::TYPES_BOOLEAN, handler: {o: TempFileManager.instance, m: :cleanup_on_exit})
470
+ @context.options.declare(:lock_port, description: 'Prevent dual execution of a command, e.g. in cron', allowed: Type::INTEGER)
471
+ @context.options.declare(:once_only, description: 'Process only new items (some commands)', allowed: Type::BOOLEAN, default: false)
472
+ @context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Type::BOOLEAN, handler: {o: SecretHider.instance, m: :log_secrets})
473
+ @context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Type::BOOLEAN, handler: {o: TempFileManager.instance, m: :cleanup_on_exit})
438
474
  @context.options.declare(:temp_folder, description: 'Temporary folder', handler: {o: TempFileManager.instance, m: :global_temp})
439
475
  @context.options.declare(:pid_file, description: 'Write process identifier to file, delete on exit')
440
476
  @context.options.declare(
@@ -452,10 +488,10 @@ module Aspera
452
488
  # @param plugin_name_sym [Symbol] symbol for plugin name
453
489
  # @return [Plugins::Base] the plugin instance
454
490
  def get_plugin_instance_with_options(plugin_name_sym)
455
- Log.log.debug{"get_plugin_instance_with_options(#{plugin_name_sym})"}
491
+ Log.log.debug { "get_plugin_instance_with_options(#{plugin_name_sym})" }
456
492
  # Load default preset options for this plugin from config file
457
493
  default_config_name = @context.presets.plugin_default_name(plugin_name_sym)
458
- Log.log.debug{"add_plugin_default_preset:#{plugin_name_sym}:#{default_config_name}"}
494
+ Log.log.debug { "add_plugin_default_preset:#{plugin_name_sym}:#{default_config_name}" }
459
495
  @context.options.add_option_preset(@context.presets.by_name(default_config_name), 'default_plugin', override: false) unless default_config_name.nil?
460
496
  command_plugin = Plugins::Factory.instance.create(plugin_name_sym, context: @context)
461
497
  return command_plugin
@@ -29,7 +29,7 @@ module Aspera
29
29
  if secret.eql?(PRESET_MAGIC)
30
30
  conf = @presets.lookup_preset(url: url, username: username)
31
31
  if conf.is_a?(Hash)
32
- Log.log.debug{"Found preset #{conf} with URL and username"}
32
+ Log.log.debug { "Found preset #{conf} with URL and username" }
33
33
  secret = conf['password']
34
34
  end
35
35
  end