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,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,73 +171,196 @@ 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
- Log.log.error{"#{exception_info[:t]}: #{exception_info[:e].message}"} unless exception_info[:e].is_a?(Cli::SchemaRequest)
161
- Log.log.debug{(['Backtrace:'] + exception_info[:e].backtrace).join("\n")} if exception_info[:debug]
177
+ Log.log.error { "#{exception_info[:t]}: #{exception_info[:e].message}" } unless exception_info[:e].is_a?(Cli::SchemaRequest)
178
+ Log.log.debug { (['Backtrace:'] + exception_info[:e].backtrace).join("\n") } if exception_info[:debug]
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
- Log.log.info{"#{exception_info[:t]}: #{exception_info[:e].message}"}
182
+ Log.log.info { "#{exception_info[:t]}: #{exception_info[:e].message}" }
168
183
  schema_path = exception_info[:e].path
169
184
  if schema_path.nil?
170
- Log.log.warn{'Sorry, no schema provided yet. Please refer to the manual or API.'}
185
+ Log.log.warn { 'Sorry, no schema provided yet. Please refer to the manual or API.' }
171
186
  else
172
- builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(schema_path)).build
173
- @context.formatter.display_results(Result::ObjectList.new(builder.rows, fields: builder.columns))
187
+ reader = Schema::Registry.instance.reader(schema_path)
188
+ rows = reader.to_rows.map do |row|
189
+ if row.key?('enum')
190
+ row = row.dup
191
+ row['type'] = 'enum'
192
+ row['description'] = "#{row['description']}\nAllowed: #{row['enum'].join(', ')}"
193
+ end
194
+ row
195
+ end
196
+ @context.formatter.display_results(Result::ObjectList.new(rows, fields: %w[name type required description]))
174
197
  end
175
198
  end
176
199
  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|
180
- Log.log.error{"Argument: #{msg}"}
181
- # Add code as exception if there is not already an error
200
+ # 2- processing of unprocessed arguments (skip when help was displayed: sub-commands are not consumed)
201
+ unless @option_help
202
+ @context.options&.final_errors&.each do |msg|
203
+ Log.log.error { "Argument: #{msg}" }
182
204
  exception_info = {e: Exception.new(msg), t: 'UnusedArg'} if exception_info.nil?
183
205
  end
184
206
  end
185
- # 3- in case of error, fail the process status
207
+ # 3- exit on error
186
208
  unless exception_info.nil?
187
- # Show stack trace in debug mode
188
209
  raise exception_info[:e] if Log.log.debug?
189
- # Else give hint and exit
190
210
  @context.formatter.display_message(:error, 'Use --log-level=debug to get more details.') if exception_info[:debug]
191
211
  Process.exit(1)
192
212
  end
193
213
  return
194
214
  end
195
215
 
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
216
+ # Display usage information and exit (used by the interactive CLI).
217
+ # @param plugin [Plugins::Base, nil] plugin instance to show subcommands for
199
218
  # @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)
219
+ def show_usage(plugin: nil)
220
+ @context.formatter.display_message(:error, usage_text(plugin: plugin))
221
+ Process.exit(0)
222
+ end
223
+
224
+ # Return usage as a Result::Text (used by run_with_result, no display, no exit).
225
+ # @param plugin [Plugins::Base, nil] plugin instance to show subcommands for
226
+ # @return [Result::Text]
227
+ def result_usage(plugin: nil)
228
+ Result::Text.new(usage_text(plugin: plugin))
229
+ end
230
+
231
+ # Composite option handler for the `log` option (dot-notation sub-properties).
232
+ # Supported sub-properties: +level+, +type+, +format+
233
+ # @param _option_sym [Symbol] Option name (unused, always :log)
234
+ # @param operation [Symbol] +:set+ or +:get+
235
+ # @param value [Hash,nil] Hash of sub-properties to set (only for +:set+)
236
+ def option_log(_option_sym, operation, value = nil)
237
+ Aspera.assert_values(operation, %i[set get])
238
+ case operation
239
+ when :set
240
+ Aspera.assert_type(value, Hash)
241
+ value.each do |k, v|
242
+ case k.to_sym
243
+ when :level then Log.instance.level = v.to_sym
244
+ when :type then Log.instance.logger_type = v.to_sym
245
+ when :format then Log.instance.formatter = v
246
+ when :secrets then SecretHider.instance.log_secrets = BoolValue.true?(v)
247
+ else Aspera.error_unexpected_value(k) { 'log sub-option (level, type, format, secrets)' }
248
+ end
215
249
  end
250
+ when :get
251
+ return {level: Log.instance.level, type: Log.instance.logger_type, format: Log.instance.formatter, secrets: SecretHider.instance.log_secrets}
216
252
  end
217
- Process.exit(0) if exit
253
+ nil
218
254
  end
