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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/RELEASE.md +25 -0
- data/Rakefile +10 -0
- data/exe/better_app_gen +6 -0
- data/lib/better_app_gen/app_generator.rb +75 -0
- data/lib/better_app_gen/cli.rb +136 -0
- data/lib/better_app_gen/configuration.rb +90 -0
- data/lib/better_app_gen/dependency_checker.rb +129 -0
- data/lib/better_app_gen/errors.rb +56 -0
- data/lib/better_app_gen/generators/base.rb +219 -0
- data/lib/better_app_gen/generators/database.rb +64 -0
- data/lib/better_app_gen/generators/docker.rb +73 -0
- data/lib/better_app_gen/generators/gemfile.rb +26 -0
- data/lib/better_app_gen/generators/home_controller.rb +34 -0
- data/lib/better_app_gen/generators/locale.rb +24 -0
- data/lib/better_app_gen/generators/rails_app.rb +60 -0
- data/lib/better_app_gen/generators/simple_form.rb +33 -0
- data/lib/better_app_gen/generators/solid_stack.rb +18 -0
- data/lib/better_app_gen/generators/vite.rb +122 -0
- data/lib/better_app_gen/templates/app/controllers/home_controller.rb.erb +4 -0
- data/lib/better_app_gen/templates/app/helpers/home_helper.rb.erb +2 -0
- data/lib/better_app_gen/templates/app/views/home/index.html.erb.erb +2 -0
- data/lib/better_app_gen/templates/app/views/layouts/application.html.erb.erb +33 -0
- data/lib/better_app_gen/templates/bin/dev.erb +11 -0
- data/lib/better_app_gen/templates/bin/docker-entrypoint.erb +7 -0
- data/lib/better_app_gen/templates/bin/docker-entrypoint.prod.erb +12 -0
- data/lib/better_app_gen/templates/config/application.rb.erb +80 -0
- data/lib/better_app_gen/templates/config/database.yml.erb +65 -0
- data/lib/better_app_gen/templates/config/initializers/active_record_schema_settings.rb.erb +3 -0
- data/lib/better_app_gen/templates/config/initializers/better_vite_helper.rb.erb +5 -0
- data/lib/better_app_gen/templates/config/initializers/simple_form.rb.erb +21 -0
- data/lib/better_app_gen/templates/config/locales/it.yml.erb +68 -0
- data/lib/better_app_gen/templates/config/locales/simple_form.it.yml.erb +49 -0
- data/lib/better_app_gen/templates/config/routes.rb.erb +15 -0
- data/lib/better_app_gen/templates/db/cable_migrate/create_solid_cable_schema.rb.erb +15 -0
- data/lib/better_app_gen/templates/db/cache_migrate/create_solid_cache_schema.rb.erb +16 -0
- data/lib/better_app_gen/templates/db/migrate/create_shared_schema.rb.erb +31 -0
- data/lib/better_app_gen/templates/db/migrate/enable_uuid_extension.rb.erb +7 -0
- data/lib/better_app_gen/templates/db/queue_migrate/create_solid_queue_schema.rb.erb +133 -0
- data/lib/better_app_gen/templates/docker/DEPLOY.md.erb +129 -0
- data/lib/better_app_gen/templates/docker/Dockerfile.dev.erb +58 -0
- data/lib/better_app_gen/templates/docker/Dockerfile.prod.erb +172 -0
- data/lib/better_app_gen/templates/docker/compose.runner.yml.erb +6 -0
- data/lib/better_app_gen/templates/docker/compose.yml.erb +86 -0
- data/lib/better_app_gen/templates/docker/env.docker.erb +28 -0
- data/lib/better_app_gen/templates/lib/tasks/db.rake.erb +28 -0
- data/lib/better_app_gen/templates/public/robots.txt.erb +1 -0
- data/lib/better_app_gen/templates/root/Procfile.dev.erb +2 -0
- data/lib/better_app_gen/templates/root/env.example.erb +27 -0
- data/lib/better_app_gen/templates/root/gitignore.erb +404 -0
- data/lib/better_app_gen/templates/root/yarnrc.yml.erb +6 -0
- data/lib/better_app_gen/templates/script/dc-attach.erb +13 -0
- data/lib/better_app_gen/templates/script/dc-build.erb +8 -0
- data/lib/better_app_gen/templates/script/dc-down.erb +8 -0
- data/lib/better_app_gen/templates/script/dc-logs-tail.erb +16 -0
- data/lib/better_app_gen/templates/script/dc-logs.erb +17 -0
- data/lib/better_app_gen/templates/script/dc-rails.erb +8 -0
- data/lib/better_app_gen/templates/script/dc-restart.erb +11 -0
- data/lib/better_app_gen/templates/script/dc-shell.erb +12 -0
- data/lib/better_app_gen/templates/script/dc-up.erb +11 -0
- data/lib/better_app_gen/templates/vite/application.css.erb +12 -0
- data/lib/better_app_gen/templates/vite/application.js.erb +6 -0
- data/lib/better_app_gen/templates/vite/controllers/application.js.erb +9 -0
- data/lib/better_app_gen/templates/vite/controllers/hello_controller.js.erb +7 -0
- data/lib/better_app_gen/templates/vite/controllers/index.js.erb +8 -0
- data/lib/better_app_gen/templates/vite/postcss.config.js.erb +5 -0
- data/lib/better_app_gen/templates/vite/vite.config.js.erb +48 -0
- data/lib/better_app_gen/version.rb +5 -0
- data/lib/better_app_gen.rb +23 -0
- 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
|