aspera-cli 4.26.2 → 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 (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/agent/factory'
4
+ require 'aspera/schema/registry'
5
+ require 'aspera/cli/option_declarator'
6
+ require 'aspera/transfer/result'
4
7
  require 'aspera/transfer/spec'
5
8
  require 'aspera/cli/info'
9
+ require 'aspera/cli/async_transfer_store'
6
10
  require 'aspera/log'
7
11
  require 'aspera/assert'
8
- require 'aspera/schema/registry'
12
+ require 'securerandom'
9
13
 
10
14
  module Aspera
11
15
  module Cli
@@ -28,53 +32,46 @@ module Aspera
28
32
  <%=ts.to_yaml%>
29
33
  END_OF_TEMPLATE
30
34
  CP4I_REMOTE_HOST_LB = 'N/A'
31
- private_constant :FILE_LIST_FROM_ARGS,
32
- :FILE_LIST_FROM_TRANSFER_SPEC,
35
+ private_constant :FILE_LIST_FROM_TRANSFER_SPEC,
33
36
  :FILE_LIST_OPTIONS,
34
37
  :DEFAULT_TRANSFER_NOTIFY_TEMPLATE
35
38
 
36
- class << self
37
- # Analyze transfer session statuses and return a global status
38
- #
39
- # @param statuses [Array] list of session status, each status is :success or an error message string
40
- # @return [:success] if all sessions statuses returned by "start" are success
41
- # @return [Exception] if one sessions statuses returned by "start" is failed
42
- def session_status(statuses)
43
- error_statuses = statuses.reject{ |i| i.eql?(:success)}
44
- return :success if error_statuses.empty?
45
- return error_statuses.first
46
- end
47
- end
39
+ extend OptionDeclarator
40
+
41
+ option :ts, description: 'Override transfer spec values', schema: Schema::Registry::TRANSFER_SPEC
42
+ option :to_folder, description: 'Destination folder for transferred files'
43
+ option :sources, description: "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS
44
+ option :src_type, description: 'Type of file list', allowed: %i[list pair], default: :list
45
+ option :transfer, description: 'Transfer agent type, or agent parameters with optional agent key', allowed: [Hash, String], schema: Schema::Registry::TRANSFER_AGENT_OPTIONS
46
+ option :transfer_info, description: 'Parameters for transfer agent', deprecation: 'use --transfer instead', schema: Schema::Registry::TRANSFER_AGENT_OPTIONS
48
47
 
49
- # @param opt_mgr [Manager] Option manager
50
- # @param config_plugin [Config] Config plugin
51
- def initialize(opt_mgr, config_plugin)
52
- @opt_mgr = opt_mgr
53
- @config = config_plugin
48
+ # @param context [Context] Application context
49
+ def initialize(context)
50
+ Aspera.assert_type(context, Context) { 'context' }
51
+ Aspera.assert_type(context.options, Parser) { 'context.options' }
52
+ @context = context
54
53
  # Command line can override transfer spec
55
54
  @user_transfer_spec = {
56
55
  'create_dir' => true,
57
56
  'resume_policy' => 'sparse_csum'
58
57
  }
59
- # options for transfer agent
60
- @transfer_info = {}
58
+ # options for transfer agent (agent type + agent-specific parameters)
59
+ @transfer_options = {}
61
60
  # the currently selected transfer agent
62
61
  @agent = nil
63
62
  # source/destination pair, like "paths" of transfer spec
64
63
  @transfer_paths = nil
65
64
  # HTTPGW URL provided by webapp
66
65
  @httpgw_url_lambda = nil
67
- @opt_mgr.declare(:ts, 'Override transfer spec values', allowed: Hash, handler: {o: self, m: :user_transfer_spec}, schema: Schema::Registry::TRANSFER_SPEC)
68
- @opt_mgr.declare(:to_folder, 'Destination folder for transferred files')
69
- @opt_mgr.declare(:sources, "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS)
70
- @opt_mgr.declare(:src_type, 'Type of file list', allowed: %i[list pair], default: :list)
71
- @opt_mgr.declare(:transfer, 'Type of transfer agent', allowed: Agent::Factory::ALL.keys, default: :direct)
72
- @opt_mgr.declare(:transfer_info, 'Parameters for transfer agent', allowed: Hash, handler: {o: self, m: :transfer_info}, schema: Schema::Registry::TRANSFER_INFO)
73
- @opt_mgr.parse_options!
66
+ self.class.declare_options(@context.options)
67
+ @context.options.set_handler(:ts, object: self, method: :user_transfer_spec)
68
+ @context.options.set_handler(:transfer, object: self, method: :option_transfer)
69
+ @context.options.set_handler(:transfer_info, object: self, method: :transfer_options)
70
+ @context.options.parse_options!
74
71
  @notification_cb = nil
75
- if !@opt_mgr.get_option(:notify_to).nil?
72
+ if !@context.options.get_option(:notify_to).nil?
76
73
  @notification_cb = ->(transfer_spec, global_status) do
77
- @config.send_email_template(email_template_default: DEFAULT_TRANSFER_NOTIFY_TEMPLATE, values: {
74
+ @context.mailer.send_email_template(email_template_default: DEFAULT_TRANSFER_NOTIFY_TEMPLATE, values: {
78
75
  subject: "#{Info::CMD_NAME} transfer: #{global_status}",
79
76
  status: global_status,
80
77
  ts: transfer_spec
@@ -83,7 +80,23 @@ module Aspera
83
80
  end
84
81
  end
85
82
 
86
- attr_accessor :user_transfer_spec, :transfer_info
83
+ attr_accessor :user_transfer_spec, :transfer_options
84
+
85
+ # Composite option handler for :transfer
86
+ # String value: shorthand for agent type, stored as {'agent' => value}
87
+ # Hash value: merged into @transfer_options (may include 'agent' key)
88
+ def option_transfer(_option_sym, operation, value = nil)
89
+ Aspera.assert_values(operation, %i[set get])
90
+ case operation
91
+ when :set
92
+ value = {'agent' => value} if value.is_a?(String)
93
+ Aspera.assert_type(value, Hash)
94
+ @transfer_options = @transfer_options.deep_merge(value)
95
+ when :get
96
+ return @transfer_options
97
+ end
98
+ nil
99
+ end
87
100
 
88
101
  def agent_instance=(instance)
89
102
  @agent = instance
@@ -92,25 +105,27 @@ module Aspera
92
105
  # analyze options and create new agent if not already created or set
93
106
  def agent_instance
94
107
  return @agent unless @agent.nil?
95
- agent_type = @opt_mgr.get_option(:transfer, mandatory: true)
96
- # set keys as symbols
97
- agent_options = @opt_mgr.get_option(:transfer_info).symbolize_keys
98
- agent_options[:progress] = @config.progress_bar
99
- agent_options[:config_dir] = @config.main_folder
108
+ # agent type: from composite option 'agent' key, default :direct
109
+ raw_type = @transfer_options['agent'] || :direct
110
+ agent_type = Parser.get_from_list(raw_type.to_s, 'transfer agent', Agent::Factory::ALL.keys)
111
+ # set keys as symbols, strip internal keys not forwarded to the agent constructor
112
+ agent_options = @transfer_options.except('agent', 'asynchronous').symbolize_keys
113
+ agent_options[:progress] = @context.progress_bar
114
+ agent_options[:config_dir] = @context.main_folder
100
115
  # special cases
101
116
  case agent_type
102
117
  when :node
103
118
  if !agent_options.key?(:url)
104
- param_set_name = @config.get_plugin_default_config_name(:node)
105
- raise Cli::BadArgument, "No default node configured. Please specify #{Manager.option_name_to_line(:transfer_info)}" if param_set_name.nil?
106
- agent_options.merge!(@config.preset_by_name(param_set_name).symbolize_keys)
119
+ param_set_name = @context.presets.plugin_default_name(:node)
120
+ Aspera.assert(!param_set_name.nil?, type: Cli::BadArgument) { "No default node configured. Please specify #{Options.option_name_to_line(:transfer)}.url or #{Options.option_name_to_line(:transfer)}" }
121
+ agent_options.merge!(@context.presets.by_name(param_set_name).symbolize_keys)
107
122
  end
108
123
  when :direct
109
124
  # by default do not display ascp native progress bar
110
125
  agent_options[:quiet] = true unless agent_options.key?(:quiet)
111
- agent_options[:check_ignore_cb] = ->(host, port){@config.ignore_cert?(host, port)}
126
+ agent_options[:check_ignore_cb] = ->(host, port) { @context.http_config.ignore_cert?(host, port) }
112
127
  # JRuby
113
- agent_options[:trusted_certs] = @config.trusted_cert_locations unless agent_options.key?(:trusted_certs)
128
+ agent_options[:trusted_certs] = @context.http_config.trusted_cert_locations unless agent_options.key?(:trusted_certs)
114
129
  when :httpgw
115
130
  unless agent_options.key?(:url) || @httpgw_url_lambda.nil?
116
131
  Log.log.debug('retrieving HTTPGW URL from webapp')
@@ -119,7 +134,7 @@ module Aspera
119
134
  end
120
135
  # get agent instance
121
136
  self.agent_instance = Agent::Factory.instance.create(agent_type, agent_options)
122
- Log.log.debug{"transfer agent is a #{@agent.class}"}
137
+ Log.log.debug { "transfer agent is a #{@agent.class}" }
123
138
  return @agent
124
139
  end
125
140
 
@@ -127,7 +142,7 @@ module Aspera
127
142
  # @param direction [String] `send`` or `receive``
128
143
  # @return [String] Destination folder for transfers (with default based on direction)
129
144
  def destination_folder(direction)
130
- dest_folder = @opt_mgr.get_option(:to_folder)
145
+ dest_folder = @context.options.get_option(:to_folder)
131
146
  # do not expand path, if user wants to expand path: user @path:
132
147
  return dest_folder unless dest_folder.nil?
133
148
  dest_folder = @user_transfer_spec['destination_root']
@@ -150,22 +165,22 @@ module Aspera
150
165
 
151
166
  # @param httpgw_url_proc [Proc]
152
167
  def httpgw_url_cb=(httpgw_url_proc)
153
- Aspera.assert_type(httpgw_url_proc, Proc){'httpgw_url_cb'}
168
+ Aspera.assert_type(httpgw_url_proc, Proc) { 'httpgw_url_cb' }
154
169
  @httpgw_url_lambda = httpgw_url_proc
155
170
  end
156
171
 
157
172
  # Transform the list of paths to a list of hash with source/dest
158
173
  # @param file_list [Array<Hash>]
159
174
  def list_to_paths(file_list)
160
- source_type = @opt_mgr.get_option(:src_type, mandatory: true)
175
+ source_type = @context.options.get_option(:src_type, mandatory: true)
161
176
  @transfer_paths =
162
177
  case source_type
163
178
  when :list
164
179
  # when providing a list, just specify source
165
- file_list.map{ |i| {'source' => i}}
180
+ file_list.map { |i| {'source' => i} }
166
181
  when :pair
167
- Aspera.assert(file_list.length.even?, type: Cli::BadArgument){"When using pair, provide an even number of paths: #{file_list.length}"}
168
- file_list.each_slice(2).map{ |s, d| {'source' => s, 'destination' => d}}
182
+ Aspera.assert(file_list.length.even?, type: Cli::BadArgument) { "When using pair, provide an even number of paths: #{file_list.length}" }
183
+ file_list.each_slice(2).map { |s, d| {'source' => s, 'destination' => d} }
169
184
  else Aspera.error_unexpected_value(source_type)
170
185
  end
171
186
  end
@@ -181,41 +196,41 @@ module Aspera
181
196
  # start with lower priority : get paths from transfer spec on command line
182
197
  @transfer_paths = @user_transfer_spec['paths'] if @user_transfer_spec.key?('paths')
183
198
  # is there a source list option ?
184
- sources = @opt_mgr.get_option(:sources)
199
+ sources = @context.options.get_option(:sources)
185
200
  @transfer_paths =
186
201
  case sources
187
202
  when FILE_LIST_FROM_ARGS
188
203
  Log.log.debug('getting file list as parameters')
189
204
  Aspera.assert_type(default, Array, NilClass)
190
205
  # get remaining arguments
191
- list = @opt_mgr.get_next_argument('source file list', multiple: true, default: default)
206
+ list = @context.options.get_next_argument('source file list', multiple: true, default: default)
192
207
  raise Cli::BadArgument, 'specify at least one file on command line or use ' \
193
208
  "--sources=#{FILE_LIST_FROM_TRANSFER_SPEC} to use transfer spec" if !list.is_a?(Array) || list.empty?
194
209
  list_to_paths(list)
195
210
  when FILE_LIST_FROM_TRANSFER_SPEC
196
211
  Log.log.debug('assume list provided in transfer spec')
197
212
  special_case_direct_with_list =
198
- @opt_mgr.get_option(:transfer, mandatory: true).eql?(:direct) &&
199
- Transfer::Parameters.ascp_args_file_list?(@opt_mgr.get_option(:transfer_info)['ascp_args'])
200
- raise Cli::BadArgument, 'transfer spec on command line must have sources' if @transfer_paths.nil? && !special_case_direct_with_list
213
+ (@transfer_options['agent'] || :direct).to_sym.eql?(:direct) &&
214
+ Transfer::Parameters.ascp_args_file_list?(@transfer_options['ascp_args'])
215
+ Aspera.assert(!@transfer_paths.nil? || special_case_direct_with_list, type: Cli::BadArgument) { 'transfer spec on command line must have sources' }
201
216
  # can be nil
202
217
  @transfer_paths
203
218
  when Array
204
219
  Log.log.debug('getting file list as extended value')
205
- Aspera.assert_array_all(sources, String, type: Cli::BadArgument){'sources must be a Array of String'}
220
+ Aspera.assert_array_all(sources, String, type: Cli::BadArgument) { 'sources must be a Array of String' }
206
221
  list_to_paths(sources)
207
- else Aspera.error_unexpected_value(sources){'sources'}
222
+ else Aspera.error_unexpected_value(sources) { 'sources' }
208
223
  end
209
- Log.log.debug{"paths=#{@transfer_paths}"}
224
+ Log.dump(:paths, @transfer_paths)
210
225
  return @transfer_paths
211
226
  end
212
227
 
213
228
  # Start a transfer and wait for completion, plugins shall use this method
214
- # @param transfer_spec [Hash]
215
- # @param rest_token [Rest] if oauth token regeneration supported
229
+ # @param transfer_spec [Hash] transfer specification
230
+ # @param rest_token [Rest, nil] if oauth token regeneration supported
216
231
  def start(transfer_spec, rest_token: nil)
217
232
  # check parameters
218
- Aspera.assert_type(transfer_spec, Hash){'transfer_spec'}
233
+ Aspera.assert_type(transfer_spec, Hash) { 'transfer_spec' }
219
234
  raise "Wrong remote host: #{CP4I_REMOTE_HOST_LB}" if transfer_spec['remote_host'].eql?(CP4I_REMOTE_HOST_LB)
220
235
  # process :src option
221
236
  case transfer_spec['direction']
@@ -240,15 +255,60 @@ module Aspera
240
255
  @user_transfer_spec['paths'] = transfer_spec['paths'] || ts_source_paths
241
256
  # updated transfer spec with command line
242
257
  transfer_spec.deep_merge!(@user_transfer_spec)
258
+ # resolve pseudo-parameter: target_rate -> target_rate_kbps (overrides target_rate_kbps if both are present)
259
+ transfer_spec['target_rate_kbps'] = Transfer::Spec.rate_string_to_kbps(transfer_spec.delete('target_rate')) if transfer_spec.key?('target_rate')
243
260
  # recursively remove values that are nil (user wants to delete)
244
- transfer_spec.deep_do{ |hash, key, value, _unused| hash.delete(key) if value.nil?}
261
+ transfer_spec.deep_do { |hash, key, value, _unused| hash.delete(key) if value.nil? }
245
262
  # if TS from app has content_protection (e.g. F5), that means content is protected: ask password if not provided
246
- transfer_spec['content_protection_password'] = @opt_mgr.prompt_user_input('content protection password', sensitive: true) if transfer_spec['content_protection'].eql?('decrypt') && !transfer_spec.key?('content_protection_password')
263
+ transfer_spec['content_protection_password'] = @context.options.prompt_user_input('content protection password', sensitive: true) if transfer_spec['content_protection'].eql?('decrypt') && !transfer_spec.key?('content_protection_password')
247
264
  # create transfer agent
248
265
  agent_instance.start_transfer(transfer_spec, token_regenerator: rest_token)
249
- # list of: :success or "error message string"
266
+ # --- async mode ---
267
+ if @transfer_options['asynchronous']
268
+ agent_type = (@transfer_options['agent'] || 'direct').to_s
269
+ # In-process agents (direct, httpgw) expose last_job_id directly.
270
+ # Remote-daemon agents do not override last_job_id (returns nil): generate a UUID.
271
+ job_id = agent_instance.last_job_id || SecureRandom.uuid
272
+ # Base agent_params from transfer options (strip internal keys)
273
+ agent_params = @transfer_options.except('agent', 'asynchronous')
274
+ # For daemon agents: capture any runtime-resolved connection detail
275
+ case agent_type
276
+ when 'desktop'
277
+ agent_params['application_id'] = agent_instance.application_id
278
+ when 'connect'
279
+ agent_params['app_id'] = agent_instance.app_id
280
+ when 'transferd'
281
+ agent_params['url'] = agent_instance.daemon_endpoint
282
+ end
283
+ # Register an in-process agent reference for direct/httpgw agents so that
284
+ # config transfer status can re-query them while the same process is alive
285
+ # (e.g. MCP server mode). The ref is stored in AsyncTransferStore's memory map,
286
+ # never on disk.
287
+ async_store.register_agent_ref(job_id, agent_instance) if %w[direct httpgw].include?(agent_type)
288
+ # transfer_id: in-process agents use job_id; daemon agents expose @transfer_id
289
+ transfer_id =
290
+ if %w[direct httpgw].include?(agent_type)
291
+ job_id
292
+ else
293
+ agent_instance.instance_variable_get(:@transfer_id).to_s
294
+ end
295
+ async_store.write(job_id, {
296
+ 'job_id' => job_id,
297
+ 'agent_type' => agent_type,
298
+ 'transfer_id' => transfer_id,
299
+ 'agent_params' => agent_params,
300
+ 'status' => 'running',
301
+ 'bytes_transferred' => 0,
302
+ 'started_at' => Time.now.utc.iso8601,
303
+ 'ended_at' => nil,
304
+ 'error' => nil
305
+ })
306
+ Log.log.info { "Async transfer started: job_id=#{job_id}, agent=#{agent_type}" }
307
+ return Transfer::Result.async(job_id: job_id)
308
+ end
309
+ # --- synchronous mode (default) ---
250
310
  result = agent_instance.wait_for_completion
251
- @notification_cb&.call(transfer_spec, self.class.session_status(result))
311
+ @notification_cb&.call(transfer_spec, result)
252
312
  return result
253
313
  end
254
314
 
@@ -256,6 +316,11 @@ module Aspera
256
316
  def shutdown
257
317
  @agent.shutdown if @agent.respond_to?(:shutdown)
258
318
  end
319
+
320
+ # Lazy accessor for the async transfer store (shared with TransferActions via context.transfer).
321
+ def async_store
322
+ @async_store ||= AsyncTransferStore.new(@context.persistency)
323
+ end
259
324
  end
260
325
  end
261
326
  end
@@ -25,16 +25,16 @@ module Aspera
25
25
  end
26
26
 
27
27
  # Called by user of progress bar with a status on a transfer session
28
- # @param session_id the unique identifier of a transfer session
28
+ # @param session_id [String] the unique identifier of a transfer session
29
29
  # @param type [Symbol] one of: sessions_init, session_start, session_size, transfer, session_end and end
30
- # @param info optional specific additional info for the given event type
30
+ # @param info [Object, nil] optional specific additional info for the given event type
31
31
  def event(type, session_id: nil, info: nil)
32
- Log.log.trace1{"progress: #{type} #{session_id} #{info}"}
32
+ Log.log.trace1 { "progress: #{type} #{session_id} #{info}" }
33
33
  return if @completed
34
34
  if @progress_bar.nil?
35
35
  @progress_bar = ProgressBar.create(
36
36
  format: '%t %a %B %p%% %r Mbps %E',
37
- rate_scale: lambda{ |rate| rate / Environment::BYTES_PER_MEBIBIT},
37
+ rate_scale: lambda { |rate| rate / Environment::BYTES_PER_MEBIBIT },
38
38
  title: '',
39
39
  total: nil
40
40
  )
@@ -50,7 +50,7 @@ module Aspera
50
50
  when :session_start
51
51
  Aspera.assert_type(session_id, String)
52
52
  Aspera.assert(info.nil?, 'info must be nil for :session_start event')
53
- raise "Session #{session_id} already started" if @sessions[session_id]
53
+ Aspera.assert(!@sessions[session_id]) { "Session #{session_id} already started" }
54
54
  @sessions[session_id] = {
55
55
  job_size: 0, # Total size of transfer (pre-calc)
56
56
  current: 0,
@@ -83,19 +83,19 @@ module Aspera
83
83
  Aspera.assert(session_id.nil?, 'session_id must be nil for :end event')
84
84
  Aspera.assert(info.nil?, 'info must be nil for :end event')
85
85
  @progress_bar.finish
86
- else Aspera.error_unexpected_value(type){'event type'}
86
+ else Aspera.error_unexpected_value(type) { 'event type' }
87
87
  end
88
- new_title = @sessions.length < 2 ? @title.to_s : "[#{@sessions.count{ |_i, d| d[:running]}}] #{@title}"
88
+ new_title = @sessions.length < 2 ? @title.to_s : "[#{@sessions.count { |_i, d| d[:running] }}] #{@title}"
89
89
  @progress_bar&.title = new_title unless @progress_bar&.title.eql?(new_title)
90
90
  @progress_bar&.increment if !progress_provided && @progress_bar.progress.nil?
91
91
  rescue ProgressBar::InvalidProgressError => e
92
- Log.log.error{"Progress error: #{e}"}
92
+ Log.log.error { "Progress error: #{e}" }
93
93
  end
94
94
 
95
95
  private
96
96
 
97
97
  def total(key)
98
- @sessions.values.inject(0){ |m, s| m + s[key]}
98
+ @sessions.values.inject(0) { |m, s| m + s[key] }
99
99
  end
100
100
  end
101
101
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/error'
4
+ require 'aspera/log'
5
+ require 'aspera/assert'
6
+ require 'json'
7
+
8
+ module Aspera
9
+ module Cli
10
+ # Mixin providing vault/keychain functionality to Plugin::Config.
11
+ # Depends on `options` and `context.main_folder` being available in the including class.
12
+ module VaultManager
13
+ def action_vault_show(label:, id: nil, **)
14
+ v = vault_required
15
+ kwargs = id && v.method(:get).parameters.any? { |_t, n| n == :id } ? {id: id} : {}
16
+ Result::SingleObject.new(v.get(label: label, **kwargs))
17
+ end
18
+
19
+ def action_vault_create(info:, **)
20
+ vault_required.set(info.symbolize_keys)
21
+ Result::Status.new('Secret added')
22
+ end
23
+
24
+ # Import secrets from a JSON array; skips entries missing :label
25
+ def action_vault_import(secrets:, **)
26
+ bulk_result(secrets, command: :import, id_result: 'label') do |entry|
27
+ vault_required.set(entry.symbolize_keys)
28
+ {'label' => entry['label'] || entry[:label]}
29
+ end
30
+ end
31
+
32
+ def action_vault_delete(label:, id: nil, **)
33
+ v = vault_required
34
+ kwargs = id && v.method(:delete).parameters.any? { |_t, n| n == :id } ? {id: id} : {}
35
+ v.delete(label: label, **kwargs)
36
+ Result::Status.new("Secret deleted: #{label}")
37
+ end
38
+
39
+ def action_vault_password(new_password:, **)
40
+ Aspera.assert(vault_required.respond_to?(:change_password), 'Vault does not support password change')
41
+ vault_required.change_password(new_password)
42
+ Result::Status.new('Vault password updated')
43
+ end
44
+
45
+ # @return [Keychain::Base] vault instance, raises if not configured
46
+ def vault_required
47
+ Aspera.assert(!vault.nil?, type: Cli::BadArgument) { 'Missing mandatory option: vault' }
48
+ vault
49
+ end
50
+
51
+ # @return [String] value from vault matching <name>.<param>
52
+ def vault_value(name)
53
+ m = name.split('.')
54
+ Aspera.assert(m.length.eql?(2), type: BadArgument) { 'vault name shall match <name>.<param>' }
55
+ info = vault_required.get(label: m[0])
56
+ value = info[m[1].to_sym]
57
+ raise "no such entry value: #{m[1]}" if value.nil?
58
+ return value
59
+ end
60
+
61
+ # @return [Keychain::Base, nil] vault instance, lazily created from options, or nil if not configured
62
+ def vault
63
+ return @vault_instance if @vault_instance_initialized
64
+ @vault_instance_initialized = true
65
+ info = options.get_option(:vault, mandatory: false)
66
+ return @vault_instance = nil if info.nil? || (info.is_a?(Hash) && info.empty?)
67
+ info = info.symbolize_keys
68
+ info[:type] ||= 'file'
69
+ require 'aspera/keychain/factory'
70
+ @vault_instance = Keychain::Factory.create(
71
+ info,
72
+ Info::CMD_NAME,
73
+ context.main_folder,
74
+ options.get_option(:vault_password)
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
@@ -4,6 +4,6 @@ module Aspera
4
4
  module Cli
5
5
  # For beta add extension : .beta1
6
6
  # For dev version add extension : .pre
7
- VERSION = '4.26.2'
7
+ VERSION = '4.27.1'
8
8
  end
9
9
  end
@@ -2,27 +2,30 @@
2
2
 
3
3
  require 'aspera/oauth/jwt'
4
4
  require 'aspera/assert'
5
+ require 'aspera/cli/option_declarator'
5
6
  require 'aspera/cli/plugins/factory'
6
7
 
7
8
  module Aspera
8
9
  module Cli
9
10
  # The wizard detects applications and generates a config
10
11
  class Wizard
12
+ extend OptionDeclarator
13
+
11
14
  WIZARD_RESULT_KEYS = %i[preset_value test_args].freeze
12
15
  DEFAULT_PRIV_KEY_FILENAME = 'my_private_key.pem' # pragma: allowlist secret
13
- private_constant :WIZARD_RESULT_KEYS,
14
- :DEFAULT_PRIV_KEY_FILENAME
16
+ private_constant :WIZARD_RESULT_KEYS, :DEFAULT_PRIV_KEY_FILENAME
17
+
18
+ option :override, description: 'Wizard: override existing value', allowed: Type::BOOLEAN, default: false
19
+ option :default, description: 'Wizard: set as default configuration for specified plugin (also: update)', allowed: Type::BOOLEAN, default: true
20
+ option :key_path, description: 'Wizard: path to private key for JWT'
15
21
 
16
22
  def initialize(parent, main_folder)
17
23
  @parent = parent
18
24
  @main_folder = main_folder
19
- # Wizard options
20
- options.declare(:override, 'Wizard: override existing value', allowed: Allowed::TYPES_BOOLEAN, default: false)
21
- options.declare(:default, 'Wizard: set as default configuration for specified plugin (also: update)', allowed: Allowed::TYPES_BOOLEAN, default: true)
22
- options.declare(:key_path, 'Wizard: path to private key for JWT')
25
+ self.class.declare_options(options)
23
26
  end
24
27
 
25
- # @return false if in test mode to avoid interactive input
28
+ # @return [Boolean] false if in test mode to avoid interactive input
26
29
  def required
27
30
  !ENV['ASCLI_WIZ_TEST']
28
31
  end
@@ -35,20 +38,15 @@ module Aspera
35
38
  @parent.formatter
36
39
  end
37
40
 
38
- def config
39
- @parent.config
40
- end
41
-
42
41
  def check_email(email)
43
- Aspera.assert(email =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i, type: ParameterError){"Username shall be an email: #{email}"}
42
+ Aspera.assert(email =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i, type: ParameterError) { "Username shall be an email: #{email}" }
44
43
  end
45
44
 
46
45
  # Find a plugin, and issue the "require"
47
46
  # @return [Hash] plugin info: { product:, name:, url:, version: }
48
- def identify_plugins_for_url
49
- app_url = options.get_next_argument('url', mandatory: true)
50
- check_only = options.get_next_argument('plugin name', mandatory: false)
51
- check_only = check_only.to_sym unless check_only.nil?
47
+ def identify_plugins_for_url(url:, plugin_name: nil)
48
+ app_url = url
49
+ check_only = plugin_name&.to_sym
52
50
  found_apps = []
53
51
  my_self_plugin_sym = self.class.name.split('::').last.downcase.to_sym
54
52
  Plugins::Factory.instance.plugin_list.each do |plugin_name_sym|
@@ -61,26 +59,26 @@ module Aspera
61
59
  next unless plugin_klass.respond_to?(:detect)
62
60
  detection_info = nil
63
61
  begin
64
- Log.log.debug{"detecting #{plugin_name_sym} at #{app_url}"}
62
+ Log.log.debug { "detecting #{plugin_name_sym} at #{app_url}" }
65
63
  RestParameters.instance.spinner_cb.call(plugin_name_sym.to_s)
66
64
  detection_info = plugin_klass.detect(app_url)
67
65
  rescue OpenSSL::SSL::SSLError => e
68
66
  Log.log.warn(e.message)
69
67
  Log.log.warn('Use option --insecure=yes to allow unchecked certificate') if e.message.include?('cert')
70
68
  rescue StandardError => e
71
- Log.log.debug{"detect error: [#{e.class}] #{e}"}
69
+ Log.log.debug { "detect error: [#{e.class}] #{e}" }
72
70
  next
73
71
  end
74
72
  next if detection_info.nil?
75
73
  Aspera.assert_type(detection_info, Hash)
76
74
  Aspera.assert_type(detection_info[:url], String) if detection_info.key?(:url)
77
- app_name = plugin_klass.respond_to?(:application_name) ? plugin_klass.application_name : plugin_klass.name.split('::').last
75
+ app_name = plugin_klass.application_name
78
76
  # If there is a redirect, then the detector can override the url.
79
77
  found_apps.push({product: plugin_name_sym, name: app_name, url: app_url, version: 'unknown'}.merge(detection_info))
80
78
  end
81
79
  RestParameters.instance.spinner_cb.call(action: :success)
82
- raise "No known application found at #{app_url}" if found_apps.empty?
83
- Aspera.assert(found_apps.all?{ |a| a.keys.all?(Symbol)}, 'all app info keys must be symbols')
80
+ Aspera.assert(!found_apps.empty?) { "No known application found at #{app_url}" }
81
+ Aspera.assert(found_apps.all? { |a| a.keys.all?(Symbol) }, 'all app info keys must be symbols')
84
82
  return found_apps
85
83
  end
86
84
 
@@ -122,18 +120,18 @@ module Aspera
122
120
 
123
121
  # Wizard function, creates configuration
124
122
  # @param apps [Array] list of detected apps
125
- def find(apps)
123
+ def find(apps, preset_name: '')
126
124
  identification = if apps.length.eql?(1)
127
- Log.log.debug{"Detected: #{identification}"}
125
+ Log.log.debug { "Detected: #{identification}" }
128
126
  apps.first
129
127
  else
130
128
  formatter.display_status('Multiple applications detected, please select from:')
131
129
  require 'aspera/cli/result'
132
130
  formatter.display_results(Result::ObjectList.new(apps, fields: %w[product url version]))
133
- answer = options.prompt_user_input_in_list('product', apps.map{ |a| a[:product]})
134
- apps.find{ |a| a[:product].eql?(answer)}
131
+ answer = options.prompt_user_input_in_list('product', apps.map { |a| a[:product] })
132
+ apps.find { |a| a[:product].eql?(answer) }
135
133
  end
136
- wiz_preset_name = options.get_next_argument('preset name', default: '')
134
+ wiz_preset_name = preset_name
137
135
  Log.dump(:identification, identification)
138
136
  wiz_url = identification[:url]
139
137
  formatter.display_status("Using: #{identification[:name]} at #{wiz_url}".bold)
@@ -141,11 +139,11 @@ module Aspera
141
139
  options.add_option_preset({url: wiz_url}, 'wizard')
142
140
  # Instantiate plugin: command line options will be known, e.g. private_key, and wizard can be called
143
141
  plugin_instance = Plugins::Factory.instance.plugin_class(identification[:product]).new(context: @parent.context)
144
- Aspera.assert(plugin_instance.respond_to?(:wizard), type: Cli::BadArgument){"Detected: #{identification[:product]}, but this application has no wizard"}
142
+ Aspera.assert(plugin_instance.respond_to?(:wizard), type: Cli::BadArgument) { "Detected: #{identification[:product]}, but this application has no wizard" }
145
143
  # Call the wizard
146
144
  wizard_result = plugin_instance.wizard(self, wiz_url)
147
- Log.log.debug{"wizard result: #{wizard_result}"}
148
- Aspera.assert(WIZARD_RESULT_KEYS.eql?(wizard_result.keys.sort)){"missing or extra keys in wizard result: #{wizard_result.keys}"}
145
+ Log.log.debug { "wizard result: #{wizard_result}" }
146
+ Aspera.assert(WIZARD_RESULT_KEYS.eql?(wizard_result.keys.sort)) { "missing or extra keys in wizard result: #{wizard_result.keys}" }
149
147
  # Get preset name from user or default
150
148
  if wiz_preset_name.empty?
151
149
  elements = [
@@ -160,7 +158,7 @@ module Aspera
160
158
  # Init defaults if necessary
161
159
  option_override = options.get_option(:override, mandatory: true)
162
160
  option_default = options.get_option(:default, mandatory: true)
163
- config.defaults_set(identification[:product], wiz_preset_name, wizard_result[:preset_value].stringify_keys, option_default, option_override)
161
+ @parent.context.presets.defaults_set(identification[:product], wiz_preset_name, wizard_result[:preset_value].stringify_keys, option_default, option_override)
164
162
  test_args = wizard_result[:test_args]
165
163
  test_args = "-P#{wiz_preset_name} #{test_args}" unless option_default
166
164
  # TODO: actually test the command
data/lib/aspera/colors.rb CHANGED
@@ -52,9 +52,9 @@ class String
52
52
  0 # by default reset all
53
53
  end
54
54
  )
55
- define_method(name){"#{begin_seq}#{self}#{end_seq}"}
55
+ define_method(name) { "#{begin_seq}#{self}#{end_seq}" }
56
56
  else
57
- define_method(name){self}
57
+ define_method(name) { self }
58
58
  end
59
59
  end
60
60
 
@@ -62,7 +62,7 @@ class String
62
62
  # @param colors [Array<Symbol>] List of decorations.
63
63
  # @return [String] Enhanced String.
64
64
  def apply(*colors)
65
- colors.reduce(self){ |s, c| s.public_send(c)}
65
+ colors.reduce(self) { |s, c| s.public_send(c) }
66
66
  end
67
67
 
68
68
  # Transform capitalized to snake case