219
255
 
220
256
  private
221
257
 
258
+ # Build the usage/help text.
259
+ #
260
+ # - No plugin: global options + list of top-level plugins
261
+ # - With plugin: global options + plugin options + subcommands at the path
262
+ # that was reached before --help was encountered
263
+ #
264
+ # @param plugin [Plugins::Base, nil] plugin instance (carries the current dispatch path)
265
+ # @return [String] the full help text
266
+ def usage_text(plugin: nil)
267
+ lines = [@context.options.help_text(banner: app_banner)]
268
+ if plugin.nil?
269
+ # Top-level: list all available plugins
270
+ plugin_names = Plugins::Factory.instance.plugin_list.reject { |s| s.eql?(COMMAND_CONFIG) }.sort
271
+ lines << "\nPLUGINS"
272
+ col_w = plugin_names.map { |n| n.to_s.length }.max + 2
273
+ plugin_names.each do |name|
274
+ app = Plugins::Factory.instance.plugin_class(name).application_name
275
+ lines << " #{name.to_s.ljust(col_w)} #{app}"
276
+ end
277
+ else
278
+ path = plugin.help_path || []
279
+ registry = plugin.class.command_registry
280
+ cmds = registry.children_of(path)
281
+ label = plugin.class.name.split('::').last.downcase
282
+ # Build label with positional argument slots inserted after each intermediate node
283
+ # e.g. [:access_keys, :do, :download] -> "node access_keys do <access_key_id> download"
284
+ path.each_with_index do |seg, i|
285
+ label += " #{seg}"
286
+ seg_path = path[0..i]
287
+ seg_spec = registry[seg_path]
288
+ if seg_spec && registry.children_of(seg_path).any? && seg_spec.arguments
289
+ seg_spec.arguments.each do |arg_spec|
290
+ label += " <#{arg_spec.name}>"
291
+ end
292
+ end
293
+ end
294
+ if cmds.none?
295
+ leaf_spec = registry[path]
296
+ leaf_spec&.arguments&.each do |arg_spec|
297
+ argument = arg_spec.mandatory ? "<#{arg_spec.name}>" : "[#{arg_spec.name}]"
298
+ argument += '...' if arg_spec.multiple
299
+ label += " #{argument}"
300
+ end
301
+ end
302
+ if cmds.any?
303
+ # Intermediate node: show current command + description, then list subcommands
304
+ lines << "\nCOMMAND: #{label}"
305
+ node_spec = path.empty? ? nil : registry[path]
306
+ lines << " #{node_spec.description}" if node_spec&.description
307
+ lines << "\n SUBCOMMANDS:"
308
+ col_w = cmds.keys.map { |k| k.to_s.length }.max + 2
309
+ cmds.each do |id, spec|
310
+ lines << " #{id.to_s.ljust(col_w)} #{spec.description}"
311
+ end
312
+ else
313
+ # Leaf node: show description + arguments.
314
+ spec = registry[path]
315
+ lines << "\nCOMMAND: #{label}"
316
+ lines << " #{spec.description}" if spec&.description
317
+ display_args = spec&.arguments || []
318
+ # transfer_paths commands use --sources for the file list; default is positional args (@args)
319
+ if spec&.transfer_paths
320
+ file_desc = if spec.transfer_paths == :receive
321
+ "Remote path(s) to download (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --to-folder)"
322
+ else
323
+ "Source file(s) to upload (default --sources=#{TransferAgent::FILE_LIST_FROM_ARGS}; see also --src-type, --to-folder)"
324
+ end
325
+ display_args += [ArgumentSpec.new(name: :source_file, description: file_desc, mandatory: false, multiple: true)]
326
+ end
327
+ if display_args.any?
328
+ lines << "\nARGUMENTS:"
329
+ col_w = display_args.map { |a| a.name.to_s.length }.max + 2
330
+ display_args.each do |arg|
331
+ flag = arg.mandatory ? arg.name.to_s : "[#{arg.name}]"
332
+ flag += '...' if arg.multiple
333
+ types = case arg.type
334
+ when :identifier then 'identifier'
335
+ when Array then arg.type.map(&:name).join(', ')
336
+ when nil then ''
337
+ else arg.type.name
338
+ end
339
+ hint = Array(arg.type).include?(Hash) && arg.schema ? ' (schema shown below)' : ''
340
+ lines << " #{flag.ljust(col_w)} #{arg.description || types}#{hint}"
341
+ lines.concat(schema_help_lines(arg)) if Array(arg.type).include?(Hash) && arg.schema
342
+ end
343
+ end
344
+ lines << "\nTIP: use --query=help to list available query parameters" if spec&.query_schema
345
+ end
346
+ end
347
+ lines.join("\n")
348
+ end
349
+
350
+ # Render the structure of a Hash argument's JSON schema in command help.
351
+ # @param arg [ArgumentSpec]
352
+ # @return [Array<String>]
353
+ def schema_help_lines(arg)
354
+ builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(arg.schema)).build
355
+ rows = builder.rows.reject { |row| row['type'].eql?('&nbsp;') }.map do |row|
356
+ builder.columns.map { |column| row[column].to_s }
357
+ end
358
+ style = {}
359
+ style[:border] = :unicode_round if Environment.terminal_supports_unicode?
360
+ table = Terminal::Table.new(headings: builder.columns, rows: rows, style: style).to_s
361
+ ["\n SCHEMA: #{arg.name}", table.lines.map { |line| " #{line.chomp}" }.join("\n")]
362
+ end
363
+
222
364
  # Initialize agents and options
223
365
  # This can throw exception if there is a problem with the environment, needs to be caught by execute method
224
366
  # @raise [StandardError] if there is a problem with the environment
@@ -228,28 +370,42 @@ module Aspera
228
370
  # Create formatter, in case there is an exception, it is used to display.
229
371
  @context.formatter = Formatter.new
230
372
  # 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)
373
+ @context.options = Parser.new(Info::CMD_NAME, @argv)
374
+ ExtendedValue.instance.on(EXTEND_ARGS) { |v| @context.options.args_as_extended(v) }
375
+ # Formatter: declare metadata (class method), then bind to the instance
376
+ Formatter.declare_options(@context.options)
377
+ @context.formatter.bind_options(@context.options)
234
378
  # Compare $0 with expected name
235
379
  current_prog_name = File.basename($PROGRAM_NAME)
236
- Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn){"Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'"}
380
+ Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn) { "Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'" }
237
381
  # Declare and parse global options
238
382
  declare_global_options
383
+ # Bootstrap: populate context services (main_folder, persistency, presets, http_config,
384
+ # progress_bar) and configure global singletons before any plugin is instantiated.
385
+ # The vault callback is lazy: @vault extended-values are only resolved after Config.new,
386
+ # so @context.config is always set by the time it is called.
387
+ @bootstrapper = Bootstrapper.new(@context)
388
+ @bootstrapper.run(
389
+ gem_plugins_folder: Plugins::Config.gem_plugins_folder,
390
+ vault_value_cb: ->(v) { @context.config.vault_value(v) }
391
+ )
239
392
  # Do not display config commands if help is asked
240
393
  @context.man_header = false
241
- # The Config plugin adds the @preset parser, so declare before TransferAgent which may use it
394
+ # Config declares remaining plugin options on top of what Bootstrapper already parsed
242
395
  @context.config = Plugins::Config.new(context: @context)
243
396
  @context.man_header = true
244
- # Data persistency is set in config
245
- Aspera.assert(@context.persistency, 'missing persistency object')
397
+ # Sync cache_tokens from Config into the OAuth persist_mgr (now that option is parsed)
398
+ OAuth::Factory.instance.persist_mgr = @context.persistency if @context.config.option_cache_tokens
399
+ # Email service: depends on options declared by Config
400
+ @context.mailer = Mailer.new(@context.options, @context.main_folder)
401
+ # Secret finder: depends on options (:secret) and presets, both set by Bootstrapper
402
+ @context.secret_finder = SecretFinder.new(@context.options, @context.presets)
246
403
  # The TransferAgent plugin may use the @preset parser
247
- @context.transfer = TransferAgent.new(@context.options, @context.config)
404
+ @context.transfer = TransferAgent.new(@context)
248
405
  # Add commands for config plugin after all options have been added
249
406
  @context.config.add_manual_header(false)
250
407
  @context.validate
251
408
  # 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
409
  end
254
410
 
255
411
  # Generate the application banner for help display
@@ -261,7 +417,7 @@ module Aspera
261
417
  #{t}#{Info::CMD_NAME} -- a command line tool for Aspera Applications (v#{Cli::VERSION})
262
418
 
263
419
  SYNOPSIS
264
- #{t}#{Info::CMD_NAME} COMMANDS [OPTIONS] [ARGS]
420
+ #{t}#{Info::CMD_NAME} [GLOBAL_OPTIONS] <command> [OPTIONS] [ARGS]
265
421
 
266
422
  DESCRIPTION
267
423
  #{t}Use Aspera application to perform operations on command line.
@@ -280,8 +436,8 @@ module Aspera
280
436
  OPTIONS
281
437
  #{t}Options begin with a '-' (minus), and value is provided on command line.
282
438
  #{t}Special values are supported beginning with special prefix @pfx:, where pfx is one of:
283
- #{t}#{ExtendedValue.instance.modifiers.join(', ')}
284
- #{t}Dates format is 'DD-MM-YY HH:MM:SS', or 'now' or '-<num>h'
439
+ #{t}#{ExtendedValue.instance.modifiers.reject(&:empty?).join(', ')}
440
+ #{t}Dates format is 'YYYY-MM-DD HH:MM:SS', or 'now' or '-<num>h'
285
441
 
286
442
  ARGS
287
443
  #{t}Some commands require mandatory arguments, e.g. a path.
@@ -292,29 +448,33 @@ module Aspera
292
448
  # @return [nil]
293
449
  def declare_global_options
294
450
  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
451
+ @context.options.declare(:help, description: 'Show this message', allowed: Type::NONE, short: 'h') do
452
+ @option_help = true
453
+ @context.options.help_requested = true
454
+ end
455
+ @context.options.declare(:show_config, description: 'Display parameters used for the provided action', allowed: Type::NONE) { @option_show_config = true }
456
+ @context.options.declare(:version, description: 'Display version', allowed: Type::NONE, short: 'v') { @context.formatter.display_message(:data, Cli::VERSION); Process.exit(0) } # rubocop:disable Style/Semicolon
298
457
  @context.options.declare(
299
- :ui, 'Method to start browser',
458
+ :ui, description: 'Method to start browser',
300
459
  allowed: USER_INTERFACES,
301
460
  handler: {o: Environment.instance, m: :url_method}
302
461
  )
303
462
  @context.options.declare(
304
- :invalid_characters, 'Replacement character and invalid filename characters',
463
+ :invalid_characters, description: 'Replacement character and invalid filename characters',
305
464
  handler: {o: Environment.instance, m: :file_illegal_characters}
306
465
  )
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')
466
+ @context.options.declare(:log_level, description: 'Log level', allowed: Log::LEVELS, handler: {o: Log.instance, m: :level})
467
+ @context.options.declare(:log_format, description: 'Log formatter', allowed: [Proc, Logger::Formatter, String], handler: {o: Log.instance, m: :formatter})
468
+ @context.options.declare(:logger, description: 'Logging method', allowed: Log::LOG_TYPES, handler: {o: Log.instance, m: :logger_type})
469
+ @context.options.declare(:log, description: 'Logging options (dot-notation: level, type, format, secrets)', handler: {o: self, m: :option_log}, schema: Schema::Registry::LOG_OPTIONS)
470
+ @context.options.declare(:lock_port, description: 'Prevent dual execution of a command, e.g. in cron', allowed: Type::INTEGER)
471
+ @context.options.declare(:once_only, description: 'Process only new items (some commands)', allowed: Type::BOOLEAN, default: false)
472
+ @context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Type::BOOLEAN, handler: {o: SecretHider.instance, m: :log_secrets})
473
+ @context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Type::BOOLEAN, handler: {o: TempFileManager.instance, m: :cleanup_on_exit})
474
+ @context.options.declare(:temp_folder, description: 'Temporary folder', handler: {o: TempFileManager.instance, m: :global_temp})
475
+ @context.options.declare(:pid_file, description: 'Write process identifier to file, delete on exit')
316
476
  @context.options.declare(
317
- :parser, 'Default parser for structured parameters and options',
477
+ :parser, description: 'Default parser for structured parameters and options',
318
478
  handler: {o: ExtendedValue.instance, m: :default_decoder},
319
479
  allowed: ExtendedValue::DEFAULT_DECODERS,
320
480
  default: ExtendedValue::DEFAULT_DECODERS.first
@@ -328,12 +488,22 @@ module Aspera
328
488
  # @param plugin_name_sym [Symbol] symbol for plugin name
329
489
  # @return [Plugins::Base] the plugin instance
330
490
  def get_plugin_instance_with_options(plugin_name_sym)
331
- 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)
491
+ Log.log.debug { "get_plugin_instance_with_options(#{plugin_name_sym})" }
492
+ # Load default preset options for this plugin from config file
493
+ default_config_name = @context.presets.plugin_default_name(plugin_name_sym)
494
+ Log.log.debug { "add_plugin_default_preset:#{plugin_name_sym}:#{default_config_name}" }
495
+ @context.options.add_option_preset(@context.presets.by_name(default_config_name), 'default_plugin', override: false) unless default_config_name.nil?
334
496
  command_plugin = Plugins::Factory.instance.create(plugin_name_sym, context: @context)
335
497
  return command_plugin
336
498
  end
499
+ COMMAND_CONFIG = :config
500
+ COMMAND_HELP = :help
501
+ # Types that go to result of type = text
502
+ SCALAR_TYPES = [String, Integer, Symbol].freeze
503
+ USER_INTERFACES = %i[text graphical].freeze
504
+ EXTEND_ARGS = :''
505
+
506
+ private_constant :COMMAND_CONFIG, :COMMAND_HELP, :SCALAR_TYPES, :USER_INTERFACES, :EXTEND_ARGS
337
507
  end
338
508
  end
339
509
  end