aspera-cli 4.27.1 → 4.27.3

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 (133) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +67 -1
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +805 -747
  6. data/lib/aspera/agent/connect.rb +6 -4
  7. data/lib/aspera/agent/desktop.rb +2 -2
  8. data/lib/aspera/agent/direct.rb +3 -1
  9. data/lib/aspera/agent/node.rb +3 -3
  10. data/lib/aspera/api/alee.rb +1 -1
  11. data/lib/aspera/api/aoc.rb +14 -12
  12. data/lib/aspera/api/ats.rb +1 -1
  13. data/lib/aspera/api/cos_node.rb +2 -2
  14. data/lib/aspera/api/faspex.rb +9 -7
  15. data/lib/aspera/api/httpgw.rb +37 -33
  16. data/lib/aspera/api/node.rb +38 -33
  17. data/lib/aspera/ascmd.rb +3 -1
  18. data/lib/aspera/ascp/installation.rb +62 -27
  19. data/lib/aspera/ascp/management.rb +1 -0
  20. data/lib/aspera/assert.rb +4 -0
  21. data/lib/aspera/cli/ascp_actions.rb +20 -41
  22. data/lib/aspera/cli/async_transfer_store.rb +2 -2
  23. data/lib/aspera/cli/bootstrapper.rb +11 -15
  24. data/lib/aspera/cli/command_line.rb +252 -0
  25. data/lib/aspera/cli/command_registry.rb +149 -33
  26. data/lib/aspera/cli/command_spec.rb +103 -14
  27. data/lib/aspera/cli/completion/ascli.bash +12 -0
  28. data/lib/aspera/cli/completion/ascli.fish +16 -0
  29. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  30. data/lib/aspera/cli/context.rb +3 -0
  31. data/lib/aspera/cli/deprecation.rb +37 -0
  32. data/lib/aspera/cli/extended_value.rb +2 -0
  33. data/lib/aspera/cli/formatter.rb +87 -75
  34. data/lib/aspera/cli/gem_checker.rb +1 -1
  35. data/lib/aspera/cli/hints.rb +7 -6
  36. data/lib/aspera/cli/http.rb +21 -21
  37. data/lib/aspera/cli/info.rb +3 -0
  38. data/lib/aspera/cli/mcp_tool.rb +47 -83
  39. data/lib/aspera/cli/option_declarator.rb +33 -42
  40. data/lib/aspera/cli/option_registry.rb +69 -0
  41. data/lib/aspera/cli/option_types.rb +103 -0
  42. data/lib/aspera/cli/option_value.rb +281 -0
  43. data/lib/aspera/cli/options.schema.yaml +38 -5
  44. data/lib/aspera/cli/parser.rb +307 -848
  45. data/lib/aspera/cli/plugins/alee.rb +7 -4
  46. data/lib/aspera/cli/plugins/aoc.rb +435 -380
  47. data/lib/aspera/cli/plugins/ats.rb +58 -73
  48. data/lib/aspera/cli/plugins/base.rb +190 -240
  49. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  50. data/lib/aspera/cli/plugins/config.rb +244 -178
  51. data/lib/aspera/cli/plugins/console.rb +102 -38
  52. data/lib/aspera/cli/plugins/cos.rb +6 -23
  53. data/lib/aspera/cli/plugins/factory.rb +3 -0
  54. data/lib/aspera/cli/plugins/faspex5.rb +176 -173
  55. data/lib/aspera/cli/plugins/faspio.rb +5 -10
  56. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  57. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  58. data/lib/aspera/cli/plugins/node.rb +277 -311
  59. data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
  60. data/lib/aspera/cli/plugins/preview.rb +79 -90
  61. data/lib/aspera/cli/plugins/server.rb +76 -50
  62. data/lib/aspera/cli/plugins/shares.rb +68 -116
  63. data/lib/aspera/cli/preset_actions.rb +17 -10
  64. data/lib/aspera/cli/preset_manager.rb +12 -2
  65. data/lib/aspera/cli/prompt.rb +35 -0
  66. data/lib/aspera/cli/result.rb +13 -18
  67. data/lib/aspera/cli/runner.rb +31 -54
  68. data/lib/aspera/cli/special_values.rb +5 -0
  69. data/lib/aspera/cli/sync_actions.rb +41 -37
  70. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  71. data/lib/aspera/cli/transfer_actions.rb +0 -6
  72. data/lib/aspera/cli/transfer_agent.rb +29 -35
  73. data/lib/aspera/cli/vault_manager.rb +0 -17
  74. data/lib/aspera/cli/version.rb +1 -1
  75. data/lib/aspera/cli/wizard.rb +4 -2
  76. data/lib/aspera/command_line_builder.rb +1 -0
  77. data/lib/aspera/coverage.rb +1 -0
  78. data/lib/aspera/environment.rb +7 -1
  79. data/lib/aspera/faspex_gw.rb +2 -1
  80. data/lib/aspera/faspex_postproc.rb +1 -0
  81. data/lib/aspera/graphql.rb +5 -5
  82. data/lib/aspera/json_rpc/client.rb +5 -5
  83. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  84. data/lib/aspera/keychain/one_password_api.rb +1 -1
  85. data/lib/aspera/link_header.rb +2 -2
  86. data/lib/aspera/log.rb +22 -25
  87. data/lib/aspera/markdown.rb +2 -0
  88. data/lib/aspera/mime.rb +25 -0
  89. data/lib/aspera/node_simulator.rb +1 -0
  90. data/lib/aspera/oauth/base.rb +35 -25
  91. data/lib/aspera/oauth/factory.rb +1 -0
  92. data/lib/aspera/oauth/generic.rb +1 -1
  93. data/lib/aspera/oauth/jwt.rb +1 -1
  94. data/lib/aspera/oauth/web.rb +9 -8
  95. data/lib/aspera/preview/file_types.rb +4 -4
  96. data/lib/aspera/preview/generator.rb +7 -0
  97. data/lib/aspera/preview/options.rb +4 -4
  98. data/lib/aspera/preview/terminal.rb +4 -3
  99. data/lib/aspera/preview/utils.rb +9 -6
  100. data/lib/aspera/products/connect.rb +1 -1
  101. data/lib/aspera/rainbow.rb +7 -0
  102. data/lib/aspera/rest/aspera_errors.rb +60 -0
  103. data/lib/aspera/rest/call_error.rb +27 -0
  104. data/lib/aspera/rest/client.rb +514 -0
  105. data/lib/aspera/rest/error_analyzer.rb +113 -0
  106. data/lib/aspera/rest/list.rb +143 -0
  107. data/lib/aspera/rest/parameters.rb +55 -0
  108. data/lib/aspera/rest/util.rb +176 -0
  109. data/lib/aspera/rest.rb +7 -621
  110. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  111. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  113. data/lib/aspera/schema/documentation.rb +13 -3
  114. data/lib/aspera/schema/registry.rb +18 -1
  115. data/lib/aspera/schema/validator.rb +92 -0
  116. data/lib/aspera/secret_hider.rb +36 -25
  117. data/lib/aspera/string_ext.rb +15 -0
  118. data/lib/aspera/temp_file_manager.rb +6 -5
  119. data/lib/aspera/transfer/parameters.rb +2 -0
  120. data/lib/aspera/transfer/spec.rb +1 -0
  121. data/lib/aspera/transfer/spec.schema.yaml +1 -0
  122. data/lib/aspera/uri_reader.rb +11 -11
  123. data/lib/aspera/web_auth/index.html +147 -0
  124. data/lib/aspera/web_auth/server.rb +81 -0
  125. data.tar.gz.sig +0 -0
  126. metadata +39 -7
  127. metadata.gz.sig +0 -0
  128. data/lib/aspera/colors.rb +0 -79
  129. data/lib/aspera/rest_call_error.rb +0 -25
  130. data/lib/aspera/rest_error_analyzer.rb +0 -111
  131. data/lib/aspera/rest_errors_aspera.rb +0 -58
  132. data/lib/aspera/rest_list.rb +0 -136
  133. data/lib/aspera/web_auth.rb +0 -211
@@ -30,6 +30,7 @@ require 'aspera/rest'
30
30
  require 'aspera/oauth/jwt'
31
31
  require 'aspera/log'
32
32
  require 'aspera/assert'
33
+ require 'aspera/string_ext'
33
34
  require 'openssl'
34
35
  require 'digest'
35
36
  require 'open3'
@@ -81,66 +82,67 @@ module Aspera
81
82
  end
82
83
 
83
84
  DEFAULT_CHECK_NEW_VERSION_DAYS = 7
84
- private_constant :DEFAULT_CHECK_NEW_VERSION_DAYS
85
+ # Shells with a completion script in COMPLETION_FOLDER
86
+ COMPLETION_SHELLS = %i[bash zsh fish].freeze
87
+ COMPLETION_FOLDER = File.expand_path('../completion', __dir__)
88
+ private_constant :DEFAULT_CHECK_NEW_VERSION_DAYS, :COMPLETION_SHELLS, :COMPLETION_FOLDER
85
89
 
86
- option :preset, description: 'Load the named option preset from current config file', short: 'P', handler: :option_preset
90
+ option :preset, description: 'Load the named option preset from current config file', short: 'P', on_set: :load_preset
87
91
  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
92
+ option :plugin_folder, description: 'Folder where to find additional plugins', on_set: :add_plugin_folders
89
93
  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}
94
+ 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=)
95
+ option :sdk_folder, description: 'Ascp: Path to folder with ascp (or product with "product:")', on_set: Ascp::Installation.instance.method(:sdk_folder=)
92
96
  option :smtp, schema: Schema::Registry::SMTP_OPTIONS
93
97
  option :notify_to, description: 'Email: Recipient for notification of transfers'
94
98
  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
99
+ option :cache_tokens, description: 'Save and reuse OAuth tokens', allowed: Type::BOOLEAN, default: true
100
+ option :expand_mounts, description: 'Commands: list commands of sub-trees provided by another plugin', allowed: Type::BOOLEAN, default: false
101
+ option :no_default, description: 'Do not load default configuration for plugin', allowed: Type::NONE, short: 'N', on_set: -> { presets.use_plugin_defaults = false }
96
102
 
97
103
  def initialize(**_)
98
104
  super
99
105
  @vault_instance = nil
100
106
  @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
107
  # Declare wizard options (Wizard#initialize calls options.declare internally)
105
108
  @wizard = Wizard.new(self, context.main_folder)
106
- options.parse_options!
107
- set_sdk_dir
108
109
  # HTTP options: declare metadata (class method), then bind to the instance
109
110
  Http.declare_options(options)
110
111
  context.http_config.bind_options(options)
112
+ # Values set through `on_set` callbacks are used below (sdk_folder)
111
113
  options.parse_options!
114
+ set_sdk_dir
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,162 @@ 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
+ @wizard.find(apps, preset_name: preset_name)
190
+ end
191
+ )
192
+ command :coffee, description: 'Show a coffee image', action: ->(**) { Result::Image.new(COFFEE_IMAGE_URL) }
193
+ command :image, description: 'Show an image',
179
194
  arguments: [{name: :image_uri, type: nil}],
180
195
  action: ->(image_uri:, **) { Result::Image.new(image_uri) }
181
196
  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) }
197
+ command :gem, description: 'Show gem information'
198
+ command :folder, description: 'Show the configuration folder path', action: ->(**) { Result::Text.new(context.main_folder) }
199
+ command :file, description: 'Show the configuration file path', action: ->(**) { Result::Text.new(context.presets.config_file) }
185
200
  command(
186
201
  :email_test, description: 'Send a test email',
187
- action: lambda do
202
+ action: lambda do |**|
188
203
  context.mailer.send_email_template(email_template_default: EMAIL_TEST_TEMPLATE)
189
204
  Result::Nothing.new
190
205
  end
191
206
  )
192
- command :smtp_settings, description: 'Display the current SMTP settings', action: -> { Result::SingleObject.new(context.mailer.email_settings) }
207
+ command :smtp_settings, description: 'Show the current SMTP settings', action: ->(**) { Result::SingleObject.new(context.mailer.email_settings) }
193
208
  command(
194
209
  :proxy_check, description: 'Check the proxy returned by the PAC script for a given URL',
195
- arguments: [{name: :server_url, type: String}],
210
+ arguments: [{name: :server_url}],
196
211
  action: lambda do |server_url:, **|
197
212
  Aspera.assert(!context.pac_executor.nil?, type: Cli::BadArgument) { 'No PAC script configured, use --fpac' }
198
213
  Result::ValueList.new(context.pac_executor.get_proxies(server_url), name: 'proxy')
199
214
  end
200
215
  )
