aspera-cli 4.26.1 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/agent/factory'
4
+ require 'aspera/schema/registry'
5
+ require 'aspera/schema/reader'
6
+
7
+ module Aspera
8
+ module Cli
9
+ # Mixin for Config plugin: ASCP / Transferd related actions
10
+ module AscpActions
11
+ TRANSFERD_APP_NAME = 'sdk'
12
+
13
+ # Mapping from agent symbol to schema key in options.schema.yaml
14
+ # Agents without dedicated schema (connect, desktop) are omitted.
15
+ AGENT_SCHEMA_KEY = {
16
+ direct: Schema::Registry::DIRECT_AGENT_OPTIONS,
17
+ node: Schema::Registry::NODE_AGENT_OPTIONS,
18
+ httpgw: Schema::Registry::HTTPGW_AGENT_OPTIONS,
19
+ transferd: Schema::Registry::TRANSFERD_AGENT_OPTIONS
20
+ }.freeze
21
+ private_constant :AGENT_SCHEMA_KEY
22
+
23
+ # Set the SDK directory, checking default and former locations
24
+ def set_sdk_dir
25
+ sdk_dir = Products::Transferd.sdk_directory rescue nil
26
+ if sdk_dir.nil?
27
+ @sdk_default_location = true
28
+ Log.log.debug('SDK folder is not set, checking default')
29
+ sdk_dir = self.class.default_app_main_folder(app_name: TRANSFERD_APP_NAME)
30
+ Log.log.debug{"Checking: #{sdk_dir}"}
31
+ if !Dir.exist?(sdk_dir)
32
+ Log.log.debug{"No such folder: #{sdk_dir}"}
33
+ former_sdk_folder = File.join(self.class.default_app_main_folder(app_name: Info::CMD_NAME), TRANSFERD_APP_NAME)
34
+ Log.log.debug{"Checking: #{former_sdk_folder}"}
35
+ sdk_dir = former_sdk_folder if Dir.exist?(former_sdk_folder)
36
+ end
37
+ Log.log.debug{"Using: #{sdk_dir}"}
38
+ Products::Transferd.sdk_directory = sdk_dir
39
+ end
40
+ end
41
+
42
+ # Install the transfer SDK (ascp + transferd) from a URL or using the default source.
43
+ # Version defaults to +Info::SDK_VERSION+; pass +LATEST+ as argument to install the latest available version.
44
+ # @param version [String, nil] version to install; nil means use the default SDK version
45
+ # @return [Result::Status] installation result message
46
+ def install_transfer_sdk(version: nil)
47
+ asked_version = version.nil? ? Info::SDK_VERSION : version
48
+ asked_version = nil if asked_version.eql?(SpecialValues::LATEST)
49
+ sdk_url = options.get_option(:sdk_url, mandatory: true)
50
+ sdk_url = nil if sdk_url.eql?(SpecialValues::DEF)
51
+ name, ver, folder = Ascp::Installation.instance.install_sdk(url: sdk_url, version: asked_version)
52
+ return Result::Status.new("Installed #{name} version #{ver} in #{folder}")
53
+ end
54
+
55
+ def action_ascp_show(**)
56
+ Result::Text.new(Ascp::Installation.instance.path(:ascp))
57
+ end
58
+
59
+ def action_ascp_info(**)
60
+ data = Ascp::Installation.instance.ascp_info
61
+ data['ts'] = transfer.user_transfer_spec
62
+ DataRepository::ELEMENTS.each_with_object(data){ |i, h| h[i.to_s] = DataRepository.instance.item(i)}
63
+ SecretHider::ADDITIONAL_KEYS_TO_HIDE.concat(DataRepository::ELEMENTS.map(&:to_s))
64
+ Result::SingleObject.new(data)
65
+ end
66
+
67
+ def action_ascp_install(version: nil, **)
68
+ install_transfer_sdk(version: version)
69
+ end
70
+
71
+ def action_ascp_spec(**)
72
+ builder = Schema::Documentation.new(TerminalFormatter, Transfer::Spec::SCHEMA, include_option: true, agent_columns: true).build
73
+ Result::ObjectList.new(builder.rows, fields: builder.columns)
74
+ end
75
+
76
+ def action_ascp_schema(agent_name: nil, **)
77
+ schema = Transfer::Spec::SCHEMA.current.merge({'$comment'=>'DO NOT EDIT, this file was generated from the YAML.'})
78
+ schema['properties'] = schema['properties'].select{ |_k, v| CommandLineBuilder.supported_by_agent(agent_name, v)} unless agent_name.nil?
79
+ schema['properties'] = schema['properties'].sort.to_h
80
+ Result::SingleObject.new(schema)
81
+ end
82
+
83
+ def action_ascp_errors(**)
84
+ error_data = []
85
+ Ascp::Management::ERRORS.each_pair do |code, prop|
86
+ error_data.push(code: code, mnemonic: prop[:c], retry: prop[:r], info: prop[:a])
87
+ end
88
+ Result::ObjectList.new(error_data)
89
+ end
90
+
91
+ def action_ascp_products_list(**)
92
+ Result::ObjectList.new(Ascp::Installation.instance.installed_products, fields: %w[name app_root])
93
+ end
94
+
95
+ def action_agents_list(**)
96
+ rows = Agent::Factory::ALL.map do |sym, names|
97
+ schema_key = AGENT_SCHEMA_KEY[sym]
98
+ param_names =
99
+ if schema_key
100
+ Schema::Registry.instance.reader(schema_key).current['properties']&.keys&.sort&.join(', ') || ''
101
+ else
102
+ ''
103
+ end
104
+ {
105
+ 'name' => sym.to_s,
106
+ 'short' => names[:short].to_s,
107
+ 'parameters' => param_names
108
+ }
109
+ end.sort_by{ |r| r['name']}
110
+ Result::ObjectList.new(rows, fields: %w[name short parameters])
111
+ end
112
+
113
+ def action_agents_show(agent_name:, **)
114
+ names = Agent::Factory::ALL[agent_name]
115
+ schema_key = AGENT_SCHEMA_KEY[agent_name]
116
+ agent_info = {
117
+ 'name' => agent_name.to_s,
118
+ 'short' => names[:short].to_s,
119
+ 'description' => schema_key ? Schema::Registry.instance.reader(schema_key).current['description'].to_s : '(no configurable parameters)'
120
+ }
121
+ if schema_key
122
+ properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
123
+ rows = properties.map do |pname, pdef|
124
+ row = {'parameter' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
125
+ row['required'] = (Schema::Registry.instance.reader(schema_key).current['required'] || []).include?(pname) ? 'yes' : 'no'
126
+ row['default'] = pdef.key?('default') ? pdef['default'].inspect : ''
127
+ row['enum'] = pdef.key?('enum') ? pdef['enum'].join(', ') : ''
128
+ row
129
+ end
130
+ return Result::ObjectList.new(rows, fields: %w[parameter required type default enum description])
131
+ end
132
+ Result::SingleObject.new(agent_info)
133
+ end
134
+
135
+ def action_agents_parameters(agent_name:, **)
136
+ schema_key = AGENT_SCHEMA_KEY[agent_name]
137
+ return Result::Nothing.new if schema_key.nil?
138
+ properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
139
+ rows = properties.map do |pname, pdef|
140
+ {'name' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
141
+ end
142
+ Result::ObjectList.new(rows, fields: %w[name type description])
143
+ end
144
+
145
+ def action_transferd_install(**)
146
+ install_transfer_sdk
147
+ end
148
+
149
+ def action_transferd_list(**)
150
+ sdk_list = Ascp::Installation.instance.sdk_locations
151
+ Result::ObjectList.new(sdk_list, fields: sdk_list.first.keys - ['url'])
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,81 @@
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
+ # @param persistency [PersistencyFolder]
31
+ def initialize(persistency)
32
+ @persistency = persistency
33
+ end
34
+
35
+ # Persist (create or update) an async transfer entry.
36
+ # @param job_id [String] the ascli-generated UUID
37
+ # @param data [Hash] fields to store (will be JSON-serialised)
38
+ def write(job_id, data)
39
+ Aspera.assert_type(job_id, String){'job_id'}
40
+ Aspera.assert_type(data, Hash){'data'}
41
+ @persistency.put(store_key(job_id), JSON.generate(data))
42
+ nil
43
+ end
44
+
45
+ # Read one entry.
46
+ # @param job_id [String]
47
+ # @return [Hash, nil] the stored data, or nil if not found
48
+ def read(job_id)
49
+ Aspera.assert_type(job_id, String){'job_id'}
50
+ raw = @persistency.get(store_key(job_id))
51
+ return if raw.nil?
52
+ JSON.parse(raw)
53
+ end
54
+
55
+ # List all async transfer entries.
56
+ # @return [Array<Hash>] each entry includes the +job_id+ field
57
+ def list
58
+ @persistency.current_items(CATEGORY).map do |key, raw|
59
+ data = JSON.parse(raw)
60
+ data['job_id'] ||= key.sub(CATEGORY, '')
61
+ data
62
+ end
63
+ end
64
+
65
+ # Delete one entry.
66
+ # @param job_id [String]
67
+ def delete(job_id)
68
+ Aspera.assert_type(job_id, String){'job_id'}
69
+ @persistency.delete(store_key(job_id))
70
+ nil
71
+ end
72
+
73
+ private
74
+
75
+ # Map a job_id to the PersistencyFolder key (includes category prefix).
76
+ def store_key(job_id)
77
+ "#{CATEGORY}#{job_id}"
78
+ end
79
+ end
80
+ end
81
+ 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: Allowed::TYPES_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 || spec.entity_execute # 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