aspera-cli 4.27.0 → 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 (140) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +29 -0
  4. data/CONTRIBUTING.md +1 -7
  5. data/TODO.md +50 -0
  6. data/docs/README.md +708 -488
  7. data/lib/aspera/agent/base.rb +10 -2
  8. data/lib/aspera/agent/connect.rb +7 -7
  9. data/lib/aspera/agent/desktop.rb +3 -3
  10. data/lib/aspera/agent/direct.rb +48 -19
  11. data/lib/aspera/agent/factory.rb +3 -3
  12. data/lib/aspera/agent/httpgw.rb +55 -14
  13. data/lib/aspera/agent/node.rb +2 -2
  14. data/lib/aspera/agent/transferd.rb +12 -12
  15. data/lib/aspera/api/aoc.rb +28 -28
  16. data/lib/aspera/api/cos_node.rb +10 -9
  17. data/lib/aspera/api/faspex.rb +2 -2
  18. data/lib/aspera/api/httpgw.rb +20 -20
  19. data/lib/aspera/api/node.rb +31 -53
  20. data/lib/aspera/ascmd.rb +13 -13
  21. data/lib/aspera/ascp/installation.rb +43 -36
  22. data/lib/aspera/ascp/management.rb +6 -6
  23. data/lib/aspera/assert.rb +12 -13
  24. data/lib/aspera/cli/ascp_actions.rb +7 -7
  25. data/lib/aspera/cli/async_transfer_store.rb +49 -5
  26. data/lib/aspera/cli/bootstrapper.rb +9 -9
  27. data/lib/aspera/cli/command_registry.rb +1 -1
  28. data/lib/aspera/cli/command_spec.rb +6 -3
  29. data/lib/aspera/cli/context.rb +3 -3
  30. data/lib/aspera/cli/extended_value.rb +26 -26
  31. data/lib/aspera/cli/formatter.rb +76 -54
  32. data/lib/aspera/cli/gem_checker.rb +1 -1
  33. data/lib/aspera/cli/hints.rb +2 -2
  34. data/lib/aspera/cli/http.rb +22 -29
  35. data/lib/aspera/cli/mailer.rb +7 -7
  36. data/lib/aspera/cli/mcp_tool.rb +31 -4
  37. data/lib/aspera/cli/option_declarator.rb +77 -0
  38. data/lib/aspera/cli/options.schema.yaml +273 -3
  39. data/lib/aspera/cli/parser.rb +539 -280
  40. data/lib/aspera/cli/plugins/alee.rb +1 -1
  41. data/lib/aspera/cli/plugins/aoc.rb +297 -313
  42. data/lib/aspera/cli/plugins/ats.rb +25 -21
  43. data/lib/aspera/cli/plugins/base.rb +357 -186
  44. data/lib/aspera/cli/plugins/basic_auth.rb +0 -12
  45. data/lib/aspera/cli/plugins/config.rb +76 -73
  46. data/lib/aspera/cli/plugins/console.rb +8 -8
  47. data/lib/aspera/cli/plugins/cos.rb +4 -3
  48. data/lib/aspera/cli/plugins/factory.rb +2 -2
  49. data/lib/aspera/cli/plugins/faspex5.rb +259 -248
  50. data/lib/aspera/cli/plugins/faspio.rb +7 -2
  51. data/lib/aspera/cli/plugins/httpgw.rb +1 -1
  52. data/lib/aspera/cli/plugins/mcp.rb +11 -11
  53. data/lib/aspera/cli/plugins/node.rb +227 -283
  54. data/lib/aspera/cli/plugins/orchestrator.rb +23 -23
  55. data/lib/aspera/cli/plugins/preview.rb +61 -64
  56. data/lib/aspera/cli/plugins/server.rb +36 -31
  57. data/lib/aspera/cli/plugins/shares.rb +174 -135
  58. data/lib/aspera/cli/preset_actions.rb +39 -19
  59. data/lib/aspera/cli/preset_manager.rb +74 -32
  60. data/lib/aspera/cli/result.rb +18 -16
  61. data/lib/aspera/cli/runner.rb +75 -39
  62. data/lib/aspera/cli/secret_finder.rb +1 -1
  63. data/lib/aspera/cli/sync_actions.rb +36 -35
  64. data/lib/aspera/cli/terminal_formatter.rb +2 -2
  65. data/lib/aspera/cli/transfer_actions.rb +17 -10
  66. data/lib/aspera/cli/transfer_agent.rb +44 -44
  67. data/lib/aspera/cli/transfer_progress.rb +7 -7
  68. data/lib/aspera/cli/vault_manager.rb +34 -12
  69. data/lib/aspera/cli/version.rb +1 -1
  70. data/lib/aspera/cli/wizard.rb +17 -20
  71. data/lib/aspera/colors.rb +3 -3
  72. data/lib/aspera/command_line_builder.rb +16 -16
  73. data/lib/aspera/command_line_converter.rb +1 -1
  74. data/lib/aspera/coverage.rb +1 -2
  75. data/lib/aspera/data_repository.rb +1 -1
  76. data/lib/aspera/dot_container.rb +8 -8
  77. data/lib/aspera/environment.rb +12 -12
  78. data/lib/aspera/faspex_gw.rb +4 -4
  79. data/lib/aspera/faspex_postproc.rb +14 -9
  80. data/lib/aspera/graphql.rb +4 -2
  81. data/lib/aspera/hash_ext.rb +2 -2
  82. data/lib/aspera/json_rpc/client.rb +1 -1
  83. data/lib/aspera/keychain/base.rb +10 -3
  84. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  85. data/lib/aspera/keychain/factory.rb +17 -5
  86. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  87. data/lib/aspera/keychain/macos_security.rb +23 -25
  88. data/lib/aspera/keychain/one_password_api.rb +86 -0
  89. data/lib/aspera/keychain/one_password_base.rb +34 -0
  90. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  91. data/lib/aspera/link_header.rb +2 -2
  92. data/lib/aspera/log.rb +13 -13
  93. data/lib/aspera/markdown.rb +5 -5
  94. data/lib/aspera/nagios.rb +8 -8
  95. data/lib/aspera/node_simulator.rb +33 -27
  96. data/lib/aspera/oauth/base.rb +4 -5
  97. data/lib/aspera/oauth/boot.rb +3 -3
  98. data/lib/aspera/oauth/factory.rb +5 -5
  99. data/lib/aspera/oauth/jwt.rb +5 -7
  100. data/lib/aspera/oauth/web.rb +4 -4
  101. data/lib/aspera/persistency_action_once.rb +4 -4
  102. data/lib/aspera/persistency_folder.rb +8 -8
  103. data/lib/aspera/preview/file_types.rb +4 -4
  104. data/lib/aspera/preview/generator.rb +49 -46
  105. data/lib/aspera/preview/options.rb +3 -2
  106. data/lib/aspera/preview/terminal.rb +5 -5
  107. data/lib/aspera/preview/utils.rb +65 -18
  108. data/lib/aspera/products/connect.rb +2 -2
  109. data/lib/aspera/products/desktop.rb +1 -1
  110. data/lib/aspera/products/other.rb +2 -2
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -10
  113. data/lib/aspera/rest.rb +149 -115
  114. data/lib/aspera/rest_error_analyzer.rb +3 -3
  115. data/lib/aspera/rest_list.rb +10 -9
  116. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  117. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  118. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  119. data/lib/aspera/schema/documentation.rb +17 -11
  120. data/lib/aspera/schema/reader.rb +78 -4
  121. data/lib/aspera/schema/registry.rb +26 -7
  122. data/lib/aspera/secret_hider.rb +5 -5
  123. data/lib/aspera/ssh.rb +64 -31
  124. data/lib/aspera/ssl.rb +3 -3
  125. data/lib/aspera/sync/database.rb +2 -2
  126. data/lib/aspera/sync/operations.rb +13 -13
  127. data/lib/aspera/temp_file_manager.rb +24 -4
  128. data/lib/aspera/transfer/faux_file.rb +2 -2
  129. data/lib/aspera/transfer/parameters.rb +15 -15
  130. data/lib/aspera/transfer/resumer.rb +9 -9
  131. data/lib/aspera/transfer/spec.rb +1 -1
  132. data/lib/aspera/uri_reader.rb +52 -15
  133. data/lib/aspera/web_auth.rb +6 -5
  134. data/lib/aspera/web_server_simple.rb +9 -9
  135. data/lib/aspera/yaml.rb +4 -4
  136. data.tar.gz.sig +0 -0
  137. metadata +10 -4
  138. metadata.gz.sig +0 -0
  139. data/lib/aspera/cli/plugins/faspex.rb +0 -576
  140. data/lib/aspera/transfer/uri.rb +0 -56
