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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/log'
4
+
5
+ module Aspera
6
+ module Cli
7
+ # Resolves the secret (password) for a given URL + username pair.
8
+ # Injected into Context as :secret_finder so that any component
9
+ # (plugins, Api::AoC, ...) can look up secrets without going through Plugins::Config.
10
+ class SecretFinder
11
+ # Special value for the :secret option that triggers a preset lookup
12
+ PRESET_MAGIC = 'PRESET'
13
+
14
+ # @param options [Parser] CLI options manager (provides :secret)
15
+ # @param presets [PresetManager] preset resolver (provides #lookup_preset)
16
+ def initialize(options, presets)
17
+ @options = options
18
+ @presets = presets
19
+ end
20
+
21
+ # Return the secret for the given URL + username.
22
+ # If the :secret option equals 'PRESET', the preset store is searched for a
23
+ # matching url/username entry and its 'password' field is returned.
24
+ # @param url [String]
25
+ # @param username [String]
26
+ # @return [String, nil]
27
+ def lookup(url:, username:)
28
+ secret = @options.get_option(:secret)
29
+ if secret.eql?(PRESET_MAGIC)
30
+ conf = @presets.lookup_preset(url: url, username: username)
31
+ if conf.is_a?(Hash)
32
+ Log.log.debug { "Found preset #{conf} with URL and username" }
33
+ secret = conf['password']
34
+ end
35
+ end
36
+ secret
37
+ end
38
+ end
39
+ end
40
+ end
@@ -9,6 +9,7 @@ module Aspera
9
9
  ALL = 'ALL'
10
10
  DEF = 'DEF'
11
11
  EOA = 'END'
12
+ LATEST = 'LATEST'
12
13
  end
13
14
  end
14
15
  end
@@ -7,28 +7,51 @@ 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
22
+ # When a plugin class includes SyncActions, register the :sql option
23
+ # in that class's DSL registry so Base#initialize auto-declares it.
17
24
  class << self
18
- def declare_options(options)
19
- options.declare(:sql, 'SQL suffix appended to sqlite3 queries for admin subcommands (e.g. WHERE clause)')
25
+ def included(base)
26
+ base.option(:sql, description: 'SQL suffix appended to sqlite3 queries for admin subcommands (e.g. WHERE clause)')
27
+ end
28
+
29
+ # DSL helper: register the 7 `sync admin` leaf commands under the given parent path.
30
+ # Called at class-load time from any plugin that includes SyncActions.
31
+ # @param base [Class] the plugin class (receiver of DSL methods)
32
+ # @param admin_path [Symbol, Array<Symbol>] path relative to current scope, e.g. :admin
33
+ def register_sync_admin_commands(base, admin_path)
34
+ base.commands_under(admin_path) do
35
+ base.command(:find, description: 'Find sync database files', arguments: [{name: :path, type: String}], action: :action_sync_admin_find)
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)
42
+ end
20
43
  end
21
44
  end
22
45
 
23
- # Read 1 or 2 command line arguments and converts to `sync_info` format
46
+ # Convert path + sync_info to internal `sync_info` format.
24
47
  # The resulting sync_info has `args` format only if it contains one of the `sessions` or `instance` keys.
25
48
  # It has the `conf` format (default) otherwise.
26
49
  # If the `conf` format is detected, then both `local` and `remote` keys are set.
27
- # @param direction [Symbol,NilClass] One of directions, or `nil` if only for admin command
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
28
53
  # @return [Hash] sync info
29
- def async_info_from_args(direction: nil)
30
- path = options.get_next_argument('path')
31
- sync_info = options.get_next_argument('sync info', mandatory: false, validation: Hash, default: {}, schema: Schema::Registry::SYNC_CONF)
54
+ def async_info_from_args(path:, sync_info: {}, direction: nil)
32
55
  # is the positional path a remote path ?
33
56
  path_is_remote = direction.eql?(:pull)
34
57
  if sync_info.key?('sessions') || sync_info.key?('instance')
