aspera-cli 4.27.3 → 4.27.5

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 (79) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +118 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +1 -0
  6. data/docs/README.md +224 -42
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +0 -4
  9. data/lib/aspera/agent/desktop.rb +0 -4
  10. data/lib/aspera/agent/direct.rb +49 -21
  11. data/lib/aspera/agent/node.rb +6 -5
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/faspex.rb +2 -0
  14. data/lib/aspera/api/httpgw.rb +1 -2
  15. data/lib/aspera/api/node.rb +3 -3
  16. data/lib/aspera/ascp/installation.rb +1 -1
  17. data/lib/aspera/cli/async_transfer_store.rb +10 -9
  18. data/lib/aspera/cli/bootstrapper.rb +3 -1
  19. data/lib/aspera/cli/command_registry.rb +69 -7
  20. data/lib/aspera/cli/command_spec.rb +1 -1
  21. data/lib/aspera/cli/extended_value.rb +4 -3
  22. data/lib/aspera/cli/formatter.rb +10 -8
  23. data/lib/aspera/cli/http.rb +8 -20
  24. data/lib/aspera/cli/option_types.rb +3 -1
  25. data/lib/aspera/cli/option_value.rb +16 -19
  26. data/lib/aspera/cli/options.schema.yaml +86 -10
  27. data/lib/aspera/cli/parser.rb +30 -18
  28. data/lib/aspera/cli/plugins/aoc.rb +133 -156
  29. data/lib/aspera/cli/plugins/ats.rb +1 -7
  30. data/lib/aspera/cli/plugins/base.rb +40 -34
  31. data/lib/aspera/cli/plugins/config.rb +24 -11
  32. data/lib/aspera/cli/plugins/console.rb +1 -1
  33. data/lib/aspera/cli/plugins/faspex5.rb +30 -11
  34. data/lib/aspera/cli/plugins/faspio.rb +1 -1
  35. data/lib/aspera/cli/plugins/node.rb +26 -19
  36. data/lib/aspera/cli/plugins/orchestrator.rb +73 -44
  37. data/lib/aspera/cli/plugins/preview.rb +21 -19
  38. data/lib/aspera/cli/plugins/server.rb +3 -4
  39. data/lib/aspera/cli/plugins/shares.rb +21 -24
  40. data/lib/aspera/cli/preset_actions.rb +28 -18
  41. data/lib/aspera/cli/preset_manager.rb +34 -19
  42. data/lib/aspera/cli/prompt.rb +2 -1
  43. data/lib/aspera/cli/result.rb +33 -22
  44. data/lib/aspera/cli/runner.rb +1 -5
  45. data/lib/aspera/cli/sync_actions.rb +17 -16
  46. data/lib/aspera/cli/transfer_actions.rb +14 -3
  47. data/lib/aspera/cli/transfer_agent.rb +6 -4
  48. data/lib/aspera/cli/transfer_progress.rb +290 -55
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +1 -1
  51. data/lib/aspera/coverage.rb +0 -1
  52. data/lib/aspera/environment.rb +29 -5
  53. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  54. data/lib/aspera/keychain/factory.rb +2 -1
  55. data/lib/aspera/log.rb +25 -2
  56. data/lib/aspera/node_emulator.rb +759 -0
  57. data/lib/aspera/oauth/base.rb +2 -1
  58. data/lib/aspera/oauth/factory.rb +6 -3
  59. data/lib/aspera/oauth/json_credentials.rb +34 -0
  60. data/lib/aspera/oauth/jwt.rb +3 -4
  61. data/lib/aspera/oauth.rb +1 -0
  62. data/lib/aspera/persistency_folder.rb +1 -3
  63. data/lib/aspera/preview/generator.rb +4 -1
  64. data/lib/aspera/preview/options.schema.yaml +119 -0
  65. data/lib/aspera/rest/aspera_errors.rb +12 -0
  66. data/lib/aspera/rest/client.rb +28 -19
  67. data/lib/aspera/rest/list.rb +14 -8
  68. data/lib/aspera/rest/parameters.rb +2 -2
  69. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  70. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  71. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  72. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  73. data/lib/aspera/schema/reader.rb +12 -18
  74. data/lib/aspera/schema/registry.rb +6 -1
  75. data.tar.gz.sig +0 -0
  76. metadata +5 -3
  77. metadata.gz.sig +0 -0
  78. data/lib/aspera/node_simulator.rb +0 -345
  79. data/lib/aspera/preview/options.rb +0 -45
@@ -24,7 +24,7 @@ using Rainbow
24
24
  module Aspera
25
25
  module Cli
26
26
  module Plugins
27
- class Aoc < Oauth # rubocop:disable Metrics/ClassLength
27
+ class Aoc < Oauth
28
28
  # default redirect for AoC web auth
29
29
  REDIRECT_LOCALHOST = 'http://localhost:12345'
30
30
  # admin objects that can be manipulated
@@ -140,44 +140,46 @@ module Aspera
140
140
  }
141
141
  end
142
142
 
