diff --git a/.rubocop.yml b/.rubocop.yml index 23954f6..d0b934d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,9 @@ require: rubocop-rspec LineLength: Max: 120 +Metrics/ModuleLength: + Max: 150 + # This is a stupid lint. Yes, module_function is more explanatory than extend self for most use cases. However, the # former does not work if you want the methods to be able to call private methods; the latter does. See # https://practicingruby.com/articles/ruby-and-the-singleton-pattern-dont-get-along for a detailed explanation of the diff --git a/lib/shanty/cli.rb b/lib/shanty/cli.rb index 72f6298..1254595 100644 --- a/lib/shanty/cli.rb +++ b/lib/shanty/cli.rb @@ -2,6 +2,9 @@ require 'i18n' require 'shanty/info' require 'shanty/task_set' +require 'shanty/env' +require 'shenanigans/hash/to_ostruct' +require 'deep_merge' module Shanty # Public: Handle the CLI interface between the user and the registered tasks @@ -11,6 +14,8 @@ class Cli attr_reader :task_sets + CONFIG_FORMAT = '[plugin]:[key] [value]' + def initialize(task_sets) @task_sets = task_sets end @@ -28,11 +33,22 @@ def run program(:description, Info::DESCRIPTION) setup_tasks + global_config run! end private + def global_config + global_option('-c', '--config [CONFIG]', "Add config via command line in the format #{CONFIG_FORMAT}") do |config| + if (match = config.match(/(?\S+):(?\S+)\s+(?\S+)/)) + Env.config.merge!(match[:plugin] => { match[:key] => match[:value] }) + else + fail(I18n.t('cli.invalid_config_param', actual: config, expected: CONFIG_FORMAT)) + end + end + end + def setup_tasks tasks.each do |name, task| setup_task(name, task) @@ -108,7 +124,7 @@ def enforce_required_options(task, options) option[:required] && options.send(option_name).nil? end.keys.join(', ') - abort("missing required params: #{missing}. Use --help for more information.") unless missing.empty? + fail(I18n.t('cli.params_missing', missing: missing)) unless missing.empty? end end end diff --git a/lib/shanty/config.rb b/lib/shanty/config.rb new file mode 100644 index 0000000..b1f9e1f --- /dev/null +++ b/lib/shanty/config.rb @@ -0,0 +1,47 @@ +require 'shenanigans/hash/to_ostruct' +require 'deep_merge' + +module Shanty + # Public: Configuration class for shanty + class Config + CONFIG_FILE = '.shanty.yml' + + def initialize(root, environment) + @root = root + @environment = environment + end + + def merge!(new_config) + @config = config_hash.clone.deep_merge(new_config) + end + + def [](key) + to_ostruct[key] + end + + def method_missing(m) + to_ostruct.send(m) || OpenStruct.new + end + + def respond_to?(method_sym, include_private = false) + super || to_ostruct.respond_to?(method_sym, include_private) + end + + def to_ostruct + config_hash.to_ostruct + end + + private + + def config_hash + return @config unless @config.nil? + return @config = {} unless File.exist?(config_path) + config = YAML.load_file(config_path) || {} + @config = config[@environment] || {} + end + + def config_path + "#{@root}/#{CONFIG_FILE}" + end + end +end diff --git a/lib/shanty/env.rb b/lib/shanty/env.rb index fe3e990..8ea4abc 100644 --- a/lib/shanty/env.rb +++ b/lib/shanty/env.rb @@ -2,7 +2,9 @@ require 'logger' require 'pathname' require 'yaml' +require 'shenanigans/hash/to_ostruct' +require 'shanty/config' require 'shanty/project_tree' module Shanty @@ -28,7 +30,7 @@ def clear! def require! Dir.chdir(root) do - (config['require'] || {}).each do |path| + (config['require'] || []).each do |path| requires_in_path(path).each { |f| require File.join(root, f) } end end @@ -59,10 +61,10 @@ def project_tree end def config - return @@config unless @@config.nil? - return @@config = {} unless File.exist?(config_path) - config = YAML.load_file(config_path) || {} - @@config = config[environment] || {} + @@config ||= Config.new(root, environment) + rescue RuntimeError + # Create config object without .shanty.yml if the project root cannot be resolved + @@config ||= Config.new(nil, environment) end private @@ -77,10 +79,6 @@ def requires_in_path(path) end end - def config_path - "#{root}/#{CONFIG_FILE}" - end - def find_root fail I18n.t('missing_root', config_file: CONFIG_FILE) if root_dir.nil? root_dir diff --git a/lib/shanty/plugin.rb b/lib/shanty/plugin.rb index 702d117..fb5d4d9 100644 --- a/lib/shanty/plugin.rb +++ b/lib/shanty/plugin.rb @@ -7,12 +7,17 @@ module Shanty class Plugin include CallMeRuby include Env + extend Env def self.inherited(plugin_class) @plugins ||= [] @plugins << plugin_class.new end + def self.plugins + (@plugins || []) + end + def self.all_projects (@plugins || []).flat_map(&:projects).uniq end @@ -22,7 +27,15 @@ def self.all_with_graph(graph) end def self.tags(*args) - (@tags ||= []).concat(args.map(&:to_sym)) + (@tags ||= [name]).concat(args.map(&:to_sym)) + end + + def self.option(option, default = nil) + config[name][option] = default if config[name][option].nil? + end + + def self.options + config[name] end def self.projects(*globs_or_syms) @@ -33,6 +46,10 @@ def self.with_graph(&block) (@with_graph_callbacks ||= []) << block end + def self.name + to_s.split('::').last.downcase.gsub('plugin', '').to_sym + end + def projects project_matchers = self.class.instance_variable_get(:@project_matchers) return [] if project_matchers.nil? || project_matchers.empty? diff --git a/lib/shanty/plugins/bundler_plugin.rb b/lib/shanty/plugins/bundler_plugin.rb index 141d6e0..2fdf378 100644 --- a/lib/shanty/plugins/bundler_plugin.rb +++ b/lib/shanty/plugins/bundler_plugin.rb @@ -4,7 +4,6 @@ module Shanty # Public: Bundler plugin for building ruby projects. class BundlerPlugin < Plugin - tags :bundler projects '**/Gemfile' subscribe :build, :bundle_install diff --git a/lib/shanty/plugins/cucumber_plugin.rb b/lib/shanty/plugins/cucumber_plugin.rb index 0747eb7..188c7f4 100644 --- a/lib/shanty/plugins/cucumber_plugin.rb +++ b/lib/shanty/plugins/cucumber_plugin.rb @@ -3,7 +3,6 @@ module Shanty # Public: Cucumber plugin for testing ruby projects. class CucumberPlugin < Plugin - tags :cucumber subscribe :test, :cucumber # By default, we'll detect Cucumber in a project if has a dependency on it in a Gemfile or *.gemspec file. If you # don't use these files, you'll need to import the plugin manually using a Shantyfile as we can't tell if RSpec is diff --git a/lib/shanty/plugins/rspec_plugin.rb b/lib/shanty/plugins/rspec_plugin.rb index feed6fc..5e1675b 100644 --- a/lib/shanty/plugins/rspec_plugin.rb +++ b/lib/shanty/plugins/rspec_plugin.rb @@ -3,7 +3,6 @@ module Shanty # Public: Rspec plugin for testing ruby projects. class RspecPlugin < Plugin - tags :rspec subscribe :test, :rspec # By default, we'll detect RSpec in a project if has a dependency on it in a Gemfile or *.gemspec file. If you don't # use these files, you'll need to import the plugin manually using a Shantyfile as we can't tell if RSpec is being diff --git a/lib/shanty/plugins/rubocop_plugin.rb b/lib/shanty/plugins/rubocop_plugin.rb index 8ea2dba..7161848 100644 --- a/lib/shanty/plugins/rubocop_plugin.rb +++ b/lib/shanty/plugins/rubocop_plugin.rb @@ -3,7 +3,6 @@ module Shanty # Public: Rubocop plugin for style checking ruby projects. class RubocopPlugin < Plugin - tags :rubocop projects '**/.rubocop.yml' subscribe :test, :rubocop diff --git a/lib/shanty/plugins/rubygem_plugin.rb b/lib/shanty/plugins/rubygem_plugin.rb index ffd75df..a98c83e 100644 --- a/lib/shanty/plugins/rubygem_plugin.rb +++ b/lib/shanty/plugins/rubygem_plugin.rb @@ -6,9 +6,9 @@ module Shanty class RubygemPlugin < Plugin ARTIFACT_EXTENSION = 'gem' - tags :rubygem projects '**/*.gemspec' subscribe :build, :build_gem + tags :gem def build_gem(project) gemspec_files(project).each do |file| diff --git a/lib/shanty/plugins/shantyfile_plugin.rb b/lib/shanty/plugins/shantyfile_plugin.rb index e83128c..f5cb226 100644 --- a/lib/shanty/plugins/shantyfile_plugin.rb +++ b/lib/shanty/plugins/shantyfile_plugin.rb @@ -3,7 +3,6 @@ module Shanty # Public: Plugin for finding all directories marked with a Shantyfile. class ShantyfilePlugin < Plugin - tags :shantyfile projects :shantyfile_projects def shantyfile_projects diff --git a/lib/shanty/task_sets/basic_task_set.rb b/lib/shanty/task_sets/basic_task_set.rb index 045798a..4f4a118 100644 --- a/lib/shanty/task_sets/basic_task_set.rb +++ b/lib/shanty/task_sets/basic_task_set.rb @@ -1,6 +1,7 @@ require 'fileutils' require 'i18n' require 'shanty/task_set' +require 'shanty/plugin' module Shanty # Public: A set of basic tasks that can be applied to all projects and that @@ -11,6 +12,13 @@ def init FileUtils.touch(File.join(Dir.pwd, '.shanty.yml')) end + desc 'plugins', 'tasks.plugins.desc' + def plugins(_) + Plugin.plugins.each do |plugin| + puts plugin.class.name + end + end + desc 'projects [--tags TAG,TAG,...]', 'tasks.projects.desc' option :tags, type: :array, desc: 'tasks.common.options.tags' def projects(options) diff --git a/shanty.gemspec b/shanty.gemspec index ca53658..976ec80 100644 --- a/shanty.gemspec +++ b/shanty.gemspec @@ -21,10 +21,11 @@ Gem::Specification.new do |gem| gem.files = Dir['**/*'].select { |d| d =~ %r{^(README|bin/|ext/|lib/)} } gem.add_dependency 'acts_as_graph_vertex', '~>1.0' - gem.add_dependency 'bundler', '~>1.10' gem.add_dependency 'algorithms', '~>0.6' + gem.add_dependency 'bundler', '~>1.10' gem.add_dependency 'call_me_ruby', '~>1.1' gem.add_dependency 'commander', '~>4.3' + gem.add_dependency 'deep_merge', '~>1.0' gem.add_dependency 'gitignore_rb', '~>0.2.2' gem.add_dependency 'i18n', '~>0.7' gem.add_dependency 'shenanigans', '~>1.0' diff --git a/spec/lib/shanty/cli_spec.rb b/spec/lib/shanty/cli_spec.rb index bb0ceed..db4cf12 100644 --- a/spec/lib/shanty/cli_spec.rb +++ b/spec/lib/shanty/cli_spec.rb @@ -1,7 +1,10 @@ require 'commander' require 'spec_helper' +require 'i18n' require 'shanty/cli' require 'shanty/info' +require 'shanty/env' +require 'shenanigans/hash/to_ostruct' require_fixture 'test_task_set' # All classes referenced belong to the shanty project @@ -94,7 +97,10 @@ module Shanty end it('fails to run a command when run without required options') do - expect(subject).to receive(:abort).with('missing required params: catweasel. Use --help for more information.') + # FIXME: Commander catches the exception and rethrows it with extra stuff, we should move away from commander + expect(Commander::Runner.instance).to receive(:abort).with( + include(I18n.t('cli.params_missing', missing: 'catweasel')) + ) ARGV.concat(%w(foo)) @@ -112,6 +118,30 @@ module Shanty subject.run end + + it('fails to run a command with config options if config is in incorrect format') do + ARGV.concat(%w(-c nic foo)) + + expect do + subject.run + end.to raise_error(I18n.t('cli.invalid_config_param', actual: 'nic', expected: Cli::CONFIG_FORMAT)) + end + + it('runs a command with a config option') do + ARGV.concat(['-c nic:kim cage']) + + subject.run + + expect(Env.config.nic).to eql({ kim: 'cage' }.to_ostruct) + end + + it('runs a command with multiple config options') do + ARGV.concat(['-c nic:kim cage', '-c nic:copolla cage']) + + subject.run + + expect(Env.config.nic).to eql({ kim: 'cage', copolla: 'cage' }.to_ostruct) + end end end end diff --git a/spec/lib/shanty/config_spec.rb b/spec/lib/shanty/config_spec.rb new file mode 100644 index 0000000..8f60c28 --- /dev/null +++ b/spec/lib/shanty/config_spec.rb @@ -0,0 +1,79 @@ +require 'shanty/config' +require 'shenanigans/hash/to_ostruct' +require 'deep_merge' + +# All classes referenced belong to the shanty project +module Shanty + RSpec.describe(Config) do + subject { Config.new('nic', 'test') } + + let(:subconfig) { { 'kim' => 'cage' } } + let(:config) { { 'nic' => subconfig } } + let(:env_config) { { 'test' => config } } + + before do + allow(File).to receive(:exist?) { true } + allow(YAML).to receive(:load_file) { env_config } + end + + describe(described_class) do + it('can respond like an OpenStruct') do + expect(subject.nic).to eql(subconfig.to_ostruct) + end + + it('handles no config file existing') do + allow(File).to receive(:exist?) { false } + expect(subject.nic).to eql(OpenStruct.new) + end + + it('handles no data in the config file') do + allow(YAML).to receive(:load_file) { false } + expect(subject.nic).to eql(OpenStruct.new) + end + + it('loads config from a file') do + allow(File).to receive(:exist?).and_call_original + allow(YAML).to receive(:load_file).and_call_original + Dir.mktmpdir('shanty-tests') do |tmp_path| + Dir.chdir(tmp_path) { FileUtils.touch('.shanty.yml') } + expect(Config.new(tmp_path, 'test').nic).to eql(OpenStruct.new) + end + end + + it('returns nothing if .shanty.yml does not exist') do + allow(File).to receive(:exist?).and_call_original + allow(YAML).to receive(:load_file).and_call_original + expect(subject.nic).to eql(OpenStruct.new) + end + end + + describe('#[]') do + it('can respond like a hash') do + expect(subject['nic']).to eql(subconfig.to_ostruct) + end + end + + describe('#merge!') do + it('can merge in new config') do + added_config = { 'copolla' => 'cage' } + subject.merge!('nic' => added_config) + + expect(subject['nic']).to eql(subconfig.deep_merge(added_config).to_ostruct) + end + end + + describe('#respond_to?') do + it('is able to repond to a method on the config class') do + expect(subject.respond_to?(:merge!)).to be(true) + end + + it('is able to respond to a method on the underlying openstruct') do + expect(subject.respond_to?(:nic)).to be(true) + end + + it('is not able to respond to a non-existent method') do + expect(subject.respond_to?(:copolla)).to be(false) + end + end + end +end diff --git a/spec/lib/shanty/env_spec.rb b/spec/lib/shanty/env_spec.rb index a232bb5..f0ca126 100644 --- a/spec/lib/shanty/env_spec.rb +++ b/spec/lib/shanty/env_spec.rb @@ -3,6 +3,8 @@ require 'i18n' require 'tmpdir' require 'shanty/env' +require 'shenanigans/hash/to_ostruct' +require 'deep_merge' # All classes referenced belong to the shanty project module Shanty @@ -104,32 +106,29 @@ module Shanty it('throws an exception if no ancestor folders have a .shanty.yml file in them') do FileUtils.rm('.shanty.yml') - expect { subject.root }.to raise_error(I18n.t('missing_root')) + expect { subject.root }.to raise_error(I18n.t('missing_root', config_file: Env::CONFIG_FILE)) end end describe('#config') do - let(:env_config) { { 'foo' => 'bar' } } - let(:config) { { 'stray_cats' => env_config } } - before do ENV['SHANTY_ENV'] = 'stray_cats' - allow(YAML).to receive(:load_file) { config } - end - - it('has all the keys from the config file for the current env') do - expect(subject.config).to eql(env_config) end it('handles no config file existing') do allow(File).to receive(:exist?) { false } - expect(subject.config).to eql({}) + expect(subject.config.nic).to eql(OpenStruct.new) end it('handles no data in the config file') do allow(YAML).to receive(:load_file) { false } expect(subject.config) end + + it('returns nothing if .shanty.yml does not exist') do + FileUtils.rm('.shanty.yml') + expect(subject.config.nic).to eql(OpenStruct.new) + end end end end diff --git a/spec/lib/shanty/plugin_spec.rb b/spec/lib/shanty/plugin_spec.rb index d128b61..b031ed1 100644 --- a/spec/lib/shanty/plugin_spec.rb +++ b/spec/lib/shanty/plugin_spec.rb @@ -20,6 +20,18 @@ def foo; end described_class.instance_variable_set(:@plugins, plugins) end + describe('.plugins') do + it('finds all the loaded plugins') do + expect(described_class.plugins.length).to be(1) + end + end + + describe('.name') do + it('gets the name of the class') do + expect(plugin_class.name).to eql(plugin_class.to_s.downcase.to_sym) + end + end + describe('.inherited') do it('stores a new instance of any class that extends Plugin') do expect(described_class.instance_variable_get(:@plugins).size).to eq(1) @@ -46,17 +58,57 @@ def foo; end end end + describe('.option') do + it('can set an expected option default') do + allow(plugin_class).to receive(:config).and_return(plugin_class.name => {}) + + plugin_class.option(:nic, 'cage') + + expect(plugin_class.options[:nic]).to eql('cage') + end + end + + describe('.options') do + it('returns a default option value') do + allow(plugin_class).to receive(:config).and_return(plugin_class.name => {}) + + plugin_class.option(:nic, 'cage') + + expect(plugin_class.options[:nic]).to eql('cage') + end + + it('returns an existing option value') do + allow(plugin_class).to receive(:config).and_return(plugin_class.name => { test: 'test' }) + + expect(plugin_class.options[:test]).to eql('test') + end + + it('overrides a default option value') do + allow(plugin_class).to receive(:config).and_return(plugin_class.name => { nic: 'cage' }) + + plugin_class.option(:nic, 'copolla') + + expect(plugin_class.options[:nic]).to eql('cage') + end + + it('returns nothing for a non-existent option') do + allow(plugin_class).to receive(:config).and_return(plugin_class.name => {}) + + expect(plugin_class.options[:nic]).to be_nil + end + end + describe('.tags') do it('stores the given tags') do plugin_class.tags(:foo, :marbles) - expect(plugin_class.instance_variable_get(:@tags)).to match_array([:foo, :marbles]) + expect(plugin_class.instance_variable_get(:@tags)).to match_array([plugin_class.name.to_sym, :foo, :marbles]) end it('converts any given tags to symbols') do plugin_class.tags('bar', 'lux') - expect(plugin_class.instance_variable_get(:@tags)).to match_array([:bar, :lux]) + expect(plugin_class.instance_variable_get(:@tags)).to match_array([plugin_class.name.to_sym, :bar, :lux]) end end @@ -77,6 +129,12 @@ def foo; end end end + describe('#artifacts') do + it('returns no artifacts when artifacts have not been implemented') do + expect(subject.artifacts(project)).to be_empty + end + end + describe('#projects') do let(:project_tree) { double('project_tree') } diff --git a/spec/lib/shanty/plugins/bundler_plugin_spec.rb b/spec/lib/shanty/plugins/bundler_plugin_spec.rb index 9667915..e10e19a 100644 --- a/spec/lib/shanty/plugins/bundler_plugin_spec.rb +++ b/spec/lib/shanty/plugins/bundler_plugin_spec.rb @@ -6,8 +6,8 @@ module Shanty RSpec.describe(BundlerPlugin) do include_context('graph') - it('adds the bundler tag') do - expect(described_class).to add_tags(:bundler) + it('adds the bundler tag automatically') do + expect(described_class.tags).to match_array([:bundler]) end it('finds projects that have a Gemfile') do diff --git a/spec/lib/shanty/plugins/cucumber_plugin_spec.rb b/spec/lib/shanty/plugins/cucumber_plugin_spec.rb index b22407c..7bbb45e 100644 --- a/spec/lib/shanty/plugins/cucumber_plugin_spec.rb +++ b/spec/lib/shanty/plugins/cucumber_plugin_spec.rb @@ -6,8 +6,8 @@ module Shanty RSpec.describe(CucumberPlugin) do include_context('graph') - it('adds the cucumber tag') do - expect(described_class).to add_tags(:cucumber) + it('adds the cucumber tag automatically') do + expect(described_class.tags).to match_array([:cucumber]) end it('finds projects by calling a method to locate the ones that depend on Cucumber') do diff --git a/spec/lib/shanty/plugins/rspec_plugin_spec.rb b/spec/lib/shanty/plugins/rspec_plugin_spec.rb index 27e3be3..a3db2b8 100644 --- a/spec/lib/shanty/plugins/rspec_plugin_spec.rb +++ b/spec/lib/shanty/plugins/rspec_plugin_spec.rb @@ -7,8 +7,8 @@ module Shanty RSpec.describe(RspecPlugin) do include_context('graph') - it('adds the rspec tag') do - expect(described_class).to add_tags(:rspec) + it('adds the rspec tag automatically') do + expect(described_class.tags).to match_array([:rspec]) end it('finds projects by calling a method to locate the ones that depend on RSpec') do diff --git a/spec/lib/shanty/plugins/rubocop_plugin_spec.rb b/spec/lib/shanty/plugins/rubocop_plugin_spec.rb index 601d995..85244e2 100644 --- a/spec/lib/shanty/plugins/rubocop_plugin_spec.rb +++ b/spec/lib/shanty/plugins/rubocop_plugin_spec.rb @@ -6,8 +6,8 @@ module Shanty RSpec.describe(RubocopPlugin) do include_context('graph') - it('adds the rubocop tag') do - expect(described_class).to add_tags(:rubocop) + it('adds the rubocop tag automatically') do + expect(described_class.tags).to match_array([:rubocop]) end it('finds projects that have a .rubocop.yml file') do diff --git a/spec/lib/shanty/plugins/rubygem_plugin_spec.rb b/spec/lib/shanty/plugins/rubygem_plugin_spec.rb index 53aba26..32cb937 100644 --- a/spec/lib/shanty/plugins/rubygem_plugin_spec.rb +++ b/spec/lib/shanty/plugins/rubygem_plugin_spec.rb @@ -17,8 +17,12 @@ module Shanty eof end - it('adds the rubygem tag') do - expect(described_class).to add_tags(:rubygem) + it('adds the gem tag') do + expect(described_class).to add_tags(:gem) + end + + it('adds the rubygem tag automatically') do + expect(described_class.tags).to match_array([:rubygem, :gem]) end it('finds projects that have a *.gemspec file') do diff --git a/spec/lib/shanty/plugins/shantyfile_plugin_spec.rb b/spec/lib/shanty/plugins/shantyfile_plugin_spec.rb index a25faa0..47ffe57 100644 --- a/spec/lib/shanty/plugins/shantyfile_plugin_spec.rb +++ b/spec/lib/shanty/plugins/shantyfile_plugin_spec.rb @@ -6,8 +6,8 @@ module Shanty RSpec.describe(ShantyfilePlugin) do include_context('graph') - it('adds the shantyfile tag') do - expect(described_class).to add_tags(:shantyfile) + it('adds the shantyfile tag shantyfile') do + expect(described_class.tags).to match_array([:shantyfile]) end it('finds projects by calling a method to locate the ones that have a Shantyfile') do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5606a5d..7c6bf7c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -31,6 +31,7 @@ def require_matchers(path) require_matchers 'plugin' I18n.enforce_available_locales = false +I18n.load_path = Dir[File.expand_path(File.join(__dir__, '..', 'translations', '*.yml'))] RSpec.configure do |config| config.expect_with(:rspec) do |c| diff --git a/translations/en.yml b/translations/en.yml index 294aff6..3b09966 100644 --- a/translations/en.yml +++ b/translations/en.yml @@ -1,10 +1,15 @@ --- en: + cli: + invalid_config_param: Invalid config format '%{actual}', should be in the format '%{expected}' + params_missing: "Missing required params: %{missing}" missing_root: Could not find a %{config_file} file in this or any parent directories. Please run `shanty init` in the directory you want to be the root of your project structure tasks: common: options: tags: An optional list of tags to filter by. By default, all projects are used. Note that the tags are ANDed together, eg. passing "foo" and "bar" will only find projects with both those tags. + plugins: + desc: Lists all plugins projects: desc: Lists all projects. build: