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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest_call_error'
4
+
5
+ module Aspera
6
+ # Helper to execute GraphQL queries via a Rest object.
7
+ # Follows the GraphQL-over-HTTP convention: POST {query, variables} to endpoint,
8
+ # parse top-level `data` key, raise on top-level `errors` array.
9
+ module GraphQL
10
+ QUERIES_FOLDER = File.join(__dir__, 'api', 'queries')
11
+ private_constant :QUERIES_FOLDER
12
+
13
+ class << self
14
+ # Execute a GraphQL query loaded from a .graphql file in lib/aspera/api/queries/
15
+ # @param rest [Rest] API object pointing at the GraphQL endpoint
16
+ # @param name [String] Base filename without extension (e.g. 'my_query')
17
+ # @param variables [Hash] GraphQL variables
18
+ # @return [Hash] The `data` object from the response
19
+ # @raise [RestCallError] if the response contains GraphQL errors
20
+ def execute(rest, name, variables = {})
21
+ query = load_query(name)
22
+ response = rest.create(nil, {query: query, variables: variables})
23
+ errors = response['errors']
24
+ raise RestCallError, errors.map{ |e| e['message']}.join("\n") if errors.is_a?(Array) && !errors.empty?
25
+ response['data']
26
+ end
27
+
28
+ private
29
+
30
+ def load_query(name)
31
+ File.read(File.join(QUERIES_FOLDER, "#{name}.graphql"))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ::Hash
4
+ # Recursively merge another hash, with nested hashes merged deeply
5
+ # @param second [Hash] Hash to merge into self
6
+ # @return [Hash] New merged hash
4
7
  def deep_merge(second)
5
8
  merge(second){ |_key, v1, v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge(v2) : v2}
6
9
  end
7
10
 
11
+ # Recursively merge another hash in-place, with nested hashes merged deeply
12
+ # @param second [Hash] Hash to merge into self
13
+ # @return [Hash] self
8
14
  def deep_merge!(second)
9
15
  merge!(second){ |_key, v1, v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge!(v2) : v2}
10
16
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore blankslate jsonrpc
4
+
5
+ require 'aspera/rest_error_analyzer'
6
+ require 'aspera/assert'
7
+ require 'aspera/json_rpc/version'
8
+ require 'blankslate'
9
+
10
+ Aspera::RestErrorAnalyzer.instance.add_simple_handler(name: 'JSON RPC', path: %w[error message], always: true)
11
+
12
+ module Aspera
13
+ module JsonRpc
14
+ # JSON-RPC 2.0 client over an Aspera::Rest HTTP endpoint.
15
+ # Methods are dispatched dynamically via method_missing.
16
+ # Example:
17
+ # client = JsonRpc::Client.new(Rest.new(base_url: 'http://127.0.0.1:33024'))
18
+ # client.get_info
19
+ # client.start_transfer(app_id: '...', transfer_spec: {...})
20
+ class Client < BlankSlate
21
+ reveal :instance_variable_get
22
+ reveal :inspect
23
+ reveal :to_s
24
+
25
+ # @param api [Rest] Aspera REST object pointing at the JSON-RPC endpoint
26
+ # @param namespace [String, nil] optional method prefix, e.g. "myns."
27
+ def initialize(api, namespace = nil)
28
+ super()
29
+ @api = api
30
+ @namespace = namespace
31
+ @request_id = 0
32
+ end
33
+
34
+ def respond_to_missing?(_sym, _include_private = false)
35
+ true
36
+ end
37
+
38
+ # Dispatch any Ruby method call as a JSON-RPC request
39
+ def method_missing(method, *args, &block)
40
+ args = args.first if args.size == 1 && args.first.is_a?(Hash)
41
+ data = @api.create('', {
42
+ jsonrpc: VERSION,
43
+ method: "#{@namespace}#{method}",
44
+ params: args,
45
+ id: @request_id += 1
46
+ })
47
+ Aspera.assert_type(data, Hash){'response'}
48
+ Aspera.assert(data['jsonrpc'] == VERSION, 'bad version in response')
49
+ Aspera.assert(data.key?('id'), 'missing id in response')
50
+ Aspera.assert(!(data.key?('error') && data.key?('result')), 'both error and response')
51
+ Aspera.assert(
52
+ !data.key?('error') ||
53
+ data['error'].is_a?(Hash) &&
54
+ data['error']['code'].is_a?(Integer) &&
55
+ data['error']['message'].is_a?(String),
56
+ 'bad error response'
57
+ )
58
+ return data['result']
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aspera
4
+ module JsonRpc
5
+ VERSION = '2.0'
6
+ end
7
+ end
@@ -11,7 +11,7 @@ module Aspera
11
11
  options.each_pair do |k, v|
