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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/log'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module Aspera
8
+ module Cli
9
+ # Persist the state of asynchronous transfers on the local file system.
10
+ #
11
+ # Each entry is a JSON document identified by a `job_id` (UUID generated by ascli).
12
+ # The underlying storage uses PersistencyFolder with the category prefix
13
+ # +CATEGORY+ so all async-transfer files are grouped together and can be
14
+ # garbage-collected independently.
15
+ #
16
+ # Schema of a stored entry:
17
+ # job_id [String] UUID generated by ascli (= the store key)
18
+ # agent_type [String] 'desktop' | 'node' | 'connect' | 'transferd' | 'direct'
19
+ # transfer_id [String] Opaque ID returned by the agent's start_transfer
20
+ # agent_params [Hash] Agent-specific connection parameters for re-querying
21
+ # status [String] 'running' | 'completed' | 'failed' | 'cancelled'
22
+ # bytes_transferred [Integer] Last known bytes transferred (0 if unknown)
23
+ # started_at [String] ISO-8601 timestamp
24
+ # ended_at [String, nil] ISO-8601 timestamp once finished
25
+ # error [String, nil] Error message when status == 'failed'
26
+ class AsyncTransferStore
27
+ CATEGORY = 'async_transfer_'
28
+ private_constant :CATEGORY
29
+
30
+ # Class-level registry of in-process agent references, keyed by job_id.
31
+ # Shared across all AsyncTransferStore instances within the same Ruby process,
32
+ # so that a reference registered in one Runner call is visible in the next
33
+ # (e.g. MCP server mode where each tool call creates a fresh Runner/Context).
34
+ # Never persisted to disk.
35
+ @agent_refs = {}
36
+ class << self
37
+ attr_reader :agent_refs
38
+ end
39
+
40
+ # @param persistency [PersistencyFolder]
41
+ def initialize(persistency)
42
+ @persistency = persistency
43
+ end
44
+
45
+ # Register an in-process agent reference for a job.
46
+ # Called by TransferAgent immediately after start_transfer (async mode).
47
+ # The reference is only available while the same Ruby process is alive.
48
+ # @param job_id [String] the ascli-generated UUID
49
+ # @param agent_ref [Object] live agent instance (responds to sessions_by_job)
50
+ def register_agent_ref(job_id, agent_ref)
51
+ self.class.agent_refs[job_id] = agent_ref
52
+ end
53
+
54
+ # Retrieve a previously registered in-process agent reference.
55
+ # @param job_id [String]
56
+ # @return [Object, nil] the live agent instance, or nil if not registered / process restarted
57
+ def agent_ref(job_id)
58
+ self.class.agent_refs[job_id]
59
+ end
60
+
61
+ # Persist (create or update) an async transfer entry.
62
+ # Keys whose name starts with '_' are in-process references (e.g. '_agent_ref')
63
+ # and are intentionally excluded from the JSON serialization — at every nesting level.
64
+ # @param job_id [String] the ascli-generated UUID
65
+ # @param data [Hash] fields to store (will be JSON-serialised)
66
+ def write(job_id, data)
67
+ Aspera.assert_type(job_id, String) { 'job_id' }
68
+ Aspera.assert_type(data, Hash) { 'data' }
69
+ @persistency.put(store_key(job_id), JSON.generate(strip_internal_keys(data)))
70
+ nil
71
+ end
72
+
73
+ # Read one entry.
74
+ # @param job_id [String]
75
+ # @return [Hash, nil] the stored data, or nil if not found
76
+ def read(job_id)
77
+ Aspera.assert_type(job_id, String) { 'job_id' }
78
+ raw = @persistency.get(store_key(job_id))
79
+ return if raw.nil?
80
+ JSON.parse(raw)
81
+ end
82
+
83
+ # List all async transfer entries.
84
+ # @return [Array<Hash>] each entry includes the +job_id+ field
85
+ def list
86
+ @persistency.current_items(CATEGORY).map do |key, raw|
87
+ data = JSON.parse(raw)
88
+ data['job_id'] ||= key.sub(CATEGORY, '')
89
+ data
90
+ end
91
+ end
92
+
93
+ # Delete one entry.
94
+ # @param job_id [String]
95
+ def delete(job_id)
96
+ Aspera.assert_type(job_id, String) { 'job_id' }
97
+ @persistency.delete(store_key(job_id))
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ # Map a job_id to the PersistencyFolder key (includes category prefix).
104
+ def store_key(job_id)
105
+ "#{CATEGORY}#{job_id}"
106
+ end
107
+
108
+ # Recursively remove keys whose name starts with '_' from a Hash.
109
+ # Such keys hold in-process Ruby object references (e.g. '_agent_ref') that
110
+ # cannot be serialized to JSON and must never reach the file system.
111
+ def strip_internal_keys(value)
112
+ case value
113
+ when Hash
114
+ value.each_with_object({}) do |(k, v), h|
115
+ h[k] = strip_internal_keys(v) unless k.to_s.start_with?('_')
116
+ end
117
+ when Array
118
+ value.map { |v| strip_internal_keys(v) }
119
+ else
120
+ value
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/plugins/factory'
4
+ require 'aspera/cli/extended_value'
5
+ require 'aspera/cli/special_values'
6
+ require 'aspera/cli/info'
7
+ require 'aspera/cli/transfer_progress'
8
+ require 'aspera/cli/preset_manager'
9
+ require 'aspera/cli/http'
10
+ require 'aspera/ascp/installation'
11
+ require 'aspera/products/transferd'
12
+ require 'aspera/transfer/parameters'
13
+ require 'aspera/proxy_auto_config'
14
+ require 'aspera/environment'
15
+ require 'aspera/persistency_folder'
16
+ require 'aspera/rest'
17
+ require 'aspera/log'
18
+ require 'aspera/assert'
19
+ require 'aspera/oauth'
20
+ require 'aspera/ssl'
21
+ require 'aspera/schema/registry'
22
+
23
+ module Aspera
24
+ module Cli
25
+ # Performs one-time bootstrap of all shared services into Context.
26
+ # Called by Runner before instantiating Plugins::Config, so that Config
27
+ # acts only as a CLI plugin (option declaration + command handlers).
28
+ #
29
+ # Responsibilities:
30
+ # - resolve context.main_folder (option :home)
31
+ # - populate context.persistency, context.presets, context.http_config, context.progress_bar
32
+ # - register plugin lookup folders
33
+ # - register @preset / @vault extended-value handlers
34
+ # - configure global singletons: RestParameters, OAuth::Factory, SSL, Transfer::Parameters,
35
+ # RestErrorAnalyzer
36
+ # - set up the PAC proxy executor (option :fpac)
37
+ class Bootstrapper
38
+ # Folder name inside $HOME for all Aspera tool data (~/.aspera)
39
+ ASPERA_HOME_FOLDER_NAME = '.aspera'
40
+ # Default name for the YAML config file
41
+ DEFAULT_CONFIG_FILENAME = 'config.yaml'
42
+ # Sub-folder for user-installed plugins
43
+ ASPERA_PLUGINS_FOLDERNAME = 'plugins'
44
+ # Sub-folder for persistency data
45
+ PERSISTENCY_FOLDER = 'persist_store'
46
+ # Sub-folder for file lists used by transfers
47
+ FILE_LIST_FOLDER_NAME = 'filelists'
48
+ # Log file for REST call exceptions
49
+ REST_EXCEPTIONS_LOG_FILENAME = 'rest_exceptions.log'
50
+ # Extended-value prefix that resolves a named preset
51
+ EXTEND_PRESET = :preset
52
+ # Extended-value prefix that resolves a vault secret
53
+ EXTEND_VAULT = :vault
54
+ # Default preset name for the global config section
55
+ CONF_GLOBAL_SYM = :config
56
+
57
+ private_constant :ASPERA_HOME_FOLDER_NAME,
58
+ :DEFAULT_CONFIG_FILENAME,
59
+ :ASPERA_PLUGINS_FOLDERNAME,
60
+ :PERSISTENCY_FOLDER,
61
+ :FILE_LIST_FOLDER_NAME,
62
+ :REST_EXCEPTIONS_LOG_FILENAME,
63
+ :EXTEND_PRESET,
64
+ :EXTEND_VAULT,
65
+ :CONF_GLOBAL_SYM
66
+
67
+ # @param context [Context] the shared context to populate
68
+ def initialize(context)
69
+ Aspera.assert_type(context, Context) { 'context' }
70
+ @context = context
71
+ @pac_exec = nil
72
+ end
73
+
74
+ # Run the full bootstrap sequence.
75
+ # Must be called after context.options and context.formatter are set.
76
+ # On return, context.persistency, context.presets, context.http_config and
77
+ # context.progress_bar are populated and all global singletons are configured.
78
+ #
79
+ # @param gem_plugins_folder [String] folder of built-in plugins (from Plugins::Config)
80
+ # @param vault_value_cb [Proc] block(name): secret value (from VaultManager)
81
+ def run(gem_plugins_folder:, vault_value_cb:)
82
+ setup_main_folder
83
+ setup_persistency_and_plugin_folders(gem_plugins_folder)
84
+ setup_config_file
85
+ setup_extended_value_handlers(vault_value_cb)
86
+ setup_progress_bar
87
+ setup_pac_executor
88
+ setup_rest_and_transfer_runtime
89
+ end
90
+
91
+ # Public accessor used as option handler for :config_file
92
+ attr_accessor :config_file_option
93
+
94
+ private
95
+
96
+ # Declare + parse :home option: sets context.main_folder
97
+ def setup_main_folder
98
+ @context.options.declare(
99
+ :home, description: 'Home folder for tool',
100
+ handler: {o: @context, m: :main_folder},
101
+ default: default_app_main_folder(app_name: Info::CMD_NAME)
102
+ )
103
+ @context.options.parse_options!
104
+ Log.log.debug { "#{Info::CMD_NAME} folder: #{@context.main_folder}" }
105
+ end
106
+
107
+ # context.persistency + plugin lookup folders
108
+ def setup_persistency_and_plugin_folders(gem_plugins_folder)
109
+ @context.persistency = PersistencyFolder.new(File.join(@context.main_folder, PERSISTENCY_FOLDER))
110
+ Plugins::Factory.instance.add_lookup_folder(gem_plugins_folder)
111
+ Plugins::Factory.instance.add_lookup_folder(File.join(@context.main_folder, ASPERA_PLUGINS_FOLDERNAME))
112
+ end
113
+
114
+ # Declare + parse :config_file option: sets context.presets + context.http_config
115
+ def setup_config_file
116
+ @context.options.declare(
117
+ :config_file, description: 'Path to YAML file with preset configuration',
118
+ handler: {o: self, m: :config_file_option},
119
+ default: File.join(@context.main_folder, DEFAULT_CONFIG_FILENAME)
120
+ )
121
+ @context.options.parse_options!
122
+ @context.presets = PresetManager.new(config_file: @config_file_option)
123
+ @context.http_config = Http.new
124
+ end
125
+
126
+ # Register @preset and @vault extended-value handlers + global config default preset
127
+ def setup_extended_value_handlers(vault_value_cb)
128
+ @context.options.declare(:secret, description: 'Secret for access keys')
129
+ @context.options.declare(:vault, schema: Schema::Registry::VAULT_OPTIONS)
130
+ @context.options.declare(:vault_password, description: 'Vault password')
131
+ # Register @preset and @vault handlers BEFORE parse_options! so that
132
+ # values like --secret=@preset:name are correctly evaluated at parse time.
133
+ ExtendedValue.instance.on(EXTEND_PRESET) { |v| @context.presets.by_name(v) }
134
+ ExtendedValue.instance.on(EXTEND_VAULT, &vault_value_cb)
135
+ @context.options.parse_options!
136
+ # Load global config default preset (equivalent of add_plugin_default_preset(:config))
137
+ default_config_name = @context.presets.plugin_default_name(CONF_GLOBAL_SYM)
138
+ unless default_config_name.nil?
139
+ Log.log.debug { "add_plugin_default_preset:#{CONF_GLOBAL_SYM}:#{default_config_name}" }
140
+ @context.options.add_option_preset(@context.presets.by_name(default_config_name), 'default_plugin', override: false)
141
+ end
142
+ end
143
+
144
+ # Declare + parse :progress_bar: sets context.progress_bar
145
+ def setup_progress_bar
146
+ @context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default: Environment.terminal?)
147
+ @context.options.parse_options!
148
+ @context.progress_bar = TransferProgress.new if @context.options.get_option(:progress_bar)
149
+ end
150
+
151
+ # Declare + parse :fpac / :proxy_credentials: sets up PAC executor
152
+ def setup_pac_executor
153
+ @context.options.declare(:fpac, description: 'Proxy auto configuration script')
154
+ @context.options.declare(:proxy_credentials, description: 'HTTP proxy credentials for fpac: user, password', allowed: [Array, NilClass])
155
+ @context.options.parse_options!
156
+ pac_script = @context.options.get_option(:fpac)
157
+ return unless pac_script
158
+
159
+ @context.pac_executor = ProxyAutoConfig.new(pac_script).register_uri_generic
160
+ proxy_user_pass = @context.options.get_option(:proxy_credentials)
161
+ if proxy_user_pass
162
+ Aspera.assert(proxy_user_pass.length.eql?(2), type: Cli::BadArgument) { "proxy_credentials shall have two elements (#{proxy_user_pass.length})" }
163
+ @context.pac_executor.proxy_user = proxy_user_pass[0]
164
+ @context.pac_executor.proxy_pass = proxy_user_pass[1]
165
+ end
166
+ end
167
+
168
+ # Configure global singletons: RestParameters, SSL, Transfer, RestErrorAnalyzer.
169
+ # OAuth persist_mgr is NOT set here: it depends on :cache_tokens which is parsed later
170
+ # by Config#initialize. Runner sets it after Config.new.
171
+ def setup_rest_and_transfer_runtime
172
+ RestParameters.instance.user_agent = Info::CMD_NAME
173
+ RestParameters.instance.progress_bar = @context.progress_bar
174
+ RestParameters.instance.session_cb = ->(http_session) { @context.http_config.update_session(http_session) }
175
+ RestParameters.instance.spinner_cb = ->(title = nil, action: :spin) { @context.formatter.long_operation(title, action: action) }
176
+ OAuth::Web.additional_info = "#{Info::CMD_NAME} v#{Cli::VERSION}"
177
+ Transfer::Parameters.file_list_folder = File.join(@context.main_folder, FILE_LIST_FOLDER_NAME)
178
+ RestErrorAnalyzer.instance.log_file = File.join(@context.main_folder, REST_EXCEPTIONS_LOG_FILENAME)
179
+ RestErrorsAspera.register_handlers
180
+ end
181
+
182
+ # @return [String] ~/.aspera
183
+ def module_family_folder
184
+ user_home_folder = Dir.home
185
+ Aspera.assert(Dir.exist?(user_home_folder), type: Cli::Error) { "Home folder does not exist: #{user_home_folder}. Check your user environment." }
186
+ File.join(user_home_folder, ASPERA_HOME_FOLDER_NAME)
187
+ end
188
+
189
+ # @return [String] ~/.aspera/<app_name>
190
+ def default_app_main_folder(app_name:)
191
+ Aspera.assert_type(app_name, String)
192
+ Aspera.assert(!app_name.empty?, 'app_name must not be empty')
193
+ File.join(module_family_folder, app_name)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/command_spec'
4
+
5
+ module Aspera
6
+ module Cli
7
+ # Stores CommandSpec objects indexed by their full path (Array<Symbol>).
8
+ # Each plugin class gets its own instance (not shared across the inheritance chain).
9
+ #
10
+ # Public API:
11
+ # register(spec) - store a CommandSpec; raises on duplicate full_path
12
+ # register_option(spec) - store an OptionSpec by name
13
+ # option_specs - Hash{Symbol => OptionSpec} of all registered options
14
+ # [](path) - retrieve a CommandSpec by full path
15
+ # children_of(path) - Hash{Symbol => CommandSpec} of direct children (O(1))
16
+ # all_paths - Array of all registered full paths
17
+ # any? - true if at least one spec has been registered
18
+ # validate! - cross-spec consistency checks; raises on violation
19
+ class CommandRegistry
20
+ # @param path [Array<Symbol>] full path to look up
21
+ # @return [CommandSpec, nil]
22
+ def [](path)
23
+ @specs[Array(path)]
24
+ end
25
+
26
+ # Register a CommandSpec. Raises if the full_path is already registered.
27
+ # Also updates the children index so children_of remains O(1).
28
+ # @param spec [CommandSpec]
29
+ # @raise [ArgumentError] on duplicate full path
30
+ # @return [CommandSpec] the registered spec
31
+ def register(spec)
32
+ path = spec.full_path
33
+ raise ArgumentError, "Duplicate command path: #{path.inspect}" if @specs.key?(path)
34
+ @specs[path] = spec
35
+ # Index: parent_path -> { child_id -> spec }
36
+ parent = path[0..-2] # [] for root-level commands
37
+ (@children_index[parent] ||= {})[spec.id] = spec
38
+ spec
39
+ end
40
+
41
+ # Returns a Hash mapping each child id to its CommandSpec for all direct
42
+ # children of `path`. Empty hash if no children are registered.
43
+ # O(1) lookup via the children index built in register().
44
+ # @param path [Array<Symbol>] parent path ([] for root-level commands)
45
+ # @return [Hash{Symbol => CommandSpec}]
46
+ def children_of(path)
47
+ @children_index[Array(path)] || {}
48
+ end
49
+
50
+ # @return [Array<Array<Symbol>>] all registered full paths
51
+ def all_paths
52
+ @specs.keys
53
+ end
54
+
55
+ # Register an OptionSpec. Raises if the option name is already registered.
56
+ # @param spec [OptionSpec]
57
+ # @raise [ArgumentError] on duplicate option name
58
+ # @return [OptionSpec] the registered spec
59
+ def register_option(spec)
60
+ raise ArgumentError, "Duplicate option: #{spec.name.inspect}" if @option_specs.key?(spec.name)
61
+ @option_specs[spec.name] = spec
62
+ end
63
+
64
+ # @return [Hash{Symbol => OptionSpec}] all registered option specs
65
+ def option_specs
66
+ @option_specs.dup
67
+ end
68
+
69
+ # @return [Boolean] true if at least one spec is registered
70
+ def any?
71
+ !@specs.empty?
72
+ end
73
+
74
+ # @return [Boolean] true if no specs have been registered
75
+ def none?
76
+ @specs.empty?
77
+ end
78
+
79
+ # Cross-spec consistency checks.
80
+ # @param plugin_class [Class, nil] when given, also verify that implicit action methods exist
81
+ # @raise [ArgumentError] on any violation
82
+ # @return [self]
83
+ def validate!(plugin_class: nil)
84
+ # Rule: every non-root parent path that appears in the children index must have
85
+ # a registered CommandSpec. A missing parent means commands_under(:x) was used
86
+ # without a matching command :x declaration.
87
+ @children_index.each_key do |parent_path|
88
+ next if parent_path.empty? # root is never a CommandSpec
89
+ unless @specs.key?(parent_path)
90
+ raise ArgumentError,
91
+ "commands_under(#{parent_path.map(&:inspect).join(', ')}) used but #{parent_path.last.inspect} has no command declaration"
92
+ end
93
+ end
94
+
95
+ @specs.each_value do |spec|
96
+ path = spec.full_path
97
+
98
+ # Rule: delegates_to must point to a known path when present (non-empty array or symbol)
99
+ if spec.delegates_to
100
+ dt_path =
101
+ case spec.delegates_to
102
+ when Symbol then [spec.delegates_to]
103
+ when Array then spec.delegates_to
104
+ end
105
+ # An empty array [] means re-enter the root - always valid
106
+ unless dt_path.empty? || @specs.key?(dt_path)
107
+ raise ArgumentError,
108
+ "#{path.inspect}: delegates_to #{dt_path.inspect} points to unknown path"
109
+ end
110
+ end
111
+
112
+ # Rule: delegate_instance requires delegates_to
113
+ if spec.delegate_instance && spec.delegates_to.nil?
114
+ raise ArgumentError,
115
+ "#{path.inspect}: delegate_instance requires delegates_to to be set"
116
+ end
117
+
118
+ # Rule: leaf commands with no explicit action must have a matching instance method
119
+ next if spec.action # explicit action: skip
120
+ next if @children_index[path]&.any? # intermediate node: skip
121
+ next if spec.delegates_to # delegated: skip
122
+ next unless plugin_class
123
+ implicit_method = CommandSpec.action_method(path)
124
+ unless plugin_class.method_defined?(implicit_method) || plugin_class.private_method_defined?(implicit_method)
125
+ raise ArgumentError,
126
+ "#{path.inspect}: no action: and no method #{implicit_method} on #{plugin_class}"
127
+ end
128
+ end
129
+ self
130
+ end
131
+
132
+ private
133
+
134
+ def initialize
135
+ # Keyed by Array<Symbol> full path
136
+ @specs = {}
137
+ # Keyed by Symbol option name
138
+ @option_specs = {}
139
+ # Children index: parent Array<Symbol> -> Hash{child_id Symbol => CommandSpec}
140
+ # Built incrementally in register(); enables O(1) children_of lookups.
141
+ @children_index = {}
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/schema/registry'
4
+
5
+ module Aspera
6
+ module Cli
7
+ # Declares a positional argument consumed by a command.
8
+ # Declaration order in a command's `arguments:` array defines parsing order.
9
+ # Mandatory arguments must come before optional ones.
10
+ #
11
+ # @!attribute name [Symbol] Name used in help and error messages
12
+ # @!attribute description [String] User-facing description
13
+ # @!attribute type [Class, Array<Class>, :identifier] Validated type; :identifier triggers instance_identifier
14
+ # @!attribute mandatory [Boolean] Default true; optional args must come after all mandatory ones
15
+ # @!attribute multiple [Boolean, String] true: consume all remaining; String: consume until named marker
16
+ # @!attribute default [Object, nil] Default value when mandatory: false and no argument provided
17
+ # @!attribute schema [String, nil] JSON schema name for validation and --help introspection
18
+ # @!attribute bulk [Boolean] When true, wraps read+loop for bulk mode (Array if --bulk yes)
19
+ # @!attribute lookup [Symbol, Proc, nil] Percent-selector resolver (only used when type: :identifier).
20
+ # Symbol → resolved via send(lookup, field, value, **ctx).
21
+ # Proc/lambda → called via instance_exec(field, value, **ctx, &lookup).
22
+ # Style: use Symbol for named methods; ->(){} for 1-liners; lambda do…end for 2–3 statements.
23
+ # @!attribute allowed [Array<Symbol>, nil] Allowed Symbol values; when set, type is forced to Symbol and accept_list is applied
24
+ # @!attribute interactive [Boolean] When true, sets ask_missing_mandatory before resolving so interactive prompting is triggered when no CLI args are provided
25
+ ArgumentSpec = Struct.new(
26
+ :name,
27
+ :description,
28
+ :type,
29
+ :mandatory,
30
+ :multiple,
31
+ :default,
32
+ :schema,
33
+ :bulk,
34
+ :lookup,
35
+ :allowed,
36
+ :interactive,
37
+ keyword_init: true
38
+ ) do
39
+ def initialize(**kwargs)
40
+ kwargs[:mandatory] = true if kwargs[:mandatory].nil?
41
+ kwargs[:multiple] = false if kwargs[:multiple].nil?
42
+ kwargs[:bulk] = false if kwargs[:bulk].nil?
43
+ kwargs[:interactive] = false if kwargs[:interactive].nil?
44
+ super
45
+ end
46
+ end
47
+
48
+ # Declares an option referenced by name from command declarations.
49
+ # Mirrors the existing `options.declare` call but associates the option with
50
+ # the command(s) that use it.
51
+ #
52
+ # @!attribute name [Symbol] Option name (same symbol used in options.declare)
53
+ # @!attribute description [String, nil] User-facing description; nil derives it from schema:
54
+ # @!attribute allowed [Array, nil] Allowed values (forwarded to options.declare)
55
+ # @!attribute default [Object, nil] Default value
56
+ # @!attribute short [String, nil] Single-character short form (e.g. 'x')
57
+ # @!attribute handler [Symbol, Hash, nil]
58
+ # - Symbol: resolved to {o: <plugin instance>, m: <symbol>} at runtime (Category B)
59
+ # - Hash: {o: <object>, m: <method>} used as-is (Category A: singletons / class constants)
60
+ # - nil: option stores its value locally (no delegation)
61
+ # @!attribute deprecation [String, nil] Forwarded to options.declare as deprecation:
62
+ # @!attribute schema [String, nil] JSON schema name; also derives description when nil
63
+ OptionSpec = Struct.new(
64
+ :name,
65
+ :description,
66
+ :allowed,
67
+ :default,
68
+ :short,
69
+ :handler, # kept as-is: this is the option accessor delegation, not a command action
70
+ :deprecation,
71
+ :schema,
72
+ keyword_init: true
73
+ )
74
+
75
+ # Declares a single command node in the flat registry.
76
+ #
77
+ # @!attribute id [Symbol] Unique identifier within its parent's namespace
78
+ # @!attribute parent [Symbol, Array<Symbol>, nil] Full path to parent; nil for root commands
79
+ # @!attribute description [String] User-facing help text
80
+ # @!attribute options [Array<Symbol>] Option names consumed by this command
81
+ # @!attribute arguments [Array<ArgumentSpec>] Positional arguments, in order.
82
+ # The first ArgumentSpec with type: :identifier is treated as the instance
83
+ # identifier for intermediate nodes (consumed in Phase A) and leaf nodes.
84
+ # @!attribute action [Symbol, Proc, nil] Instance method (Symbol) or inline block (Proc) called when this is a leaf command
85
+ # @!attribute setup [Symbol, nil] Instance method called before dispatching to children; returns Hash merged into ctx
86
+ # @!attribute delegates_to [Symbol, Array<Symbol>, nil] Re-enter the command tree at this path
87
+ # @!attribute delegate_instance [Symbol, nil] Instance method returning a different plugin object
88
+ # @!attribute aliases [Array<Symbol>, nil] Alternative names accepted for this command (each resolves to this command's id)
89
+ # @!attribute transfer_paths [:send, :receive, nil] File-list resolution delegated to TransferAgent; mutually exclusive with arguments
90
+ # @!attribute condition [Symbol, nil] Instance method returning Boolean; if false command is hidden from dispatch
91
+ # @!attribute query_schema [String, nil] Schema path for --query help; when set, the runner hints `--query=help`
92
+ CommandSpec = Struct.new(
93
+ :id,
94
+ :parent,
95
+ :description,
96
+ :options,
97
+ :arguments,
98
+ :action,
99
+ :setup,
100
+ :delegates_to,
101
+ :delegate_instance,
102
+ :aliases,
103
+ :transfer_paths,
104
+ :condition,
105
+ :query_schema,
106
+ keyword_init: true
107
+ ) do
108
+ def initialize(**kwargs)
109
+ # Coerce each element of arguments: from Hash to ArgumentSpec if needed
110
+ if kwargs[:arguments]
111
+ kwargs[:arguments] = kwargs[:arguments].map do |a|
112
+ a.is_a?(Hash) ? ArgumentSpec.new(**a) : a
113
+ end
114
+ end
115
+ super
116
+ end
117
+
118
+ class << self
119
+ # Derive the implicit action method name from a path array.
120
+ # e.g. [:admin, :user, :list] -> :action_admin_user_list
121
+ # @param path [Array<Symbol>]
122
+ # @return [Symbol]
123
+ def action_method(path)
124
+ :"action_#{path.join('_')}"
125
+ end
126
+ end
127
+
128
+ # Compute the full path as Array<Symbol> from parent + id.
129
+ # @return [Array<Symbol>]
130
+ def full_path
131
+ Array(parent) + [id]
132
+ end
133
+
134
+ # Derive the implicit action method name from the full path.
135
+ # @return [Symbol]
136
+ def action_method_name
137
+ self.class.action_method(full_path)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/assert'
4
+ require 'aspera/cli/mailer'
5
+ require 'aspera/cli/secret_finder'
4
6
 
