diff --git a/docs/commands.md b/docs/commands.md index 2077e1af..14fa758a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -444,6 +444,14 @@ cpflow run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db: cpflow setup-app -a $APP_NAME ``` +### `terraform generate` + +- Generates terraform configuration files based on `controlplane.yml` and `templates/` config + +```sh +cpflow terraform generate +``` + ### `version` - Displays the current version of the CLI diff --git a/lib/command/base.rb b/lib/command/base.rb index b0c645e3..38dedff1 100644 --- a/lib/command/base.rb +++ b/lib/command/base.rb @@ -12,6 +12,8 @@ class Base # rubocop:disable Metrics/ClassLength VALIDATIONS_WITH_ADDITIONAL_OPTIONS = %w[templates].freeze ALL_VALIDATIONS = VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS + VALIDATIONS_WITH_ADDITIONAL_OPTIONS + # Used to call the command (`cpflow SUBCOMMAND_NAME NAME`) + SUBCOMMAND_NAME = nil # Used to call the command (`cpflow NAME`) # NAME = "" # Displayed when running `cpflow help` or `cpflow help NAME` (defaults to `NAME`) @@ -43,11 +45,21 @@ def initialize(config) @config = config end - def self.all_commands - Dir["#{__dir__}/*.rb"].each_with_object({}) do |file, result| - filename = File.basename(file, ".rb") - classname = File.read(file).match(/^\s+class (\w+) < Base($| .*$)/)&.captures&.first - result[filename.to_sym] = Object.const_get("::Command::#{classname}") if classname + def self.all_commands # rubocop:disable Metrics/MethodLength + Dir["#{__dir__}/**/*.rb"].each_with_object({}) do |file, result| + content = File.read(file) + + classname = content.match(/^\s+class (\w+) < (?:.*Base)(?:$| .*$)/)&.captures&.first + next unless classname + + namespaces = content.scan(/^\s+module (\w+)/).flatten + full_classname = [*namespaces, classname].join("::").prepend("::") + + command_key = File.basename(file, ".rb") + prefix = namespaces[1..].map(&:downcase).join("_") + command_key.prepend(prefix.concat("_")) unless prefix.empty? + + result[command_key.to_sym] = Object.const_get(full_classname) end end diff --git a/lib/command/base_sub_command.rb b/lib/command/base_sub_command.rb new file mode 100644 index 00000000..02133eb3 --- /dev/null +++ b/lib/command/base_sub_command.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Inspired by https://github.com/rails/thor/wiki/Subcommands +class BaseSubCommand < Thor + def self.banner(command, _namespace = nil, _subcommand = false) # rubocop:disable Style/OptionalBooleanParameter + "#{basename} #{subcommand_prefix} #{command.usage}" + end + + def self.subcommand_prefix + name + .gsub(/.*::/, "") + .gsub(/^[A-Z]/) { |match| match[0].downcase } + .gsub(/[A-Z]/) { |match| "-#{match[0].downcase}" } + end +end diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb new file mode 100644 index 00000000..55141dd0 --- /dev/null +++ b/lib/command/terraform/generate.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Command + module Terraform + class Generate < Base + SUBCOMMAND_NAME = "terraform" + NAME = "generate" + DESCRIPTION = "Generates terraform configuration files" + LONG_DESCRIPTION = <<~DESC + - Generates terraform configuration files based on `controlplane.yml` and `templates/` config + DESC + WITH_INFO_HEADER = false + VALIDATIONS = [].freeze + + def call + # TODO: Implement + end + end + end +end diff --git a/lib/cpflow.rb b/lib/cpflow.rb index be0f94f4..e4123fb3 100644 --- a/lib/cpflow.rb +++ b/lib/cpflow.rb @@ -113,12 +113,21 @@ def self.check_cpflow_version # rubocop:disable Metrics/MethodLength def self.fix_help_option help_mappings = Thor::HELP_MAPPINGS + ["help"] matches = help_mappings & ARGV + + # Help option works correctly for subcommands + return if matches && subcommand? + matches.each do |match| ARGV.delete(match) ARGV.unshift(match) end end + def self.subcommand? + (subcommand_names & ARGV).any? + end + private_class_method :subcommand? + # Needed to silence deprecation warning def self.exit_on_failure? true @@ -149,6 +158,10 @@ def self.all_base_commands ::Command::Base.all_commands.merge(deprecated_commands) end + def self.subcommand_names + Dir["#{__dir__}/command/*"].filter_map { |name| File.basename(name) if File.directory?(name) } + end + def self.process_option_params(params) # Ensures that if no value is provided for a non-boolean option (e.g., `cpflow command --option`), # it defaults to an empty string instead of the option name (which is the default Thor behavior) @@ -157,6 +170,17 @@ def self.process_option_params(params) params end + def self.klass_for(subcommand_name) + klass_name = subcommand_name.to_s.split("-").map(&:capitalize).join + return Cpflow.const_get(klass_name) if Cpflow.const_defined?(klass_name) + + Cpflow.const_set(klass_name, Class.new(BaseSubCommand)).tap do |subcommand_klass| + desc(subcommand_name, "#{subcommand_name.capitalize} commands") + subcommand(subcommand_name, subcommand_klass) + end + end + private_class_method :klass_for + @commands_with_required_options = [] @commands_with_extra_options = [] @@ -169,6 +193,7 @@ def self.process_option_params(params) deprecated = deprecated_commands[command_key] name = command_class::NAME + subcommand_name = command_class::SUBCOMMAND_NAME name_for_method = deprecated ? command_key : name.tr("-", "_") usage = command_class::USAGE.empty? ? name : command_class::USAGE requires_args = command_class::REQUIRES_ARGS @@ -188,21 +213,25 @@ def self.process_option_params(params) # so we store it here to be able to use it raise_args_error = ->(*args) { handle_argument_error(commands[name_for_method], ArgumentError, *args) } - desc(usage, description, hide: hide) - long_desc(long_description) - - command_options.each do |option| - params = process_option_params(option[:params]) - method_option(option[:name], **params) - end - # We'll handle required options manually in `Config` required_options = command_options.select { |option| option[:params][:required] }.map { |option| option[:name] } @commands_with_required_options.push(name_for_method.to_sym) if required_options.any? @commands_with_extra_options.push(name_for_method.to_sym) if accepts_extra_options - define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength + klass = subcommand_name ? klass_for(subcommand_name) : self + + klass.class_eval do + desc(usage, description, hide: hide) + long_desc(long_description) + + command_options.each do |option| + params = process_option_params(option[:params]) + method_option(option[:name], **params) + end + end + + klass.define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength if deprecated normalized_old_name = ::Helpers.normalize_command_name(command_key) ::Shell.warn_deprecated("Command '#{normalized_old_name}' is deprecated, " \ diff --git a/script/update_command_docs b/script/update_command_docs index 74e7dea2..8ab78c34 100755 --- a/script/update_command_docs +++ b/script/update_command_docs @@ -12,12 +12,16 @@ commands.keys.sort.each do |command_key| next if command_class::HIDE name = command_class::NAME - usage = command_class::USAGE.empty? ? name : command_class::USAGE + subcommand_name = command_class::SUBCOMMAND_NAME + + full_command = [subcommand_name, name].compact.join(" ") + + usage = command_class::USAGE.empty? ? full_command : command_class::USAGE options = command_class::OPTIONS long_description = command_class::LONG_DESCRIPTION examples = command_class::EXAMPLES - command_str = "### `#{name}`\n\n" + command_str = "### `#{full_command}`\n\n" command_str += "#{long_description.strip}\n\n" if examples.empty? diff --git a/spec/cpflow_spec.rb b/spec/cpflow_spec.rb index 10031ff3..761117d5 100644 --- a/spec/cpflow_spec.rb +++ b/spec/cpflow_spec.rb @@ -19,4 +19,22 @@ expect(result[:stderr]).to include("No value provided for option --#{option[:name].to_s.tr('_', '-')}") end end + + it "handles subcommands correctly" do + result = run_cpflow_command("--help") + + expect(result[:status]).to eq(0) + + # Temporary solution, will be fixed with https://github.com/rails/thor/issues/742 + basename = Cpflow::Cli.send(:basename) + + Cpflow::Cli.subcommand_names.each do |subcommand| + expect(result[:stdout]).to include("#{basename} #{subcommand}") + + subcommand_result = run_cpflow_command(subcommand, "--help") + + expect(subcommand_result[:status]).to eq(0) + expect(subcommand_result[:stdout]).to include("#{basename} #{subcommand} help [COMMAND]") + end + end end