12
12
  Aspera.assert_type(v, String){k.to_s}
13
13
  end
14
- Aspera.assert(options.key?(:label)){'label is required'}
14
+ Aspera.assert(options.key?(:label), 'label is required')
15
15
  end
16
16
  end
17
17
  end
@@ -28,7 +28,7 @@ module Aspera
28
28
  vault_file = File.read(@path)
29
29
  if vault_file.start_with?('---')
30
30
  vault_info = YAML.parse(vault_file).to_ruby
31
- Aspera.assert(vault_info.keys.sort == FILE_KEYS){'Invalid vault file'}
31
+ Aspera.assert(vault_info.keys.sort == FILE_KEYS, 'Invalid vault file')
32
32
  @cipher_name = vault_info['cipher']
33
33
  vault_encrypted_data = vault_info['data']
34
34
  else
@@ -64,7 +64,7 @@ module Aspera
64
64
  def set(options)
65
65
  validate_set(options)
66
66
  label = options.delete(:label)
67
- raise "secret #{label} already exist, delete first" if @all_secrets.key?(label)
67
+ Aspera.assert(!@all_secrets.key?(label)){"secret #{label} already exist, delete first"}
68
68
  @all_secrets[label] = options.symbolize_keys
69
69
  save
70
70
  end
@@ -20,23 +20,23 @@ module Aspera
20
20
  when 'file'
21
21
  info[:file] = name || 'vault.bin'
22
22
  info[:file] = File.join(folder, info[:file]) unless File.absolute_path?(info[:file])
23
- Aspera.assert(!password.nil?){'please provide password'}
23
+ Aspera.assert(!password.nil?, 'please provide password')
24
24
  info[:password] = password
25
25
  # this module requires compilation, so it is optional
26
26
  require 'aspera/keychain/encrypted_hash'
27
- @vault = Keychain::EncryptedHash.new(**info)
27
+ Keychain::EncryptedHash.new(**info)
28
28
  when 'system'
29
29
  case Environment.instance.os
30
30
  when Environment::OS_MACOS
31
31
  info[:name] ||= name
32
- @vault = Keychain::MacosSystem.new(**info)
32
+ Keychain::MacosSystem.new(**info)
33
33
  else
34
34
  raise Error, 'not implemented for this OS'
35
35
  end
36
36
  when 'vault'
37
37
  require 'aspera/keychain/hashicorp_vault'
38
38
  info[:token] ||= password
39
- @vault = Keychain::HashicorpVault.new(**info)
39
+ Keychain::HashicorpVault.new(**info)
40
40
  else Aspera.error_unexpected_value(vault_type)
41
41
  end
42
42
  end
@@ -42,9 +42,9 @@ module Aspera
42
42
  url = options&.delete(:url)
43
43
  if !url.nil?
44
44
  uri = URI.parse(url)
45
- Aspera.assert(uri.scheme.eql?('https')){'only https'}
45
+ Aspera.assert(uri.scheme.eql?('https'), 'only https')
46
46
  options[:protocol] = 'htps' # cspell: disable-line
47
- raise Error, 'host required in URL' if uri.host.nil?
47
+ Aspera.assert(!uri.host.nil?, type: Error){'host required in URL'}
48
48
  options[:server] = uri.host
49
49
  options[:path] = uri.path unless ['', '/'].include?(uri.path)
50
50
  options[:port] = uri.port unless uri.port.eql?(443) && !url.include?(':443/')
@@ -128,7 +128,7 @@ module Aspera
128
128
  super()
129
129
  @keychain_name = name.nil? ? 'default keychain' : name
130
130
  @keychain = name.nil? ? MacosSecurity::Keychain.default : MacosSecurity::Keychain.by_name(name)
131
- raise "no such keychain #{name}" if @keychain.nil?
131
+ Aspera.assert(!@keychain.nil?){"no such keychain #{name}"}
132
132
  end
133
133
 
134
134
  def info
@@ -155,7 +155,7 @@ module Aspera
155
155
  unsupported = options.keys - %i[label]
156
156
  Aspera.assert(unsupported.empty?){"unsupported options: #{unsupported}"}
157
157
  info = @keychain.password(:find, :generic, label: options[:label])
158
- raise Error, 'not found' if info.nil?
158
+ Aspera.assert(!info.nil?, type: Error){'not found'}
159
159
  result = options.clone
160
160
  result[:secret] = info['password']
161
161
  result[:description] = info['icmt'] # cspell: disable-line
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module Aspera
6
+ # Parse and represent an HTTP Link header as per RFC 8288.
7
+ # Inspired by the link_header gem, with the following fixes:
8
+ # - rel lookup is case-insensitive (RFC 7230: parameter names are case-insensitive)
9
+ # - uses StringScanner so commas inside <URI> are never mistaken for entry separators
10
+ # - no external dependency
11
+ class LinkHeader
12
+ # A single link entry: one URI and its attribute pairs.
13
+ class Link
14
+ attr_reader :href, :attr_pairs
15
+
16
+ def initialize(href, attr_pairs)
17
+ @href = href
18
+ @attr_pairs = attr_pairs
19
+ end
20
+
21
+ # Retrieve an attribute value by name, case-insensitively.
22
+ def [](key)
23
+ pair = @attr_pairs.detect{ |k, _v| k.casecmp?(key)}
24
+ pair&.last
25
+ end
26
+ end
27
+
28
+ attr_reader :links
29
+
30
+ def initialize(links = [])
31
+ @links = links
32
+ end
33
+
34
+ # Return the href of the first link whose +rel+ attribute matches +rel+.
35
+ # Comparison is case-insensitive per RFC 7230 s.3.2 and RFC 8288 s.3.
36
+ # Returns nil if no link with that relation exists.
37
+ # @param rel [String]
38
+ # @return [String, nil]
39
+ def find_href(rel: 'next')
40
+ @links.detect{ |link| link['rel']&.casecmp?(rel)}&.href
41
+ end
42
+
43
+ class << self
44
+ # Parse a raw Link header value into a +LinkHeader+ instance.
45
+ # Uses StringScanner so that commas inside <URI> are not treated as separators.
46
+ # @param raw [String, nil]
47
+ # @return [LinkHeader]
48
+ def parse(raw)
49
+ return new unless raw && !raw.empty?
50
+
51
+ links = []
52
+ scanner = StringScanner.new(raw)
53
+
54
+ while scanner.scan(HREF_RE)
55
+ href = scanner[1].strip
56
+ attrs = []
57
+ while scanner.scan(ATTR_RE)
58
+ key = scanner[1]
59
+ # scanner[2] = full match (token or "quoted"), scanner[3] = content inside double-quotes
60
+ value = scanner[3] || scanner[2]
61
+ attrs << [key, value]
62
+ break unless scanner.scan(SEMI_RE)
63
+ end
64
+ links << Link.new(href, attrs)
65
+ break unless scanner.scan(COMMA_RE)
66
+ end
67
+
68
+ new(links)
69
+ end
70
+
71
+ private :new
72
+ end
73
+
74
+ # RFC 2616 token: any char except separators
75
+ TOKEN_RE = /[^()<>@,;:\"\[\]?={}\s]+/ # RFC 2616 token
76
+ QUOTED_RE = /"((?:[^"\\]|\\.)*)"/ # double-quoted string with backslash escapes
77
+ HREF_RE = /\s*<([^>]*)>\s*;?\s*/ # <URI> possibly followed by ;
78
+ ATTR_RE = /(#{TOKEN_RE})\s*=\s*(#{TOKEN_RE}|#{QUOTED_RE})\s*/ # key=value or key="value"
79
+ SEMI_RE = /;\s*/ # parameter separator
80
+ COMMA_RE = /,\s*/ # link entry separator
81
+ end
82
+ end
data/lib/aspera/log.rb CHANGED
@@ -36,13 +36,16 @@ class Logger
36
36
  end
37
37
 
38
38
  class << self
39
- # Define methods for a given log level
39
+ # Define logger methods for a given log level (e.g. `debug`, `debug?`, `debug!`)
40
+ # @param str_level [String] Log level name (e.g. "DEBUG", "TRACE1")
41
+ # @return [nil]
40
42
  def make_methods(str_level)
41
43
  int_level = ::Logger.const_get(str_level.upcase)
42
44
  method_base = str_level.downcase
43
45
  define_method(method_base, ->(message = nil, &block){add(int_level, message, &block)})
44
46
  define_method("#{method_base}?", ->{level <= int_level})
45
47
  define_method("#{method_base}!", ->{self.level = int_level})
48
+ nil
46
49
  end
47
50
  end
48
51
  # Declare methods for all levels
@@ -54,6 +57,10 @@ $VERBOSE = old_verbose
54
57
 
55
58
  module Aspera
56
59
  # Singleton object for logging
60
+ #
61
+ # @!method self.instance
62
+ # Returns the singleton instance of Log
63
+ # @return [Log] the singleton instance
57
64
  class Log
58
65
  include Singleton
59
66
 
@@ -80,7 +87,7 @@ module Aspera
80
87
  # @yieldreturn [Object] Computed object to dump (alternative to object parameter)
81
88
  def dump(name, object = nil, level: :debug, &block)
82
89
  return unless instance.logger.send(:"#{level}?")
83
- Aspera.assert(object.nil? || block.nil?){'Use either object, or block, not both'}
90
+ Aspera.assert(object.nil? || block.nil?, 'Use either object, or block, not both')
84
91
  object = yield if block_given?
85
92
  instance.logger.send(level, obj_dump(name, object))
86
93
  end
@@ -138,9 +145,13 @@ module Aspera
138
145
  attr_reader :logger_type, :logger
139
146
  attr_accessor :dump_format
140
147
 
148
+ # Set the program name used in log output
149
+ # @param value [String] Program name
150
+ # @return [nil]
141
151
  def program_name=(value)
142
152
  @program_name = value
143
153
  self.logger_type = @logger_type
154
+ nil
144
155
  end
145
156
 
146
157
  # Set log level of underlying logger given symbol level
@@ -150,15 +161,19 @@ module Aspera
150
161
  @logger.level = Logger::Severity.const_get(new_level.to_sym.upcase)
151
162
  end
152
163
 
164
+ # Set log formatter; accepts a symbol name, a Proc, or a Logger::Formatter instance
165
+ # @param formatter [String, Proc, Logger::Formatter] Formatter to use; one of: standard, default, caller
166
+ # @return [nil]
153
167
  def formatter=(formatter)
154
168
  if formatter.is_a?(String)
155
- raise Error, "Unknown formatter #{formatter}, use one of: #{FORMATTERS.join(', ')}" unless FORMATTER_LAMBDAS.key?(formatter.to_sym)
169
+ Aspera.assert(FORMATTER_LAMBDAS.key?(formatter.to_sym), type: Error){"Unknown formatter #{formatter}, use one of: #{FORMATTERS.join(', ')}"}
156
170
  formatter = FORMATTER_LAMBDAS[formatter.to_sym]
157
171
  elsif !formatter.respond_to?(:call) && !formatter.is_a?(Logger::Formatter)
158
172
  raise Error, 'Formatter must be a String, a Logger::Formatter or a Proc'
159
173
  end
160
174
  # Update formatter with password hiding
161
175
  @logger.formatter = SecretHider.instance.log_formatter(formatter)
176
+ nil
162
177
  end
163
178
 
164
179
  def formatter
@@ -168,11 +183,13 @@ module Aspera
168
183
  # Get symbol of debug level of underlying logger
169
184
  # @return [Symbol] One of LEVELS
170
185
  def level
171
- Aspera.assert(Logger::SEVERITY_LABEL.key?(@logger.level))
186
+ Aspera.assert(Logger::SEVERITY_LABEL.key?(@logger.level), 'unexpected logger level value')
172
187
  Logger::SEVERITY_LABEL[@logger.level].downcase
173
188
  end
174
189
 
175
- # Change underlying logger, but keep log level (default: INFO)
190
+ # Change underlying logger output destination, keeping current log level
191
+ # @param new_log_type [Symbol] Log destination; one of LOG_TYPES (:stderr, :stdout, :syslog)
192
+ # @return [nil]
176
193
  def logger_type=(new_log_type)
177
194
  # [Integer]
178
195
  current_severity_integer = @logger&.level || ENV['AS_LOG_LEVEL']&.to_i || Logger::Severity::INFO
@@ -199,6 +216,7 @@ module Aspera
199
216
  @logger_type = new_log_type
200
217
  # add secret hider to default logger
201
218
  self.formatter = @logger.formatter
219
+ nil
202
220
  end
203
221
 
204
222
  private
@@ -9,6 +9,71 @@ module Aspera
9
9
 
10
10
  class << self
11
11
  COL_WIDTH = 80
12
+
13
+ # Convert a Markdown heading text to a GitHub-flavoured anchor.
14
+ # Rules: downcase, keep letters/digits/spaces/hyphens, replace spaces with hyphens.
15
+ # Duplicate anchors are disambiguated by appending -1, -2, … (pass a seen Hash to track).
16
+ # @param text [String] raw heading text (without leading # and spaces)
17
+ # @param seen [Hash{String=>Integer}, nil] mutable counter; pass the same Hash across a document
18
+ # @return [String] anchor slug (without leading #)
19
+ def heading_to_anchor(text, seen: nil)
20
+ slug = text
21
+ .downcase
22
+ .gsub(/[`*_]/, '') # strip inline code/bold/italic markers
23
+ .gsub(/&[a-z]+;/, '') # strip HTML entities
24
+ .gsub(/[^\w\s-]/, '') # keep word chars, spaces, hyphens
25
+ .gsub(/\s+/, '-') # spaces → hyphens
26
+ .squeeze('-') # collapse consecutive hyphens
27
+ .strip
28
+ if seen
29
+ count = seen[slug].to_i
30
+ seen[slug] = count + 1
31
+ slug = "#{slug}-#{count}" if count > 0
32
+ end
33
+ slug
34
+ end
35
+
36
+ # Extract the table of contents from a Markdown document.
37
+ # @param content [String] full Markdown source
38
+ # @return [Array<Hash>] array of { level, title, anchor }
39
+ HEADING_RE = /^(\#{1,6})\s+(.+)$/
40
+
41
+ def toc(content)
42
+ seen = {}
43
+ content.each_line.filter_map do |line|
44
+ m = line.match(HEADING_RE)
45
+ next unless m
46
+ title = m[2].strip
47
+ {level: m[1].length, title: title, anchor: heading_to_anchor(title, seen: seen)}
48
+ end
49
+ end
50
+
51
+ # Extract the content of a single section (heading + body until next heading of same/higher level).
52
+ # @param content [String] full Markdown source
53
+ # @param anchor [String] GitHub anchor slug (without #)
54
+ # @return [String, nil] the section content, or nil if not found
55
+ def extract_section(content, anchor)
56
+ seen = {}
57
+ section_level = nil
58
+ result = []
59
+ content.each_line do |line|
60
+ m = line.match(HEADING_RE)
61
+ if m
62
+ slug = heading_to_anchor(m[2].strip, seen: seen)
63
+ if section_level.nil?
64
+ # not yet found: check if this heading matches
65
+ next unless slug == anchor
66
+ section_level = m[1].length
67
+ elsif m[1].length <= section_level
68
+ # already in section: stop at same/higher level heading
69
+ break
70
+ end
71
+ end
72
+ result << line if section_level
73
+ end
74
+ result.empty? ? nil : result.join
75
+ end
76
+
12
77
  # Generate markdown from the provided 2D table
13
78
  # @param table [Array<Array<String>>] 2D array of strings
14
79
  # @return [String] markdown table
@@ -26,28 +91,46 @@ module Aspera
26
91
  end
27
92
 
28
93
  # Generate markdown list from the provided list
94
+ # @param items [Array<String>] list of items
95
+ # @return [String] markdown unordered list
29
96
  def list(items)
30
97
  items.map{ |i| "- #{i}"}.join("\n")
31
98
  end
32
99
 
100
+ # Generate a markdown heading
101
+ # @param title [String] heading text
102
+ # @param level [Integer] heading level (1–6)
103
+ # @return [String] markdown heading
33
104
  def heading(title, level: 1)
34
105
  "#{'#' * level} #{title}\n\n"
35
106
  end
36
107
 
37
- # type: NOTE CAUTION WARNING IMPORTANT TIP INFO
108
+ # Generate a GitHub-flavoured admonition block
109
+ # @param lines [Array<String>] lines of the admonition body
110
+ # @param type [String] admonition type: NOTE, CAUTION, WARNING, IMPORTANT, TIP, INFO
111
+ # @return [String] markdown admonition block
38
112
  def admonition(lines, type: 'INFO')
39
113
  "> [!#{type}]\n#{lines.map{ |l| "> #{l}"}.join("\n")}\n\n"
40
114
  end
41
115
 
116
+ # Generate a fenced code block
117
+ # @param lines [Array<String>] lines of code
118
+ # @param type [String] language identifier for syntax highlighting
119
+ # @return [String] markdown fenced code block
42
120
  def code(lines, type: 'shell')
43
121
  "```#{type}\n#{lines.join("\n")}\n```\n\n"
44
122
  end
45
123
 
46
- # inline code
124
+ # Wrap text in inline code backticks
125
+ # @param text [String] text to wrap
126
+ # @return [String] inline code span
47
127
  def icode(text)
48
128
  "`#{text}`"
49
129
  end
50
130
 
131
+ # Wrap text in a markdown paragraph (trailing blank line)
132
+ # @param text [String] paragraph content
133
+ # @return [String] paragraph with trailing newlines
51
134
  def paragraph(text)
52
135
  "#{text}\n\n"
53
136
  end
data/lib/aspera/nagios.rb CHANGED
@@ -24,7 +24,7 @@ module Aspera
24
24
  # Process results of a analysis and display status and exit with code
25
25
  def process(data)
26
26
  Aspera.assert_type(data, Array)
27
- Aspera.assert(!data.empty?){'data is empty'}
27
+ Aspera.assert(!data.empty?, 'data is empty')
28
28
  %w[status component message].each do |c|
29
29
  Aspera.assert(data.first.key?(c)){"result must have #{c}"}
30
30
  end
@@ -79,7 +79,7 @@ module Aspera
79
79
  # Readable status list
80
80
  # @return [Array] of Hash
81
81
  def status_list
82
- Aspera.assert(!@data.empty?){'missing result'}
82
+ Aspera.assert(!@data.empty?, 'missing result')
83
83
  @data.map{ |i| {'status' => LEVELS[i[:code]].to_s, 'component' => i[:comp], 'message' => i[:msg]}}
84
84
  end
85
85
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'aspera/assert'
3
4
  require 'aspera/ascp/installation'
4
5
  require 'aspera/agent/direct'
5
6
  require 'aspera/log'
@@ -128,6 +129,7 @@ module Aspera
128
129
 
129
130
  # Process event from management port
130
131
  def process_event(event)
132
+ # Code duplicated on purpose, as implementation goes, blocks will be replaced
131
133
  case event['Type']
132
134
  when 'NOP' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
133
135
  # rubocop:disable Lint/DuplicateBranch
@@ -174,7 +176,7 @@ module Aspera
174
176
  require 'time'
175
177
 
176
178
  def folder_to_structure(folder_path)
177
- raise "Path does not exist or is not a directory: #{folder_path}" unless Dir.exist?(folder_path)
179
+ Aspera.assert(Dir.exist?(folder_path)){"Path does not exist or is not a directory: #{folder_path}"}
178
180
 
179
181
  # Build self structure
180
182
  folder_stat = File.stat(folder_path)
@@ -15,15 +15,15 @@ module Aspera
15
15
  class Base
16
16
  Aspera.require_method!(:create_token)
17
17
  # @param params [Hash] Parameters for token creation (client_id, client_secret, scope, etc...)
18
- # @param use_query [Boolean] Provide parameters in query instead of body
19
18
  # @param path_token [String] API end point to create a token from base URL
19
+ # @param use_query [Boolean] Provide parameters in query instead of body
20
20
  # @param token_field [String] Field in result that contains the token
21
21
  # @param cache_ids [Array] List of unique identifiers for cache id generation
22
22
  # @param **rest_params [Hash] Parameters for REST
23
23
  def initialize(
24
24
  params: {},
25
+ path_token: 'token',
25
26
  use_query: false,
26
- path_token: 'token',
27
27
  token_field: Factory::TOKEN_FIELD,
28
28
  cache_ids: [],
29
29
  **rest_params
@@ -42,14 +42,14 @@ module Aspera
42
42
 
43
43
  # The OAuth API Object
44
44
  attr_reader :api
45
- # Sub path to generate token
46
- attr_reader :path_token
47
45
  # Parameters to generate token
48
46
  attr_reader :params
47
+ # Sub path to generate token
48
+ attr_reader :path_token
49
49
 
50
50
  # Helper method to create token as per RFC
51
- # @return [HTTPResponse]
52
- # @raise RestError if not 2XX code
51
+ # @return [Net::HTTPResponse] raw HTTP response with token
52
+ # @raise [RestCallError] if not 2XX code
53
53
  def create_token_call(creation_params)
54
54
  Log.log.debug{'Generating a new token'.bg_green}
55
55
  return @api.create(@path_token, nil, query: creation_params, ret: :resp) if @use_query
@@ -73,8 +73,8 @@ module Aspera
73
73
  # get an OAuth v2 token (generated, cached, refreshed)
74
74
  # call token() to get a token.
75
75
  # if a token is expired (api returns 4xx), call again token(refresh: true)
76
- # @param cache set to false to disable cache
77
- # @param refresh set to true to force refresh or re-generation (if previous failed)
76
+ # @param cache [Boolean] set to false to disable cache
77
+ # @param refresh [Boolean] set to true to force refresh or re-generation (if previous failed)
78
78
  def token(cache: true, refresh: false)
79
79
  # get token info from cache (or nil), decoded with date and expiration status
80
80
  token_info = Factory.instance.get_token_info(@token_cache_id) if cache
@@ -106,7 +106,7 @@ module Aspera
106
106
  json_data = http.body
107
107
  token_data = JSON.parse(json_data)
108
108
  Factory.instance.persist_mgr.put(@token_cache_id, json_data)
109
- rescue => e
109
+ rescue StandardError => e
110
110
  # Refresh token can fail.
111
111
  Log.log.warn{"Refresh failed: #{e}"}
112
112
  end
@@ -122,7 +122,7 @@ module Aspera
122
122
  token_data = JSON.parse(json_data)
123
123
  Factory.instance.persist_mgr.put(@token_cache_id, json_data)
124
124
  end
125
- Aspera.assert(token_data.key?(@token_field)){"API error: No such field in answer: #{@token_field}"}
125
+ Aspera.assert(token_data.key?(@token_field)){"API error: No such field in answer: #{@token_field}"} unless token_data.nil?
126
126
  # ok we shall have a token here
127
127
  return token_data[@token_field]
128
128
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/oauth/base'
4
+ require 'aspera/assert'
5
+ require 'json'
6
+
7
+ module Aspera
8
+ module OAuth
9
+ # Token provider bootstrapped from an existing cookie (e.g. AoC browser cookie).
10
+ # Injects the bearer token and optional refresh token directly into the cache.
11
+ # Never generates a new token from scratch - raises if cache+refresh are both exhausted.
12
+ class Boot < Base
13
+ # @param cookie [String, nil] Raw cookie string (--password), nil to rely on existing cache
14
+ # @param username [String, nil] Expected subject; if provided, must match token's `sub` claim
15
+ # @param **base_params [Hash] Forwarded to Base (base_url:, params: {client_id:, scope:}, etc.)
16
+ def initialize(cookie: nil, username: nil, **base_params)
17
+ if cookie.nil?
18
+ # No cookie: rely on existing cache, identified by username if provided
19
+ Aspera.assert(username, 'Provide --password (cookie) on first use, or --username for cache lookup', type: ParameterError)
20
+ super(**base_params, cache_ids: [username])
21
+ else
22
+ cookies = cookie.split('; ').map{ |p| p.split('=', 2)}.to_h
23
+ Aspera.assert(cookies.key?('aoc.token'), '--password cookie does not contain aoc.token', type: ParameterError)
24
+ token = cookies['aoc.token']
25
+ decoded = Factory.instance.decode_token(token)
26
+ Aspera.assert_type(decoded, Hash){'Boot: token is not a decodable JWT'}
27
+ sub = decoded['sub']
28
+ Aspera.assert(username.nil? || username.eql?(sub)){"Boot: --username #{username} does not match token subject #{sub}"}
29
+ super(**base_params, cache_ids: [sub])
30
+ token_data = {Factory::TOKEN_FIELD => token}
31
+ token_data['refresh_token'] = cookies['aoc.refresh'] if cookies.key?('aoc.refresh')
32
+ Factory.instance.persist_mgr.put(@token_cache_id, JSON.generate(token_data))
33
+ end
34
+ end
35
+
36
+ # Should never be reached: if cache and refresh are both exhausted, re-authenticate via browser
37
+ def create_token
38
+ Aspera.report_error(AssertError, 'Boot: token expired and no refresh available — re-authenticate in browser')
39
+ end
40
+ end
41
+ Factory.instance.register_token_creator(Boot)
42
+ end
43
+ end