aspera-cli 4.26.2 → 4.27.0

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 (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'aspera/cli/command_spec'
3
4
  require 'aspera/cli/context'
4
- require 'aspera/cli/manager'
5
+ require 'aspera/cli/parser'
5
6
  require 'aspera/cli/formatter'
6
7
  require 'aspera/cli/plugins/factory'
8
+ require 'aspera/cli/bootstrapper'
7
9
  require 'aspera/cli/plugins/config'
10
+ require 'aspera/cli/mailer'
11
+ require 'aspera/cli/secret_finder'
8
12
  require 'aspera/cli/extended_value'
9
13
  require 'aspera/cli/transfer_agent'
10
14
  require 'aspera/cli/version'
11
15
  require 'aspera/cli/info'
12
16
  require 'aspera/cli/hints'
13
17
  require 'aspera/cli/result'
18
+ require 'aspera/transfer/result'
14
19
  require 'aspera/secret_hider'
15
20
  require 'aspera/log'
16
21
  require 'aspera/assert'
@@ -25,38 +30,45 @@ module Aspera
25
30
  class Runner
26
31
  # Plugins store transfer result using this key and use result_transfer_multiple()
27
32
  STATUS_FIELD = 'status'
28
- COMMAND_CONFIG = :config
29
- COMMAND_HELP = :help
30
- # Types that go to result of type = text
31
- SCALAR_TYPES = [String, Integer, Symbol].freeze
32
- USER_INTERFACES = %i[text graphical].freeze
33
-
34
- private_constant :COMMAND_CONFIG, :COMMAND_HELP, :SCALAR_TYPES, :USER_INTERFACES
35
33
 
36
34
  class << self
37
- # Process statuses of finished transfer sessions
38
- # @param statuses [Array] array of transfer session statuses
39
- # @raise [Symbol] exception if there is one error
40
- # @return [Result] empty status result if all transfers succeeded
41
- def result_transfer(statuses)
42
- worst = TransferAgent.session_status(statuses)
43
- raise worst unless worst.eql?(:success)
44
- return Result::Nothing.new
35
+ # Process the typed result of a finished (or submitted) transfer.
36
+ # @param transfer_result [Transfer::Result] typed result from TransferAgent#start
37
+ # @raise [StandardError] if the transfer failed
38
+ # @return [Result] CLI result object
39
+ def result_transfer(transfer_result)
40
+ case transfer_result
41
+ when Transfer::Result::Async
42
+ return Result::SingleObject.new(transfer_result.to_h)
43
+ when Transfer::Result::Error
44
+ raise transfer_result.exception
45
+ when Transfer::Result::Success
46
+ return Result::Nothing.new
47
+ else
48
+ raise "Unexpected transfer result type: #{transfer_result.class}"
49
+ end
45
50
  end
46
51
 
47
52
  # Used when one command executes several transfer jobs (each job being possibly multi session)
48
- # @param status_table [Array] [{STATUS_FIELD=>[status array],...},...]
53
+ # @param status_table [Array] [{STATUS_FIELD=>Transfer::Result,...},...]
49
54
  # @return [Result] a status object suitable as command result
50
- # Each element has a key STATUS_FIELD which contains the result of possibly multiple sessions
51
55
  def result_transfer_multiple(status_table)
52
- global_status = :success
53
- # Transform status array into string and find if there was problem
56
+ failed_result = nil
54
57
  status_table.each do |item|
55
- worst = TransferAgent.session_status(item[STATUS_FIELD])
56
- global_status = worst unless worst.eql?(:success)
57
- item[STATUS_FIELD] = item[STATUS_FIELD].join(',')
58
+ tr = item[STATUS_FIELD]
59
+ case tr
60
+ when Transfer::Result::Error
61
+ failed_result ||= tr
62
+ item[STATUS_FIELD] = tr.exception.message
63
+ when Transfer::Result::Success
64
+ item[STATUS_FIELD] = 'success'
65
+ when Transfer::Result::Async
66
+ item[STATUS_FIELD] = "async:#{tr.job_id}"
67
+ else
68
+ item[STATUS_FIELD] = tr.to_s
69
+ end
58
70
  end
59
- raise global_status unless global_status.eql?(:success)
71
+ raise failed_result.exception unless failed_result.nil?
60
72
  return Result::ObjectList.new(status_table)
61
73
  end
62
74
  end
@@ -72,73 +84,80 @@ module Aspera
72
84
  @context = Context.new
73
85
  end
74
86
 
75
- # This is the main function called by initial script just after constructor
76
- # Processes command line arguments, executes commands, and handles exceptions
77
- # @return [nil]
78
- def run
79
- # Catch exception information , if any
80
- exception_info = nil
81
- # False if command shall not be executed (e.g. --show-config)
82
- execute_command = true
83
- # Catch exceptions
84
- begin
85
- init_agents_and_options
86
- # Find plugins, shall be after parse! ?
87
- Plugins::Factory.instance.add_plugins_from_lookup_folders
88
- # Help requested without command ? (plugins must be known here)
89
- show_usage if @option_help && @context.options.command_or_arg_empty?
90
- @context.config.periodic_check_newer_gem_version
91
- command_sym =
92
- if @option_show_config && @context.options.command_or_arg_empty?
93
- COMMAND_CONFIG
94
- else
95
- @context.options.get_next_command(Plugins::Factory.instance.plugin_list.unshift(COMMAND_HELP))
96
- end
97
- # Command will not be executed, but we need manual
98
- @context.options.fail_on_missing_mandatory = false if @option_help || @option_show_config
99
- # Main plugin is not dynamically instantiated
100
- case command_sym
101
- when COMMAND_HELP
102
- show_usage
103
- when COMMAND_CONFIG
104
- command_plugin = @context.config
87
+ attr_reader :context
88
+
89
+ # Execute the command and return the raw `Result` object.
90
+ # Pure computation: no display, no Process.exit - raises on any error.
91
+ # @return [Result, nil] the result of the command, or nil if nothing to execute
92
+ def run_with_result
93
+ init_agents_and_options
94
+ Plugins::Factory.instance.add_plugins_from_lookup_folders
95
+ # Help requested without command? Show global options + plugin list
96
+ return result_usage if @option_help && @context.options.command_or_arg_empty?
97
+ @context.config.periodic_check_newer_gem_version
98
+ command_sym =
99
+ if @option_show_config && @context.options.command_or_arg_empty?
100
+ COMMAND_CONFIG
105
101
  else
106
- # Get plugin, set options, etc
107
- command_plugin = get_plugin_instance_with_options(command_sym)
108
- # Parse plugin specific options
109
- @context.options.parse_options!
102
+ @context.options.get_next_command(Plugins::Factory.instance.plugin_list.unshift(COMMAND_HELP))
110
103
  end
111
- # Help requested for current plugin
112
- show_usage(all: false) if @option_help
113
- if @option_show_config
114
- @context.formatter.display_results(Result::SingleObject.new(@context.options.known_options(only_defined: true).stringify_keys))
104
+ @context.options.fail_on_missing_mandatory = false if @option_help || @option_show_config
105
+ case command_sym
106
+ when COMMAND_HELP
107
+ return result_usage
108
+ when COMMAND_CONFIG
109
+ command_plugin = @context.config
110
+ else
111
+ command_plugin = get_plugin_instance_with_options(command_sym)
112
+ @context.options.parse_options!
113
+ end
114
+ # --help after a plugin name: if no positional args remain, show plugin-level help now.
115
+ # If args remain (e.g. `ascli aoc files -h`), let the dispatch consume them and
116
+ # intercept --help at the right depth via Cli::HelpRequest.
117
+ return result_usage(plugin: command_plugin) if @option_help && @context.options.command_or_arg_empty?
118
+ if @option_show_config
119
+ result = Result::SingleObject.new(@context.options.known_options(only_defined: true).stringify_keys)
120
+ @context.presets.save_if_needed
121
+ @context.transfer.shutdown
122
+ TempFileManager.instance.cleanup
123
+ return result
124
+ end
125
+ execute_command = true
126
+ lock_port = @context.options.get_option(:lock_port)
127
+ if !lock_port.nil?
128
+ begin
129
+ Log.log.debug{"Opening lock port #{lock_port}"}
130
+ @tcp_server = TCPServer.new('127.0.0.1', lock_port)
131
+ rescue StandardError => e
115
132
  execute_command = false
133
+ Log.log.warn{"Another instance is already running (#{e.message})."}
116
134
  end
117
- # Locking for single execution (only after "per plugin" option, in case lock port is there)
118
- lock_port = @context.options.get_option(:lock_port)
119
- if !lock_port.nil?
120
- begin
121
- # No need to close later, will be freed on process exit. must save in member else it is garbage collected
122
- Log.log.debug{"Opening lock port #{lock_port}"}
123
- # Loopback address, could also be 'localhost'
124
- @tcp_server = TCPServer.new('127.0.0.1', lock_port)
125
- rescue StandardError => e
126
- execute_command = false
127
- Log.log.warn{"Another instance is already running (#{e.message})."}
128
- end
129
- end
130
- pid_file = @context.options.get_option(:pid_file)
131
- if !pid_file.nil?
132
- File.write(pid_file, Process.pid)
133
- Log.log.debug{"Wrote pid #{Process.pid} to #{pid_file}"}
134
- at_exit{File.delete(pid_file)}
135
- end
136
- # Execute and display (if not exclusive execution)
137
- @context.formatter.display_results(command_plugin.execute_action) if execute_command
138
- # Save config file if command modified it
139
- @context.config.save_config_file_if_needed
140
- # Finish
135
+ end
136
+ pid_file = @context.options.get_option(:pid_file)
137
+ if !pid_file.nil?
138
+ File.write(pid_file, Process.pid)
139
+ Log.log.debug{"Wrote pid #{Process.pid} to #{pid_file}"}
140
+ at_exit{File.delete(pid_file)}
141
+ end
142
+ begin
143
+ result = command_plugin.execute_action if execute_command
144
+ rescue Cli::HelpRequest => e
145
+ return result_usage(plugin: e.plugin)
146
+ ensure
147
+ @context.presets.save_if_needed
141
148
  @context.transfer.shutdown
149
+ TempFileManager.instance.cleanup
150
+ end
151
+ return result
152
+ end
153
+
154
+ # Main entry point: execute the command, display results, exit on error.
155
+ # @return [nil]
156
+ def run
157
+ exception_info = nil
158
+ begin
159
+ result = run_with_result
160
+ @context.formatter.display_results(result) if result
142
161
  rescue Net::SSH::AuthenticationFailed => e; exception_info = {e: e, t: 'SSH', security: true}
143
162
  rescue OpenSSL::SSL::SSLError => e; exception_info = {e: e, t: 'SSL'}
144
163
  rescue Cli::BadArgument => e; exception_info = {e: e, t: 'Argument', usage: true}
@@ -152,17 +171,13 @@ module Aspera
152
171
  rescue StandardError => e; exception_info = {e: e, t: "Other(#{e.class.name})", debug: true}
153
172
  rescue Interrupt => e; exception_info = {e: e, t: 'Interruption', debug: true}
154
173
  end
155
- # Cleanup file list files
156
- TempFileManager.instance.cleanup
157
174
  # 1- processing of error condition
158
175
  unless exception_info.nil?
159
176
  Log.log.warn(exception_info[:e].message) if Log.instance.logger_type.eql?(:syslog) && exception_info[:security]
160
177
  Log.log.error{"#{exception_info[:t]}: #{exception_info[:e].message}"} unless exception_info[:e].is_a?(Cli::SchemaRequest)
161
178
  Log.log.debug{(['Backtrace:'] + exception_info[:e].backtrace).join("\n")} if exception_info[:debug]
162
179
  @context.formatter.display_message(:error, 'Use option -h to get help.') if exception_info[:usage]
163
- # Is that a known error condition with proposal for remediation ?
164
180
  Hints.hint_for(exception_info[:e], @context.formatter)
165
- # Requested help for a Hash parameter/option ?
166
181
  if exception_info[:e].is_a?(Cli::SchemaRequest)
167
182
  Log.log.info{"#{exception_info[:t]}: #{exception_info[:e].message}"}
168
183
  schema_path = exception_info[:e].path
@@ -174,51 +189,142 @@ module Aspera
174
189
  end
175
190
  end
176
191
  end
177
- # 2- processing of command not processed (due to exception or bad command line)
178
- if execute_command || @option_show_config
179
- @context.options.final_errors.each do |msg|
192
+ # 2- processing of unprocessed arguments (skip when help was displayed: sub-commands are not consumed)
193
+ unless @option_help
194
+ @context.options&.final_errors&.each do |msg|
180
195
  Log.log.error{"Argument: #{msg}"}
181
- # Add code as exception if there is not already an error
182
196
  exception_info = {e: Exception.new(msg), t: 'UnusedArg'} if exception_info.nil?
183
197
  end
184
198
  end
185
- # 3- in case of error, fail the process status
199
+ # 3- exit on error
186
200
  unless exception_info.nil?
187
- # Show stack trace in debug mode
188
201
  raise exception_info[:e] if Log.log.debug?
189
- # Else give hint and exit
190
202
  @context.formatter.display_message(:error, 'Use --log-level=debug to get more details.') if exception_info[:debug]
191
203
  Process.exit(1)
192
204
  end
193
205
  return
194
206
  end
195
207
 
196
- # Display usage information and help
197
- # @param all [Boolean] if true, show help for all plugins; if false, show only current plugin
198
- # @param exit [Boolean] if true, exit the process after displaying help
208
+ # Display usage information and exit (used by the interactive CLI).
209
+ # @param plugin [Plugins::Base, nil] plugin instance to show subcommands for
199
210
  # @return [nil]
200
- def show_usage(all: true, exit: true)
201
- # Display main plugin options (+config)
202
- @context.formatter.display_message(:error, @context.options.parser)
203
- if all
204
- @context.only_manual!
205
- # List plugins that have a "require" field, i.e. all but main plugin
206
- Plugins::Factory.instance.plugin_list.each do |plugin_name_sym|
207
- # Config was already included in the global options
208
- next if plugin_name_sym.eql?(COMMAND_CONFIG)
209
- # Override main option parser with a brand new, to avoid having global options
210
- @context.options = Manager.new(Info::CMD_NAME)
211
- @context.options.parser.banner = '' # Remove default banner
212
- get_plugin_instance_with_options(plugin_name_sym)
213
- # Display generated help for plugin options
214
- @context.formatter.display_message(:error, @context.options.parser.help)
211
+ def show_usage(plugin: nil)
212
+ @context.formatter.display_message(:error, usage_text(plugin: plugin))
213
+ Process.exit(0)
214
+ end
215
+
216
+ # Return usage as a Result::Text (used by run_with_result, no display, no exit).
217
+ # @param plugin [Plugins::Base, nil] plugin instance to show subcommands for
218
+ # @return [Result::Text]
219
+ def result_usage(plugin: nil)
220
+ Result::Text.new(usage_text(plugin: plugin))
221
+ end
222
+
223
+ # Composite option handler for the `log` option (dot-notation sub-properties).
224
+ # Supported sub-properties: +level+, +type+, +format+
225
+ # @param _option_sym [Symbol] Option name (unused, always :log)
226
+ # @param operation [Symbol] +:set+ or +:get+
227
+ # @param value [Hash,nil] Hash of sub-properties to set (only for +:set+)
228
+ def option_log(_option_sym, operation, value = nil)
229
+ Aspera.assert_values(operation, %i[set get])
230
+ case operation
231
+ when :set
232
+ Aspera.assert_type(value, Hash)
233
+ value.each do |k, v|
234
+ case k.to_sym
235
+ when :level then Log.instance.level = v.to_sym
236
+ when :type then Log.instance.logger_type = v.to_sym
237
+ when :format then Log.instance.formatter = v
238
+ when :secrets then SecretHider.instance.log_secrets = BoolValue.true?(v)
239
+ else Aspera.error_unexpected_value(k){'log sub-option (level, type, format, secrets)'}
240
+ end
215
241
  end
242
+ when :get
243
+ return {level: Log.instance.level, type: Log.instance.logger_type, format: Log.instance.formatter, secrets: SecretHider.instance.log_secrets}
216
244
  end
217
- Process.exit(0) if exit
245
+ nil
218
246
  end
219
247
 
220
248
  private
221
249
 
250
+ # Build the usage/help text.
251
+ #
252
+ # - No plugin: global options + list of top-level plugins
253
+ # - With plugin: global options + plugin options + subcommands at the path
254
+ # that was reached before --help was encountered
255
+ #
256
+ # @param plugin [Plugins::Base, nil] plugin instance (carries the current dispatch path)
257
+ # @return [String] the full help text
258
+ def usage_text(plugin: nil)
259
+ lines = [@context.options.help_text(banner: app_banner)]
260
+ if plugin.nil?
261
+ # Top-level: list all available plugins
262
+ plugin_names = Plugins::Factory.instance.plugin_list.reject{ |s| s.eql?(COMMAND_CONFIG)}.sort
263
+ lines << "\nPLUGINS"
264
+ col_w = plugin_names.map{ |n| n.to_s.length}.max + 2
265
+ plugin_names.each do |name|
266
+ app = Plugins::Factory.instance.plugin_class(name).application_name
267
+ lines << " #{name.to_s.ljust(col_w)} #{app}"
268
+ end
269
+ else
270
+ path = plugin.help_path || []
271
+ registry = plugin.class.command_registry
272
+ cmds = registry.children_of(path)
273
+ label = plugin.class.name.split('::').last.downcase
274
+ # Build label with positional argument slots inserted after each intermediate node
275
+ # e.g. [:access_keys, :do, :download] -> "node access_keys do <access_key_id> download"
276
+ path.each_with_index do |seg, i|
277
+ label += " #{seg}"
278
+ seg_path = path[0..i]
279
+ seg_spec = registry[seg_path]
280
+ if seg_spec && registry.children_of(seg_path).any? && seg_spec.arguments
281
+ seg_spec.arguments.each do |arg_spec|
282
+ label += " <#{arg_spec.name}>"
283
+ end
284
+ end
285
+ end
286
+ if cmds.any?
287
+ # Intermediate node: list subcommands
288
+ lines << "\nCOMMANDS: #{label}"
289
+ col_w = cmds.keys.map{ |k| k.to_s.length}.max + 2
290
+ cmds.each do |id, spec|
291
+ lines << " #{id.to_s.ljust(col_w)} #{spec.description}"
292
+ end
293
+ else
294
+ # Leaf node: show description + arguments
295
+ spec = registry[path]
296
+ lines << "\nCOMMAND: #{label}"
297
+ lines << " #{spec.description}" if spec&.description
298
+ display_args = spec&.arguments || []
299
+ # transfer_paths commands use --sources for the file list; default is positional args (@args)
300
+ if spec&.transfer_paths
301
+ file_desc = spec.transfer_paths == :receive \
302
+ ? "Remote path(s) to download (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --to-folder)" \
303
+ : "Source file(s) to upload (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --src-type, --to-folder)"
304
+ display_args += [ArgumentSpec.new(name: :source_file, description: file_desc, mandatory: false, multiple: true)]
305
+ end
306
+ if display_args.any?
307
+ lines << "\nARGUMENTS:"
308
+ col_w = display_args.map{ |a| a.name.to_s.length}.max + 2
309
+ display_args.each do |arg|
310
+ flag = arg.mandatory ? arg.name.to_s : "[#{arg.name}]"
311
+ flag += '...' if arg.multiple
312
+ types = case arg.type
313
+ when :identifier then 'identifier'
314
+ when Array then arg.type.map(&:name).join(', ')
315
+ when nil then ''
316
+ else arg.type.name
317
+ end
318
+ hint = arg.type.eql?(Hash) && arg.schema ? " (use 'help' as value to see schema)" : ''
319
+ lines << " #{flag.ljust(col_w)} #{arg.description || types}#{hint}"
320
+ end
321
+ end
322
+ lines << "\nTIP: use --query=help to list available query parameters" if spec&.query_schema || spec&.entity_execute&.[](:query_schema)
323
+ end
324
+ end
325
+ lines.join("\n")
326
+ end
327
+
222
328
  # Initialize agents and options
223
329
  # This can throw exception if there is a problem with the environment, needs to be caught by execute method
224
330
  # @raise [StandardError] if there is a problem with the environment
@@ -228,28 +334,42 @@ module Aspera
228
334
  # Create formatter, in case there is an exception, it is used to display.
229
335
  @context.formatter = Formatter.new
230
336
  # Create command line manager with arguments
231
- @context.options = Manager.new(Info::CMD_NAME, @argv)
232
- # Formatter adds options
233
- @context.formatter.declare_options(@context.options)
337
+ @context.options = Parser.new(Info::CMD_NAME, @argv)
338
+ ExtendedValue.instance.on(EXTEND_ARGS){ |v| @context.options.args_as_extended(v)}
339
+ # Formatter: declare metadata (class method), then bind to the instance
340
+ Formatter.declare_options(@context.options)
341
+ @context.formatter.bind_options(@context.options)
234
342
  # Compare $0 with expected name
235
343
  current_prog_name = File.basename($PROGRAM_NAME)
236
344
  Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn){"Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'"}
237
345
  # Declare and parse global options
238
346
  declare_global_options
347
+ # Bootstrap: populate context services (main_folder, persistency, presets, http_config,
348
+ # progress_bar) and configure global singletons before any plugin is instantiated.
349
+ # The vault callback is lazy: @vault extended-values are only resolved after Config.new,
350
+ # so @context.config is always set by the time it is called.
351
+ @bootstrapper = Bootstrapper.new(@context)
352
+ @bootstrapper.run(
353
+ gem_plugins_folder: Plugins::Config.gem_plugins_folder,
354
+ vault_value_cb: ->(v){@context.config.vault_value(v)}
355
+ )
239
356
  # Do not display config commands if help is asked
240
357
  @context.man_header = false
241
- # The Config plugin adds the @preset parser, so declare before TransferAgent which may use it
358
+ # Config declares remaining plugin options on top of what Bootstrapper already parsed
242
359
  @context.config = Plugins::Config.new(context: @context)
243
360
  @context.man_header = true
244
- # Data persistency is set in config
245
- Aspera.assert(@context.persistency, 'missing persistency object')
361
+ # Sync cache_tokens from Config into the OAuth persist_mgr (now that option is parsed)
362
+ OAuth::Factory.instance.persist_mgr = @context.persistency if @context.config.option_cache_tokens
363
+ # Email service: depends on options declared by Config
364
+ @context.mailer = Mailer.new(@context.options, @context.main_folder)
365
+ # Secret finder: depends on options (:secret) and presets, both set by Bootstrapper
366
+ @context.secret_finder = SecretFinder.new(@context.options, @context.presets)
246
367
  # The TransferAgent plugin may use the @preset parser