@@ -7,13 +7,18 @@ require 'pathname'
7
7
 
8
8
  module Aspera
9
9
  module Cli
10
- # Manage command line arguments to provide to Sync::Operations and Sync::Database
10
+ # Helpers shared by plugins that expose Aspera Sync (async) operations.
11
+ # Provides DSL registration, argument-to-sync-info conversion, and action methods.
11
12
  module SyncActions
12
13
  # Translate state id (int) to string
13
14
  STATE_STR = (['Nil'] +
14
- (1..18).map{ |i| "P(#{i})"} +
15
+ (1..18).map { |i| "P(#{i})" } +
15
16
  %w[Syncd Error Confl Pconf] +
16
- (23..24).map{ |i| "P(#{i})"}).freeze
17
+ (23..24).map { |i| "P(#{i})" }).freeze
18
+ # Positional arguments shared by sync transfer commands (push/pull/bidi) and sync admin commands.
19
+ PATH_AND_INFO_ARGS = [{name: :path, type: String}, {name: :sync_info, type: Hash, mandatory: false, default: {}}].freeze
20
+ # Names of the leaf commands registered under any `sync admin` node.
21
+ ADMIN_COMMANDS = %i[find status meta counters file_info overview query].freeze
17
22
  # When a plugin class includes SyncActions, register the :sql option
18
23
  # in that class's DSL registry so Base#initialize auto-declares it.
19
24
  class << self
@@ -24,17 +29,16 @@ module Aspera
24
29
  # DSL helper: register the 7 `sync admin` leaf commands under the given parent path.
25
30
  # Called at class-load time from any plugin that includes SyncActions.
26
31
  # @param base [Class] the plugin class (receiver of DSL methods)
27
- # @param admin_path [Symbol, Array<Symbol>] full parent path, e.g. %i[sync admin]
32
+ # @param admin_path [Symbol, Array<Symbol>] path relative to current scope, e.g. :admin
28
33
  def register_sync_admin_commands(base, admin_path)
29
- path_and_info_args = [{name: :path, type: String}, {name: :sync_info, type: Hash, mandatory: false, default: {}}]
30
34
  base.commands_under(admin_path) do
31
- base.command(:status, description: 'Show sync session status', arguments: path_and_info_args, action: :action_sync_admin_status)
32
35
  base.command(:find, description: 'Find sync database files', arguments: [{name: :path, type: String}], action: :action_sync_admin_find)
33
- base.command(:meta, description: 'Show sync session metadata', arguments: path_and_info_args, action: :action_sync_admin_meta)
34
- base.command(:counters, description: 'Show sync counters', arguments: path_and_info_args, action: :action_sync_admin_counters)
35
- base.command(:file_info, description: 'Show per-file sync state', arguments: path_and_info_args, action: :action_sync_admin_file_info)
36
- base.command(:overview, description: 'Show sync database overview', arguments: path_and_info_args, action: :action_sync_admin_overview)
37
- base.command(:query, description: 'Execute a raw SQL query', arguments: path_and_info_args, action: :action_sync_admin_query)
36
+ base.command(:status, description: 'Show sync session status', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_status)
37
+ base.command(:meta, description: 'Show sync session metadata', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_meta)
38
+ base.command(:counters, description: 'Show sync counters', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_counters)
39
+ base.command(:file_info, description: 'Show per-file sync state', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_file_info)
40
+ base.command(:overview, description: 'Show sync database overview', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_overview)
41
+ base.command(:query, description: 'Execute a raw SQL query', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_query)
38
42
  end
39
43
  end
40
44
  end
@@ -43,16 +47,11 @@ module Aspera
43
47
  # The resulting sync_info has `args` format only if it contains one of the `sessions` or `instance` keys.
44
48
  # It has the `conf` format (default) otherwise.
45
49
  # If the `conf` format is detected, then both `local` and `remote` keys are set.
46
- # @param path [String,NilClass] local/remote path; when nil, read from CLI
47
- # @param sync_info [Hash,NilClass] extra sync info; when nil, read from CLI
48
- # @param direction [Symbol,NilClass] one of DIRECTIONS, or nil for admin commands
50
+ # @param path [String] local/remote path
51
+ # @param sync_info [Hash] extra sync info (may be empty)
52
+ # @param direction [Symbol,NilClass] one of DIRECTIONS, or nil for admin commands
49
53
  # @return [Hash] sync info
50
- def async_info_from_args(path: nil, sync_info: nil, direction: nil)
51
- if path.nil?
52
- path = options.get_next_argument('path')
53
- sync_info = options.get_next_argument('sync info', mandatory: false, validation: Hash, default: {}, schema: Schema::Registry::SYNC_CONF)
54
- end
55
- sync_info ||= {}
54
+ def async_info_from_args(path:, sync_info: {}, direction: nil)
56
55
  # is the positional path a remote path ?
57
56
  path_is_remote = direction.eql?(:pull)
58
57
  if sync_info.key?('sessions') || sync_info.key?('instance')
@@ -61,33 +60,33 @@ module Aspera
61
60
  Aspera.assert(sync_info['sessions'].length == 1, 'Only one session is supported')
62
61
  session = sync_info['sessions'].first
63
62
  dir_key = path_is_remote ? 'remote_dir' : 'local_dir'
64
- Aspera.assert(!session.key?(dir_key)){"Parameter #{dir_key} shall not be in sync_info"}
63
+ Aspera.assert(!session.key?(dir_key)) { "Parameter #{dir_key} shall not be in sync_info" }
65
64
  session[dir_key] = path
66
65
  if direction
67
66
  dir_key = path_is_remote ? 'local_dir' : 'remote_dir'
68
- Aspera.assert(!session.key?(dir_key)){"Parameter #{dir_key} shall not be in sync_info"}
67
+ Aspera.assert(!session.key?(dir_key)) { "Parameter #{dir_key} shall not be in sync_info" }
69
68
  session[dir_key] = transfer.destination_folder(path_is_remote ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND)
70
- local_remote = %w[local remote].map{ |i| session["#{i}_dir"]}
69
+ local_remote = %w[local remote].map { |i| session["#{i}_dir"] }
71
70
  end
72
71
  else
73
72
  # `conf`
74
73
  session = sync_info
75
74
  dir_key = path_is_remote ? 'remote' : 'local'
76
75
  session[dir_key] ||= {}
77
- Aspera.assert(!session[dir_key].key?('path')){"Parameter #{dir_key}.path shall not be in sync_info"}
76
+ Aspera.assert(!session[dir_key].key?('path')) { "Parameter #{dir_key}.path shall not be in sync_info" }
78
77
  session[dir_key]['path'] = path
79
78
  if direction
80
79
  dir_key = path_is_remote ? 'local' : 'remote'
81
80
  session[dir_key] ||= {}
82
- Aspera.assert(!session[dir_key].key?('path')){"Parameter #{dir_key}.path shall not be in sync_info"}
81
+ Aspera.assert(!session[dir_key].key?('path')) { "Parameter #{dir_key}.path shall not be in sync_info" }
83
82
  session[dir_key]['path'] = transfer.destination_folder(path_is_remote ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND)
84
- local_remote = %w[local remote].map{ |i| session[i]['path']}
83
+ local_remote = %w[local remote].map { |i| session[i]['path'] }
85
84
  end
86
85
  # `conf` is quiet by default
87
86
  session['quiet'] = false if !session.key?('quiet') && Environment.terminal?
88
87
  end
89
88
  if direction
90
- Aspera.assert(!session.key?('direction'), type: BadArgument){'direction shall not be in sync_info'}
89
+ Aspera.assert(!session.key?('direction'), type: BadArgument) { 'direction shall not be in sync_info' }
91
90
  session['direction'] = direction.to_s
92
91
  # generate name if not provided by user
93
92
  if !session.key?('name')
@@ -105,16 +104,16 @@ module Aspera
105
104
  end
106
105
 
107
106
  # Provide database object from path + sync_info for admin ops
108
- # @param path [String,NilClass] when nil, read from CLI
109
- # @param sync_info [Hash,NilClass] when nil, read from CLI
110
- def db_from_args(path: nil, sync_info: nil)
107
+ # @param path [String] local path
108
+ # @param sync_info [Hash] extra sync info (may be empty)
109
+ def db_from_args(path:, sync_info: {})
111
110
  sync_info = async_info_from_args(path: path, sync_info: sync_info)
112
111
  session = sync_info.key?('sessions') ? sync_info['sessions'].first : sync_info
113
112
  # if name not provided, check in db folder if there is only one name
114
113
  if !session.key?('name')
115
114
  local_db_dir = Sync::Operations.local_db_folder(sync_info)
116
115
  dbs = Sync::Operations.list_db_files(local_db_dir)
117
- Aspera.assert(dbs.length == 1){"#{dbs.length} session found in #{local_db_dir}, please provide a name"}
116
+ Aspera.assert(dbs.length == 1) { "#{dbs.length} session found in #{local_db_dir}, please provide a name" }
118
117
  session['name'] = dbs.keys.first
119
118
  end
120
119
  Sync::Database.new(Sync::Operations.session_db_file(sync_info))
@@ -126,7 +125,7 @@ module Aspera
126
125
 
127
126
  def action_sync_admin_find(path:, **)
128
127
  dbs = Sync::Operations.list_db_files(path)
129
- Result::ObjectList.new(dbs.keys.map{ |n| {name: n, path: dbs[n]}})
128
+ Result::ObjectList.new(dbs.keys.map { |n| {name: n, path: dbs[n]} })
130
129
  end
131
130
 
132
131
  def action_sync_admin_meta(path:, sync_info: {}, **)
@@ -159,10 +158,12 @@ module Aspera
159
158
  end
160
159
 
161
160
  # Execute a sync transfer for a given direction.
162
- # @param direction [Symbol] one of Sync::Operations::DIRECTIONS (:push, :pull, :bidi)
161
+ # @param direction [Symbol] one of Sync::Operations::DIRECTIONS (:push, :pull, :bidi)
162
+ # @param path [String] local or remote path (depends on direction)
163
+ # @param sync_info [Hash] extra sync configuration (may be empty)
163
164
  # @param block [Proc, nil] block to generate transfer spec; receives (direction, local_dir, remote_dir)
164
- def run_sync_transfer(direction, &block)
165
- Sync::Operations.start(async_info_from_args(direction: direction), transfer.user_transfer_spec, &block)
165
+ def run_sync_transfer(direction, path:, sync_info: {}, &block)
166
+ Sync::Operations.start(async_info_from_args(path: path, sync_info: sync_info, direction: direction), transfer.user_transfer_spec, &block)
166
167
  Result::Success.new
167
168
  end
168
169
  end
@@ -29,13 +29,13 @@ module Aspera
29
29
  # Format special values with colors (dim for empty, reverse for others)
30
30
  def special_format(what)
31
31
  result = "<#{what}>"
32
- return %w[null empty].any?{ |s| what.include?(s)} ? result.dim : result.reverse_color
32
+ return %w[null empty].any? { |s| what.include?(s) } ? result.dim : result.reverse_color
33
33
  end
34
34
 
35
35
  # Prepare table row for terminal display (word wrap arrays)
36
36
  def check_row(row)
37
37
  row.each_key do |k|
38
- row[k] = row[k].map{ |i| WordWrap.ww(i.to_s, 120).chomp}.join("\n") if row[k].is_a?(Array)
38
+ row[k] = row[k].map { |i| WordWrap.ww(i.to_s, 120).chomp }.join("\n") if row[k].is_a?(Array)
39
39
  end
40
40
  end
41
41
 
@@ -24,7 +24,10 @@ module Aspera
24
24
  def action_transfer_status(job_id:, **)
25
25
  store = async_transfer_store
26
26
  entry = store.read(job_id)
27
- Aspera.assert(!entry.nil?, type: Cli::BadArgument){"Unknown job_id: #{job_id}"}
27
+ Aspera.assert(!entry.nil?, type: Cli::BadArgument) { "Unknown job_id: #{job_id}" }
28
+ # Inject the in-process agent reference (direct/httpgw) if still alive in this process.
29
+ ref = store.agent_ref(job_id)
30
+ entry['agent_params']['_agent_ref'] = ref if ref
28
31
  live = query_live_status(entry)
29
32
  if live
30
33
  entry.merge!(live)
@@ -46,7 +49,7 @@ module Aspera
46
49
  def action_transfer_cleanup(**)
47
50
  store = async_transfer_store
48
51
  deleted = store.list
49
- .select{ |e| TERMINAL_STATUSES.include?(e['status'])}
52
+ .select { |e| TERMINAL_STATUSES.include?(e['status']) }
50
53
  .map do |e|
51
54
  store.delete(e['job_id'])
52
55
  e['job_id']
@@ -57,25 +60,29 @@ module Aspera
57
60
  private
58
61
 
59
62
  # Lazy accessor - requires context.persistency (available in all Base sub-classes).
63
+ # Delegate to the TransferAgent's store so that the in-memory agent-ref
64
+ # registry is shared between the two components in the same process.
60
65
  def async_transfer_store
61
- @async_transfer_store ||= AsyncTransferStore.new(persistency)
66
+ context.transfer.async_store
62
67
  end
63
68
 
64
69
  # Delegate to the appropriate agent class method.
65
- # Returns nil for direct transfers (in-process only, not re-queryable across processes).
70
+ # All agent types are supported:
71
+ # - in-process agents (direct, httpgw): re-queryable while the same process lives
72
+ # (e.g. MCP mode); '_agent_ref' in agent_params points to the live instance.
73
+ # When the process has restarted, '_agent_ref' is nil and transfer_status returns
74
+ # {'status' => 'unknown'} — which is the correct and safe answer.
75
+ # - daemon agents (node, desktop, connect, transferd): re-query via their REST/IPC API.
66
76
  def query_live_status(entry)
67
- agent_type = entry['agent_type']
68
- transfer_id = entry['transfer_id']
77
+ agent_type = entry['agent_type']
78
+ transfer_id = entry['transfer_id']
69
79
  agent_params = entry['agent_params'] || {}
70
80
 
71
- # direct transfers live in Ruby threads - not persistable across process restarts
72
- return if agent_type.eql?('direct')
73
-
74
81
  require "aspera/agent/#{agent_type}"
75
82
  agent_class = Aspera::Agent.const_get(agent_type.capitalize)
76
83
  agent_class.transfer_status(transfer_id, agent_params)
77
84
  rescue StandardError => e
78
- Log.log.warn{"Could not re-query #{entry['agent_type']} agent for job #{entry['job_id']}: #{e}"}
85
+ Log.log.warn { "Could not re-query #{entry['agent_type']} agent for job #{entry['job_id']}: #{e}" }
79
86
  nil
80
87
  end
81
88
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'aspera/agent/factory'
4
4
  require 'aspera/schema/registry'
5
+ require 'aspera/cli/option_declarator'
5
6
  require 'aspera/transfer/result'
6
7
  require 'aspera/transfer/spec'
7
8
  require 'aspera/cli/info'
@@ -35,23 +36,19 @@ module Aspera
35
36
  :FILE_LIST_OPTIONS,
36
37
  :DEFAULT_TRANSFER_NOTIFY_TEMPLATE
37
38
 
