aspera-cli 4.27.2 → 4.27.4

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 +133 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +3 -1
  6. data/docs/README.md +1006 -766
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +6 -8
  9. data/lib/aspera/agent/desktop.rb +2 -6
  10. data/lib/aspera/agent/direct.rb +52 -22
  11. data/lib/aspera/agent/node.rb +9 -8
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/alee.rb +1 -1
  14. data/lib/aspera/api/aoc.rb +14 -12
  15. data/lib/aspera/api/ats.rb +1 -1
  16. data/lib/aspera/api/cos_node.rb +2 -2
  17. data/lib/aspera/api/faspex.rb +11 -7
  18. data/lib/aspera/api/httpgw.rb +38 -35
  19. data/lib/aspera/api/node.rb +39 -34
  20. data/lib/aspera/ascmd.rb +3 -1
  21. data/lib/aspera/ascp/installation.rb +63 -28
  22. data/lib/aspera/ascp/management.rb +1 -0
  23. data/lib/aspera/assert.rb +4 -0
  24. data/lib/aspera/cli/ascp_actions.rb +20 -41
  25. data/lib/aspera/cli/async_transfer_store.rb +12 -11
  26. data/lib/aspera/cli/bootstrapper.rb +14 -16
  27. data/lib/aspera/cli/command_line.rb +252 -0
  28. data/lib/aspera/cli/command_registry.rb +215 -37
  29. data/lib/aspera/cli/command_spec.rb +104 -15
  30. data/lib/aspera/cli/completion/ascli.bash +12 -0
  31. data/lib/aspera/cli/completion/ascli.fish +16 -0
  32. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  33. data/lib/aspera/cli/context.rb +3 -0
  34. data/lib/aspera/cli/deprecation.rb +37 -0
  35. data/lib/aspera/cli/extended_value.rb +6 -3
  36. data/lib/aspera/cli/formatter.rb +94 -80
  37. data/lib/aspera/cli/gem_checker.rb +1 -1
  38. data/lib/aspera/cli/hints.rb +7 -6
  39. data/lib/aspera/cli/http.rb +22 -34
  40. data/lib/aspera/cli/info.rb +3 -0
  41. data/lib/aspera/cli/mcp_tool.rb +47 -83
  42. data/lib/aspera/cli/option_declarator.rb +33 -42
  43. data/lib/aspera/cli/option_registry.rb +69 -0
  44. data/lib/aspera/cli/option_types.rb +105 -0
  45. data/lib/aspera/cli/option_value.rb +278 -0
  46. data/lib/aspera/cli/options.schema.yaml +124 -15
  47. data/lib/aspera/cli/parser.rb +333 -862
  48. data/lib/aspera/cli/plugins/alee.rb +7 -4
  49. data/lib/aspera/cli/plugins/aoc.rb +545 -518
  50. data/lib/aspera/cli/plugins/ats.rb +59 -80
  51. data/lib/aspera/cli/plugins/base.rb +221 -265
  52. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  53. data/lib/aspera/cli/plugins/config.rb +263 -184
  54. data/lib/aspera/cli/plugins/console.rb +103 -39
  55. data/lib/aspera/cli/plugins/cos.rb +6 -23
  56. data/lib/aspera/cli/plugins/factory.rb +3 -0
  57. data/lib/aspera/cli/plugins/faspex5.rb +204 -182
  58. data/lib/aspera/cli/plugins/faspio.rb +6 -11
  59. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  60. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  61. data/lib/aspera/cli/plugins/node.rb +300 -327
  62. data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
  63. data/lib/aspera/cli/plugins/preview.rb +96 -105
  64. data/lib/aspera/cli/plugins/server.rb +78 -53
  65. data/lib/aspera/cli/plugins/shares.rb +80 -131
  66. data/lib/aspera/cli/preset_actions.rb +44 -27
  67. data/lib/aspera/cli/preset_manager.rb +44 -19
  68. data/lib/aspera/cli/prompt.rb +36 -0
  69. data/lib/aspera/cli/result.rb +42 -36
  70. data/lib/aspera/cli/runner.rb +32 -59
  71. data/lib/aspera/cli/special_values.rb +5 -0
  72. data/lib/aspera/cli/sync_actions.rb +51 -46
  73. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  74. data/lib/aspera/cli/transfer_actions.rb +14 -9
  75. data/lib/aspera/cli/transfer_agent.rb +34 -38
  76. data/lib/aspera/cli/transfer_progress.rb +290 -55
  77. data/lib/aspera/cli/vault_manager.rb +0 -17
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +5 -3
  80. data/lib/aspera/coverage.rb +1 -1
  81. data/lib/aspera/environment.rb +35 -5
  82. data/lib/aspera/faspex_gw.rb +2 -1
  83. data/lib/aspera/faspex_postproc.rb +1 -0
  84. data/lib/aspera/graphql.rb +5 -5
  85. data/lib/aspera/json_rpc/client.rb +5 -5
  86. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  87. data/lib/aspera/keychain/factory.rb +2 -1
  88. data/lib/aspera/keychain/one_password_api.rb +1 -1
  89. data/lib/aspera/link_header.rb +2 -2
  90. data/lib/aspera/log.rb +47 -27
  91. data/lib/aspera/markdown.rb +2 -0
  92. data/lib/aspera/mime.rb +25 -0
  93. data/lib/aspera/node_emulator.rb +759 -0
  94. data/lib/aspera/oauth/base.rb +37 -26
  95. data/lib/aspera/oauth/factory.rb +7 -3
  96. data/lib/aspera/oauth/generic.rb +1 -1
  97. data/lib/aspera/oauth/json_credentials.rb +34 -0
  98. data/lib/aspera/oauth/jwt.rb +4 -5
  99. data/lib/aspera/oauth/web.rb +9 -8
  100. data/lib/aspera/oauth.rb +1 -0
  101. data/lib/aspera/persistency_folder.rb +1 -3
  102. data/lib/aspera/preview/file_types.rb +4 -4
  103. data/lib/aspera/preview/generator.rb +11 -1
  104. data/lib/aspera/preview/options.schema.yaml +119 -0
  105. data/lib/aspera/preview/terminal.rb +4 -3
  106. data/lib/aspera/preview/utils.rb +9 -6
  107. data/lib/aspera/products/connect.rb +1 -1
  108. data/lib/aspera/rainbow.rb +7 -0
  109. data/lib/aspera/rest/aspera_errors.rb +72 -0
  110. data/lib/aspera/rest/call_error.rb +27 -0
  111. data/lib/aspera/rest/client.rb +523 -0
  112. data/lib/aspera/rest/error_analyzer.rb +113 -0
  113. data/lib/aspera/rest/list.rb +149 -0
  114. data/lib/aspera/rest/parameters.rb +55 -0
  115. data/lib/aspera/rest/util.rb +176 -0
  116. data/lib/aspera/rest.rb +7 -621
  117. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  118. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  119. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  120. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  121. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  122. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  123. data/lib/aspera/schema/documentation.rb +13 -3
  124. data/lib/aspera/schema/reader.rb +12 -18
  125. data/lib/aspera/schema/registry.rb +23 -1
  126. data/lib/aspera/schema/validator.rb +92 -0
  127. data/lib/aspera/secret_hider.rb +36 -25
  128. data/lib/aspera/string_ext.rb +15 -0
  129. data/lib/aspera/temp_file_manager.rb +6 -5
  130. data/lib/aspera/transfer/parameters.rb +2 -0
  131. data/lib/aspera/transfer/spec.rb +1 -0
  132. data/lib/aspera/uri_reader.rb +11 -11
  133. data/lib/aspera/web_auth/index.html +147 -0
  134. data/lib/aspera/web_auth/server.rb +81 -0
  135. data.tar.gz.sig +0 -0
  136. metadata +43 -9
  137. metadata.gz.sig +0 -0
  138. data/lib/aspera/colors.rb +0 -79
  139. data/lib/aspera/node_simulator.rb +0 -344
  140. data/lib/aspera/preview/options.rb +0 -45
  141. data/lib/aspera/rest_call_error.rb +0 -25
  142. data/lib/aspera/rest_error_analyzer.rb +0 -111
  143. data/lib/aspera/rest_errors_aspera.rb +0 -58
  144. data/lib/aspera/rest_list.rb +0 -136
  145. data/lib/aspera/web_auth.rb +0 -211
@@ -8,6 +8,7 @@ require 'aspera/cli/extended_value'
8
8
  require 'aspera/cli/special_values'
9
9
  require 'aspera/cli/version'
10
10
  require 'aspera/cli/formatter'
11
+ require 'aspera/cli/http'
11
12
  require 'aspera/cli/info'
12
13
  require 'aspera/cli/wizard'
13
14
  require 'aspera/cli/sync_actions'
@@ -30,6 +31,7 @@ require 'aspera/rest'
30
31
  require 'aspera/oauth/jwt'
31
32
  require 'aspera/log'
32
33
  require 'aspera/assert'
34
+ require 'aspera/string_ext'
33
35
  require 'openssl'
34
36
  require 'digest'
35
37
  require 'open3'
@@ -81,66 +83,66 @@ module Aspera
81
83
  end
82
84
 
83
85
  DEFAULT_CHECK_NEW_VERSION_DAYS = 7
84
- private_constant :DEFAULT_CHECK_NEW_VERSION_DAYS
86
+ # Shells with a completion script in COMPLETION_FOLDER
87
+ COMPLETION_SHELLS = %i[bash zsh fish].freeze
88
+ COMPLETION_FOLDER = File.expand_path('../completion', __dir__)
89
+ private_constant :DEFAULT_CHECK_NEW_VERSION_DAYS, :COMPLETION_SHELLS, :COMPLETION_FOLDER
85
90
 
86
- option :preset, description: 'Load the named option preset from current config file', short: 'P', handler: :option_preset
91
+ option :preset, description: 'Load the named option preset from current config file', short: 'P', on_set: :load_preset
87
92
  option :version_check_days, description: 'Period in days to check new version (zero to disable)', allowed: Type::INTEGER, default: DEFAULT_CHECK_NEW_VERSION_DAYS
88
- option :plugin_folder, description: 'Folder where to find additional plugins', handler: :option_plugin_folder
93
+ option :plugin_folder, description: 'Folder where to find additional plugins', on_set: :add_plugin_folders
89
94
  option :sdk_url, description: 'Ascp: URL to get Aspera Transfer Executables', default: SpecialValues::DEF
90
- option :locations_url, description: 'Ascp: URL to get download locations of Aspera Transfer Daemon', handler: {o: Ascp::Installation.instance, m: :transferd_urls}
91
- option :sdk_folder, description: 'Ascp: Path to folder with ascp (or product with "product:")', handler: {o: Products::Transferd, m: :sdk_directory}
95
+ option :locations_url, description: 'Ascp: URL to get download locations of Aspera Transfer Daemon', default: Ascp::Installation.instance.transferd_urls, on_set: Ascp::Installation.instance.method(:transferd_urls=)
96
+ option :sdk_folder, description: 'Ascp: Path to folder with ascp (or product with "product:")', on_set: Ascp::Installation.instance.method(:sdk_folder=)
92
97
  option :smtp, schema: Schema::Registry::SMTP_OPTIONS
93
98
  option :notify_to, description: 'Email: Recipient for notification of transfers'
94
99
  option :notify_template, description: 'Email: ERB template for notification of transfers'
95
- option :cache_tokens, description: 'Save and reuse OAuth tokens', allowed: Type::BOOLEAN, default: true, handler: :option_cache_tokens
100
+ option :cache_tokens, description: 'Save and reuse OAuth tokens', allowed: Type::BOOLEAN, default: true
101
+ option :expand_mounts, description: 'Commands: list commands of sub-trees provided by another plugin', allowed: Type::BOOLEAN, default: false
102
+ option :no_default, description: 'Do not load default configuration for plugin', allowed: Type::NONE, short: 'N', on_set: -> { presets.use_plugin_defaults = false }
103
+ use_options Wizard
104
+ # `on_set` callbacks of HTTP options are methods of `http_config`
105
+ use_options Http, target: -> { http_config }
96
106
 
97
107
  def initialize(**_)
98
108
  super
99
109
  @vault_instance = nil
100
110
  @sdk_default_location = false
101
- @option_cache_tokens = true
102
- # :no_default uses a &block callback - must stay imperative
103
- options.declare(:no_default, description: 'Do not load default configuration for plugin', allowed: Type::NONE, short: 'N') { presets.use_plugin_defaults = false }
104
- # Declare wizard options (Wizard#initialize calls options.declare internally)
105
111
  @wizard = Wizard.new(self, context.main_folder)
112
+ # Values set through `on_set` callbacks are used below (sdk_folder)
106
113
  options.parse_options!
107
114
  set_sdk_dir
108
- # HTTP options: declare metadata (class method), then bind to the instance
109
- Http.declare_options(options)
110
- context.http_config.bind_options(options)
111
- options.parse_options!
112
115
  end
113
116
 
114
- # DSL command declarations - replaces ACTIONS + execute_action
115
117
  command :preset, description: 'Manage configuration presets'
116
118
  commands_under :preset do
117
- command :list, description: 'List all presets'
118
- command :overview, description: 'Display all options from all presets'
119
+ command :list, description: 'List all presets', action: ->(**) { Result::ValueList.new(presets.config_presets.keys, name: 'name') }
120
+ command :overview, description: 'Show all options from all presets'
119
121
  command :lookup, description: 'Find preset matching URL and username'
120
122
  command :secure, description: 'Move secrets to vault',
121
- arguments: [{name: :config_name, type: String, mandatory: false}]
122
- command :show, description: 'Display a preset',
123
+ arguments: [{name: :config_name, mandatory: false}]
124
+ command :show, description: 'Show a preset',
123
125
  arguments: [{name: :name, type: :identifier}]
124
126
  command :delete, description: 'Delete a preset',
125
127
  arguments: [{name: :name, type: :identifier}]
126
- command :get, description: 'Get a single parameter from a preset',
127
- arguments: [{name: :name, type: :identifier}, {name: :param_name, type: String}]
128
+ command :get, description: 'Show a single parameter of a preset',
129
+ arguments: [{name: :name, type: :identifier}, {name: :param_name}]
128
130
  command :unset, description: 'Remove a parameter from a preset',
129
- arguments: [{name: :name, type: :identifier}, {name: :param_name, type: String}]
131
+ arguments: [{name: :name, type: :identifier}, {name: :param_name}]
130
132
  command :set, description: 'Set a parameter in a preset',
131
- arguments: [{name: :name, type: :identifier}, {name: :param_name, type: String},
133
+ arguments: [{name: :name, type: :identifier}, {name: :param_name},
132
134
  {name: :param_value, type: nil}]
133
135
  command :initialize, description: 'Initialize a preset with a value',
134
- arguments: [{name: :name, type: :identifier}, {name: :config_value, type: Hash}]
136
+ arguments: [{name: :name, type: :identifier}, {name: :preset, type: Hash}]
135
137
  command :update, description: 'Update a preset with current option values',
136
138
  arguments: [{name: :name, type: :identifier}]
137
- command :ask, description: 'Interactively ask for option values',
139
+ command :ask, description: 'Ask for option values interactively',
138
140
  arguments: [{name: :name, type: :identifier},
139
- {name: :option_names, type: String, multiple: true, interactive: true}]
141
+ {name: :option_names, multiple: true, interactive: true}]
140
142
  end
141
143
  command(
142
144
  :open, description: 'Open the configuration file in the default editor',
143
- action: lambda do
145
+ action: lambda do |**|
144
146
  Environment.instance.open_editor(context.presets.config_file.to_s)
145
147
  Result::Nothing.new
146
148
  end
@@ -148,106 +150,163 @@ module Aspera
148
150
  command :documentation, description: 'Open the documentation in the default browser',
149
151
  arguments: [
150
152
  {name: :location, type: Symbol, mandatory: false, default: :github, allowed: %i[github local toc]},
151
- {name: :section, type: String, mandatory: false}
153
+ {name: :section, mandatory: false}
152
154
  ]
153
- command :genkey, description: 'Generate a new RSA private key',
155
+ command(
156
+ :genkey, description: 'Generate a new RSA private key',
154
157
  arguments: [
155
- {name: :private_key_path, type: String},
158
+ {name: :private_key_path},
156
159
  {name: :private_key_length, type: Integer, mandatory: false, default: OAuth::Jwt::DEFAULT_PRIV_KEY_LENGTH}
157
- ]
158
- command :pubkey, description: 'Display the public key of an RSA private key',
159
- arguments: [{name: :private_key_pem, type: String}],
160
+ ],
161
+ action: lambda do |private_key_path:, private_key_length: OAuth::Jwt::DEFAULT_PRIV_KEY_LENGTH, **|
162
+ OAuth::Jwt.generate_rsa_private_key(path: private_key_path, length: private_key_length)
163
+ Result::Status.new("Generated #{private_key_length} bit RSA key: #{private_key_path}")
164
+ end
165
+ )
166
+ command :pubkey, description: 'Show the public key of an RSA private key',
167
+ arguments: [{name: :private_key_pem}],
160
168
  action: ->(private_key_pem:, **) { Result::Text.new(OpenSSL::PKey::RSA.new(private_key_pem).public_key.to_s) }
161
169
  command :remote_certificate, description: 'Retrieve the certificate chain of a remote HTTPS server'
162
- command :echo, description: 'Display the value of a given argument',
170
+ command :echo, description: 'Show the value of a given argument',
163
171
  arguments: [{name: :value, type: nil}],
164
172
  action: ->(value:, **) { Result.auto(value) }
165
173
  command :download, description: 'Download a file from a URL',
166
174
  arguments: [
167
- {name: :file_url, type: String},
168
- {name: :file_dest, type: String, mandatory: false}
175
+ {name: :file_url},
176
+ {name: :file_dest, mandatory: false}
169
177
  ]
170
178
  command :tokens, description: 'Manage OAuth tokens'
171
179
  command :plugins, description: 'Manage CLI plugins'
172
180
  command :detect, description: 'Detect the Aspera product from a URL (interactive)',
173
- arguments: [{name: :url, type: String}, {name: :plugin_name, mandatory: false, default: nil}]
174
- command :wizard, description: 'Run the setup wizard for an Aspera product (interactive)',
175
- arguments: [{name: :url, type: String}, {name: :plugin_name, mandatory: false, default: nil},
176
- {name: :preset_name, mandatory: false, default: ''}]
177
- command :coffee, description: 'Display a coffee image', action: -> { Result::Image.new(COFFEE_IMAGE_URL) }
178
- command :image, description: 'Display an image',
181
+ arguments: [{name: :url, interactive: true}, {name: :plugin_name, mandatory: false, default: nil}],
182
+ action: ->(url:, plugin_name: nil, **) { Result::ObjectList.new(@wizard.identify_plugins_for_url(url: url, plugin_name: plugin_name).freeze) }
183
+ command(
184
+ :wizard, description: 'Run the setup wizard for an Aspera product (interactive)',
185
+ arguments: [{name: :url, interactive: true}, {name: :plugin_name, mandatory: false, default: nil},
186
+ {name: :preset_name, mandatory: false, default: ''}],
187
+ action: lambda do |url:, plugin_name: nil, preset_name: '', **|
188
+ apps = @wizard.identify_plugins_for_url(url: url, plugin_name: plugin_name).freeze
189
+ # The product wizard prompts for missing mandatory options (username, password, ...)
190
+ options.with_interactive { @wizard.find(apps, preset_name: preset_name) }
191
+ end
192
+ )
193
+ command :coffee, description: 'Show a coffee image', action: ->(**) { Result::Image.new(COFFEE_IMAGE_URL) }
194
+ command :image, description: 'Show an image',
179
195
  arguments: [{name: :image_uri, type: nil}],
180
196
  action: ->(image_uri:, **) { Result::Image.new(image_uri) }
181
197
  command :sync, description: 'Manage Aspera Sync operations'
182
- command :gem, description: 'Display gem information'
183
- command :folder, description: 'Display the configuration folder path', action: -> { Result::Text.new(context.main_folder) }
184
- command :file, description: 'Display the configuration file path', action: -> { Result::Text.new(context.presets.config_file) }
198
+ command :gem, description: 'Show gem information'
199
+ command :folder, description: 'Show the configuration folder path', action: ->(**) { Result::Text.new(context.main_folder) }
200
+ command :file, description: 'Show the configuration file path', action: ->(**) { Result::Text.new(context.presets.config_file) }
185
201
  command(
186
202
  :email_test, description: 'Send a test email',
187
- action: lambda do
203
+ action: lambda do |**|
188
204
  context.mailer.send_email_template(email_template_default: EMAIL_TEST_TEMPLATE)
189
205
  Result::Nothing.new
190
206
  end
191
207
  )
192
- command :smtp_settings, description: 'Display the current SMTP settings', action: -> { Result::SingleObject.new(context.mailer.email_settings) }
208
+ command :smtp_settings, description: 'Show the current SMTP settings', action: ->(**) { Result::SingleObject.new(context.mailer.email_settings) }
193
209
  command(
194
210
  :proxy_check, description: 'Check the proxy returned by the PAC script for a given URL',
195
- arguments: [{name: :server_url, type: String}],
211
+ arguments: [{name: :server_url}],
196
212
  action: lambda do |server_url:, **|
197
213
  Aspera.assert(!context.pac_executor.nil?, type: Cli::BadArgument) { 'No PAC script configured, use --fpac' }
198
214
  Result::ValueList.new(context.pac_executor.get_proxies(server_url), name: 'proxy')
199
215
  end
200
216
  )
201
- command :check_update, description: 'Check if a newer version of the gem is available', action: -> { Result::SingleObject.new(check_gem_version) }
217
+ command :check_update, description: 'Check if a newer version of the gem is available', action: ->(**) { Result::SingleObject.new(check_gem_version) }
202
218
  command :initdemo, description: 'Initialize the demo server preset'
203
219
  command :vault, description: 'Manage secrets in the vault'
204
220
  commands_under :vault do
205
- command :info, description: 'Display vault information',
221
+ command :info, description: 'Show vault information',
206
222
  action: ->(**) { Result::SingleObject.new(vault_required.info) }
207
223
  command :ids, description: 'List secret labels in the vault',
208
224
  action: ->(**) { Result::ObjectList.new(vault_required.ids) }
209
225
  command :list, description: 'List all secrets with full details',
210
226
  action: ->(**) { Result::ObjectList.new(vault_required.all) }
211
- command :show, description: 'Show a secret by label (or id)',
212
- arguments: [{name: :label, type: String}, {name: :id, type: String, mandatory: false, default: nil}]
213
- command :create, description: 'Add a new secret to the vault',
214
- arguments: [{name: :info, type: Hash, schema: Schema::Registry::VAULT_SECRET}]
215
- command :delete, description: 'Delete a secret by label (or id)',
216
- arguments: [{name: :label, type: String}, {name: :id, type: String, mandatory: false, default: nil}]
217
- command :password, description: 'Change the vault password',
218
- arguments: [{name: :new_password, type: String}]
219
- command :import, description: 'Import secrets from a JSON array (supports --bulk)',
227
+ command(
228
+ :show, description: 'Show a secret by label (or id)',
229
+ arguments: [{name: :label}, {name: :id, mandatory: false, default: nil}],
230
+ action: lambda do |label:, id: nil, **|
231
+ v = vault_required
232
+ kwargs = id && v.method(:get).parameters.any? { |_t, n| n == :id } ? {id: id} : {}
233
+ Result::SingleObject.new(v.get(label: label, **kwargs))
234
+ end
235
+ )
236
+ command(
237
+ :create, description: 'Add a new secret to the vault',
238
+ arguments: [{name: :secret, type: Hash, schema: Schema::Registry::VAULT_SECRET}],
239
+ action: lambda do |secret:, **|
240
+ vault_required.set(secret.symbolize_keys)
241
+ Result::Status.new('Secret added')
242
+ end
243
+ )
244
+ command :delete, description: 'Delete a secret by label (or id)',
245
+ arguments: [{name: :label}, {name: :id, mandatory: false, default: nil}]
246
+ command(
247
+ :password, description: 'Change the vault password',
248
+ arguments: [{name: :new_password}],
249
+ action: lambda do |new_password:, **|
250
+ Aspera.assert(vault_required.respond_to?(:change_password), 'Vault does not support password change')
251
+ vault_required.change_password(new_password)
252
+ Result::Status.new('Vault password updated')
253
+ end
254
+ )
255
+ command :import, description: 'Import secrets from a JSON array (supports --bulk)',
220
256
  arguments: [{name: :secrets, type: Array, schema: {type: 'array', items: {'$ref' => Schema::Registry::VAULT_SECRET}}}]
221
257
  end
222
- command :commands, description: 'List all available commands across all plugins'
258
+ command :commands, description: 'List all available commands, of all plugins or only the given one, optionally under a command path',
259
+ arguments: [{name: :plugin_name, mandatory: false, default: nil},
260
+ {name: :command_path, multiple: true, mandatory: false, default: nil}]
223
261
  command :options, description: 'List all options available for a plugin',
224
- arguments: [{name: :plugin_name, type: String}]
262
+ arguments: [{name: :plugin_name}]
225
263
  command :test, description: 'Internal test commands'
226
- command :platform, description: 'Display the current platform/architecture', action: -> { Result::Text.new(Environment.instance.architecture) }
227
- command :completion, description: 'Generate shell completion scripts'
264
+ command :platform, description: 'Show the current platform/architecture', action: ->(**) { Result::Text.new(Environment.instance.architecture) }
265
+ command :completion, description: 'Shell completion: activation scripts and next words'
228
266
 
229
267
  # remote_certificate sub-commands
230
268
  commands_under :remote_certificate do
231
- command :chain, description: 'Display the full certificate chain as PEM',
232
- arguments: [{name: :remote_url, type: String}]
233
- command :only, description: 'Display only the server certificate as PEM',
234
- arguments: [{name: :remote_url, type: String}]
235
- command :name, description: 'Display the CN of the server certificate',
236
- arguments: [{name: :remote_url, type: String}]
269
+ command(
270
+ :chain, description: 'Show the full certificate chain as PEM',
271
+ arguments: [{name: :remote_url}],
272
+ action: lambda do |remote_url:, **|
273
+ remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
274
+ Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
275
+ Result::Text.new(remote_chain.map(&:to_pem).join("\n"))
276
+ end
277
+ )
278
+ command(
279
+ :only, description: 'Show only the server certificate as PEM',
280
+ arguments: [{name: :remote_url}],
281
+ action: lambda do |remote_url:, **|
282
+ remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
283
+ Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
284
+ Result::Text.new(remote_chain.first.to_pem)
285
+ end
286
+ )
287
+ command(
288
+ :name, description: 'Show the CN of the server certificate',
289
+ arguments: [{name: :remote_url}],
290
+ action: lambda do |remote_url:, **|
291
+ remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
292
+ Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
293
+ Result::Text.new(remote_chain.first.subject.to_a.find { |name, _, _| name == 'CN' }[1])
294
+ end
295
+ )
237
296
  end
238
297
 
239
298
  # tokens sub-commands
240
299
  commands_under :tokens do
241
300
  command(
242
301
  :flush, description: 'Delete all cached OAuth tokens',
243
- action: lambda do
302
+ action: lambda do |**|
244
303
  require 'aspera/api/node'
245
304
  Result::ValueList.new(OAuth::Factory.instance.flush_tokens, name: 'file')
246
305
  end
247
306
  )
248
307
  command(
249
308
  :list, description: 'List all cached OAuth tokens',
250
- action: lambda do
309
+ action: lambda do |**|
251
310
  require 'aspera/api/node'
252
311
  Result::ObjectList.new(OAuth::Factory.instance.persisted_tokens)
253
312
  end
@@ -261,25 +320,33 @@ module Aspera
261
320
  command :list, description: 'List all available plugins'
262
321
  command :create, description: 'Create a new plugin skeleton file',
263
322
  arguments: [
264
- {name: :name, type: String},
265
- {name: :folder, type: String, mandatory: false}
323
+ {name: :name},
324
+ {name: :folder, mandatory: false}
266
325
  ]
267
326
  end
268
327
 
269
328
  # ascp sub-commands
270
329
  command :ascp, description: 'Manage FASP/ascp transfer engine'
271
330
  commands_under :ascp do
272
- command :show, description: 'Display ascp binary path'
273
- command :info, description: 'Display ascp and transfer spec information'
331
+ command :show, description: 'Show ascp binary path', action: ->(**) { Result::Text.new(Ascp::Installation.instance.path(:ascp)) }
332
+ command :info, description: 'Show ascp and transfer spec information'
274
333
  command :install, description: 'Install the transfer SDK',
275
- arguments: [{name: :version, mandatory: false, default: nil}]
276
- command :spec, description: 'Display the transfer spec schema'
277
- command :schema, description: 'Display the transfer spec JSON schema',
278
- arguments: [{name: :agent_name, mandatory: false, default: nil}]
279
- command :errors, description: 'Display FASP error codes'
334
+ arguments: [{name: :version, mandatory: false, default: nil}],
335
+ action: ->(version: nil, **) { install_transfer_sdk(version: version) }
336
+ command(
337
+ :spec, description: 'Show the transfer spec schema',
338
+ action: lambda do |**|
339
+ builder = Schema::Documentation.new(TerminalFormatter, Transfer::Spec::SCHEMA, include_option: true, agent_columns: true).build
340
+ Result::ObjectList.new(builder.rows, fields: builder.columns)
341
+ end
342
+ )
343
+ command :schema, description: 'Show the transfer spec JSON schema',
344
+ arguments: [{name: :agent_name, allowed: Agent::Factory::ALL.keys, mandatory: false, default: nil}]
345
+ command :errors, description: 'Show FASP error codes'
280
346
  command :products, description: 'Manage installed Aspera products'
281
347
  commands_under :products do
282
- command :list, description: 'List installed Aspera products'
348
+ command :list, description: 'List installed Aspera products',
349
+ action: ->(**) { Result::ObjectList.new(Ascp::Installation.instance.installed_products, fields: %w[name app_root]) }
283
350
  end
284
351
  end
285
352
 
@@ -296,75 +363,93 @@ module Aspera
296
363
  # transfer async management sub-commands
297
364
  command :transfer, description: 'Manage asynchronous transfers'
298
365
  commands_under :transfer do
299
- command :list, description: 'List all async transfer jobs'
366
+ command(
367
+ :list, description: 'List all async transfer jobs',
368
+ action: lambda do |**|
369
+ rows = async_transfer_store.list
370
+ Result::ObjectList.new(rows, fields: %w[job_id agent_type status started_at ended_at bytes_transferred transfer_id])
371
+ end
372
+ )
300
373
  command :status, description: 'Show status of an async transfer job',
301
- arguments: [{name: :job_id, type: String}]
374
+ arguments: [{name: :job_id}]
302
375
  command :cleanup, description: 'Remove completed/failed/cancelled transfer entries'
303
376
  end
304
377
 
305
378
  # transferd sub-commands
306
379
  command :transferd, description: 'Manage the transfer daemon (transferd)'
307
380
  commands_under :transferd do
308
- command :install, description: 'Install the transfer daemon'
309
- command :list, description: 'List available SDK locations'
381
+ command :install, description: 'Install the transfer daemon', action: ->(**) { install_transfer_sdk }
382
+ command(
383
+ :list, description: 'List available SDK locations',
384
+ action: lambda do |**|
385
+ sdk_list = Ascp::Installation.instance.sdk_locations
386
+ Result::ObjectList.new(sdk_list, fields: sdk_list.first.keys - ['url'])
387
+ end
388
+ )
310
389
  end
311
390
 
312
391
  # sync sub-commands
313
392
  commands_under :sync do
314
393
  command(
315
- :spec, description: 'Display the sync configuration schema',
316
- action: lambda do
394
+ :spec, description: 'Show the sync configuration schema',
395
+ action: lambda do |**|
317
396
  builder = Schema::Documentation.new(TerminalFormatter, Sync::Operations::CONF_SCHEMA, include_option: true).build
318
397
  Result::ObjectList.new(builder.rows, fields: builder.columns)
319
398
  end
320
399
  )
321
- command :admin, description: 'Manage sync database (admin operations)'
322
- SyncActions.register_sync_admin_commands(self, :admin)
400
+ sync_admin_commands :admin
323
401
  command :translate, description: 'Translate async-style arguments to sync config format',
324
- arguments: [{name: :async_arguments, type: String, multiple: true}],
402
+ arguments: [{name: :async_arguments, multiple: true}],
325
403
  action: ->(async_arguments:, **) { Result::SingleObject.new(Sync::Operations.args_to_conf(async_arguments)) }
326
404
  end
327
405
 
328
406
  # gem sub-commands
329
407
  commands_under :gem do
330
- command :path, description: 'Display the gem source root path', action: -> { Result::Text.new(self.class.gem_src_root) }
331
- command :version, description: 'Display the gem version', action: -> { Result::Text.new(Cli::VERSION) }
332
- command :name, description: 'Display the gem name', action: -> { Result::Text.new(Info::GEM_NAME) }
408
+ command :path, description: 'Show the gem source root path', action: ->(**) { Result::Text.new(self.class.gem_src_root) }
409
+ command :version, description: 'Show the gem version', action: ->(**) { Result::Text.new(Cli::VERSION) }
410
+ command :name, description: 'Show the gem name', action: ->(**) { Result::Text.new(Info::GEM_NAME) }
333
411
  end
334
412
 
335
413
  # test sub-commands
336
414
  commands_under :test do
337
- command :throw, description: 'Raise an exception (for testing)',
415
+ command(
416
+ :throw, description: 'Raise an exception (for testing)',
338
417
  arguments: [
339
- {name: :exception_class_name, type: String},
340
- {name: :exception_text, type: String}
341
- ]
342
- command :web, description: 'Test web browser interaction', action: -> {}
418
+ {name: :exception_class_name},
419
+ {name: :exception_text}
420
+ ],
421
+ action: lambda do |exception_class_name:, exception_text:, **|
422
+ type = Object.const_get(exception_class_name)
423
+ Aspera.assert(type <= Exception) { "#{type} is not an exception: #{type.class}" }
424
+ raise type, exception_text
425
+ end
426
+ )
427
+ command :web, description: 'Test web browser interaction', action: ->(**) {}
343
428
  end
344
429
 
345
430
  # completion sub-commands
346
431
  commands_under :completion do
