aspera-cli 4.27.2 → 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 (130) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +50 -0
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +804 -746
  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 +430 -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/coverage.rb +1 -0
  77. data/lib/aspera/environment.rb +6 -0
  78. data/lib/aspera/faspex_gw.rb +2 -1
  79. data/lib/aspera/faspex_postproc.rb +1 -0
  80. data/lib/aspera/graphql.rb +5 -5
  81. data/lib/aspera/json_rpc/client.rb +5 -5
  82. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  83. data/lib/aspera/keychain/one_password_api.rb +1 -1
  84. data/lib/aspera/link_header.rb +2 -2
  85. data/lib/aspera/log.rb +22 -25
  86. data/lib/aspera/markdown.rb +2 -0
  87. data/lib/aspera/mime.rb +25 -0
  88. data/lib/aspera/node_simulator.rb +1 -0
  89. data/lib/aspera/oauth/base.rb +35 -25
  90. data/lib/aspera/oauth/factory.rb +1 -0
  91. data/lib/aspera/oauth/generic.rb +1 -1
  92. data/lib/aspera/oauth/jwt.rb +1 -1
  93. data/lib/aspera/oauth/web.rb +9 -8
  94. data/lib/aspera/preview/file_types.rb +4 -4
  95. data/lib/aspera/preview/generator.rb +7 -0
  96. data/lib/aspera/preview/options.rb +4 -4
  97. data/lib/aspera/preview/terminal.rb +4 -3
  98. data/lib/aspera/preview/utils.rb +9 -6
  99. data/lib/aspera/products/connect.rb +1 -1
  100. data/lib/aspera/rainbow.rb +7 -0
  101. data/lib/aspera/rest/aspera_errors.rb +60 -0
  102. data/lib/aspera/rest/call_error.rb +27 -0
  103. data/lib/aspera/rest/client.rb +514 -0
  104. data/lib/aspera/rest/error_analyzer.rb +113 -0
  105. data/lib/aspera/rest/list.rb +143 -0
  106. data/lib/aspera/rest/parameters.rb +55 -0
  107. data/lib/aspera/rest/util.rb +176 -0
  108. data/lib/aspera/rest.rb +7 -621
  109. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  110. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  111. data/lib/aspera/schema/documentation.rb +13 -3
  112. data/lib/aspera/schema/registry.rb +18 -1
  113. data/lib/aspera/schema/validator.rb +92 -0
  114. data/lib/aspera/secret_hider.rb +36 -25
  115. data/lib/aspera/string_ext.rb +15 -0
  116. data/lib/aspera/temp_file_manager.rb +6 -5
  117. data/lib/aspera/transfer/parameters.rb +2 -0
  118. data/lib/aspera/transfer/spec.rb +1 -0
  119. data/lib/aspera/uri_reader.rb +11 -11
  120. data/lib/aspera/web_auth/index.html +147 -0
  121. data/lib/aspera/web_auth/server.rb +81 -0
  122. data.tar.gz.sig +0 -0
  123. metadata +39 -7
  124. metadata.gz.sig +0 -0
  125. data/lib/aspera/colors.rb +0 -79
  126. data/lib/aspera/rest_call_error.rb +0 -25
  127. data/lib/aspera/rest_error_analyzer.rb +0 -111
  128. data/lib/aspera/rest_errors_aspera.rb +0 -58
  129. data/lib/aspera/rest_list.rb +0 -136
  130. data/lib/aspera/web_auth.rb +0 -211
@@ -81,7 +81,6 @@ module Aspera
81
81
  @connection_type = :ssh
82
82
  @ascmd_executor = nil
83
83
  @server_transfer_spec = nil
84
- options.parse_options!
85
84
  @ssh_opts = options.get_option(:ssh_options).symbolize_keys
86
85
  end
87
86
 
@@ -160,7 +159,7 @@ module Aspera
160
159
  end
161
160
 
162
161
  Sync::Operations::DIRECTIONS.each do |dir|
163
- define_method(:"action_sync_#{dir}") { |path:, sync_info: {}, **| run_sync_transfer(dir, path: path, sync_info: sync_info) { @server_transfer_spec } }
162
+ define_method(:"action_sync_#{dir}") { |path:, sync_info: {}, **| run_sync_transfer(dir, path: path, sync_info: sync_info) { server_transfer_spec } }
164
163
  end
165
164
 
166
165
  # --- DSL ---
@@ -169,22 +168,18 @@ module Aspera
169
168
  option :passphrase, description: 'SSH private key passphrase'
170
169
  option :ssh_options, description: 'SSH options', allowed: Hash
171
170
 
172
- # root_setup runs once before any command argument is consumed, populating
173
- # @server_transfer_spec and @ascmd_executor so that condition methods work.
174
- root_setup :setup_server
175
-
176
171
  command :health, description: 'Check transfer health'
177
- command(:upload, description: 'Upload files to server', transfer_paths: :send, action: lambda do
178
- @server_transfer_spec['direction'] = Transfer::Spec.transfer_type_to_direction(:upload)
179
- Runner.result_transfer(transfer.start(@server_transfer_spec))
172
+ command(:upload, description: 'Upload files to server', transfer_paths: :send, action: lambda do |**|
173
+ server_transfer_spec['direction'] = Transfer::Spec.transfer_type_to_direction(:upload)
174
+ Runner.result_transfer(transfer.start(server_transfer_spec))
180
175
  end)
181
176
  command(
182
177
  :download,
183
178
  description: 'Download files from server',
184
179
  transfer_paths: :receive,
185
- action: lambda do
186
- @server_transfer_spec['direction'] = Transfer::Spec.transfer_type_to_direction(:download)
187
- Runner.result_transfer(transfer.start(@server_transfer_spec))
180
+ action: lambda do |**|
181
+ server_transfer_spec['direction'] = Transfer::Spec.transfer_type_to_direction(:download)
182
+ Runner.result_transfer(transfer.start(server_transfer_spec))
188
183
  end
189
184
  )
190
185
  command :sync, description: 'Synchronize files with server'
@@ -201,43 +196,76 @@ module Aspera
201
196
  end
202
197
 
203
198
  # AsCmd operations (available only when SSH/local - not WSS)
204
- ASCMD_ARGS = [{name: :command_arguments, multiple: true, mandatory: false, default: nil}].freeze
205
- private_constant :ASCMD_ARGS
199
+ # Arguments follow AsCmd::OPS_ARGS: one path per operation, or source and destination
200
+ PATHS_ARGS = [{name: :paths, multiple: true}].freeze
201
+ private_constant :PATHS_ARGS
206
202
 
207
- command :ls, description: 'List files', condition: :ascmd_available?, aliases: [:browse], arguments: ASCMD_ARGS
208
- command :rm, description: 'Delete files', condition: :ascmd_available?, aliases: [:delete], arguments: ASCMD_ARGS
209
- command :mv, description: 'Rename/move files', condition: :ascmd_available?, aliases: [:rename], arguments: ASCMD_ARGS
210
- command :cp, description: 'Copy files', condition: :ascmd_available?, arguments: ASCMD_ARGS
211
- command :mkdir, description: 'Create directory', condition: :ascmd_available?, arguments: ASCMD_ARGS
212
- command :df, description: 'Show disk usage', condition: :ascmd_available?, arguments: ASCMD_ARGS
213
- command :du, description: 'Show file sizes', condition: :ascmd_available?, arguments: ASCMD_ARGS
214
- command :md5sum, description: 'Compute MD5 checksums', condition: :ascmd_available?, arguments: ASCMD_ARGS
215
- command :info, description: 'Show server system information', condition: :ascmd_available?, arguments: ASCMD_ARGS
203
+ command(
204
+ :ls, description: 'List files', condition: :ascmd_available?, aliases: [:browse], arguments: PATHS_ARGS,
205
+ action: lambda do |paths:, **|
206
+ execute_ascmd(:ls, paths) do |result|
207
+ Result::ObjectList.new(result.map(&:stringify_keys), fields: %w[zmode zuid zgid size mtime name])
208
+ end
209
+ end
210
+ )
211
+ command :rm, description: 'Delete files', condition: :ascmd_available?, aliases: [:delete], arguments: PATHS_ARGS
212
+ command :mv, description: 'Rename/move files: source destination pairs, or sources with --to-folder', condition: :ascmd_available?, aliases: [:rename], arguments: PATHS_ARGS
213
+ command :cp, description: 'Copy files: source destination pairs, or sources with --to-folder', condition: :ascmd_available?, arguments: PATHS_ARGS
214
+ command :mkdir, description: 'Create directory', condition: :ascmd_available?, arguments: PATHS_ARGS
215
+ command(
216
+ :df, description: 'Show disk usage', condition: :ascmd_available?,
217
+ action: lambda do |**|
218
+ execute_ascmd(:df, []) do |result|
219
+ Result::ObjectList.new(result.map(&:stringify_keys))
220
+ end
221
+ end
222
+ )
223
+ command :du, description: 'Show file sizes', condition: :ascmd_available?, arguments: PATHS_ARGS
224
+ command :md5sum, description: 'Compute MD5 checksums', condition: :ascmd_available?, arguments: PATHS_ARGS
225
+ command :info, description: 'Show server system information', condition: :ascmd_available?,
226
+ action: ->(**) { execute_ascmd(:info, []) { |r| Result::SingleObject.new(r.stringify_keys) } }
216
227
 
217
228
  # Generate ascmd handlers - convention: action_<op>
218
- %i[rm mv cp mkdir].each do |op|
219
- define_action_method([op]) do |command_arguments:, **|
220
- execute_ascmd(op, command_arguments) { Result::Success.new }
229
+ %i[rm mkdir].each do |op|
230
+ define_action_method([op]) do |paths:, **|
231
+ execute_ascmd(op, paths) { Result::Success.new }
232
+ end
233
+ end
234
+
235
+ %i[mv cp].each do |op|
236
+ define_action_method([op]) do |paths:, **|
237
+ execute_ascmd(op, source_destination_pairs(paths)) { Result::Success.new }
221
238
  end
222
239
  end
223
240
 
224
- %i[du md5sum info].each do |op|
225
- define_action_method([op]) do |command_arguments:, **|
226
- execute_ascmd(op, command_arguments) { |r| Result::SingleObject.new(r.stringify_keys) }
241
+ %i[du md5sum].each do |op|
242
+ define_action_method([op]) do |paths:, **|
243
+ execute_ascmd(op, paths) { |r| Result::SingleObject.new(r.stringify_keys) }
227
244
  end
228
245
  end
229
246
 
230
247
  # --- conditions ---
231
248
 
232
249
  def ascmd_available?
233
- !@ascmd_executor.nil?
250
+ !ascmd_executor.nil?
251
+ end
252
+
253
+ # --- server access ---
254
+
255
+ # @return [Hash] base transfer spec, built from CLI options on first use
256
+ def server_transfer_spec
257
+ build_server_access if @server_transfer_spec.nil?
258
+ @server_transfer_spec
234
259
  end
235
260
 
236
- # --- setup ---
261
+ # @return [LocalExecutor, Ssh, nil] ascmd executor, nil with WSS (ascmd not available)
262
+ def ascmd_executor
263
+ build_server_access if @server_transfer_spec.nil?
264
+ @ascmd_executor
265
+ end
237
266
 
238
267
  # Build the transfer spec and ascmd executor from CLI options.
239
- # Stores results in instance variables; returns empty context hash.
240
- def setup_server(**)
268
+ def build_server_access
241
269
  @server_transfer_spec = options_to_base_transfer_spec
242
270
  @ascmd_executor = case @connection_type
243
271
  when :local then LocalExecutor.new
@@ -245,14 +273,13 @@ module Aspera
245
273
  when :ssh then Ssh.new(@server_transfer_spec['remote_host'], @server_transfer_spec['remote_user'], @ssh_opts)
246
274
  else Aspera.error_unexpected_value(@connection_type) { 'connection type' }
247
275
  end
248
- {}
249
276
  end
250
277
 
251
278
  # --- health ---
252
279
 
253
- def action_health_transfer
280
+ def action_health_transfer(**)
254
281
  nagios = Nagios.new
255
- probe_ts = @server_transfer_spec.merge({
282
+ probe_ts = server_transfer_spec.merge({
256
283
  'direction' => 'send',
257
284
  'cookie' => 'aspera.sync', # hide in console
258
285
  'resume_policy' => 'none',
@@ -269,24 +296,23 @@ module Aspera
269
296
 
270
297
  # --- ascmd handlers ---
271
298
 
272
- def action_ls(command_arguments:, **)
273
- execute_ascmd(:ls, command_arguments) do |result|
274
- Result::ObjectList.new(result.map(&:stringify_keys), fields: %w[zmode zuid zgid size mtime name])
275
- end
276
- end
299
+ private
277
300
 
278
- def action_df(command_arguments:, **)
279
- execute_ascmd(:df, command_arguments) do |result|
280
- Result::ObjectList.new(result.map(&:stringify_keys))
281
- end
301
+ # AsCmd `cp` and `mv` take source and destination pairs.
302
+ # With option `to_folder`, paths are only sources, copied or moved into that folder.
303
+ # @param paths [Array<String>] command line arguments
304
+ # @return [Array<String>] flat list of source and destination pairs
305
+ def source_destination_pairs(paths)
306
+ to_folder = options.get_option(:to_folder)
307
+ return paths.flat_map { |source| [source, "#{to_folder.chomp('/')}/#{File.basename(source)}"] } unless to_folder.nil?
308
+ Aspera.assert(paths.length.even?, type: Cli::BadArgument) { 'expecting pairs: <source> <destination> ..., or sources with option to_folder' }
309
+ paths
282
310
  end
283
311
 
284
- private
285
-
286
- def execute_ascmd(op, command_arguments)
287
- ascmd = AsCmd.new(@ascmd_executor)
312
+ def execute_ascmd(op, arguments)
313
+ ascmd = AsCmd.new(ascmd_executor)
288
314
  begin
289
- result = ascmd.execute_single(op, command_arguments)
315
+ result = ascmd.execute_single(op, arguments)
290
316
  yield(result)
291
317
  rescue AsCmd::Error => e
292
318
  raise Cli::BadArgument, e.extended_message
@@ -3,7 +3,7 @@
3
3
  require 'aspera/cli/plugins/basic_auth'
4
4
  require 'aspera/cli/plugins/node'
5
5
  require 'aspera/assert'
6
- require 'aspera/rest_list'
6
+ require 'aspera/rest/list'
7
7
 
8
8
  module Aspera
9
9
  module Cli
@@ -23,7 +23,7 @@ module Aspera
23
23
  result[:version] =
24
24
  begin
25
25
  version = nil
26
- login_page = Rest
26
+ login_page = Rest::Client
27
27
  .new(base_url: url, redirect_max: 2)
28
28
  .read('', headers: {'Accept'=>'text/html'})
29
29
  Aspera.assert(login_page.include?('aspera-Shares')) { 'not Shares' }
@@ -39,16 +39,16 @@ module Aspera
39
39
  end
40
40
  result[:ping] =
41
41
  begin
42
- Rest
42
+ Rest::Client
43
43
  .new(base_url: "#{url}/#{NODE_API_PATH}")
44
- .read('ping', headers: {'Content-Type'=>'application/json'})
44
+ .read('ping', headers: {'Content-Type' => Mime::JSON})
45
45
  'ping ok'
46
46
  rescue => e
47
47
  e
48
48
  end
49
49
  result[:api] =
50
50
  begin
51
- data, resp = Rest
51
+ data, resp = Rest::Client
52
52
  .new(base_url: "#{url}/#{NODE_API_PATH}", redirect_max: 1)
53
53
  .read('info', exception: false, ret: :both)
54
54
  # shall fail: shares requires auth, but we check error message
@@ -103,12 +103,19 @@ module Aspera
103
103
  SHARE_PERMISSIONS_OPS = %i[list show].freeze
104
104
  # group users: Rails only exposes index+show+update+destroy (no create)
105
105
  GROUP_USERS_OPS = (Operations::ALL - %i[create]).freeze
106
+ # Arguments of group users commands, by operation
107
+ GROUP_USERS_ARGS = {
108
+ show: [{name: :user_id, type: :identifier}],
109
+ modify: [{name: :user_id, type: :identifier}, {name: :user, type: Hash}],
110
+ delete: [{name: :user_id, type: :identifier}]
111
+ }.freeze
106
112
 
107
113
  # --- DSL ---
108
114
 
109
115
  command :health, description: 'Check Shares health'
110
- command :info, description: 'Show server information', action: -> { Result::SingleObject.new(basic_auth_api(NODE_API_PATH).read('info', headers: {'Content-Type'=>'application/json'})) }
111
- command :files, description: 'Browse and transfer files on Shares', aliases: [:repository], setup: :setup_shares_node
116
+ command :info, description: 'Show server information', action: ->(**) { Result::SingleObject.new(basic_auth_api(NODE_API_PATH).read('info', headers: {'Content-Type' => Mime::JSON})) }
117
+ command :files, description: 'Browse and transfer files on Shares', aliases: [:repository],
118
+ mount: {plugin: Node, instance: :shares_node_plugin, only: Node::COMMANDS_SHARES}
112
119
  command :admin, description: 'Administer Shares', setup: :setup_admin
113
120
 
114
121
  commands_under :admin do
@@ -137,30 +144,33 @@ module Aspera
137
144
  command :ldap, description: "LDAP #{entity_type}s"
138
145
  command :saml, description: "SAML #{entity_type}s"
139
146
 
147
+ user_fields = %w[id user_id username first_name last_name email] if entity_type.eql?(:user)
140
148
  # all: list/show/delete only (no create, no modify — users/groups/:all has no update route)
141
149
  lookup_method_all = :"lookup_shares_#{entity_type}_all_id"
142
150
  commands_under :all do
143
- (Operations::ALL - %i[create modify]).each do |op|
144
- id_args = Operations::GLOBAL.include?(op) ? {} : {arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_all}]}
145
- command op, description: "#{op.capitalize} #{entity_type}s", **id_args
146
- end
151
+ crud_commands entity: "data/#{entity_type}s",
152
+ api: :@api_shares_admin,
153
+ name: entity_type.to_s,
154
+ operations: Operations::ALL - %i[create modify],
155
+ lookup: lookup_method_all,
156
+ display_fields: user_fields && (user_fields + %w[directory_user])
147
157
  USR_GRP_SETTINGS.each do |setting|
148
158
  # share_permissions: Rails only exposes index+show (read-only)
149
159
  setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
150
160
  command setting,
151
- description: "Manage #{setting} for a #{entity_type}",
161
+ description: "Manage #{entity_noun(setting, singular: false)} for a #{entity_type}",
152
162
  arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_all}]
153
163
  commands_under setting do
154
164
  setting_ops.each do |op|
155
- # share_permissions > show needs a permission identifier (list does not)
156
- perm_id_arg = if setting.eql?(:share_permissions) && op.eql?(:show)
157
- [{name: :permission_id, type: :identifier, lookup: :lookup_share_id}]
158
- else
159
- []
160
- end
165
+ # share_permissions > show needs a permission identifier (list does not), modify needs data
166
+ op_args =
167
+ case op
168
+ when :show then setting.eql?(:share_permissions) ? [{name: :permission_id, type: :identifier, lookup: :lookup_share_id}] : nil
169
+ when :modify then [{name: setting, type: Hash}]
170
+ end
161
171
  command op,
162
- description: "#{op.capitalize} #{setting} for a #{entity_type}",
163
- arguments: perm_id_arg.empty? ? nil : perm_id_arg
172
+ description: "#{op.capitalize} #{entity_noun(setting, singular: false)} for a #{entity_type}",
173
+ arguments: op_args
164
174
  end
165
175
  end
166
176
  end
@@ -170,7 +180,7 @@ module Aspera
170
180
  arguments: [{name: :group_id, type: :identifier, lookup: :lookup_shares_group_all_id}]
171
181
  commands_under :users do
172
182
  GROUP_USERS_OPS.each do |op|
173
- command op, description: "#{op.capitalize} users of a group"
183
+ command op, description: "#{op.capitalize} users of a group", arguments: GROUP_USERS_ARGS[op]
174
184
  end
175
185
  end
176
186
  end
@@ -179,17 +189,19 @@ module Aspera
179
189
  # local: full CRUD only — no nested settings (Rails does not nest transfer_settings/app_authorizations/share_permissions under local_users/local_groups)
180
190
  commands_under :local do
181
191
  lookup_method_local = :"lookup_shares_#{entity_type}_local_id"
182
- Operations::ALL.each do |op|
183
- id_args = Operations::GLOBAL.include?(op) ? {} : {arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_local}]}
184
- command op, description: "#{op.capitalize} #{entity_type}s", **id_args
185
- end
192
+ crud_commands entity: "data/local_#{entity_type}s",
193
+ api: :@api_shares_admin,
194
+ name: "local #{entity_type}",
195
+ lookup: lookup_method_local,
196
+ display_fields: user_fields,
197
+ body_component: Schema::Registry::SHARES
186
198
  if entity_type.eql?(:group)