38
- class << self
39
- # Declare all transfer CLI options (metadata only - no handler binding yet).
40
- # @param options [Aspera::Cli::Parser]
41
- def declare_options(options)
42
- options.declare(:ts, description: 'Override transfer spec values', schema: Schema::Registry::TRANSFER_SPEC)
43
- options.declare(:to_folder, description: 'Destination folder for transferred files')
44
- options.declare(:sources, description: "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS)
45
- options.declare(:src_type, description: 'Type of file list', allowed: %i[list pair], default: :list)
46
- options.declare(:transfer, description: 'Transfer agent type, or agent parameters with optional agent key', allowed: [Hash, String], schema: Schema::Registry::TRANSFER_AGENT_OPTIONS)
47
- options.declare(:transfer_info, description: 'Parameters for transfer agent', allowed: Hash, deprecation: 'use --transfer instead', schema: Schema::Registry::TRANSFER_AGENT_OPTIONS)
48
- end
49
- end
39
+ extend OptionDeclarator
40
+
41
+ option :ts, description: 'Override transfer spec values', schema: Schema::Registry::TRANSFER_SPEC
42
+ option :to_folder, description: 'Destination folder for transferred files'
43
+ option :sources, description: "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS
44
+ option :src_type, description: 'Type of file list', allowed: %i[list pair], default: :list
45
+ option :transfer, description: 'Transfer agent type, or agent parameters with optional agent key', allowed: [Hash, String], schema: Schema::Registry::TRANSFER_AGENT_OPTIONS
46
+ option :transfer_info, description: 'Parameters for transfer agent', deprecation: 'use --transfer instead', schema: Schema::Registry::TRANSFER_AGENT_OPTIONS
50
47
 
51
48
  # @param context [Context] Application context
52
49
  def initialize(context)
53
- Aspera.assert_type(context, Context){'context'}
54
- Aspera.assert_type(context.options, Parser){'context.options'}
50
+ Aspera.assert_type(context, Context) { 'context' }
51
+ Aspera.assert_type(context.options, Parser) { 'context.options' }
55
52
  @context = context
56
53
  # Command line can override transfer spec
57
54
  @user_transfer_spec = {
@@ -120,13 +117,13 @@ module Aspera
120
117
  when :node
121
118
  if !agent_options.key?(:url)
122
119
  param_set_name = @context.presets.plugin_default_name(:node)
123
- Aspera.assert(!param_set_name.nil?, type: Cli::BadArgument){"No default node configured. Please specify #{Options.option_name_to_line(:transfer)}.url or #{Options.option_name_to_line(:transfer)}"}
120
+ Aspera.assert(!param_set_name.nil?, type: Cli::BadArgument) { "No default node configured. Please specify #{Options.option_name_to_line(:transfer)}.url or #{Options.option_name_to_line(:transfer)}" }
124
121
  agent_options.merge!(@context.presets.by_name(param_set_name).symbolize_keys)
125
122
  end
126
123
  when :direct
127
124
  # by default do not display ascp native progress bar
128
125
  agent_options[:quiet] = true unless agent_options.key?(:quiet)
129
- agent_options[:check_ignore_cb] = ->(host, port){@context.http_config.ignore_cert?(host, port)}
126
+ agent_options[:check_ignore_cb] = ->(host, port) { @context.http_config.ignore_cert?(host, port) }
130
127
  # JRuby
131
128
  agent_options[:trusted_certs] = @context.http_config.trusted_cert_locations unless agent_options.key?(:trusted_certs)
132
129
  when :httpgw
@@ -137,7 +134,7 @@ module Aspera
137
134
  end
138
135
  # get agent instance
139
136
  self.agent_instance = Agent::Factory.instance.create(agent_type, agent_options)
140
- Log.log.debug{"transfer agent is a #{@agent.class}"}
137
+ Log.log.debug { "transfer agent is a #{@agent.class}" }
141
138
  return @agent
142
139
  end
143
140
 
@@ -168,7 +165,7 @@ module Aspera
168
165
 
169
166
  # @param httpgw_url_proc [Proc]
170
167
  def httpgw_url_cb=(httpgw_url_proc)
171
- Aspera.assert_type(httpgw_url_proc, Proc){'httpgw_url_cb'}
168
+ Aspera.assert_type(httpgw_url_proc, Proc) { 'httpgw_url_cb' }
172
169
  @httpgw_url_lambda = httpgw_url_proc
173
170
  end
174
171
 
@@ -180,10 +177,10 @@ module Aspera
180
177
  case source_type
181
178
  when :list
182
179
  # when providing a list, just specify source
183
- file_list.map{ |i| {'source' => i}}
180
+ file_list.map { |i| {'source' => i} }
184
181
  when :pair
185
- Aspera.assert(file_list.length.even?, type: Cli::BadArgument){"When using pair, provide an even number of paths: #{file_list.length}"}
186
- file_list.each_slice(2).map{ |s, d| {'source' => s, 'destination' => d}}
182
+ Aspera.assert(file_list.length.even?, type: Cli::BadArgument) { "When using pair, provide an even number of paths: #{file_list.length}" }
183
+ file_list.each_slice(2).map { |s, d| {'source' => s, 'destination' => d} }
187
184
  else Aspera.error_unexpected_value(source_type)
188
185
  end
189
186
  end
@@ -215,16 +212,16 @@ module Aspera
215
212
  special_case_direct_with_list =
216
213
  (@transfer_options['agent'] || :direct).to_sym.eql?(:direct) &&
217
214
  Transfer::Parameters.ascp_args_file_list?(@transfer_options['ascp_args'])
218
- Aspera.assert(!@transfer_paths.nil? || special_case_direct_with_list, type: Cli::BadArgument){'transfer spec on command line must have sources'}
215
+ Aspera.assert(!@transfer_paths.nil? || special_case_direct_with_list, type: Cli::BadArgument) { 'transfer spec on command line must have sources' }
219
216
  # can be nil
220
217
  @transfer_paths
221
218
  when Array
222
219
  Log.log.debug('getting file list as extended value')
223
- Aspera.assert_array_all(sources, String, type: Cli::BadArgument){'sources must be a Array of String'}
220
+ Aspera.assert_array_all(sources, String, type: Cli::BadArgument) { 'sources must be a Array of String' }
224
221
  list_to_paths(sources)
225
- else Aspera.error_unexpected_value(sources){'sources'}
222
+ else Aspera.error_unexpected_value(sources) { 'sources' }
226
223
  end
227
- Log.log.debug{"paths=#{@transfer_paths}"}
224
+ Log.dump(:paths, @transfer_paths)
228
225
  return @transfer_paths
229
226
  end
230
227
 
@@ -233,7 +230,7 @@ module Aspera
233
230
  # @param rest_token [Rest, nil] if oauth token regeneration supported
234
231
  def start(transfer_spec, rest_token: nil)
235
232
  # check parameters
236
- Aspera.assert_type(transfer_spec, Hash){'transfer_spec'}
233
+ Aspera.assert_type(transfer_spec, Hash) { 'transfer_spec' }
237
234
  raise "Wrong remote host: #{CP4I_REMOTE_HOST_LB}" if transfer_spec['remote_host'].eql?(CP4I_REMOTE_HOST_LB)
238
235
  # process :src option
239
236
  case transfer_spec['direction']
@@ -261,7 +258,7 @@ module Aspera
261
258
  # resolve pseudo-parameter: target_rate -> target_rate_kbps (overrides target_rate_kbps if both are present)
262
259
  transfer_spec['target_rate_kbps'] = Transfer::Spec.rate_string_to_kbps(transfer_spec.delete('target_rate')) if transfer_spec.key?('target_rate')
263
260
  # recursively remove values that are nil (user wants to delete)
264
- transfer_spec.deep_do{ |hash, key, value, _unused| hash.delete(key) if value.nil?}
261
+ transfer_spec.deep_do { |hash, key, value, _unused| hash.delete(key) if value.nil? }
265
262
  # if TS from app has content_protection (e.g. F5), that means content is protected: ask password if not provided
266
263
  transfer_spec['content_protection_password'] = @context.options.prompt_user_input('content protection password', sensitive: true) if transfer_spec['content_protection'].eql?('decrypt') && !transfer_spec.key?('content_protection_password')
267
264
  # create transfer agent
@@ -269,31 +266,36 @@ module Aspera
269
266
  # --- async mode ---
270
267
  if @transfer_options['asynchronous']
271
268
  agent_type = (@transfer_options['agent'] || 'direct').to_s
272
- raise Cli::BadArgument, 'asynchronous mode is not supported for agent httpgw' if agent_type.eql?('httpgw')
273
- # For direct agent: the transfer lives in this Ruby process (threads).
274
- # We return the job_id from the agent itself; no persistent store needed.
275
- if agent_type.eql?('direct')
276
- job_id = agent_instance.instance_variable_get(:@sessions).last[:job_id]
277
- Log.log.info{"Async direct transfer started: job_id=#{job_id}"}
278
- return Transfer::Result.async(job_id: job_id)
279
- end
280
- # For remote-daemon agents (desktop, node, connect, transferd): persist transfer_id
281
- # so the status can be re-queried in a later process invocation.
269
+ # In-process agents (direct, httpgw) expose last_job_id directly.
270
+ # Remote-daemon agents do not override last_job_id (returns nil): generate a UUID.
271
+ job_id = agent_instance.last_job_id || SecureRandom.uuid
272
+ # Base agent_params from transfer options (strip internal keys)
282
273
  agent_params = @transfer_options.except('agent', 'asynchronous')
274
+ # For daemon agents: capture any runtime-resolved connection detail
283
275
  case agent_type
284
276
  when 'desktop'
285
277
  agent_params['application_id'] = agent_instance.application_id
286
278
  when 'connect'
287
279
  agent_params['app_id'] = agent_instance.app_id
288
280
  when 'transferd'
289
- # store the resolved daemon endpoint (auto-port may have been assigned)
290
281
  agent_params['url'] = agent_instance.daemon_endpoint
291
282
  end
292
- job_id = SecureRandom.uuid
283
+ # Register an in-process agent reference for direct/httpgw agents so that
284
+ # config transfer status can re-query them while the same process is alive
285
+ # (e.g. MCP server mode). The ref is stored in AsyncTransferStore's memory map,
286
+ # never on disk.
287
+ async_store.register_agent_ref(job_id, agent_instance) if %w[direct httpgw].include?(agent_type)
288
+ # transfer_id: in-process agents use job_id; daemon agents expose @transfer_id
289
+ transfer_id =
290
+ if %w[direct httpgw].include?(agent_type)
291
+ job_id
292
+ else
293
+ agent_instance.instance_variable_get(:@transfer_id).to_s
294
+ end
293
295
  async_store.write(job_id, {
294
296
  'job_id' => job_id,
295
297
  'agent_type' => agent_type,
296
- 'transfer_id' => agent_instance.instance_variable_get(:@transfer_id).to_s,
298
+ 'transfer_id' => transfer_id,
297
299
  'agent_params' => agent_params,
298
300
  'status' => 'running',
299
301
  'bytes_transferred' => 0,
@@ -301,7 +303,7 @@ module Aspera
301
303
  'ended_at' => nil,
302
304
  'error' => nil
303
305
  })
304
- Log.log.info{"Async transfer started: job_id=#{job_id}"}
306
+ Log.log.info { "Async transfer started: job_id=#{job_id}, agent=#{agent_type}" }
305
307
  return Transfer::Result.async(job_id: job_id)
306
308
  end
307
309
  # --- synchronous mode (default) ---
@@ -315,9 +317,7 @@ module Aspera
315
317
  @agent.shutdown if @agent.respond_to?(:shutdown)
316
318
  end
317
319
 
318
- private
319
-
320
- # Lazy accessor for the async transfer store (only created when needed)
320
+ # Lazy accessor for the async transfer store (shared with TransferActions via context.transfer).
321
321
  def async_store
322
322
  @async_store ||= AsyncTransferStore.new(@context.persistency)
323
323
  end
@@ -29,12 +29,12 @@ module Aspera
29
29
  # @param type [Symbol] one of: sessions_init, session_start, session_size, transfer, session_end and end
30
30
  # @param info [Object, nil] optional specific additional info for the given event type
31
31
  def event(type, session_id: nil, info: nil)
32
- Log.log.trace1{"progress: #{type} #{session_id} #{info}"}
32
+ Log.log.trace1 { "progress: #{type} #{session_id} #{info}" }
33
33
  return if @completed
34
34
  if @progress_bar.nil?
35
35
  @progress_bar = ProgressBar.create(
36
36
  format: '%t %a %B %p%% %r Mbps %E',
37
- rate_scale: lambda{ |rate| rate / Environment::BYTES_PER_MEBIBIT},
37
+ rate_scale: lambda { |rate| rate / Environment::BYTES_PER_MEBIBIT },
38
38
  title: '',
39
39
  total: nil
40
40
  )
@@ -50,7 +50,7 @@ module Aspera
50
50
  when :session_start
51
51
  Aspera.assert_type(session_id, String)
52
52
  Aspera.assert(info.nil?, 'info must be nil for :session_start event')
53
- Aspera.assert(!@sessions[session_id]){"Session #{session_id} already started"}
53
+ Aspera.assert(!@sessions[session_id]) { "Session #{session_id} already started" }
54
54
  @sessions[session_id] = {
55
55
  job_size: 0, # Total size of transfer (pre-calc)
56
56
  current: 0,
@@ -83,19 +83,19 @@ module Aspera
83
83
  Aspera.assert(session_id.nil?, 'session_id must be nil for :end event')
84
84
  Aspera.assert(info.nil?, 'info must be nil for :end event')
85
85
  @progress_bar.finish
86
- else Aspera.error_unexpected_value(type){'event type'}
86
+ else Aspera.error_unexpected_value(type) { 'event type' }
87
87
  end
88
- new_title = @sessions.length < 2 ? @title.to_s : "[#{@sessions.count{ |_i, d| d[:running]}}] #{@title}"
88
+ new_title = @sessions.length < 2 ? @title.to_s : "[#{@sessions.count { |_i, d| d[:running] }}] #{@title}"
89
89
  @progress_bar&.title = new_title unless @progress_bar&.title.eql?(new_title)
90
90
  @progress_bar&.increment if !progress_provided && @progress_bar.progress.nil?
91
91
  rescue ProgressBar::InvalidProgressError => e
92
- Log.log.error{"Progress error: #{e}"}
92
+ Log.log.error { "Progress error: #{e}" }
93
93
  end
94
94
 
95
95
  private
96
96
 
97
97
  def total(key)
98
- @sessions.values.inject(0){ |m, s| m + s[key]}
98
+ @sessions.values.inject(0) { |m, s| m + s[key] }
99
99
  end
100
100
  end
101
101
  end
@@ -3,46 +3,68 @@
3
3
  require 'aspera/cli/error'
4
4
  require 'aspera/log'
5
5
  require 'aspera/assert'
6
+ require 'json'
6
7
 
7
8
  module Aspera
8
9
  module Cli
9
10
  # Mixin providing vault/keychain functionality to Plugin::Config.
10
11
  # Depends on `options` and `context.main_folder` being available in the including class.
11
12
  module VaultManager
12
- def action_vault_show(label:, **)
13
- Result::SingleObject.new(vault.get(label: label))
13
+ def action_vault_show(label:, id: nil, **)
14
+ v = vault_required
15
+ kwargs = id && v.method(:get).parameters.any? { |_t, n| n == :id } ? {id: id} : {}
16
+ Result::SingleObject.new(v.get(label: label, **kwargs))
14
17
  end
15
18
 
16
19
  def action_vault_create(info:, **)
17
- vault.set(info.symbolize_keys)
20
+ vault_required.set(info.symbolize_keys)
18
21
  Result::Status.new('Secret added')
19
22
  end
20
23
 
21
- def action_vault_delete(label:, **)
22
- vault.delete(label: label)
24
+ # Import secrets from a JSON array; skips entries missing :label
25
+ def action_vault_import(secrets:, **)
26
+ bulk_result(secrets, command: :import, id_result: 'label') do |entry|
27
+ vault_required.set(entry.symbolize_keys)
28
+ {'label' => entry['label'] || entry[:label]}
29
+ end
30
+ end
31
+
32
+ def action_vault_delete(label:, id: nil, **)
33
+ v = vault_required
34
+ kwargs = id && v.method(:delete).parameters.any? { |_t, n| n == :id } ? {id: id} : {}
35
+ v.delete(label: label, **kwargs)
23
36
  Result::Status.new("Secret deleted: #{label}")
24
37
  end
25
38
 
26
39
  def action_vault_password(new_password:, **)
27
- Aspera.assert(vault.respond_to?(:change_password), 'Vault does not support password change')
28
- vault.change_password(new_password)
40
+ Aspera.assert(vault_required.respond_to?(:change_password), 'Vault does not support password change')
41
+ vault_required.change_password(new_password)
29
42
  Result::Status.new('Vault password updated')
30
43
  end
31
44
 
45
+ # @return [Keychain::Base] vault instance, raises if not configured
46
+ def vault_required
47
+ Aspera.assert(!vault.nil?, type: Cli::BadArgument) { 'Missing mandatory option: vault' }
48
+ vault
49
+ end
50
+
32
51
  # @return [String] value from vault matching <name>.<param>
33
52
  def vault_value(name)
34
53
  m = name.split('.')
35
- Aspera.assert(m.length.eql?(2), type: BadArgument){'vault name shall match <name>.<param>'}
36
- info = vault.get(label: m[0])
54
+ Aspera.assert(m.length.eql?(2), type: BadArgument) { 'vault name shall match <name>.<param>' }
55
+ info = vault_required.get(label: m[0])
37
56
  value = info[m[1].to_sym]
38
57
  raise "no such entry value: #{m[1]}" if value.nil?
39
58
  return value
40
59
  end
41
60
 
42
- # @return [Keychain::Base] vault instance, lazily created from options
61
+ # @return [Keychain::Base, nil] vault instance, lazily created from options, or nil if not configured
43
62
  def vault
44
- return @vault_instance unless @vault_instance.nil?
45
- info = options.get_option(:vault, mandatory: true).symbolize_keys
63
+ return @vault_instance if @vault_instance_initialized
64
+ @vault_instance_initialized = true
65
+ info = options.get_option(:vault, mandatory: false)
66
+ return @vault_instance = nil if info.nil? || (info.is_a?(Hash) && info.empty?)
67
+ info = info.symbolize_keys
46
68
  info[:type] ||= 'file'
47
69
  require 'aspera/keychain/factory'
48
70
  @vault_instance = Keychain::Factory.create(
@@ -4,6 +4,6 @@ module Aspera
4
4
  module Cli
5
5
  # For beta add extension : .beta1
6
6
  # For dev version add extension : .pre
7
- VERSION = '4.27.0'
7
+ VERSION = '4.27.1'
8
8
  end
9
9
  end