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
@@ -4,6 +4,11 @@ require 'aspera/cli/special_values'
4
4
  require 'aspera/log'
5
5
  require 'aspera/assert'
6
6
  require 'aspera/line_logger'
7
+ require 'aspera/schema/registry'
8
+ require 'aspera/cli/option_declarator'
9
+ require 'aspera/rest'
10
+ require 'aspera/oauth'
11
+ require 'aspera/ssl'
7
12
  require 'openssl'
8
13
 
9
14
  module Aspera
@@ -12,6 +17,8 @@ module Aspera
12
17
  # Extracted from Plugins::Config so it can be referenced independently
13
18
  # via Context#http_config without coupling to the plugin machinery.
14
19
  class Http
20
+ extend OptionDeclarator
21
+
15
22
  # Certificate file extensions recognized when scanning a folder
16
23
  CERT_EXT = %w[crt cer pem der].freeze
17
24
  # OpenSSL constant that disables peer verification (VERIFY_NONE)
@@ -19,6 +26,13 @@ module Aspera
19
26
 
20
27
  private_constant :CERT_EXT, :SELF_SIGNED_CERT
21
28
 
29
+ option :insecure, description: 'HTTP/S: Do not validate any certificate', allowed: Type::BOOLEAN, default: false
30
+ option :ignore_certificate, description: 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass]
31
+ option :warn_insecure, description: 'HTTP/S: Issue a warning if certificate is ignored', allowed: Type::BOOLEAN, default: true
32
+ option :cert_stores, description: 'HTTP/S: List of folder with trusted certificates', allowed: Type::STRING_ARRAY
33
+ option :http_options, schema: Schema::Registry::HTTP_OPTIONS
34
+ option :http_proxy, description: 'HTTP/S: URL for proxy with optional credentials'
35
+
22
36
  def initialize
23
37
  @insecure = false
24
38
  @warn_insecure = true
@@ -29,19 +43,47 @@ module Aspera
29
43
  @certificate_paths = nil
30
44
  end
31
45
 
32
- attr_accessor :insecure, :warn_insecure, :http_options
33
- attr_reader :ignore_cert_host_port
34
-
35
- # Declare all HTTP/S CLI options, with handlers pointing to self.
36
- # Called once from Config#initialize after this object is instantiated.
37
- # @param options [Aspera::Cli::Manager]
38
- def declare_options(options)
39
- options.declare(:insecure, 'HTTP/S: Do not validate any certificate', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :insecure}, default: false)
40
- options.declare(:ignore_certificate, 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass], handler: {o: self, m: :ignore_cert_host_port})
41
- options.declare(:warn_insecure, 'HTTP/S: Issue a warning if certificate is ignored', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :warn_insecure}, default: true)
42
- options.declare(:cert_stores, 'HTTP/S: List of folder with trusted certificates', allowed: Allowed::TYPES_STRING_ARRAY, handler: {o: self, m: :trusted_cert_locations})
43
- options.declare(:http_options, 'HTTP/S: Options for HTTP/S socket', allowed: Hash, handler: {o: self, m: :http_options}, default: {})
44
- options.declare(:http_proxy, 'HTTP/S: URL for proxy with optional credentials', handler: {o: self, m: :http_proxy})
46
+ attr_accessor :insecure, :warn_insecure
47
+ attr_reader :ignore_cert_host_port, :http_options
48
+
49
+ # Bind all HTTP options to this instance using set_handler.
50
+ # Called from Config#initialize immediately after Http.new.
51
+ # @param options [Aspera::Cli::Parser]
52
+ # @return [nil]
53
+ def bind_options(options)
54
+ options.set_handler(:insecure, object: self, method: :insecure)
55
+ options.set_handler(:ignore_certificate, object: self, method: :ignore_cert_host_port)
56
+ options.set_handler(:warn_insecure, object: self, method: :warn_insecure)
57
+ options.set_handler(:cert_stores, object: self, method: :trusted_cert_locations)
58
+ options.set_handler(:http_options, object: self, method: :http_options)
59
+ options.set_handler(:http_proxy, object: self, method: :http_proxy)
60
+ end
61
+
62
+ # Setter for http_options: dispatch each key to its target singleton immediately.
63
+ # Keys matching RestParameters setters go to RestParameters, 'ssl_options' goes to SSL,
64
+ # keys matching OAuth::Factory.instance.parameters go to OAuth, and the rest are kept
65
+ # in @http_options for Net::HTTP session configuration in update_session.
66
+ # This runs on every assignment (JSON hash, dotted notation, preset merge) so timing
67
+ # of option parsing never matters.
68
+ # @param new_options [Hash] merged http_options hash
69
+ # @return [nil]
70
+ def http_options=(new_options)
71
+ Aspera.assert_type(new_options, Hash)
72
+ kept = {}
73
+ new_options.each do |k, v|
74
+ method = "#{k}=".to_sym
75
+ if RestParameters.instance.respond_to?(method)
76
+ RestParameters.instance.send(method, v)
77
+ elsif k.to_s.eql?('ssl_options')
78
+ Aspera::SSL.option_list = v
79
+ elsif OAuth::Factory.instance.parameters.key?(k.to_sym)
80
+ OAuth::Factory.instance.parameters[k.to_sym] = v
81
+ else
82
+ kept[k] = v
83
+ end
84
+ end
85
+ @http_options = kept
86
+ nil
45
87
  end
46
88
 
47
89
  # ------------------------------------------------------------------
@@ -64,7 +106,7 @@ module Aspera
64
106
  def ignore_cert_host_port=(url_list)
65
107
  url_list.each do |url|
66
108
  uri = URI.parse(url)
67
- raise "Expecting https scheme: #{url}" unless uri.scheme.eql?('https')
109
+ Aspera.assert(uri.scheme.eql?('https')) { "Expecting https scheme: #{url}" }
68
110
  @ignore_cert_host_port.push([uri.host, uri.port].freeze)
69
111
  end
70
112
  end
@@ -77,11 +119,11 @@ module Aspera
77
119
  if ignore_cert && @warn_insecure
78
120
  base_url = "https://#{address}:#{port}"
79
121
  unless @ssl_warned_urls.include?(base_url)
80
- Log.log.warn{"Ignoring certificate for: #{base_url}. Do not deactivate certificate verification in production."}
122
+ Log.log.warn { "Ignoring certificate for: #{base_url}. Do not deactivate certificate verification in production." }
81
123
  @ssl_warned_urls.push(base_url)
82
124
  end
83
125
  end
84
- Log.log.debug{"ignore cert? #{endpoint} -> #{ignore_cert}"}
126
+ Log.log.debug { "ignore cert? #{endpoint} -> #{ignore_cert}" }
85
127
  ignore_cert
86
128
  end
87
129
 
@@ -90,23 +132,24 @@ module Aspera
90
132
  # ------------------------------------------------------------------
91
133
 
92
134
  # Add files, folders or the default OS locations to the cert store.
93
- # @param path_list [Array<String>]
135
+ # @param path_list [Array<String>] list of file/folder paths to add to the certificate store
136
+ # @return [nil]
94
137
  def trusted_cert_locations=(path_list)
95
- Aspera.assert_type(path_list, Array){'cert locations'}
138
+ Aspera.assert_type(path_list, Array) { 'cert locations' }
96
139
  if @certificate_store.nil?
97
140
  Log.log.debug('Creating SSL Cert store')
98
141
  @certificate_store = OpenSSL::X509::Store.new
99
142
  @certificate_paths = []
100
143
  end
101
144
  path_list.each do |path|
102
- Aspera.assert_type(path, String){'Expecting a String for certificate location'}
145
+ Aspera.assert_type(path, String) { 'Expecting a String for certificate location' }
103
146
  paths_to_add = [path]
