yoker 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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +223 -0
  5. data/Rakefile +12 -0
  6. data/exe/yoker +177 -0
  7. data/exe/yoker (Copy) +87 -0
  8. data/lib/yoker/cli/base.rb +106 -0
  9. data/lib/yoker/cli/init.rb +193 -0
  10. data/lib/yoker/cli/update.rb +457 -0
  11. data/lib/yoker/configuration.rb +290 -0
  12. data/lib/yoker/detectors/database_detector.rb +35 -0
  13. data/lib/yoker/detectors/rails_detector.rb +48 -0
  14. data/lib/yoker/detectors/version_manager_detector.rb +91 -0
  15. data/lib/yoker/errors.rb +149 -0
  16. data/lib/yoker/generators/base_generator.rb +116 -0
  17. data/lib/yoker/generators/container/docker.rb +255 -0
  18. data/lib/yoker/generators/container/docker_compose.rb +255 -0
  19. data/lib/yoker/generators/container/none.rb +314 -0
  20. data/lib/yoker/generators/database/mysql.rb +147 -0
  21. data/lib/yoker/generators/database/postgresql.rb +64 -0
  22. data/lib/yoker/generators/database/sqlite.rb +123 -0
  23. data/lib/yoker/generators/version_manager/mise.rb +140 -0
  24. data/lib/yoker/generators/version_manager/rbenv.rb +165 -0
  25. data/lib/yoker/generators/version_manager/rvm.rb +246 -0
  26. data/lib/yoker/templates/bin/setup.rb.erb +232 -0
  27. data/lib/yoker/templates/config/database_mysql.yml.erb +47 -0
  28. data/lib/yoker/templates/config/database_postgresql.yml.erb +34 -0
  29. data/lib/yoker/templates/config/database_sqlite.yml.erb +40 -0
  30. data/lib/yoker/templates/docker/Dockerfile.erb +124 -0
  31. data/lib/yoker/templates/docker/docker-compose.yml.erb +117 -0
  32. data/lib/yoker/templates/docker/entrypoint.sh.erb +94 -0
  33. data/lib/yoker/templates/docker/init_mysql.sql.erb +44 -0
  34. data/lib/yoker/templates/docker/init_postgresql.sql.erb +203 -0
  35. data/lib/yoker/templates/version_managers/gemrc.erb +61 -0
  36. data/lib/yoker/templates/version_managers/mise.toml.erb +72 -0
  37. data/lib/yoker/templates/version_managers/rbenv_setup.sh.erb +93 -0
  38. data/lib/yoker/templates/version_managers/rvm_setup.sh.erb +99 -0
  39. data/lib/yoker/templates/version_managers/rvmrc.erb +70 -0
  40. data/lib/yoker/version.rb +5 -0
  41. data/lib/yoker.rb +32 -0
  42. data/sig/yoker.rbs +4 -0
  43. metadata +215 -0
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "toml-rb" if defined?(TomlRB)
5
+
6
+ module Yoker
7
+ class Configuration
8
+ SUPPORTED_DATABASES = %w[postgresql mysql sqlite3].freeze
9
+ SUPPORTED_VERSION_MANAGERS = %w[mise rbenv rvm none].freeze
10
+ SUPPORTED_CONTAINERS = %w[docker-compose docker none].freeze
11
+ SUPPORTED_ADDITIONAL_SERVICES = %w[redis sidekiq mailcatcher].freeze
12
+
13
+ DEFAULT_RUBY_VERSION = RUBY_VERSION
14
+ DEFAULT_DATABASE = "postgresql"
15
+ DEFAULT_VERSION_MANAGER = "mise"
16
+ DEFAULT_CONTAINER = "docker-compose"
17
+
18
+ attr_accessor :app_name, :ruby_version, :database, :version_manager,
19
+ :container, :additional_services, :force_overwrite,
20
+ :backup_existing, :verbose, :database_port, :database_username,
21
+ :database_password, :database_host
22
+
23
+ def initialize(options = {})
24
+ @app_name = options[:app_name] || detect_app_name
25
+ @ruby_version = options[:ruby_version] || DEFAULT_RUBY_VERSION
26
+ @database = options[:database] || DEFAULT_DATABASE
27
+ @version_manager = options[:version_manager] || DEFAULT_VERSION_MANAGER
28
+ @container = options[:container] || DEFAULT_CONTAINER
29
+ @additional_services = Array(options[:additional_services])
30
+ @force_overwrite = options[:force_overwrite] || false
31
+ @backup_existing = options[:backup_existing] || true
32
+ @verbose = options[:verbose] || false
33
+
34
+ # Database-specific settings
35
+ @database_port = options[:database_port] || default_database_port
36
+ @database_username = options[:database_username] || default_database_username
37
+ @database_password = options[:database_password] || default_database_password
38
+ @database_host = options[:database_host] || default_database_host
39
+
40
+ validate!
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ app_name: app_name,
46
+ ruby_version: ruby_version,
47
+ database: database,
48
+ version_manager: version_manager,
49
+ container: container,
50
+ additional_services: additional_services,
51
+ force_overwrite: force_overwrite,
52
+ backup_existing: backup_existing,
53
+ verbose: verbose,
54
+ database_port: database_port,
55
+ database_username: database_username,
56
+ database_password: database_password,
57
+ database_host: database_host
58
+ }
59
+ end
60
+
61
+ def self.from_file(path)
62
+ return new unless File.exist?(path)
63
+
64
+ case File.extname(path)
65
+ when ".yml", ".yaml"
66
+ from_yaml(path)
67
+ when ".toml"
68
+ from_toml(path)
69
+ else
70
+ raise InvalidConfigurationError, "Unsupported configuration file format: #{path}"
71
+ end
72
+ end
73
+
74
+ def self.from_yaml(path)
75
+ config = YAML.safe_load(File.read(path), symbolize_names: true)
76
+ new(config)
77
+ rescue StandardError => e
78
+ raise InvalidConfigurationError, "Failed to load YAML configuration: #{e.message}"
79
+ end
80
+
81
+ def self.from_toml(path)
82
+ raise InvalidConfigurationError, "toml-rb gem required to load TOML configuration" unless defined?(TomlRB)
83
+
84
+ config = TomlRB.load_file(path)
85
+ symbolized_config = deep_symbolize_keys(config)
86
+ new(symbolized_config)
87
+ rescue StandardError => e
88
+ raise InvalidConfigurationError, "Failed to load TOML configuration: #{e.message}"
89
+ end
90
+
91
+ def save_to_file(path)
92
+ case File.extname(path)
93
+ when ".yml", ".yaml"
94
+ File.write(path, to_h.to_yaml)
95
+ when ".toml"
96
+ raise InvalidConfigurationError, "toml-rb gem required to save TOML configuration" unless defined?(TomlRB)
97
+
98
+ File.write(path, TomlRB.dump(to_h.stringify_keys))
99
+ else
100
+ raise InvalidConfigurationError, "Unsupported configuration file format: #{path}"
101
+ end
102
+ end
103
+
104
+ # Validation methods
105
+ def valid?
106
+ validate!
107
+ true
108
+ rescue InvalidConfigurationError
109
+ false
110
+ end
111
+
112
+ def postgresql?
113
+ database == "postgresql"
114
+ end
115
+
116
+ def mysql?
117
+ database == "mysql"
118
+ end
119
+
120
+ def sqlite?
121
+ database == "sqlite3"
122
+ end
123
+
124
+ def uses_containers?
125
+ container != "none"
126
+ end
127
+
128
+ def uses_docker_compose?
129
+ container == "docker-compose"
130
+ end
131
+
132
+ def uses_version_manager?
133
+ version_manager != "none"
134
+ end
135
+
136
+ def uses_mise?
137
+ version_manager == "mise"
138
+ end
139
+
140
+ def includes_service?(service)
141
+ additional_services.include?(service.to_s)
142
+ end
143
+
144
+ def database_service_needed?
145
+ uses_containers? && !sqlite?
146
+ end
147
+
148
+ def redis_needed?
149
+ includes_service?("redis") || includes_service?("sidekiq")
150
+ end
151
+
152
+ # Merge configurations (for updating existing configs)
153
+ def merge!(other_config)
154
+ return self unless other_config.is_a?(Configuration)
155
+
156
+ other_config.to_h.each do |key, value|
157
+ public_send("#{key}=", value) if respond_to?("#{key}=") && !value.nil?
158
+ end
159
+
160
+ validate!
161
+ self
162
+ end
163
+
164
+ private
165
+
166
+ def validate!
167
+ validate_database!
168
+ validate_version_manager!
169
+ validate_container!
170
+ validate_additional_services!
171
+ validate_ruby_version!
172
+ validate_app_name!
173
+ validate_compatibility!
174
+ end
175
+
176
+ def validate_database!
177
+ return if SUPPORTED_DATABASES.include?(database)
178
+
179
+ raise UnsupportedDatabaseError, database
180
+ end
181
+
182
+ def validate_version_manager!
183
+ return if SUPPORTED_VERSION_MANAGERS.include?(version_manager)
184
+
185
+ raise UnsupportedVersionManagerError, version_manager
186
+ end
187
+
188
+ def validate_container!
189
+ return if SUPPORTED_CONTAINERS.include?(container)
190
+
191
+ raise UnsupportedContainerError, container
192
+ end
193
+
194
+ def validate_additional_services!
195
+ invalid_services = additional_services - SUPPORTED_ADDITIONAL_SERVICES
196
+ unless invalid_services.empty?
197
+ raise InvalidConfigurationError,
198
+ "Unsupported additional services: #{invalid_services.join(", ")}"
199
+ end
200
+
201
+ # Validate service dependencies
202
+ return unless includes_service?("sidekiq") && !includes_service?("redis")
203
+
204
+ raise ConfigurationConflictError,
205
+ "Sidekiq requires Redis. Please include 'redis' in additional_services"
206
+ end
207
+
208
+ def validate_ruby_version!
209
+ return if ruby_version.match?(/\A\d+\.\d+(\.\d+)?\z/)
210
+
211
+ raise InvalidConfigurationError,
212
+ "Invalid Ruby version format: #{ruby_version}. Expected format: X.Y.Z"
213
+ end
214
+
215
+ def validate_app_name!
216
+ raise InvalidConfigurationError, "App name cannot be empty" if app_name.nil? || app_name.empty?
217
+
218
+ return if app_name.match?(/\A[a-zA-Z0-9_-]+\z/)
219
+
220
+ raise InvalidConfigurationError,
221
+ "App name contains invalid characters. Only alphanumeric, underscore, and hyphen allowed"
222
+ end
223
+
224
+ def validate_compatibility!
225
+ # SQLite doesn't need containers for database
226
+ if sqlite? && uses_containers?
227
+ # This is allowed but warn that database container won't be created
228
+ end
229
+
230
+ # Some services only make sense with containers
231
+ if !uses_containers? && includes_service?("mailcatcher")
232
+ raise ConfigurationConflictError,
233
+ "Mailcatcher service requires containerization (Docker or Docker Compose)"
234
+ end
235
+
236
+ # Version manager compatibility
237
+ nil unless version_manager == "mise" && !uses_containers?
238
+ # Mise can manage database tools natively
239
+ end
240
+
241
+ def detect_app_name
242
+ if File.exist?("config/application.rb")
243
+ # Try to extract from Rails application.rb
244
+ content = File.read("config/application.rb")
245
+ return ::Regexp.last_match(1).downcase if content =~ /module\s+(\w+)/
246
+ end
247
+
248
+ # Fallback to directory name
249
+ File.basename(Dir.pwd).gsub(/[^a-zA-Z0-9]/, "_").downcase
250
+ end
251
+
252
+ def default_database_port
253
+ case database
254
+ when "postgresql" then 5432
255
+ when "mysql" then 3306
256
+ when "sqlite3" then nil
257
+ end
258
+ end
259
+
260
+ def default_database_username
261
+ case database
262
+ when "postgresql"
263
+ uses_containers? ? "postgres" : ENV["USER"] || "postgres"
264
+ when "mysql"
265
+ uses_containers? ? "rails" : "root"
266
+ when "sqlite3" then nil
267
+ end
268
+ end
269
+
270
+ def default_database_password
271
+ case database
272
+ when "postgresql", "mysql"
273
+ uses_containers? ? "password" : ""
274
+ when "sqlite3" then nil
275
+ end
276
+ end
277
+
278
+ def default_database_host
279
+ uses_containers? ? "db" : "localhost"
280
+ end
281
+
282
+ def self.deep_symbolize_keys(hash)
283
+ hash.each_with_object({}) do |(key, value), result|
284
+ new_key = key.to_sym
285
+ new_value = value.is_a?(Hash) ? deep_symbolize_keys(value) : value
286
+ result[new_key] = new_value
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yoker
4
+ module Detectors
5
+ class DatabaseDetector
6
+ def self.detect
7
+ return "postgresql" if gemfile_includes?("pg")
8
+ return "mysql" if gemfile_includes?("mysql2")
9
+ return "sqlite3" if gemfile_includes?("sqlite3")
10
+
11
+ # Check database.yml
12
+ detect_from_database_yml
13
+ end
14
+
15
+ private
16
+
17
+ def self.gemfile_includes?(gem_name)
18
+ return false unless File.exist?("Gemfile")
19
+
20
+ File.read("Gemfile").include?(gem_name)
21
+ end
22
+
23
+ def self.detect_from_database_yml
24
+ return nil unless File.exist?("config/database.yml")
25
+
26
+ db_config = File.read("config/database.yml")
27
+ return "postgresql" if db_config.include?("postgresql") || db_config.include?("adapter: postgresql")
28
+ return "mysql" if db_config.include?("mysql") || db_config.include?("adapter: mysql2")
29
+ return "sqlite3" if db_config.include?("sqlite") || db_config.include?("adapter: sqlite3")
30
+
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yoker
4
+ module Detectors
5
+ class RailsDetector
6
+ def self.rails_app?
7
+ File.exist?("config/application.rb") &&
8
+ File.exist?("Gemfile") &&
9
+ File.read("Gemfile").include?("rails")
10
+ end
11
+
12
+ def self.rails_version
13
+ return nil unless rails_app?
14
+
15
+ # Try to get version from Gemfile.lock first
16
+ if File.exist?("Gemfile.lock")
17
+ gemfile_lock = File.read("Gemfile.lock")
18
+ # Look for the rails gem specifically in the GEM section
19
+ # Format: " rails (8.1.0.beta1)"
20
+ if gemfile_lock =~ /^\s{4}rails\s+\(([^)]+)\)/
21
+ return $1
22
+ end
23
+ end
24
+
25
+ # Fallback to checking Rails constant if available
26
+ begin
27
+ require_relative File.expand_path("config/application", Dir.pwd)
28
+ return Rails::VERSION::STRING if defined?(Rails::VERSION::STRING)
29
+ rescue
30
+ nil
31
+ end
32
+
33
+ nil
34
+ rescue
35
+ nil
36
+ end
37
+
38
+ def self.rails_api_only?
39
+ return false unless rails_app?
40
+
41
+ app_rb = File.read("config/application.rb")
42
+ app_rb.include?("config.api_only = true")
43
+ rescue
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yoker
4
+ module Detectors
5
+ class VersionManagerDetector
6
+ # Preference order for version managers
7
+ PREFERENCE_ORDER = %w[mise rvm rbenv].freeze
8
+
9
+ def self.detect
10
+ # First, check which version managers are actually installed
11
+ installed_managers = detect_installed_managers
12
+
13
+ return "none" if installed_managers.empty?
14
+
15
+ # Check for configuration files that match installed managers
16
+ file_based_detection = detect_from_files
17
+
18
+ # If we found a config file for an installed manager, use it
19
+ return file_based_detection if file_based_detection && installed_managers.include?(file_based_detection)
20
+
21
+ # Otherwise, return the highest priority installed manager
22
+ PREFERENCE_ORDER.each do |manager|
23
+ return manager if installed_managers.include?(manager)
24
+ end
25
+
26
+ "none"
27
+ end
28
+
29
+ def self.detect_installed_managers
30
+ managers = []
31
+
32
+ # Check for mise
33
+ managers << "mise" if command_exists?("mise")
34
+
35
+ # Check for rvm
36
+ managers << "rvm" if command_exists?("rvm")
37
+
38
+ # Check for rbenv
39
+ managers << "rbenv" if command_exists?("rbenv")
40
+
41
+ managers
42
+ end
43
+
44
+ def self.detect_from_files
45
+ # Check for version manager configuration files
46
+ return "mise" if File.exist?("mise.toml") || File.exist?(".mise.toml")
47
+ return "mise" if File.exist?(".tool-versions") # mise/asdf format
48
+ return "rvm" if File.exist?(".rvmrc") || File.exist?(".ruby-gemset")
49
+ # .ruby-version alone suggests rbenv
50
+ return "rbenv" if File.exist?(".ruby-version") && !File.exist?(".ruby-gemset")
51
+
52
+ nil
53
+ end
54
+
55
+ def self.command_exists?(command)
56
+ system("which #{command} > /dev/null 2>&1")
57
+ end
58
+
59
+ def self.current_ruby_version
60
+ RUBY_VERSION
61
+ end
62
+
63
+ def self.ruby_version_from_file
64
+ if File.exist?("mise.toml")
65
+ extract_ruby_from_mise_toml
66
+ elsif File.exist?(".ruby-version")
67
+ File.read(".ruby-version").strip
68
+ elsif File.exist?(".tool-versions")
69
+ extract_ruby_from_tool_versions
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def self.extract_ruby_from_mise_toml
76
+ content = File.read("mise.toml")
77
+ ::Regexp.last_match(1) if content =~ /ruby\s*=\s*["']([^"']+)["']/
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ def self.extract_ruby_from_tool_versions
83
+ content = File.read(".tool-versions")
84
+ ruby_line = content.lines.find { |line| line.start_with?("ruby ") }
85
+ ruby_line&.split&.last&.strip
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yoker
4
+ # Base error class for all Yoker errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when not in a Rails application directory
8
+ class NotRailsAppError < Error
9
+ def initialize(path = Dir.pwd)
10
+ super("Directory '#{path}' is not a Rails application. Please run from Rails app root.")
11
+ end
12
+ end
13
+
14
+ # Raised when required external tools are missing
15
+ class MissingDependencyError < Error
16
+ def initialize(tool, installation_hint = nil)
17
+ message = "Required tool '#{tool}' is not installed or not in PATH."
18
+ message += " #{installation_hint}" if installation_hint
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ # Raised when Docker is not running or accessible
24
+ class DockerNotAvailableError < MissingDependencyError
25
+ def initialize
26
+ super(
27
+ "Docker",
28
+ "Please ensure Docker is installed and running. Visit https://docs.docker.com/get-docker/"
29
+ )
30
+ end
31
+ end
32
+
33
+ # Raised when version manager is not available
34
+ class VersionManagerNotAvailableError < MissingDependencyError
35
+ def initialize(manager)
36
+ hints = {
37
+ "mise" => "Install with: curl https://mise.run | sh",
38
+ "rbenv" => "Install with: brew install rbenv ruby-build",
39
+ "rvm" => "Install with: \\curl -sSL https://get.rvm.io | bash"
40
+ }
41
+
42
+ super(manager, hints[manager])
43
+ end
44
+ end
45
+
46
+ # Raised when configuration is invalid
47
+ class InvalidConfigurationError < Error
48
+ def initialize(message)
49
+ super("Invalid configuration: #{message}")
50
+ end
51
+ end
52
+
53
+ # Raised when unsupported database is specified
54
+ class UnsupportedDatabaseError < InvalidConfigurationError
55
+ def initialize(database)
56
+ supported = %w[postgresql mysql sqlite3]
57
+ super("Database '#{database}' is not supported. Supported: #{supported.join(", ")}")
58
+ end
59
+ end
60
+
61
+ # Raised when unsupported version manager is specified
62
+ class UnsupportedVersionManagerError < InvalidConfigurationError
63
+ def initialize(manager)
64
+ supported = %w[mise rbenv rvm none]
65
+ super("Version manager '#{manager}' is not supported. Supported: #{supported.join(", ")}")
66
+ end
67
+ end
68
+
69
+ # Raised when unsupported container option is specified
70
+ class UnsupportedContainerError < InvalidConfigurationError
71
+ def initialize(container)
72
+ supported = %w[docker-compose docker none]
73
+ super("Container option '#{container}' is not supported. Supported: #{supported.join(", ")}")
74
+ end
75
+ end
76
+
77
+ # Raised when file operations fail
78
+ class FileOperationError < Error
79
+ def initialize(operation, path, reason = nil)
80
+ message = "Failed to #{operation} '#{path}'"
81
+ message += ": #{reason}" if reason
82
+ super(message)
83
+ end
84
+ end
85
+
86
+ # Raised when template is missing or invalid
87
+ class TemplateError < Error
88
+ def initialize(template_path, reason = nil)
89
+ message = "Template error for '#{template_path}'"
90
+ message += ": #{reason}" if reason
91
+ super(message)
92
+ end
93
+ end
94
+
95
+ # Raised when generator fails
96
+ class GeneratorError < Error
97
+ def initialize(generator_name, reason)
98
+ super("Generator '#{generator_name}' failed: #{reason}")
99
+ end
100
+ end
101
+
102
+ # Raised when service fails to start (Docker containers, etc.)
103
+ class ServiceStartupError < Error
104
+ def initialize(service_name, timeout = nil)
105
+ message = "Service '#{service_name}' failed to start"
106
+ message += " within #{timeout} seconds" if timeout
107
+ super(message)
108
+ end
109
+ end
110
+
111
+ # Raised when database operations fail
112
+ class DatabaseError < Error
113
+ def initialize(operation, database, reason = nil)
114
+ message = "Database operation '#{operation}' failed for #{database}"
115
+ message += ": #{reason}" if reason
116
+ super(message)
117
+ end
118
+ end
119
+
120
+ # Raised when Ruby version conflicts occur
121
+ class RubyVersionError < Error
122
+ def initialize(required, current)
123
+ super("Ruby version mismatch. Required: #{required}, Current: #{current}")
124
+ end
125
+ end
126
+
127
+ # Raised when conflicting configurations are detected
128
+ class ConfigurationConflictError < InvalidConfigurationError
129
+ def initialize(conflict_description)
130
+ super("Configuration conflict: #{conflict_description}")
131
+ end
132
+ end
133
+
134
+ # Raised when backup operations fail
135
+ class BackupError < FileOperationError
136
+ def initialize(path, reason = nil)
137
+ super("backup", path, reason)
138
+ end
139
+ end
140
+
141
+ # Raised when network operations fail (downloading, etc.)
142
+ class NetworkError < Error
143
+ def initialize(operation, reason = nil)
144
+ message = "Network operation failed: #{operation}"
145
+ message += " (#{reason})" if reason
146
+ super(message)
147
+ end
148
+ end
149
+ end