aspera-cli 4.25.3 → 4.26.0.pre

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 (45) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +39 -6
  4. data/CONTRIBUTING.md +119 -111
  5. data/README.md +9 -7
  6. data/lib/aspera/agent/direct.rb +10 -8
  7. data/lib/aspera/agent/factory.rb +3 -3
  8. data/lib/aspera/agent/node.rb +1 -1
  9. data/lib/aspera/api/alee.rb +1 -0
  10. data/lib/aspera/api/aoc.rb +13 -12
  11. data/lib/aspera/api/ats.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +5 -0
  13. data/lib/aspera/api/faspex.rb +15 -2
  14. data/lib/aspera/api/httpgw.rb +2 -0
  15. data/lib/aspera/api/node.rb +82 -29
  16. data/lib/aspera/ascp/installation.rb +9 -10
  17. data/lib/aspera/cli/error.rb +8 -0
  18. data/lib/aspera/cli/formatter.rb +27 -11
  19. data/lib/aspera/cli/info.rb +2 -1
  20. data/lib/aspera/cli/main.rb +30 -12
  21. data/lib/aspera/cli/manager.rb +43 -31
  22. data/lib/aspera/cli/plugins/aoc.rb +7 -5
  23. data/lib/aspera/cli/plugins/base.rb +2 -79
  24. data/lib/aspera/cli/plugins/config.rb +2 -1
  25. data/lib/aspera/cli/plugins/faspex.rb +1 -1
  26. data/lib/aspera/cli/plugins/faspex5.rb +51 -51
  27. data/lib/aspera/cli/plugins/node.rb +9 -14
  28. data/lib/aspera/cli/plugins/shares.rb +4 -2
  29. data/lib/aspera/cli/special_values.rb +1 -0
  30. data/lib/aspera/cli/transfer_agent.rb +3 -0
  31. data/lib/aspera/cli/version.rb +1 -1
  32. data/lib/aspera/cli/wizard.rb +2 -1
  33. data/lib/aspera/dot_container.rb +10 -10
  34. data/lib/aspera/log.rb +1 -1
  35. data/lib/aspera/markdown.rb +1 -1
  36. data/lib/aspera/persistency_folder.rb +1 -1
  37. data/lib/aspera/rest.rb +34 -49
  38. data/lib/aspera/rest_list.rb +116 -0
  39. data/lib/aspera/sync/operations.rb +1 -1
  40. data/lib/aspera/transfer/parameters.rb +8 -8
  41. data/lib/aspera/transfer/spec.rb +1 -0
  42. data/lib/aspera/yaml.rb +1 -1
  43. data.tar.gz.sig +0 -0
  44. metadata +4 -3
  45. metadata.gz.sig +0 -0
@@ -7,11 +7,15 @@ require 'aspera/hash_ext'
7
7
  require 'aspera/data_repository'
8
8
  require 'aspera/transfer/spec'
9
9
  require 'aspera/api/node'
10
+ require 'aspera/rest_list'
10
11
  require 'base64'
11
12
 
12
13
  module Aspera
13
14
  module Api
15
+ # Aspera on Cloud API client
14
16
  class AoC < Rest
17
+ include RestList
18
+
15
19
  PRODUCT_NAME = 'Aspera on Cloud'
16
20
  # use default workspace if it is set, else none
17
21
  DEFAULT_WORKSPACE = ''
@@ -144,12 +148,11 @@ module Aspera
144
148
 
145
149
  # Call `block` with same query using paging and response information.
146
150
  # Block must return a 2 element `Array` with data and http response
147
- # @param query [Hash] Additionnal query parameters
148
- # @param progress [nil, Object] Uses methods: `long_operation_running` and `long_operation_terminated`
151
+ # @param query [Hash] Additional query parameters
149
152
  # @return [Hash] Items and total number of items
150
153
  # @option return [Array<Hash>] :items The list of items
151
154
  # @option return [Integer] :total The total number of items
152
- def call_paging(query: {}, progress: nil)
155
+ def call_paging(query: {})
153
156
  Aspera.assert_type(query, Hash){'query'}
154
157
  Aspera.assert(block_given?)
155
158
  # set default large page if user does not specify own parameters. AoC Caps to 1000 anyway