@@ -37,33 +60,33 @@ module Aspera
37
60
  Aspera.assert(sync_info['sessions'].length == 1, 'Only one session is supported')
38
61
  session = sync_info['sessions'].first
39
62
  dir_key = path_is_remote ? 'remote_dir' : 'local_dir'
40
- raise "Parameter #{dir_key} shall not be in sync_info" if session.key?(dir_key)
63
+ Aspera.assert(!session.key?(dir_key)) { "Parameter #{dir_key} shall not be in sync_info" }
41
64
  session[dir_key] = path
42
65
  if direction
43
66
  dir_key = path_is_remote ? 'local_dir' : 'remote_dir'
44
- raise "Parameter #{dir_key} shall not be in sync_info" if session.key?(dir_key)
67
+ Aspera.assert(!session.key?(dir_key)) { "Parameter #{dir_key} shall not be in sync_info" }
45
68
  session[dir_key] = transfer.destination_folder(path_is_remote ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND)
46
- local_remote = %w[local remote].map{ |i| session["#{i}_dir"]}
69
+ local_remote = %w[local remote].map { |i| session["#{i}_dir"] }
47
70
  end
48
71
  else
49
72
  # `conf`
50
73
  session = sync_info
51
74
  dir_key = path_is_remote ? 'remote' : 'local'
52
75
  session[dir_key] ||= {}
53
- raise "Parameter #{dir_key}.path shall not be in sync_info" if session[dir_key].key?('path')
76
+ Aspera.assert(!session[dir_key].key?('path')) { "Parameter #{dir_key}.path shall not be in sync_info" }
54
77
  session[dir_key]['path'] = path
55
78
  if direction
56
79
  dir_key = path_is_remote ? 'local' : 'remote'
57
80
  session[dir_key] ||= {}
58
- raise "Parameter #{dir_key}.path shall not be in sync_info" if session[dir_key].key?('path')
81
+ Aspera.assert(!session[dir_key].key?('path')) { "Parameter #{dir_key}.path shall not be in sync_info" }
59
82
  session[dir_key]['path'] = transfer.destination_folder(path_is_remote ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND)
60
- local_remote = %w[local remote].map{ |i| session[i]['path']}
83
+ local_remote = %w[local remote].map { |i| session[i]['path'] }
61
84
  end
62
85
  # `conf` is quiet by default
63
86
  session['quiet'] = false if !session.key?('quiet') && Environment.terminal?
64
87
  end
65
88
  if direction
66
- raise BadArgument, 'direction shall not be in sync_info' if session.key?('direction')
89
+ Aspera.assert(!session.key?('direction'), type: BadArgument) { 'direction shall not be in sync_info' }
67
90
  session['direction'] = direction.to_s
68
91
  # generate name if not provided by user
69
92
  if !session.key?('name')
@@ -80,66 +103,68 @@ module Aspera
80
103
  sync_info
81
104
  end
82
105
 
83
- # Provide database object from command line arguments for admin ops
84
- def db_from_args
85
- sync_info = async_info_from_args
106
+ # Provide database object from path + sync_info for admin ops
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: {})
110
+ sync_info = async_info_from_args(path: path, sync_info: sync_info)
86
111
  session = sync_info.key?('sessions') ? sync_info['sessions'].first : sync_info
87
112
  # if name not provided, check in db folder if there is only one name
88
113
  if !session.key?('name')
89
114
  local_db_dir = Sync::Operations.local_db_folder(sync_info)
90
115
  dbs = Sync::Operations.list_db_files(local_db_dir)
91
- raise "#{dbs.length} session found in #{local_db_dir}, please provide a name" unless dbs.length == 1
116
+ Aspera.assert(dbs.length == 1) { "#{dbs.length} session found in #{local_db_dir}, please provide a name" }
92
117
  session['name'] = dbs.keys.first
93
118
  end
94
119
  Sync::Database.new(Sync::Operations.session_db_file(sync_info))
95
120
  end
96
121
 
97
- def execute_sync_admin
98
- command2 = options.get_next_command(%i[status find meta counters file_info overview query])
99
- require 'aspera/sync/database' unless command2.eql?(:status)
100
- sql_suffix = options.get_option(:sql)
101
- case command2
102
- when :status
103
- return Result::SingleObject.new(Sync::Operations.admin_status(async_info_from_args))
104
- when :find
105
- folder = options.get_next_argument('path')
106
- dbs = Sync::Operations.list_db_files(folder)
107
- return Result::ObjectList.new(dbs.keys.map{ |n| {name: n, path: dbs[n]}})
108
- when :meta, :counters
109
- return Result::SingleObject.new(db_from_args.send(command2, sql_suffix))
110
- when :file_info
111
- result = db_from_args.file_info(sql_suffix)
112
- result.each do |r|
113
- r['sstate'] = SyncActions::STATE_STR[r['state']] if r['state']
114
- end
115
- return Result::ObjectList.new(
116
- result,
117
- fields: %w[sstate record_id f_meta_path message]
118
- )
119
- when :overview
120
- return Result::ObjectList.new(
121
- db_from_args.overview,
122
- fields: %w[table name type]
123
- )
124
- when :query
125
- return Result.auto(db_from_args.execute(options.get_option(:sql, mandatory: true)))
126
- else Aspera.error_unexpected_value(command2)
127
- end
122
+ def action_sync_admin_status(path:, sync_info: {}, **)
123
+ Result::SingleObject.new(Sync::Operations.admin_status(async_info_from_args(path: path, sync_info: sync_info)))
124
+ end
125
+
126
+ def action_sync_admin_find(path:, **)
127
+ dbs = Sync::Operations.list_db_files(path)
128
+ Result::ObjectList.new(dbs.keys.map { |n| {name: n, path: dbs[n]} })
129
+ end
130
+
131
+ def action_sync_admin_meta(path:, sync_info: {}, **)
132
+ require 'aspera/sync/database'
133
+ Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).meta(options.get_option(:sql)))
128
134
  end
129
135
 
130
- # Execute sync action
131
- # @param &block [nil, Proc] block to generate transfer spec, takes: `direction` (one of DIRECTIONS), `local_dir`, `remote_dir`
132
- def execute_sync_action(&block)
133
- command = options.get_next_command(%i[admin] + Sync::Operations::DIRECTIONS)
134
- # try to get 3 arguments as simple arguments
135
- case command
136
- when *Sync::Operations::DIRECTIONS
137
- Sync::Operations.start(async_info_from_args(direction: command), transfer.user_transfer_spec, &block)
138
- return Result::Success.new
139
- when :admin
140
- return execute_sync_admin
141
- else Aspera.error_unexpected_value(command)
136
+ def action_sync_admin_counters(path:, sync_info: {}, **)
137
+ require 'aspera/sync/database'
138
+ Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).counters(options.get_option(:sql)))
139
+ end
140
+
141
+ def action_sync_admin_file_info(path:, sync_info: {}, **)
142
+ require 'aspera/sync/database'
143
+ result = db_from_args(path: path, sync_info: sync_info).file_info(options.get_option(:sql))
144
+ result.each do |r|
145
+ r['sstate'] = SyncActions::STATE_STR[r['state']] if r['state']
142
146
  end
147
+ Result::ObjectList.new(result, fields: %w[sstate record_id f_meta_path message])
148
+ end
149
+
150
+ def action_sync_admin_overview(path:, sync_info: {}, **)
151
+ require 'aspera/sync/database'
152
+ Result::ObjectList.new(db_from_args(path: path, sync_info: sync_info).overview, fields: %w[table name type])
153
+ end
154
+
155
+ def action_sync_admin_query(path:, sync_info: {}, **)
156
+ require 'aspera/sync/database'
157
+ Result.auto(db_from_args(path: path, sync_info: sync_info).execute(options.get_option(:sql, mandatory: true)))
158
+ end
159
+
160
+ # Execute a sync transfer for a given direction.
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)
164
+ # @param block [Proc, nil] block to generate transfer spec; receives (direction, local_dir, remote_dir)
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)
167
+ Result::Success.new
143
168
  end
