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
@@ -7,6 +7,15 @@ module Aspera
7
7
  module Schema
8
8
  # Generate documentation from Schema, for Transfer Spec, or async Conf spec
9
9
  class Documentation
10
+ # Map JSON Schema type names to user-friendly display names
11
+ JSON_TYPE_TO_DOC = {
12
+ 'string' => 'String',
13
+ 'integer' => 'Integer',
14
+ 'number' => 'Number',
15
+ 'boolean' => 'Bool',
16
+ 'array' => 'Array',
17
+ 'object' => 'Hash'
18
+ }.freeze
10
19
  # @param formatter [Cli::Formatter] Formatter instance with methods: markdown_text, tick, check_row
11
20
  # @param schema [Reader]
12
21
  # @param include_option [Boolean] `true`: include CLI options (switches, env vars) in descriptions
@@ -18,25 +27,27 @@ module Aspera
18
27
  @include_option = include_option
19
28
  @agent_columns = agent_columns
20
29
  @code_highlight = code_highlight
21
- @columns = %i[name type description]
22
- @columns.insert(-2, *Agent::Factory::ALL.values.map{ |i| i[:short]}.sort) if @agent_columns
23
- # @type [Array<Hash<Symbol,String>>]
24
- @rows = []
30
+ @columns = %w[name type description]
31
+ @columns.insert(-2, *Agent::Factory::ALL.values.map { |i| i[:short].to_s }.sort) if @agent_columns
32
+ # Sections: each entry is {header: row_or_nil, rows: []}
33
+ # A flat schema produces a single section with no header.
34
+ @sections = [{header: nil, rows: []}]
25
35
  end
26
36
 
27
37
  def rows
28
- @rows.sort_by{ |i| i[:name]}
38
+ @sections.flat_map do |section|
39
+ sorted = section[:rows].sort_by { |i| i['name'] }
40
+ section[:header] ? [section[:header]] + sorted : sorted
41
+ end
29
42
  end
30
43
 
31
44
  # @return [Array<String>]
32
- def columns
33
- @columns.map(&:to_s)
34
- end
45
+ attr_reader :columns
35
46
 
36
- # First row is the titles
47
+ # First row is the titles (for Markdown table generation)
37
48
  # @return [Array<Array<String>>]
38
49
  def table
39
- [@columns.map(&:to_s)] + @rows.sort_by{ |i| i[:name]}.map{ |row| @columns.map{ |field| row[field]}}
50
+ [@columns] + rows.map { |row| @columns.map { |field| row[field] } }
40
51
  end
41
52
 
42
53
  # Generate a documentation table from a JSON schema for transfer specifications
@@ -47,39 +58,65 @@ module Aspera
47
58
  # @param schema [Reader] The JSON schema to process
48
59
  # @return [Documentation]
49
60
  def build(schema = nil)
50
- code = @code_highlight ? ->(c){"`#{c}`"} : ->(c){c}
61
+ code = @code_highlight ? ->(c) { "`#{c}`" } : ->(c) { c }
51
62
  schema ||= @schema
52
- schema.each_property do |property_schema, _name, property_full_name|
63
+ render_title = ->(title) { title.gsub(Markdown::FORMATS) { @formatter.markdown_text(Regexp.last_match) } }
64
+ on_variant = ->(variant_reader, discriminant_property, discriminant_value) do
65
+ title = variant_reader.current['title'] || variant_reader.current['description']
66
+ header =
67
+ if discriminant_property && discriminant_value
68
+ desc = render_title.call("`#{discriminant_value}`")
69
+ desc += ": #{render_title.call(title)}" if title
70
+ @formatter.check_row({
71
+ 'name' => render_title.call("**#{discriminant_property}**"),
72
+ 'type' => code.call(JSON_TYPE_TO_DOC['string']),
73
+ 'description' => desc
74
+ })
75
+ elsif title
76
+ @formatter.check_row({'name' => "**#{render_title.call(title)}**", 'type' => '&nbsp;', 'description' => '&nbsp;'})
77
+ end
78
+ @sections.push({header: header, rows: []})
79
+ end
80
+ schema.each_property(on_variant: on_variant) do |property_schema, _name, property_full_name|
53
81
  node = property_schema.current
