aspera-cli 4.27.2 → 4.27.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +133 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +3 -1
  6. data/docs/README.md +1006 -766
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +6 -8
  9. data/lib/aspera/agent/desktop.rb +2 -6
  10. data/lib/aspera/agent/direct.rb +52 -22
  11. data/lib/aspera/agent/node.rb +9 -8
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/alee.rb +1 -1
  14. data/lib/aspera/api/aoc.rb +14 -12
  15. data/lib/aspera/api/ats.rb +1 -1
  16. data/lib/aspera/api/cos_node.rb +2 -2
  17. data/lib/aspera/api/faspex.rb +11 -7
  18. data/lib/aspera/api/httpgw.rb +38 -35
  19. data/lib/aspera/api/node.rb +39 -34
  20. data/lib/aspera/ascmd.rb +3 -1
  21. data/lib/aspera/ascp/installation.rb +63 -28
  22. data/lib/aspera/ascp/management.rb +1 -0
  23. data/lib/aspera/assert.rb +4 -0
  24. data/lib/aspera/cli/ascp_actions.rb +20 -41
  25. data/lib/aspera/cli/async_transfer_store.rb +12 -11
  26. data/lib/aspera/cli/bootstrapper.rb +14 -16
  27. data/lib/aspera/cli/command_line.rb +252 -0
  28. data/lib/aspera/cli/command_registry.rb +215 -37
  29. data/lib/aspera/cli/command_spec.rb +104 -15
  30. data/lib/aspera/cli/completion/ascli.bash +12 -0
  31. data/lib/aspera/cli/completion/ascli.fish +16 -0
  32. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  33. data/lib/aspera/cli/context.rb +3 -0
  34. data/lib/aspera/cli/deprecation.rb +37 -0
  35. data/lib/aspera/cli/extended_value.rb +6 -3
  36. data/lib/aspera/cli/formatter.rb +94 -80
  37. data/lib/aspera/cli/gem_checker.rb +1 -1
  38. data/lib/aspera/cli/hints.rb +7 -6
  39. data/lib/aspera/cli/http.rb +22 -34
  40. data/lib/aspera/cli/info.rb +3 -0
  41. data/lib/aspera/cli/mcp_tool.rb +47 -83
  42. data/lib/aspera/cli/option_declarator.rb +33 -42
  43. data/lib/aspera/cli/option_registry.rb +69 -0
  44. data/lib/aspera/cli/option_types.rb +105 -0
  45. data/lib/aspera/cli/option_value.rb +278 -0
  46. data/lib/aspera/cli/options.schema.yaml +124 -15
  47. data/lib/aspera/cli/parser.rb +333 -862
  48. data/lib/aspera/cli/plugins/alee.rb +7 -4
  49. data/lib/aspera/cli/plugins/aoc.rb +545 -518
  50. data/lib/aspera/cli/plugins/ats.rb +59 -80
  51. data/lib/aspera/cli/plugins/base.rb +221 -265
  52. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  53. data/lib/aspera/cli/plugins/config.rb +263 -184
  54. data/lib/aspera/cli/plugins/console.rb +103 -39
  55. data/lib/aspera/cli/plugins/cos.rb +6 -23
  56. data/lib/aspera/cli/plugins/factory.rb +3 -0
  57. data/lib/aspera/cli/plugins/faspex5.rb +204 -182
  58. data/lib/aspera/cli/plugins/faspio.rb +6 -11
  59. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  60. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  61. data/lib/aspera/cli/plugins/node.rb +300 -327
  62. data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
  63. data/lib/aspera/cli/plugins/preview.rb +96 -105
  64. data/lib/aspera/cli/plugins/server.rb +78 -53
  65. data/lib/aspera/cli/plugins/shares.rb +80 -131
  66. data/lib/aspera/cli/preset_actions.rb +44 -27
  67. data/lib/aspera/cli/preset_manager.rb +44 -19
  68. data/lib/aspera/cli/prompt.rb +36 -0
  69. data/lib/aspera/cli/result.rb +42 -36
  70. data/lib/aspera/cli/runner.rb +32 -59
  71. data/lib/aspera/cli/special_values.rb +5 -0
  72. data/lib/aspera/cli/sync_actions.rb +51 -46
  73. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  74. data/lib/aspera/cli/transfer_actions.rb +14 -9
  75. data/lib/aspera/cli/transfer_agent.rb +34 -38
  76. data/lib/aspera/cli/transfer_progress.rb +290 -55
  77. data/lib/aspera/cli/vault_manager.rb +0 -17
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +5 -3
  80. data/lib/aspera/coverage.rb +1 -1
  81. data/lib/aspera/environment.rb +35 -5
  82. data/lib/aspera/faspex_gw.rb +2 -1
  83. data/lib/aspera/faspex_postproc.rb +1 -0
  84. data/lib/aspera/graphql.rb +5 -5
  85. data/lib/aspera/json_rpc/client.rb +5 -5
  86. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  87. data/lib/aspera/keychain/factory.rb +2 -1
  88. data/lib/aspera/keychain/one_password_api.rb +1 -1
  89. data/lib/aspera/link_header.rb +2 -2
  90. data/lib/aspera/log.rb +47 -27
  91. data/lib/aspera/markdown.rb +2 -0
  92. data/lib/aspera/mime.rb +25 -0
  93. data/lib/aspera/node_emulator.rb +759 -0
  94. data/lib/aspera/oauth/base.rb +37 -26
  95. data/lib/aspera/oauth/factory.rb +7 -3
  96. data/lib/aspera/oauth/generic.rb +1 -1
  97. data/lib/aspera/oauth/json_credentials.rb +34 -0
  98. data/lib/aspera/oauth/jwt.rb +4 -5
  99. data/lib/aspera/oauth/web.rb +9 -8
  100. data/lib/aspera/oauth.rb +1 -0
  101. data/lib/aspera/persistency_folder.rb +1 -3
  102. data/lib/aspera/preview/file_types.rb +4 -4
  103. data/lib/aspera/preview/generator.rb +11 -1
  104. data/lib/aspera/preview/options.schema.yaml +119 -0
  105. data/lib/aspera/preview/terminal.rb +4 -3
  106. data/lib/aspera/preview/utils.rb +9 -6
  107. data/lib/aspera/products/connect.rb +1 -1
  108. data/lib/aspera/rainbow.rb +7 -0
  109. data/lib/aspera/rest/aspera_errors.rb +72 -0
  110. data/lib/aspera/rest/call_error.rb +27 -0
  111. data/lib/aspera/rest/client.rb +523 -0
  112. data/lib/aspera/rest/error_analyzer.rb +113 -0
  113. data/lib/aspera/rest/list.rb +149 -0
  114. data/lib/aspera/rest/parameters.rb +55 -0
  115. data/lib/aspera/rest/util.rb +176 -0
  116. data/lib/aspera/rest.rb +7 -621
  117. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  118. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  119. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  120. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  121. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  122. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  123. data/lib/aspera/schema/documentation.rb +13 -3
  124. data/lib/aspera/schema/reader.rb +12 -18
  125. data/lib/aspera/schema/registry.rb +23 -1
  126. data/lib/aspera/schema/validator.rb +92 -0
  127. data/lib/aspera/secret_hider.rb +36 -25
  128. data/lib/aspera/string_ext.rb +15 -0
  129. data/lib/aspera/temp_file_manager.rb +6 -5
  130. data/lib/aspera/transfer/parameters.rb +2 -0
  131. data/lib/aspera/transfer/spec.rb +1 -0
  132. data/lib/aspera/uri_reader.rb +11 -11
  133. data/lib/aspera/web_auth/index.html +147 -0
  134. data/lib/aspera/web_auth/server.rb +81 -0
  135. data.tar.gz.sig +0 -0
  136. metadata +43 -9
  137. metadata.gz.sig +0 -0
  138. data/lib/aspera/colors.rb +0 -79
  139. data/lib/aspera/node_simulator.rb +0 -344
  140. data/lib/aspera/preview/options.rb +0 -45
  141. data/lib/aspera/rest_call_error.rb +0 -25
  142. data/lib/aspera/rest_error_analyzer.rb +0 -111
  143. data/lib/aspera/rest_errors_aspera.rb +0 -58
  144. data/lib/aspera/rest_list.rb +0 -136
  145. data/lib/aspera/web_auth.rb +0 -211