@@ -177,9 +180,9 @@ module Aspera
177
180
  break if !max_items.nil? && item_list.count >= max_items
178
181
  break if !max_pages.nil? && page_count >= max_pages
179
182
  break if total_count&.<=(item_list.count)
180
- progress&.long_operation_running("#{item_list.count} / #{total_count}") unless total_count.eql?(item_list.count.to_s)
183
+ RestParameters.instance.spinner_cb.call("#{item_list.count} / #{total_count}") unless total_count.eql?(item_list.count.to_s)
181
184
  end
182
- progress&.long_operation_terminated
185
+ RestParameters.instance.spinner_cb.call(action: :success)
183
186
  item_list = item_list[0..max_items - 1] if !max_items.nil? && item_list.count > max_items
184
187
  return {items: item_list, total: total_count}
185
188
  end
@@ -210,7 +213,7 @@ module Aspera
210
213
  when 'upload' then %w[mkdir write]
211
214
  when Array
212
215
  Aspera.assert_array_all(levels, String){'access_levels'}
213
- levels.each{ |level| Aspera.assert_value(level, Node::ACCESS_LEVELS){'access_level'}}
216
+ levels.each{ |level| Aspera.assert_values(level, Node::ACCESS_LEVELS){'access_level'}}
214
217
  levels
215
218
  else Aspera.error_unexpected_value(levels){"access_levels must be a list of #{Node::ACCESS_LEVELS.join(', ')} or one of edit, preview, download, upload"}
216
219
  end
@@ -232,8 +235,7 @@ module Aspera
232
235
  username: nil,
233
236
  password: nil,
234
237
  workspace: nil,
235
- secret_finder: nil,
236
- progress_disp: nil
238
+ secret_finder: nil
237
239
  )
238
240
  # Test here because link may set url
239
241
  Aspera.assert(url, 'Missing mandatory option: url', type: ParameterError)
@@ -244,7 +246,6 @@ module Aspera
244
246
  # key: access key
245
247
  # value: associated secret
246
248
  @secret_finder = secret_finder
247
- @progress_disp = progress_disp
248
249
  @workspace_name = workspace
249
250
  @cache_user_info = nil
250
251
  @cache_url_token_info = nil
@@ -309,7 +310,7 @@ module Aspera
309
310
  # @param query [nil, Hash] Additional query
310
311
  # @return [Hash] {items: , total: }
311
312
  def read_with_paging(subpath, query = nil)
312
- return self.class.call_paging(query: query, progress: @progress_disp) do |paged_query|
313
+ return self.class.call_paging(query: query) do |paged_query|
313
314
  read(subpath, query: paged_query, ret: :both)
314
315
  end
315
316
  end
@@ -495,7 +496,7 @@ module Aspera
495
496
  # in that case, the name is resolved and replaced with {type: , id: }
496
497
  # @param package_data [Hash] The whole package creation payload
497
498
  # @param rcpt_lst_field [String] The field in structure, i.e. recipients or bcc_recipients
498
- # @param new_user_option [Hash] Additionnal fields for contact creation
499
+ # @param new_user_option [Hash] Additional fields for contact creation
499
500
  # @return nil, `package_data` is modified
500
501
  def resolve_package_recipients(package_data, rcpt_lst_field, new_user_option)
501
502
  return unless package_data.key?(rcpt_lst_field)
@@ -513,7 +514,7 @@ module Aspera
513
514
  # email: user, else dropbox
514
515
  entity_type = short_recipient_info.include?('@') ? 'contacts' : 'dropboxes'
515
516
  begin
516
- full_recipient_info = lookup_by_name(entity_type, short_recipient_info, query: {'current_workspace_id' => ws_id})
517
+ full_recipient_info = lookup_by_name(entity_type, short_recipient_info, query: {'workspace_id' => ws_id})
517
518
  rescue EntityNotFound
518
519
  # dropboxes cannot be created on the fly
519
520
  Aspera.assert_values(entity_type, 'contacts', type: Error){"No such shared inbox in workspace #{ws_id}"}
@@ -5,7 +5,7 @@ require 'aspera/rest'
5
5
 
6
6
  module Aspera
7
7
  module Api
8
- # ATS API without authentication
8
+ # Aspera Transfer Service API client without authentication
9
9
  class Ats < Aspera::Rest
10
10
  SERVICE_BASE_URL = 'https://ats.aspera.io'
11
11
  # currently supported clouds
@@ -7,9 +7,11 @@ require 'xmlsimple'
7
7
 
8
8
  module Aspera
9
9
  module Api
10
+ # Cloud Object Storage Node API Client
10
11
  class CosNode < Node
11
12
  IBM_CLOUD_TOKEN_URL = 'https://iam.cloud.ibm.com/identity'
12
13
  TOKEN_FIELD = 'delegated_refresh_token'
14
+ FASP_INFO_KEYS = %w[ATSEndpoint AccessKey].freeze
13
15
  class << self
14
16
  def parameters_from_svc_credentials(service_credentials, bucket_region)
15
17
  # check necessary contents
@@ -60,6 +62,9 @@ module Aspera
60
62
  ).body
61
63
  ats_info = XmlSimple.xml_in(xml_result_text, {'ForceArray' => false})
62
64
  Log.dump(:ats_info, ats_info)
65
+ Aspera.assert_hash_all(ats_info, String, nil){'ats_info'}
66
+ Aspera.assert((FASP_INFO_KEYS - ats_info.keys).empty?){'ats_info missing required keys'}
67
+ Aspera.assert_hash_all(ats_info['AccessKey'], String, String){'ats_info'}
63
68
  @storage_credentials = {
64
69
  'type' => 'token',
65
70
  'token' => {TOKEN_FIELD => nil}
@@ -2,10 +2,11 @@
2
2
 
3
3
  require 'aspera/rest'
4
4
  require 'aspera/oauth/base'
5
+ require 'aspera/rest_list'
5
6
  require 'digest'
6
7
 
7
8
  module Aspera
8
- # Implement OAuth for Faspex public link
9
+ # OAuth for Faspex public link
9
10
  class FaspexPubLink < OAuth::Base
10
11
  class << self
11
12
  attr_accessor :additional_info
@@ -55,7 +56,10 @@ module Aspera
55
56
  end
56
57
  OAuth::Factory.instance.register_token_creator(FaspexPubLink)
57
58
  module Api
59
+ # Aspera Faspex 5 API Client
58
60
  class Faspex < Aspera::Rest
61
+ include RestList
62
+
59
63
  # endpoint for authentication API
60
64
  PATH_AUTH = 'auth'
61
65
  PATH_API_V5 = 'api/v5'
@@ -91,7 +95,7 @@ module Aspera
91
95
  JOB_RUNNING = %w[queued working].freeze
92
96
  PATH_STANDARD_ROOT = '/aspera/faspex'
93
97
  PATH_API_DETECT = "#{PATH_API_V5}/#{PATH_HEALTH}"
94
- HEADER_ITERATION_TOKEN = 'X-Aspera-Next-Iteration-Token'
98
+ HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
95
99
  HEADER_FASPEX_VERSION = 'X-IBM-Aspera'
96
100
  EMAIL_NOTIF_LIST = %w[
97
101
  welcome_email
@@ -136,6 +140,15 @@ module Aspera
136
140
  end
137
141
  attr_reader :pub_link_context
138
142
 
143
+ # @param url Faspex URL, can be a public link
144
+ # @param auth Authentication method: :boot (token in header), :web (open browser), :jwt (client_id + private key), :public_link (context in URL)
145
+ # @param password For :boot auth, the token copied directly from browser in developer mode
146
+ # @param client_id For :web and :jwt auth, the client_id of web UI application
147
+ # @param client_secret For :web auth, the client_secret of web UI application (not needed for :jwt)
148
+ # @param redirect_uri For :web auth, the redirect_uri of web UI application (must be the same as in application configuration)
149
+ # @param username For :jwt auth, the username of the user to impersonate
150
+ # @param private_key For :jwt auth, the private key to sign JWT token
151
+ # @param passphrase For :jwt auth, the passphrase of the private key
139
152
  def initialize(
140
153
  url:,
141
154
  auth:,
@@ -4,6 +4,7 @@ require 'aspera/log'
4
4
  require 'aspera/rest'
5
5
  require 'aspera/transfer/faux_file'
6
6
  require 'aspera/assert'
7
+ require 'net/protocol'
7
8
  require 'securerandom'
8
9
  require 'websocket'
9
10
  require 'base64'
@@ -11,6 +12,7 @@ require 'json'
11
12
 
12
13
  module Aspera
13
14
  module Api
15
+ # Aspera HTTP Gateway API client
14
16
  # Start a transfer using Aspera HTTP Gateway, using web socket secure for uploads
15
17
  # ref: https://api.ibm.com/explorer/catalog/aspera/product/ibm-aspera/api/http-gateway-api/doc/guides-toc
16
18
  # https://developer.ibm.com/apis/catalog?search=%22aspera%20http%22
@@ -6,16 +6,17 @@ require 'aspera/oauth'
6
6
  require 'aspera/log'
7
7
  require 'aspera/assert'
8
8
  require 'aspera/environment'
9
- require 'zlib'
10
9
  require 'base64'
11
10
  require 'openssl'
12
11
  require 'pathname'
12
+ require 'zlib'
13
13
  require 'net/ssh/buffer'
14
14
 
15
15
  module Aspera
16
16
  module Api
17
- # Provides additional functions using node API with gen4 extensions (access keys)
18
- class Node < Aspera::Rest
17
+ # Aspera Node API client
18
+ # with gen4 extensions (access keys)
19
+ class Node < Rest
19
20
  # Format of node scope : node.<access key>:<scope>
20
21
  module Scope
21
22
  # Node sub-scopes
@@ -42,9 +43,10 @@ module Aspera
42
43
  ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
43
44
  # Special HTTP Headers
44
45
  HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
45
- HEADER_X_TOTAL_COUNT = 'X-Total-Count'
46
46
  HEADER_X_CACHE_CONTROL = 'X-Aspera-Cache-Control'
47
+ HEADER_X_TOTAL_COUNT = 'X-Total-Count'
47
48
  HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
49
+ HEADER_ACCEPT_VERSION = 'Accept-Version'
48
50
  # / in cloud
49
51
  PATH_SEPARATOR = '/'
50
52
 
@@ -52,22 +54,36 @@ module Aspera
52
54
  OAuth::Factory.instance.register_decoder(lambda{ |token| Node.decode_bearer_token(token)})
53
55
 
54
56
  # Class instance variable, access with accessors on class
55
- @use_standard_ports = true
56
- @use_node_cache = true
57
-
58
- class << self
57
+ @api_options = {
59
58
  # Set to false to read transfer parameters from download_setup
60
- attr_accessor :use_standard_ports
59
+ standard_ports: true,
61
60
  # Set to false to bypass cache in redis
62
- attr_accessor :use_node_cache
61
+ cache: true,
62
+ accept_v4: true
63
+ }
64
+ OPTIONS = @api_options.keys.freeze
65
+
66
+ class << self
67
+ attr_reader :api_options
63
68
  attr_reader :use_dynamic_key
64
69
 
65
- # Adds cache control header, as globally specified to read request
66
- # Use like this: read(...,**cache_control)
67
- def cache_control
68
- headers = {'Accept' => Mime::JSON}
69
- headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless use_node_cache
70
- {headers: headers}
70
+ def api_options=(h)
71
+ Aspera.assert_type(h, Hash)
72
+ h.each do |k, v|
73
+ Aspera.assert(@api_options.key?(k.to_sym)){"unknown api option: #{k} (#{OPTIONS.join(', ')})"}
74
+ Aspera.assert_type(v, TrueClass, FalseClass){"api options value for #{k} should be boolean"}
75
+ @api_options[k.to_sym] = v
76
+ end
77
+ end
78
+
79
+ # Adds cache control header for node API /files/:id
80
+ # as globally specified to read request
81
+ # Use like this: read(..., headers: add_cache_control)
82
+ # @param headers [Hash] optional initial headers to add to
83
+ # @return [Hash] headers with cache control header added if needed
84
+ def add_cache_control(headers = {})
85
+ headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless api_options[:cache]
86
+ headers
71
87
  end
72
88
 
73
89
  # Set private key to be used
@@ -180,8 +196,8 @@ module Aspera
180
196
  Aspera.assert(!access_key.nil?)
181
197
  end
182
198
  return {
183
- Node::HEADER_X_ASPERA_ACCESS_KEY => access_key,
184
- 'Authorization' => bearer_auth
199
+ HEADER_X_ASPERA_ACCESS_KEY => access_key,
200
+ 'Authorization' => bearer_auth
185
201
  }
186
202
  end
187
203
  end
@@ -248,6 +264,50 @@ module Aspera
248
264
  return false
249
265
  end
250
266
 
267
+ # Read folder content, with pagination management for gen4, not recursive
268
+ # if `Accept-Version: 4.0` is not specified:
269
+ # if `page` and `per_page` are not specified, then all entries are returned.
270
+ # if either `page` or `per_page` is specified, then both are required, else 400
271
+ # if `Accept-Version: 4.0` is specified:
272
+ # those queries are not available: page (not mentioned), sort, min_size, max_size, min_modified_time, max_modified_time, target_id, target_node_id, files_prefetch_count, page, name_iglob : either ignored or result in API error 400.
273
+ # query include is accepted, but seems to do nothing as access_levels and recursive_counts are already included in results.
274
+ # query `iteration_token` is accepted and allows to get paginated results, with `X-Aspera-Next-Iteration-Token` header in response to get next page token. `X-Aspera-Total-Count` header gives total count of entries.
275
+ def read_folder_content(file_id, query = nil, exception: true, path: nil)
276
+ folder_items = []
277
+ begin
278
+ query ||= {}
279
+ headers = self.class.add_cache_control
280
+ use_v4 = self.class.api_options[:accept_v4]
281
+ return read("files/#{file_id}/files", query, headers: headers) unless use_v4 || query.key?('page') || query.key?('per_page')
282
+ if use_v4
283
+ headers[HEADER_ACCEPT_VERSION] = '4.0'
284
+ query['per_page'] = 1000 unless query.key?('per_page')
285
+ elsif query.key?('per_page') && !query.key?('page')
286
+ query['page'] = 0
287
+ end
288
+ loop do
289
+ RestParameters.instance.spinner_cb.call(folder_items.count)
290
+ data, http = read("files/#{file_id}/files", query, headers: headers, ret: :both)
291
+ folder_items.concat(data)
292
+ if use_v4
293
+ iteration_token = http[HEADER_X_NEXT_ITER_TOKEN]
294
+ break if iteration_token.nil? || iteration_token.empty?
295
+ query['iteration_token'] = iteration_token
296
+ else
297
+ break if data['item_count'].eql?(0)
298
+ query['offset'] += data['item_count']
299
+ end
300
+ end
301
+ rescue StandardError => e
302
+ raise e if exception
303
+ Log.log.warn{"#{path}: #{e.class} #{e.message}"}
304
+ Log.log.debug{(['Backtrace:'] + e.backtrace).join("\n")}
305
+ ensure
306
+ RestParameters.instance.spinner_cb.call(folder_items.count, action: :success)
307
+ end
308
+ folder_items
309
+ end
310
+
251
311
  # Recursively browse in a folder (with non-recursive method)
252
312
  # Entries of folders are processed if the processing method returns true
253
313
  # Links are processed on the respective node
@@ -267,14 +327,7 @@ module Aspera
267
327
  current_item = folders_to_explore.shift
268
328
  Log.log.debug{"Exploring #{current_item[:path]}".bg_green}
269
329
  # Get folder content
270
- folder_contents =
271
- begin
272
- # TODO: use header
273
- read("files/#{current_item[:id]}/files", query, **self.class.cache_control)
274
- rescue StandardError => e
275
- Log.log.warn{"#{current_item[:path]}: #{e.class} #{e.message}"}
276
- []
277
- end
330
+ folder_contents = read_folder_content(current_item[:id], query, exception: false, path: current_item[:path])
278
331
  Log.dump(:folder_contents, folder_contents)
279
332
  folder_contents.each do |entry|
280
333
  if entry.key?('error')
@@ -309,7 +362,7 @@ module Aspera
309
362
  # @param path [String] file or folder path (end with "/" is like setting process_last_link)
310
363
  # @param process_last_link [Boolean] if true, follow the last link
311
364
  # @return [Hash] Result data
312
- # @option return [Aspera::Rest] :api REST client instance
365
+ # @option return [Rest] :api REST client instance
313
366
  # @option return [String] :file_id File identifier
314
367
  def resolve_api_fid(top_file_id, path, process_last_link = false)
315
368
  Aspera.assert_type(top_file_id, String)
@@ -445,7 +498,7 @@ module Aspera
445
498
  # Add application specific tags (AoC)
446
499
  @app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: @app_info) unless @app_info.nil?
447
500
  # Add remote host info
448
- if self.class.use_standard_ports
501
+ if self.class.api_options[:standard_ports]
449
502
  # Get default TCP/UDP ports and transfer user
450
503
  transfer_spec.merge!(Transfer::Spec::AK_TSPEC_BASE)
451
504
  # By default: same address as node API
@@ -456,7 +509,7 @@ module Aspera
456
509
  # Get the transfer user from info on access key
457
510
  transfer_spec['remote_user'] = info['transfer_user'] if info['transfer_user']
458
511
  # Get settings from name.value array to hash key.value
459
- settings = info['settings']&.each_with_object({}){ |i, h| h[i['name']] = i['value']}
512
+ settings = info['settings']&.to_h{ |i| [i['name'], i['value']]}
460
513
  # Check WSS ports
461
514
  Transfer::Spec::WSS_FIELDS.each do |i|
462
515
  transfer_spec[i] = settings[i] if settings.key?(i)
@@ -83,16 +83,15 @@ module Aspera
83
83
 
84
84
  # @return [Hash] with key = file name (String), and value = path to file
85
85
  def file_paths
86
- return SDK_FILES.each_with_object({}) do |v, m|
87
- m[v.to_s] =
88
- begin
89
- path(v)
90
- rescue Errno::ENOENT => e
91
- e.message.gsub(/.*assertion failed: /, '').gsub(/\): .*/, ')')
92
- rescue => e
93
- e.message
94
- end
95
- end
86
+ return SDK_FILES.to_h do |v|
87
+ [v.to_s, begin
88
+ path(v)
89
+ rescue Errno::ENOENT => e
90
+ e.message.gsub(/.*assertion failed: /, '').gsub(/\): .*/, ')')
91
+ rescue => e
92
+ e.message
93
+ end]
94
+ end
96
95
  end