104
- Log.log.debug{"Adding cert location: #{path}"}
147
+ Log.log.debug { "Adding cert location: #{path}" }
105
148
  if path.eql?(SpecialValues::DEF)
106
149
  @certificate_store.set_default_paths
107
150
  paths_to_add = [OpenSSL::X509::DEFAULT_CERT_DIR]
108
151
  paths_to_add.push(OpenSSL::X509::DEFAULT_CERT_FILE) unless defined?(JRUBY_VERSION)
109
- paths_to_add.select!{ |f| File.exist?(f)}
152
+ paths_to_add.select! { |f| File.exist?(f) }
110
153
  elsif File.file?(path)
111
154
  @certificate_store.add_file(path)
112
155
  elsif File.directory?(path)
@@ -118,14 +161,15 @@ module Aspera
118
161
  pp = [File.realpath(p)]
119
162
  if File.directory?(p)
120
163
  pp = Dir.entries(p)
121
- .map{ |e| File.realpath(File.join(p, e))}
122
- .select{ |entry| File.file?(entry)}
123
- .select{ |entry| CERT_EXT.any?{ |ext| entry.end_with?(ext)}}
164
+ .map { |e| File.realpath(File.join(p, e)) }
165
+ .select { |entry| File.file?(entry) }
166
+ .select { |entry| CERT_EXT.any? { |ext| entry.end_with?(ext) } }
124
167
  end
125
168
  @certificate_paths.concat(pp)
126
169
  end
127
170
  end
128
171
  @certificate_paths.uniq!
172
+ nil
129
173
  end
130
174
 
131
175
  # Return cert file paths (computes OS defaults lazily if never set).
@@ -145,22 +189,22 @@ module Aspera
145
189
  # Called every time a new Net::HTTP session is opened.
146
190
  # ------------------------------------------------------------------
147
191
 
148
- # @param http_session [Net::HTTP]
192
+ # @param http_session [Net::HTTP] HTTP session to configure
193
+ # @return [nil]
149
194
  def update_session(http_session)
150
195
  http_session.set_debug_output(LineLogger.new(:trace2)) if Log.instance.logger.trace2?
151
- if http_session.use_ssl? && ignore_cert?(http_session.address, http_session.port)
152
- http_session.verify_mode = SELF_SIGNED_CERT
153
- end
196
+ http_session.verify_mode = SELF_SIGNED_CERT if http_session.use_ssl? && ignore_cert?(http_session.address, http_session.port)
154
197
  http_session.cert_store = @certificate_store if @certificate_store
155
- Log.log.debug{"Using cert store #{http_session.cert_store} (#{@certificate_store})"} unless http_session.cert_store.nil?
198
+ Log.log.debug { "Using cert store #{http_session.cert_store} (#{@certificate_store})" } unless http_session.cert_store.nil?
156
199
  @http_options.each do |k, v|
157
200
  method = "#{k}=".to_sym
158
201
  if http_session.respond_to?(method)
159
202
  http_session.send(method, v)
160
203
  else
161
- Log.log.error{"Unknown HTTP session attribute: #{k}"}
204
+ Log.log.error { "Unknown HTTP session attribute: #{k}" }
162
205
  end
163
206
  end
207
+ nil
164
208
  end
165
209
  end
166
210
  end
@@ -15,6 +15,8 @@ module Aspera
15
15
  # Set this to warn in advance when minimum required ruby version will increase
16
16
  # See also required_ruby_version in gemspec file
17
17
  RUBY_FUTURE_MINIMUM_VERSION = '3.2'
18
+ # Version with which this version of CLI was tested
19
+ SDK_VERSION = '1.1.9'
18
20
  end
19
21
  end
20
22
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/environment'
4
+ require 'aspera/log'
5
+ require 'aspera/assert'
6
+ require 'erb'
7
+
8
+ module Aspera
9
+ module Cli
10
+ # Email service injected into Context as :mailer so that any component
11
+ # (TransferAgent, plugins, ...) can send emails via SMTP and ERB templates.
12
+ # Provides :smtp, :notify_to, :notify_template options via the Options manager.
13
+ class Mailer
14
+ SMTP_CONF_PARAMS = %i[server tls ssl port domain username password from_name from_email].freeze
15
+ SMTP_BOOL_PARAMS = %i[tls ssl].freeze
16
+ SMTP_INT_PARAMS = %i[port].freeze
17
+ SMTP_STR_PARAMS = %i[server domain username password from_email from_name].freeze
18
+
19
+ # @param options [Parser] CLI options manager (provides :smtp, :notify_to, :notify_template)
20
+ # @param main_folder [String] application main folder (unused directly but kept for symmetry)
21
+ def initialize(options, main_folder)
22
+ @options = options
23
+ @main_folder = main_folder
24
+ end
25
+
26
+ # @return [Parser]
27
+ attr_reader :options
28
+
29
+ # @return [Hash] email server settings with defaults applied
30
+ def email_settings
31
+ smtp = options.get_option(:smtp, mandatory: true)
32
+ # Change keys from string into symbol
33
+ smtp = smtp.symbolize_keys
34
+ unsupported = smtp.keys - SMTP_CONF_PARAMS
35
+ Aspera.assert(unsupported.empty?, type: Cli::Error) { "Unsupported SMTP parameter: #{unsupported.join(', ')}, use: #{SMTP_CONF_PARAMS.join(', ')}" }
36
+ # Boolean fields must be actual booleans, not strings like "false"
37
+ SMTP_BOOL_PARAMS.each do |k|
38
+ Aspera.assert_values(smtp[k], [true, false], type: Cli::Error) { "smtp.#{k}" } if smtp.key?(k)
39
+ end
40
+ SMTP_INT_PARAMS.each do |k|
41
+ Aspera.assert_type(smtp[k], Integer, type: Cli::Error) { "smtp.#{k}" } if smtp.key?(k)
42
+ end
43
+ SMTP_STR_PARAMS.each do |k|
44
+ Aspera.assert_type(smtp[k], String, type: Cli::Error) { "smtp.#{k}" } if smtp.key?(k)
45
+ end
46
+ # smtp[:ssl] = nil (false)
47
+ smtp[:tls] = !smtp[:ssl] unless smtp.key?(:tls)
48
+ smtp[:port] ||= if smtp[:tls]
49
+ 587
50
+ elsif smtp[:ssl]
51
+ 465
52
+ else
53
+ 25
54
+ end
55
+ smtp[:from_email] ||= smtp[:username] if smtp.key?(:username)
56
+ smtp[:from_name] ||= smtp[:from_email].sub(/@.*$/, '').gsub(/[^a-zA-Z]/, ' ').capitalize if smtp.key?(:username)
57
+ smtp[:domain] ||= smtp[:from_email].sub(/^.*@/, '') if smtp.key?(:from_email)
58
+ %i[server port domain].each do |n|
59
+ Aspera.assert(smtp.key?(n)) { "Missing mandatory smtp parameter: #{n}" }
60
+ end
61
+ Log.dump(:smtp, smtp.reject { |k, _| k == :password })
62
+ return smtp
63
+ end
64
+
65
+ # Send email using ERB template
66
+ # @param email_template_default [String] default template, can be overridden by option
67
+ # @param values [Hash] values to be used in template, keys with default: to, from_name, from_email
68
+ def send_email_template(email_template_default: nil, values: {})
69
+ values[:to] ||= options.get_option(:notify_to, mandatory: true)
70
+ notify_template = options.get_option(:notify_template, mandatory: email_template_default.nil?) || email_template_default
71
+ mail_conf = email_settings
72
+ values[:from_name] ||= mail_conf[:from_name]
73
+ values[:from_email] ||= mail_conf[:from_email]
74
+ %i[to from_email].each do |n|
75
+ Aspera.assert_type(values[n], String) { "Missing email parameter: #{n} in config" }
76
+ end
77
+ start_options = [mail_conf[:domain]]
78
+ start_options.push(mail_conf[:username], mail_conf[:password], :login) if mail_conf.key?(:username) && mail_conf.key?(:password)
79
+ template_binding = Environment.empty_binding
80
+ values.each do |k, v|
81
+ Aspera.assert_type(k, Symbol)
82
+ template_binding.local_variable_set(k, v)
83
+ end
84
+ msg_with_headers = ERB.new(notify_template).result(template_binding)
85
+ Log.dump(:msg_with_headers, msg_with_headers)
86
+ require 'net/smtp'
87
+ smtp = Net::SMTP.new(mail_conf[:server], mail_conf[:port])
88
+ smtp.enable_starttls if mail_conf[:tls]
89
+ smtp.enable_tls if mail_conf[:ssl]
90
+ smtp.start(*start_options) do |smtp_session|
91
+ smtp_session.send_message(msg_with_headers, values[:from_email], values[:to])
92
+ end
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore ascli
4
+
5
+ require 'json'
6
+ require 'aspera/log'
7
+ require 'aspera/cli/runner'
8
+ require 'aspera/cli/error'
9
+ require 'aspera/schema/reader'
10
+ require 'aspera/schema/registry'
11
+ unless defined?(MCP::Tool)
12
+ begin
13
+ require 'mcp'
14
+ rescue LoadError
15
+ raise Cli::Error, "The 'mcp' and 'rack' gems are required. Install them with: gem install mcp rack"
16
+ end
17
+ end
18
+
19
+ module Aspera
20
+ module Cli
21
+ # MCP Tool: executes an ascli command in-process.
22
+ class McpTool < MCP::Tool
23
+ # Default maximum byte size of the JSON text content returned for list results.
24
+ # Items are appended whole until the limit is reached; the full list is always
25
+ # available in structuredContent.
26
+ DEFAULT_MAX_TEXT_BYTES = 100_000
27
+ # Default extra arguments automatically prepended to every ascli call.
28
+ # Keeps the AI from having to remember mandatory flags on every invocation.
29
+ DEFAULT_EXTRA_ARGS = ['--interactive=no', '--transfer.asynchronous=true'].freeze
30
+
31
+ tool_name 'execute_ascli_command'
32
+
33
+ description <<~DESC.strip
34
+ Execute any ascli (Aspera CLI) command in-process and return its result.
35
+
36
+ SYNTAX
37
+ args is a JSON array of strings mirroring the CLI command line.
38
+ Element 0 : plugin name (aoc, faspex5, node, server, config, …).
39
+ Elements 1+: sub-commands, then --option=value flags in any order.
40
+ Passing structured values: use an extended-value prefix on the relevant element:
41
+ "@json:{...}" — inline JSON object or array (preferred for LLMs: no shell quoting, natural JSON)
42
+ "@preset:name" — expand a saved credential preset
43
+ "@env:VAR" — read value from environment variable
44
+ "@file:/path" — read value from a file
45
+ Note: the dot-path form (@: key=value ...) is designed for humans typing in a terminal shell.
46
+ Prefer @json: when building args programmatically or as an LLM.
47
+
48
+ AUTOMATIC FLAGS
49
+ The server automatically prepends extra_args to every call (default:
50
+ #{DEFAULT_EXTRA_ARGS.join(' ')}).
51
+ Do NOT repeat these flags in your args array — they are already injected.
52
+ If you explicitly need to override one (e.g. --interactive=yes), include it
53
+ in your args; your value will take precedence because it appears after the
54
+ injected ones.
55
+ If credentials are missing or incomplete, the command will return an error;
56
+ report it and stop, never wait for input.
57
+
58
+ DISCOVERY — recommended sequence
59
+ Step 1 — enumerate all commands:
60
+ ["config", "commands"]
61
+ Returns { syntax, description } for every leaf command of every plugin.
62
+ Syntax notation: <arg> mandatory, [<arg>] optional, <a|b> enum, <arg...> variadic.
63
+ This single call covers all 800+ commands — no other discovery step is needed
64
+ unless you want details about a specific command or its Hash arguments.
65
+ IMPORTANT: never guess command names from training data. If you are unsure of
66
+ the exact subcommand name (e.g. shared_folders vs shared_inboxes), always call
67
+ ["config", "commands"] first to find the correct name.
68
+
69
+ Step 2 — inspect a Hash argument schema BEFORE calling any command with <data>:
70
+ MANDATORY: whenever the command syntax shows a <data> argument, you MUST call
71
+ "help" offline first. Never infer field names from server error messages.
72
+ ["<plugin>", "<cmd>", ..., "help"]
73
+ Replace the Hash positional argument with the literal string "help".
74
+ Returns a table of field names, types, and descriptions for that argument.
75
+ Example: ["aoc", "admin", "user", "create", "help"]
76
+ Note: only works for Hash-typed arguments, not for plain String arguments.
77
+
78
+ Step 2b — discover available query/filter parameters for list commands:
79
+ ["<plugin>", "<cmd>", ..., "--query=help"]
80
+ Add --query=help to any list command to see all supported filter parameters
81
+ with their types and descriptions.
82
+ Example: ["aoc", "admin", "user", "list", "--query=help"]
83
+ Example: ["faspex5", "admin", "packages", "list", "--query=help"]
84
+ Note: only works on commands that support --query filtering (list/delete).
85
+
86
+ Step 3 — list all options for a plugin as structured data:
87
+ ["config", "options", "<plugin>"]
88
+ Returns { option, description, allowed, deprecated } for every --flag
89
+ accepted by that plugin (global + plugin-specific, ~80 entries).
90
+ Use when you need to know the exact allowed values or find a specific flag.
91
+
92
+ Full documentation:
93
+ ["config", "documentation", "toc"]
94
+ Returns the table of contents: { level, title, anchor } for every heading.
95
+ ["config", "documentation", "local", "<anchor>"]
96
+ Returns only the section matching that anchor (same slugs as GitHub).
97
+ ["config", "documentation", "local", "--ui=text"]
98
+ Returns the complete README (~300 KB). Use only when a specific section
99
+ is insufficient and you need broader narrative context.
100
+
101
+ RESULT FORMAT
102
+ Structured data is always in structuredContent (a JSON object).
103
+ For list results, text content is limited to #{DEFAULT_MAX_TEXT_BYTES} bytes (whole items only).
104
+ When truncated, a WARNING block is appended: "WARNING: result truncated to N of TOTAL items."
105
+ You MUST read structuredContent.items to obtain the full dataset — never report
106
+ counts, totals, or search results from a truncated text block.
107
+
108
+ FILE LIST FOR TRANSFERS
109
+ For all transfers (upload, download, package send, …), append source file paths
110
+ at the end of the args array — no --sources flag needed.
111
+ ["server", "upload", "--to-folder=/dst", "/local/file1", "/local/file2"]
112
+ ["aoc", "packages", "send", "@:", "name=pkg", "recipients.0=user@example.com", "END",
113
+ "/local/file1", "/local/file2"]
114
+
115
+ EXAMPLES
116
+ ["config", "commands"] ← Step 1: full capability map
117
+ ["aoc", "admin", "user", "create", "help"] ← Step 2: schema of <data> Hash
118
+ ["config", "options", "aoc"] ← Step 3: all --flags for aoc plugin
119
+ ["config", "documentation", "toc"] ← TOC of local README
120
+ ["config", "documentation", "local",
121
+ "leveraging-ai-assistance"] ← single README section by anchor
122
+ ["aoc", "admin", "user", "create",
123
+ '@json:{"email":"a@b.com","name":"Alice"}',
124
+ "--url=https://org.ibmaspera.com", "--username=admin@org.com",
125
+ "--password=secret"]
126
+ ["server", "browse", "/",
127
+ "--url=https://host", "--username=user", "--password=secret"]
128
+ ["server", "upload", "--url=https://host", "--username=user", "--password=secret",
129
+ "--to-folder=/uploads", "/local/file1.txt", "/local/file2.txt"]
130
+ ["aoc", "packages", "list", "--workspace=MyWorkspace"]
131
+ ["node", "info", "--url=https://node-host",
132
+ "--username=user", "--password=pass"]
133
+ DESC
134
+
135
+ input_schema(
136
+ properties: {
137
+ args: {
138
+ type: 'array',
139
+ items: {type: 'string'},
140
+ minItems: 1,
141
+ description: 'ascli arguments: first element is the plugin name, followed by action and --option=value flags'
142
+ }
143
+ },
144
+ required: ['args']
145
+ )
146
+
147
+ class << self
148
+ attr_accessor :max_text_bytes, :extra_args
149
+
150
+ def call(args:, server_context: nil)
151
+ effective_args = Array(extra_args || DEFAULT_EXTRA_ARGS) + args
152
+ Log.dump(:mcp_execute, effective_args)
153
+ runner = Runner.new(effective_args)
154
+ result = runner.run_with_result
155
+ case result
156
+ when Result::Nothing, Result::Empty, NilClass
157
+ MCP::Tool::Response.new([{type: 'text', text: ''}])
158
+ when Result::SingleObject, Result::ObjectList, Result::ValueList
159
+ # Apply --select filter in place (affects both text and structuredContent).
160
+ runner.context.formatter.filter_columns_on_select(result.data) if result.data.is_a?(Array)
161
+ # MCP spec requires structuredContent to be a JSON object (not an array).
162
+ structured = result.data.is_a?(Array) ? {items: result.data} : result.data
163
+ content = if result.data.is_a?(Array)
164
+ text_limit = max_text_bytes || DEFAULT_MAX_TEXT_BYTES
165
+ truncated_items = truncate_items_by_bytes(result.data, text_limit)
166
+ total = result.data.size
167
+ if truncated_items.size < total
168
+ [
169
+ {type: 'text', text: JSON.generate(truncated_items)},
170
+ {type: 'text', text: "WARNING: result truncated to #{truncated_items.size} of #{total} items. Full dataset available in structuredContent."}
171
+ ]
172
+ else
173
+ [{type: 'text', text: JSON.generate(result.data)}]
174
+ end
175
+ else
176
+ [{type: 'text', text: JSON.generate(result.data)}]
177
+ end
178
+ MCP::Tool::Response.new(content, structured_content: structured)
179
+ else
180
+ MCP::Tool::Response.new([{type: 'text', text: result.data.to_s}])
181
+ end
182
+ rescue Cli::SchemaRequest => e
183
+ schema_path = e.path
184
+ if schema_path.nil?
185
+ MCP::Tool::Response.new([{type: 'text', text: "#{e.class}: #{e.message} (no schema available)"}], error: true)
186
+ else
187
+ rows = schema_to_rows(Schema::Registry.instance.reader(schema_path))
188
+ structured = {items: rows}
189
+ MCP::Tool::Response.new([{type: 'text', text: JSON.generate(rows)}], structured_content: structured)
190
+ end
191
+ rescue SystemExit => e
192
+ MCP::Tool::Response.new([{type: 'text', text: "exited with status #{e.status}"}], error: !e.status.zero?)
193
+ rescue => e
194
+ MCP::Tool::Response.new([{type: 'text', text: "#{e.class}: #{dedupe_lines(e.message)}"}], error: true)
195
+ end
196
+
197
+ # Delegate to Schema::Reader#to_rows — semantic fields, no ANSI, MCP/JSON-ready.
198
+ def schema_to_rows(reader)
199
+ reader.to_rows
200
+ end
201
+
202
+ # Collapse consecutive duplicate lines in a multi-line message.
203
+ # Each run of identical lines is replaced by one line + "(×N)" suffix when N > 1.
204
+ def dedupe_lines(msg)
205
+ return msg unless msg.include?("\n")
206
+ msg.split("\n").chunk_while { |a, b| a == b }.map do |group|
207
+ group.size > 1 ? "#{group.first} (x#{group.size})" : group.first
208
+ end.join("\n")
209
+ end
210
+
211
+ # Returns the largest prefix of +items+ whose JSON serialization fits within +max_bytes+.
212
+ # Items are appended whole — no item is ever split mid-JSON.
213
+ def truncate_items_by_bytes(items, max_bytes)
214
+ buf = +''
215
+ items.each_with_index do |item, i|
216
+ fragment = (i.zero? ? '[' : ',') + JSON.generate(item)
217
+ break if buf.bytesize + fragment.bytesize + 1 > max_bytes # +1 for closing ']'
218
+ buf << fragment
219
+ end
220
+ buf.empty? ? [] : JSON.parse("#{buf}]")
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/parser'
4
+ require 'aspera/cli/command_spec'
5
+
6
+ module Aspera
7
+ module Cli
8
+ # Mixin providing declarative CLI option definitions (`option :name, ...`)
9
+ # and batch declaration onto a Parser instance (`declare_options(parser)`).
10
+ module OptionDeclarator
11
+ # Registry of OptionSpec objects defined on this class/module.
12
+ # @return [Hash{Symbol => OptionSpec}]
13
+ def option_specs
14
+ @option_specs ||= {}
15
+ end
16
+
17
+ # Declare an option in this class's registry.
18
+ #
19
+ # @param name [Symbol] Option name
20
+ # @param description [String, nil] User-facing description
21
+ # @param short [String, nil] Single-character short form
22
+ # @param allowed [Object, nil] Allowed values
23
+ # @param default [Object, nil] Default value
24
+ # @param handler [Symbol, Hash, nil] Handler (Symbol or Hash)
25
+ # @param deprecation [String, nil] Deprecation message
26
+ # @param schema [String, nil] Schema reference
27
+ def option(name, description: nil,
28
+ short: nil, allowed: nil, default: nil,
29
+ handler: nil, deprecation: nil, schema: nil)
30
+ raise ArgumentError, "Duplicate option: #{name.inspect}" if option_specs.key?(name)
31
+ option_specs[name] = OptionSpec.new(
32
+ name: name,
33
+ description: description,
34
+ short: short,
35
+ allowed: allowed,
36
+ default: default,
37
+ handler: handler,
38
+ deprecation: deprecation,
39
+ schema: schema
40
+ )
41
+ end
42
+
43
+ # Declare all options registered on this class onto a Parser instance.
44
+ # Skips options already declared on the parser.
45
+ #
46
+ # @param parser [Aspera::Cli::Parser]
47
+ # @param target [Object, nil] default target object for Symbol handlers (defaults to self)
48
+ # @return [void]
49
+ def declare_options(parser, target: self)
50
+ option_specs.each_value do |spec|
51
+ next if parser.option_declared?(spec.name)
52
+ resolved_handler =
53
+ case spec.handler
54
+ when Symbol then {o: target, m: spec.handler}
55
+ when Hash then spec.handler
56
+ end
57
+ parser.declare(
58
+ spec.name,
59
+ description: spec.description,
60
+ short: spec.short,
61
+ allowed: spec.allowed,
62
+ default: spec.default,
63
+ handler: resolved_handler,
64
+ deprecation: spec.deprecation,
65
+ schema: schema_for_spec(spec)
66
+ )
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def schema_for_spec(spec)
73
+ spec.schema
74
+ end
75
+ end
76
+ end
77
+ end