347
- command :bash, description: 'Generate bash completion script',
348
- arguments: [{name: :words, type: String, multiple: true, mandatory: false}]
432
+ command :words, description: 'List words that can follow the given words (used by completion scripts)',
433
+ arguments: [{name: :words, multiple: true, mandatory: false}]
434
+ COMPLETION_SHELLS.each do |shell|
435
+ command shell, description: "Generate #{shell} completion script, to evaluate in shell",
436
+ action: ->(**) { completion_script(shell) }
437
+ end
349
438
  end
350
439
 
351
- attr_accessor :option_cache_tokens
352
-
353
440
  attr_reader :gem_url
354
441
 
355
- def option_plugin_folder=(value)
442
+ # `on_set` callback of option `plugin_folder`
443
+ # @param value [String, Array<String>] folder(s) where to find plugins
444
+ def add_plugin_folders(value)
356
445
  value = [value] unless value.is_a?(Array)
357
446
  Aspera.assert_array_all(value, String) { 'plugin folder(s)' }
358
447
  value.each { |f| Plugins::Factory.instance.add_lookup_folder(f) }
359
448
  end
360
449
 
361
- def option_plugin_folder
362
- return Plugins::Factory.instance.lookup_folders
363
- end
364
-
365
- def option_preset; 'write-only option'; end
366
-
367
- def option_preset=(value)
450
+ # `on_set` callback of option `preset`
451
+ # @param value [String, Hash] preset name, or set of option values
452
+ def load_preset(value)
368
453
  case value
369
454
  when Hash
370
455
  options.add_option_preset(value, 'set')
@@ -406,34 +491,11 @@ module Aspera
406
491
  end
407
492
  end
408
493
 
409
- def action_genkey(private_key_path:, private_key_length: OAuth::Jwt::DEFAULT_PRIV_KEY_LENGTH, **)
410
- OAuth::Jwt.generate_rsa_private_key(path: private_key_path, length: private_key_length)
411
- Result::Status.new("Generated #{private_key_length} bit RSA key: #{private_key_path}")
412
- end
413
-
414
- def action_remote_certificate_chain(remote_url:, **)
415
- remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
416
- Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
417
- Result::Text.new(remote_chain.map(&:to_pem).join("\n"))
418
- end
419
-
420
- def action_remote_certificate_only(remote_url:, **)
421
- remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
422
- Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
423
- Result::Text.new(remote_chain.first.to_pem)
424
- end
425
-
426
- def action_remote_certificate_name(remote_url:, **)
427
- remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
428
- Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
429
- Result::Text.new(remote_chain.first.subject.to_a.find { |name, _, _| name == 'CN' }[1])
430
- end
431
-
432
494
  def action_download(file_url:, file_dest: nil, **)
433
495
  file_url = file_url.chomp
434
- file_dest = File.join(transfer.destination_folder(Transfer::Spec::DIRECTION_RECEIVE), file_url.gsub(%r{.*/}, '')) if file_dest.nil?
496
+ file_dest = File.join(transfer.destination_folder(Transfer::Spec::DIRECTION_RECEIVE), file_url.rpartition('/').last) if file_dest.nil?
435
497
  Log.log.info("Downloading: #{file_url}")
436
- Rest.new(base_url: file_url).call(operation: 'GET', save_to: file_dest)
498
+ Rest::Client.new(base_url: file_url).call(operation: 'GET', save_to: file_dest)
437
499
  Result::Status.new("Saved to: #{file_dest}")
438
500
  end
439
501
 
@@ -444,7 +506,7 @@ module Aspera
444
506
  Result::SingleObject.new(data)
445
507
  end
446
508
 
447
- def action_plugins_list
509
+ def action_plugins_list(**)
448
510
  result = Plugins::Factory.instance.plugin_list.map do |name|
449
511
  plugin_class = Plugins::Factory.instance.plugin_class(name)
450
512
  {
@@ -467,10 +529,7 @@ module Aspera
467
529
  module Cli
468
530
  module Plugins
469
531
  class #{plugin_name.snake_to_capital} < Base
470
- command :example, description: 'example command', action: :action_example
471
- def action_example
472
- Result::Status.new('You called plugin #{plugin_name}')
473
- end
532
+ command :example, description: 'example command', action: ->(**) { Result::Status.new('You called plugin #{plugin_name}') }
474
533
  end
475
534
  end
476
535
  end
@@ -480,19 +539,7 @@ module Aspera
480
539
  Result::Status.new("Created #{plugin_file}")
481
540
  end
482
541
 
483
- def action_detect(url:, plugin_name: nil, **)
484
- options.ask_missing_mandatory = true
485
- apps = @wizard.identify_plugins_for_url(url: url, plugin_name: plugin_name).freeze
486
- Result::ObjectList.new(apps)
487
- end
488
-
489
- def action_wizard(url:, plugin_name: nil, preset_name: '', **)
490
- options.ask_missing_mandatory = true
491
- apps = @wizard.identify_plugins_for_url(url: url, plugin_name: plugin_name).freeze
492
- @wizard.find(apps, preset_name: preset_name)
493
- end
494
-
495
- def action_initdemo
542
+ def action_initdemo(**)
496
543
  cp = presets.config_presets
497
544
  if cp.key?(DEMO_PRESET)
498
545
  Log.log.warn { "Demo server preset already present: #{DEMO_PRESET}" }
@@ -516,55 +563,79 @@ module Aspera
516
563
  Result::Status.new('Done')
517
564
  end
518
565
 
519
- def action_commands
520
- commands = Plugins::Factory.instance.plugin_list.flat_map do |name|
521
- plugin_class = Plugins::Factory.instance.plugin_class(name)
522
- reg = plugin_class.command_registry
523
- reg.all_paths.reject { |path| reg.children_of(path).any? }.map do |path|
524
- spec = reg[path]
525
- # Build syntax by interleaving each path segment with the arguments declared on that node
526
- tokens = [name.to_s]
527
- path.each_with_index do |seg, i|
528
- tokens << seg.to_s
529
- node_args = reg[path[0, i + 1]]&.arguments.to_a
530
- node_args.each do |a|
531
- token = a.allowed ? a.allowed.join('|') : a.name.to_s
532
- token += '...' if a.multiple
533
- tokens << (a.mandatory ? "<#{token}>" : "[<#{token}>]")
534
- end
566
+ def action_commands(plugin_name: nil, command_path: nil, **)
567
+ plugin_names = Plugins::Factory.instance.plugin_list
568
+ unless plugin_name.nil?
569
+ Aspera.assert_values(plugin_name.to_sym, plugin_names, type: Cli::BadArgument)
570
+ plugin_names = [plugin_name.to_sym]
571
+ end
572
+ words = Array(command_path).map(&:to_sym)
573
+ Aspera.assert(words.empty? || plugin_names.length.eql?(1), type: Cli::BadArgument) { 'command path requires a plugin name' }
574
+ expand_mounts = options.get_option(:expand_mounts)
575
+ commands = plugin_names.flat_map do |name|
576
+ reg = Plugins::Factory.instance.plugin_class(name).command_registry
577
+ prefix = listed_command_path(name, reg, words)
578
+ paths = prefix.empty? || reg.children_of(prefix).any? ? reg.leaf_paths(prefix, expand_mounts: expand_mounts) : [prefix]
579
+ paths.map do |path|
580
+ mount = expand_mounts ? nil : reg.mount_at(path)
581
+ description = reg[path]&.description.to_s
582
+ if mount
583
+ target = mount.plugin.name.split('::').last.downcase
584
+ description = "#{description} (see: #{command_syntax(target, mount.registry, mount.at)})"
535
585
  end
536
- syntax = tokens.join(' ')
537
586
  {
538
- syntax: syntax,
539
- description: spec&.description.to_s
587
+ syntax: command_syntax(name, reg, path) + (mount ? ' <command...>' : ''),
588
+ description: description
540
589
  }
541
590
  end
542
591
  end
543
592
  Result::ObjectList.new(commands, fields: %w[syntax description])
544
593
  end
545
594
 
595
+ # Command path to list, from words that may include positional arguments, as on a command line.
596
+ # e.g. `faspex5 packages receive ALL` lists `faspex5 packages receive`.
597
+ # @param name [Symbol] plugin name
598
+ # @param reg [CommandRegistry] registry of plugin
599
+ # @param words [Array<Symbol>] sub-commands (or aliases), possibly mixed with their arguments
600
+ # @return [Array<Symbol>] command path
601
+ # @raise [Cli::BadArgument] if a word is neither a sub-command nor an expected argument
602
+ def listed_command_path(name, reg, words)
603
+ path, unknown = reg.command_path(words)
604
+ raise Cli::BadArgument, Parser.multi_choice_assert_msg("no such command: #{[name, *path, unknown].join(' ')}", reg.children_of(path).keys) unless unknown.nil?
605
+ path
606
+ end
607
+
608
+ # Build syntax by interleaving each path segment with the arguments declared on that node
609
+ # @param name [String, Symbol] plugin name
610
+ # @param reg [CommandRegistry] registry of plugin
611
+ # @param path [Array<Symbol>] command path
612
+ # @return [String] e.g. `node access_keys do <access_key_id> ls <path>`
613
+ def command_syntax(name, reg, path)
614
+ tokens = [name.to_s]
615
+ path.each_index do |i|
616
+ tokens << path[i].to_s
617
+ reg.arguments_at(path[0, i + 1]).each { |a| tokens << a.syntax }
618
+ end
619
+ tokens.join(' ')
620
+ end
621
+
546
622
  def action_options(plugin_name:, **)
547
623
  # Instantiate the plugin so that it registers all its options in context.options
548
624
  Plugins::Factory.instance.create(plugin_name.to_sym, context: context)
549
625
  rows = context.options.declared_options.map do |sym, opt|
550
626
  row = {
551
- option: "--#{sym.to_s.tr('_', '-')}",
552
- description: opt.description.to_s
627
+ 'option' => "--#{sym.to_s.tr('_', '-')}",
628
+ 'description' => opt.description.to_s
553
629
  }
554
- row[:allowed] = opt.values.join('|') if opt.values&.any?
555
- row[:deprecated] = opt.deprecation if opt.deprecation
630
+ row['allowed'] = opt.allowed_info unless opt.allowed_info.nil?
631
+ row['deprecated'] = opt.deprecation.last if opt.deprecation
632
+ row['replacement'] = opt.deprecation.message if opt.deprecation
556
633
  row
557
634
  end
558
- Result::ObjectList.new(rows, fields: %w[option description allowed deprecated])
559
- end
560
-
561
- def action_test_throw(exception_class_name:, exception_text:, **)
562
- type = Object.const_get(exception_class_name)
563
- Aspera.assert(type <= Exception) { "#{type} is not an exception: #{type.class}" }
564
- raise type, exception_text
635
+ Result::ObjectList.new(rows, fields: %w[option description allowed deprecated replacement])
565
636
  end
566
637
 
567
- def action_completion_bash(words: nil, **)
638
+ def action_completion_words(words: nil, **)
568
639
  if words.nil? || words.empty?
569
640
  # Level 0: propose plugin names
570
641
  Plugins::Factory.instance.plugin_list.each { |p| puts p }
@@ -582,6 +653,14 @@ module Aspera
582
653
  Process.exit(0)
583
654
  end
584
655
 
656
+ # Display the completion script for the given shell.
657
+ # Raw output, not formatted, so that it can be evaluated by the shell.
658
+ # @param shell [Symbol] One of `COMPLETION_SHELLS`
659
+ def completion_script(shell)
660
+ $stdout.write(File.read(File.join(COMPLETION_FOLDER, "#{Info::CMD_NAME}.#{shell}")))
661
+ Process.exit(0)
662
+ end
663
+
585
664
  # Folder in $HOME for application files (~/.aspera)
586
665
  ASPERA_HOME_FOLDER_NAME = '.aspera'
587
666
  # Default config file name