97
96
 
98
97
  # TODO: if using another product than SDK, should use files from there
@@ -9,7 +9,15 @@ module Aspera
9
9
  class MissingArgument < Error; end
10
10
  class NoSuchElement < Error; end
11
11
 
12
+ # Raised when a lookup for a specific entity fails to return exactly one result.
13
+ #
14
+ # Provides a formatted message indicating whether the entity was missing
15
+ # or if multiple matches were found (ambiguity).
12
16
  class BadIdentifier < Error
17
+ # @param res_type [String] The type of entity being looked up (e.g., 'user').
18
+ # @param res_id [String] The value of the identifier that failed.
19
+ # @param field [String] The name of the field used for lookup (defaults to 'identifier').
20
+ # @param count [Integer] The number of matches found (0 for not found, >1 for ambiguous).
13
21
  def initialize(res_type, res_id, field: 'identifier', count: 0)
14
22
  msg = count.eql?(0) ? 'not found' : "found #{count}"
15
23
  super("#{res_type} with #{field}=#{res_id}: #{msg}")
@@ -128,20 +128,34 @@ module Aspera
128
128
  @spinner = nil
129
129
  end
130
130
 
131
- # call this after REST calls if several api calls are expected
132
- def long_operation_running(title = '')
131
+ def long_operation(title = nil, action: :spin)
133
132
  return unless Environment.terminal?