54
82
  # Manual table
83
+ item_type =
84
+ if node['type'].is_a?(Array)
85
+ node['type'].map { |t| JSON_TYPE_TO_DOC.fetch(t, t) }.join(', ')
86
+ elsif node['type'].eql?('array') && node.dig('items', 'type').is_a?(String)
87
+ "#{JSON_TYPE_TO_DOC.fetch(node['type'], node['type'])}[#{JSON_TYPE_TO_DOC.fetch(node.dig('items', 'type'), node.dig('items', 'type'))}]"
88
+ else
89
+ JSON_TYPE_TO_DOC.fetch(node['type'], node['type'])
90
+ end
55
91
  item = {
56
- name: code.call(property_full_name),
57
- type: code.call(node['type']),
58
- description: []
92
+ 'name' => code.call(property_full_name),
93
+ 'type' => code.call(item_type),
94
+ 'description' => []
59
95
  }
60
96
  # Render Markdown formatting and split lines
61
- item[:description] =
97
+ item['description'] =
62
98
  node['description']
63
- .gsub(Markdown::FORMATS){@formatter.markdown_text(Regexp.last_match)}
99
+ .gsub(Markdown::FORMATS) { @formatter.markdown_text(Regexp.last_match) }
64
100
  .split("\n") if node.key?('description')
65
- item[:description].unshift("DEPRECATED: #{node['x-deprecation']}") if node.key?('x-deprecation')
101
+ item['description'].unshift("DEPRECATED: #{node['x-deprecation']}") if node.key?('x-deprecation')
66
102
  # Add flags for supported agents in doc
67
103
  agents = []
68
104
  Agent::Factory::ALL.each_key do |sym|
69
105
  agents.push(sym) if node['x-agents'].nil? || node['x-agents'].include?(sym.to_s)
70
106
  end
71
- Aspera.assert(agents.include?(:direct)){"#{name}: x-cli-option requires agent direct (or nil)"} if node['x-cli-option']
107
+ Aspera.assert(agents.include?(:direct)) { "#{property_full_name}: x-cli-option requires agent direct (or nil)" } if node['x-cli-option']
72
108
  if @agent_columns
73
109
  Agent::Factory::ALL.each do |sym, names|
74
- item[names[:short]] = @formatter.tick(agents.include?(sym))
110
+ item[names[:short].to_s] = @formatter.tick(agents.include?(sym))
75
111
  end
76
112
  else
77
- item[:description].push("(#{agents.map{ |i| Agent::Factory::ALL[i][:short].to_s.upcase}.sort.join(', ')})") unless agents.length.eql?(Agent::Factory::ALL.length)
113
+ item['description'].push("(#{agents.map { |i| Agent::Factory::ALL[i][:short].to_s.upcase }.sort.join(', ')})") unless agents.length.eql?(Agent::Factory::ALL.length)
78
114
  end
79
115
  # Only keep lines that are usable in supported agents
80
- next false if agents.empty?
81
- item[:description].push("Allowed values: #{node['enum'].map{ |v| @formatter.markdown_text("`#{v}`")}.join(', ')}.") if node.key?('enum')
82
- item[:description].push("Default: #{code.call(node['default'])}.") if node.key?('default')
116
+ next if agents.empty?
117
+ item['description'].push("Allowed values: #{node['enum'].map { |v| @formatter.markdown_text("`#{v}`") }.join(', ')}.") if node.key?('enum')
118
+ item['description'].push("Default: #{@formatter.markdown_text("`#{node['default']}`")}.") if node.key?('default')
119
+ item['description'].push("Example: #{@formatter.markdown_text("`#{node['example']}`")}.") if node.key?('example')
83
120
  if @include_option
84
121
  envvar_prefix = ''
85
122
  cli_option =
@@ -87,18 +124,18 @@ module Aspera
87
124
  envvar_prefix = 'env:'
88
125
  node['x-cli-envvar']
89
126
  elsif node['x-cli-switch']
90
- node['x-cli-option']
127
+ false_part = node.key?('x-cli-false') ? " / #{node['x-cli-false']}" : ''
128
+ "#{node['x-cli-option']}#{false_part}"
91
129
  elsif node['x-cli-option']
92
130
  arg_type = node.key?('enum') ? '{enum}' : "{#{[node['type']].flatten.join('|')}}"
93
- # conversion_tag = node['x-cli-convert']
94
131
  conversion_tag = node.key?('x-cli-convert') ? 'conversion' : nil
95
132
  sep = node['x-cli-option'].start_with?('--') ? '=' : ' '
96
133
  "#{node['x-cli-option']}#{sep}#{"(#{conversion_tag})" if conversion_tag}#{arg_type}"
97
134
  end
98
135
  short = node.key?('x-cli-short') ? "(#{node['x-cli-short']})" : nil
99
- item[:description].push("(#{'special:' if node['x-cli-special']}#{envvar_prefix}#{@formatter.markdown_text("`#{cli_option}`")})#{short}") if cli_option
136
+ item['description'].push("(#{'special:' if node['x-cli-special']}#{envvar_prefix}#{@formatter.markdown_text("`#{cli_option}`")})#{short}") if cli_option
100
137
  end
101
- @rows.push(@formatter.check_row(item))
138
+ @sections.last[:rows].push(@formatter.check_row(item))
102
139
  end
103
140
  self
104
141
  end
@@ -7,11 +7,34 @@ module Aspera
7
7
  class Reader
8
8
  attr_reader :current
9
9
 
10
+ class << self
11
+ # Build a synthetic Reader from an OAS `parameters` array (entries with `in: query`).
12
+ # Produces a JSON Schema object whose `properties` map each query param name to its schema,
13
+ # with the OAS-level `description` and `required` merged in.
14
+ # @param params [Array<Hash>] raw OAS parameter objects (may contain path/header params too)
15
+ # @return [Reader]
16
+ def from_query_params(params)
17
+ properties = {}
18
+ required_names = []
19
+ params.each do |param|
20
+ next unless param['in'] == 'query'
21
+ name = param['name']
22
+ prop = (param['schema'] || {}).dup
23
+ prop['description'] = param['description'] if param['description'] && !prop.key?('description')
24
+ properties[name] = prop
25
+ required_names << name if param['required']
26
+ end
27
+ synthetic = {'type' => 'object', 'properties' => properties}
28
+ synthetic['required'] = required_names unless required_names.empty?
29
+ new(synthetic)
30
+ end
31
+ end
32
+
10
33
  # Shortcut to access current value at path
11
- # @param x [String] path element
34
+ # @param key [String] path element
12
35
  # @return [Hash, Array, String, Integer] current value at path
13
- def [](x)
14
- @current[x]
36
+ def [](key)
37
+ @current[key]
15
38
  end
16
39
 
17
40
  # Find sub path relative to current
@@ -19,18 +42,24 @@ module Aspera
19
42
  def dig(*path)
20
43
  current = @current
21
44
  path.each do |p|
22
- Aspera.assert(current.key?(p)){"schema: #{p} in #{path}"}
45
+ Aspera.assert(current.key?(p)) { "schema: #{p} in #{path}" }
23
46
  current = current[p]
24
- Aspera.assert_type(current, Hash){'schema'}
47
+ Aspera.assert_type(current, Hash) { 'schema' }
25
48
  if current.key?('$ref')
26
49
  ref = current['$ref']
27
- Aspera.assert(ref.start_with?('#/')){"schema $ref must start with '#/': #{ref}"}
50
+ Aspera.assert(ref.start_with?('#/')) { "schema $ref must start with '#/': #{ref}" }
28
51
  current = @root.dig(*ref[2..].split('/'))
29
52
  end
30
53
  end
31
54
  Reader.new(@root, current)
32
55
  end
33
56
 
57
+ # Resolve a $ref string to a Reader
58
+ def resolve_ref(ref)
59
+ Aspera.assert(ref.start_with?('#/')) { "schema $ref must start with '#/': #{ref}" }
60
+ Reader.new(@root, @root.dig(*ref[2..].split('/')))
61
+ end
62
+
34
63
  # Read schema from file or from cache
35
64
  # @param root [Hash] root schema
36
65
  # @param current [Hash, nil] current position in
@@ -40,15 +69,44 @@ module Aspera
40
69
  @current = current || root
41
70
  end
42
71
 
43
- # Recursively traverse schema properties with a block
44
- # Handles nested objects and arrays automatically
45
- # @param prefix [String] Prefix for property names (e.g., 'parent.child.')
72
+ # Recursively traverse schema properties with a block.
73
+ # If the current node has `oneOf`, each variant is traversed in turn and
74
+ # `on_variant` is called (if given) before each variant's properties.
75
+ # @param prefix [String] Prefix for property names (e.g., 'parent.child.')
76
+ # @param on_variant [Proc, nil] Called with the variant Reader before its properties
46
77
  # @yield [property_schema, name, full_name] Yields property info to block
47
- # @yieldparam property_schema [Reader] Schema reader for this property (use .current to get node hash)
78
+ # @yieldparam property_schema [Reader] Schema reader for this property
48
79
  # @yieldparam name [String] Property name
49
80
  # @yieldparam full_name [String] Full property name with prefix
50
81
  # @return [nil]
51
- def each_property(prefix = '', &block)
82
+ def each_property(prefix = '', on_variant: nil, &block)
83
+ if @current.key?('oneOf')
84
+ # Build reverse map: $ref -> discriminant value, from discriminator.mapping if present
85
+ discriminant_by_ref = {}
86
+ if @current.dig('discriminator', 'mapping').is_a?(Hash)
87
+ @current['discriminator']['mapping'].each do |value, ref|
88
+ discriminant_by_ref[ref] = value
89
+ end
90
+ end
91
+ discriminant_property = @current.dig('discriminator', 'propertyName')
92
+ @current['oneOf'].each do |variant_node|
93
+ ref = variant_node['$ref']
94
+ variant_reader = ref ? resolve_ref(ref) : Reader.new(@root, variant_node)
95
+ discriminant_value = ref ? discriminant_by_ref[ref] : nil
96
+ on_variant&.call(variant_reader, discriminant_property, discriminant_value)
97
+ variant_reader.each_property(prefix, on_variant: on_variant, &block)
98
+ end
99
+ return
100
+ end
101
+ if @current.key?('allOf')
102
+ # Merge all branches: each branch contributes its properties (no variants)
103
+ @current['allOf'].each do |branch_node|
104
+ ref = branch_node['$ref']
105
+ branch_reader = ref ? resolve_ref(ref) : Reader.new(@root, branch_node)
106
+ branch_reader.each_property(prefix, on_variant: on_variant, &block)
107
+ end
108
+ return
109
+ end
52
110
  properties = dig('properties')
53
111
  properties.current.each_key do |name|
54
112
  property_full_name = "#{prefix}#{name}"
@@ -61,13 +119,78 @@ module Aspera
61
119
  # Recursively process nested structures
62
120
  case node['type']
63
121
  when 'object'
64
- property_schema.each_property("#{property_full_name}.", &block) if node['properties']
122
+ property_schema.each_property("#{property_full_name}.", on_variant: on_variant, &block) if node['properties']
65
123
  when 'array'
66
124
  if node['items']
67
125
  array_item_schema = property_schema.dig('items')
68
- array_item_schema.each_property("#{property_full_name}[].", &block) if array_item_schema.current['properties']
126
+ array_item_schema.each_property("#{property_full_name}[].", on_variant: on_variant, &block) if array_item_schema.current['properties']
127
+ end
128
+ end
129
+ # allOf without explicit type: object — recurse to merge all branches
130
+ property_schema.each_property("#{property_full_name}.", on_variant: on_variant, &block) if node['allOf']
131
+ end
132
+ end
133
+
134
+ # Convert this schema to a flat array of field descriptors.
135
+ # Returns raw semantic fields with no ANSI or formatting, suitable for
136
+ # JSON/YAML output or MCP consumption. An AI or script can use the result
137
+ # directly to build a valid payload.
138
+ #
139
+ # Each Hash entry contains:
140
+ # name [String] dot/bracket-path of the field (e.g. "recipients[].name")
141
+ # type [String] JSON type string (e.g. "string", "boolean", "Array[object]")
142
+ # required [Boolean] true when the field is in its immediate parent's required list
143
+ # description [String] human description (may contain Markdown **bold** / `code`)
144
+ # default [Object] (optional) default value as native Ruby type
145
+ # enum [Array] (optional) list of allowed string values
146
+ #
147
+ # @return [Array<Hash>]
148
+ def to_rows
149
+ rows = []
150
+ collect_rows(rows, self, '')
151
+ rows
152
+ end
153
+
154
+ private
155
+
156
+ # Recursively collect rows from a schema node, passing each property's own
157
+ # parent `required` array so that only direct-parent membership is checked.
158
+ def collect_rows(rows, reader, prefix)
159
+ return unless reader.current.key?('properties')
160
+ parent_required = Set.new(Array(reader.current['required']))
161
+ props_reader = reader.dig('properties')
162
+ props_reader.current.each_key do |name|
163
+ prop_reader = props_reader.dig(name)
164
+ node = prop_reader.current
165
+ full_name = "#{prefix}#{name}"
166
+ type_val =
167
+ if node['type'].is_a?(Array)
168
+ node['type'].join(', ')
169
+ elsif node['type'].eql?('array') && node.dig('items', 'type').is_a?(String)
170
+ "Array[#{node.dig('items', 'type')}]"
171
+ else
172
+ node['type'].to_s
173
+ end
174
+ row = {
175
+ 'name' => full_name,
176
+ 'type' => type_val,
177
+ 'required' => parent_required.include?(name),
178
+ 'description' => node['description'].to_s
179
+ }
180
+ row['default'] = node['default'] if node.key?('default')
181
+ row['enum'] = node['enum'] if node.key?('enum')
182
+ rows << row
183
+ # Recurse into nested object or array-of-objects
184
+ case node['type']
185
+ when 'object'
186
+ collect_rows(rows, prop_reader, "#{full_name}.") if node['properties']
187
+ when 'array'
188
+ if node['items']
189
+ item_reader = prop_reader.dig('items')
190
+ collect_rows(rows, item_reader, "#{full_name}[].") if item_reader.current['properties']
69
191
  end
70
192
  end
193
+ collect_rows(rows, prop_reader, "#{full_name}.") if node['allOf']
71
194
  end
72
195
  end
73
196
  end
@@ -18,10 +18,32 @@ module Aspera
18
18
  LOCATIONS.key?(sym)
19
19
  end
20
20
 
21
- # Get path to request body, no check if it exists
22
- # @return [String] path to request body
21
+ # Split a component string into registry key and optional path prefix.
22
+ # Syntax: 'key' or 'key+/prefix' (e.g. 'shares+/api/v1')
23
+ # @param component [String] registry key with optional '+/prefix'
24
+ # @return [Array(String, String)] [key, prefix] where prefix may be ''
25
+ def split_component(component)
26
+ key, prefix = component.split('+', 2)
27
+ [key, prefix || '']
28
+ end
29
+
30
+ # Get path to request body schema, no check if it exists
31
+ # @param component [String] registry key, optionally with path prefix (e.g. 'shares+/api/v1')
32
+ # @param endpoint [String] endpoint path without leading slash (e.g. 'data/shares.post')
33
+ # @return [String] schema path usable in schema: keyword
23
34
  def req_body(component, endpoint)
24
- "#{component}:paths./#{endpoint}.requestBody.content.application/json.schema"
35
+ key, prefix = split_component(component)
36
+ "#{key}:paths.#{prefix}/#{endpoint}.requestBody.content.application/json.schema"
37
+ end
38
+
39
+ # Get path to query parameters for a GET endpoint
40
+ # @param component [String] registry key, optionally with path prefix (e.g. 'shares+/api/v1')
41
+ # @param endpoint [String] resource path without leading slash (e.g. 'data/shares')
42
+ # @param method [String] HTTP method (default: 'get')
43
+ # @return [String] schema path usable in query_schema: keyword
44
+ def query_params(component, endpoint, method: 'get')
45
+ key, prefix = split_component(component)
46
+ "#{key}:paths.#{prefix}/#{endpoint}.#{method}#{QUERY_PARAMS_SUFFIX}"
25
47
  end
26
48
  end
27
49
 
@@ -32,6 +54,9 @@ module Aspera
32
54
  opts: 'aspera/cli/options.schema.yaml',
33
55
  aoc: 'aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml',
34
56
  faspex: 'aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml',
57
+ faspio: 'aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml',
58
+ node: 'aspera/schema/IBM Aspera Node API-4.4.6.yaml',
59
+ shares: 'aspera/schema/IBM_Aspera_Shares.yaml',
35
60
  async_tables: 'aspera/schema/async_tables.yaml'
36
61
  }
37
62
 
@@ -41,26 +66,53 @@ module Aspera
41
66
  SYNC_ARGS = 'args'
42
67
  AOC = 'aoc'
43
68
  FASPEX = 'faspex'
69
+ FASPIO = 'faspio'
70
+ NODE = 'node'
71
+ SHARES = 'shares+/api/v1'
44
72
  ASYNC_TABLES = 'async_tables'
45
- TRANSFER_INFO = "#{OPTIONS}:components.schemas.TransferInfo"
73
+ LOG_OPTIONS = "#{OPTIONS}:components.schemas.LogOptions"
74
+ DIRECT_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.DirectAgentOptions"
75
+ NODE_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.NodeAgentOptions"
76
+ HTTPGW_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.HttpgwAgentOptions"
77
+ TRANSFERD_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.TransferdAgentOptions"
78
+ TRANSFER_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.TransferAgentOptions"
79
+ SMTP_OPTIONS = "#{OPTIONS}:components.schemas.SmtpOptions"
80
+ HTTP_OPTIONS = "#{OPTIONS}:components.schemas.HttpOptions"
81
+ VAULT_OPTIONS = "#{OPTIONS}:components.schemas.VaultOptions"
82
+ VAULT_SECRET = "#{OPTIONS}:components.schemas.VaultSecret"
83
+ IMAGE_OPTIONS = "#{OPTIONS}:components.schemas.ImageOptions"
84
+ OUT_OPTIONS = "#{OPTIONS}:components.schemas.OutOptions"
85
+ PACKAGE_FOLDER_OPTIONS = "#{OPTIONS}:components.schemas.PackageFolderOptions"
46
86
 
47
87
  REQ_BODY = '.requestBody.content.application/json.schema'
88
+ # Suffix appended to a dotted path to signal query-param extraction in reader()
89
+ QUERY_PARAMS_SUFFIX = '.parameters'
48
90
 
49
91
  def initialize
50
92
  @cache = {}
51
93
  @main_folder = File.expand_path('../..', __dir__)
52
94
  end
53
95
 
54
- # Read schema from file or from cache
55
- # @param name_path [String] one of the keys in LOCATIONS, with optional :<path> suffix
56
- # @return [Reader] schema
96
+ # Read schema from file or from cache.
97
+ # When name_path ends with QUERY_PARAMS_SUFFIX, the OAS `parameters` array at that path
98
+ # is synthesised into an object schema via Reader.from_query_params instead of navigating
99
+ # into the tree.
100
+ # @param name_path [String] registry key with optional colon-separated dotted path suffix,
101
+ # e.g. "faspex:paths./packages.get.parameters" or "faspex:paths./packages.post.requestBody..."
102
+ # @return [Reader] schema reader
57
103
  def reader(name_path)
58
104
  name, path = name_path.split(':', 2)
59
105
  sym = name.to_sym
60
- Aspera.assert(Registry.known?(sym)){"schema: #{sym}"}
106
+ Aspera.assert(Registry.known?(sym)) { "schema: #{sym}" }
61
107
  spec_file = File.join(@main_folder, LOCATIONS[sym])
62
108
  @cache[sym] = Yaml.safe_load(File.read(spec_file)) if spec_file.end_with?('.yaml') && !@cache.key?(sym)
63
109
  @cache[sym] = JSON.parse(File.read(spec_file)) if spec_file.end_with?('.json') && !@cache.key?(sym)
110
+ # Query-params path: strip the suffix, navigate to the operation node, extract parameters
111
+ if path&.end_with?(QUERY_PARAMS_SUFFIX)
112
+ parent_path = path.delete_suffix(QUERY_PARAMS_SUFFIX)
113
+ node = @cache[sym].dig(*parent_path.split('.'))
114
+ return Reader.from_query_params(node&.fetch('parameters', []) || [])
115
+ end
64
116
  reader = Reader.new(@cache[sym])
65
117
  return reader unless path
66
118
  reader.dig(*path.split('.'))
@@ -45,41 +45,46 @@ module Aspera
45
45
  private_constant :HIDDEN_PASSWORD, :ASCP_ENV_SECRETS, :KEY_SECRETS, :HTTP_SECRETS, :ALL_SECRETS, :KEY_FALSE_POSITIVES, :REGEX_LOG_REPLACES
46
46
  attr_accessor :log_secrets
47
47
 
48
- # @return new log formatter that hides secrets
48
+ # @return [Proc] new log formatter that hides secrets
49
49
  def log_formatter(original_formatter)