@@ -21,26 +21,31 @@ module Aspera
21
21
  private_constant :AGENT_SCHEMA_KEY
22
22
 
23
23
  # Set the SDK directory, checking default and former locations
24
+ # If a product is selected for `ascp`, the SDK directory is still needed (keys, conf, transferd)
24
25
  def set_sdk_dir
25
- sdk_dir = Products::Transferd.sdk_directory rescue nil
26
- if sdk_dir.nil?
27
- @sdk_default_location = true
28
- Log.log.debug('SDK folder is not set, checking default')
29
- sdk_dir = self.class.default_app_main_folder(app_name: TRANSFERD_APP_NAME)
30
- Log.log.debug { "Checking: #{sdk_dir}" }
31
- if !Dir.exist?(sdk_dir)
32
- Log.log.debug { "No such folder: #{sdk_dir}" }
33
- former_sdk_folder = File.join(self.class.default_app_main_folder(app_name: Info::CMD_NAME), TRANSFERD_APP_NAME)
34
- Log.log.debug { "Checking: #{former_sdk_folder}" }
35
- sdk_dir = former_sdk_folder if Dir.exist?(former_sdk_folder)
36
- end
37
- Log.log.debug { "Using: #{sdk_dir}" }
26
+ sdk_folder = options.get_option(:sdk_folder)
27
+ product_selected = Ascp::Installation.product_selector?(sdk_folder)
28
+ return unless sdk_folder.nil? || product_selected
29
+ @sdk_default_location = true
30
+ Log.log.debug('SDK folder is not set, checking default')
31
+ sdk_dir = self.class.default_app_main_folder(app_name: TRANSFERD_APP_NAME)
32
+ Log.log.debug { "Checking: #{sdk_dir}" }
33
+ if !Dir.exist?(sdk_dir)
34
+ Log.log.debug { "No such folder: #{sdk_dir}" }
35
+ former_sdk_folder = File.join(self.class.default_app_main_folder(app_name: Info::CMD_NAME), TRANSFERD_APP_NAME)
36
+ Log.log.debug { "Checking: #{former_sdk_folder}" }
37
+ sdk_dir = former_sdk_folder if Dir.exist?(former_sdk_folder)
38
+ end
39
+ Log.log.debug { "Using: #{sdk_dir}" }
40
+ if product_selected
38
41
  Products::Transferd.sdk_directory = sdk_dir