247
- @context.transfer = TransferAgent.new(@context.options, @context.config)
368
+ @context.transfer = TransferAgent.new(@context)
248
369
  # Add commands for config plugin after all options have been added
249
370
  @context.config.add_manual_header(false)
250
371
  @context.validate
251
372
  # Set banner when all environment is created so that additional extended value modifiers are known, e.g. @preset
252
- @context.options.parser.banner = app_banner
253
373
  end
254
374
 
255
375
  # Generate the application banner for help display
@@ -292,29 +412,33 @@ module Aspera
292
412
  # @return [nil]
293
413
  def declare_global_options
294
414
  Log.log.debug('declare_global_options')
295
- @context.options.declare(:help, 'Show this message', allowed: Allowed::TYPES_NONE, short: 'h'){@option_help = true}
296
- @context.options.declare(:show_config, 'Display parameters used for the provided action', allowed: Allowed::TYPES_NONE){@option_show_config = true}
297
- @context.options.declare(:version, 'Display version', allowed: Allowed::TYPES_NONE, short: 'v'){@context.formatter.display_message(:data, Cli::VERSION); Process.exit(0)} # rubocop:disable Style/Semicolon
415
+ @context.options.declare(:help, description: 'Show this message', allowed: Allowed::TYPES_NONE, short: 'h') do
416
+ @option_help = true
417
+ @context.options.help_requested = true
418
+ end
419
+ @context.options.declare(:show_config, description: 'Display parameters used for the provided action', allowed: Allowed::TYPES_NONE){@option_show_config = true}
420
+ @context.options.declare(:version, description: 'Display version', allowed: Allowed::TYPES_NONE, short: 'v'){@context.formatter.display_message(:data, Cli::VERSION); Process.exit(0)} # rubocop:disable Style/Semicolon
298
421
  @context.options.declare(
299
- :ui, 'Method to start browser',
422
+ :ui, description: 'Method to start browser',
300
423
  allowed: USER_INTERFACES,
301
424
  handler: {o: Environment.instance, m: :url_method}
302
425
  )