5
7
  module Aspera
6
8
  module Cli
7
9
  # Global objects shared with plugins
8
10
  class Context
9
11
  # @type [Array<Symbol>]
10
- MEMBERS = %i[options transfer config formatter persistency man_header presets http_config].freeze
12
+ # Members that must be non-nil after bootstrap (validated in #validate)
13
+ MEMBERS = %i[options transfer config formatter persistency man_header presets http_config main_folder mailer secret_finder].freeze
11
14
  # @!attribute [rw] options
12
- # @return [Manager] the command line options manager
15
+ # @return [Parser] the command line options manager
13
16
  # @!attribute [rw] transfer
14
17
  # @return [TransferAgent] the transfer agent, used by transfer plugins
15
18
  # @!attribute [rw] config
@@ -24,21 +27,31 @@ module Aspera
24
27
  # @return [PresetManager] manages the YAML config file and preset resolution
25
28
  # @!attribute [rw] http_config
26
29
  # @return [Http] manages HTTP/S and TLS runtime options
30
+ # @!attribute [rw] mailer
31
+ # @return [Mailer] sends emails (SMTP) via ERB templates
32
+ # @!attribute [rw] secret_finder
33
+ # @return [SecretFinder] resolves secrets for url+username pairs
27
34
  attr_accessor(*MEMBERS)
