diff --git a/exe/rage b/exe/rage index b2a97c23..4f4c1ea7 100755 --- a/exe/rage +++ b/exe/rage @@ -1,4 +1,4 @@ #!/usr/bin/env ruby require_relative "../lib/rage/cli" -Rage::CLI.start +Rage::CLI::App.start diff --git a/lib/rage-rb.rb b/lib/rage-rb.rb index adc7af03..f31240e0 100644 --- a/lib/rage-rb.rb +++ b/lib/rage-rb.rb @@ -182,6 +182,9 @@ class << self alias_method :configuration, :config end + module CLI + end + module Router module Strategies end diff --git a/lib/rage/cli.rb b/lib/rage/cli.rb index 40b73ebe..3a8d85dc 100644 --- a/lib/rage/cli.rb +++ b/lib/rage/cli.rb @@ -4,65 +4,14 @@ require "rack" require "rage/version" +require "rage/cli/base" require "rage/cli/skills" +require "rage/cli/openapi" +require "rage/cli/code_generator" +require "rage/cli/new_app_generator" -module Rage - class CLICodeGenerator < Thor - include Thor::Actions - - def self.source_root - File.expand_path("templates", __dir__) - end - - desc "migration NAME", "Generate a new migration" - def migration(name = nil) - return help("migration") if name.nil? - - setup - Rake::Task["db:new_migration"].invoke(name) - end - - desc "model NAME", "Generate a new model" - def model(name = nil) - return help("model") if name.nil? - - setup - migration("create_#{name.pluralize}") - @model_name = name.classify - template("model-template/model.rb", "app/models/#{name.singularize.underscore}.rb") - end - - desc "controller NAME", "Generate a new controller" - def controller(name = nil) - return help("controller") if name.nil? - - setup - unless defined?(ActiveSupport::Inflector) - raise LoadError, <<~ERR - ActiveSupport::Inflector is required to run this command. Add the following line to your Gemfile: - gem "activesupport", require: "active_support/inflector" - ERR - end - - # remove trailing Controller if already present - normalized_name = name.sub(/_?controller$/i, "") - @controller_name = "#{normalized_name.camelize}Controller" - file_name = "#{normalized_name.underscore}_controller.rb" - - template("controller-template/controller.rb", "app/controllers/#{file_name}") - end - - private - - def setup - @setup ||= begin - require "rake" - load "Rakefile" - end - end - end - - class CLI < Thor +module Rage::CLI + class App < Base def self.exit_on_failure? true end @@ -74,7 +23,7 @@ def new(path = nil) return help("new") if options.help? || path.nil? require "rage/all" - CLINewAppGenerator.start([path, options[:database]]) + NewAppGenerator.start([path, options[:database]]) end desc "s", "Start the app server" @@ -232,11 +181,14 @@ def version end desc "skills", "Manage coding agent skills" - subcommand "skills", CLISkills + subcommand "skills", Skills + + desc "openapi", "OpenAPI validation tools" + subcommand "openapi", OpenAPI map "generate" => :g desc "g TYPE", "Generate new code" - subcommand "g", CLICodeGenerator + subcommand "g", CodeGenerator map "--tasks" => :tasks desc "--tasks", "See the list of available tasks" @@ -273,26 +225,6 @@ def respond_to_missing?(method_name, include_private = false) private - def environment - require File.expand_path("config/application.rb", Dir.pwd) - - if Rage.config.internal.rails_mode - require File.expand_path("config/environment.rb", Dir.pwd) - end - end - - def set_env(options) - if options[:environment] - ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = options[:environment] - elsif ENV["RAGE_ENV"] - ENV["RAILS_ENV"] = ENV["RAGE_ENV"] - elsif ENV["RAILS_ENV"] - ENV["RAGE_ENV"] = ENV["RAILS_ENV"] - else - ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = "development" - end - end - def linked_rake_tasks require "rake" Rake::TaskManager.record_task_metadata = true @@ -371,67 +303,4 @@ def print_event_subscribers_tree(event_class) end end end - - class CLINewAppGenerator < Thor::Group - include Thor::Actions - argument :path, type: :string - argument :database, type: :string, required: false - - def self.source_root - File.expand_path("templates", __dir__) - end - - def setup - @use_database = !database.nil? - end - - def create_directory - empty_directory(path) - end - - def copy_files - inject_templates - end - - def install_database - return unless @use_database - - @app_name = path.tr("-", "_").downcase - append_to_file "#{path}/Gemfile", <<~RUBY - - gem "#{get_db_gem_name}" - gem "activerecord" - gem "standalone_migrations", require: false - RUBY - - inject_templates("db-templates") - inject_templates("db-templates/#{database}") - end - - private - - def inject_templates(from = nil) - root = "#{self.class.source_root}/#{from}" - - Dir.glob("*", base: root).each do |template| - next if File.directory?("#{root}/#{template}") - - *template_path_parts, template_name = template.split("-") - template("#{root}/#{template}", [path, *template_path_parts, template_name].join("/")) - end - end - - def get_db_gem_name - case database - when "mysql" - "mysql2" - when "trilogy" - "trilogy" - when "postgresql" - "pg" - when "sqlite3" - "sqlite3" - end - end - end end diff --git a/lib/rage/cli/base.rb b/lib/rage/cli/base.rb new file mode 100644 index 00000000..cb08f2e3 --- /dev/null +++ b/lib/rage/cli/base.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Rage::CLI + class Base < Thor + private + + def environment + require File.expand_path("config/application.rb", Dir.pwd) + + if Rage.config.internal.rails_mode + require File.expand_path("config/environment.rb", Dir.pwd) + end + end + + def set_env(options) + if options[:environment] + ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = options[:environment] + elsif ENV["RAGE_ENV"] + ENV["RAILS_ENV"] = ENV["RAGE_ENV"] + elsif ENV["RAILS_ENV"] + ENV["RAGE_ENV"] = ENV["RAILS_ENV"] + else + ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = "development" + end + end + end +end diff --git a/lib/rage/cli/code_generator.rb b/lib/rage/cli/code_generator.rb new file mode 100644 index 00000000..c016eb9c --- /dev/null +++ b/lib/rage/cli/code_generator.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Rage::CLI + class CodeGenerator < Base + include Thor::Actions + + def self.source_root + File.expand_path("../templates", __dir__) + end + + desc "migration NAME", "Generate a new migration" + def migration(name = nil) + return help("migration") if name.nil? + + setup + Rake::Task["db:new_migration"].invoke(name) + end + + desc "model NAME", "Generate a new model" + def model(name = nil) + return help("model") if name.nil? + + setup + migration("create_#{name.pluralize}") + @model_name = name.classify + template("model-template/model.rb", "app/models/#{name.singularize.underscore}.rb") + end + + desc "controller NAME", "Generate a new controller" + def controller(name = nil) + return help("controller") if name.nil? + + setup + unless defined?(ActiveSupport::Inflector) + raise LoadError, <<~ERR + ActiveSupport::Inflector is required to run this command. Add the following line to your Gemfile: + gem "activesupport", require: "active_support/inflector" + ERR + end + + # remove trailing Controller if already present + normalized_name = name.sub(/_?controller$/i, "") + @controller_name = "#{normalized_name.camelize}Controller" + file_name = "#{normalized_name.underscore}_controller.rb" + + template("controller-template/controller.rb", "app/controllers/#{file_name}") + end + + private + + def setup + @setup ||= begin + require "rake" + load "Rakefile" + end + end + end +end diff --git a/lib/rage/cli/new_app_generator.rb b/lib/rage/cli/new_app_generator.rb new file mode 100644 index 00000000..0aa107f8 --- /dev/null +++ b/lib/rage/cli/new_app_generator.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Rage::CLI + class NewAppGenerator < Thor::Group + include Thor::Actions + argument :path, type: :string + argument :database, type: :string, required: false + + def self.source_root + File.expand_path("../templates", __dir__) + end + + def setup + @use_database = !database.nil? + end + + def create_directory + empty_directory(path) + end + + def copy_files + inject_templates + end + + def install_database + return unless @use_database + + @app_name = path.tr("-", "_").downcase + append_to_file "#{path}/Gemfile", <<~RUBY + + gem "#{get_db_gem_name}" + gem "activerecord" + gem "standalone_migrations", require: false + RUBY + + inject_templates("db-templates") + inject_templates("db-templates/#{database}") + end + + private + + def inject_templates(from = nil) + root = "#{self.class.source_root}/#{from}" + + Dir.glob("*", base: root).each do |template| + next if File.directory?("#{root}/#{template}") + + *template_path_parts, template_name = template.split("-") + template("#{root}/#{template}", [path, *template_path_parts, template_name].join("/")) + end + end + + def get_db_gem_name + case database + when "mysql" + "mysql2" + when "trilogy" + "trilogy" + when "postgresql" + "pg" + when "sqlite3" + "sqlite3" + end + end + end +end diff --git a/lib/rage/cli/openapi.rb b/lib/rage/cli/openapi.rb new file mode 100644 index 00000000..291fc522 --- /dev/null +++ b/lib/rage/cli/openapi.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Rage::CLI + class OpenAPI < Base + desc "validate", "Validate OpenAPI tags and fail if any warnings were produced" + def validate + environment + + abort "OpenAPI validation requires a booted application." unless Rage.config.internal.initialized? + + warnings = Rage::OpenAPI.__collect_warnings { Rage::OpenAPI.build } + + if warnings.any? + abort "#{set_color("𐄂", :red, :bold)} OpenAPI validation failed with #{warnings.size} warning(s)." + else + say "#{set_color("✓", :green, :bold)} OpenAPI validation passed without warnings." + end + end + end +end diff --git a/lib/rage/cli/skills.rb b/lib/rage/cli/skills.rb index 01c4d58a..e6cd4d56 100644 --- a/lib/rage/cli/skills.rb +++ b/lib/rage/cli/skills.rb @@ -1,291 +1,293 @@ # frozen_string_literal: true -class CLISkills < Thor - SKILLS_DIR = "rage-framework" - VERSION_FILE = ".version" - - desc "install", "Install skills for coding agents" - option :verbose, desc: "Debug output" - def install - installation_path = choose_installation_path - return unless installation_path - - skills_version = fetch_skills_version - say "Downloading skills..." - install_skills(installation_path, skills_version) - - say "#{set_color("✓", :green)} Installed Rage skills #{set_color(skills_version, :bold)} to #{set_color(installation_path, :cyan)}." - say "#{set_color("✓", :green)} Skills are now available to your coding agent." - rescue => e - say_error(e) - end - - desc "update", "Update installed skills" - option :verbose, desc: "Debug output" - option :json, type: :boolean, desc: "Output JSON for programmatic use" - def update - skills_destinations = Dir.glob(".*/skills/#{SKILLS_DIR}") - debug { "Existing skills installations found: #{skills_destinations}" } - - if skills_destinations.empty? - log "No existing installation found. Running fresh install...\n\n" - return install +module Rage::CLI + class Skills < Base + SKILLS_DIR = "rage-framework" + VERSION_FILE = ".version" + + desc "install", "Install skills for coding agents" + option :verbose, desc: "Debug output" + def install + installation_path = choose_installation_path + return unless installation_path + + skills_version = fetch_skills_version + say "Downloading skills..." + install_skills(installation_path, skills_version) + + say "#{set_color("✓", :green)} Installed Rage skills #{set_color(skills_version, :bold)} to #{set_color(installation_path, :cyan)}." + say "#{set_color("✓", :green)} Skills are now available to your coding agent." + rescue => e + say_error(e) end - skills_version = fetch_skills_version - updated_paths = [] + desc "update", "Update installed skills" + option :verbose, desc: "Debug output" + option :json, type: :boolean, desc: "Output JSON for programmatic use" + def update + skills_destinations = Dir.glob(".*/skills/#{SKILLS_DIR}") + debug { "Existing skills installations found: #{skills_destinations}" } - skills_destinations.each do |destination| - version_file = File.join(destination, VERSION_FILE) - current_version = File.exist?(version_file) ? File.read(version_file).strip : nil - - if current_version == skills_version - log "#{set_color(destination, :cyan)}: already up to date." - next + if skills_destinations.empty? + log "No existing installation found. Running fresh install...\n\n" + return install end - log "Updating #{set_color(destination, :cyan)}..." - install_skills(destination, skills_version) - updated_paths << destination - end + skills_version = fetch_skills_version + updated_paths = [] - if updated_paths.any? - log "#{set_color("✓", :green)} Updated #{updated_paths.size} installation#{"s" if updated_paths.size > 1} to #{set_color(skills_version, :bold)}." - end + skills_destinations.each do |destination| + version_file = File.join(destination, VERSION_FILE) + current_version = File.exist?(version_file) ? File.read(version_file).strip : nil - json_output( - status: updated_paths.any? ? "updated" : "up_to_date", - version: skills_version, - paths: skills_destinations - ) - rescue => e - if options[:json] - json_output(status: "error", message: e.message) - exit 1 - else - say_error(e) - end - end + if current_version == skills_version + log "#{set_color(destination, :cyan)}: already up to date." + next + end - no_commands do - def debug - puts("* #{yield}") if options[:verbose] - end + log "Updating #{set_color(destination, :cyan)}..." + install_skills(destination, skills_version) + updated_paths << destination + end + + if updated_paths.any? + log "#{set_color("✓", :green)} Updated #{updated_paths.size} installation#{"s" if updated_paths.size > 1} to #{set_color(skills_version, :bold)}." + end - def log(message) + json_output( + status: updated_paths.any? ? "updated" : "up_to_date", + version: skills_version, + paths: skills_destinations + ) + rescue => e if options[:json] - warn(message) + json_output(status: "error", message: e.message) + exit 1 else - say(message) + say_error(e) end end - def json_output(data) - return unless options[:json] - require "json" - puts JSON.generate(data) - end - - def say_error(error) - say "#{set_color("𐄂 Error:", :red, :bold)} #{error.message}" - debug { "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}" } - end + no_commands do + def debug + puts("* #{yield}") if options[:verbose] + end - def choose_installation_path - agent_options = [ - ["1", "Claude Code", ".claude/skills"], - ["2", "GitHub Copilot", ".github/skills"], - ["3", "Cursor", ".cursor/skills"], - ["4", "Amp/Codex", ".agents/skills"], - ["5", "Antigravity", ".agent/skills"], - ["6", "Gemini CLI", ".gemini/skills"], - ["7", "Windsurf", ".windsurf/skills"], - ["8", "OpenCode", ".opencode/skills"], - ["9", "Other", ".claude/skills"] - ] - - display_options = ([["Option", "Coding Agent", "Installation Path"]] + agent_options).map.with_index do |(option, agent, path), index| - if index == 0 - [set_color(option, :bold), set_color(agent, :bold), set_color(path, :bold)] + def log(message) + if options[:json] + warn(message) else - [set_color(option, :bold), agent, set_color(path, :cyan)] + say(message) end end - print_ansi_table(display_options) - agent_choice = ask(set_color("Select your coding agent (1-#{agent_options[-1][0]}):", :bold), default: "1") + def json_output(data) + return unless options[:json] + require "json" + puts JSON.generate(data) + end - installation_path = ".claude/skills" + def say_error(error) + say "#{set_color("𐄂 Error:", :red, :bold)} #{error.message}" + debug { "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}" } + end - agent_options.each do |option, agent, path| - if agent_choice == option || agent.downcase.include?(agent_choice.downcase) - installation_path = path - break + def choose_installation_path + agent_options = [ + ["1", "Claude Code", ".claude/skills"], + ["2", "GitHub Copilot", ".github/skills"], + ["3", "Cursor", ".cursor/skills"], + ["4", "Amp/Codex", ".agents/skills"], + ["5", "Antigravity", ".agent/skills"], + ["6", "Gemini CLI", ".gemini/skills"], + ["7", "Windsurf", ".windsurf/skills"], + ["8", "OpenCode", ".opencode/skills"], + ["9", "Other", ".claude/skills"] + ] + + display_options = ([["Option", "Coding Agent", "Installation Path"]] + agent_options).map.with_index do |(option, agent, path), index| + if index == 0 + [set_color(option, :bold), set_color(agent, :bold), set_color(path, :bold)] + else + [set_color(option, :bold), agent, set_color(path, :cyan)] + end end - end - say "\n#{set_color("Source:", :bold)} #{set_color("https://github.com/rage-rb/skills", :cyan)}" - say "#{set_color("Destination:", :bold)} #{set_color(installation_path, :cyan)}" + print_ansi_table(display_options) + agent_choice = ask(set_color("Select your coding agent (1-#{agent_options[-1][0]}):", :bold), default: "1") - answer = ask(set_color("? ", :green, :bold) + set_color("Proceed with installation? [Y/n]", :bold), default: "y") - unless answer.downcase.start_with?("y") - say "Installation cancelled." - return nil - end + installation_path = ".claude/skills" - File.join(installation_path, SKILLS_DIR) - end + agent_options.each do |option, agent, path| + if agent_choice == option || agent.downcase.include?(agent_choice.downcase) + installation_path = path + break + end + end - def fetch_skills_version - require "json" + say "\n#{set_color("Source:", :bold)} #{set_color("https://github.com/rage-rb/skills", :cyan)}" + say "#{set_color("Destination:", :bold)} #{set_color(installation_path, :cyan)}" - manifest = begin - JSON.parse(fetch("https://rage-rb.github.io/skills/manifest.json")) - rescue => e - debug { "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" } - raise "Could not download skills manifest. Please check your network connection." + answer = ask(set_color("? ", :green, :bold) + set_color("Proceed with installation? [Y/n]", :bold), default: "y") + unless answer.downcase.start_with?("y") + say "Installation cancelled." + return nil + end + + File.join(installation_path, SKILLS_DIR) end - major, minor, _ = Rage::VERSION.split(".").map(&:to_i) + def fetch_skills_version + require "json" - debug { "Rage::VERSION: #{Rage::VERSION}; Manifest: #{manifest["versions"]}" } + manifest = begin + JSON.parse(fetch("https://rage-rb.github.io/skills/manifest.json")) + rescue => e + debug { "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" } + raise "Could not download skills manifest. Please check your network connection." + end - # Find the closest matching version: same major, highest minor <= current - matched_major, matched_minor = manifest["versions"]. - keys. - map { |v| v.split(".").map(&:to_i) }. - select { |_major, _| _major == major }. - select { |_, _minor| _minor <= minor }. - max_by { |_, _minor| _minor } + major, minor, _ = Rage::VERSION.split(".").map(&:to_i) - if matched_major && matched_minor - manifest["versions"]["#{matched_major}.#{matched_minor}"] - else - raise "No skills available for Rage #{major}.x." - end - end + debug { "Rage::VERSION: #{Rage::VERSION}; Manifest: #{manifest["versions"]}" } - def install_skills(installation_path, version) - require "zlib" - require "rubygems/package" - require "fileutils" - require "stringio" - require "digest" + # Find the closest matching version: same major, highest minor <= current + matched_major, matched_minor = manifest["versions"]. + keys. + map { |v| v.split(".").map(&:to_i) }. + select { |_major, _| _major == major }. + select { |_, _minor| _minor <= minor }. + max_by { |_, _minor| _minor } - Thread.report_on_exception = false + if matched_major && matched_minor + manifest["versions"]["#{matched_major}.#{matched_minor}"] + else + raise "No skills available for Rage #{major}.x." + end + end - artifact_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/skills.tar.gz") } - checksum_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/checksums.txt") } + def install_skills(installation_path, version) + require "zlib" + require "rubygems/package" + require "fileutils" + require "stringio" + require "digest" - artifact, checksum = artifact_request.value, checksum_request.value + Thread.report_on_exception = false - if artifact.nil? || checksum.nil? - raise "Could not download the skills package. Please check your network connection and try again." - end + artifact_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/skills.tar.gz") } + checksum_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/checksums.txt") } - sha, _ = checksum.split - if Digest::SHA256.hexdigest(artifact) != sha - raise "Download verification failed. Please try again." - end + artifact, checksum = artifact_request.value, checksum_request.value - destination = File.expand_path(installation_path) - FileUtils.mkdir_p(destination) + if artifact.nil? || checksum.nil? + raise "Could not download the skills package. Please check your network connection and try again." + end - # Clear existing contents but keep the directory intact - Dir.children(destination).each do |child| - debug { "Removing #{child}" } - FileUtils.rm_rf(File.join(destination, child)) - end + sha, _ = checksum.split + if Digest::SHA256.hexdigest(artifact) != sha + raise "Download verification failed. Please try again." + end - Zlib::GzipReader.wrap(StringIO.new(artifact)) do |gz| - Gem::Package::TarReader.new(gz) do |tar| - tar.each do |entry| - path = File.join(destination, entry.full_name) + destination = File.expand_path(installation_path) + FileUtils.mkdir_p(destination) - unless File.expand_path(path).start_with?("#{destination}/") - raise "Invalid archive: contains files outside the destination directory." - end + # Clear existing contents but keep the directory intact + Dir.children(destination).each do |child| + debug { "Removing #{child}" } + FileUtils.rm_rf(File.join(destination, child)) + end - if entry.directory? - debug { "Created directory #{entry.full_name}" } - FileUtils.mkdir_p(path) - elsif entry.file? - debug { "Written file #{entry.full_name}" } - FileUtils.mkdir_p(File.dirname(path)) - File.binwrite(path, entry.read) + Zlib::GzipReader.wrap(StringIO.new(artifact)) do |gz| + Gem::Package::TarReader.new(gz) do |tar| + tar.each do |entry| + path = File.join(destination, entry.full_name) + + unless File.expand_path(path).start_with?("#{destination}/") + raise "Invalid archive: contains files outside the destination directory." + end + + if entry.directory? + debug { "Created directory #{entry.full_name}" } + FileUtils.mkdir_p(path) + elsif entry.file? + debug { "Written file #{entry.full_name}" } + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, entry.read) + end end end end - end - File.write(File.join(destination, VERSION_FILE), version) - end + File.write(File.join(destination, VERSION_FILE), version) + end - def fetch(uri, retries = 2) - response = request(uri) - return response if response + def fetch(uri, retries = 2) + response = request(uri) + return response if response - retries > 0 ? fetch(uri, retries - 1) : nil - end + retries > 0 ? fetch(uri, retries - 1) : nil + end - def request(uri, limit = 3) - require "net/http" + def request(uri, limit = 3) + require "net/http" - raise "Too many HTTP redirects" if limit == 0 + raise "Too many HTTP redirects" if limit == 0 - debug { "Fetching #{uri[0..100]}" } + debug { "Fetching #{uri[0..100]}" } - parsed_uri = URI(uri) - http = Net::HTTP.new(parsed_uri.host, parsed_uri.port) - http.open_timeout = 5 - http.read_timeout = 5 + parsed_uri = URI(uri) + http = Net::HTTP.new(parsed_uri.host, parsed_uri.port) + http.open_timeout = 5 + http.read_timeout = 5 - if parsed_uri.scheme == "https" - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_PEER - http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths) - end + if parsed_uri.scheme == "https" + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths) + end - response = http.request(Net::HTTP::Get.new(parsed_uri)) + response = http.request(Net::HTTP::Get.new(parsed_uri)) - case response - when Net::HTTPSuccess - response.body.force_encoding("ASCII-8BIT") - when Net::HTTPRedirection - debug { "Redirected to #{response["Location"][0..100]}" } - request(response["Location"], limit - 1) + case response + when Net::HTTPSuccess + response.body.force_encoding("ASCII-8BIT") + when Net::HTTPRedirection + debug { "Redirected to #{response["Location"][0..100]}" } + request(response["Location"], limit - 1) + end end - end - # print a table stripping ANSI codes to ensure paddings are based on visible length - def print_ansi_table(rows) - return if rows.empty? + # print a table stripping ANSI codes to ensure paddings are based on visible length + def print_ansi_table(rows) + return if rows.empty? - widths = rows.each_with_object([]) do |row, maxima| - row.each_with_index do |column, index| - visible_width = strip_ansi(column.to_s).size - maxima[index] = [maxima[index] || 0, visible_width].max + widths = rows.each_with_object([]) do |row, maxima| + row.each_with_index do |column, index| + visible_width = strip_ansi(column.to_s).size + maxima[index] = [maxima[index] || 0, visible_width].max + end end - end - border = "+" + widths.map { |width| "-" * (width + 2) }.join("+") + "+" - say(border) + border = "+" + widths.map { |width| "-" * (width + 2) }.join("+") + "+" + say(border) + + rows.each do |row| + formatted_cells = widths.each_index.map do |index| + cell = row[index].to_s + cell_padding = widths[index] - strip_ansi(cell).size + " #{cell}#{" " * cell_padding} " + end - rows.each do |row| - formatted_cells = widths.each_index.map do |index| - cell = row[index].to_s - cell_padding = widths[index] - strip_ansi(cell).size - " #{cell}#{" " * cell_padding} " + say("|#{formatted_cells.join("|")}|") end - say("|#{formatted_cells.join("|")}|") + say(border) end - say(border) - end - - def strip_ansi(text) - text.gsub(/\e\[[0-9;]*m/, "") + def strip_ansi(text) + text.gsub(/\e\[[0-9;]*m/, "") + end end end end diff --git a/lib/rage/tasks/openapi.rake b/lib/rage/tasks/openapi.rake deleted file mode 100644 index f46c7915..00000000 --- a/lib/rage/tasks/openapi.rake +++ /dev/null @@ -1,14 +0,0 @@ -namespace :openapi do - desc "Validate OpenAPI tags and fail if any warnings were produced" - task :validate do - abort "OpenAPI validation requires a booted application." unless Rage.config.internal.initialized? - - warnings = Rage::OpenAPI.__collect_warnings { Rage::OpenAPI.build } - - if warnings.any? - abort "OpenAPI validation failed with #{warnings.size} warning(s)." - else - puts "OpenAPI validation passed without warnings." - end - end -end diff --git a/spec/rage/cli/openapi_spec.rb b/spec/rage/cli/openapi_spec.rb new file mode 100644 index 00000000..f7cb49f0 --- /dev/null +++ b/spec/rage/cli/openapi_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rage/cli" + +RSpec.describe Rage::CLI::OpenAPI do + include_context "mocked_classes" + include_context "mocked_rage_routes" + + subject(:openapi_cli) { described_class.new } + + before do + allow(openapi_cli).to receive(:environment) + allow(openapi_cli).to receive(:set_color) { |text, *| text } + end + + let(:routes) do + { "GET /users" => "UsersController#index" } + end + + describe "#validate" do + subject(:invoke_validate) do + openapi_cli.validate + nil + rescue SystemExit => e + e + end + + context "when the application is not booted" do + before do + allow(Rage.config.internal).to receive(:initialized?).and_return(false) + end + + it "doesn't build the spec" do + expect(Rage::OpenAPI).not_to receive(:build) + + expect { invoke_validate }.to output(/OpenAPI validation requires a booted application\./).to_stderr + end + + it "exits with status 1" do + exit_error = nil + + expect { exit_error = invoke_validate }.to output.to_stderr + + expect(exit_error.status).to eq(1) + end + end + + context "when the application is booted" do + before do + allow(Rage.config.internal).to receive(:initialized?).and_return(true) + end + + context "when the spec builds without warnings" do + let_class("UsersController", parent: RageController::API) do + <<~'RUBY' + # @response { id: Integer, full_name: String } + def index + end + RUBY + end + + it "prints a success message" do + expect(openapi_cli).to receive(:say).with(/OpenAPI validation passed without warnings\./) + + invoke_validate + end + + it "doesn't exit with an error" do + allow(openapi_cli).to receive(:say) + + expect(invoke_validate).to be_nil + end + end + + context "when the build produces warnings" do + let_class("UsersController", parent: RageController::API) do + <<~'RUBY' + # @response UnknownResource + def index + end + RUBY + end + + it "prints the warnings" do + expect { invoke_validate }.to output(/unrecognized `@response` tag detected/).to_stdout + end + + it "exits with status 1 and prints the failure count" do + exit_error = nil + + expect { exit_error = invoke_validate }. + to output.to_stdout. + and output(/OpenAPI validation failed with 1 warning\(s\)\./).to_stderr + + expect(exit_error.status).to eq(1) + end + end + end + end +end diff --git a/spec/rage/cli/skills_spec.rb b/spec/rage/cli/skills_spec.rb index 503ed520..3a19a3fb 100644 --- a/spec/rage/cli/skills_spec.rb +++ b/spec/rage/cli/skills_spec.rb @@ -3,7 +3,7 @@ require "rage/cli" require "tmpdir" -RSpec.describe CLISkills do +RSpec.describe Rage::CLI::Skills do subject(:skills_cli) { described_class.new } let(:manifest) do diff --git a/spec/rage/cli_spec.rb b/spec/rage/cli_spec.rb index d877811b..6d222cc2 100644 --- a/spec/rage/cli_spec.rb +++ b/spec/rage/cli_spec.rb @@ -3,7 +3,7 @@ require "rake" require "active_support/inflector" -RSpec.describe Rage::CLICodeGenerator do +RSpec.describe Rage::CLI::CodeGenerator do subject(:rage_cli_code_generator) { described_class.new } around(:example, :with_temp_directory) do |example| @@ -191,7 +191,7 @@ end end -RSpec.describe Rage::CLI do +RSpec.describe Rage::CLI::App do subject(:rage_cli) { described_class.new } describe "#middleware" do diff --git a/spec/rage/tasks_spec.rb b/spec/rage/tasks_spec.rb deleted file mode 100644 index f05a58f2..00000000 --- a/spec/rage/tasks_spec.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -require "rake" -require "rage/tasks" - -RSpec.describe Rage::Tasks do - # `Rake.application` is a process-wide singleton owning the task registry; a fresh one per example - # keeps the registry empty and tasks re-invokable, while restoring it avoids leaking into other specs. - around do |example| - original_application = Rake.application - Rake.application = Rake::Application.new - example.run - ensure - Rake.application = original_application - end - - describe ".load_rage_tasks" do - it "loads the openapi:validate task" do - expect(Rake::Task.task_defined?("openapi:validate")).to eq(false) - - described_class.send(:load_rage_tasks) - - expect(Rake::Task.task_defined?("openapi:validate")).to eq(true) - end - end -end - -RSpec.describe "openapi:validate" do - include_context "mocked_classes" - include_context "mocked_rage_routes" - - around do |example| - original_application = Rake.application - Rake.application = Rake::Application.new - Rage::Tasks.send(:load_rage_tasks) - example.run - ensure - Rake.application = original_application - end - - before do - allow(Rage.config.internal).to receive(:initialized?).and_return(true) - end - - let(:routes) do - { "GET /users" => "UsersController#index" } - end - - # returns the `SystemExit` error the task exited with, or `nil` if it didn't exit - subject(:invoke_task) do - Rake::Task["openapi:validate"].invoke - nil - rescue SystemExit => e - e - end - - context "when the application is not booted" do - before do - allow(Rage.config.internal).to receive(:initialized?).and_return(false) - end - - it "doesn't build the spec" do - expect(Rage::OpenAPI).not_to receive(:build) - - expect { invoke_task }. - to output(/OpenAPI validation requires a booted application\./).to_stderr - end - - it "exits with status 1" do - exit_error = nil - - expect { exit_error = invoke_task }.to output.to_stderr - - expect(exit_error.status).to eq(1) - end - end - - context "when the spec builds without warnings" do - let_class("UsersController", parent: RageController::API) do - <<~'RUBY' - # @response { id: Integer, full_name: String } - def index - end - RUBY - end - - it "prints a success message" do - expect { invoke_task }.to output(/OpenAPI validation passed without warnings\./).to_stdout - end - - it "doesn't exit with an error" do - allow($stdout).to receive(:puts) - - expect(invoke_task).to be_nil - end - end - - context "when the build produces warnings" do - let_class("UsersController", parent: RageController::API) do - <<~'RUBY' - # @response UnknownResource - def index - end - RUBY - end - - it "prints the warnings and the number of failures" do - expect { invoke_task }. - to output(/unrecognized `@response` tag detected/).to_stdout. - and output(/OpenAPI validation failed with 1 warning\(s\)\./).to_stderr - end - - it "exits with status 1" do - allow($stdout).to receive(:puts) - - exit_error = nil - - expect { exit_error = invoke_task }.to output.to_stderr - - expect(exit_error.status).to eq(1) - end - end -end