From 4d7583db56b3e4c735bc25c0c4329253a6f94656 Mon Sep 17 00:00:00 2001 From: Eoin Kelly Date: Sat, 23 Nov 2024 18:56:12 +1300 Subject: [PATCH 1/2] Add scripts to help with project maintenance --- bin/build | 47 +++++++++++++++++++++ bin/compare | 61 +++++++++++++++++++++++++++ bin/create_prs | 20 +++++++++ bin/lib/builder.rb | 100 +++++++++++++++++++++++++++++++++++++++++++++ bin/lib/config.rb | 96 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100755 bin/build create mode 100755 bin/compare create mode 100755 bin/create_prs create mode 100644 bin/lib/builder.rb create mode 100644 bin/lib/config.rb diff --git a/bin/build b/bin/build new file mode 100755 index 00000000..b4a91bb4 --- /dev/null +++ b/bin/build @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +require "fileutils" +require "yaml" +require_relative "./lib/config" +require_relative "./lib/builder" + +# Usage: +# +# $ ./build "all_variants" | "vanilla" | "default" | +# +# where is the name of a file in the `ci/configs` directory +# (minus the .yml extension) +# +# Examples: +# +# $ ./build all_variants # build every known variant +# $ ./build vanilla # build a vanilla Rails app (which doesn't use our template) +# $ ./build default # build using the default config +# $ ./build basic # build using the basic CI config +# $ ./build react # build using the React CI config + +class Main + class << self + def main + configs = Config.resolve_to_configs(ARGV.first) + + puts configs_summary(configs) + + configs.each do |config| + Builder.new(config:).build + end + end + + private + + def configs_summary(configs) + <<~EO_HEAD + Building apps for #{configs.length} configs: + - #{configs.map(&:name).join("\n - ")} + + EO_HEAD + end + end +end + +Main.main diff --git a/bin/compare b/bin/compare new file mode 100755 index 00000000..5233400c --- /dev/null +++ b/bin/compare @@ -0,0 +1,61 @@ +#!/usr/bin/env ruby + +require "fileutils" +require "pry" + +PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, ".")) +BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") +COMPARISONS_REPO_ROOT_PATH = File.join(PROJECT_ROOT_PATH, "tmp/rails-template-variants-comparison") +ALL_BUILDS = Dir.children(BUILD_PATH) + +VANILLA_BUILD_NAME = ALL_BUILDS.find { |build_name| build_name.start_with?("vanilla_") } +VANILLA_BUILD_PATH = File.join(BUILD_PATH, VANILLA_BUILD_NAME) +COMPARISON_BUILDS = ALL_BUILDS - [VANILLA_BUILD_NAME] + +def copy_build(build_name:, branch_name:, build_path:) + system("git checkout -b #{branch_name}") + + # the `.` at the end of the cp command is what copies the contents of the dir + # including "hidden" `.` files (the same trick works with shell `cp` command) + # FileUtils.cp_r("#{build_path}/.", COMPARISONS_REPO_ROOT_PATH) + system("rsync -av --exclude='.git' --exclude='node_modules' --exclude='tmp' #{build_path}/ #{COMPARISONS_REPO_ROOT_PATH}/") + + system("git add .") + system("git commit -n -m 'Add #{build_name}'") +end + +def main + FileUtils.rm_rf(COMPARISONS_REPO_ROOT_PATH) + FileUtils.mkdir_p(COMPARISONS_REPO_ROOT_PATH) + + Dir.chdir(COMPARISONS_REPO_ROOT_PATH) do + system("git init") + system("git remote add origin git@github.com:ackama/rails-template-variants-comparison.git") + + copy_build( + build_name: VANILLA_BUILD_NAME, + branch_name: "main", + build_path: VANILLA_BUILD_PATH + ) + + COMPARISON_BUILDS.each do |build_name| + build_path = File.join(BUILD_PATH, build_name) + keepers = [".git", "."] + deletable_files = Dir.glob("{*,.*}").reject { |f| keepers.include?(f) } + + # clean out all the files from the previous build (except .git) + FileUtils.rm_rf(deletable_files) + + copy_build( + build_name:, + branch_name: build_name, + build_path: + ) + + # go back to main before we start the next build + system("git checkout main") + end + end +end + +main diff --git a/bin/create_prs b/bin/create_prs new file mode 100755 index 00000000..7042f31f --- /dev/null +++ b/bin/create_prs @@ -0,0 +1,20 @@ +#!/usr/bin/env ruby + +PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, ".")) +BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") +COMPARISONS_REPO_ROOT_PATH = File.join(PROJECT_ROOT_PATH, "tmp/rails-template-variants-comparison") +ALL_BUILDS = Dir.children(BUILD_PATH) +VANILLA_BUILD_NAME = ALL_BUILDS.find { |build_name| build_name.start_with?("vanilla_") } +COMPARISON_BUILDS = ALL_BUILDS - [VANILLA_BUILD_NAME] + +def main + Dir.chdir(COMPARISONS_REPO_ROOT_PATH) do + COMPARISON_BUILDS.each do |build_name| + puts build_name + # add --dry-run to test + system("gh pr create --title 'Compare #{build_name} against vanilla Rails' --body 'Compare #{build_name} against vanilla Rails' --base main --head #{build_name}") + end + end +end + +main diff --git a/bin/lib/builder.rb b/bin/lib/builder.rb new file mode 100644 index 00000000..6f6bc44d --- /dev/null +++ b/bin/lib/builder.rb @@ -0,0 +1,100 @@ +## +# Invokes the template as a user would, not as CI does via the build-and-test script. +# Automatically removes old DB before generating the app if required +# +class Builder + def initialize(config:) + @config = config + end + + def build + verify_rails_cmd_is_expected_version! + + Dir.chdir(@config.build_path) do + delete_any_previous_build + drop_dbs_from_previous_build + + # TODO: how to handle errors? raise? or log and continue? or rename the app dir with a .failed suffix? + # TODO: how to handle output? redirect to a file? or sep files for stdout and stderr? + + if @config.vanilla? + system(build_vanilla_cmd_env, build_vanilla_cmd) + else + system(build_cmd_env, build_cmd) + end + end + end + + private + + def base_cmd_parts + [ + "rails new #{@config.app_name}", + "-d postgresql", + "--skip-javascript", + "--skip-kamal", + "--skip-solid" + ] + end + + def verify_rails_cmd_is_expected_version! + actual_rails_version = `rails -v`.strip.sub("Rails ", "") + + return if actual_rails_version.start_with?(@config.target_rails_major_minor) + + raise "Expected Rails version #{@config.target_rails_major_minor}, but got #{actual_rails_version}" + end + + def delete_any_previous_build + FileUtils.rm_rf(@config.app_path) + end + + def build_cmd + cmd_parts = base_cmd_parts.append("-m #{@config.template_path}") + + puts <<~EO_INFO + Building command:: #{cmd_parts.inspect} + EO_INFO + + cmd_parts.join(" ") + end + + def build_cmd_env + cmd_env = { + "TARGET_VERSIONS_PATH" => @config.target_versions_path, + "CONFIG_PATH" => @config.config_path + } + + puts <<~EO_INFO + Building ENV: #{cmd_env.inspect} + EO_INFO + + cmd_env + end + + def build_vanilla_cmd_env + {} + end + + def build_vanilla_cmd + puts <<~EO_INFO + Building command: #{base_cmd_parts.inspect} + EO_INFO + + base_cmd_parts.join(" ") + end + + def print_separator + puts "" + puts "*" * 80 + puts "" + end + + def drop_dbs_from_previous_build + print_separator + system "psql -c 'DROP DATABASE IF EXISTS #{@config.app_name}_test;'" + system "psql -c 'DROP DATABASE IF EXISTS #{@config.app_name}_development;'" + system "psql -c 'DROP DATABASE IF EXISTS #{@config.app_name}_test;'" + print_separator + end +end diff --git a/bin/lib/config.rb b/bin/lib/config.rb new file mode 100644 index 00000000..0a12731f --- /dev/null +++ b/bin/lib/config.rb @@ -0,0 +1,96 @@ +class Config + PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, "../..")) + CI_CONFIGS_PATH = File.join(PROJECT_ROOT_PATH, "ci/configs") + BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") + TEMPLATE_PATH = File.join(PROJECT_ROOT_PATH, "template.rb") + TARGET_VERSIONS_PATH = File.join(PROJECT_ROOT_PATH, "target_versions.yml") + DEFAULT_CONFIG_NAME = "readme_example".freeze + VANILLA_CONFIG_NAME = "vanilla".freeze + APP_NAME_SUFFIX = "template_app".freeze + CI_CONFIGS = Dir.children(CI_CONFIGS_PATH).map { |f| f.sub(".yml", "") } + AVAILABLE_CONFIG_NAMES = [ + VANILLA_CONFIG_NAME, + DEFAULT_CONFIG_NAME, + *CI_CONFIGS + ].freeze + TARGET_RAILS_MAJOR_MINOR = YAML.safe_load_file(TARGET_VERSIONS_PATH).fetch("target_rails_major_minor") + + def self.all_configs + AVAILABLE_CONFIG_NAMES.map { |name| new(name:) } + end + + ## + # Resolves the CLI parameter to a list of Config instances + # + def self.resolve_to_configs(cli_param) + return all_configs if cli_param == "all_variants" + return [new(name: cli_param)] if AVAILABLE_CONFIG_NAMES.include?(cli_param) + + [new(name: DEFAULT_CONFIG_NAME)] + end + + attr_reader :name, :app_name, :config_path + + def initialize(name:) + @name = name + @app_name = case name + when VANILLA_CONFIG_NAME + "#{name}_#{target_rails_major}_#{APP_NAME_SUFFIX}" + else + "#{name}_#{APP_NAME_SUFFIX}" + end + + @config_path = case name + when DEFAULT_CONFIG_NAME + File.join(PROJECT_ROOT_PATH, "ackama_rails_template.config.yml") + when VANILLA_CONFIG_NAME + nil + else + File.join(CI_CONFIGS_PATH, "#{name}.yml") + end + end + + def vanilla? + name == VANILLA_CONFIG_NAME + end + + def app_path + File.join(BUILD_PATH, app_name) + end + + def target_versions_path + TARGET_VERSIONS_PATH + end + + def build_path + BUILD_PATH + end + + def template_path + return nil if vanilla? + + TEMPLATE_PATH + end + + def inspect + <<~INSPECT + + INSPECT + end + + def target_rails_major_minor + TARGET_RAILS_MAJOR_MINOR + end + + def target_rails_major + TARGET_RAILS_MAJOR_MINOR.split(".").first + end +end From 6a21a191399f4b87b06b194fe2cc174a11e208c8 Mon Sep 17 00:00:00 2001 From: Eoin Kelly Date: Sun, 24 Nov 2024 10:37:25 +1300 Subject: [PATCH 2/2] Refactor and tidy up --- bin/build | 19 ++++-- bin/compare | 61 ------------------ bin/create_comarison_repo_prs | 20 ++++++ bin/create_comparison_repo | 87 ++++++++++++++++++++++++++ bin/create_prs | 20 ------ bin/lib/{config.rb => build_config.rb} | 2 +- bin/lib/builder.rb | 46 +++++++------- bin/lib/comparison_config.rb | 30 +++++++++ bin/lib/terminal.rb | 12 ++++ 9 files changed, 189 insertions(+), 108 deletions(-) delete mode 100755 bin/compare create mode 100755 bin/create_comarison_repo_prs create mode 100755 bin/create_comparison_repo delete mode 100755 bin/create_prs rename bin/lib/{config.rb => build_config.rb} (99%) create mode 100644 bin/lib/comparison_config.rb create mode 100644 bin/lib/terminal.rb diff --git a/bin/build b/bin/build index b4a91bb4..d8d4ad95 100755 --- a/bin/build +++ b/bin/build @@ -2,8 +2,9 @@ require "fileutils" require "yaml" -require_relative "./lib/config" -require_relative "./lib/builder" +require_relative "lib/build_config" +require_relative "lib/builder" +require_relative "lib/terminal" # Usage: # @@ -19,13 +20,23 @@ require_relative "./lib/builder" # $ ./build default # build using the default config # $ ./build basic # build using the basic CI config # $ ./build react # build using the React CI config +# +# You can capture the output to a file using the `tee` command: +# +# $ ./build all_variants | tee build.log +# +# If you want to capture the output to a file and keep the color output, you can +# use the `unbuffer` command: +# +# $ brew install expect # or your linux distro equivalent +# $ unbuffer ./build all_variants | tee build.log class Main class << self def main - configs = Config.resolve_to_configs(ARGV.first) + configs = BuildConfig.resolve_to_configs(ARGV.first) - puts configs_summary(configs) + Terminal.puts_header(configs_summary(configs)) configs.each do |config| Builder.new(config:).build diff --git a/bin/compare b/bin/compare deleted file mode 100755 index 5233400c..00000000 --- a/bin/compare +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env ruby - -require "fileutils" -require "pry" - -PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, ".")) -BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") -COMPARISONS_REPO_ROOT_PATH = File.join(PROJECT_ROOT_PATH, "tmp/rails-template-variants-comparison") -ALL_BUILDS = Dir.children(BUILD_PATH) - -VANILLA_BUILD_NAME = ALL_BUILDS.find { |build_name| build_name.start_with?("vanilla_") } -VANILLA_BUILD_PATH = File.join(BUILD_PATH, VANILLA_BUILD_NAME) -COMPARISON_BUILDS = ALL_BUILDS - [VANILLA_BUILD_NAME] - -def copy_build(build_name:, branch_name:, build_path:) - system("git checkout -b #{branch_name}") - - # the `.` at the end of the cp command is what copies the contents of the dir - # including "hidden" `.` files (the same trick works with shell `cp` command) - # FileUtils.cp_r("#{build_path}/.", COMPARISONS_REPO_ROOT_PATH) - system("rsync -av --exclude='.git' --exclude='node_modules' --exclude='tmp' #{build_path}/ #{COMPARISONS_REPO_ROOT_PATH}/") - - system("git add .") - system("git commit -n -m 'Add #{build_name}'") -end - -def main - FileUtils.rm_rf(COMPARISONS_REPO_ROOT_PATH) - FileUtils.mkdir_p(COMPARISONS_REPO_ROOT_PATH) - - Dir.chdir(COMPARISONS_REPO_ROOT_PATH) do - system("git init") - system("git remote add origin git@github.com:ackama/rails-template-variants-comparison.git") - - copy_build( - build_name: VANILLA_BUILD_NAME, - branch_name: "main", - build_path: VANILLA_BUILD_PATH - ) - - COMPARISON_BUILDS.each do |build_name| - build_path = File.join(BUILD_PATH, build_name) - keepers = [".git", "."] - deletable_files = Dir.glob("{*,.*}").reject { |f| keepers.include?(f) } - - # clean out all the files from the previous build (except .git) - FileUtils.rm_rf(deletable_files) - - copy_build( - build_name:, - branch_name: build_name, - build_path: - ) - - # go back to main before we start the next build - system("git checkout main") - end - end -end - -main diff --git a/bin/create_comarison_repo_prs b/bin/create_comarison_repo_prs new file mode 100755 index 00000000..871e6da8 --- /dev/null +++ b/bin/create_comarison_repo_prs @@ -0,0 +1,20 @@ +#!/usr/bin/env ruby + +require_relative "lib/comparison_config" +require_relative "lib/terminal" + +class Main + class << self + def main(dry_run:) + Dir.chdir(ComparisonConfig.comparison_repo_root_path) do + ComparisonConfig.comparison_builds.each do |build_name| + Terminal.puts_header("Creating PR for #{build_name}") + cmd = "gh pr create --title 'Compare #{build_name} against vanilla Rails' --body 'Compare #{build_name} against vanilla Rails' --base main --head #{build_name} #{dry_run ? "--dry-run" : ""}" + system(cmd) + end + end + end + end +end + +Main.main(dry_run: ARGV.include?("--dry-run")) diff --git a/bin/create_comparison_repo b/bin/create_comparison_repo new file mode 100755 index 00000000..fb03b131 --- /dev/null +++ b/bin/create_comparison_repo @@ -0,0 +1,87 @@ +#!/usr/bin/env ruby + +require "fileutils" +require_relative "lib/comparison_config" +require_relative "lib/terminal" + +class Main + class << self + def main + reset_comparison_repo + use_vanilla_rails_as_main_branch + + ComparisonConfig.comparison_builds.each do |build_name| + populate_new_branch_for_build(build_name:) + checkout_main + end + end + + private + + def reset_comparison_repo + repo_path = ComparisonConfig.comparison_repo_root_path + + FileUtils.rm_rf(repo_path) + FileUtils.mkdir_p(repo_path) + + Dir.chdir(repo_path) do + system("git init") + system("git remote add origin git@github.com:ackama/rails-template-variants-comparison.git") + end + end + + def use_vanilla_rails_as_main_branch + Terminal.puts_header("Adding vanilla Rails build to main branch") + copy_build(build_path: ComparisonConfig.vanilla_build_path) + commit_changes(commit_message: "Add #{ComparisonConfig.vanilla_build_name}") + end + + def populate_new_branch_for_build(build_name:) + Terminal.puts_header("Creating branch for #{build_name}") + + rm_rf_except_git_dir + create_new_branch(branch_name: build_name) + + build_path = File.join(ComparisonConfig.build_path, build_name) + copy_build(build_path:) + + commit_changes(commit_message: "Add #{build_name}") + end + + def checkout_main + Dir.chdir(ComparisonConfig.comparison_repo_root_path) do + system("git checkout main") + end + end + + # clean out all the files from the previous build (except .git) + def rm_rf_except_git_dir + Dir.chdir(ComparisonConfig.comparison_repo_root_path) do + keepers = [".git", "."] + deletable_files = Dir.glob("{*,.*}").reject { |f| keepers.include?(f) } + + FileUtils.rm_rf(deletable_files) + end + end + + def create_new_branch(branch_name:) + Dir.chdir(ComparisonConfig.comparison_repo_root_path) do + system("git checkout -b #{branch_name}") + end + end + + def commit_changes(commit_message:) + Dir.chdir(ComparisonConfig.comparison_repo_root_path) do + system("git add .") + # skip pre-commit hooks because they're not relevant here + system("git commit --quiet --no-verify -m '#{commit_message}'") + end + end + + def copy_build(build_path:) + system("rsync -qav --exclude='.git' --exclude='node_modules' --exclude='tmp' #{build_path}/ #{ComparisonConfig.comparison_repo_root_path}/") + end + end +end + +Main.main diff --git a/bin/create_prs b/bin/create_prs deleted file mode 100755 index 7042f31f..00000000 --- a/bin/create_prs +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env ruby - -PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, ".")) -BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") -COMPARISONS_REPO_ROOT_PATH = File.join(PROJECT_ROOT_PATH, "tmp/rails-template-variants-comparison") -ALL_BUILDS = Dir.children(BUILD_PATH) -VANILLA_BUILD_NAME = ALL_BUILDS.find { |build_name| build_name.start_with?("vanilla_") } -COMPARISON_BUILDS = ALL_BUILDS - [VANILLA_BUILD_NAME] - -def main - Dir.chdir(COMPARISONS_REPO_ROOT_PATH) do - COMPARISON_BUILDS.each do |build_name| - puts build_name - # add --dry-run to test - system("gh pr create --title 'Compare #{build_name} against vanilla Rails' --body 'Compare #{build_name} against vanilla Rails' --base main --head #{build_name}") - end - end -end - -main diff --git a/bin/lib/config.rb b/bin/lib/build_config.rb similarity index 99% rename from bin/lib/config.rb rename to bin/lib/build_config.rb index 0a12731f..481e1313 100644 --- a/bin/lib/config.rb +++ b/bin/lib/build_config.rb @@ -1,4 +1,4 @@ -class Config +class BuildConfig PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, "../..")) CI_CONFIGS_PATH = File.join(PROJECT_ROOT_PATH, "ci/configs") BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") diff --git a/bin/lib/builder.rb b/bin/lib/builder.rb index 6f6bc44d..a157837d 100644 --- a/bin/lib/builder.rb +++ b/bin/lib/builder.rb @@ -1,25 +1,21 @@ -## -# Invokes the template as a user would, not as CI does via the build-and-test script. -# Automatically removes old DB before generating the app if required -# +require "rubygems" + class Builder def initialize(config:) @config = config + @rails_cmd_version = build_rails_cmd_version(target_rails_major_minor: config.target_rails_major_minor) end def build - verify_rails_cmd_is_expected_version! - Dir.chdir(@config.build_path) do delete_any_previous_build drop_dbs_from_previous_build - # TODO: how to handle errors? raise? or log and continue? or rename the app dir with a .failed suffix? - # TODO: how to handle output? redirect to a file? or sep files for stdout and stderr? - if @config.vanilla? + Terminal.puts_header("Building vanilla Rails app") system(build_vanilla_cmd_env, build_vanilla_cmd) else + Terminal.puts_header("Building Rails app") system(build_cmd_env, build_cmd) end end @@ -29,7 +25,7 @@ def build def base_cmd_parts [ - "rails new #{@config.app_name}", + "rails _#{@rails_cmd_version}_ new #{@config.app_name}", "-d postgresql", "--skip-javascript", "--skip-kamal", @@ -37,12 +33,25 @@ def base_cmd_parts ] end - def verify_rails_cmd_is_expected_version! - actual_rails_version = `rails -v`.strip.sub("Rails ", "") + def build_rails_cmd_version(target_rails_major_minor:) + specs = Gem::Specification.find_all_by_name("rails") + + raise "No versions of gem '#{gem_name}' are installed" if specs.empty? - return if actual_rails_version.start_with?(@config.target_rails_major_minor) + version = specs + .map { _1.version.to_s } + .sort + .reverse + .find { |v| v.start_with?(target_rails_major_minor) } + test_cmd = "rails _#{version}_ -v" + expected_test_output = "Rails #{version}" + actual_test_output = `#{test_cmd}`.strip - raise "Expected Rails version #{@config.target_rails_major_minor}, but got #{actual_rails_version}" + raise "Command failed: #{test_cmd}. Actual: #{actual_test_output}" unless expected_test_output == actual_test_output + + Terminal.puts_header("Using Rails version #{version}") + + version end def delete_any_previous_build @@ -84,17 +93,10 @@ def build_vanilla_cmd base_cmd_parts.join(" ") end - def print_separator - puts "" - puts "*" * 80 - puts "" - end - def drop_dbs_from_previous_build - print_separator + Terminal.puts_header("Dropping databases from previous build") system "psql -c 'DROP DATABASE IF EXISTS #{@config.app_name}_test;'" system "psql -c 'DROP DATABASE IF EXISTS #{@config.app_name}_development;'" system "psql -c 'DROP DATABASE IF EXISTS #{@config.app_name}_test;'" - print_separator end end diff --git a/bin/lib/comparison_config.rb b/bin/lib/comparison_config.rb new file mode 100644 index 00000000..e27d6a3b --- /dev/null +++ b/bin/lib/comparison_config.rb @@ -0,0 +1,30 @@ +class ComparisonConfig + PROJECT_ROOT_PATH = File.absolute_path(File.join(__dir__, "../..")) + BUILD_PATH = File.join(PROJECT_ROOT_PATH, "tmp/builds") + + class << self + def build_path + BUILD_PATH + end + + def comparison_repo_root_path + File.join(PROJECT_ROOT_PATH, "tmp/rails-template-variants-comparison") + end + + def vanilla_build_name + all_builds.find { |build_name| build_name.start_with?("vanilla_") } + end + + def vanilla_build_path + File.join(BUILD_PATH, vanilla_build_name) + end + + def comparison_builds + all_builds - [vanilla_build_name] + end + + def all_builds + Dir.children(BUILD_PATH) + end + end +end diff --git a/bin/lib/terminal.rb b/bin/lib/terminal.rb new file mode 100644 index 00000000..f55fb5ae --- /dev/null +++ b/bin/lib/terminal.rb @@ -0,0 +1,12 @@ +class Terminal + class << self + def puts_header(title) + puts <<~EO_HEADER + + #{title} + #{"=" * title.length} + + EO_HEADER + end + end +end