143
- # DSL helper: register the 5 short_link leaf commands under the given parent path
144
- # and define the corresponding action methods on `base`.
145
- # @param base [Class] the plugin class
146
- # @param parent_path [Array<Symbol>] full path ending with :short_link
147
- def register_short_link_commands(base, parent_path)
148
- base.commands_under(parent_path) do
149
- base.command(
150
- :create, description: base.operation_description(:create, 'short link'),
151
- arguments: [{name: :short_link, type: Hash, mandatory: false, default: {}}]
143
+ # Optional `Hash` argument of short link create and modify, merged into the request body
144
+ # @param endpoint [String] endpoint of the request body schema, e.g. `short_links.post`
145
+ # @return [Hash] argument declaration
146
+ def short_link_argument(endpoint)
147
+ {
148
+ name: :short_link, type: Hash, mandatory: false, default: {},
149
+ description: 'Merged into the request body, and: `password` (link password), `access_levels` (shared folder only)',
150
+ schema: Schema::Registry.req_body(Schema::Registry::AOC, endpoint)
151
+ }
152
+ end
153
+
154
+ # DSL class method: declare the 5 short link leaf commands under the given node.
155
+ # The node itself is declared by the caller, with its arguments and setup.
156
+ # @param parent [Symbol, Array<Symbol>] path relative to current scope, e.g. :short_link
157
+ def short_link_commands(parent)
158
+ commands_under(parent) do
159
+ command(
160
+ :create, description: operation_description(:create, 'short link'),
161
+ arguments: [short_link_argument('short_links.post')],
162
+ action: ->(short_link: {}, **ctx) { short_link_create(short_link, **ctx) }
152
163
  )
153
- base.command(
154
- :modify, description: base.operation_description(:modify, 'short link'),
155
- arguments: [{name: :short_link_id, type: :identifier}, {name: :short_link, type: Hash, mandatory: false, default: {}}]
164
+ command(
165
+ :modify, description: operation_description(:modify, 'short link'),
166
+ arguments: [{name: :short_link_id, type: :identifier}, short_link_argument('short_links/{id}.put')],
167
+ action: ->(short_link: {}, **ctx) { short_link_modify(short_link, **short_link_fetch_list(**ctx), **ctx) }
156
168
  )
157
- base.command(:list, description: base.operation_description(:list, 'short link'))
158
- base.command(
159
- :show, description: base.operation_description(:show, 'short link'),
160
- arguments: [{name: :short_link_id, type: :identifier}]
169
+ command(
170
+ :list, description: operation_description(:list, 'short link'), query_schema: SHORT_LINK_QUERY_SCHEMA,
171
+ action: ->(**ctx) { short_link_list(**short_link_fetch_list(**ctx)) }
161
172
  )
162
- base.command(
163
- :delete, description: base.operation_description(:delete, 'short link'),
164
- arguments: [{name: :short_link_id, type: :identifier}]
173
+ command(
174
+ :show, description: operation_description(:show, 'short link'),
175
+ arguments: [{name: :short_link_id, type: :identifier}],
176
+ action: ->(**ctx) { short_link_show(**short_link_fetch_list(**ctx), **ctx) }
177
+ )
178
+ command(
179
+ :delete, description: operation_description(:delete, 'short link'),
180
+ arguments: [{name: :short_link_id, type: :identifier}],
181
+ action: ->(**ctx) { short_link_delete(**short_link_fetch_list(**ctx), **ctx) }
165
182
  )
166
- end
167
- base.define_action_method(parent_path + [:create]) do |short_link: {}, **ctx|
168
- sl_exec_create(short_link, **ctx)
169
- end
170
- base.define_action_method(parent_path + [:list]) do |**ctx|
171
- sl_exec_list(**sl_fetch_list(**ctx))
172
- end
173
- base.define_action_method(parent_path + [:show]) do |**ctx|
174
- sl_exec_show(**sl_fetch_list(**ctx))
175
- end
176
- base.define_action_method(parent_path + [:delete]) do |**ctx|
177
- sl_exec_delete(**sl_fetch_list(**ctx), **ctx)
178
- end
179
- base.define_action_method(parent_path + [:modify]) do |short_link: {}, **ctx|
180
- sl_exec_modify(short_link, **sl_fetch_list(**ctx), **ctx)
181
183
  end
182
184
  end
183
185
  end
@@ -474,13 +476,10 @@ module Aspera
474
476
  )))
475
477
  end
476
478
 
477
- # Execute an action on admin resources
478
- # @param resource_type [Symbol] One of ADMIN_OBJECTS
479
479
  # Per-resource configuration for admin CRUD resources.
480
480
  # Keys: path, list_fields, id_result, require_ws_id, create_schema, extra_ops, singleton, op_setup, op_mount, op_descriptions
481
- # op_setup: Hash of op => setup method name, used for ops that require consuming an instance identifier.
482
- # For Operations::INSTANCE ops (show/modify/delete), use the auto-generated :setup_admin_<res>_instance.
483
- # For extra_ops that are instance ops, specify explicitly (or rely on the auto-generated one).
481
+ # Ops other than `create` and `list` read the identifier `<res>_id` (argument of the op node).
482
+ # op_setup: Hash of op => setup method name, for ops that need context derived from the identifier.
484
483
  # op_mount: Hash of op => mount: of that op's node.
485
484
  # op_descriptions: Hash of op => description, for ops other than standard ones.
486
485
  ADMIN_OBJECT_CONFIG = {
@@ -515,7 +514,6 @@ module Aspera
515
514
  user: {
516
515
  list_fields: %w[id name email],
517
516
  extra_ops: %i[preferences notifications],
518
- op_setup: {preferences: :setup_admin_user_instance, notifications: :setup_admin_user_instance},
519
517
  op_descriptions: {preferences: 'Manage user preferences', notifications: 'Manage user notification preferences'}
520
518
  },
521
519
  workspace: {
@@ -544,37 +542,24 @@ module Aspera
544
542
  }))
