aspera-cli 4.27.3 → 4.27.5

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 (79) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +118 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +1 -0
  6. data/docs/README.md +224 -42
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +0 -4
  9. data/lib/aspera/agent/desktop.rb +0 -4
  10. data/lib/aspera/agent/direct.rb +49 -21
  11. data/lib/aspera/agent/node.rb +6 -5
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/faspex.rb +2 -0
  14. data/lib/aspera/api/httpgw.rb +1 -2
  15. data/lib/aspera/api/node.rb +3 -3
  16. data/lib/aspera/ascp/installation.rb +1 -1
  17. data/lib/aspera/cli/async_transfer_store.rb +10 -9
  18. data/lib/aspera/cli/bootstrapper.rb +3 -1
  19. data/lib/aspera/cli/command_registry.rb +69 -7
  20. data/lib/aspera/cli/command_spec.rb +1 -1
  21. data/lib/aspera/cli/extended_value.rb +4 -3
  22. data/lib/aspera/cli/formatter.rb +10 -8
  23. data/lib/aspera/cli/http.rb +8 -20
  24. data/lib/aspera/cli/option_types.rb +3 -1
  25. data/lib/aspera/cli/option_value.rb +16 -19
  26. data/lib/aspera/cli/options.schema.yaml +86 -10
  27. data/lib/aspera/cli/parser.rb +30 -18
  28. data/lib/aspera/cli/plugins/aoc.rb +133 -156
  29. data/lib/aspera/cli/plugins/ats.rb +1 -7
  30. data/lib/aspera/cli/plugins/base.rb +40 -34
  31. data/lib/aspera/cli/plugins/config.rb +24 -11
  32. data/lib/aspera/cli/plugins/console.rb +1 -1
  33. data/lib/aspera/cli/plugins/faspex5.rb +30 -11
  34. data/lib/aspera/cli/plugins/faspio.rb +1 -1
  35. data/lib/aspera/cli/plugins/node.rb +26 -19
  36. data/lib/aspera/cli/plugins/orchestrator.rb +73 -44
  37. data/lib/aspera/cli/plugins/preview.rb +21 -19
  38. data/lib/aspera/cli/plugins/server.rb +3 -4
  39. data/lib/aspera/cli/plugins/shares.rb +21 -24
  40. data/lib/aspera/cli/preset_actions.rb +28 -18
  41. data/lib/aspera/cli/preset_manager.rb +34 -19
  42. data/lib/aspera/cli/prompt.rb +2 -1
  43. data/lib/aspera/cli/result.rb +33 -22
  44. data/lib/aspera/cli/runner.rb +1 -5
  45. data/lib/aspera/cli/sync_actions.rb +17 -16
  46. data/lib/aspera/cli/transfer_actions.rb +14 -3
  47. data/lib/aspera/cli/transfer_agent.rb +6 -4
  48. data/lib/aspera/cli/transfer_progress.rb +290 -55
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +1 -1
  51. data/lib/aspera/coverage.rb +0 -1
  52. data/lib/aspera/environment.rb +29 -5
  53. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  54. data/lib/aspera/keychain/factory.rb +2 -1
  55. data/lib/aspera/log.rb +25 -2
  56. data/lib/aspera/node_emulator.rb +759 -0
  57. data/lib/aspera/oauth/base.rb +2 -1
  58. data/lib/aspera/oauth/factory.rb +6 -3
  59. data/lib/aspera/oauth/json_credentials.rb +34 -0
  60. data/lib/aspera/oauth/jwt.rb +3 -4
  61. data/lib/aspera/oauth.rb +1 -0
  62. data/lib/aspera/persistency_folder.rb +1 -3
  63. data/lib/aspera/preview/generator.rb +4 -1
  64. data/lib/aspera/preview/options.schema.yaml +119 -0
  65. data/lib/aspera/rest/aspera_errors.rb +12 -0
  66. data/lib/aspera/rest/client.rb +28 -19
  67. data/lib/aspera/rest/list.rb +14 -8
  68. data/lib/aspera/rest/parameters.rb +2 -2
  69. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  70. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  71. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  72. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  73. data/lib/aspera/schema/reader.rb +12 -18
  74. data/lib/aspera/schema/registry.rb +6 -1
  75. data.tar.gz.sig +0 -0
  76. metadata +5 -3
  77. metadata.gz.sig +0 -0
  78. data/lib/aspera/node_simulator.rb +0 -345
  79. data/lib/aspera/preview/options.rb +0 -45
@@ -107,8 +107,6 @@ module Aspera
107
107
  @sessions = []
108
108
  # mutex protects global data accessed by threads
109
109
  @mutex = Mutex.new
110
- @pre_calc_sent = false
111
- @pre_calc_last_size = nil
112
110
  # Check on all management messages if that file exists, and if so, read commands from it
113
111
  @command_file = File.join(config_dir || '.', "send_#{$PROCESS_ID}")
114
112
  end
@@ -164,6 +162,11 @@ module Aspera
164
162
  error: nil, # exception if failed
165
163
  io: nil, # management port server socket
166
164
  token_regenerator: token_regenerator, # regenerate bearer token with oauth
165
+ progress_id: nil, # session identifier for progress, one per `ascp` process, same on resume
166
+ size_sent: false, # `true` when total size was notified (PreTransferBytes)
167
+ last_size: nil, # last notified transferred size
168
+ skipped: 0, # size of whole files already at destination
169
+ last_skipped: nil, # last notified size not transferred (already at destination)
167
170
  # env vars and args for ascp (from transfer spec)
168
171
  exec_spec: Transfer::Parameters.new(transfer_spec, **@tr_opts).ascp_args
169
172
  }
@@ -210,7 +213,6 @@ module Aspera
210
213
  session[:thread].join
211
214
  result.push(session[:error] || :success)
212
215
  end
213
- notify_progress(:end)
214
216
  Log.log.debug('all transfers joined')
215
217
  # since all are finished and we return the result, clear statuses
216
218
  @sessions.clear
@@ -347,7 +349,7 @@ module Aspera
347
349
  # store session identifier
348
350
  session[:id] = event['SessionId'] if event['Type'].eql?('INIT')
349
351
  @management_cb&.call(event)
350
- process_progress(event)
352
+ process_progress(session, event)
351
353
  next unless File.exist?(@command_file)
352
354
  begin
353
355
  commands = JSON.parse(File.read(@command_file))
@@ -373,7 +375,9 @@ module Aspera
373
375
  env['ASPERA_SCP_TOKEN'] = session[:token_regenerator].refreshed_transfer_token
374
376
  end
375
377
  raise Transfer::Error.new(last_event['Description'], code: last_event['Code'].to_i)
376
- else Aspera.error_unexpected_value(last_event['Type'], :error) { 'last event type' }
378
+ else
379
+ # e.g. process was killed: like a lost connection (code 16), so that transfer is resumed
380
+ raise Transfer::Error.new("#{exec} ended without final status (last event: #{last_event['Type']})", code: 16)
377
381
  end
378
382
  rescue SystemCallError => e
379
383
  # Process.spawn failed, or socket error
@@ -418,39 +422,63 @@ module Aspera
418
422
  attr_reader :sessions
419
423
 
420
424
  # Notify progress to callback
421
- # @param event [Hash] management port event
422
- def process_progress(event)
423
- session_id = event['SessionId']
425
+ # @param session [Hash] This session information, progress state is stored in it
426
+ # @param event [Hash] management port event
427
+ def process_progress(session, event)
428
+ # Not the `ascp` session id: must be the same when session is resumed
429
+ progress_id = session[:progress_id] ||= SecureRandom.uuid
424
430
  case event['Type']
425
431
  when 'INIT'
426
- @pre_calc_sent = false
427
- @pre_calc_last_size = nil
428
- notify_progress(:session_start, session_id: session_id)
432
+ session[:size_sent] = false
433
+ session[:last_size] = nil
434
+ session[:skipped] = 0
435
+ session[:last_skipped] = nil
436
+ notify_progress(:session_start, session_id: progress_id)
429
437
  when 'NOTIFICATION' # sent from remote
430
438
  if event.key?('PreTransferBytes')
431
- @pre_calc_sent = true
432
- notify_progress(:session_size, session_id: session_id, info: event['PreTransferBytes'])
439
+ session[:size_sent] = true
440
+ notify_progress(:session_size, session_id: progress_id, info: event['PreTransferBytes'])
433
441
  end
434
442
  when 'STATS' # during transfer
435
- @pre_calc_last_size = event['TransferBytes'].to_i + event['StartByte'].to_i
436
- notify_progress(:transfer, session_id: session_id, info: @pre_calc_last_size)
443
+ notify_transferred(session, event)
444
+ when 'STOP' # one file is completed
445
+ size = event['Size'].to_i
446
+ # Whole file already at destination (resume): not transferred, and not in `FileBytes`.
447
+ # Note: in multi-session, each session notifies all such files: progress bar limits progress to size of session.
448
+ session[:skipped] += size if size.positive? && event['StartByte'].to_i.eql?(size)
449
+ notify_transferred(session, event)
437
450
  when 'DONE', 'ERROR' # end of session