50
50
  original_formatter ||= Logger::Formatter.new
51
51
  # NOTE: that @log_secrets may be set AFTER this init is done, so it's done at runtime
52
52
  return lambda do |severity, date_time, program_name, msg|
53
53
  if msg.is_a?(String) && !@log_secrets
54
54
  REGEX_LOG_REPLACES.each do |reg_ex|
55
- msg = msg.gsub(reg_ex){"#{Regexp.last_match(:begin)}#{HIDDEN_PASSWORD}#{Regexp.last_match(:end)}"}
55
+ msg = msg.gsub(reg_ex) { "#{Regexp.last_match(:begin)}#{HIDDEN_PASSWORD}#{Regexp.last_match(:end)}" }
56
56
  end
57
57
  end
58
58
  original_formatter.call(severity, date_time, program_name, msg)
59
59
  end
60
60
  end
61
61
 
62
+ # Replace private key content in a string with the hidden password placeholder
63
+ # @param value [String] Input string possibly containing a private key
64
+ # @return [String] String with private key replaced by placeholder
62
65
  def hide_secrets_in_string(value)
63
- return value.gsub(REGEX_LOG_REPLACES.first){"#{Regexp.last_match(:begin)}#{HIDDEN_PASSWORD}#{Regexp.last_match(:end)}"}
66
+ return value.gsub(REGEX_LOG_REPLACES.first) { "#{Regexp.last_match(:begin)}#{HIDDEN_PASSWORD}#{Regexp.last_match(:end)}" }
64
67
  end
65
68
 
66
- # @return true if the key denotes a secret
69
+ # @param keyword [String, Symbol] Key name to check
70
+ # @param value [String] Associated value (must be a String to be a secret)
71
+ # @return [Boolean] true if the key denotes a secret
67
72
  def secret?(keyword, value)
68
73
  keyword = keyword.to_s if keyword.is_a?(Symbol)
69
74
  # only Strings can be secrets, not booleans, or hash, arrays
70
75
  return false unless keyword.is_a?(String) && value.is_a?(String)
71
76
  # those are not secrets
72
- return false if KEY_FALSE_POSITIVES.any?{ |f| f.match?(keyword)}
77
+ return false if KEY_FALSE_POSITIVES.any? { |f| f.match?(keyword) }
73
78
  return true if ADDITIONAL_KEYS_TO_HIDE.include?(keyword)
74
79
  # check if keyword (name) contains an element that designate it as a secret
75
- ALL_SECRETS.any?{ |kw| keyword.include?(kw)}
80
+ ALL_SECRETS.any? { |kw| keyword.include?(kw) }
76
81
  end
77
82
 
78
83
  # Hides recursively secrets in Hash or Array of Hash
79
84
  def deep_remove_secret(obj)
80
85
  case obj
81
86
  when Array
82
- obj.each{ |i| deep_remove_secret(i)}
87
+ obj.each { |i| deep_remove_secret(i) }
83
88
  when Hash
84
89
  obj.each do |k, v|
85
90
  if secret?(k, v)