35
+ # Optional: nil when progress bar is disabled
36
+ attr_accessor :progress_bar
37
+ # Optional: nil when no PAC script is configured
38
+ attr_accessor :pac_executor
28
39
 
29
40
  # Initialize all members to nil, so that they are defined and can be validated later
30
41
  # @return [nil]
31
42
  def initialize
32
- MEMBERS.each{ |i| instance_variable_set(:"@#{i}", nil)}
43
+ MEMBERS.each { |i| instance_variable_set(:"@#{i}", nil) }
44
+ @progress_bar = nil
45
+ @pac_executor = nil
33
46
  end
34
47
 
35
- # Validate that all members are set, raise exception if not
48
+ # Validate that all mandatory members are non-nil (detect bootstrap bugs)
36
49
  # @raise [Aspera::AssertionError] if any member is not set
37
50
  # @return [nil]
38
51
  def validate
39
52
  MEMBERS.each do |i|
40
- Aspera.assert(instance_variable_defined?(:"@#{i}")){"context member @#{i} is not defined"}
41
- Aspera.assert(!instance_variable_get(:"@#{i}").nil?){"context member @#{i} is nil"}
53
+ Aspera.assert(instance_variable_defined?(:"@#{i}")) { "context member @#{i} is not defined" }
54
+ Aspera.assert(!instance_variable_get(:"@#{i}").nil?) { "context member @#{i} is nil" }
42
55
  end
43
56
  end
44
57