438
- total_size = event['TransferBytes'].to_i + event['StartByte'].to_i
439
- notify_progress(:session_size, session_id: session_id, info: total_size) if !@pre_calc_sent && !total_size.zero?
440
- notify_progress(:transfer, session_id: session_id, info: total_size) if @pre_calc_last_size != total_size
441
- notify_progress(:session_end, session_id: session_id)
451
+ notify_transferred(session, event)
452
+ total_size = session[:last_size] + session[:last_skipped]
453
+ notify_progress(:session_size, session_id: progress_id, info: total_size) if !session[:size_sent] && !total_size.zero?
454
+ notify_progress(:session_end, session_id: progress_id)
442
455
  # cspell:disable
443
456
  when 'SESSION'
444
457
  when 'ARGSTOP'
445
458
  when 'FILEERROR'
446
459
  Log.log.error { "#{event['Type']} #{event['Description']}" }
447
- when 'STOP'
448
460
  # cspell:enable
449
- # stop event when one file is completed
450
461
  else
451
462
  Log.log.debug { "Unknown event type for progress: #{event['Type']}" }
452
463
  end
453
464
  end
465
+
466
+ # Notify transferred size, and size not transferred because already at destination (resume), if changed
467
+ # @param session [Hash] This session information, progress state is stored in it
468
+ # @param event [Hash] management port event, with `TransferBytes` and `FileBytes`
469
+ def notify_transferred(session, event)
470
+ transferred = event['TransferBytes'].to_i
471
+ # `FileBytes` also includes the part of files already at destination.
472
+ # Note: `StartByte` is not used: in multi-session, it is the offset of the part of file transferred by the session.
473
+ skipped = session[:skipped] + [event['FileBytes'].to_i - transferred, 0].max
474
+ if session[:last_skipped] != skipped
475
+ session[:last_skipped] = skipped
476
+ notify_progress(:skip, session_id: session[:progress_id], info: skipped)
477
+ end
478
+ return if session[:last_size].eql?(transferred)
479
+ session[:last_size] = transferred
480
+ notify_progress(:transfer, session_id: session[:progress_id], info: transferred)
481
+ end
454
482
  end
455
483
  end
456
484
  end
@@ -38,6 +38,9 @@ module Aspera
38
38
  result['ended_at'] = Time.now.utc.iso8601
39
39
  when 'failed'
40
40
  result.merge!('status' => 'failed', 'ended_at' => Time.now.utc.iso8601, 'error' => data['error_desc'].to_s)
41
+ when 'canceled'
42
+ result['status'] = 'cancelled'
43
+ result['ended_at'] = Time.now.utc.iso8601
41
44
  else
42
45
  result['status'] = 'running'
43
46
  end
@@ -109,7 +112,7 @@ module Aspera
109
112
  # status is empty sometimes with status 200...
110
113
  transfer_data = node_api_.read("ops/transfers/#{@transfer_id}") || {'status' => 'unknown'} rescue {'status' => 'waiting(api error)'}
111
114
  case transfer_data['status']
112
- when 'waiting', 'partially_completed', 'unknown', 'waiting(read error)', 'waiting(api error)'
115
+ when 'waiting', 'partially_completed', 'paused', 'unknown', 'waiting(read error)', 'waiting(api error)'
113
116
  notify_progress(:sessions_init, info: transfer_data['status'])
114
117
  when 'running'
115
118
  if !session_started
@@ -129,13 +132,11 @@ module Aspera
129
132
  when 'completed'
130
133
  notify_progress(:transfer, session_id: @transfer_id, info: bytes_expected) if bytes_expected
131
134
  notify_progress(:session_end, session_id: @transfer_id)
132
- notify_progress(:end)
133
135
  break
134
- when 'failed'
136
+ when 'failed', 'canceled'
135
137
  notify_progress(:session_end, session_id: @transfer_id)
136
- notify_progress(:end)
137
138
  # Bug in HSTS ? transfer is marked failed, but there is no reason
138
- break if transfer_data['error_code'].eql?(0) && transfer_data['error_desc'].empty?
139
+ break if transfer_data['status'].eql?('failed') && transfer_data['error_code'].eql?(0) && transfer_data['error_desc'].empty?
139
140
  raise Transfer::Error, "status: #{transfer_data['status']}. code: #{transfer_data['error_code']}. description: #{transfer_data['error_desc']}"
140
141
  else Aspera.error_unexpected_value(transfer_data['status']) { "transfer_data -> #{transfer_data}" }
141
142
  end
@@ -140,6 +140,8 @@ module Aspera
140
140
 
141
141
  # Actual endpoint the daemon is listening on (resolved after connect, e.g. when port 0 was used)
142
142
  attr_reader :daemon_endpoint
143
+ # gRPC client of the daemon
144
+ attr_reader :transfer_client
143
145
 
144
146
  # :reek:UnusedParameters token_regenerator
145
147
  def start_transfer(transfer_spec, token_regenerator: nil)
@@ -181,11 +183,9 @@ module Aspera
181
183
  when :COMPLETED
182
184
  notify_progress(:transfer, session_id: @transfer_id, info: bytes_expected) if bytes_expected
183
185
  notify_progress(:session_end, session_id: @transfer_id)
184
- notify_progress(:end)
185
186
  break
186
187
  when :FAILED, :CANCELED
187
188
  notify_progress(:session_end, session_id: @transfer_id)
188
- notify_progress(:end)
189
189
  raise Transfer::Error, JSON.parse(response.message)['Description']
190
190
  when :QUEUED, :UNKNOWN_STATUS, :PAUSED, :ORPHANED
191
191
  notify_progress(:sessions_init, info: response.status.to_s.downcase)
@@ -138,6 +138,7 @@ module Aspera
138
138
  SENT_MAILBOX_TYPES.include?(box) || box == 'ALL' ? :sent : :received
139
139
  end
140
140
  end
141
+ # @return [Hash, nil] public link context decoded from URL (keys: resource, type, id, passcode, package_id, email), or nil if not a public link
141
142
  attr_reader :pub_link_context
142
143
 
143
144
  # @param url [String] Faspex URL, can be a public link
@@ -163,6 +164,7 @@ module Aspera
163
164
  passphrase: nil
164
165
  )
165
166
  auth = :public_link if self.class.public_link?(url)
167
+ # @type [Hash, nil]
166
168
  @pub_link_context = nil
167
169
  super(**
168
170
  case auth
@@ -254,12 +254,11 @@ module Aspera
254
254
  # session no more used
255
255
  @ws_io = nil
256
256
  http_session&.finish
257
- @notify_cb&.call(:end)
258
257
  end
259
258
 
260
259
  def download(transfer_spec)
261
260
  transfer_spec['source_root'] ||= '/'
262
- default_file_name = transfer_spec['paths'].first['source']
261
+ default_file_name = transfer_spec['paths'].first&.fetch('source')
263
262
  source_is_folder = %w[. /].include?(default_file_name)
264
263
  default_file_name = 'http_download' if source_is_folder
265
264
  transfer_spec['zip_required'] ||= source_is_folder || transfer_spec['paths'].length > 1
@@ -457,7 +457,7 @@ module Aspera
457
457
  create(
458
458
  'files/download_setup',
459
459
  {transfer_requests: [{transfer_request: {paths: [{source: '/'}]}}]}
460
- )['transfer_specs'].first['transfer_spec']
460
+ )['transfer_specs'].first&.fetch('transfer_spec')
461
461
  end
462
462
 
463
463
  # Get generic part of transfer spec with transport parameters only
@@ -617,8 +617,8 @@ module Aspera
617
617
  # Method called in loop for each entry for `resolve_api_fid`
618
618
  # @return [Boolean] `true` to continue digging, `false` to stop processing: set state[:result] if found
619
619
  def process_api_fid(entry, path, state)
620
- # Stop digging here if not in right path
621
- return false unless entry['name'].eql?(state[:path].first)
620
+ # Stop digging here if already found, or if not the next path element directly under the consumed path (not a sibling)
621
+ return false if state[:path].empty? || !path.eql?(File.join(PATH_SEPARATOR, *state[:consumed], state[:path].first))
622
622
  # Ok it matches, so we remove the match, and continue digging
623
623
  state[:consumed].push(state[:path].shift)
624
624
  path_fully_consumed = state[:path].empty?
@@ -311,8 +311,8 @@ module Aspera
311
311
  archive_io.write(File.binread(UriReader.file_path(url)))
312
312
  else
313
313
  Rest::Client.new(base_url: url, redirect_max: 3).call(operation: 'GET', save_to: archive_io)
314
- archive_io.rewind
315
314
  end
315
+ archive_io.rewind
316
316
  extract_archive_files(url, archive_io) do |entry_name, entry_stream, link_target|
317
317
  dest_folder = if block_given?
318
318
  yield(entry_name)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/log'
4
+ require 'aspera/secret_hider'
4
5
  require 'json'
5
6
  require 'securerandom'
6
7
 
@@ -59,14 +60,14 @@ module Aspera
59
60
  end
60
61
 
61
62
  # Persist (create or update) an async transfer entry.
62
- # Keys whose name starts with '_' are in-process references (e.g. '_agent_ref')
63
- # and are intentionally excluded from the JSON serialization — at every nesting level.
63
+ # Keys whose name starts with '_' are in-process references (e.g. '_agent_ref'), and secrets (e.g. agent `password`)
64
+ # are not written on disk: they are intentionally excluded from the JSON serialization — at every nesting level.
64
65
  # @param job_id [String] the ascli-generated UUID
65
66
  # @param data [Hash] fields to store (will be JSON-serialised)
66
67
  def write(job_id, data)
67
68
  Aspera.assert_type(job_id, String) { 'job_id' }
68
69
  Aspera.assert_type(data, Hash) { 'data' }
69
- @persistency.put(store_key(job_id), JSON.generate(strip_internal_keys(data)))
70
+ @persistency.put(store_key(job_id), JSON.generate(persistable(data)))
70
71
  nil
71
72
  end
72
73
 
@@ -105,17 +106,17 @@ module Aspera
105
106
  "#{CATEGORY}#{job_id}"
106
107
  end
107
108
 
108
- # Recursively remove keys whose name starts with '_' from a Hash.
109
- # Such keys hold in-process Ruby object references (e.g. '_agent_ref') that
110
- # cannot be serialized to JSON and must never reach the file system.
111
- def strip_internal_keys(value)
109
+ # Recursively remove from a Hash the keys that must never reach the file system:
110
+ # - keys whose name starts with '_': in-process Ruby object references (e.g. '_agent_ref') that cannot be serialized to JSON
111
+ # - secrets (e.g. 'password'): taken from current options when the transfer is queried
112
+ def persistable(value)
112
113
  case value
113
114
  when Hash
114
115
  value.each_with_object({}) do |(k, v), h|
115
- h[k] = strip_internal_keys(v) unless k.to_s.start_with?('_')
116
+ h[k] = persistable(v) unless k.to_s.start_with?('_') || SecretHider.instance.secret?(k, v)
116
117
  end
117
118
  when Array
118
- value.map { |v| strip_internal_keys(v) }
119
+ value.map { |v| persistable(v) }
119
120
  else
120
121
  value
121
122
  end
@@ -139,9 +139,11 @@ module Aspera
139
139
 
140
140
  # Declare + parse :progress_bar: sets context.progress_bar
141
141
  def setup_progress_bar
142
- @context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default: Environment.terminal?)
142
+ @context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default: $stderr.tty?)
143
143
  @context.options.parse_options!
144
144
  @context.progress_bar = TransferProgress.new if @context.options.get_option(:progress_bar)
145
+ # Log lines do not overwrite the progress bar
146
+ Log.instance.status_line = @context.progress_bar
145
147
  end
146
148
 
147
149
  # Declare + parse :fpac / :proxy_credentials: sets up PAC executor
@@ -20,6 +20,7 @@ module Aspera
20
20
  # own_children_of(path) - Hash{Symbol => CommandSpec} of direct children declared on the node, without mounted ones
21
21
  # arguments_at(path) - Array of ArgumentSpec read by the node at path (mount arguments first)
22
22
  # leaf_paths - Array of all leaf paths (follows mounts, or stops at mount nodes)
23
+ # command_path(words) - command path designated by command line words (aliases, arguments)
23
24
  # all_paths - Array of all locally registered full paths
24
25
  # any? - true if at least one spec has been registered
25
26
  # validate! - cross-spec consistency checks; raises on violation
@@ -138,6 +139,33 @@ module Aspera
138
139
  end
139
140
  end
140
141
 
142
+ # Command path designated by words of a command line: sub-commands (or their aliases),
143
+ # each possibly followed by its positional arguments, e.g. `packages receive ALL` designates `packages receive`.
144
+ # Words after a leaf command are its arguments.
145
+ # @param words [Array<Symbol>] words following the plugin name
146
+ # @return [Array(Array<Symbol>, Symbol)] command path, and the first word that is neither a sub-command
147
+ # nor an expected argument (`nil` if none)
148
+ def command_path(words)
149
+ path = []
150
+ # Number of positional arguments still accepted by the node at path
151
+ args_left = 0
152
+ words.each do |word|
153
+ children = children_of(path)
154
+ break if children.empty?
155
+ id = children.key?(word) ? word : children.find { |_, c| Array(c.aliases).include?(word) }&.first
156
+ if id
157
+ path += [id]
158
+ arguments = arguments_at(path)
159
+ args_left = arguments.any?(&:multiple) ? Float::INFINITY : arguments.length
160
+ elsif args_left.positive?
161
+ args_left -= 1
162
+ else
163
+ return [path, word]
164
+ end
165
+ end
166
+ [path, nil]
167
+ end
168
+
141
169
  # @return [Array<Array<Symbol>>] all locally registered full paths
142
170
  def all_paths
143
171
  @specs.keys
@@ -168,29 +196,42 @@ module Aspera
168
196
  end
169
197
 
170
198
  # Cross-spec consistency checks.
171
- # @param plugin_class [Class, nil] when given, also verify that implicit action methods exist
199
+ # @param plugin_class [Class, nil] when given, also verify that methods referenced by Symbol
200
+ # (implicit and explicit actions, `setup:`, `condition:`, `lookup:`, mount `instance:`) exist
172
201
  # @raise [ArgumentError] on any violation
173
202
  # @return [self]
174
203
  def validate!(plugin_class: nil)
175
- # Rule: every non-root parent path that appears in the children index must have
176
- # a registered CommandSpec. A missing parent means commands_under(:x) was used
177
- # without a matching command :x declaration.
178
- @children_index.each_key do |parent_path|
179
- next if parent_path.empty? # root is never a CommandSpec
180
- unless @specs.key?(parent_path)
204
+ @children_index.each do |parent_path, children|
205
+ # Rule: every non-root parent path that appears in the children index must have
206
+ # a registered CommandSpec. A missing parent means commands_under(:x) was used
207
+ # without a matching command :x declaration.
208
+ unless parent_path.empty? || @specs.key?(parent_path)
181
209
  raise ArgumentError,
182
210
  "commands_under(#{parent_path.map(&:inspect).join(', ')}) used but #{parent_path.last.inspect} has no command declaration"
183
211
  end
212
+ # Rule: an alias designates a single command, and does not hide a sibling command
213
+ aliases = children.values.flat_map { |c| Array(c.aliases) }
214
+ conflicts = aliases.select { |a| children.key?(a) || aliases.count(a) > 1 }.uniq
215
+ raise ArgumentError, "#{parent_path.inspect}: alias conflicts with a sibling command or alias: #{conflicts.inspect}" unless conflicts.empty?
184
216
  end
185
217
 
186
218
  @specs.each_value do |spec|
187
219
  path = spec.full_path
188
220
 
221
+ validate_arguments(path, spec.arguments, plugin_class)
222
+ # Rule: methods called on the plugin instance exist
223
+ if plugin_class
224
+ {setup: spec.setup, condition: spec.condition}.each do |attribute, method_name|
225
+ raise ArgumentError, "#{path.inspect}: no method #{method_name} on #{plugin_class} for #{attribute}:" unless method_name.nil? || instance_method?(plugin_class, method_name)
226
+ end
227
+ end
228
+
189
229
  if (mount = spec.mount)
190
230
  # Rule: a mount needs an instance method, no action, and must point to existing target nodes
191
231
  raise ArgumentError, "#{path.inspect}: mount requires instance:" if mount.instance.nil?
192
232
  # Mount arguments precede the arguments of the mounted command: they cannot be optional
193
233
  raise ArgumentError, "#{path.inspect}: mount arguments must be mandatory" unless mount.arguments.all?(&:mandatory)
234
+ validate_arguments(path, mount.arguments, plugin_class)
194
235
  raise ArgumentError, "#{path.inspect}: mount and action: are exclusive" if spec.action
195
236
  raise ArgumentError, "#{path.inspect}: mount at #{mount.at.inspect} not found in #{mount.plugin}" unless mount.at.empty? || mount.registry[mount.at]
196
237
  target_ids = mount.registry.children_of(mount.at).keys
@@ -229,6 +270,27 @@ module Aspera
229
270
  plugin_class.method_defined?(name) || plugin_class.private_method_defined?(name)
230
271
  end
231
272
 
273
+ # Consistency of the positional arguments of a node, in reading order.
274
+ # @param path [Array<Symbol>] node path, for error messages
275
+ # @param arg_specs [Array<ArgumentSpec>] arguments of the node (or of its mount)
276
+ # @param plugin_class [Class, nil] when given, verify that `lookup:` methods exist
277
+ # @raise [ArgumentError] on any violation
278
+ def validate_arguments(path, arg_specs, plugin_class)
279
+ previous = nil
280
+ Array(arg_specs).each do |arg|
281
+ where = "#{path.inspect}: argument #{arg.name}"
282
+ # Rule: an argument following an optional one, or one that takes all remaining arguments, is never read reliably
283
+ raise ArgumentError, "#{where}: mandatory after optional #{previous.name}" if previous && arg.mandatory && !previous.mandatory
284
+ raise ArgumentError, "#{where}: after #{previous.name}, which takes all remaining arguments" if previous&.multiple.eql?(true)
285
+ unless arg.lookup.nil?
286
+ # Rule: the percent-selector lookup is only used for identifiers
287
+ raise ArgumentError, "#{where}: lookup: requires type: :identifier" unless arg.type.eql?(:identifier)
288
+ raise ArgumentError, "#{where}: no method #{arg.lookup} on #{plugin_class} for lookup:" if arg.lookup.is_a?(Symbol) && plugin_class && !instance_method?(plugin_class, arg.lookup)
289
+ end
290
+ previous = arg
291
+ end
292
+ end
293
+
232
294
  # @param path [Array<Symbol>] non-empty path in this registry's namespace
233
295
  # @return [Array<ArgumentSpec>] arguments of the mount exposing the last segment of path, if any
234
296
  def mount_arguments(path)
@@ -23,7 +23,7 @@ module Aspera
23
23
  # Proc/lambda → called via instance_exec(field, value, **ctx, &lookup).
24
24
  # Style: use Symbol for named methods; ->(){} for 1-liners; lambda do…end for 2–3 statements.
25
25
  # @!attribute allowed [Array<Symbol>, nil] Allowed Symbol values; when set, type is forced to Symbol and accept_list is applied
26
- # @!attribute interactive [Boolean] When true, sets ask_missing_mandatory before resolving so interactive prompting is triggered when no CLI args are provided
26
+ # @!attribute interactive [Boolean] When true, prompts for this argument (only) when no CLI args are provided
27
27
  ArgumentSpec = Struct.new(
28
28
  :name,
29
29
  :description,
@@ -77,7 +77,7 @@ module Aspera
77
77
  case mode
78
78
  when '' then $stdin.read
79
79
  when 'bin' then $stdin.binmode.read
80
- when 'chomp' then $stdin.chomp
80
+ when 'chomp' then $stdin.read.chomp
81
81
  else raise BadArgument, "`stdin` supports only: '', 'bin' or 'chomp'"
82
82
  end
83
83
  end
@@ -118,10 +118,11 @@ module Aspera
118
118
  end
119
119
 
120
120
  # Update the Regex to match an extended value based on @handlers
121
+ # Anchored on the whole value (`\A`, `\z`), not on lines: a multi-line value is extended only if it starts with a modifier.
121
122
  def update_regex
122
123
  handler_regex = "#{MARKER_START}(#{modifiers.join('|')})#{MARKER_END}"
123
- @regex_single = Regexp.new("^#{handler_regex}(.*)$", Regexp::MULTILINE)
124
- @regex_extend = Regexp.new("^(.*)#{handler_regex}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)$", Regexp::MULTILINE)
124
+ @regex_single = Regexp.new("\\A#{handler_regex}(.*)\\z", Regexp::MULTILINE)
125
+ @regex_extend = Regexp.new("\\A(.*)#{handler_regex}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)\\z", Regexp::MULTILINE)
125
126
  end
126
127
 
127
128
  public
@@ -51,6 +51,8 @@ module Aspera
51
51
  option :multi_single, description: '(Table) Control how object list is displayed as single table, or multiple objects', allowed: %i[no yes single], default: :no, deprecation: {last: '4.27.0', message: 'use --out.table.pivot'}
52
52
  option :show_secrets, description: 'Show secrets on command output', allowed: Type::BOOLEAN, default: false, deprecation: {last: '4.27.0', message: 'use --out.secrets'}
53
53
  option :image, schema: Schema::Registry::IMAGE_OPTIONS, deprecation: {last: '4.27.0', message: 'use --out.img'}
54
+ # All formatter options are handled by `option_handler`, executed on the formatter instance
55
+ option_specs.each_value { |spec| spec.on_set = ->(value) { option_handler(spec.name, value) } }
54
56
 
55
57
  class << self
56
58
  # Replace special values with a readable version on terminal
@@ -146,15 +148,14 @@ module Aspera
146
148
  end
147
149
  end
148
150
 
149
- # Bind all formatter options to this instance using `on_set`.
151
+ # Declare formatter options on the parser, with `on_set` callbacks executed on this instance.
150
152
  # Called from Runner after Formatter.new.
151
- # @param options [Aspera::Cli::Parser]
153
+ # @param parser [Aspera::Cli::Parser]
152
154
  # @return [nil]
153
- def bind_options(options)
154
- @parser = options
155
- %i[out display format output fields select table_style flat_hash multi_single show_secrets image].each do |opt|
156
- options.on_set(opt, ->(value) { option_handler(opt, value) })
157
- end
155
+ def declare_options(parser)
156
+ # Used by `option_handler` to dispatch `--out` sub-options
157
+ @parser = parser
158
+ self.class.declare_options(parser, target: self)
158
159
  nil
159
160
  end
160
161
 
@@ -195,7 +196,8 @@ module Aspera
195
196
  # special handling of some options
196
197
  case option_symbol
197
198
  when :format
198
- @options[:display] = value.eql?(:table) ? :info : :data
199
+ # The default output level depends on the format, unless the level is set explicitly (e.g. `--out.level`)
200
+ @options[:display] = value.eql?(:table) ? :info : :data if @parser.nil? || @parser.option_def(:display).source.eql?(:default)
199
201
  when :output
200
202
  $stdout = if value.eql?('-')
201
203
  STDOUT # rubocop:disable Style/GlobalStdStream
@@ -26,12 +26,13 @@ module Aspera
26
26
 
27
27
  private_constant :CERT_EXT, :SELF_SIGNED_CERT
28
28
 
29
- option :insecure, description: 'HTTP/S: Do not validate any certificate', allowed: Type::BOOLEAN, default: false
30
- option :ignore_certificate, description: 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass]
31
- option :warn_insecure, description: 'HTTP/S: Issue a warning if certificate is ignored', allowed: Type::BOOLEAN, default: true
32
- option :cert_stores, description: 'HTTP/S: List of folder with trusted certificates', allowed: Type::STRING_ARRAY
33
- option :http_options, schema: Schema::Registry::HTTP_OPTIONS
34
- option :http_proxy, description: 'HTTP/S: URL for proxy with optional credentials'
29
+ # `on_set` callbacks are methods of the instance given as `target:` to `declare_options`
30
+ option :insecure, description: 'HTTP/S: Do not validate any certificate', allowed: Type::BOOLEAN, default: false, on_set: :insecure=
31
+ option :ignore_certificate, description: 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass], on_set: :ignore_cert_host_port=
32
+ option :warn_insecure, description: 'HTTP/S: Issue a warning if certificate is ignored', allowed: Type::BOOLEAN, default: true, on_set: :warn_insecure=
33
+ option :cert_stores, description: 'HTTP/S: List of folder with trusted certificates', allowed: Type::STRING_ARRAY, on_set: :trusted_cert_locations=
34
+ option :http_options, schema: Schema::Registry::HTTP_OPTIONS, on_set: :http_options=
35
+ option :http_proxy, description: 'HTTP/S: URL for proxy with optional credentials', on_set: :http_proxy=
35
36
 
36
37
  def initialize
37
38
  @insecure = false
@@ -46,19 +47,6 @@ module Aspera
46
47
  attr_accessor :insecure, :warn_insecure
47
48
  attr_reader :ignore_cert_host_port, :http_options
48
49
 
49
- # Bind all HTTP options to this instance using `on_set`.
50
- # Called from Config#initialize immediately after Http.new.
51
- # @param options [Aspera::Cli::Parser]
52
- # @return [nil]
53
- def bind_options(options)
54
- options.on_set(:insecure, method(:insecure=))
55
- options.on_set(:ignore_certificate, method(:ignore_cert_host_port=))
56
- options.on_set(:warn_insecure, method(:warn_insecure=))
57
- options.on_set(:cert_stores, method(:trusted_cert_locations=))
58
- options.on_set(:http_options, method(:http_options=))
59
- options.on_set(:http_proxy, method(:http_proxy=))
60
- end
61
-
62
50
  # Setter for http_options: dispatch each key to its target singleton immediately.
63
51
  # Keys matching Rest::Parameters setters go to Rest::Parameters, 'ssl_options' goes to SSL,
64
52
  # keys matching OAuth::Factory.instance.parameters go to OAuth, and the rest are kept
@@ -148,7 +136,7 @@ module Aspera
148
136
  if path.eql?(SpecialValues::DEF)
149
137
  @certificate_store.set_default_paths
150
138
  paths_to_add = [OpenSSL::X509::DEFAULT_CERT_DIR]
151
- paths_to_add.push(OpenSSL::X509::DEFAULT_CERT_FILE) unless defined?(JRUBY_VERSION)
139
+ paths_to_add.push(ENV.fetch(OpenSSL::X509::DEFAULT_CERT_FILE_ENV, OpenSSL::X509::DEFAULT_CERT_FILE)) unless defined?(JRUBY_VERSION)
152
140
  paths_to_add.select! { |f| File.exist?(f) }
153
141
  elsif File.file?(path)
154
142
  @certificate_store.add_file(path)
@@ -54,7 +54,7 @@ module Aspera
54
54
  end
55
55
 
56
56
  # Type specifiers for the `allowed:` parameter of option declarations.
57
- # Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
57
+ # Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, FLOAT, BOOLEAN, NONE.
58
58
  # Internal (do not pass as `allowed:`):
59
59
  # ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
60
60
  # STRING - the implicit default; equivalent to omitting `allowed:` entirely
@@ -65,6 +65,8 @@ module Aspera
65
65
  SYMBOL_ARRAY = [Array, Symbol].freeze
66
66
  # Option value is coerced to Integer
67
67
  INTEGER = [Integer].freeze
68
+ # Option value is coerced to Float
69
+ FLOAT = [Float].freeze
68
70
  # Option value is a Boolean
69
71
  BOOLEAN = BoolValue::TYPES
70
72
  # Option has no value — it is a flag switch (e.g. `-N`, `--help`)