187
199
  command :users,
188
200
  description: 'Manage users of a group',
189
201
  arguments: [{name: :group_id, type: :identifier, lookup: :lookup_shares_group_local_id}]
190
202
  commands_under :users do
191
203
  GROUP_USERS_OPS.each do |op|
192
- command op, description: "#{op.capitalize} users of a group"
204
+ command op, description: "#{op.capitalize} users of a group", arguments: GROUP_USERS_ARGS[op]
193
205
  end
194
206
  end
195
207
  end
@@ -198,13 +210,13 @@ module Aspera
198
210
  # ldap: add only
199
211
  commands_under :ldap do
200
212
  command :add, description: "Add a LDAP #{entity_type}",
201
- arguments: [{name: :entity_name, type: String, bulk: true}]
213
+ arguments: [{name: entity_type, bulk: true}]
202
214
  end
203
215
 
204
216
  # saml: import only
205
217
  commands_under :saml do
206
218
  command :import, description: "Import a SAML #{entity_type}",
207
- arguments: [{name: :entity_parameters, type: Hash, bulk: true}]
219
+ arguments: [{name: entity_type, type: Hash, bulk: true}]
208
220
  end
209
221
  end
210
222
  end
@@ -221,7 +233,7 @@ module Aspera
221
233
  lookup_method = :"lookup_shares_#{entity_type}_#{location}_id"
222
234
  define_method(lookup_method) do |field, value, **|
223
235
  path = admin_entity_path(entity_type, location)
224
- RestList.lookup_entity_generic(entity: entity_type, field: field, value: value) { @api_shares_admin.read(path) }['id']
236
+ Rest::List.lookup_entity_generic(entity: entity_type, field: field, value: value) { @api_shares_admin.read(path) }['id']
225
237
  end
226
238
  private lookup_method
227
239
  end
@@ -245,7 +257,8 @@ module Aspera
245
257
  %i[user_permissions group_permissions].each do |perm_type|
246
258
  commands_under [:admin, :share, perm_type] do
247
259
  SHARE_PERMISSIONS_OPS.each do |op|
248
- command op, description: "#{op.capitalize} #{perm_type}"
260
+ command op, description: "#{op.capitalize} #{entity_noun(perm_type, singular: false)}",
261
+ arguments: op.eql?(:show) ? [{name: :permission_id, type: :identifier}] : nil
249
262
  end
250
263
  end
251
264
  end
@@ -259,11 +272,11 @@ module Aspera
259
272
 
260
273
  # --- setup ---
261
274
 
262
- # Build the Shares node API plugin and inject into ctx.
263
- # @return [Hash] context hash containing :shares_node_plugin
264
- def setup_shares_node(**)
265
- api_shares_node = basic_auth_api(NODE_API_PATH)
266
- {shares_node_plugin: Node.new(context: context, api: api_shares_node)}
275
+ # files - mount target: Node plugin on the Shares node API.
276
+ # Api::Node (not plain Rest::Client) so that Node commands using its helpers work (e.g. spec, transport).
277
+ # @return [Node]
278
+ def shares_node_plugin(**)
279
+ Node.new(context: context, api: Api::Node.new(**basic_auth_params(NODE_API_PATH)))
267
280
  end
268
281
 
269
282
  # Build the admin REST API and store in ivar.
@@ -275,12 +288,12 @@ module Aspera
275
288
 
276
289
  # Lookup a share id by field/value using the admin API.
277
290
  def lookup_share_id(field, value, **)
278
- RestList.lookup_entity_generic(entity: 'share', field: field, value: value) { @api_shares_admin.read('data/shares') }['id']
291
+ Rest::List.lookup_entity_generic(entity: 'share', field: field, value: value) { @api_shares_admin.read('data/shares') }['id']
279
292
  end
280
293
 
281
294
  # --- health ---
282
295
 
283
- def action_health
296
+ def action_health(**)
284
297
  nagios = Nagios.new
285
298
  shares_url = options.get_option(:url, mandatory: true)
286
299
  health = self.class.health_check(shares_url)
@@ -298,44 +311,6 @@ module Aspera
298
311
  Result::ObjectList.new(nagios.status_list)
299
312
  end
300
313
 
301
- # --- files sub-commands (restricted to COMMANDS_SHARES, delegated to Node) ---
302
-
303
- commands_under :files do
304
- Node::COMMANDS_SHARES.each do |cmd|
305
- next if cmd.eql?(:sync) # intermediate node with sub-commands, declared separately below
306
- spec = Node::COMMANDS_GEN3_SPEC[cmd] || {description: "Node #{cmd} command"}
307
- command cmd, **spec
308
- end
309
- command :sync, description: 'Synchronize folders (Gen3)'
310
- commands_under :sync do
311
- Sync::Operations::DIRECTIONS.each do |dir|
312
- command dir, description: "#{dir.capitalize}-sync (Gen3)", transfer_paths: :send, arguments: SyncActions::PATH_AND_INFO_ARGS
313
- end
314
- command :admin, description: 'Manage sync database (admin operations)'
315
- SyncActions.register_sync_admin_commands(self, :admin)
316
- end
317
- end
318
-
319
- # One handler per COMMANDS_SHARES command (except :sync which is an intermediate node).
320
- Node::COMMANDS_SHARES.reject { |cmd| cmd.eql?(:sync) }.each do |cmd|
321
- define_action_method([:files, cmd]) do |shares_node_plugin:, **ctx|
322
- shares_node_plugin.dispatch_v3_command(cmd, **ctx)
323
- end
324
- end
325
-
326
- # Handlers for files > sync > <direction>: delegate to Node's [:sync, dir] leaf.
327
- Sync::Operations::DIRECTIONS.each do |dir|
328
- define_action_method([:files, :sync, dir]) do |shares_node_plugin:, **ctx|
329
- shares_node_plugin.dispatch_from_registry([:sync, dir], ctx, skip_setup: true)
330
- end
331
- end
332
- # Handlers for files > sync > admin > <op>: delegate to Node's [:sync, :admin, op] leaf.
333
- SyncActions::ADMIN_COMMANDS.each do |op|
334
- define_action_method([:files, :sync, :admin, op]) do |shares_node_plugin:, **ctx|
335
- shares_node_plugin.dispatch_from_registry([:sync, :admin, op], ctx, skip_setup: true)
336
- end
337
- end
338
-
339
314
  private
340
315
 
341
316
  # @return [String] admin API path for the given entity_type and location
@@ -346,42 +321,25 @@ module Aspera
346
321
 
347
322
  # --- admin > user|group handlers ---
348
323
 
349
- # Shared handler for list/show/delete/create/modify on user or group.
350
- # @param entity_type [Symbol] :user or :group
351
- # @param location [Symbol] :all or :local
352
- # @param op [Symbol] CRUD operation
353
- def action_admin_entity_crud(entity_type, location, op)
354
- path = admin_entity_path(entity_type, location)
355
- lookup = ->(f, v) { RestList.lookup_entity_generic(entity: entity_type, field: f, value: v) { @api_shares_admin.read(path) }['id'] }
356
- display_fields = entity_type.eql?(:user) ? %w[id user_id username first_name last_name email] : nil
357
- display_fields&.push('directory_user') if entity_type.eql?(:user) && location.eql?(:all)
358
- # :all excludes :create; :local has a documented POST+PUT requestBody for both users and groups
359
- body_component = location.eql?(:local) ? Schema::Registry::SHARES : nil
360
- id = Operations::INSTANCE.include?(op) ? options.instance_identifier(&lookup) : nil
361
- send(:"entity_#{op}", api: @api_shares_admin, entity: path, id: id, display_fields: display_fields, body_component: body_component)
362
- end
363
-
364
324
  # Shared handler for USR_GRP_SETTINGS (transfer_settings, app_authorizations, share_permissions)
365
325
  # @param entity_type [Symbol] :user or :group
366
326
  # @param location [Symbol] :all or :local
367
327
  # @param setting [Symbol] one of USR_GRP_SETTINGS
368
328
  # @param op [Symbol] CRUD operation
369
- # entity_id: resolved via arguments:(:identifier) on each setting leaf command
370
- def action_admin_entity_setting(entity_type, location, setting, op, entity_id:, permission_id: nil, **)
371
- path = admin_entity_path(entity_type, location)
372
- entity_path = "#{path}/#{entity_id}/#{setting}"
373
- is_singleton = !setting.eql?(:share_permissions)
374
- id = permission_id || (Operations::INSTANCE.include?(op) && !is_singleton ? options.instance_identifier { |f, v| lookup_share_id(f, v) } : nil)
375
- send(:"entity_#{op}", api: @api_shares_admin, entity: entity_path, id: id, is_singleton: is_singleton)
329
+ # entity_id: resolved via arguments:(:identifier) on the setting node
330
+ # permission_id: (share_permissions show) and data: (modify) resolved via arguments: on the leaf
331
+ def action_admin_entity_setting(entity_type, location, setting, op, entity_id:, permission_id: nil, data: nil)
332
+ entity_path = "#{admin_entity_path(entity_type, location)}/#{entity_id}/#{setting}"
333
+ send(:"entity_#{op}", api: @api_shares_admin, entity: entity_path, id: permission_id, is_singleton: !setting.eql?(:share_permissions), data: data)
376
334
  end
377
335
 
378
336
  # Shared handler for :users (group only)
379
337
  # group_id: resolved by Phase A via arguments:(:identifier) on the :users node
380
- def action_admin_entity_users(entity_type, location, op, group_id:, **)
338
+ # user_id: and data: resolved via arguments: on the leaf (GROUP_USERS_ARGS)
339
+ def action_admin_entity_users(entity_type, location, op, group_id:, user_id: nil, data: nil)
381
340
  path = admin_entity_path(entity_type, location)
382
341
  prefix = location.eql?(:all) ? '' : "#{location}_"
383
- id = Operations::INSTANCE.include?(op) ? options.instance_identifier : nil
384
- send(:"entity_#{op}", api: @api_shares_admin, entity: "#{path}/#{group_id}/#{prefix}users", id: id)
342
+ send(:"entity_#{op}", api: @api_shares_admin, entity: "#{path}/#{group_id}/#{prefix}users", id: user_id, data: data)
385
343
  end
386
344
 
387
345
  # Generate action_admin_<user|group>_<location>_<verb> for all combinations
@@ -389,19 +347,13 @@ module Aspera
389
347
  # all: list/show/delete only (no create, no modify — no update route on /users and /groups)
390
348
  # local: full CRUD only — no nested settings under local_users/local_groups
391
349
  ENTITY_LOCATIONS.each do |location|
392
- ops = location.eql?(:all) ? (Operations::ALL - %i[create modify]) : Operations::ALL
393
- ops.each do |op|
394
- define_action_method([:admin, entity_type, location, op]) do
395
- action_admin_entity_crud(entity_type, location, op)
396
- end
397
- end
398
350
  unless location.eql?(:local)
399
351
  USR_GRP_SETTINGS.each do |setting|
400
352
  # share_permissions: Rails only exposes index+show (read-only)
401
353
  setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
402
354
  setting_ops.each do |op|
403
355
  define_action_method([:admin, entity_type, location, setting, op]) do |**kwargs|
404
- action_admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], permission_id: kwargs[:permission_id])
356
+ action_admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], permission_id: kwargs[:permission_id], data: kwargs[setting])
405
357
  end
406
358
  end
407
359
  end
@@ -409,24 +361,24 @@ module Aspera
409
361
  next unless entity_type.eql?(:group)
410
362
  # group users: no create route (Rails only exposes index+show+update+destroy)
411
363
  GROUP_USERS_OPS.each do |op|
412
- define_action_method([:admin, entity_type, location, :users, op]) do |group_id:, **|
413
- action_admin_entity_users(entity_type, location, op, group_id: group_id)
364
+ define_action_method([:admin, entity_type, location, :users, op]) do |group_id:, user_id: nil, user: nil, **|
365
+ action_admin_entity_users(entity_type, location, op, group_id: group_id, user_id: user_id, data: user)
414
366
  end
415
367
  end
416
368
  end
417
369
 
418
370
  # ldap: add
419
- define_action_method([:admin, entity_type, :ldap, :add]) do |entity_name:, **|
371
+ define_action_method([:admin, entity_type, :ldap, :add]) do |**kwargs|
420
372
  path = admin_entity_path(entity_type, :ldap)
421
- bulk_result(entity_name, command: :add, id_result: entity_type.to_s) do |entity_name|
373
+ bulk_result(kwargs.fetch(entity_type), command: :add, id_result: entity_type.to_s) do |entity_name|
422
374
  @api_shares_admin.create(path, {entity_type => entity_name})
423
375
  end
424
376
  end
425
377
 
426
378
  # saml: import
427
- define_action_method([:admin, entity_type, :saml, :import]) do |entity_parameters:, **|
379
+ define_action_method([:admin, entity_type, :saml, :import]) do |**kwargs|
428
380
  path = admin_entity_path(entity_type, :saml)
429
- bulk_result(entity_parameters, command: :import) do |entity_parameters|
381
+ bulk_result(kwargs.fetch(entity_type), command: :import) do |entity_parameters|
430
382
  entity_parameters = entity_parameters.transform_keys { |k| k.gsub(/\s+/, '_').downcase }
431
383
  Aspera.assert_type(entity_parameters, Hash)
432
384
  SAML_IMPORT_MANDATORY.each { |p| raise "missing mandatory field: #{p}" if entity_parameters[p].nil? }
@@ -440,12 +392,12 @@ module Aspera
440
392
 
441
393
  # Handlers for admin > share > user_permissions|group_permissions > op
442
394
  # share_id: resolved by Phase A via arguments:(:identifier, lookup: :lookup_share_id) on the perm_type node
395
+ # permission_id: resolved via arguments: on the show leaf
443
396
  # Rails only exposes index+show for share permissions (read-only)
444
397
  %i[user_permissions group_permissions].each do |perm_type|
445
398
  SHARE_PERMISSIONS_OPS.each do |op|
446
- define_action_method([:admin, :share, perm_type, op]) do |share_id:, **|
447
- id = Operations::INSTANCE.include?(op) ? options.instance_identifier : nil
448
- send(:"entity_#{op}", api: @api_shares_admin, entity: "data/shares/#{share_id}/#{perm_type}", id: id)
399
+ define_action_method([:admin, :share, perm_type, op]) do |share_id:, permission_id: nil, **|
400
+ send(:"entity_#{op}", api: @api_shares_admin, entity: "data/shares/#{share_id}/#{perm_type}", id: permission_id)
449
401
  end
450
402
  end
451
403
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/assert'
4
+ require 'aspera/cli/preset_manager'
4
5
 
5
6
  module Aspera
6
7
  module Cli
@@ -11,10 +12,6 @@ module Aspera
11
12
  # Display columns for preset overview
12
13
  CONF_OVERVIEW_KEYS = %w[preset parameter value].freeze
13
14
 
14
- def action_preset_list(**)
15
- Result::ValueList.new(presets.config_presets.keys, name: 'name')
16
- end
17
-
18
15
  def action_preset_overview(**)
19
16
  cp = presets.config_presets
20
17
  # Display process modifies the value (hide secrets): we do not want to save removed secrets
@@ -55,18 +52,21 @@ module Aspera
55
52
 
56
53
  SECRET_KEYWORDS = %w[password secret].freeze
57
54
 
58
- # If +option_name+ ends with a secret keyword and the vault is configured,
55
+ # If `option_name` ends with a secret keyword and the vault is configured,
59
56
  # move the clear-text value into the vault and replace it with a @vault: reference.
60
57
  # @param preset [Hash] the preset hash (modified in place)
61
58
  # @param preset_name [String] name used as base for the vault label
62
59
  # @param option_name [String] option key to inspect
63
60
  def secure_preset_option(preset, preset_name, option_name)
64
61
  return unless SECRET_KEYWORDS.any? { |kw| option_name.end_with?(kw) }
62
+ # Vault password is not saved in vault
63
+ return if option_name.eql?('vault_password')
65
64
  # Never auto-secure the global preset: it holds vault credentials themselves
66
- return if preset_name.eql?(presets.global_default_preset)
65
+ # Checked before opening the vault, which may not be usable yet (e.g. vault_password not set)
66
+ return if presets.global_preset?(preset_name)
67
67
  return if vault.nil?
68
68
  value = preset[option_name]
69
- # Already a vault reference or nil — nothing to do
69
+ # Already a vault reference or nil - nothing to do
70
70
  return if value.nil? || value.to_s.start_with?('@vault:')
71
71
  vault_label = preset_name
72
72
  incr = 0
@@ -93,6 +93,12 @@ module Aspera
93
93
  cp = presets.config_presets
94
94
  raise "no such preset: #{name}" unless cp.key?(name)
95
95
  cp.delete(name)
96
+ # Remove default declarations referring to the deleted preset
97
+ defaults = cp[PresetManager::Key::DEFAULTS]
98
+ defaults&.select { |_, preset_name| preset_name.eql?(name) }&.each_key do |plugin|
99
+ defaults.delete(plugin)
100
+ Log.log.info { "Removed: #{PresetManager::Key::DEFAULTS}: #{plugin}" }
101
+ end
96
102
  Result::Status.new("Deleted: #{name}")
97
103
  end
98
104
 
@@ -124,11 +130,11 @@ module Aspera
124
130
  Result::Nothing.new
125
131
  end
126
132
 
127
- def action_preset_initialize(name:, config_value:, **)
133
+ def action_preset_initialize(name:, preset:, **)
128
134
  name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
129
135
  cp = presets.config_presets
130
136
  Log.log.warn { "configuration already exists: #{name}, overwriting" } if cp.key?(name)
131
- cp[name] = config_value
137
+ cp[name] = preset
132
138
  Result::Status.new("Modified: #{@option_config_file}")
133
139
  end
134
140
 
@@ -147,8 +153,9 @@ module Aspera
147
153
  name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
148
154
  cp = presets.config_presets
149
155
  cp[name] ||= {}
156
+ # Option names are only known at runtime: cannot be declared as interactive arguments
150
157
  option_names.each do |option_name|
151
- option_value = options.get_interactive(option_name, check_option: true)
158
+ option_value = options.get_interactive(option_name)
152
159
  cp[name][option_name] = option_value
153
160
  secure_preset_option(cp[name], name, option_name)
154
161
  end