303
426
  @context.options.declare(
304
- :invalid_characters, 'Replacement character and invalid filename characters',
427
+ :invalid_characters, description: 'Replacement character and invalid filename characters',
305
428
  handler: {o: Environment.instance, m: :file_illegal_characters}
306
429
  )
307
- @context.options.declare(:log_level, 'Log level', allowed: Log::LEVELS, handler: {o: Log.instance, m: :level})
308
- @context.options.declare(:log_format, 'Log formatter', allowed: [Proc, Logger::Formatter, String], handler: {o: Log.instance, m: :formatter})
309
- @context.options.declare(:logger, 'Logging method', allowed: Log::LOG_TYPES, handler: {o: Log.instance, m: :logger_type})
310
- @context.options.declare(:lock_port, 'Prevent dual execution of a command, e.g. in cron', allowed: Allowed::TYPES_INTEGER)
311
- @context.options.declare(:once_only, 'Process only new items (some commands)', allowed: Allowed::TYPES_BOOLEAN, default: false)
312
- @context.options.declare(:log_secrets, 'Show passwords in logs', allowed: Allowed::TYPES_BOOLEAN, handler: {o: SecretHider.instance, m: :log_secrets})
313
- @context.options.declare(:clean_temp, 'Cleanup temporary files on exit', allowed: Allowed::TYPES_BOOLEAN, handler: {o: TempFileManager.instance, m: :cleanup_on_exit})
314
- @context.options.declare(:temp_folder, 'Temporary folder', handler: {o: TempFileManager.instance, m: :global_temp})
315
- @context.options.declare(:pid_file, 'Write process identifier to file, delete on exit')
430
+ @context.options.declare(:log_level, description: 'Log level', allowed: Log::LEVELS, handler: {o: Log.instance, m: :level})
431
+ @context.options.declare(:log_format, description: 'Log formatter', allowed: [Proc, Logger::Formatter, String], handler: {o: Log.instance, m: :formatter})
432
+ @context.options.declare(:logger, description: 'Logging method', allowed: Log::LOG_TYPES, handler: {o: Log.instance, m: :logger_type})
433
+ @context.options.declare(:log, description: 'Logging options (dot-notation: level, type, format, secrets)', handler: {o: self, m: :option_log}, schema: Schema::Registry::LOG_OPTIONS)
434
+ @context.options.declare(:lock_port, description: 'Prevent dual execution of a command, e.g. in cron', allowed: Allowed::TYPES_INTEGER)
435
+ @context.options.declare(:once_only, description: 'Process only new items (some commands)', allowed: Allowed::TYPES_BOOLEAN, default: false)
436
+ @context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Allowed::TYPES_BOOLEAN, handler: {o: SecretHider.instance, m: :log_secrets})
437
+ @context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Allowed::TYPES_BOOLEAN, handler: {o: TempFileManager.instance, m: :cleanup_on_exit})
438
+ @context.options.declare(:temp_folder, description: 'Temporary folder', handler: {o: TempFileManager.instance, m: :global_temp})
439
+ @context.options.declare(:pid_file, description: 'Write process identifier to file, delete on exit')
316
440
  @context.options.declare(
317
- :parser, 'Default parser for structured parameters and options',
441
+ :parser, description: 'Default parser for structured parameters and options',
318
442
  handler: {o: ExtendedValue.instance, m: :default_decoder},
319
443
  allowed: ExtendedValue::DEFAULT_DECODERS,
320
444
  default: ExtendedValue::DEFAULT_DECODERS.first
@@ -329,11 +453,21 @@ module Aspera
329
453
  # @return [Plugins::Base] the plugin instance
330
454
  def get_plugin_instance_with_options(plugin_name_sym)
331
455
  Log.log.debug{"get_plugin_instance_with_options(#{plugin_name_sym})"}
332
- # Load default params only if no param already loaded before plugin instantiation
333
- @context.config.add_plugin_default_preset(plugin_name_sym)
456
+ # Load default preset options for this plugin from config file
457
+ default_config_name = @context.presets.plugin_default_name(plugin_name_sym)
458
+ Log.log.debug{"add_plugin_default_preset:#{plugin_name_sym}:#{default_config_name}"}
459
+ @context.options.add_option_preset(@context.presets.by_name(default_config_name), 'default_plugin', override: false) unless default_config_name.nil?
334
460
  command_plugin = Plugins::Factory.instance.create(plugin_name_sym, context: @context)
335
461
  return command_plugin
336
462
  end
463
+ COMMAND_CONFIG = :config
464
+ COMMAND_HELP = :help
465
+ # Types that go to result of type = text
466
+ SCALAR_TYPES = [String, Integer, Symbol].freeze
467
+ USER_INTERFACES = %i[text graphical].freeze
468
+ EXTEND_ARGS = :''
469
+
470
+ private_constant :COMMAND_CONFIG, :COMMAND_HELP, :SCALAR_TYPES, :USER_INTERFACES, :EXTEND_ARGS
337
471
  end
338
472
  end
339
473
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/log'
4
+
5
+ module Aspera
6
+ module Cli
7
+ # Resolves the secret (password) for a given URL + username pair.
8
+ # Injected into Context as :secret_finder so that any component
9
+ # (plugins, Api::AoC, ...) can look up secrets without going through Plugins::Config.
10
+ class SecretFinder
11
+ # Special value for the :secret option that triggers a preset lookup
12
+ PRESET_MAGIC = 'PRESET'
13
+
14
+ # @param options [Parser] CLI options manager (provides :secret)
15
+ # @param presets [PresetManager] preset resolver (provides #lookup_preset)
16
+ def initialize(options, presets)
17
+ @options = options
18
+ @presets = presets
19
+ end
20
+
21
+ # Return the secret for the given URL + username.
22
+ # If the :secret option equals 'PRESET', the preset store is searched for a
23
+ # matching url/username entry and its 'password' field is returned.
24
+ # @param url [String]
25
+ # @param username [String]
26
+ # @return [String, nil]
27
+ def lookup(url:, username:)
28
+ secret = @options.get_option(:secret)
29
+ if secret.eql?(PRESET_MAGIC)
30
+ conf = @presets.lookup_preset(url: url, username: username)
31
+ if conf.is_a?(Hash)
32
+ Log.log.debug{"Found preset #{conf} with URL and username"}
33
+ secret = conf['password']
34
+ end
35
+ end
36
+ secret
37
+ end
38
+ end
39
+ end
40
+ end
@@ -9,6 +9,7 @@ module Aspera
9
9
  ALL = 'ALL'
10
10
  DEF = 'DEF'
11
11
  EOA = 'END'
12
+ LATEST = 'LATEST'
12
13
  end
13
14
  end
14
15
  end