201
- command :check_update, description: 'Check if a newer version of the gem is available', action: -> { Result::SingleObject.new(check_gem_version) }
216
+ command :check_update, description: 'Check if a newer version of the gem is available', action: ->(**) { Result::SingleObject.new(check_gem_version) }
202
217
  command :initdemo, description: 'Initialize the demo server preset'
203
218
  command :vault, description: 'Manage secrets in the vault'
204
219
  commands_under :vault do
205
- command :info, description: 'Display vault information',
220
+ command :info, description: 'Show vault information',
206
221
  action: ->(**) { Result::SingleObject.new(vault_required.info) }
207
222
  command :ids, description: 'List secret labels in the vault',
208
223
  action: ->(**) { Result::ObjectList.new(vault_required.ids) }
209
224
  command :list, description: 'List all secrets with full details',
210
225
  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)',
226
+ command(
227
+ :show, description: 'Show a secret by label (or id)',
228
+ arguments: [{name: :label}, {name: :id, mandatory: false, default: nil}],
229
+ action: lambda do |label:, id: nil, **|
230
+ v = vault_required
231
+ kwargs = id && v.method(:get).parameters.any? { |_t, n| n == :id } ? {id: id} : {}
232
+ Result::SingleObject.new(v.get(label: label, **kwargs))
233
+ end
234
+ )
235
+ command(
236
+ :create, description: 'Add a new secret to the vault',
237
+ arguments: [{name: :secret, type: Hash, schema: Schema::Registry::VAULT_SECRET}],
238
+ action: lambda do |secret:, **|
239
+ vault_required.set(secret.symbolize_keys)
240
+ Result::Status.new('Secret added')
241
+ end
242
+ )
243
+ command :delete, description: 'Delete a secret by label (or id)',
244
+ arguments: [{name: :label}, {name: :id, mandatory: false, default: nil}]
245
+ command(
246
+ :password, description: 'Change the vault password',
247
+ arguments: [{name: :new_password}],
248
+ action: lambda do |new_password:, **|
249
+ Aspera.assert(vault_required.respond_to?(:change_password), 'Vault does not support password change')
250
+ vault_required.change_password(new_password)
251
+ Result::Status.new('Vault password updated')
252
+ end
253
+ )
254
+ command :import, description: 'Import secrets from a JSON array (supports --bulk)',
220
255
  arguments: [{name: :secrets, type: Array, schema: {type: 'array', items: {'$ref' => Schema::Registry::VAULT_SECRET}}}]
221
256
  end
222
- command :commands, description: 'List all available commands across all plugins'
257
+ command :commands, description: 'List all available commands, of all plugins or only the given one, optionally under a command path',
258
+ arguments: [{name: :plugin_name, mandatory: false, default: nil},
259
+ {name: :command_path, multiple: true, mandatory: false, default: nil}]
223
260
  command :options, description: 'List all options available for a plugin',
224
- arguments: [{name: :plugin_name, type: String}]
261
+ arguments: [{name: :plugin_name}]
225
262
  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'
263
+ command :platform, description: 'Show the current platform/architecture', action: ->(**) { Result::Text.new(Environment.instance.architecture) }
264
+ command :completion, description: 'Shell completion: activation scripts and next words'
228
265
 
229
266
  # remote_certificate sub-commands
230
267
  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}]
268
+ command(
269
+ :chain, description: 'Show the full certificate chain as PEM',
270
+ arguments: [{name: :remote_url}],
271
+ action: lambda do |remote_url:, **|
272
+ remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
273
+ Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
274
+ Result::Text.new(remote_chain.map(&:to_pem).join("\n"))
275
+ end
276
+ )
277
+ command(
278
+ :only, description: 'Show only the server certificate as PEM',
279
+ arguments: [{name: :remote_url}],
280
+ action: lambda do |remote_url:, **|
281
+ remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
282
+ Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
283
+ Result::Text.new(remote_chain.first.to_pem)
284
+ end
285
+ )
286
+ command(
287
+ :name, description: 'Show the CN of the server certificate',
288
+ arguments: [{name: :remote_url}],
289
+ action: lambda do |remote_url:, **|
290
+ remote_chain = Rest.remote_certificate_chain(remote_url, as_string: false)
291
+ Aspera.assert(remote_chain&.first) { "No certificate found for #{remote_url}" }
292
+ Result::Text.new(remote_chain.first.subject.to_a.find { |name, _, _| name == 'CN' }[1])
293
+ end
294
+ )
237
295
  end
238
296
 
239
297
  # tokens sub-commands
240
298
  commands_under :tokens do
241
299
  command(
242
300
  :flush, description: 'Delete all cached OAuth tokens',
243
- action: lambda do
301
+ action: lambda do |**|
244
302
  require 'aspera/api/node'
245
303
  Result::ValueList.new(OAuth::Factory.instance.flush_tokens, name: 'file')
246
304
  end
247
305
  )
248
306
  command(
249
307
  :list, description: 'List all cached OAuth tokens',
250
- action: lambda do
308
+ action: lambda do |**|
251
309
  require 'aspera/api/node'
252
310
  Result::ObjectList.new(OAuth::Factory.instance.persisted_tokens)
253
311
  end
@@ -261,25 +319,33 @@ module Aspera
261
319
  command :list, description: 'List all available plugins'
262
320
  command :create, description: 'Create a new plugin skeleton file',
263
321
  arguments: [
264
- {name: :name, type: String},
265
- {name: :folder, type: String, mandatory: false}
322
+ {name: :name},
323
+ {name: :folder, mandatory: false}
266
324
  ]
267
325
  end
268
326
 
269
327
  # ascp sub-commands
270
328
  command :ascp, description: 'Manage FASP/ascp transfer engine'
271
329
  commands_under :ascp do
272
- command :show, description: 'Display ascp binary path'
273
- command :info, description: 'Display ascp and transfer spec information'
330
+ command :show, description: 'Show ascp binary path', action: ->(**) { Result::Text.new(Ascp::Installation.instance.path(:ascp)) }
331
+ command :info, description: 'Show ascp and transfer spec information'
274
332
  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'
333
+ arguments: [{name: :version, mandatory: false, default: nil}],
334
+ action: ->(version: nil, **) { install_transfer_sdk(version: version) }
335
+ command(
336
+ :spec, description: 'Show the transfer spec schema',
337
+ action: lambda do |**|
338
+ builder = Schema::Documentation.new(TerminalFormatter, Transfer::Spec::SCHEMA, include_option: true, agent_columns: true).build
339
+ Result::ObjectList.new(builder.rows, fields: builder.columns)
340
+ end
341
+ )
342
+ command :schema, description: 'Show the transfer spec JSON schema',
343
+ arguments: [{name: :agent_name, allowed: Agent::Factory::ALL.keys, mandatory: false, default: nil}]
344
+ command :errors, description: 'Show FASP error codes'
280
345
  command :products, description: 'Manage installed Aspera products'
281
346
  commands_under :products do
282
- command :list, description: 'List installed Aspera products'
347
+ command :list, description: 'List installed Aspera products',
348
+ action: ->(**) { Result::ObjectList.new(Ascp::Installation.instance.installed_products, fields: %w[name app_root]) }
283
349
  end
284
350
  end
285
351
 
@@ -296,24 +362,36 @@ module Aspera
296
362
  # transfer async management sub-commands
297
363
  command :transfer, description: 'Manage asynchronous transfers'
298
364
  commands_under :transfer do
299
- command :list, description: 'List all async transfer jobs'
365
+ command(
366
+ :list, description: 'List all async transfer jobs',
367
+ action: lambda do |**|
368
+ rows = async_transfer_store.list
369
+ Result::ObjectList.new(rows, fields: %w[job_id agent_type status started_at ended_at bytes_transferred transfer_id])
370
+ end
371
+ )
300
372
  command :status, description: 'Show status of an async transfer job',
301
- arguments: [{name: :job_id, type: String}]
373
+ arguments: [{name: :job_id}]
302
374
  command :cleanup, description: 'Remove completed/failed/cancelled transfer entries'
303
375
  end
304
376
 
305
377
  # transferd sub-commands
306
378
  command :transferd, description: 'Manage the transfer daemon (transferd)'
307
379
  commands_under :transferd do
308
- command :install, description: 'Install the transfer daemon'
309
- command :list, description: 'List available SDK locations'
380
+ command :install, description: 'Install the transfer daemon', action: ->(**) { install_transfer_sdk }
381
+ command(
382
+ :list, description: 'List available SDK locations',
383
+ action: lambda do |**|
384
+ sdk_list = Ascp::Installation.instance.sdk_locations
385
+ Result::ObjectList.new(sdk_list, fields: sdk_list.first.keys - ['url'])
386
+ end
387
+ )
310
388
  end
311
389
 
312
390
  # sync sub-commands
313
391
  commands_under :sync do
314
392
  command(
315
- :spec, description: 'Display the sync configuration schema',
316
- action: lambda do
393
+ :spec, description: 'Show the sync configuration schema',
394
+ action: lambda do |**|
317
395
  builder = Schema::Documentation.new(TerminalFormatter, Sync::Operations::CONF_SCHEMA, include_option: true).build
318
396
  Result::ObjectList.new(builder.rows, fields: builder.columns)
319
397
  end
@@ -321,50 +399,57 @@ module Aspera
321
399
  command :admin, description: 'Manage sync database (admin operations)'
322
400
  SyncActions.register_sync_admin_commands(self, :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
496
  file_dest = File.join(transfer.destination_folder(Transfer::Spec::DIRECTION_RECEIVE), file_url.gsub(%r{.*/}, '')) 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,66 @@ 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
+ prefix = Array(command_path).map(&:to_sym)
573
+ Aspera.assert(prefix.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
+ Aspera.assert(prefix.empty? || reg[prefix], type: Cli::BadArgument) { "no such command: #{name} #{prefix.join(' ')}" }
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
+ # Build syntax by interleaving each path segment with the arguments declared on that node
596
+ # @param name [String, Symbol] plugin name
597
+ # @param reg [CommandRegistry] registry of plugin
598
+ # @param path [Array<Symbol>] command path
599
+ # @return [String] e.g. `node access_keys do <access_key_id> ls <path>`
600
+ def command_syntax(name, reg, path)
601
+ tokens = [name.to_s]
602
+ path.each_index do |i|
603
+ tokens << path[i].to_s
604
+ reg.arguments_at(path[0, i + 1]).each { |a| tokens << a.syntax }
605
+ end
606
+ tokens.join(' ')
607
+ end
608
+
546
609
  def action_options(plugin_name:, **)
547
610
  # Instantiate the plugin so that it registers all its options in context.options
548
611
  Plugins::Factory.instance.create(plugin_name.to_sym, context: context)
549
612
  rows = context.options.declared_options.map do |sym, opt|
550
613
  row = {
551
- option: "--#{sym.to_s.tr('_', '-')}",
552
- description: opt.description.to_s
614
+ 'option' => "--#{sym.to_s.tr('_', '-')}",
615
+ 'description' => opt.description.to_s
553
616
  }
554
- row[:allowed] = opt.values.join('|') if opt.values&.any?
555
- row[:deprecated] = opt.deprecation if opt.deprecation
617
+ row['allowed'] = opt.allowed_info unless opt.allowed_info.nil?
618
+ row['deprecated'] = opt.deprecation.last if opt.deprecation
619
+ row['replacement'] = opt.deprecation.message if opt.deprecation
556
620
  row
557
621
  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
622
+ Result::ObjectList.new(rows, fields: %w[option description allowed deprecated replacement])
565
623
  end
566
624
 
567
- def action_completion_bash(words: nil, **)
625
+ def action_completion_words(words: nil, **)
568
626
  if words.nil? || words.empty?
569
627
  # Level 0: propose plugin names
570
628
  Plugins::Factory.instance.plugin_list.each { |p| puts p }
@@ -582,6 +640,14 @@ module Aspera
582
640
  Process.exit(0)
583
641
  end
584
642
 
643
+ # Display the completion script for the given shell.
644
+ # Raw output, not formatted, so that it can be evaluated by the shell.
645
+ # @param shell [Symbol] One of `COMPLETION_SHELLS`
646
+ def completion_script(shell)
647
+ $stdout.write(File.read(File.join(COMPLETION_FOLDER, "#{Info::CMD_NAME}.#{shell}")))
648
+ Process.exit(0)
649
+ end
650
+
585
651
  # Folder in $HOME for application files (~/.aspera)
586
652
  ASPERA_HOME_FOLDER_NAME = '.aspera'
587
653
  # Default config file name