144
169
  end
145
170
  end
@@ -14,7 +14,7 @@ module Aspera
14
14
  HINT = 'HINT:'.bg_green.gray.blink.freeze
15
15
  include FormatterInterface
16
16
 
17
- # Format boolean with colored symbol (✓/✗ or Y/ )
17
+ # Format boolean with colored symbol (+/- or Y/ )
18
18
  def tick(yes)
19
19
  result =
20
20
  if Environment.terminal_supports_unicode?
@@ -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
 
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/async_transfer_store'
4
+ require 'aspera/agent/factory'
5
+ require 'aspera/log'
6
+ require 'aspera/assert'
7
+
8
+ module Aspera
9
+ module Cli
10
+ # Mixin for Config plugin: async transfer management actions.
11
+ # Exposes three sub-commands under `config transfer`:
12
+ # status --id=<job_id> Re-query a running/completed transfer
13
+ # list List all persisted async transfer entries
14
+ # cleanup Remove completed/failed/cancelled entries
15
+ #
16
+ # Architecture:
17
+ # - remote-daemon agents (desktop, node, connect, transferd): transfer_id + agent_params
18
+ # are persisted in AsyncTransferStore; status is re-queried via Agent::Xxx.transfer_status
19
+ # - direct agent: transfers live in Ruby threads - store is the agent's @sessions;
20
+ # status is not persistable across process restarts (returns an informational message)
21
+ module TransferActions
22
+ # Re-query the status of a single async transfer job.
23
+ # @param job_id [String] UUID returned at submission time
24
+ def action_transfer_status(job_id:, **)
25
+ store = async_transfer_store
26
+ entry = store.read(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
31
+ live = query_live_status(entry)
32
+ if live
33
+ entry.merge!(live)
34
+ store.write(job_id, entry)
35
+ end
36
+ Result::SingleObject.new(entry)
37
+ end
38
+
39
+ # List all persisted async transfer entries.
40
+ def action_transfer_list(**)
41
+ rows = async_transfer_store.list
42
+ Result::ObjectList.new(rows, fields: %w[job_id agent_type status started_at ended_at bytes_transferred transfer_id])
43
+ end
44
+
45
+ TERMINAL_STATUSES = %w[completed failed cancelled].freeze
46
+ private_constant :TERMINAL_STATUSES
47
+
48
+ # Delete completed, failed, and cancelled entries from the store.
49
+ def action_transfer_cleanup(**)
50
+ store = async_transfer_store
51
+ deleted = store.list
52
+ .select { |e| TERMINAL_STATUSES.include?(e['status']) }
53
+ .map do |e|
54
+ store.delete(e['job_id'])
55
+ e['job_id']
56
+ end
57
+ Result::Status.new("Deleted #{deleted.size} completed transfer(s)#{": #{deleted.join(', ')}" unless deleted.empty?}")
58
+ end
59
+
60
+ private
61
+
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.
65
+ def async_transfer_store
66
+ context.transfer.async_store
67
+ end
68
+
69
+ # Delegate to the appropriate agent class method.
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.
76
+ def query_live_status(entry)
77
+ agent_type = entry['agent_type']
78
+ transfer_id = entry['transfer_id']
79
+ agent_params = entry['agent_params'] || {}
80
+
81
+ require "aspera/agent/#{agent_type}"
82
+ agent_class = Aspera::Agent.const_get(agent_type.capitalize)
83
+ agent_class.transfer_status(transfer_id, agent_params)
84
+ rescue StandardError => e
85
+ Log.log.warn { "Could not re-query #{entry['agent_type']} agent for job #{entry['job_id']}: #{e}" }
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ end