require_relative '../helper' require_relative '../globals' require_relative 'config_item' require_relative 'commander_generator' require_relative 'configuration_file' module FastlaneCore class Configuration attr_accessor :available_options attr_accessor :values # @return [Array] An array of symbols which are all available keys attr_reader :all_keys # @return [String] The name of the configuration file (not the path). Optional! attr_accessor :config_file_name # @return [Hash] Options that were set from a config file using load_configuration_file. Optional! attr_accessor :config_file_options def self.create(available_options, values) UI.user_error!("values parameter must be a hash") unless values.kind_of?(Hash) v = values.dup v.each do |key, val| v[key] = val.dup if val.kind_of?(String) # this is necessary when fetching a value from an environment variable end if v.kind_of?(Hash) && available_options.kind_of?(Array) # we only want to deal with the new configuration system # Now see if --verbose would be a valid input # If not, it might be because it's an action and not a tool unless available_options.find { |a| a.kind_of?(ConfigItem) && a.key == :verbose } v.delete(:verbose) # as this is being processed by commander end end Configuration.new(available_options, v) end ##################################################### # @!group Setting up the configuration ##################################################### # collect sensitive strings def self.sensitive_strings @sensitive_strings ||= [] end def initialize(available_options, values) self.available_options = available_options || [] self.values = values || {} self.config_file_options = {} # used for pushing and popping values to provide nesting configuration contexts @values_stack = [] # if we are in captured output mode - keep a array of sensitive option values # those will be later - replaced by #### if FastlaneCore::Globals.capture_output? available_options.each do |element| next unless element.sensitive self.class.sensitive_strings << values[element.key] end end verify_input_types verify_value_exists verify_no_duplicates verify_conflicts verify_default_value_matches_verify_block end def verify_input_types UI.user_error!("available_options parameter must be an array of ConfigItems but is #{@available_options.class}") unless @available_options.kind_of?(Array) @available_options.each do |item| UI.user_error!("available_options parameter must be an array of ConfigItems. Found #{item.class}.") unless item.kind_of?(ConfigItem) end UI.user_error!("values parameter must be a hash") unless @values.kind_of?(Hash) end def verify_value_exists # Make sure the given value keys exist @values.each do |key, value| next if key == :trace # special treatment option = self.verify_options_key!(key) @values[key] = option.auto_convert_value(value) UI.deprecated("Using deprecated option: '--#{key}' (#{option.deprecated})") if option.deprecated option.verify!(@values[key]) # Call the verify block for it too end end def verify_no_duplicates # Make sure a key was not used multiple times @available_options.each do |current| count = @available_options.count { |option| option.key == current.key } UI.user_error!("Multiple entries for configuration key '#{current.key}' found!") if count > 1 unless current.short_option.to_s.empty? count = @available_options.count { |option| option.short_option == current.short_option } UI.user_error!("Multiple entries for short_option '#{current.short_option}' found!") if count > 1 end end end def verify_conflicts option_keys = @values.keys option_keys.each do |current| index = @available_options.find_index { |item| item.key == current } current = @available_options[index] # ignore conflicts because option value is nil next if @values[current.key].nil? next if current.conflicting_options.nil? conflicts = current.conflicting_options & option_keys next if conflicts.nil? conflicts.each do |conflicting_option_key| index = @available_options.find_index { |item| item.key == conflicting_option_key } conflicting_option = @available_options[index] # ignore conflicts because value of conflict option is nil next if @values[conflicting_option.key].nil? if current.conflict_block begin current.conflict_block.call(conflicting_option) rescue => ex UI.error("Error resolving conflict between options: '#{current.key}' and '#{conflicting_option.key}'") raise ex end else UI.user_error!("Unresolved conflict between options: '#{current.key}' and '#{conflicting_option.key}'") end end end end # Verifies the default value is also valid def verify_default_value_matches_verify_block @available_options.each do |item| next unless item.verify_block && item.default_value begin unless @values[item.key] # this is important to not verify if there already is a value there item.verify_block.call(item.default_value) end rescue => ex UI.error(ex) UI.user_error!("Invalid default value for #{item.key}, doesn't match verify_block") end end end # This method takes care of parsing and using the configuration file as values # Call this once you know where the config file might be located # Take a look at how `gym` uses this method # # @param config_file_name [String] The name of the configuration file to use (optional) # @param block_for_missing [Block] A ruby block that is called when there is an unknown method # in the configuration file def load_configuration_file(config_file_name = nil, block_for_missing = nil, skip_printing_values = false) return unless config_file_name self.config_file_name = config_file_name path = FastlaneCore::Configuration.find_configuration_file_path(config_file_name: config_file_name) return if path.nil? begin configuration_file = ConfigurationFile.new(self, path, block_for_missing, skip_printing_values) options = configuration_file.options rescue FastlaneCore::ConfigurationFile::ExceptionWhileParsingError => e options = e.recovered_options wrapped_exception = e.wrapped_exception end # Make sure all the values set in the config file pass verification options.each do |key, val| option = self.verify_options_key!(key) option.verify!(val) end # Merge the new options into the old ones, keeping all previously set keys self.config_file_options = options.merge(self.config_file_options) verify_conflicts # important, since user can set conflicting options in configuration file # Now that everything is verified, re-raise an exception that was raised in the config file raise wrapped_exception unless wrapped_exception.nil? configuration_file end def self.find_configuration_file_path(config_file_name: nil) paths = [] paths += Dir["./fastlane/#{config_file_name}"] paths += Dir["./.fastlane/#{config_file_name}"] paths += Dir["./#{config_file_name}"] paths += Dir["./fastlane_core/spec/fixtures/#{config_file_name}"] if Helper.test? return nil if paths.count == 0 return paths.first end ##################################################### # @!group Actually using the class ##################################################### # Returns the value for a certain key. fastlane_core tries to fetch the value from different sources # if 'ask' is true and the value is not present, the user will be prompted to provide a value if optional # if 'force_ask' is true, the option is not required to be optional to ask # rubocop:disable Metrics/PerceivedComplexity def fetch(key, ask: true, force_ask: false) UI.crash!("Key '#{key}' must be a symbol. Example :#{key}") unless key.kind_of?(Symbol) option = verify_options_key!(key) # Same order as https://docs.fastlane.tools/advanced/#priorities-of-parameters-and-options value = if @values.key?(key) && !@values[key].nil? @values[key] elsif (env_value = option.fetch_env_value) env_value elsif self.config_file_options.key?(key) self.config_file_options[key] else option.default_value end value = option.auto_convert_value(value) value = nil if value.nil? && !option.string? # by default boolean flags are false return value unless value.nil? && (!option.optional || force_ask) && ask # fallback to asking if Helper.test? || !UI.interactive? # Since we don't want to be asked on tests, we'll just call the verify block with no value # to raise the exception that is shown when the user passes an invalid value set(key, '') # If this didn't raise an exception, just raise a default one UI.user_error!("No value found for '#{key}'") end while value.nil? UI.important("To not be asked about this value, you can specify it using '#{option.key}'") if ENV["FASTLANE_ONBOARDING_IN_PROCESS"].to_s.length == 0 value = option.sensitive ? UI.password("#{option.description}: ") : UI.input("#{option.description}: ") # ConfigItem allows to specify a type for the item but UI.password and # UI.input return String values. Try to convert the String input to # the option's type before passing it along. value = option.auto_convert_value(value) # Also store this value to use it from now on begin set(key, value) rescue => ex UI.error(ex) value = nil end end # It's very, very important to use the self[:my_key] notation # as this will make sure to use the `fetch` method # that is responsible for auto converting the values into the right # data type # Found out via https://github.com/fastlane/fastlane/issues/11243 return self[key] end # rubocop:enable Metrics/PerceivedComplexity # Overwrites or sets a new value for a given key # @param key [Symbol] Must be a symbol def set(key, value) UI.crash!("Key '#{key}' must be a symbol. Example :#{key}.") unless key.kind_of?(Symbol) option = option_for_key(key) unless option UI.user_error!("Could not find option '#{key}' in the list of available options: #{@available_options.collect(&:key).join(', ')}") end option.verify!(value) @values[key] = value true end # see fetch def values(ask: true) # As the user accesses all values, we need to iterate through them to receive all the values @available_options.each do |option| @values[option.key] = fetch(option.key, ask: ask) unless @values[option.key] end @values end # Direct access to the values, without iterating through all items def _values @values end # Clears away any current configuration values by pushing them onto a stack. # Values set after calling push_values! will be merged with the previous # values after calling pop_values! # # see: pop_values! def push_values! @values_stack.push(@values) @values = {} end # Restores a previous set of configuration values by merging any current # values on top of them # # see: push_values! def pop_values! return if @values_stack.empty? @values = @values_stack.pop.merge(@values) end def all_keys @available_options.collect(&:key) end # Returns the config_item object for a given key def option_for_key(key) @available_options.find { |o| o.key == key } end # Aliases `[key]` to `fetch(key)` because Ruby can do it. alias [] fetch alias []= set def verify_options_key!(key) option = option_for_key(key) UI.user_error!("Could not find option '#{key}' in the list of available options: #{@available_options.collect(&:key).join(', ')}") unless option option end end end