42
+ else
43
+ options.set_option(:sdk_folder, sdk_dir, source: :default)
39
44
  end
40
45
  end
41
46
 
42
47
  # Install the transfer SDK (ascp + transferd) from a URL or using the default source.
43
- # Version defaults to +Info::SDK_VERSION+; pass +LATEST+ as argument to install the latest available version.
48
+ # Version defaults to `Info::SDK_VERSION`; pass `LATEST` as argument to install the latest available version.
44
49
  # @param version [String, nil] version to install; nil means use the default SDK version
45
50
  # @return [Result::Status] installation result message
46
51
  def install_transfer_sdk(version: nil)
@@ -52,27 +57,14 @@ module Aspera
52
57
  return Result::Status.new("Installed #{name} version #{ver} in #{folder}")
53
58
  end
54
59
 
55
- def action_ascp_show(**)
56
- Result::Text.new(Ascp::Installation.instance.path(:ascp))
57
- end
58
-
59
60
  def action_ascp_info(**)
61
+ SecretHider.instance.add_secret_keys(DataRepository::ELEMENTS)
60
62
  data = Ascp::Installation.instance.ascp_info
61
63
  data['ts'] = transfer.user_transfer_spec
62
64
  DataRepository::ELEMENTS.each_with_object(data) { |i, h| h[i.to_s] = DataRepository.instance.item(i) }
63
- SecretHider::ADDITIONAL_KEYS_TO_HIDE.concat(DataRepository::ELEMENTS.map(&:to_s))
64
65
  Result::SingleObject.new(data)
65
66
  end
66
67
 
67
- def action_ascp_install(version: nil, **)
68
- install_transfer_sdk(version: version)
69
- end
70
-
71
- def action_ascp_spec(**)
72
- builder = Schema::Documentation.new(TerminalFormatter, Transfer::Spec::SCHEMA, include_option: true, agent_columns: true).build
73
- Result::ObjectList.new(builder.rows, fields: builder.columns)
74
- end
75
-
76
68
  def action_ascp_schema(agent_name: nil, **)
77
69
  schema = Transfer::Spec::SCHEMA.current.merge({'$comment'=>'DO NOT EDIT, this file was generated from the YAML.'})
78
70
  schema['properties'] = schema['properties'].select { |_k, v| CommandLineBuilder.supported_by_agent(agent_name, v) } unless agent_name.nil?
@@ -88,10 +80,6 @@ module Aspera
88
80
  Result::ObjectList.new(error_data)
89
81
  end
90
82
 
91
- def action_ascp_products_list(**)
92
- Result::ObjectList.new(Ascp::Installation.instance.installed_products, fields: %w[name app_root])
93
- end
94
-
95
83
  def action_agents_list(**)
96
84
  rows = Agent::Factory::ALL.map do |sym, names|
97
85
  schema_key = AGENT_SCHEMA_KEY[sym]
@@ -141,15 +129,6 @@ module Aspera
141
129
  end
142
130
  Result::ObjectList.new(rows, fields: %w[name type description])
143
131
  end
144
-
145
- def action_transferd_install(**)
146
- install_transfer_sdk
147
- end
148
-
149
- def action_transferd_list(**)
150
- sdk_list = Ascp::Installation.instance.sdk_locations
151
- Result::ObjectList.new(sdk_list, fields: sdk_list.first.keys - ['url'])
152
- end
153
132
  end
154
133
  end
155
134
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/log'
4
+ require 'aspera/secret_hider'
4
5
  require 'json'
5
6
  require 'securerandom'
6
7
 
@@ -10,7 +11,7 @@ module Aspera
10
11
  #
11
12
  # Each entry is a JSON document identified by a `job_id` (UUID generated by ascli).
12
13
  # The underlying storage uses PersistencyFolder with the category prefix
13
- # +CATEGORY+ so all async-transfer files are grouped together and can be
14
+ # `CATEGORY` so all async-transfer files are grouped together and can be
14
15
  # garbage-collected independently.
15
16
  #
16
17
  # Schema of a stored entry:
@@ -59,14 +60,14 @@ module Aspera
59
60
  end
60
61
 
61
62
  # Persist (create or update) an async transfer entry.
62
- # Keys whose name starts with '_' are in-process references (e.g. '_agent_ref')
63
- # and are intentionally excluded from the JSON serialization — at every nesting level.
63
+ # Keys whose name starts with '_' are in-process references (e.g. '_agent_ref'), and secrets (e.g. agent `password`)
64
+ # are not written on disk: they are intentionally excluded from the JSON serialization — at every nesting level.
64
65
  # @param job_id [String] the ascli-generated UUID
65
66
  # @param data [Hash] fields to store (will be JSON-serialised)
66
67
  def write(job_id, data)
67
68
  Aspera.assert_type(job_id, String) { 'job_id' }
68
69
  Aspera.assert_type(data, Hash) { 'data' }
69
- @persistency.put(store_key(job_id), JSON.generate(strip_internal_keys(data)))
70
+ @persistency.put(store_key(job_id), JSON.generate(persistable(data)))
70
71
  nil
71
72
  end
72
73
 
@@ -81,7 +82,7 @@ module Aspera
81
82
  end
82
83
 
83
84
  # List all async transfer entries.
84
- # @return [Array<Hash>] each entry includes the +job_id+ field
85
+ # @return [Array<Hash>] each entry includes the `job_id` field
85
86
  def list
86
87
  @persistency.current_items(CATEGORY).map do |key, raw|
87
88
  data = JSON.parse(raw)
@@ -105,17 +106,17 @@ module Aspera
105
106
  "#{CATEGORY}#{job_id}"
106
107
  end
107
108
 
108
- # Recursively remove keys whose name starts with '_' from a Hash.
109
- # Such keys hold in-process Ruby object references (e.g. '_agent_ref') that
110
- # cannot be serialized to JSON and must never reach the file system.
111
- def strip_internal_keys(value)
109
+ # Recursively remove from a Hash the keys that must never reach the file system:
110
+ # - keys whose name starts with '_': in-process Ruby object references (e.g. '_agent_ref') that cannot be serialized to JSON
111
+ # - secrets (e.g. 'password'): taken from current options when the transfer is queried
112
+ def persistable(value)
112
113
  case value
113
114
  when Hash
114
115
  value.each_with_object({}) do |(k, v), h|
115
- h[k] = strip_internal_keys(v) unless k.to_s.start_with?('_')
116
+ h[k] = persistable(v) unless k.to_s.start_with?('_') || SecretHider.instance.secret?(k, v)
116
117
  end
117
118
  when Array
118
- value.map { |v| strip_internal_keys(v) }
119
+ value.map { |v| persistable(v) }
119
120
  else
120
121
  value
121
122
  end
@@ -31,8 +31,8 @@ module Aspera
31
31
  # - populate context.persistency, context.presets, context.http_config, context.progress_bar
32
32
  # - register plugin lookup folders
33
33
  # - register @preset / @vault extended-value handlers
34
- # - configure global singletons: RestParameters, OAuth::Factory, SSL, Transfer::Parameters,
35
- # RestErrorAnalyzer
34
+ # - configure global singletons: Rest::Parameters, OAuth::Factory, SSL, Transfer::Parameters,
35
+ # Rest::ErrorAnalyzer
36
36
  # - set up the PAC proxy executor (option :fpac)
37
37
  class Bootstrapper
38
38
  # Folder name inside $HOME for all Aspera tool data (~/.aspera)
@@ -88,16 +88,13 @@ module Aspera
88
88
  setup_rest_and_transfer_runtime
89
89
  end
90
90
 
91
- # Public accessor used as option handler for :config_file
92
- attr_accessor :config_file_option
93
-
94
91
  private
95
92
 
96
93
  # Declare + parse :home option: sets context.main_folder
97
94
  def setup_main_folder
98
95
  @context.options.declare(
99
96
  :home, description: 'Home folder for tool',
100
- handler: {o: @context, m: :main_folder},
97
+ on_set: @context.method(:main_folder=),
101
98
  default: default_app_main_folder(app_name: Info::CMD_NAME)
102
99
  )
103
100
  @context.options.parse_options!
@@ -115,11 +112,10 @@ module Aspera
115
112
  def setup_config_file
116
113
  @context.options.declare(
117
114
  :config_file, description: 'Path to YAML file with preset configuration',
118
- handler: {o: self, m: :config_file_option},
119
115
  default: File.join(@context.main_folder, DEFAULT_CONFIG_FILENAME)
120
116
  )
121
117
  @context.options.parse_options!
122
- @context.presets = PresetManager.new(config_file: @config_file_option)
118
+ @context.presets = PresetManager.new(config_file: @context.options.get_option(:config_file))
123
119
  @context.http_config = Http.new
124
120
  end
125
121
 
@@ -143,9 +139,11 @@ module Aspera
143
139
 
144
140
  # Declare + parse :progress_bar: sets context.progress_bar
145
141
  def setup_progress_bar
146
- @context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default: Environment.terminal?)
142
+ @context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default: $stderr.tty?)
147
143
  @context.options.parse_options!
148
144
  @context.progress_bar = TransferProgress.new if @context.options.get_option(:progress_bar)
145
+ # Log lines do not overwrite the progress bar
146
+ Log.instance.status_line = @context.progress_bar
149
147
  end
150
148
 
151
149
  # Declare + parse :fpac / :proxy_credentials: sets up PAC executor
@@ -165,18 +163,18 @@ module Aspera
165
163
  end
166
164
  end
167
165
 
168
- # Configure global singletons: RestParameters, SSL, Transfer, RestErrorAnalyzer.
166
+ # Configure global singletons: Rest::Parameters, SSL, Transfer, Rest::ErrorAnalyzer.
169
167
  # OAuth persist_mgr is NOT set here: it depends on :cache_tokens which is parsed later
170
168
  # by Config#initialize. Runner sets it after Config.new.
171
169
  def setup_rest_and_transfer_runtime
172
- RestParameters.instance.user_agent = Info::CMD_NAME
173
- RestParameters.instance.progress_bar = @context.progress_bar
174
- RestParameters.instance.session_cb = ->(http_session) { @context.http_config.update_session(http_session) }
175
- RestParameters.instance.spinner_cb = ->(title = nil, action: :spin) { @context.formatter.long_operation(title, action: action) }
170
+ Rest::Parameters.instance.user_agent = Info::CMD_NAME
171
+ Rest::Parameters.instance.progress_bar = @context.progress_bar
172
+ Rest::Parameters.instance.session_cb = ->(http_session) { @context.http_config.update_session(http_session) }
173
+ Rest::Parameters.instance.spinner_cb = ->(title = nil, action: :spin) { @context.formatter.long_operation(title, action: action) }
176
174
  OAuth::Web.additional_info = "#{Info::CMD_NAME} v#{Cli::VERSION}"
177
175
  Transfer::Parameters.file_list_folder = File.join(@context.main_folder, FILE_LIST_FOLDER_NAME)
178
- RestErrorAnalyzer.instance.log_file = File.join(@context.main_folder, REST_EXCEPTIONS_LOG_FILENAME)
179
- RestErrorsAspera.register_handlers
176
+ Rest::ErrorAnalyzer.instance.log_file = File.join(@context.main_folder, REST_EXCEPTIONS_LOG_FILENAME)
177
+ Rest::AsperaErrors.register_handlers
180
178
  end
181
179
 
