aspera-cli 4.26.2 → 4.27.1

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 (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -1,766 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'aspera/cli/extended_value'
4
- require 'aspera/cli/error'
5
- require 'aspera/cli/terminal_formatter'
6
- require 'aspera/colors'
7
- require 'aspera/secret_hider'
8
- require 'aspera/log'
9
- require 'aspera/assert'
10
- require 'aspera/dot_container'
11
- require 'io/console'
12
- require 'optparse'
13
-
14
- module Aspera
15
- module Cli
16
- # Exception raised when schema is asked (`help`)
17
- class SchemaRequest < Error
18
- # @return [String, nil] path to schema file
19
- attr_reader :path
20
-
21
- # @param type [Symbol] :argument or :option
22
- # @param name [String] name of the option/argument
23
- # @param schema_path [String, nil] path to schema file, or `nil` if not available
24
- def initialize(type, name, schema_path)
25
- super("#{type}: #{name}")
26
- @path = schema_path
27
- end
28
- end
29
-
30
- module BoolValue
31
- # boolean options are set to true/false from the following values
32
- YES_SYM = :yes
33
- NO_SYM = :no
34
- FALSE_VALUES = [NO_SYM, false].freeze
35
- TRUE_VALUES = [YES_SYM, true].freeze
36
- private_constant :YES_SYM, :NO_SYM, :FALSE_VALUES, :TRUE_VALUES
37
- # Boolean values
38
- # @return [Array<true, false, :yes, :no>]
39
- ALL = (TRUE_VALUES + FALSE_VALUES).freeze
40
- # `false` and `true`
41
- TYPES = [FalseClass, TrueClass].freeze
42
- SYMBOLS = [NO_SYM, YES_SYM].freeze
43
- # @return `true` if value is a value for `true` in ALL
44
- def true?(enum)
45
- Aspera.assert_values(enum, ALL){'boolean'}
46
- TRUE_VALUES.include?(enum)
47
- end
48
-
49
- # @return [:yes, :no]
50
- def to_sym(enum)
51
- Aspera.assert_values(enum, ALL){'boolean'}
52
- TRUE_VALUES.include?(enum) ? YES_SYM : NO_SYM
53
- end
54
-
55
- # @return `true` if value is a value for `true` or `false` in ALL
56
- def symbol?(sym)
57
- ALL.include?(sym)
58
- end
59
- module_function :true?, :to_sym, :symbol?
60
- end
61
-
62
- # Constants to be used as parameter `allowed:` for `OptionValue`
63
- module Allowed
64
- # This option can be set to a single string or array, multiple times, and gives Array of String
65
- TYPES_STRING_ARRAY = [Array, String].freeze
66
- # A list of symbols with constrained values
67
- TYPES_SYMBOL_ARRAY = [Array, Symbol].freeze
68
- # Value will be coerced to int
69
- TYPES_INTEGER = [Integer].freeze
70
- TYPES_BOOLEAN = BoolValue::TYPES
71
- # No value at all for the option, it's a switch, like `-N`
72
- TYPES_NONE = [].freeze
73
- # Symbol
74
- TYPES_ENUM = [Symbol].freeze
75
- # String
76
- TYPES_STRING = [String].freeze
77
- end
78
-
79
- # Description of option, how to manage
80
- class OptionValue
81
- # [Array(Class)] List of allowed types
82
- attr_reader :types, :sensitive, :schema, :option
83
- # [Array] List of allowed values (Symbols and specific values)
84
- attr_accessor :values
85
-
86
- # @param option [Symbol] Name of option
87
- # @param description [String] Description for help
88
- # @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
89
- # @param handler [Hash] Accessor: keys: :o(object) and :m(method)
90
- # @param deprecation [String] Deprecation message
91
- # @param schema [String] Declaration of schema
92
- # `allowed`:
93
- # - `nil` No validation, so just a string
94
- # - `Class` The single allowed Class
95
- # - `Array<Class>` Multiple allowed classes
96
- # - `Array<Symbol>` List of allowed values
97
- def initialize(option:, description:, allowed: Allowed::TYPES_STRING, handler: nil, deprecation: nil, schema: nil)
98
- Log.log.trace1{"option: #{option}, allowed: #{allowed}"}
99
- @option = option
100
- @description = description
101
- # by default passwords and secrets are sensitive, else specify when declaring the option
102
- @sensitive = SecretHider.instance.secret?(@option, '')
103
- # either the value, or object giving value
104
- @object = handler&.[](:o)
105
- @read_method = handler&.[](:m)
106
- @write_method = @read_method ? "#{@read_method}=".to_sym : nil
107
- @deprecation = deprecation
108
- @schema = schema
109
- @access = if @object.nil?
110
- :local
111
- elsif @object.respond_to?(@write_method)
112
- :write
113
- else
114
- :setter
115
- end
116
- Aspera.assert(@object.respond_to?(@read_method)){"#{@object} does not respond to #{@read_method}"} unless @access.eql?(:local)
117
- @types = nil
118
- @values = nil
119
- if !allowed.nil?
120
- allowed = [allowed] if allowed.is_a?(Class)
121
- Aspera.assert_type(allowed, Array)
122
- if allowed.take(Allowed::TYPES_SYMBOL_ARRAY.length) == Allowed::TYPES_SYMBOL_ARRAY
123
- # Special case: array of defined symbol values
124
- @types = Allowed::TYPES_SYMBOL_ARRAY
125
- @values = allowed[Allowed::TYPES_SYMBOL_ARRAY.length..]
126
- elsif allowed.all?(Class)
127
- @types = allowed
128
- @values = BoolValue::ALL if allowed.eql?(Allowed::TYPES_BOOLEAN)
129
- # Default value for array
130
- @object ||= [] if @types.first.eql?(Array) && !@types.include?(NilClass)
131
- @object ||= {} if @types.first.eql?(Hash) && !@types.include?(NilClass)
132
- elsif allowed.all?(Symbol)
133
- @types = Allowed::TYPES_ENUM
134
- @values = allowed
135
- else
136
- Aspera.error_unexpected_value(allowed)
137
- end
138
- end
139
- Log.log.trace1{"declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green}
140
- end
141
-
142
- def clear
143
- @object = nil
144
- end
145
-
146
- def value(log: true)
147
- current_value =
148
- case @access
149
- when :local then @object
150
- when :write then @object.send(@read_method)
151
- when :setter then @object.send(@read_method, @option, :get)
152
- end
153
- Log.log.trace1{"#{@option} -> (#{current_value.class})#{current_value}"} if log
154
- current_value
155
- end
156
-
157
- # Assign value to option.
158
- # Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
159
- # @param value [String, Object] Value to assign to option
160
- # @param where [String] Where the value is assigned from
161
- # @return [nil]
162
- def assign_value(value, where:)
163
- Aspera.assert(!@deprecation, type: warn){"Option #{@option} is deprecated: #{@deprecation}"}
164
- new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
165
- Log.log.trace1{"#{where}: #{@option} <- (#{new_value.class})#{new_value}"}
166
- new_value = BoolValue.true?(new_value) if @types.eql?(Allowed::TYPES_BOOLEAN)
167
- new_value = Integer(new_value) if @types.eql?(Allowed::TYPES_INTEGER)
168
- new_value = [new_value] if @types.eql?(Allowed::TYPES_STRING_ARRAY) && new_value.is_a?(String)
169
- # Setting a Hash to null set an empty hash
170
- new_value = {} if new_value.eql?(nil) && @types&.first.eql?(Hash)
171
- # Setting a Array to null set an empty array
172
- new_value = [] if new_value.eql?(nil) && @types&.first.eql?(Array)
173
- if @types.eql?(Aspera::Cli::Allowed::TYPES_SYMBOL_ARRAY)
174
- new_value = [new_value] if new_value.is_a?(String)
175
- Aspera.assert_array_all(new_value, String, type: BadArgument)
176
- new_value = new_value.map{ |v| Manager.get_from_list(v, @option, @values)}
177
- end
178
- Aspera.assert_type(new_value, *@types, type: BadArgument){"Option #{@option}"} if @types
179
- if new_value.is_a?(Hash) || new_value.is_a?(Array)
180
- current_value = value(log: false)
181
- new_value = current_value.deep_merge(new_value) if new_value.is_a?(Hash) && current_value.is_a?(Hash) && !current_value.empty?
182
- new_value = current_value + new_value if new_value.is_a?(Array) && current_value.is_a?(Array) && !current_value.empty?
183
- end
184
- case @access
185
- when :local then @object = new_value
186
- when :write then @object.send(@write_method, new_value)
187
- when :setter then @object.send(@read_method, @option, :set, new_value)
188
- end
189
- Log.log.trace1{v = value(log: false); "#{@option} <- (#{v.class})#{v}"} # rubocop:disable Style/Semicolon
190
- nil
191
- end
192
- end
193
-
194
- # parse command line options
195
- # arguments options start with '-', others are commands
196
- # resolves on extended value syntax
197
- class Manager
198
- class << self
199
- # Find shortened string value in allowed symbol list
200
- def get_from_list(short_value, descr, allowed_values)
201
- Aspera.assert_type(short_value, String)
202
- # we accept shortcuts
203
- matching_exact = allowed_values.select{ |i| i.to_s.eql?(short_value)}
204
- return matching_exact.first if matching_exact.length == 1
205
- matching = allowed_values.select{ |i| i.to_s.start_with?(short_value)}
206
- Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
207
- Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
208
- return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
209
- matching.first
210
- end
211
-
212
- # Generates error message with list of allowed values
213
- # @param error_msg [String] Error message
214
- # @param accept_list [Array<Symbol>] List of allowed values
215
- def multi_choice_assert_msg(error_msg, accept_list)
216
- [error_msg, 'Use:', *accept_list.map{ |choice| "- #{choice}"}.sort].join("\n")
217
- end
218
-
219
- # Change option name with dash to name with underscore
220
- # @param name [String] option name
221
- # @return [String]
222
- def option_line_to_name(name)
223
- name.gsub(OPTION_SEP_LINE, OPTION_SEP_SYMBOL)
224
- end
225
-
226
- def option_name_to_line(name)
227
- "#{OPTION_PREFIX}#{name.to_s.gsub(OPTION_SEP_SYMBOL, OPTION_SEP_LINE)}"
228
- end
229
-
230
- # @return [Hash{Symbol => String}, nil] `{field:,value:}` if identifier is a percent selector, else `nil`
231
- def percent_selector(identifier)
232
- Aspera.assert_type(identifier, String)
233
- if (m = identifier.match(REGEX_LOOKUP_ID_BY_FIELD))
234
- return {field: m[1], value: ExtendedValue.instance.evaluate(m[2], context: "percent selector: #{m[1]}")}
235
- end
236
- nil
237
- end
238
- end
239
-
240
- attr_reader :parser
241
- attr_accessor :ask_missing_mandatory, :ask_missing_optional
242
- attr_writer :fail_on_missing_mandatory
243
-
244
- # @param program_name [String] Name of the program
245
- # @param argv [Array<String>, nil] Command line arguments to parse
246
- def initialize(program_name, argv = nil)
247
- # command line values *not* starting with '-'
248
- @unprocessed_cmd_line_arguments = []
249
- # command line values starting with at least one '-'
250
- @unprocessed_cmd_line_options = []
251
- # a copy of all initial options
252
- @initial_cli_options = []
253
- # Option descriptions: maps option symbol to its OptionValue descriptor
254
- # @type [Hash{Symbol => OptionValue}]
255
- @declared_options = {}
256
- # do we ask missing options and arguments to user ?
257
- @ask_missing_mandatory = false # STDIN.isatty
258
- # ask optional options if not provided and in interactive
259
- @ask_missing_optional = false
260
- # get_option fails if a mandatory parameter is asked
261
- @fail_on_missing_mandatory = true
262
- # Array of [key(sym), value]
263
- # those must be set before parse
264
- # parse consumes those defined only
265
- @option_pairs_batch = {}
266
- @option_pairs_env = {}
267
- # NOTE: was initially inherited but it is preferred to have specific methods
268
- @parser = OptionParser.new
269
- @parser.program_name = program_name
270
- # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
271
- env_prefix = program_name.upcase + OPTION_SEP_SYMBOL
272
- ENV.each do |k, v|
273
- @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
274
- end
275
- Log.log.debug{"env=#{@option_pairs_env}".red}
276
- @unprocessed_cmd_line_options = []
277
- @unprocessed_cmd_line_arguments = []
278
- return if argv.nil?
279
- # true until `--` is found (stop options)
280
- process_options = true
281
- until argv.empty?
282
- value = argv.shift
283
- if process_options && value.start_with?('-')
284
- Log.log.trace1{"opt: #{value}"}
285
- if value.eql?(OPTIONS_STOP)
286
- process_options = false
287
- else
288
- @unprocessed_cmd_line_options.push(value)
289
- end
290
- else
291
- Log.log.trace1{"arg: #{value}"}
292
- @unprocessed_cmd_line_arguments.push(value)
293
- end
294
- end
295
- @initial_cli_options = @unprocessed_cmd_line_options.dup.freeze
296
- Log.log.trace1{"add_cmd_line_options:commands/arguments=#{@unprocessed_cmd_line_arguments},options=#{@unprocessed_cmd_line_options}".red}
297
- @parser.separator('')
298
- @parser.separator('OPTIONS: global')
299
- declare(:interactive, 'Use interactive input of missing params', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
300
- declare(:ask_options, 'Ask even optional options', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_optional})
301
- # do not parse options yet, let's wait for option `-h` to be overridden
302
- end
303
-
304
- # Add a type to the message if not special types
305
- # @param types [Array<Class>] types to add
306
- # @return [String] Types if relevant
307
- def add_types_info(types)
308
- return '' if !types || types.empty? || types.eql?(Allowed::TYPES_ENUM) || types.eql?(Allowed::TYPES_BOOLEAN) || types.eql?(Allowed::TYPES_STRING)
309
- " (#{types.map(&:name).join(', ')})"
310
- end
311
-
312
- # Declare an option
313
- # @param option_symbol [Symbol] option name
314
- # @param description [String] description for help
315
- # @param short [String] short option name
316
- # @param allowed [Object] Allowed values, see `OptionValue`
317
- # @param default [Object] default value
318
- # @param handler [Hash] handler for option value: keys: :o(object) and :m(method)
319
- # @param deprecation [String] deprecation
320
- # @param schema [String] Definition of schema for Hash parameters
321
- # @param block [Proc] Block to execute when option is found
322
- def declare(option_symbol, description, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
323
- Aspera.assert_type(option_symbol, Symbol)
324
- Aspera.assert(!@declared_options.key?(option_symbol)){"#{option_symbol} already declared"}
325
- Aspera.assert(description[-1] != '.'){"#{option_symbol} ends with dot"}
326
- Aspera.assert(description[0] == description[0].upcase){"#{option_symbol} description does not start with an uppercase"}
327
- Aspera.assert(!['hash', 'extended value'].any?{ |s| description.downcase.include?(s)}){"#{option_symbol} shall use :allowed instead of hash/extended value in option description"}
328
- Aspera.assert_type(handler, Hash) if handler
329
- Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
330
- option_attrs = @declared_options[option_symbol] = OptionValue.new(
331
- option: option_symbol,
332
- description: description,
333
- allowed: allowed,
334
- handler: handler,
335
- deprecation: deprecation,
336
- schema: schema
337
- )
338
- real_types = option_attrs.types&.reject{ |i| [NilClass, String, Symbol].include?(i)}
339
- description += add_types_info(real_types)
340
- description = "#{description} (#{'deprecated'.blue}: #{deprecation})" if deprecation
341
- set_option(option_symbol, default, where: 'default') unless default.nil?
342
- on_args = [description]
343
- case option_attrs.types
344
- when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN
345
- # This option value must be a symbol (or array of symbols)
346
- set_option(option_symbol, BoolValue.true?(default), where: 'default') if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
347
- value = get_option(option_symbol)
348
- help_values =
349
- if option_attrs.types.eql?(Allowed::TYPES_BOOLEAN)
350
- highlight_current_in_list(BoolValue::SYMBOLS, BoolValue.to_sym(value))
351
- else
352
- highlight_current_in_list(option_attrs.values, value)
353
- end
354
- on_args[0] = "#{description}: #{help_values}"
355
- on_args.push(symbol_to_option(option_symbol, 'ENUM'))
356
- # on_args.push(option_attrs.values)
357
- @parser.on(*on_args) do |v|
358
- set_option(option_symbol, self.class.get_from_list(v.to_s, description, option_attrs.values), where: SOURCE_USER)
359
- end
360
- when Allowed::TYPES_NONE
361
- Aspera.assert_type(block, Proc){"missing execution block for #{option_symbol}"}
362
- on_args.push(symbol_to_option(option_symbol))
363
- on_args.push("-#{short}") if short.is_a?(String)
364
- @parser.on(*on_args, &block)
365
- else
366
- on_args.push(symbol_to_option(option_symbol, 'VALUE'))
367
- on_args.push("-#{short}VALUE") unless short.nil?
368
- # coerce integer
369
- on_args.push(Integer) if option_attrs.types.eql?(Allowed::TYPES_INTEGER)
370
- @parser.on(*on_args) do |v|
371
- set_option(option_symbol, v, where: SOURCE_USER)
372
- end
373
- end
374
- Log.log.trace1{"on_args=#{on_args}"}
375
- end
376
-
377
- # @param descr [String] description for help
378
- # @param mandatory [Boolean] `true`: raise error no more argument
379
- # @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
380
- # @param accept_list [Array<Symbol>, NilClass] list of allowed values
381
- # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
382
- # @param aliases [Hash] map of aliases: key = alias, value = real value
383
- # @param default [Object] default value
384
- # @return one value, list or nil (if optional and no default)
385
- def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Allowed::TYPES_STRING, aliases: nil, default: nil, schema: nil)
386
- Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
387
- Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
388
- validation = Symbol unless accept_list.nil?
389
- validation = [validation] unless validation.is_a?(Array) || validation.nil?
390
- Aspera.assert_array_all(validation, Class){'validation'} unless validation.nil?
391
- descr = "#{descr}#{add_types_info(validation)}"
392
- result =
393
- if !@unprocessed_cmd_line_arguments.empty?
394
- case multiple
395
- when true
396
- values = @unprocessed_cmd_line_arguments.shift(@unprocessed_cmd_line_arguments.length)
397
- when false
398
- values = [@unprocessed_cmd_line_arguments.shift]
399
- when String
400
- index = @unprocessed_cmd_line_arguments.index(multiple)
401
- if index
402
- values = @unprocessed_cmd_line_arguments.shift(index)
403
- @unprocessed_cmd_line_arguments.shift # remove end marker
404
- else
405
- values = @unprocessed_cmd_line_arguments.shift(@unprocessed_cmd_line_arguments.length)
406
- end
407
- else Aspera.error_unexpected_value(multiple){'multiple'}
408
- end
409
- values = values.map{ |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation)}
410
- # If expecting list and only one arg of type array : it is the list
411
- values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
412
- if accept_list
413
- allowed_values = [].concat(accept_list)
414
- allowed_values.concat(aliases.keys) unless aliases.nil?
415
- values = values.map{ |v| self.class.get_from_list(v, descr, allowed_values)}
416
- end
417
- multiple ? values : values.first
418
- elsif !default.nil? then default
419
- # no value provided, either get value interactively, or exception
420
- elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, schema: schema)
421
- end
422
- if result.is_a?(String) && validation&.eql?(Allowed::TYPES_INTEGER)
423
- int_result = Integer(result, exception: false)
424
- raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
425
- result = int_result
426
- end
427
- Log.log.trace1{"#{descr}=#{result}"}
428
- result = aliases[result] if aliases&.key?(result)
429
- # if value comes from JSON/YAML, it may come as Integer
430
- result = result.to_s if result.is_a?(Integer) && validation&.eql?(Allowed::TYPES_STRING)
431
- if validation && (mandatory || !result.nil?)
432
- value_list = multiple ? result : [result]
433
- value_list.each do |value|
434
- raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
435
- raise Cli::BadArgument,
436
- "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" unless validation.any?{ |t| value.is_a?(t)}
437
- end
438
- end
439
- result
440
- end
441
-
442
- # Resource identifier as positional parameter
443
- #
444
- # @param description [String] description of the identifier
445
- # @param block [Proc] block to search for identifier based on attribute value
446
- # @return [String, Array<String>] identifier or list of IDs (if `bulk` option is set)
447
- # @yieldparam field [String] The field name from percent selector
448
- # @yieldparam value [String] The value from percent selector
449
- # @yieldreturn [String] Resolved identifier
450
- def instance_identifier(description: 'identifier', &block)
451
- res_id = get_next_argument(description, multiple: get_option(:bulk)) if res_id.nil?
452
- # Can be an Array
453
- if res_id.is_a?(String) && (m = Manager.percent_selector(res_id))
454
- Aspera.assert(block_given?, type: Cli::BadArgument){"Percent syntax for #{description} not supported in this context"}
455
- res_id = yield(m[:field], m[:value])
456
- end
457
- res_id
458
- end
459
-
460
- def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end
461
-
462
- # Get an option definition by name
463
- # @param option_symbol [Symbol]
464
- # @return [OptionValue] Option definition
465
- # @raise [Cli::BadArgument] if option not found
466
- def option_def(option_symbol)
467
- Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument){"Unknown option: #{option_symbol}"}
468
- @declared_options[option_symbol]
469
- end
470
-
471
- # Get an option value by name
472
- # either return value or calls handler, can return nil
473
- # ask interactively if requested/required
474
- # @param option_symbol [Symbol]
475
- # @param mandatory [Boolean] if true, raise error if option not set
476
- def get_option(option_symbol, mandatory: false)
477
- Aspera.assert_type(option_symbol, Symbol)
478
- option_attrs = option_def(option_symbol)
479
- result = option_attrs.value
480
- # Do not fail for manual generation if option mandatory but not set
481
- return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
482
- if result.nil?
483
- if !@ask_missing_mandatory
484
- Aspera.assert(!mandatory, type: Cli::BadArgument){"Missing mandatory option: #{option_symbol}"}
485
- elsif @ask_missing_optional || mandatory
486
- # ask_missing_mandatory
487
- result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
488
- set_option(option_symbol, result, where: 'interactive')
489
- end
490
- end
491
- result
492
- end
493
-
494
- # Set an option value by name, either store value or call handler
495
- # String is given to extended value
496
- # @param option_symbol [Symbol] option name
497
- # @param value [String] Value to set
498
- # @param where [String] Where the value comes from
499
- def set_option(option_symbol, value, where: 'code override')
500
- Aspera.assert_type(option_symbol, Symbol)
501
- option = option_def(option_symbol)
502
- raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP)
503
- option.assign_value(value, where: where)
504
- end
505
-
506
- # Set option to `nil`
507
- def clear_option(option_symbol)
508
- Aspera.assert_type(option_symbol, Symbol)
509
- option_def(option_symbol).clear
510
- end
511
-
512
- # Adds each of the keys of specified hash as an option
513
- # @param preset_hash [Hash] Options to add
514
- # @param where [String] Where the value comes from
515
- # @param override [Boolean] Override if already present
516
- def add_option_preset(preset_hash, where, override: true)
517
- Aspera.assert_type(preset_hash, Hash)
518
- Log.log.debug{"add_option_preset: #{preset_hash}, #{where}, #{override}"}
519
- preset_hash.each do |k, v|
520
- option_symbol = k.to_sym
521
- @option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
522
- end
523
- end
524
-
525
- # Allows a plugin to add an argument as next argument to process
526
- def unshift_next_argument(argument)
527
- @unprocessed_cmd_line_arguments.unshift(argument)
528
- end
529
-
530
- # Check if there were unprocessed values to generate error
531
- def command_or_arg_empty?
532
- @unprocessed_cmd_line_arguments.empty?
533
- end
534
-
535
- # Unprocessed options or arguments ?
536
- def final_errors
537
- result = []
538
- result.push("unprocessed options: #{@unprocessed_cmd_line_options}") unless @unprocessed_cmd_line_options.empty?
539
- result.push("unprocessed values: #{@unprocessed_cmd_line_arguments}") unless @unprocessed_cmd_line_arguments.empty?
540
- result
541
- end
542
-
543
- # Get all original options on command line used to generate a config in config file
544
- # @return [Hash] options as taken from config file and command line just before command execution
545
- def unprocessed_options_with_value
546
- result = {}
547
- @initial_cli_options.each do |option_argument|
548
- # ignore short options
549
- next unless option_argument.start_with?(OPTION_PREFIX)
550
- name, value = option_argument.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
551
- # ignore options without value
552
- next if value.nil?
553
- Log.log.debug{"option #{name}=#{value}"}
554
- path = name.split(DotContainer::SEPARATOR)
555
- path[0] = self.class.option_line_to_name(path[0])
556
- DotContainer.dotted_to_container(path, smart_convert(value), result)
557
- @unprocessed_cmd_line_options.delete(option_argument)
558
- end
559
- result
560
- end
561
-
562
- # @param only_defined [Boolean] if true, only return options that were defined
563
- # @return [Hash] options as taken from config file and command line just before command execution
564
- def known_options(only_defined: false)
565
- result = {}
566
- @declared_options.each_key do |option_symbol|
567
- v = get_option(option_symbol)
568
- result[option_symbol] = v unless only_defined && v.nil?
569
- rescue => e
570
- result[option_symbol] = e.to_s
571
- end
572
- result
573
- end
574
-
575
- # Removes already known options from the list
576
- def parse_options!
577
- Log.log.trace1('parse_options!'.red)
578
- # First options from conf file
579
- consume_option_pairs(@option_pairs_batch, 'set')
580
- # Then, env var (to override)
581
- consume_option_pairs(@option_pairs_env, 'env')
582
- # Then, command line override
583
- unknown_options = []
584
- begin
585
- # remove known options one by one, exception if unknown
586
- Log.log.trace1('Before parse')
587
- Log.dump(:unprocessed_cmd_line_options, @unprocessed_cmd_line_options, level: :trace1)
588
- @parser.parse!(@unprocessed_cmd_line_options)
589
- Log.log.trace1('After parse')
590
- rescue OptionParser::InvalidOption => e
591
- Log.log.trace1{"InvalidOption #{e}".red}
592
- # An option like --a.b.c=d does: a={"b":{"c":ext_val(d)}}
593
- if e.args.first.start_with?(OPTION_PREFIX)
594
- name, value = e.args.first.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
595
- if !value.nil?
596
- path = name.split(DotContainer::SEPARATOR)
597
- option_sym = self.class.option_line_to_name(path.shift).to_sym
598
- if @declared_options.key?(option_sym)
599
- # it's a known option, so let's process it
600
- set_option(option_sym, DotContainer.dotted_to_container(path, smart_convert(value), get_option(option_sym)), where: 'dotted')
601
- # resume to next
602
- retry
603
- end
604
- end
605
- end
606
- # Save for later processing
607
- unknown_options.push(e.args.first)
608
- retry
609
- end
610
- Log.log.trace1{"remains: #{unknown_options}"}
611
- # Set unprocessed options for next time
612
- @unprocessed_cmd_line_options = unknown_options
613
- end
614
-
615
- def prompt_user_input(prompt, sensitive: false)
616
- return $stdin.getpass("#{prompt}> ") if sensitive
617
- print("#{prompt}> ")
618
- line = $stdin.gets
619
- Aspera.assert_type(line, String){'Unexpected end of standard input'}
620
- line.chomp
621
- end
622
-
623
- # prompt user for input in a list of symbols
624
- # @param prompt [String] prompt to display
625
- # @param sym_list [Array] list of symbols to select from
626
- # @return [Symbol] selected symbol
627
- def prompt_user_input_in_list(prompt, sym_list)
628
- loop do
629
- input = prompt_user_input(prompt).to_sym
630
- if sym_list.any?{ |a| a.eql?(input)}
631
- return input
632
- else
633
- $stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
634
- end
635
- end
636
- end
637
-
638
- # Prompt user for input in a list of symbols
639
- # @param descr [String] description for help
640
- # @param check_option [Boolean] Check attributes of option with name=descr
641
- # @param multiple [Boolean, String] `true` if multiple values expected
642
- # @param accept_list [Array<Symbol>,NilClass] List of expected values
643
- # @return [String] user input
644
- def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, schema: nil)
645
- option_attrs = @declared_options[descr.to_sym]
646
- what = option_attrs ? 'option' : 'argument'
647
- default_prompt = "#{what}: #{descr}"
648
- if !@ask_missing_mandatory
649
- message = "Missing #{default_prompt}"
650
- message = self.class.multi_choice_assert_msg(message, accept_list) if accept_list
651
- message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
652
- raise Cli::MissingArgument, message
653
- end
654
- # ask interactively
655
- result = []
656
- puts(' (one per line, end with empty line)') if multiple
657
- loop do
658
- prompt = default_prompt
659
- prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
660
- entry = prompt_user_input(prompt, sensitive: option_attrs&.sensitive)
661
- break if entry.empty? && multiple
662
- entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
663
- entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
664
- return entry unless multiple
665
- result.push(entry)
666
- end
667
- result
668
- end
669
-
670
- # Read remaining args and build an `Array` or `Hash`
671
- # @param value [String] Argument to `@:` extended value
672
- # @return [Hash, Array] Object representing dot-path values
673
- def args_as_extended(end_marker)
674
- # This extended value does not take args (`@:`)
675
- # ExtendedValue.assert_no_value(end_marker, :p)
676
- end_marker = SpecialValues::EOA if end_marker.empty?
677
- result = nil
678
- get_next_argument('args', multiple: end_marker).each do |argument|
679
- Aspera.assert(argument.include?(OPTION_VALUE_SEPARATOR)){"Positional argument: #{argument} does not include #{OPTION_VALUE_SEPARATOR}"}
680
- path, value = argument.split(OPTION_VALUE_SEPARATOR, 2)
681
- result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), smart_convert(value), result)
682
- end
683
- result
684
- end
685
-
686
- # ======================================================
687
- private
688
-
689
- # Using dotted hash notation, convert value to bool, int, float or extended value
690
- # @param value [String] The value to convert to appropriate type
691
- # @return the converted value
692
- def smart_convert(value)
693
- case value
694
- when 'true' then true
695
- when 'false' then false
696
- else
697
- Integer(value, exception: false) ||
698
- Float(value, exception: false) ||
699
- ExtendedValue.instance.evaluate(value, context: 'dotted expression')
700
- end
701
- end
702
-
703
- # generate command line option from option symbol
704
- def symbol_to_option(symbol, opt_val = nil)
705
- result = [OPTION_PREFIX, symbol.to_s.gsub(OPTION_SEP_SYMBOL, OPTION_SEP_LINE)].join
706
- result = [result, OPTION_VALUE_SEPARATOR, opt_val].join unless opt_val.nil?
707
- result
708
- end
709
-
710
- # TODO: use formatter
711
- # Highlight current value in list
712
- # @param list [Array<Symbol>] List of possible values
713
- # @param current [Symbol] Current value
714
- # @return [String] comma separated sorted list of values, with the current value highlighted
715
- def highlight_current_in_list(list, current)
716
- list.sort.map do |i|
717
- if i.eql?(current)
718
- $stdout.isatty ? i.to_s.red.bold : "[#{i}]"
719
- else
720
- i
721
- end
722
- end.join(', ')
723
- end
724
-
725
- # Try to evaluate options set in batch
726
- # @param unprocessed_options [Array] list of options to apply (key_sym,value)
727
- # @param where [String] where the options come from
728
- def consume_option_pairs(unprocessed_options, where)
729
- Log.log.trace1{"consume_option_pairs: #{where}"}
730
- options_to_set = {}
731
- unprocessed_options.each do |k, v|
732
- if @declared_options.key?(k)
733
- # constrained parameters as string are revert to symbol
734
- v = self.class.get_from_list(v, "#{k} in #{where}", @declared_options[k].values) if @declared_options[k].values && v.is_a?(String)
735
- options_to_set[k] = v
736
- else
737
- Log.log.trace1{"unprocessed: #{k}: #{v}"}
738
- end
739
- end
740
- options_to_set.each do |k, v|
741
- set_option(k, v, where: where)
742
- # keep only unprocessed values for next parse
743
- unprocessed_options.delete(k)
744
- end
745
- end
746
-
747
- # Option name separator on command line, e.g. in --option-blah, third "-"
748
- OPTION_SEP_LINE = '-'
749
- # Option name separator in code (symbol), e.g. in :option_blah, the "_"
750
- OPTION_SEP_SYMBOL = '_'
751
- # Option value separator on command line, e.g. in --option-blah=foo, the "="
752
- OPTION_VALUE_SEPARATOR = '='
753
- # Starts an option, e.g. in --option-blah, the two first "--"
754
- OPTION_PREFIX = '--'
755
- # when this is alone, this stops option processing
756
- OPTIONS_STOP = '--'
757
- SOURCE_USER = 'cmdline' # cspell:disable-line
758
- # Percent selector: select by this field for this value
759
- REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
760
- # Ask for schema of Extended value
761
- HELP = 'help'
762
-
763
- private_constant :OPTION_SEP_LINE, :OPTION_SEP_SYMBOL, :OPTION_VALUE_SEPARATOR, :OPTION_PREFIX, :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD
764
- end
765
- end
766
- end