discharger 0.2.10 → 0.2.12

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +75 -26
  3. data/Rakefile +3 -1
  4. data/lib/discharger/setup_runner/command_factory.rb +56 -0
  5. data/lib/discharger/setup_runner/command_registry.rb +62 -0
  6. data/lib/discharger/setup_runner/commands/asdf_command.rb +61 -0
  7. data/lib/discharger/setup_runner/commands/base_command.rb +185 -0
  8. data/lib/discharger/setup_runner/commands/brew_command.rb +26 -0
  9. data/lib/discharger/setup_runner/commands/bundler_command.rb +25 -0
  10. data/lib/discharger/setup_runner/commands/config_command.rb +51 -0
  11. data/lib/discharger/setup_runner/commands/custom_command.rb +41 -0
  12. data/lib/discharger/setup_runner/commands/database_command.rb +111 -0
  13. data/lib/discharger/setup_runner/commands/docker_command.rb +100 -0
  14. data/lib/discharger/setup_runner/commands/env_command.rb +42 -0
  15. data/lib/discharger/setup_runner/commands/git_command.rb +45 -0
  16. data/lib/discharger/setup_runner/commands/yarn_command.rb +45 -0
  17. data/lib/discharger/setup_runner/condition_evaluator.rb +130 -0
  18. data/lib/discharger/setup_runner/configuration.rb +71 -0
  19. data/lib/discharger/setup_runner/runner.rb +111 -0
  20. data/lib/discharger/setup_runner/version.rb +7 -0
  21. data/lib/discharger/setup_runner.rb +55 -0
  22. data/lib/discharger/task.rb +2 -0
  23. data/lib/discharger/version.rb +1 -1
  24. data/lib/discharger.rb +1 -0
  25. data/lib/generators/discharger/install/install_generator.rb +14 -0
  26. data/lib/generators/discharger/install/templates/setup +36 -0
  27. data/lib/generators/discharger/install/templates/setup.yml +60 -0
  28. metadata +47 -1
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require "open3"
5
+
6
+ module Discharger
7
+ module SetupRunner
8
+ module Commands
9
+ class DatabaseCommand < BaseCommand
10
+ def execute
11
+ # Drop and recreate development database
12
+ terminate_database_connections
13
+ with_spinner("Dropping and recreating development database") do
14
+ _stdout, stderr, status = Open3.capture3("bash", "-c", "bin/rails db:drop db:create > /dev/null 2>&1")
15
+ if status.success?
16
+ {success: true}
17
+ else
18
+ {success: false, error: "Failed to drop/create database: #{stderr}"}
19
+ end
20
+ end
21
+
22
+ # Load schema and run migrations
23
+ with_spinner("Loading database schema and running migrations") do
24
+ _stdout, stderr, status = Open3.capture3("bin/rails db:schema:load db:migrate")
25
+ if status.success?
26
+ {success: true}
27
+ else
28
+ {success: false, error: "Failed to load schema: #{stderr}"}
29
+ end
30
+ end
31
+
32
+ # Seed the database
33
+ env = (config.respond_to?(:seed_env) && config.seed_env) ? {"SEED_DEV_ENV" => "true"} : {}
34
+ with_spinner("Seeding the database") do
35
+ _stdout, stderr, status = Open3.capture3(env, "bin/rails db:seed")
36
+ if status.success?
37
+ {success: true}
38
+ else
39
+ {success: false, error: "Failed to seed database: #{stderr}"}
40
+ end
41
+ end
42
+
43
+ # Setup test database
44
+ terminate_database_connections("test")
45
+ with_spinner("Setting up test database") do
46
+ _stdout, stderr, status = Open3.capture3({"RAILS_ENV" => "test"}, "bash", "-c", "bin/rails db:drop db:create db:schema:load > /dev/null 2>&1")
47
+ if status.success?
48
+ {success: true}
49
+ else
50
+ {success: false, error: "Failed to setup test database: #{stderr}"}
51
+ end
52
+ end
53
+
54
+ # Clear logs and temp files
55
+ with_spinner("Clearing logs and temp files") do
56
+ _stdout, _stderr, status = Open3.capture3("bash", "-c", "bin/rails log:clear tmp:clear > /dev/null 2>&1")
57
+ if status.success?
58
+ else
59
+ # Don't fail for log clearing
60
+ end
61
+ {success: true}
62
+ end
63
+ end
64
+
65
+ def can_execute?
66
+ File.exist?(File.join(app_root, "bin/rails"))
67
+ end
68
+
69
+ def description
70
+ "Setup database"
71
+ end
72
+
73
+ private
74
+
75
+ def terminate_database_connections(rails_env = nil)
76
+ # Use a Rails runner to terminate connections within the Rails context
77
+ env_vars = rails_env ? {"RAILS_ENV" => rails_env} : {}
78
+
79
+ runner_script = <<~RUBY
80
+ begin
81
+ # Only proceed if using PostgreSQL
82
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.connection.adapter_name =~ /postgresql/i
83
+ ActiveRecord::Base.connection.execute <<-SQL
84
+ SELECT pg_terminate_backend(pid)
85
+ FROM pg_stat_activity
86
+ WHERE datname = current_database() AND pid <> pg_backend_pid();
87
+ SQL
88
+ end
89
+ rescue => e
90
+ # If we can't connect or terminate, that's okay - the database might not exist yet
91
+ # Log error silently in test environment
92
+ puts "Note: Could not terminate existing connections: \#{e.message}" unless ENV['QUIET_SETUP']
93
+ end
94
+ RUBY
95
+
96
+ with_spinner("Terminating existing database connections#{rails_env ? " (#{rails_env})" : ""}") do
97
+ stdout, stderr, status = Open3.capture3(env_vars, "bin/rails", "runner", runner_script)
98
+
99
+ if status.success?
100
+ logger&.debug("Output: #{stdout}") if stdout && !stdout.empty?
101
+ elsif stderr && !stderr.empty?
102
+ logger&.debug("Error: #{stderr}")
103
+ # Don't fail if we can't terminate connections - the database might not exist
104
+ end
105
+ {success: true}
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class DockerCommand < BaseCommand
9
+ def execute
10
+ log "Ensure Docker is running"
11
+
12
+ unless system_quiet("docker info > /dev/null 2>&1")
13
+ log "Starting Docker..."
14
+ system_quiet("open -a Docker")
15
+ sleep 10
16
+ unless system_quiet("docker info > /dev/null 2>&1")
17
+ log "Docker is not running. Please start Docker manually."
18
+ return
19
+ end
20
+ end
21
+
22
+ # Setup database container if configured
23
+ if config.respond_to?(:database) && config.database
24
+ setup_container(
25
+ name: config.database.name || "db-app",
26
+ port: config.database.port || 5432,
27
+ image: "postgres:#{config.database.version || "14"}",
28
+ env: {"POSTGRES_PASSWORD" => config.database.password || "postgres"},
29
+ volume: "#{config.database.name || "db-app"}:/var/lib/postgresql/data",
30
+ internal_port: 5432
31
+ )
32
+ end
33
+
34
+ # Setup Redis container if configured
35
+ if config.respond_to?(:redis) && config.redis
36
+ setup_container(
37
+ name: config.redis.name || "redis-app",
38
+ port: config.redis.port || 6379,
39
+ image: "redis:#{config.redis.version || "latest"}",
40
+ internal_port: 6379
41
+ )
42
+ end
43
+ end
44
+
45
+ def can_execute?
46
+ # Only execute if Docker is available and containers are configured
47
+ system_quiet("which docker") && (
48
+ (config.respond_to?(:database) && config.database) ||
49
+ (config.respond_to?(:redis) && config.redis)
50
+ )
51
+ end
52
+
53
+ def description
54
+ "Setup Docker containers"
55
+ end
56
+
57
+ private
58
+
59
+ def setup_container(name:, port:, image:, internal_port:, env: {}, volume: nil)
60
+ log "Checking #{name} container"
61
+
62
+ if system_quiet("docker ps | grep #{name} > /dev/null 2>&1")
63
+ log "#{name} container is already running"
64
+ return
65
+ end
66
+
67
+ # Check if container exists but is stopped
68
+ if system_quiet("docker inspect #{name} > /dev/null 2>&1")
69
+ log "Starting existing #{name} container"
70
+ unless system_quiet("docker start #{name}")
71
+ log "Removing failed #{name} container"
72
+ system_quiet("docker rm -f #{name}")
73
+ create_container(name: name, port: port, image: image, env: env, volume: volume, internal_port: internal_port)
74
+ end
75
+ else
76
+ create_container(name: name, port: port, image: image, env: env, volume: volume, internal_port: internal_port)
77
+ end
78
+
79
+ # Verify container is running
80
+ sleep 2
81
+ unless system_quiet("docker ps | grep #{name} > /dev/null 2>&1")
82
+ log "#{name} container failed to start"
83
+ raise "#{name} container failed to start"
84
+ end
85
+ end
86
+
87
+ def create_container(name:, port:, image:, internal_port:, env: {}, volume: nil)
88
+ log "Creating new #{name} container"
89
+
90
+ cmd = ["docker", "run", "-d", "--name", name, "-p", "#{port}:#{internal_port}"]
91
+ env.each { |k, v| cmd.push("-e", "#{k}=#{v}") }
92
+ cmd.push("-v", volume) if volume
93
+ cmd.push(image)
94
+
95
+ system!(*cmd)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "base_command"
5
+
6
+ module Discharger
7
+ module SetupRunner
8
+ module Commands
9
+ class EnvCommand < BaseCommand
10
+ def execute
11
+ if File.exist?(".env")
12
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
13
+ require "rainbow"
14
+ puts Rainbow(" → .env file already exists. Skipping.").yellow
15
+ end
16
+ return
17
+ end
18
+
19
+ unless File.exist?(".env.example")
20
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
21
+ require "rainbow"
22
+ puts Rainbow(" → WARNING: .env.example not found. Skipping .env creation").yellow
23
+ end
24
+ return
25
+ end
26
+
27
+ simple_action("Creating .env from .env.example") do
28
+ FileUtils.cp(".env.example", ".env")
29
+ end
30
+ end
31
+
32
+ def can_execute?
33
+ File.exist?(".env.example")
34
+ end
35
+
36
+ def description
37
+ "Setup environment file"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class GitCommand < BaseCommand
9
+ def execute
10
+ log "Setting up git configuration"
11
+
12
+ # Set up commit template if it exists
13
+ commit_template = File.join(app_root, ".commit-template")
14
+ if File.exist?(commit_template)
15
+ system! "git config --local commit.template .commit-template"
16
+ log "Git commit template configured"
17
+ end
18
+
19
+ # Set up git hooks if .githooks directory exists
20
+ githooks_dir = File.join(app_root, ".githooks")
21
+ if File.directory?(githooks_dir)
22
+ system! "git config --local core.hooksPath .githooks"
23
+ log "Git hooks path configured"
24
+ end
25
+
26
+ # Any other git config from the setup.yml
27
+ if config.respond_to?(:git_config) && config.git_config
28
+ config.git_config.each do |key, value|
29
+ system! "git config --local #{key} '#{value}'"
30
+ log "Set git config #{key}"
31
+ end
32
+ end
33
+ end
34
+
35
+ def can_execute?
36
+ File.directory?(File.join(app_root, ".git"))
37
+ end
38
+
39
+ def description
40
+ "Setup git configuration"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class YarnCommand < BaseCommand
9
+ def execute
10
+ log "Installing Node modules"
11
+
12
+ # Enable corepack if yarn.lock exists (Yarn 2+)
13
+ if File.exist?(File.join(app_root, "yarn.lock"))
14
+ if system_quiet("which corepack")
15
+ system! "corepack enable"
16
+ system! "corepack use yarn@stable"
17
+ end
18
+
19
+ # Install dependencies
20
+ system_quiet("yarn check --check-files > /dev/null 2>&1") || system!("yarn install")
21
+ elsif File.exist?(File.join(app_root, "package-lock.json"))
22
+ # NPM project
23
+ log "Found package-lock.json, using npm"
24
+ system! "npm ci"
25
+ elsif File.exist?(File.join(app_root, "package.json"))
26
+ # Generic package.json - try yarn first, fall back to npm
27
+ if system_quiet("which yarn")
28
+ system! "yarn install"
29
+ else
30
+ system! "npm install"
31
+ end
32
+ end
33
+ end
34
+
35
+ def can_execute?
36
+ File.exist?(File.join(app_root, "package.json"))
37
+ end
38
+
39
+ def description
40
+ "Install JavaScript dependencies"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ class ConditionEvaluator
8
+ class << self
9
+ def evaluate(condition, context = {})
10
+ return true if condition.nil? || condition.strip.empty?
11
+ ast = Prism.parse(condition).value
12
+ raise "Parse error" unless ast
13
+ evaluate_node(ast)
14
+ rescue => e
15
+ log_warning("Condition evaluation failed: #{e.message}")
16
+ false
17
+ end
18
+
19
+ private
20
+
21
+ def evaluate_node(node)
22
+ case node.type
23
+ when :program_node
24
+ # Evaluate the first statement in the program
25
+ stmts = node.statements
26
+ if stmts&.body&.any?
27
+ evaluate_node(stmts.body.first)
28
+ else
29
+ true
30
+ end
31
+ when :statements_node
32
+ # Evaluate the first statement
33
+ if node.body.any?
34
+ evaluate_node(node.body.first)
35
+ else
36
+ true
37
+ end
38
+ when :and_node
39
+ left = evaluate_node(node.left)
40
+ right = evaluate_node(node.right)
41
+ left && right
42
+ when :or_node
43
+ left = evaluate_node(node.left)
44
+ right = evaluate_node(node.right)
45
+ left || right
46
+ when :call_node
47
+ # Handle method calls
48
+ if node.receiver&.type == :constant_read_node
49
+ case node.receiver.name
50
+ when :ENV
51
+ if node.name == :[]
52
+ ENV[evaluate_node(node.arguments.arguments.first)]
53
+ else
54
+ raise "Unsafe ENV method: #{node.name}"
55
+ end
56
+ when :File
57
+ case node.name
58
+ when :exist?
59
+ File.exist?(evaluate_node(node.arguments.arguments.first))
60
+ when :directory?
61
+ File.directory?(evaluate_node(node.arguments.arguments.first))
62
+ when :file?
63
+ File.file?(evaluate_node(node.arguments.arguments.first))
64
+ else
65
+ raise "Unsafe File method: #{node.name}"
66
+ end
67
+ when :Dir
68
+ if node.name == :exist?
69
+ Dir.exist?(evaluate_node(node.arguments.arguments.first))
70
+ else
71
+ raise "Unsafe Dir method: #{node.name}"
72
+ end
73
+ else
74
+ raise "Unsafe method call: #{node.receiver.name}.#{node.name}"
75
+ end
76
+ elsif node.receiver&.type == :call_node
77
+ # Handle chained calls like ENV['FOO'] == 'bar'
78
+ if node.name == :==
79
+ left = evaluate_node(node.receiver)
80
+ right = evaluate_node(node.arguments.arguments.first)
81
+ left == right
82
+ elsif node.name == :!=
83
+ left = evaluate_node(node.receiver)
84
+ right = evaluate_node(node.arguments.arguments.first)
85
+ left != right
86
+ else
87
+ raise "Unsafe operator: #{node.name}"
88
+ end
89
+ elsif node.receiver.nil?
90
+ # Method call without receiver (like system)
91
+ raise "Unsafe method call: #{node.name}"
92
+ else
93
+ raise "Unsafe method call: #{node.receiver&.name}.#{node.name}"
94
+ end
95
+ when :constant_read_node
96
+ node.name
97
+ when :string_node
98
+ node.unescaped
99
+ when :true_node # standard:disable Lint/BooleanSymbol
100
+ true
101
+ when :false_node # standard:disable Lint/BooleanSymbol
102
+ false
103
+ when :array_node
104
+ node.elements.map { |el| evaluate_node(el) }
105
+ when :symbol_node
106
+ node.unescaped
107
+ when :integer_node
108
+ node.value
109
+ when :x_string_node
110
+ # Backtick commands - block for security
111
+ raise "Unsafe backtick command"
112
+ when :parentheses_node
113
+ # Evaluate the expression inside parentheses
114
+ evaluate_node(node.body)
115
+ else
116
+ raise "Unsafe node: #{node.type}"
117
+ end
118
+ end
119
+
120
+ def log_warning(message)
121
+ if defined?(Rails)
122
+ Rails.logger.warn(message)
123
+ else
124
+ warn("[SetupRunner] #{message}")
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ class Configuration
8
+ attr_accessor :app_name, :db_config, :redis_config, :services, :steps, :custom_steps
9
+
10
+ def initialize
11
+ @app_name = "Application"
12
+ @db_config = DatabaseConfig.new
13
+ @redis_config = RedisConfig.new
14
+ @services = []
15
+ @steps = []
16
+ @custom_steps = []
17
+ end
18
+
19
+ def self.from_file(path)
20
+ config = new
21
+ yaml = YAML.load_file(path)
22
+
23
+ # Handle empty YAML files
24
+ return config if yaml.nil? || yaml == false
25
+
26
+ config.app_name = yaml["app_name"] if yaml["app_name"]
27
+ config.db_config.from_hash(yaml["database"]) if yaml["database"]
28
+ config.redis_config.from_hash(yaml["redis"]) if yaml["redis"]
29
+ config.services = yaml["services"] || []
30
+ config.steps = yaml["steps"] || []
31
+ config.custom_steps = yaml["custom_steps"] || []
32
+
33
+ config
34
+ end
35
+ end
36
+
37
+ class DatabaseConfig
38
+ attr_accessor :port, :name, :version, :password
39
+
40
+ def initialize
41
+ @port = 5432
42
+ @name = "db-app"
43
+ @version = "14"
44
+ @password = "postgres"
45
+ end
46
+
47
+ def from_hash(hash)
48
+ @port = hash["port"] if hash["port"]
49
+ @name = hash["name"] if hash["name"]
50
+ @version = hash["version"] if hash["version"]
51
+ @password = hash["password"] if hash["password"]
52
+ end
53
+ end
54
+
55
+ class RedisConfig
56
+ attr_accessor :port, :name, :version
57
+
58
+ def initialize
59
+ @port = 6379
60
+ @name = "redis-app"
61
+ @version = "latest"
62
+ end
63
+
64
+ def from_hash(hash)
65
+ @port = hash["port"] if hash["port"]
66
+ @name = hash["name"] if hash["name"]
67
+ @version = hash["version"] if hash["version"]
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "logger"
5
+ require_relative "command_factory"
6
+
7
+ module Discharger
8
+ module SetupRunner
9
+ class Error < StandardError; end
10
+
11
+ class Runner
12
+ attr_reader :config, :app_root, :logger, :command_factory
13
+
14
+ def initialize(config, app_root = nil, logger = nil)
15
+ @config = config
16
+ @app_root = app_root || Dir.pwd
17
+ @logger = logger || Logger.new($stdout)
18
+ @command_factory = CommandFactory.new(config, app_root, logger)
19
+ end
20
+
21
+ def run
22
+ require "rainbow"
23
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
24
+ puts Rainbow("\nšŸš€ Starting setup for #{config.app_name}").bright.blue
25
+ puts Rainbow("=" * 50).blue
26
+ end
27
+
28
+ FileUtils.chdir app_root do
29
+ execute_commands
30
+ end
31
+
32
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
33
+ puts Rainbow("\nāœ… Setup completed successfully!").bright.green
34
+ end
35
+ rescue => e
36
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
37
+ puts Rainbow("\nāŒ Setup failed: #{e.message}").bright.red
38
+ end
39
+ raise Error, e.message
40
+ end
41
+
42
+ def add_command(command)
43
+ commands << command
44
+ end
45
+
46
+ def remove_command(command_name)
47
+ commands.reject! { |cmd| cmd.class.name.demodulize.underscore == command_name.to_s }
48
+ end
49
+
50
+ def replace_command(command_name, new_command)
51
+ remove_command(command_name)
52
+ add_command(new_command)
53
+ end
54
+
55
+ def insert_command_before(target_command_name, new_command)
56
+ target_index = commands.find_index { |cmd| cmd.class.name.demodulize.underscore == target_command_name.to_s }
57
+ if target_index
58
+ commands.insert(target_index, new_command)
59
+ else
60
+ add_command(new_command)
61
+ end
62
+ end
63
+
64
+ def insert_command_after(target_command_name, new_command)
65
+ target_index = commands.find_index { |cmd| cmd.class.name.demodulize.underscore == target_command_name.to_s }
66
+ if target_index
67
+ commands.insert(target_index + 1, new_command)
68
+ else
69
+ add_command(new_command)
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def commands
76
+ @commands ||= command_factory.create_all_commands
77
+ end
78
+
79
+ def execute_commands
80
+ commands.each do |command|
81
+ execute_command(command)
82
+ end
83
+ end
84
+
85
+ def execute_command(command)
86
+ unless command.can_execute?
87
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
88
+ require "rainbow"
89
+ puts Rainbow("ā­ļø Skipping #{command.description} (prerequisites not met)").yellow
90
+ end
91
+ return
92
+ end
93
+
94
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
95
+ puts Rainbow("\nā–¶ļø #{command.description}").bright
96
+ end
97
+ command.execute
98
+ rescue => e
99
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
100
+ require "rainbow"
101
+ puts Rainbow("āŒ Command #{command.description} failed: #{e.message}").red
102
+ end
103
+ raise e
104
+ end
105
+
106
+ def log(message)
107
+ logger.info message
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discharger
4
+ module SetupRunner
5
+ VERSION = "0.1.0"
6
+ end
7
+ end