aspera-cli 4.26.1 → 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 (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  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 +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -0,0 +1,473 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/command_spec'
4
+ require 'aspera/cli/context'
5
+ require 'aspera/cli/parser'
6
+ require 'aspera/cli/formatter'
7
+ require 'aspera/cli/plugins/factory'
8
+ require 'aspera/cli/bootstrapper'
9
+ require 'aspera/cli/plugins/config'
10
+ require 'aspera/cli/mailer'
11
+ require 'aspera/cli/secret_finder'
12
+ require 'aspera/cli/extended_value'
13
+ require 'aspera/cli/transfer_agent'
14
+ require 'aspera/cli/version'
15
+ require 'aspera/cli/info'
16
+ require 'aspera/cli/hints'
17
+ require 'aspera/cli/result'
18
+ require 'aspera/transfer/result'
19
+ require 'aspera/secret_hider'
20
+ require 'aspera/log'
21
+ require 'aspera/assert'
22
+ require 'aspera/schema/documentation'
23
+ require 'aspera/schema/registry'
24
+ require 'net/ssh/errors'
25
+ require 'openssl'
26
+
27
+ module Aspera
28
+ module Cli
29
+ # The main CLI class
30
+ class Runner
31
+ # Plugins store transfer result using this key and use result_transfer_multiple()
32
+ STATUS_FIELD = 'status'
33
+
34
+ class << self
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
50
+ end
51
+
52
+ # Used when one command executes several transfer jobs (each job being possibly multi session)
53
+ # @param status_table [Array] [{STATUS_FIELD=>Transfer::Result,...},...]
54
+ # @return [Result] a status object suitable as command result
55
+ def result_transfer_multiple(status_table)
56
+ failed_result = nil
57
+ status_table.each do |item|
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
70
+ end
71
+ raise failed_result.exception unless failed_result.nil?
72
+ return Result::ObjectList.new(status_table)
73
+ end
74
+ end
75
+
76
+ # Minimum initialization, no exception raised
77
+ # @param argv [Array<String>] command line arguments
78
+ # @return [nil]
79
+ def initialize(argv)
80
+ @argv = argv
81
+ Log.dump(:argv, @argv, level: :trace2)
82
+ @option_help = false
83
+ @option_show_config = false
84
+ @context = Context.new
85
+ end
86
+
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
101
+ else
102
+ @context.options.get_next_command(Plugins::Factory.instance.plugin_list.unshift(COMMAND_HELP))
103
+ end
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
132
+ execute_command = false
133
+ Log.log.warn{"Another instance is already running (#{e.message})."}
134
+ end
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
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
161
+ rescue Net::SSH::AuthenticationFailed => e; exception_info = {e: e, t: 'SSH', security: true}
162
+ rescue OpenSSL::SSL::SSLError => e; exception_info = {e: e, t: 'SSL'}
163
+ rescue Cli::BadArgument => e; exception_info = {e: e, t: 'Argument', usage: true}
164
+ rescue Cli::MissingArgument => e; exception_info = {e: e, t: 'Missing'}
165
+ rescue Cli::BadIdentifier => e; exception_info = {e: e, t: 'Identifier'}
166
+ rescue Cli::SchemaRequest => e; exception_info = {e: e, t: 'Schema'}
167
+ rescue Cli::Error => e; exception_info = {e: e, t: 'Tool', usage: true}
168
+ rescue Transfer::Error => e; exception_info = {e: e, t: 'Transfer'}
169
+ rescue RestCallError => e; exception_info = {e: e, t: 'Rest'}
170
+ rescue SocketError => e; exception_info = {e: e, t: 'Network'}
171
+ rescue StandardError => e; exception_info = {e: e, t: "Other(#{e.class.name})", debug: true}
172
+ rescue Interrupt => e; exception_info = {e: e, t: 'Interruption', debug: true}
173
+ end
174
+ # 1- processing of error condition
175
+ unless exception_info.nil?
176
+ Log.log.warn(exception_info[:e].message) if Log.instance.logger_type.eql?(:syslog) && exception_info[:security]
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]
179
+ @context.formatter.display_message(:error, 'Use option -h to get help.') if exception_info[:usage]
180
+ Hints.hint_for(exception_info[:e], @context.formatter)
181
+ if exception_info[:e].is_a?(Cli::SchemaRequest)
182
+ Log.log.info{"#{exception_info[:t]}: #{exception_info[:e].message}"}
183
+ schema_path = exception_info[:e].path
184
+ if schema_path.nil?
185
+ Log.log.warn{'Sorry, no schema provided yet. Please refer to the manual or API.'}
186
+ else
187
+ builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(schema_path)).build
188
+ @context.formatter.display_results(Result::ObjectList.new(builder.rows, fields: builder.columns))
189
+ end
190
+ end
191
+ end
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|
195
+ Log.log.error{"Argument: #{msg}"}
196
+ exception_info = {e: Exception.new(msg), t: 'UnusedArg'} if exception_info.nil?
197
+ end
198
+ end
199
+ # 3- exit on error
200
+ unless exception_info.nil?
201
+ raise exception_info[:e] if Log.log.debug?
202
+ @context.formatter.display_message(:error, 'Use --log-level=debug to get more details.') if exception_info[:debug]
203
+ Process.exit(1)
204
+ end
205
+ return
206
+ end
207
+
208
+ # Display usage information and exit (used by the interactive CLI).
209
+ # @param plugin [Plugins::Base, nil] plugin instance to show subcommands for
210
+ # @return [nil]
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
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}
244
+ end
245
+ nil
246
+ end
247
+
248
+ private
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
+
328
+ # Initialize agents and options
329
+ # This can throw exception if there is a problem with the environment, needs to be caught by execute method
330
+ # @raise [StandardError] if there is a problem with the environment
331
+ # @return [nil]
332
+ def init_agents_and_options
333
+ @context.man_header = true
334
+ # Create formatter, in case there is an exception, it is used to display.
335
+ @context.formatter = Formatter.new
336
+ # Create command line manager with arguments
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)
342
+ # Compare $0 with expected name
343
+ current_prog_name = File.basename($PROGRAM_NAME)
344
+ Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn){"Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'"}
345
+ # Declare and parse global options
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
+ )
356
+ # Do not display config commands if help is asked
357
+ @context.man_header = false
358
+ # Config declares remaining plugin options on top of what Bootstrapper already parsed
359
+ @context.config = Plugins::Config.new(context: @context)
360
+ @context.man_header = true
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)
367
+ # The TransferAgent plugin may use the @preset parser
368
+ @context.transfer = TransferAgent.new(@context)
369
+ # Add commands for config plugin after all options have been added
370
+ @context.config.add_manual_header(false)
371
+ @context.validate
372
+ # Set banner when all environment is created so that additional extended value modifiers are known, e.g. @preset
373
+ end
374
+
375
+ # Generate the application banner for help display
376
+ # @return [String] formatted banner text
377
+ def app_banner
378
+ t = ' ' * 8
379
+ return <<~END_OF_BANNER
380
+ NAME
381
+ #{t}#{Info::CMD_NAME} -- a command line tool for Aspera Applications (v#{Cli::VERSION})
382
+
383
+ SYNOPSIS
384
+ #{t}#{Info::CMD_NAME} COMMANDS [OPTIONS] [ARGS]
385
+
386
+ DESCRIPTION
387
+ #{t}Use Aspera application to perform operations on command line.
388
+ #{t}Documentation and examples: #{Info::GEM_URL}
389
+ #{t}execute: #{Info::CMD_NAME} conf doc
390
+ #{t}or visit: #{Info::DOC_URL}
391
+ #{t}source repo: #{Info::SRC_URL}
392
+
393
+ ENVIRONMENT VARIABLES
394
+ #{t}Any option can be set as an environment variable, refer to the manual
395
+
396
+ COMMANDS
397
+ #{t}To list first level commands, execute: #{Info::CMD_NAME}
398
+ #{t}Note that commands can be written shortened (provided it is unique).
399
+
400
+ OPTIONS
401
+ #{t}Options begin with a '-' (minus), and value is provided on command line.
402
+ #{t}Special values are supported beginning with special prefix @pfx:, where pfx is one of:
403
+ #{t}#{ExtendedValue.instance.modifiers.join(', ')}
404
+ #{t}Dates format is 'DD-MM-YY HH:MM:SS', or 'now' or '-<num>h'
405
+
406
+ ARGS
407
+ #{t}Some commands require mandatory arguments, e.g. a path.
408
+ END_OF_BANNER
409
+ end
410
+
411
+ # Define header for manual and declare all global options
412
+ # @return [nil]
413
+ def declare_global_options
414
+ Log.log.debug('declare_global_options')
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
421
+ @context.options.declare(
422
+ :ui, description: 'Method to start browser',
423
+ allowed: USER_INTERFACES,
424
+ handler: {o: Environment.instance, m: :url_method}
425
+ )
426
+ @context.options.declare(
427
+ :invalid_characters, description: 'Replacement character and invalid filename characters',
428
+ handler: {o: Environment.instance, m: :file_illegal_characters}
429
+ )
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')
440
+ @context.options.declare(
441
+ :parser, description: 'Default parser for structured parameters and options',
442
+ handler: {o: ExtendedValue.instance, m: :default_decoder},
443
+ allowed: ExtendedValue::DEFAULT_DECODERS,
444
+ default: ExtendedValue::DEFAULT_DECODERS.first
445
+ )
446
+ # Parse declared options
447
+ @context.options.parse_options!
448
+ end
449
+
450
+ # Get the plugin instance based on name
451
+ # Also loads the plugin options, and default values from conf file
452
+ # @param plugin_name_sym [Symbol] symbol for plugin name
453
+ # @return [Plugins::Base] the plugin instance
454
+ def get_plugin_instance_with_options(plugin_name_sym)
455
+ Log.log.debug{"get_plugin_instance_with_options(#{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?
460
+ command_plugin = Plugins::Factory.instance.create(plugin_name_sym, context: @context)
461
+ return command_plugin
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
471
+ end
472
+ end
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