545
543
  end
546
544
 
547
- # Compute short-link purposes from shared_data keys and link_type.
548
- # @param shared_data [Hash] :dropbox_id + :name or :file_id + :node_id
549
- # @param link_type [Symbol] :public or :private
550
- # @return [Array(String,String)] [token_purpose, short_link_purpose]
551
- def short_link_purposes(shared_data, link_type)
552
- if shared_data.keys.sort == %i[dropbox_id name]
553
- token_purpose = 'send_package_to_dropbox'
554
- short_link_purpose = link_type.eql?(:public) ? 'send_package_to_dropbox' : 'shared_folder_auth_link'
555
- elsif shared_data.keys.sort == %i[file_id node_id]
556
- token_purpose = 'view_shared_file'
557
- short_link_purpose = link_type.eql?(:public) ? 'token_auth_redirection' : 'shared_folder_auth_link'
558
- else
559
- Aspera.error_unexpected_value(shared_data.keys)
560
- end
561
- [token_purpose, short_link_purpose]
562
- end
545
+ # Purpose of a short link, by link type (the purpose of its URL token depends on the shared resource)
546
+ SHORT_LINK_PURPOSES = {public: 'token_auth_redirection', private: 'shared_folder_auth_link'}.freeze
547
+ # Query parameters of short link list
548
+ SHORT_LINK_QUERY_SCHEMA = Schema::Registry.query_params(Schema::Registry::AOC, 'short_links')
563
549
 
564
550
  # Build the list_params hash used by delete/list/show/modify short link operations.
565
551
  # @return [Hash]
566
- def short_link_list_params(shared_data:, link_type:, token_purpose:, short_link_purpose:, **)
552
+ def short_link_list_params(shared_data:, link_type:, token_purpose:, short_link_purpose:, **ctx)
567
553
  query = if link_type.eql?(:private)
568
554
  shared_data
569
555
  else
570
556
  {url_token_data: {data: shared_data, purpose: token_purpose}}
571
557
  end
572
- {
573
- json_query: query.to_json,
574
- purpose: short_link_purpose,
575
- edit_access: true,
576
- sort: '-created_at'
577
- }
558
+ # String keys: the user's `--query` overrides them
559
+ list_params = {'json_query' => query.to_json, 'purpose' => short_link_purpose, 'sort' => '-created_at'}
560
+ # `edit_access` requires `file_id` and `node_id`: shared folder links only
561
+ list_params['edit_access'] = true if ctx.key?(:shared_apifid)
562
+ list_params
578
563
  end
579
564
 
580
565
  # @return [PersistencyActionOnce, nil] persistency object if option `once_only` is used.
@@ -926,22 +911,20 @@ module Aspera
926
911
  arguments: [{name: :dropbox_id, type: :identifier, lookup: :lookup_aoc_dropbox_id}],
927
912
  action: ->(dropbox_id:, **) { Result::SingleObject.new(aoc_api.read("dropboxes/#{dropbox_id}")) }
928
913
  command :short_link, description: 'Manage shared inbox short links',
929
- arguments: [{name: :link_type, allowed: %i[public private]}, {name: :dropbox_id, type: :identifier, lookup: :lookup_aoc_dropbox_id}],
914
+ arguments: [{name: :link_type, allowed: %i[public]}, {name: :dropbox_id, type: :identifier, lookup: :lookup_aoc_dropbox_id}],
930
915
  setup: :setup_packages_short_link
916
+ short_link_commands :short_link
931
917
  end
932
- # packages > shared_inboxes > short_link sub-commands
933
- register_short_link_commands(self, %i[packages shared_inboxes short_link])
934
918
 
935
919
  # files sub-commands: Gen4 commands are mounted, plus AoC-specific commands
936
920
  commands_under :files do
937
921
  command :short_link, description: 'Manage file short link',
938
922
  arguments: [{name: :folder}, {name: :link_type, allowed: %i[public private]}],
939
923
  setup: :setup_files_short_link
924
+ short_link_commands :short_link
940
925
  command :transfer, description: 'Transfer files (node-to-node)', arguments: TRANSFER_ARGS,
941
926
  action: ->(direction:, source_folder:, **) { nodegen4_transfer(aoc_api.home[:node_id], file_id: aoc_api.home[:file_id], scope: Api::Node::Scope::USER, direction: direction, source_folder: source_folder) }
942
927
  end
943
- # files > short_link sub-commands
944
- register_short_link_commands(self, %i[files short_link])
945
928
 
946
929
  # automation sub-commands
947
930
  # Automation API: a workflow has ordered steps (step_order), a step has ordered actions (action_order)
@@ -1156,8 +1139,8 @@ module Aspera
1156
1139
  end
1157
1140
 
1158
1141
  # setup: files > short_link
1159
- # Resolves the target folder, consumes link_type argument, computes purposes.
1160
- # @return [Hash] ctx keys: sl_shared_data, sl_link_type, sl_token_purpose, sl_short_link_purpose, sl_perm_block, sl_shared_apifid, sl_folder_dest
1142
+ # Resolves the target folder, sets purposes.
1143
+ # @return [Hash] ctx keys: shared_data, token_purpose, short_link_purpose, shared_apifid
1161
1144
  def setup_files_short_link(folder:, link_type:, **)
1162
1145
  home_node_api = aoc_api.node_api_from(
1163
1146
  node_id: aoc_api.home[:node_id],
@@ -1168,77 +1151,75 @@ module Aspera
1168
1151
  node_id: shared_apifid.node_api.app_info.node_info['id'],
1169
1152
  file_id: shared_apifid.file_id
1170
1153
  }
1171
- token_purpose, short_link_purpose = short_link_purposes(shared_data, link_type)
1172
- perm_block = lambda do |op, id, access_levels|
1173
- case op
1174
- when :create
1175
- perm_data = {
1176
- 'file_id' => shared_apifid.file_id,
1177
- 'access_id' => id,
1178
- 'access_type' => 'user',
1179
- 'access_levels' => Api::AoC.expand_access_levels(access_levels),
1180
- 'tags' => {
1181
- 'url_token' => true,
1182
- 'folder_name' => File.basename(folder),
1183
- 'created_by_name' => aoc_api.current_user_info['name'],
1184
- 'created_by_email' => aoc_api.current_user_info['email'],
1185
- 'access_key' => shared_apifid.node_api.app_info.node_info['access_key'],
1186
- 'node' => shared_apifid.node_api.app_info.node_info['name'],
1187
- **workspace_id_hash(string: true, name: true)
1188
- }
1189
- }
1190
- created_data = shared_apifid.node_api.create('permissions', perm_data)
1191
- aoc_api.permissions_send_event(event_data: created_data, app_info: shared_apifid.node_api.app_info)
1192
- when :update
1193
- found = shared_apifid.node_api.read('permissions', {file_id: shared_apifid.file_id, inherited: false, access_type: 'user', access_id: id}).find { |i| i['access_id'].eql?(id) }
1194
- Aspera.assert(!found.nil?, type: Error) { "Short link not found: #{id}" }
1195
- shared_apifid.node_api.update("permissions/#{found['id']}", {access_levels: Api::AoC.expand_access_levels(access_levels)})
1196
- when :delete
1197
- found = shared_apifid.node_api.read('permissions', {file_id: shared_apifid.file_id, inherited: false, access_type: 'user', access_id: id}).first
1198
- Aspera.assert(!found.nil?, type: Error) { "Short link not found: #{id}" }
1199
- shared_apifid.node_api.delete("permissions/#{found['id']}")
1200
- else Aspera.error_unexpected_value(op)
1201
- end
1202
- end
1203
1154
  {
1204
- sl_shared_data: shared_data,
1205
- sl_link_type: link_type,
1206
- sl_token_purpose: token_purpose,
1207
- sl_short_link_purpose: short_link_purpose,
1208
- sl_perm_block: perm_block
1155
+ shared_data: shared_data,
1156
+ token_purpose: 'view_shared_file',
1157
+ short_link_purpose: SHORT_LINK_PURPOSES[link_type],
1158
+ shared_apifid: shared_apifid
1209
1159
  }
1210
1160
  end
1211
1161
 
1162
+ # Node permission of a public short link on the shared folder (files > short_link only)
1163
+ # @param op [Symbol] :create, :update or :delete
1164
+ # @param id [String] Short link resource id: access_id of the permission
1165
+ # @param access_levels [String, Array, nil] Access levels for :create and :update
1166
+ def short_link_permission(op, id, access_levels, shared_apifid:, folder:, **)
1167
+ case op
1168
+ when :create
1169
+ perm_data = {
1170
+ 'file_id' => shared_apifid.file_id,
1171
+ 'access_id' => id,
1172
+ 'access_type' => 'user',
1173
+ 'access_levels' => Api::AoC.expand_access_levels(access_levels),
1174
+ 'tags' => {
1175
+ 'url_token' => true,
1176
+ 'folder_name' => File.basename(folder),
1177
+ 'created_by_name' => aoc_api.current_user_info['name'],
1178
+ 'created_by_email' => aoc_api.current_user_info['email'],
1179
+ 'access_key' => shared_apifid.node_api.app_info.node_info['access_key'],
1180
+ 'node' => shared_apifid.node_api.app_info.node_info['name'],
1181
+ **workspace_id_hash(string: true, name: true)
1182
+ }
1183
+ }
1184
+ created_data = shared_apifid.node_api.create('permissions', perm_data)
1185
+ aoc_api.permissions_send_event(event_data: created_data, app_info: shared_apifid.node_api.app_info)
1186
+ when :update
1187
+ found = shared_apifid.node_api.read('permissions', {file_id: shared_apifid.file_id, inherited: false, access_type: 'user', access_id: id}).find { |i| i['access_id'].eql?(id) }
1188
+ Aspera.assert(!found.nil?, type: Error) { "Short link not found: #{id}" }
1189
+ shared_apifid.node_api.update("permissions/#{found['id']}", {access_levels: Api::AoC.expand_access_levels(access_levels)})
1190
+ when :delete
1191
+ found = shared_apifid.node_api.read('permissions', {file_id: shared_apifid.file_id, inherited: false, access_type: 'user', access_id: id}).first
1192
+ Aspera.assert(!found.nil?, type: Error) { "Short link not found: #{id}" }
1193
+ shared_apifid.node_api.delete("permissions/#{found['id']}")
1194
+ else Aspera.error_unexpected_value(op)
1195
+ end
1196
+ end
1197
+
1212
1198
  # setup: packages > shared_inboxes > short_link
1213
- # link_type: and dropbox_id: resolved via arguments: on the node, computes purposes.
1214
- # @return [Hash] ctx keys: sl_shared_data, sl_link_type, sl_token_purpose, sl_short_link_purpose
1199
+ # link_type: and dropbox_id: resolved via arguments: on the node, sets purposes.
1200
+ # @return [Hash] ctx keys: shared_data, token_purpose, short_link_purpose
1215
1201
  def setup_packages_short_link(link_type:, dropbox_id:, **)
1216
- shared_data = {dropbox_id: dropbox_id, name: ''}
1217
- token_purpose, short_link_purpose = short_link_purposes(shared_data, link_type)
1218
1202
  {
1219
- sl_shared_data: shared_data,
1220
- sl_link_type: link_type,
1221
- sl_token_purpose: token_purpose,
1222
- sl_short_link_purpose: short_link_purpose,
1223
- sl_perm_block: nil
1203
+ shared_data: {dropbox_id: dropbox_id, name: ''},
1204
+ token_purpose: 'send_package_to_dropbox',
1205
+ short_link_purpose: SHORT_LINK_PURPOSES[link_type]
1224
1206
  }
1225
1207
  end
1226
1208
 
1227
1209
  # Shared implementation for short_link > create
1228
- def sl_exec_create(custom_data = {}, sl_shared_data:, sl_link_type:, sl_token_purpose:, sl_short_link_purpose:, sl_perm_block:, **)
1229
- shared_data = sl_shared_data.dup
1230
- workspace_id_hash(shared_data)
1231
- create_payload = {purpose: sl_short_link_purpose, user_selected_name: nil}
1232
- case sl_link_type
1210
+ def short_link_create(custom_data = {}, shared_data:, link_type:, token_purpose:, short_link_purpose:, **ctx)
1211
+ ws_shared_data = workspace_id_hash(shared_data.dup)
1212
+ create_payload = {purpose: short_link_purpose, user_selected_name: nil}
1213
+ case link_type
1233
1214
  when :private
1234
- create_payload[:data] = shared_data
1215
+ create_payload[:data] = ws_shared_data
1235
1216
  when :public
1236
1217
  create_payload[:expires_at] = nil
1237
1218
  create_payload[:password_enabled] = false
1238
- shared_data[:name] = ''
1219
+ ws_shared_data[:name] = ''
1239
1220
  create_payload[:data] = {
1240
1221
  aoc: true,
1241
- url_token_data: {data: shared_data, purpose: sl_token_purpose}
1222
+ url_token_data: {data: ws_shared_data, purpose: token_purpose}
1242
1223
  }
1243
1224
  end
1244
1225
  custom_data = custom_data.dup
@@ -1249,66 +1230,65 @@ module Aspera
1249
1230
  end
1250
1231
  create_payload.deep_merge!(custom_data)
1251
1232
  result_create_short_link = aoc_api.create('short_links', create_payload)
1252
- sl_perm_block&.call(:create, result_create_short_link['resource_id'], access_levels) if sl_link_type.eql?(:public)
1233
+ short_link_permission(:create, result_create_short_link['resource_id'], access_levels, **ctx) if link_type.eql?(:public) && ctx.key?(:shared_apifid)
1253
1234
  Result::SingleObject.new(result_create_short_link)
1254
1235
  end
1255
1236
 
1256
1237
  # Shared implementation for short_link > delete|list|show|modify: fetch the short_list
1257
- def sl_fetch_list(sl_shared_data:, sl_link_type:, sl_token_purpose:, sl_short_link_purpose:, **)
1258
- shared_data = sl_shared_data.dup
1259
- workspace_id_hash(shared_data)
1260
- list_params = short_link_list_params(
1261
- shared_data: shared_data, link_type: sl_link_type,
1262
- token_purpose: sl_token_purpose, short_link_purpose: sl_short_link_purpose
1263
- )
1238
+ def short_link_fetch_list(shared_data:, **ctx)
1239
+ ws_shared_data = workspace_id_hash(shared_data.dup)
1240
+ list_params = short_link_list_params(shared_data: ws_shared_data, **ctx)
1264
1241
  {
1265
- sl_short_list: aoc_api.read_with_paging('short_links', list_params.merge(query_read_delete(default: {})).compact),
1266
- sl_shared_data_ws: shared_data
1242
+ short_list: aoc_api.read_with_paging('short_links', list_params.merge(query_read_delete(default: {}, schema: SHORT_LINK_QUERY_SCHEMA)).compact),
1243
+ ws_shared_data: ws_shared_data
1267
1244
  }
1268
1245
  end
1269
1246
 
1270
1247
  # Shared implementation for short_link > delete
1271
- def sl_exec_delete(sl_shared_data_ws:, sl_short_list:, sl_link_type:, sl_perm_block:, short_link_id: nil, **)
1248
+ def short_link_delete(ws_shared_data:, short_list:, link_type:, short_link_id: nil, **ctx)
1272
1249
  one_id = short_link_id
1273
- if sl_link_type.eql?(:public)
1274
- found = sl_short_list[:items].find { |item| item['id'].eql?(one_id) }
1250
+ if link_type.eql?(:public)
1251
+ found = short_list[:items].find { |item| item['id'].eql?(one_id) }
1275
1252
  raise BadIdentifier.new('Short link', one_id) if found.nil?
1276
- sl_perm_block&.call(:delete, found['resource_id'], nil)
1253
+ short_link_permission(:delete, found['resource_id'], nil, **ctx) if ctx.key?(:shared_apifid)
1277
1254
  end
1278
- aoc_api.delete("short_links/#{one_id}", {edit_access: true, json_query: sl_shared_data_ws.to_json})
1255
+ # `edit_access` requires `file_id` and `node_id`: shared folder links only
1256
+ aoc_api.delete("short_links/#{one_id}", ({edit_access: true, json_query: ws_shared_data.to_json} if ctx.key?(:shared_apifid)))
1279
1257
  Result::Status.new('deleted')
1280
1258
  end
1281
1259
 
1282
1260
  # Shared implementation for short_link > list
1283
- def sl_exec_list(sl_short_list:, **)
1284
- Result::ObjectList.new(sl_short_list[:items], fields: Formatter.all_but('data'), total: sl_short_list[:total])
1261
+ def short_link_list(short_list:, **)
1262
+ Result::ObjectList.new(short_list[:items], fields: Formatter.all_but('data'), total: short_list[:total])
1285
1263
  end
1286
1264
 
1287
1265
  # Shared implementation for short_link > show
1288
- def sl_exec_show(sl_short_list:, short_link_id: nil, **)
1266
+ def short_link_show(short_list:, short_link_id: nil, **)
1289
1267
  one_id = short_link_id
1290
- found = sl_short_list[:items].find { |item| item['id'].eql?(one_id) }
1268
+ found = short_list[:items].find { |item| item['id'].eql?(one_id) }
1291
1269
  raise BadIdentifier.new('Short link', one_id) if found.nil?
1292
1270
  Result::SingleObject.new(found, fields: Formatter.all_but('data'))
1293
1271
  end
1294
1272
 
1295
1273
  # Shared implementation for short_link > modify
1296
- def sl_exec_modify(custom_data = {}, sl_shared_data:, sl_short_list:, sl_link_type:, sl_perm_block:, short_link_id: nil, **)
1297
- Aspera.assert_values(sl_link_type, [:public], type: Cli::BadArgument) { 'link_type' }
1274
+ def short_link_modify(custom_data = {}, shared_data:, short_list:, link_type:, short_link_id: nil, **ctx)
1275
+ Aspera.assert_values(link_type, [:public], type: Cli::BadArgument) { 'link_type' }
1298
1276
  one_id = short_link_id
1299
- node_file = sl_shared_data.slice(:node_id, :file_id)
1300
- modify_payload = {edit_access: true, json_query: node_file}
1277
+ # Identifies the shared resource: `node_id` and `file_id`, or `dropbox_id`
1278
+ token_data = shared_data.except(:name)
1279
+ # `edit_access` requires `file_id` and `node_id`: shared folder links only
1280
+ modify_payload = ctx.key?(:shared_apifid) ? {edit_access: true, json_query: token_data} : {}
1301
1281
  custom_data = custom_data.dup
1302
1282
  if (pass = custom_data.delete('password'))
1303
1283
  modify_payload[:password_enabled] = true
1304
- modify_payload[:data] = {url_token_data: {password: pass, data: node_file}}
1284
+ modify_payload[:data] = {url_token_data: {password: pass, data: token_data}}
1305
1285
  else
1306
1286
  modify_payload[:password_enabled] = false
1307
1287
  end
1308
- if custom_data.delete('access_levels')
1309
- found = sl_short_list[:items].find { |item| item['id'].eql?(one_id) }
1288
+ if (access_levels = custom_data.delete('access_levels'))
1289
+ found = short_list[:items].find { |item| item['id'].eql?(one_id) }
1310
1290
  raise BadIdentifier.new('Short link', one_id) if found.nil?
1311
- sl_perm_block&.call(:update, found['resource_id'], nil)
1291
+ short_link_permission(:update, found['resource_id'], access_levels, **ctx) if ctx.key?(:shared_apifid)
1312
1292
  end
1313
1293
  modify_payload.deep_merge!(custom_data)
1314
1294
  aoc_api.update("short_links/#{one_id}", modify_payload)
@@ -1544,9 +1524,6 @@ module Aspera
1544
1524
  Ats.new(context: context, api: ats_api)
1545
1525
  end
1546
1526
 
1547
- # admin > node > do | bearer_token — setup reuses the generic instance setup
1548
- # (setup_admin_node_instance is auto-generated above, providing res_id:)
1549
-
1550
1527
  # admin > node > do - mount target: Gen4 commands on the node, admin scope
1551
1528
  def admin_node_do_plugin(node_id:, **)
1552
1529
  nodegen4_plugin(node_id, scope: Api::Node::Scope::ADMIN)
@@ -1617,7 +1594,7 @@ module Aspera
1617
1594
  end
1618
1595
 
1619
1596
  # admin > user > preferences|notifications > show|modify
1620
- # (setup_admin_user_instance is auto-generated, providing res_id:)
1597
+ # (user_id: argument of the preferences|notifications node)
1621
1598
  %i[preferences notifications].each do |pref|
1622
1599
  pref_path = pref.eql?(:preferences) ? 'user_interaction_preferences' : 'notification_preferences'
1623
1600
  define_action_method([:admin, :user, pref, :show]) do |user_id:, **|
@@ -229,12 +229,6 @@ module Aspera
229
229
  [Node.new(context: context, api: api_node), {do_root_file_id: ak_data['root_file_id']}]
230
230
  end
231
231
 
232
- def action_api_key_instances
233
- instances = ats_api_v2_auth_ibm.read('instances')
234
- Log.log.warn { "more instances remaining: #{instances['remaining']}" } unless instances['remaining'].to_i.eql?(0)
235
- Result::ValueList.new(instances['data'], name: 'instance')
236
- end
237
-
238
232
  def action_access_key_cluster(access_key_id:, **)
239
233
  ats_url = ats_api.base_url
240
234
  api_ak_auth = Rest::Client.new(
@@ -255,7 +249,7 @@ module Aspera
255
249
  def build_ats_ibm_api_with_instance
256
250
  instance = options.get_option(:instance)
257
251
  if instance.nil?
258
- instance = ats_api_v2_auth_ibm.read('instances')['data'].first
252
+ instance = ats_api_v2_auth_ibm.read('instances')['data']&.first
259
253
  formatter.display_status("using first instance: #{instance}")
260
254
  end
261
255
  ats_api_v2_auth_ibm({'X-ATS-Service-Instance-Id' => instance})
@@ -6,6 +6,7 @@ require 'aspera/assert'
6
6
  require 'aspera/cli/result'
7
7
  require 'aspera/cli/command_registry'
8
8
  require 'aspera/cli/option_declarator'
9
+ require 'aspera/cli/special_values'
9
10
  require 'aspera/schema/registry'
10
11
 
11
12
  module Aspera
@@ -26,16 +27,18 @@ module Aspera
26
27
  class << self
27
28
  include OptionDeclarator
28
29
 
29
- # Option sources added with `use_options`.
30
- # @return [Array<Class, Module>]
30
+ # Option sources added with `use_options`, with their `on_set` target.
31
+ # @return [Hash{Class, Module => Proc, nil}]
31
32
  def used_option_sources
32
- @used_option_sources ||= []
33
+ @used_option_sources ||= {}
33
34
  end
34
35
 
35
36
  # Include options from another plugin or OptionDeclarator module.
36
37
  # @param source [Class, Module]
37
- def use_options(source)
38
- used_option_sources << source unless used_option_sources.include?(source)
38
+ # @param target [Proc, nil] Executed on the plugin instance, returns the object for Symbol and Proc `on_set` callbacks
39
+ # of `source` (e.g. `-> { http_config }`); nil: the plugin instance
40
+ def use_options(source, target: nil)
41
+ used_option_sources[source] = target unless used_option_sources.key?(source)
39
42
  end
40
43
 
41
44
  # Per-class DSL registry (not inherited: each subclass gets its own instance).
@@ -218,15 +221,15 @@ module Aspera
218
221
 
219
222
  # Classes and modules whose options apply to this plugin:
220
223
  # this class, its plugin ancestors and sources added via `use_options`.
221
- # @return [Array<Class, Module>] each responds to `option_specs`
224
+ # @return [Hash{Class, Module => Proc, nil}] key responds to `option_specs`, value: `on_set` target (see `use_options`)
222
225
  def option_sources
223
- sources = []
226
+ sources = {}
224
227
  ancestors.each do |klass|
225
228
  next unless klass.is_a?(Class) && klass <= Base
226
- sources << klass if klass.instance_variable_defined?(:@command_registry)
227
- sources.concat(klass.used_option_sources)
229
+ sources[klass] = nil if klass.instance_variable_defined?(:@command_registry) && !sources.key?(klass)
230
+ klass.used_option_sources.each { |src, src_target| sources[src] = src_target unless sources.key?(src) }
228
231
  end
229
- sources.uniq
232
+ sources
230
233
  end
231
234
 
232
235
  # Declare all options of `option_sources` onto a Parser instance.
@@ -235,9 +238,10 @@ module Aspera
235
238
  # @param target [Base, nil] plugin instance for Symbol and Proc `on_set` callbacks; nil: such callbacks are not bound
236
239
  # @param parse [Boolean] whether to call parse_options! after declaring
237
240
  def declare_options(options, target: nil, parse: false)
238
- option_sources.each do |src|
241
+ option_sources.each do |src, src_target|
242
+ src_target = src_target.nil? ? target : target&.instance_exec(&src_target)
239
243
  src.option_specs.each_value do |spec|
240
- spec.declare_on(options, target: target) unless options.option_declared?(spec.name)
244
+ spec.declare_on(options, target: src_target) unless options.option_declared?(spec.name)
241
245
  end
242
246
  end
243
247
  options.parse_options! if parse
@@ -312,11 +316,6 @@ module Aspera
312
316
  # @return [Aspera::Cli::TransferProgress, nil]
313
317
  def progress_bar; @context.progress_bar; end
314
318
 
315
- def add_manual_header(_has_options = true)
316
- # No-op: the group is set at the start of initialize.
317
- # Kept for compatibility with Config, which calls add_manual_header(false) from Runner.
318
- end
319
-
320
319
  # Entry point for all DSL-based plugins.
321
320
  def execute_action
322
321
  @help_path = nil
@@ -390,7 +389,8 @@ module Aspera
390
389
  # (mounted children are only walked here for --help, see below).
391
390
  # With --help, conditions are not evaluated: they may need the API, which is not built for help.
392
391
  available = children.reject { |id, c| c.condition && !@context.help_requested && registry.local?(current_path + [id]) && !send(c.condition) }
393
- aliases = children.values.each_with_object({}) do |c, h|
392
+ # Aliases of available commands only: an alias must not bypass the condition of its command
393
+ aliases = available.values.each_with_object({}) do |c, h|
394
394
  Array(c.aliases).each { |a| h[a] = c.id } if c.aliases
395
395
  end
396
396
 
@@ -485,14 +485,19 @@ module Aspera
485
485
  end
486
486
 
487
487
  # Resolve positional arguments from the CLI argument stream, in order.
488
- # Arguments already present in `ctx` are not read again.
488
+ # Arguments already present in `ctx` (e.g. injected by a setup method) are not read from the command line.
489
+ # If the next CLI argument is that same value, or `ALL`, it is skipped, so that it is not mistaken for a later argument
490
+ # (e.g. `faspex5 packages receive ALL` with a public link to a package).
489
491
  # For type: :identifier, the percent-selector lookup receives the ctx accumulated so far.
490
492
  # @param arg_specs [Array<ArgumentSpec>]
491
493
  # @param ctx [Hash] accumulated context
492
494
  # @return [Hash] ctx merged with the resolved arguments
493
495
  def resolve_arguments(arg_specs, ctx)
494
496
  arg_specs.each do |arg_spec|
495
- next if ctx.key?(arg_spec.name)
497
+ if ctx.key?(arg_spec.name)
498
+ options.get_next_argument(arg_spec.name.to_s, mandatory: false) if options.next_argument_in?([ctx[arg_spec.name].to_s, SpecialValues::ALL])
499
+ next
500
+ end
496
501
  lookup_cb = arg_spec.lookup if arg_spec.type.eql?(:identifier)
497
502
  current = ctx
498
503
  block =
@@ -508,6 +513,7 @@ module Aspera
508
513
 
509
514
  # Resolve a single positional argument from the CLI argument stream.
510
515
  # When arg_spec.bulk is true, always returns an Array (normalized to [value] when non-bulk).
516
+ # Only such arguments are read as a list with `--bulk=yes`: others (e.g. the id of a parent node) are single values.
511
517
  # For type: :identifier, an optional block provides the percent-selector lookup.
512
518
  # @param arg_spec [ArgumentSpec]
513
519
  # @yieldparam field [String] field name from a percent-selector (%field:value)
@@ -518,7 +524,7 @@ module Aspera
518
524
  if arg_spec.bulk
519
525
  is_bulk = options.get_option(:bulk)
520
526
  if arg_spec.type.eql?(:identifier)
521
- val = options.instance_identifier(description: arg_spec.name.to_s, &block)
527
+ val = options.instance_identifier(description: arg_spec.name.to_s, multiple: is_bulk, &block)
522
528
  else
523
529
  val = options.get_next_argument(
524
530
  arg_spec.name.to_s,
@@ -539,19 +545,19 @@ module Aspera
539
545
  options.instance_identifier(description: arg_spec.name.to_s, &block)
540
546
  else
541
547
  # Class or Array<Class> -> pass as validation type
542
- # When interactive: true, set ask_missing_mandatory so that get_interactive is triggered
543
- # when no CLI arguments are provided (mandatory is forced to true for the same reason:
544
- # a non-nil default would short-circuit get_interactive before it is ever called).
545
- options.ask_missing_mandatory = true if arg_spec.interactive
546
- options.get_next_argument(
547
- arg_spec.name.to_s,
548
- mandatory: arg_spec.interactive ? true : arg_spec.mandatory,
549
- multiple: arg_spec.multiple || false,
550
- validation: arg_spec.type,
551
- accept_list: arg_spec.allowed,
552
- default: arg_spec.interactive ? nil : arg_spec.default,
553
- schema: arg_spec.schema
554
- )
548
+ # When interactive: true, prompt for this argument only when no CLI argument is provided
549
+ # (mandatory is forced to true and default ignored: a non-nil default would short-circuit the prompt).
550
+ options.with_interactive(enabled: arg_spec.interactive) do
551
+ options.get_next_argument(
552
+ arg_spec.name.to_s,
553
+ mandatory: arg_spec.interactive ? true : arg_spec.mandatory,
554
+ multiple: arg_spec.multiple || false,
555
+ validation: arg_spec.type,
556
+ accept_list: arg_spec.allowed,
557
+ default: arg_spec.interactive ? nil : arg_spec.default,
558
+ schema: arg_spec.schema
559
+ )
560
+ end
555
561
  end
556
562
  end
557
563
  end