182
180
  # @return [String] ~/.aspera
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/error'
4
+ require 'aspera/dot_container'
5
+ require 'aspera/log'
6
+ require 'aspera/assert'
7
+
8
+ module Aspera
9
+ module Cli
10
+ # Positional (non-option) command line token.
11
+ class Argument
12
+ # @return [String] the raw argument value
13
+ attr_reader :value
14
+ # @return [Option, nil] option token claiming this token as its value (`--opt value` form)
15
+ attr_accessor :owner
16
+ # @return [Boolean] `true` once used, as positional argument or as option value
17
+ attr_accessor :consumed
18
+
19
+ def initialize(value)
20
+ @value = value
21
+ @owner = nil
22
+ @consumed = false
23
+ end
24
+
25
+ # @return [Boolean] `true` if available as positional argument
26
+ def positional? = !@consumed && @owner.nil?
27
+
28
+ # @return [String] raw argument value
29
+ def to_s = @value
30
+ end
31
+
32
+ # Option command line token (long or short form).
33
+ class Option
34
+ # @return [String] raw token as it appeared in argv (e.g. "--log-level=debug", "-Pval")
35
+ attr_reader :raw
36
+ # @return [String, nil] option name with underscores (e.g. "log_level"), nil for short options
37
+ attr_reader :name
38
+ # @return [String, nil] single-char short option letter (e.g. "P"), nil for long options
39
+ attr_reader :short_char
40
+ # @return [Array<String>, nil] sub-keys for dot-path notation (e.g. ["field"] for --custom.field)
41
+ attr_reader :dot_path
42
+ # @return [String, nil] value given in the same token (`--opt=val` or `-Oval`)
43
+ attr_reader :inline_value
44
+ # @return [Argument, nil] next token, claimed as value when there is no inline value (`--opt val` or `-O val`)
45
+ attr_accessor :value_token
46
+ # @return [Boolean] `true` once applied to a declared option
47
+ attr_accessor :consumed
48
+ # @return [Symbol, nil] option this token was resolved to, when `name` is an abbreviation
49
+ attr_accessor :abbreviation_of
50
+
51
+ def initialize(raw:, name: nil, short_char: nil, dot_path: nil, inline_value: nil)
52
+ @raw = raw
53
+ @name = name
54
+ @short_char = short_char
55
+ @dot_path = dot_path
56
+ @inline_value = inline_value
57
+ @value_token = nil
58
+ @consumed = false
59
+ @abbreviation_of = nil
60
+ end
61
+
62
+ # @return [Boolean] `true` if value is in the same token
63
+ def inline? = !@inline_value.nil?
64
+
65
+ # @return [String, nil] inline value, or value of next token
66
+ def value = @inline_value || @value_token&.value
67
+
68
+ # @return [String] raw token, followed by its separate value if any
69
+ def to_s = @value_token.nil? ? @raw : "#{@raw} #{@value_token.value}"
70
+
71
+ class << self
72
+ # @param token [String] command line token
73
+ # @return [Boolean] `true` if token is an option, i.e. not `-`, `--`, or a negative number
74
+ def option?(token)
75
+ token.match?(/\A-\D/) && !token.eql?(STOP)
76
+ end
77
+
78
+ # Build an Option from a raw token
79
+ # @param raw [String] e.g. "--log-level=debug", "--custom.field", "-P", "-Pval"
80
+ # @return [Option]
81
+ def parse(raw)
82
+ if raw.start_with?(PREFIX)
83
+ name_raw, value = raw.delete_prefix(PREFIX).split(VALUE_SEP, 2)
84
+ root, *dot_path = name_raw.to_s.split(DotContainer::SEPARATOR)
85
+ new(raw: raw, name: root.to_s.gsub(NAME_SEP_LINE, NAME_SEP_SYMBOL), dot_path: dot_path.empty? ? nil : dot_path, inline_value: value)
86
+ else
87
+ new(raw: raw, short_char: raw[1], inline_value: raw.length > 2 ? raw[2..] : nil)
88
+ end
89
+ end
90
+ end
91
+
92
+ # Option name separator on command line (e.g. `--option-name`, the `-` between words)
93
+ NAME_SEP_LINE = '-'
94
+ # Option name separator in code/symbol (e.g. `:option_name`, the `_` between words)
95
+ NAME_SEP_SYMBOL = '_'
96
+ # Separator between option name and its inline value (e.g. `--opt=val`, the `=`)
97
+ VALUE_SEP = '='
98
+ # Long-option prefix (e.g. `--opt`)
99
+ PREFIX = '--'
100
+ # When alone, stops option processing: following tokens are positional arguments
101
+ STOP = '--'
102
+ end
103
+
104
+ # Command line split in tokens, in original order.
105
+ # Tokens are never removed, only marked as consumed, so that positions are kept.
106
+ #
107
+ # An option without inline value claims the next token as its value (`--opt val`, `-O val`),
108
+ # unless that token looks like an option.
109
+ # If the option turns out to be a flag, the claimed token is given back to positional arguments.
110
+ class CommandLine
111
+ # @param argv [Array<String>] command line arguments
112
+ def initialize(argv)
113
+ # @type [Array<Option, Argument>]
114
+ @tokens = []
115
+ # Option token being applied (used by `@:` extended value)
116
+ @current_option = nil
117
+ # When set, only positional arguments after this token are available
118
+ @arguments_after = nil
119
+ process_options = true
120
+ argv.each do |value|
121
+ if process_options && value.eql?(Option::STOP)
122
+ process_options = false
123
+ elsif process_options && Option.option?(value)
124
+ @tokens.push(Option.parse(value))
125
+ else
126
+ argument = Argument.new(value)
127
+ previous = @tokens.last
128
+ if process_options && previous.is_a?(Option) && !previous.inline? && previous.value_token.nil?
129
+ previous.value_token = argument
130
+ argument.owner = previous
131
+ end
132
+ @tokens.push(argument)
133
+ end
134
+ end
135
+ Log.log.trace1 { "arguments=#{pending_arguments},options=#{pending_options}" }
136
+ end
137
+
138
+ # @return [Array<Option>] option tokens whose name was resolved as an abbreviation
139
+ def abbreviated_option_tokens
140
+ @tokens.select { |t| t.is_a?(Option) && !t.abbreviation_of.nil? }
141
+ end
142
+
143
+ # @return [Array<Option>] option tokens not applied yet
144
+ def pending_option_tokens
145
+ @tokens.select { |t| t.is_a?(Option) && !t.consumed }
146
+ end
147
+
148
+ # Mark option token as applied, and get its value.
149
+ # @param tok [Option] option token
150
+ # @param takes_value [Boolean] `false` for flags: claimed token is given back to positional arguments
151
+ # @return [String, nil] value, or `nil` for flags
152
+ # @raise [BadArgument] if option takes a value and none was given
153
+ def consume(tok, takes_value:)
154
+ tok.consumed = true
155
+ return release(tok) unless takes_value
156
+ return tok.inline_value if tok.inline?
157
+ Aspera.assert(!tok.value_token.nil?, type: BadArgument) { "Option #{tok.raw} requires a value" }
158
+ tok.value_token.consumed = true
159
+ tok.value_token.value
160
+ end
161
+
162
+ # Execute block with `tok` as current option
163
+ # @param tok [Option] option token being applied
164
+ def with_current_option(tok)
165
+ @current_option = tok
166
+ yield
167
+ ensure
168
+ @current_option = nil
169
+ end
170
+
171
+ # Execute block with only positional arguments after current option available, if any
172
+ def with_arguments_after_current_option
173
+ @arguments_after = @current_option
174
+ yield
175
+ ensure
176
+ @arguments_after = nil
177
+ end
178
+
179
+ # @return [Array<String>] values of available positional arguments
180
+ def pending_arguments
181
+ positional_tokens.map(&:value)
182
+ end
183
+
184
+ # @return [Array<String>] options not applied yet, with their value if separate
185
+ def pending_options
186
+ pending_option_tokens.map(&:to_s)
187
+ end
188
+
189
+ # Consume positional arguments.
190
+ # @param multiple [false, true, String] consumption mode:
191
+ # false — consume exactly one token
192
+ # true — consume all remaining tokens
193
+ # String — consume up to the marker token (marker is consumed too, not returned), or all if absent
194
+ # @return [Array<String>] consumed values
195
+ def shift_arguments(multiple)
196
+ arg_tokens = positional_tokens
197
+ selected =
198
+ case multiple
199
+ when false then arg_tokens.first(1)
200
+ when true then arg_tokens
201
+ when String
202
+ index = arg_tokens.index { |t| t.value.eql?(multiple) }
203
+ arg_tokens[index].consumed = true unless index.nil?
204
+ arg_tokens.take(index || arg_tokens.length)
205
+ else Aspera.error_unexpected_value(multiple) { 'multiple' }
206
+ end
207
+ selected.each { |t| t.consumed = true }
208
+ selected.map(&:value)
209
+ end
210
+
211
+ # Add an argument before the available positional arguments
212
+ # @param value [String] argument value
213
+ def unshift_argument(value)
214
+ index = @tokens.index { |t| t.is_a?(Argument) && t.positional? } || @tokens.length
215
+ @tokens.insert(index, Argument.new(value))
216
+ end
217
+
218
+ # Consume all long options with a value, whether applied or not.
219
+ # @yieldparam tok [Option] long option token with a value
220
+ def each_long_option_with_value
221
+ @tokens.each do |tok|
222
+ next unless tok.is_a?(Option) && tok.short_char.nil? && !tok.value.nil?
223
+ yield(tok)
224
+ tok.consumed = true
225
+ tok.value_token&.consumed = true
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ # Give back claimed token to positional arguments.
232
+ # @param tok [Option] flag option token
233
+ # @return [nil]
234
+ def release(tok)
235
+ value_token = tok.value_token
236
+ return if value_token.nil?
237
+ index = @tokens.index { |t| t.equal?(value_token) }
238
+ Aspera.assert(@tokens[index + 1..].none? { |t| t.is_a?(Argument) && t.consumed && t.owner.nil? }) do
239
+ "Flag #{tok.raw} declared after following positional arguments were used"
240
+ end
241
+ value_token.owner = nil
242
+ tok.value_token = nil
243
+ end
244
+
245
+ # @return [Array<Argument>] available positional arguments, in order
246
+ def positional_tokens
247
+ start = @arguments_after.nil? ? 0 : @tokens.index { |t| t.equal?(@arguments_after) } + 1
248
+ @tokens[start..].select { |t| t.is_a?(Argument) && t.positional? }
249
+ end
250
+ end
251
+ end
252
+ end