134
- if @spinner.nil?
133
+ return if %i[error data].include?(@options[:display])
134
+
135
+ # Handle the "delayed start" state
136
+ return @spinner = :starting if action == :spin && @spinner.nil?
137
+
138
+ # Cleanup if we try to stop a spinner that never actually started
139
+ @spinner = nil if action != :spin && @spinner == :starting
140
+ return if @spinner.nil?
141
+
142
+ # Initialize the real TTY object if it's currently just the :starting symbol
143
+ if @spinner == :starting
135
144
  @spinner = TTY::Spinner.new('[:spinner] :title', format: :classic)
145
+ @spinner.update(title: '')
136
146
  @spinner.start
137
147
  end
138
- @spinner.update(title: title)
139
- @spinner.spin
140
- end
141
148
 
142
- def long_operation_terminated
143
- @spinner&.stop
144
- @spinner = nil
149
+ @spinner.update(title: title) if title
150
+
151
+ case action
152
+ when :spin
153
+ @spinner.spin
154
+ when :success, :fail
155
+ action == :success ? @spinner.success : @spinner.error
156
+ @spinner.stop
157
+ @spinner = nil
158
+ end
145
159
  end
146
160
 
147
161
  def declare_options(options)
@@ -150,9 +164,9 @@ module Aspera
150
164
  else
151
165
  {}
152
166
  end
167
+ options.declare(:display, 'Output only some information', allowed: DISPLAY_LEVELS, handler: {o: self, m: :option_handler}, default: :data)
153
168
  options.declare(:format, 'Output format', allowed: DISPLAY_FORMATS, handler: {o: self, m: :option_handler}, default: :table)
154
169
  options.declare(:output, 'Destination for results', handler: {o: self, m: :option_handler})
155
- options.declare(:display, 'Output only some information', allowed: DISPLAY_LEVELS, handler: {o: self, m: :option_handler}, default: :info)
156
170
  options.declare(
157
171
  :fields, "Comma separated list of: fields, or #{SpecialValues::ALL}, or #{SpecialValues::DEF}", handler: {o: self, m: :option_handler},
158
172
  allowed: [String, Array, Regexp, Proc],
@@ -178,6 +192,8 @@ module Aspera
178
192
  @options[option_symbol] = value
179
193
  # special handling of some options
180
194
  case option_symbol
195
+ when :format
196
+ @options[:display] = value.eql?(:table) ? :info : :data
181
197
  when :output
182
198
  $stdout = if value.eql?('-')
183
199
  STDOUT # rubocop:disable Style/GlobalStdStream
@@ -197,7 +213,7 @@ module Aspera
197
213
  nil
198
214
  end
199
215
 
200
- # main output method
216
+ # Main output method
201
217
  # data: for requested data, not displayed if level==error
202
218
  # info: additional info, displayed if level==info
203
219
  # error: always displayed on stderr
@@ -7,7 +7,8 @@ module Aspera
7
7
  CMD_NAME = 'ascli'
8
8
  # Name of the containing gem, same as in <gem name>.gemspec
9
9
  GEM_NAME = 'aspera-cli'
10
- DOC_URL = "https://www.rubydoc.info/gems/#{GEM_NAME}"
10
+ DOC_URL = 'https://ibm.biz/ascli-doc'
11
+ RUBYDOC_URL = "https://www.rubydoc.info/gems/#{GEM_NAME}"
11
12
  GEM_URL = "https://rubygems.org/gems/#{GEM_NAME}"
12
13
  SRC_URL = 'https://github.com/IBM/aspera-cli'
13
14
  CONTAINER = 'docker.io/martinlaurent/ascli'
@@ -12,18 +12,34 @@ require 'aspera/cli/hints'
12
12
  require 'aspera/secret_hider'
13
13
  require 'aspera/log'
14
14
  require 'aspera/assert'
15
+ require 'net/ssh/errors'
16
+ require 'openssl'
15
17
 
16
18
  module Aspera
17
19
  module Cli
18
20
  # Global objects shared with plugins
19
21
  class Context
22
+ # @!attribute [rw] options
23
+ # @return [Manager] the command line options manager
24
+ # @!attribute [rw] transfer
25
+ # @return [TransferAgent] the transfer agent, used by transfer plugins
26
+ # @!attribute [rw] config
27
+ # @return [Plugins::Config] the configuration plugin, used by plugins to get configuration values and presets
28
+ # @!attribute [rw] formatter
29
+ # @return [Formatter] the formatter, used by plugins to display results and messages
30
+ # @!attribute [rw] persistency
31
+ # @return [Object] # whatever the type is
32
+ # @!attribute [rw] man_header
33
+ # @return [Boolean] whether to display the manual header in plugin help
20
34
  MEMBERS = %i[options transfer config formatter persistency man_header].freeze
21
35
  attr_accessor(*MEMBERS)
22
36
 
37
+ # Initialize all members to nil, so that they are defined and can be validated later
23
38
  def initialize
24
- @man_header = true
39
+ MEMBERS.each{ |i| instance_variable_set(:"@#{i}", nil)}
25
40
  end
26
41
 
42
+ # Validate that all members are set, raise exception if not
27
43
  def validate
28
44
  MEMBERS.each do |i|
29
45
  Aspera.assert(instance_variable_defined?(:"@#{i}"))
@@ -35,7 +51,7 @@ module Aspera
35
51
  transfer.eql?(:only_manual)
36
52
  end
37
53
 
38
- def only_manual
54
+ def only_manual!
39
55
  @transfer = :only_manual
40
56
  end
41
57
  end
@@ -89,13 +105,15 @@ module Aspera
89
105
  status_table.each do |item|
90
106
  worst = TransferAgent.session_status(item[STATUS_FIELD])
91
107
  global_status = worst unless worst.eql?(:success)
92
- item[STATUS_FIELD] = item[STATUS_FIELD].map(&:to_s).join(',')
108
+ item[STATUS_FIELD] = item[STATUS_FIELD].join(',')
93
109
  end
94
110
  raise global_status unless global_status.eql?(:success)
95
111
  return result_object_list(status_table)
96
112
  end
97
113
 
98
114
  # Display image for that URL or directly blob
115
+ #
116
+ # @param url_or_blob [String] URL or blob to display as image
99
117
  def result_image(url_or_blob)
100
118
  return {type: :image, data: url_or_blob}
101
119
  end
@@ -111,6 +129,9 @@ module Aspera
111
129
  end
112
130
 
113
131
  # A list of values
132
+ #
133
+ # @param data [Array] The list of values
134
+ # @param name [String] The name of the list (used for display)
114
135
  def result_value_list(data, name: 'id')
115
136
  Aspera.assert_type(data, Array)
116
137
  Aspera.assert_type(name, String)
@@ -155,7 +176,9 @@ module Aspera
155
176
  execute_command = true
156
177
  # Catch exceptions
157
178
  begin
158
- init_agents_options_plugins
179
+ init_agents_and_options
180
+ # Find plugins, shall be after parse! ?
181
+ Plugins::Factory.instance.add_plugins_from_lookup_folders
159
182
  # Help requested without command ? (plugins must be known here)
160
183
  show_usage if @option_help && @context.options.command_or_arg_empty?
161
184
  generate_bash_completion if @bash_completion
@@ -253,17 +276,11 @@ module Aspera
253
276
  return
254
277
  end
255
278
 
256
- def init_agents_options_plugins
257
- init_agents_and_options
258
- # Find plugins, shall be after parse! ?
259
- Plugins::Factory.instance.add_plugins_from_lookup_folders
260
- end
261
-
262
279
  def show_usage(all: true, exit: true)
263
280
  # Display main plugin options (+config)
264
281
  @context.formatter.display_message(:error, @context.options.parser)
265
282
  if all
266
- @context.only_manual
283
+ @context.only_manual!
267
284
  # List plugins that have a "require" field, i.e. all but main plugin
268
285
  Plugins::Factory.instance.plugin_list.each do |plugin_name_sym|
269
286
  # Config was already included in the global options
@@ -283,6 +300,7 @@ module Aspera
283
300
 
284
301
  # This can throw exception if there is a problem with the environment, needs to be caught by execute method
285
302
  def init_agents_and_options
303
+ @context.man_header = true
286
304
  # Create formatter, in case there is an exception, it is used to display.
287
305
  @context.formatter = Formatter.new
288
306
  # Create command line manager with arguments
@@ -336,7 +354,7 @@ module Aspera
336
354
  OPTIONS
337
355
  #{t}Options begin with a '-' (minus), and value is provided on command line.
338
356
  #{t}Special values are supported beginning with special prefix @pfx:, where pfx is one of:
339
- #{t}#{ExtendedValue.instance.modifiers.map(&:to_s).join(', ')}
357
+ #{t}#{ExtendedValue.instance.modifiers.join(', ')}
340
358
  #{t}Dates format is 'DD-MM-YY HH:MM:SS', or 'now' or '-<num>h'
341
359
 
342
360
  ARGS