better_app_gen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +134 -0
  4. data/CHANGELOG.md +29 -0
  5. data/CLAUDE.md +84 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +174 -0
  8. data/RELEASE.md +25 -0
  9. data/Rakefile +10 -0
  10. data/exe/better_app_gen +6 -0
  11. data/lib/better_app_gen/app_generator.rb +75 -0
  12. data/lib/better_app_gen/cli.rb +136 -0
  13. data/lib/better_app_gen/configuration.rb +90 -0
  14. data/lib/better_app_gen/dependency_checker.rb +129 -0
  15. data/lib/better_app_gen/errors.rb +56 -0
  16. data/lib/better_app_gen/generators/base.rb +219 -0
  17. data/lib/better_app_gen/generators/database.rb +64 -0
  18. data/lib/better_app_gen/generators/docker.rb +73 -0
  19. data/lib/better_app_gen/generators/gemfile.rb +26 -0
  20. data/lib/better_app_gen/generators/home_controller.rb +34 -0
  21. data/lib/better_app_gen/generators/locale.rb +24 -0
  22. data/lib/better_app_gen/generators/rails_app.rb +60 -0
  23. data/lib/better_app_gen/generators/simple_form.rb +33 -0
  24. data/lib/better_app_gen/generators/solid_stack.rb +18 -0
  25. data/lib/better_app_gen/generators/vite.rb +122 -0
  26. data/lib/better_app_gen/templates/app/controllers/home_controller.rb.erb +4 -0
  27. data/lib/better_app_gen/templates/app/helpers/home_helper.rb.erb +2 -0
  28. data/lib/better_app_gen/templates/app/views/home/index.html.erb.erb +2 -0
  29. data/lib/better_app_gen/templates/app/views/layouts/application.html.erb.erb +33 -0
  30. data/lib/better_app_gen/templates/bin/dev.erb +11 -0
  31. data/lib/better_app_gen/templates/bin/docker-entrypoint.erb +7 -0
  32. data/lib/better_app_gen/templates/bin/docker-entrypoint.prod.erb +12 -0
  33. data/lib/better_app_gen/templates/config/application.rb.erb +80 -0
  34. data/lib/better_app_gen/templates/config/database.yml.erb +65 -0
  35. data/lib/better_app_gen/templates/config/initializers/active_record_schema_settings.rb.erb +3 -0
  36. data/lib/better_app_gen/templates/config/initializers/better_vite_helper.rb.erb +5 -0
  37. data/lib/better_app_gen/templates/config/initializers/simple_form.rb.erb +21 -0
  38. data/lib/better_app_gen/templates/config/locales/it.yml.erb +68 -0
  39. data/lib/better_app_gen/templates/config/locales/simple_form.it.yml.erb +49 -0
  40. data/lib/better_app_gen/templates/config/routes.rb.erb +15 -0
  41. data/lib/better_app_gen/templates/db/cable_migrate/create_solid_cable_schema.rb.erb +15 -0
  42. data/lib/better_app_gen/templates/db/cache_migrate/create_solid_cache_schema.rb.erb +16 -0
  43. data/lib/better_app_gen/templates/db/migrate/create_shared_schema.rb.erb +31 -0
  44. data/lib/better_app_gen/templates/db/migrate/enable_uuid_extension.rb.erb +7 -0
  45. data/lib/better_app_gen/templates/db/queue_migrate/create_solid_queue_schema.rb.erb +133 -0
  46. data/lib/better_app_gen/templates/docker/DEPLOY.md.erb +129 -0
  47. data/lib/better_app_gen/templates/docker/Dockerfile.dev.erb +58 -0
  48. data/lib/better_app_gen/templates/docker/Dockerfile.prod.erb +172 -0
  49. data/lib/better_app_gen/templates/docker/compose.runner.yml.erb +6 -0
  50. data/lib/better_app_gen/templates/docker/compose.yml.erb +86 -0
  51. data/lib/better_app_gen/templates/docker/env.docker.erb +28 -0
  52. data/lib/better_app_gen/templates/lib/tasks/db.rake.erb +28 -0
  53. data/lib/better_app_gen/templates/public/robots.txt.erb +1 -0
  54. data/lib/better_app_gen/templates/root/Procfile.dev.erb +2 -0
  55. data/lib/better_app_gen/templates/root/env.example.erb +27 -0
  56. data/lib/better_app_gen/templates/root/gitignore.erb +404 -0
  57. data/lib/better_app_gen/templates/root/yarnrc.yml.erb +6 -0
  58. data/lib/better_app_gen/templates/script/dc-attach.erb +13 -0
  59. data/lib/better_app_gen/templates/script/dc-build.erb +8 -0
  60. data/lib/better_app_gen/templates/script/dc-down.erb +8 -0
  61. data/lib/better_app_gen/templates/script/dc-logs-tail.erb +16 -0
  62. data/lib/better_app_gen/templates/script/dc-logs.erb +17 -0
  63. data/lib/better_app_gen/templates/script/dc-rails.erb +8 -0
  64. data/lib/better_app_gen/templates/script/dc-restart.erb +11 -0
  65. data/lib/better_app_gen/templates/script/dc-shell.erb +12 -0
  66. data/lib/better_app_gen/templates/script/dc-up.erb +11 -0
  67. data/lib/better_app_gen/templates/vite/application.css.erb +12 -0
  68. data/lib/better_app_gen/templates/vite/application.js.erb +6 -0
  69. data/lib/better_app_gen/templates/vite/controllers/application.js.erb +9 -0
  70. data/lib/better_app_gen/templates/vite/controllers/hello_controller.js.erb +7 -0
  71. data/lib/better_app_gen/templates/vite/controllers/index.js.erb +8 -0
  72. data/lib/better_app_gen/templates/vite/postcss.config.js.erb +5 -0
  73. data/lib/better_app_gen/templates/vite/vite.config.js.erb +48 -0
  74. data/lib/better_app_gen/version.rb +5 -0
  75. data/lib/better_app_gen.rb +23 -0
  76. metadata +182 -0
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "pastel"
5
+ require "tty-spinner"
6
+
7
+ module BetterAppGen
8
+ # CLI class for handling command-line interactions
9
+ class CLI < Thor
10
+ def self.exit_on_failure?
11
+ true
12
+ end
13
+
14
+ desc "new APP_NAME", "Generate a new Rails 8 application with an opinionated stack"
15
+ long_desc <<~LONGDESC
16
+ Creates a new Rails 8 application with a production-ready stack including:
17
+
18
+ - Solid Cache/Queue/Cable (PostgreSQL-backed instead of Redis)
19
+ - Vite 7 + Tailwind CSS 4 for assets
20
+ - Multi-database setup (primary, cache, queue, cable)
21
+ - Docker development environment (optional)
22
+ - UUID primary keys by default
23
+ - Configurable locale and timezone
24
+
25
+ Examples:
26
+ better_app_gen new my-blog
27
+ better_app_gen new my-app --with-simple-form
28
+ better_app_gen new my-app --rails-port 3001 --vite-port 5174
29
+ better_app_gen new my-app --skip-docker
30
+ better_app_gen new my-app --locale it
31
+ LONGDESC
32
+ method_option :with_simple_form, type: :boolean, default: false,
33
+ desc: "Include SimpleForm with Tailwind CSS styling"
34
+ method_option :rails_port, type: :numeric, default: 3000,
35
+ desc: "Rails server port (default: 3000)"
36
+ method_option :vite_port, type: :numeric, default: 5173,
37
+ desc: "Vite dev server port (default: 5173)"
38
+ method_option :skip_docker, type: :boolean, default: false,
39
+ desc: "Skip Docker configuration"
40
+ method_option :locale, type: :string, default: "en",
41
+ desc: "Default locale (en, it, de, fr, es, pt, nl, pl, ru, ja, zh)"
42
+ def new(app_name)
43
+ pastel = Pastel.new
44
+
45
+ # Create configuration
46
+ begin
47
+ config = Configuration.new(
48
+ app_name: app_name,
49
+ rails_port: options[:rails_port],
50
+ vite_port: options[:vite_port],
51
+ locale: options[:locale],
52
+ with_simple_form: options[:with_simple_form],
53
+ skip_docker: options[:skip_docker]
54
+ )
55
+ rescue Error => e
56
+ say pastel.red("Error: #{e.message}")
57
+ exit 1
58
+ end
59
+
60
+ # Check if directory already exists
61
+ if Dir.exist?(config.app_path)
62
+ say pastel.red("Error: Directory '#{app_name}' already exists.")
63
+ exit 1
64
+ end
65
+
66
+ # Verify dependencies
67
+ checker = DependencyChecker.new
68
+ unless checker.check_all
69
+ say pastel.red("\nMissing required dependencies:")
70
+ checker.missing_dependencies.each { |dep| say pastel.red(" - #{dep}") }
71
+ say pastel.yellow("\nPlease install the missing dependencies and try again.")
72
+ exit 1
73
+ end
74
+
75
+ # Generate the application
76
+ say pastel.green("\nGenerating Rails 8 application: #{app_name}\n")
77
+
78
+ generator = AppGenerator.new(config)
79
+ generator.generate!
80
+
81
+ # Success message
82
+ say pastel.green("\nApplication '#{app_name}' created successfully!")
83
+ say pastel.cyan("\nNext steps:")
84
+ say " cd #{app_name}"
85
+
86
+ if options[:skip_docker]
87
+ say " bundle install"
88
+ say " yarn install"
89
+ say " rails db:create db:schema:load"
90
+ say " bin/dev # Start Rails + Vite"
91
+ else
92
+ say " script/dc-up # Start Docker containers"
93
+ say " script/dc-shell # Open shell in Rails container"
94
+ say " rails db:create # Create databases"
95
+ say " rails db:schema:load # Load schema"
96
+ say " exit # Exit shell"
97
+ say " script/dc-down && script/dc-up # Restart containers"
98
+ end
99
+
100
+ # Warn if locale doesn't have translation files
101
+ warn_about_missing_locale_files(config.locale, pastel)
102
+
103
+ say pastel.cyan("\nHappy coding!")
104
+ end
105
+
106
+ desc "check", "Verify that all required dependencies are installed"
107
+ def check
108
+ pastel = Pastel.new
109
+ say pastel.cyan("\nChecking dependencies...\n")
110
+
111
+ checker = DependencyChecker.new
112
+ checker.check_all(verbose: true)
113
+ end
114
+
115
+ desc "version", "Show better_app_gen version"
116
+ def version
117
+ puts "better_app_gen v#{VERSION}"
118
+ end
119
+
120
+ # Version aliases
121
+ map %w[-v --version] => :version
122
+
123
+ # Locales that have translation templates included
124
+ LOCALES_WITH_TEMPLATES = %w[en it].freeze
125
+
126
+ private
127
+
128
+ def warn_about_missing_locale_files(locale, pastel)
129
+ return if LOCALES_WITH_TEMPLATES.include?(locale)
130
+
131
+ say pastel.yellow("\nNote: Locale '#{locale}' does not include translation files.")
132
+ say pastel.yellow("You may want to add translations from the rails-i18n gem:")
133
+ say pastel.yellow(" https://github.com/svenfuchs/rails-i18n")
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAppGen
4
+ # Immutable configuration object holding all options for app generation
5
+ class Configuration
6
+ attr_reader :app_name, :rails_port, :vite_port, :locale, :with_simple_form, :skip_docker, :app_path,
7
+ :app_name_snake, :app_name_pascal, :app_name_dash
8
+
9
+ # Valid locale codes supported by the generator
10
+ SUPPORTED_LOCALES = %w[en it de fr es pt nl pl ru ja zh].freeze
11
+
12
+ def initialize(app_name:, rails_port: 3000, vite_port: 5173, locale: "en",
13
+ with_simple_form: false, skip_docker: false, app_path: nil)
14
+ @app_name = app_name
15
+ @rails_port = rails_port
16
+ @vite_port = vite_port
17
+ @locale = locale
18
+ @with_simple_form = with_simple_form
19
+ @skip_docker = skip_docker
20
+ @app_path = app_path || File.expand_path(app_name, Dir.pwd)
21
+
22
+ # Compute derived values before freezing
23
+ @app_name_snake = app_name.tr("-", "_")
24
+ @app_name_pascal = @app_name_snake.split("_").map(&:capitalize).join
25
+ @app_name_dash = app_name.tr("_", "-")
26
+
27
+ validate!
28
+ freeze
29
+ end
30
+
31
+ # Returns the timezone based on locale
32
+ def timezone
33
+ case locale
34
+ when "it" then "Europe/Rome"
35
+ when "de" then "Europe/Berlin"
36
+ when "fr" then "Europe/Paris"
37
+ when "es" then "Europe/Madrid"
38
+ when "pt" then "Europe/Lisbon"
39
+ when "nl" then "Europe/Amsterdam"
40
+ when "pl" then "Europe/Warsaw"
41
+ when "ru" then "Europe/Moscow"
42
+ when "ja" then "Asia/Tokyo"
43
+ when "zh" then "Asia/Shanghai"
44
+ else "UTC"
45
+ end
46
+ end
47
+
48
+ # Returns a hash representation for template binding
49
+ def to_binding_hash
50
+ {
51
+ app_name: app_name,
52
+ app_name_snake: app_name_snake,
53
+ app_name_pascal: app_name_pascal,
54
+ app_name_dash: app_name_dash,
55
+ rails_port: rails_port,
56
+ vite_port: vite_port,
57
+ locale: locale,
58
+ timezone: timezone,
59
+ with_simple_form: with_simple_form,
60
+ skip_docker: skip_docker
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def validate!
67
+ validate_app_name!
68
+ validate_locale!
69
+ validate_ports!
70
+ end
71
+
72
+ def validate_app_name!
73
+ return if app_name.match?(/\A[a-zA-Z][a-zA-Z0-9_-]*\z/)
74
+
75
+ raise InvalidAppNameError, app_name
76
+ end
77
+
78
+ def validate_locale!
79
+ return if SUPPORTED_LOCALES.include?(locale)
80
+
81
+ raise Error, "Unsupported locale '#{locale}'. Supported locales: #{SUPPORTED_LOCALES.join(", ")}"
82
+ end
83
+
84
+ def validate_ports!
85
+ raise Error, "Rails port must be between 1024 and 65535" unless (1024..65_535).include?(rails_port)
86
+ raise Error, "Vite port must be between 1024 and 65535" unless (1024..65_535).include?(vite_port)
87
+ raise Error, "Rails port and Vite port must be different" if rails_port == vite_port
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ module BetterAppGen
5
+ # Checks for required system dependencies
6
+ class DependencyChecker
7
+ REQUIRED_DEPENDENCIES = {
8
+ "ruby" => { command: "ruby --version", min_version: "3.2.0" },
9
+ "rails" => { command: "rails --version", min_version: "8.0.0" },
10
+ "node" => { command: "node --version", min_version: "20.0.0" },
11
+ "yarn" => { command: "yarn --version", min_version: "4.0.0" },
12
+ "git" => { command: "git --version", min_version: nil },
13
+ "psql" => { command: "psql --version", min_version: nil }
14
+ }.freeze
15
+
16
+ attr_reader :pastel
17
+
18
+ def initialize
19
+ @pastel = Pastel.new
20
+ @results = {}
21
+ end
22
+
23
+ # Check all dependencies, optionally with verbose output
24
+ # Returns true if all dependencies are met
25
+ def check_all(verbose: false)
26
+ missing = []
27
+
28
+ REQUIRED_DEPENDENCIES.each do |name, config|
29
+ result = check_dependency(name, config)
30
+ @results[name] = result
31
+
32
+ print_result(name, result) if verbose
33
+ missing << name unless result[:satisfied]
34
+ end
35
+
36
+ if verbose
37
+ puts
38
+ if missing.empty?
39
+ puts pastel.green("All dependencies satisfied!")
40
+ else
41
+ puts pastel.red("Missing dependencies: #{missing.join(", ")}")
42
+ end
43
+ end
44
+
45
+ missing.empty?
46
+ end
47
+
48
+ # Check a specific dependency
49
+ def check(name)
50
+ config = REQUIRED_DEPENDENCIES[name.to_s]
51
+ raise Error, "Unknown dependency: #{name}" unless config
52
+
53
+ check_dependency(name.to_s, config)[:satisfied]
54
+ end
55
+
56
+ # Returns missing dependencies as an array
57
+ def missing_dependencies
58
+ @results.reject { |_, r| r[:satisfied] }.keys
59
+ end
60
+
61
+ private
62
+
63
+ def check_dependency(_name, config)
64
+ # Run command outside of bundler environment to detect system-wide installations
65
+ output = nil
66
+ installed = false
67
+
68
+ Bundler.with_unbundled_env do
69
+ output = `#{config[:command]} 2>&1`.strip
70
+ installed = $CHILD_STATUS.success?
71
+ end
72
+
73
+ if installed && config[:min_version]
74
+ version = extract_version(output)
75
+ version_ok = version_satisfied?(version, config[:min_version])
76
+ {
77
+ satisfied: version_ok,
78
+ installed: true,
79
+ version: version,
80
+ min_version: config[:min_version]
81
+ }
82
+ else
83
+ {
84
+ satisfied: installed,
85
+ installed: installed,
86
+ version: installed ? extract_version(output) : nil,
87
+ min_version: config[:min_version]
88
+ }
89
+ end
90
+ end
91
+
92
+ def extract_version(output)
93
+ # Match common version patterns like "1.2.3", "v1.2.3", "Ruby 3.2.0", etc.
94
+ match = output.match(/v?(\d+\.\d+(?:\.\d+)?)/i)
95
+ match ? match[1] : nil
96
+ end
97
+
98
+ def version_satisfied?(current, required)
99
+ return true unless required && current
100
+
101
+ current_parts = current.split(".").map(&:to_i)
102
+ required_parts = required.split(".").map(&:to_i)
103
+
104
+ # Compare each version part
105
+ required_parts.each_with_index do |req_part, i|
106
+ cur_part = current_parts[i] || 0
107
+ return true if cur_part > req_part
108
+ return false if cur_part < req_part
109
+ end
110
+
111
+ true
112
+ end
113
+
114
+ def print_result(name, result)
115
+ if result[:satisfied]
116
+ status = pastel.green("OK")
117
+ version_info = result[:version] ? " (#{result[:version]})" : ""
118
+ puts " #{status} #{name}#{version_info}"
119
+ else
120
+ status = pastel.red("MISSING")
121
+ if result[:installed] && result[:min_version]
122
+ puts " #{status} #{name} - version #{result[:version]} < #{result[:min_version]} required"
123
+ else
124
+ puts " #{status} #{name}"
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAppGen
4
+ # Base error class for all BetterAppGen errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a required dependency is missing
8
+ class DependencyError < Error
9
+ attr_reader :missing_dependencies
10
+
11
+ def initialize(missing_dependencies)
12
+ @missing_dependencies = missing_dependencies
13
+ super("Missing required dependencies: #{missing_dependencies.join(", ")}")
14
+ end
15
+ end
16
+
17
+ # Raised when the app name is invalid
18
+ class InvalidAppNameError < Error
19
+ def initialize(app_name)
20
+ super("Invalid app name '#{app_name}'. App name must start with a letter and contain only letters, " \
21
+ "numbers, hyphens, and underscores.")
22
+ end
23
+ end
24
+
25
+ # Raised when the target directory already exists
26
+ class DirectoryExistsError < Error
27
+ def initialize(path)
28
+ super("Directory '#{path}' already exists. Please choose a different name or remove the existing directory.")
29
+ end
30
+ end
31
+
32
+ # Raised when a template file is not found
33
+ class TemplateNotFoundError < Error
34
+ def initialize(template_path)
35
+ super("Template file not found: #{template_path}")
36
+ end
37
+ end
38
+
39
+ # Raised when a shell command fails
40
+ class CommandFailedError < Error
41
+ attr_reader :command, :exit_code
42
+
43
+ def initialize(command, exit_code)
44
+ @command = command
45
+ @exit_code = exit_code
46
+ super("Command '#{command}' failed with exit code #{exit_code}")
47
+ end
48
+ end
49
+
50
+ # Raised when Rails app generation fails
51
+ class RailsGenerationError < Error
52
+ def initialize(message = "Failed to generate Rails application")
53
+ super
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "erb"
5
+ require "fileutils"
6
+ require "json"
7
+ require "securerandom"
8
+
9
+ module BetterAppGen
10
+ module Generators
11
+ # Base class for all generators with ERB template support
12
+ class Base
13
+ attr_reader :config
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ # Main entry point for the generator - subclasses must implement
20
+ def generate!
21
+ raise NotImplementedError, "Subclasses must implement #generate!"
22
+ end
23
+
24
+ protected
25
+
26
+ # Delegate config accessors
27
+ def app_name = config.app_name
28
+ def app_name_snake = config.app_name_snake
29
+ def app_name_pascal = config.app_name_pascal
30
+ def app_name_dash = config.app_name_dash
31
+ def rails_port = config.rails_port
32
+ def vite_port = config.vite_port
33
+ def locale = config.locale
34
+ def timezone = config.timezone
35
+ def with_simple_form = config.with_simple_form
36
+ def skip_docker = config.skip_docker
37
+ def app_path = config.app_path
38
+
39
+ # Returns the templates directory path
40
+ def templates_path
41
+ BetterAppGen.templates_path
42
+ end
43
+
44
+ # Reads and renders an ERB template file
45
+ def render_template(template_name, trim_mode: "-")
46
+ template_file = templates_path.join(template_name)
47
+ raise TemplateNotFoundError, template_file unless template_file.exist?
48
+
49
+ template_content = template_file.read
50
+ ERB.new(template_content, trim_mode: trim_mode).result(binding)
51
+ end
52
+
53
+ # Creates a file with the given content
54
+ def create_file(relative_path, content)
55
+ full_path = File.join(app_path, relative_path)
56
+ FileUtils.mkdir_p(File.dirname(full_path))
57
+ File.write(full_path, content)
58
+ end
59
+
60
+ # Creates a file from an ERB template
61
+ def create_file_from_template(relative_path, template_name)
62
+ content = render_template(template_name)
63
+ create_file(relative_path, content)
64
+ end
65
+
66
+ # Reads a file from the app directory
67
+ def read_file(relative_path)
68
+ full_path = File.join(app_path, relative_path)
69
+ File.read(full_path)
70
+ end
71
+
72
+ # Updates a file with new content
73
+ def update_file(relative_path, content)
74
+ full_path = File.join(app_path, relative_path)
75
+ File.write(full_path, content)
76
+ end
77
+
78
+ # Checks if a file exists in the app directory
79
+ def file_exists?(relative_path)
80
+ full_path = File.join(app_path, relative_path)
81
+ File.exist?(full_path)
82
+ end
83
+
84
+ # Appends content to a file if not already present
85
+ def append_to_file(relative_path, content)
86
+ full_path = File.join(app_path, relative_path)
87
+ current_content = File.read(full_path)
88
+ return if current_content.include?(content)
89
+
90
+ File.write(full_path, current_content + content)
91
+ end
92
+
93
+ # Inserts content before a matching pattern
94
+ def insert_before(relative_path, pattern, content)
95
+ full_path = File.join(app_path, relative_path)
96
+ current_content = File.read(full_path)
97
+ new_content = current_content.gsub(pattern) { |match| content + match }
98
+ File.write(full_path, new_content)
99
+ end
100
+
101
+ # Inserts content after a matching pattern
102
+ def insert_after(relative_path, pattern, content)
103
+ full_path = File.join(app_path, relative_path)
104
+ current_content = File.read(full_path)
105
+ new_content = current_content.gsub(pattern) { |match| match + content }
106
+ File.write(full_path, new_content)
107
+ end
108
+
109
+ # Replaces content matching a pattern
110
+ def gsub_file(relative_path, pattern, replacement)
111
+ full_path = File.join(app_path, relative_path)
112
+ current_content = File.read(full_path)
113
+ new_content = current_content.gsub(pattern, replacement)
114
+ File.write(full_path, new_content)
115
+ end
116
+
117
+ # Intelligent Gemfile merge - adds gems without duplicates
118
+ def merge_gemfile(gems_to_add)
119
+ gemfile_path = File.join(app_path, "Gemfile")
120
+ current_content = File.read(gemfile_path)
121
+
122
+ gems_to_add.each do |gem_line|
123
+ # Extract gem name from line (e.g., gem "solid_cache" -> solid_cache)
124
+ gem_name = gem_line.match(/gem ["']([^"']+)["']/)[1]
125
+
126
+ # Skip if gem already present
127
+ next if current_content.match?(/gem ["']#{Regexp.escape(gem_name)}["']/)
128
+
129
+ # Insert before development group if exists, otherwise append
130
+ if current_content.match?(/group :development do/)
131
+ insert_before("Gemfile", /group :development do/, "#{gem_line}\n")
132
+ else
133
+ append_to_file("Gemfile", "\n#{gem_line}\n")
134
+ end
135
+ current_content = File.read(gemfile_path)
136
+ end
137
+ end
138
+
139
+ # Intelligent package.json merge
140
+ def merge_package_json(dependencies: {}, dev_dependencies: {}, scripts: {}, extra: {})
141
+ package_path = File.join(app_path, "package.json")
142
+
143
+ package_data = if File.exist?(package_path)
144
+ JSON.parse(File.read(package_path))
145
+ else
146
+ { "name" => app_name_dash, "private" => true }
147
+ end
148
+
149
+ # Merge dependencies
150
+ package_data["dependencies"] ||= {}
151
+ package_data["dependencies"].merge!(dependencies)
152
+
153
+ # Merge devDependencies
154
+ package_data["devDependencies"] ||= {}
155
+ package_data["devDependencies"].merge!(dev_dependencies)
156
+
157
+ # Merge scripts
158
+ package_data["scripts"] ||= {}
159
+ package_data["scripts"].merge!(scripts)
160
+
161
+ # Merge extra fields
162
+ extra.each { |key, value| package_data[key] = value }
163
+
164
+ # Write formatted JSON
165
+ File.write(package_path, JSON.pretty_generate(package_data))
166
+ end
167
+
168
+ # Creates a directory
169
+ def create_directory(relative_path)
170
+ full_path = File.join(app_path, relative_path)
171
+ FileUtils.mkdir_p(full_path)
172
+ end
173
+
174
+ # Copies a file within the app directory
175
+ def copy_file(source_path, destination_path)
176
+ source_full = File.join(app_path, source_path)
177
+ dest_full = File.join(app_path, destination_path)
178
+ FileUtils.mkdir_p(File.dirname(dest_full))
179
+ FileUtils.cp(source_full, dest_full)
180
+ end
181
+
182
+ # Makes a file executable
183
+ def chmod_executable(relative_path)
184
+ full_path = File.join(app_path, relative_path)
185
+ FileUtils.chmod("+x", full_path)
186
+ end
187
+
188
+ # Removes a file or directory
189
+ def remove_file(relative_path)
190
+ full_path = File.join(app_path, relative_path)
191
+ FileUtils.rm_rf(full_path)
192
+ end
193
+
194
+ # Generates a timestamp for migrations
195
+ def migration_timestamp(offset_seconds = 0)
196
+ (Time.now + offset_seconds).utc.strftime("%Y%m%d%H%M%S")
197
+ end
198
+
199
+ # Runs a shell command in the app directory
200
+ def run_command(command, capture: false)
201
+ Dir.chdir(app_path) do
202
+ if capture
203
+ `#{command}`
204
+ else
205
+ system(command)
206
+ end
207
+ end
208
+ end
209
+
210
+ # Runs a shell command and raises on failure
211
+ def run_command!(command)
212
+ Dir.chdir(app_path) do
213
+ success = system(command)
214
+ raise CommandFailedError.new(command, $CHILD_STATUS.exitstatus) unless success
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAppGen
4
+ module Generators
5
+ # Sets up database configuration and migrations
6
+ class Database < Base
7
+ def generate!
8
+ create_database_yml
9
+ create_migrations
10
+ create_db_rake_task
11
+ create_schema_settings_initializer
12
+ end
13
+
14
+ private
15
+
16
+ def create_database_yml
17
+ create_file_from_template("config/database.yml", "config/database.yml.erb")
18
+ end
19
+
20
+ def create_migrations
21
+ # UUID extension migration
22
+ create_file_from_template(
23
+ "db/migrate/#{migration_timestamp(0)}_enable_uuid_extension.rb",
24
+ "db/migrate/enable_uuid_extension.rb.erb"
25
+ )
26
+
27
+ # Shared schema migration
28
+ create_file_from_template(
29
+ "db/migrate/#{migration_timestamp(1)}_create_shared_schema.rb",
30
+ "db/migrate/create_shared_schema.rb.erb"
31
+ )
32
+
33
+ # Solid Cache migration
34
+ create_file_from_template(
35
+ "db/cache_migrate/#{migration_timestamp(2)}_create_solid_cache_schema.rb",
36
+ "db/cache_migrate/create_solid_cache_schema.rb.erb"
37
+ )
38
+
39
+ # Solid Queue migration
40
+ create_file_from_template(
41
+ "db/queue_migrate/#{migration_timestamp(3)}_create_solid_queue_schema.rb",
42
+ "db/queue_migrate/create_solid_queue_schema.rb.erb"
43
+ )
44
+
45
+ # Solid Cable migration
46
+ create_file_from_template(
47
+ "db/cable_migrate/#{migration_timestamp(4)}_create_solid_cable_schema.rb",
48
+ "db/cable_migrate/create_solid_cable_schema.rb.erb"
49
+ )
50
+ end
51
+
52
+ def create_db_rake_task
53
+ create_file_from_template("lib/tasks/db.rake", "lib/tasks/db.rake.erb")
54
+ end
55
+
56
+ def create_schema_settings_initializer
57
+ create_file_from_template(
58
+ "config/initializers/active_record_schema_settings.rb",
59
+ "config/initializers/active_record_schema_settings.rb.erb"
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end