discharger 0.2.10 → 0.2.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 123fc77a1effa1f7cd70e68b9056cfb9d9491069edafca3939b40c092cf580fd
4
- data.tar.gz: 9800b64540311c15e0805d835e6f137e39b5751a932ee3106406743b21353d8e
3
+ metadata.gz: 23bf801a204e17e5f37876fba770bc9610814b629ee570924c04414547bc801b
4
+ data.tar.gz: 594b4fceeb396a77ada63a549eee831d72ea6b1c942ff1a1ac813cea877e6c56
5
5
  SHA512:
6
- metadata.gz: f840bfaf4c5db5c4321263d968d7fda42388d978fa49571bf78be219b64afcd52b9e06e870fe6d33fdd54b417104d1646dbbed4bf77ab42c154ff851d68da96c
7
- data.tar.gz: 6dc97ddbfe29459973e3d2eaee4fa299c644112b5d0a3c3c65d566015c7018ba3ae4c0da0265781fc3d6785c0bbfe38bca0bdacbede5584646cafdaa09dff8bc
6
+ metadata.gz: ec6dadb8198acd18144256694bcfdd0cd7c58275e6ad8f2ed6d9bc374633db99e6bf50278fb137a7675af74759e9ecc885dc8450852e2561655b0d5ba6e2cf75
7
+ data.tar.gz: fcf463c7d4e9721b4a94a470a6ee0b75673888cf0f0c9e87fa998f208e0a3a1a88d8338de4add5ae1205f1a643e0ae49b59f814bf28854c5cd78bb5a09b96b0e
data/README.md CHANGED
@@ -58,6 +58,16 @@ $ gem install discharger
58
58
 
59
59
  This gem is managed with [Reissue](https://github.com/SOFware/reissue).
60
60
 
61
+ ### Releasing
62
+
63
+ Releases are automated via GitHub Actions:
64
+
65
+ 1. Go to Actions → "Prepare Release" → Run workflow
66
+ 2. Select version type (major, minor, patch, or custom)
67
+ 3. Review the created PR with version bumps and changelog updates
68
+ 4. Add the `approved-release` label and merge
69
+ 5. The gem will be automatically published to RubyGems.org
70
+
61
71
  Bug reports and pull requests are welcome on GitHub.
62
72
 
63
73
  ## License
data/Rakefile CHANGED
@@ -7,7 +7,9 @@ require "reissue/gem"
7
7
 
8
8
  Reissue::Task.create :reissue do |task|
9
9
  task.version_file = "lib/discharger/version.rb"
10
- task.commit = true
10
+ task.commit = !ENV["GITHUB_ACTIONS"]
11
+ task.commit_finalize = !ENV["GITHUB_ACTIONS"]
12
+ task.push_finalize = :branch
11
13
  end
12
14
 
13
15
  Rake::TestTask.new(:test) do |t|
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_registry"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ class CommandFactory
8
+ attr_reader :config, :app_root, :logger
9
+
10
+ def initialize(config, app_root, logger)
11
+ @config = config
12
+ @app_root = app_root
13
+ @logger = logger
14
+ end
15
+
16
+ def create_command(name)
17
+ command_class = CommandRegistry.get(name)
18
+ return nil unless command_class
19
+
20
+ command_class.new(config, app_root, logger)
21
+ rescue => e
22
+ logger&.warn "Failed to create command #{name}: #{e.message}"
23
+ nil
24
+ end
25
+
26
+ def create_all_commands
27
+ commands = []
28
+
29
+ # Create built-in commands from steps
30
+ if config.steps.any?
31
+ config.steps.each do |step|
32
+ command = create_command(step)
33
+ commands << command if command
34
+ end
35
+ else
36
+ # If no steps specified, create all registered commands
37
+ CommandRegistry.names.each do |name|
38
+ command = create_command(name)
39
+ commands << command if command
40
+ end
41
+ end
42
+
43
+ # Create custom commands
44
+ if config.respond_to?(:custom_steps) && config.custom_steps.any?
45
+ require_relative "commands/custom_command"
46
+ config.custom_steps.each do |step_config|
47
+ command = Commands::CustomCommand.new(config, app_root, logger, step_config)
48
+ commands << command
49
+ end
50
+ end
51
+
52
+ commands
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discharger
4
+ module SetupRunner
5
+ class CommandRegistry
6
+ class << self
7
+ def register(name, command_class)
8
+ commands[name.to_s] = command_class
9
+ end
10
+
11
+ def get(name)
12
+ commands[name.to_s]
13
+ end
14
+
15
+ def all
16
+ commands.values
17
+ end
18
+
19
+ def names
20
+ commands.keys
21
+ end
22
+
23
+ def clear
24
+ commands.clear
25
+ end
26
+
27
+ def load_commands
28
+ # Load base command first
29
+ require_relative "commands/base_command"
30
+
31
+ # Load all command files from the commands directory
32
+ commands_dir = File.expand_path("commands", __dir__)
33
+ Dir.glob(File.join(commands_dir, "*_command.rb")).each do |file|
34
+ require file
35
+ end
36
+
37
+ # Auto-register commands based on naming convention
38
+ Commands.constants.each do |const_name|
39
+ next unless const_name.to_s.end_with?("Command")
40
+
41
+ command_class = Commands.const_get(const_name)
42
+ next unless command_class < Commands::BaseCommand
43
+ next if command_class == Commands::BaseCommand
44
+
45
+ # Convert class name to command name (e.g., AsdfCommand -> asdf)
46
+ command_name = const_name.to_s.sub(/Command$/, "").gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
47
+ register(command_name, command_class)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def commands
54
+ @commands ||= {}
55
+ end
56
+ end
57
+
58
+ # Load and register all built-in commands
59
+ load_commands
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class AsdfCommand < BaseCommand
9
+ def execute
10
+ log "Install tool-versions dependencies via ASDF"
11
+
12
+ unless system_quiet("which asdf")
13
+ log "asdf not installed. Run `brew install asdf` if you want bin/setup to ensure versions are up-to-date"
14
+ return
15
+ end
16
+
17
+ tools_versions_file = File.join(app_root, ".tool-versions")
18
+ return log("No .tool-versions file found") unless File.exist?(tools_versions_file)
19
+
20
+ dependencies = File.read(tools_versions_file).split("\n")
21
+ installables = []
22
+
23
+ # Check for nodejs plugin
24
+ unless system_quiet("asdf plugin list | grep nodejs")
25
+ node_deps = dependencies.select { |item| item.match?(/node/) }
26
+ if node_deps.any?
27
+ ask_to_install "asdf to manage Node JS" do
28
+ installables.concat(node_deps)
29
+ system! "asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git"
30
+ end
31
+ end
32
+ end
33
+
34
+ # Check for ruby plugin
35
+ unless system_quiet("asdf plugin list | grep ruby")
36
+ ruby_deps = dependencies.select { |item| item.match?(/ruby/) }
37
+ if ruby_deps.any?
38
+ ask_to_install "asdf to manage Ruby" do
39
+ installables.concat(ruby_deps)
40
+ system! "asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git"
41
+ end
42
+ end
43
+ end
44
+
45
+ # Install all versions
46
+ installables.each do |name_version|
47
+ system! "asdf install #{name_version}"
48
+ end
49
+ end
50
+
51
+ def can_execute?
52
+ File.exist?(File.join(app_root, ".tool-versions"))
53
+ end
54
+
55
+ def description
56
+ "Install tool versions with asdf"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discharger
4
+ module SetupRunner
5
+ module Commands
6
+ class BaseCommand
7
+ attr_reader :config, :app_root, :logger
8
+
9
+ def initialize(config, app_root, logger)
10
+ @config = config
11
+ @app_root = app_root
12
+ @logger = logger
13
+ end
14
+
15
+ def execute
16
+ raise NotImplementedError, "#{self.class} must implement #execute"
17
+ end
18
+
19
+ def can_execute?
20
+ true
21
+ end
22
+
23
+ def description
24
+ class_name = self.class.name || "AnonymousCommand"
25
+ class_name.demodulize.underscore.humanize
26
+ end
27
+
28
+ protected
29
+
30
+ def log(message, emoji: nil)
31
+ return unless logger
32
+ class_name = self.class.name || "AnonymousCommand"
33
+ prefix = emoji ? "#{emoji} " : ""
34
+ logger.info "#{prefix}[#{class_name.demodulize}] #{message}"
35
+ end
36
+
37
+ def with_spinner(message)
38
+ if ENV["CI"] || ENV["NO_SPINNER"] || !$stdout.tty? || ENV["QUIET_SETUP"]
39
+ result = yield
40
+ # Handle error case when spinner is disabled
41
+ if result.is_a?(Hash) && !result[:success] && result[:raise_error] != false
42
+ raise result[:error]
43
+ end
44
+ return result
45
+ end
46
+
47
+ require "rainbow"
48
+ spinner_chars = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
49
+ spinner_thread = nil
50
+ stop_spinner = false
51
+
52
+ begin
53
+ # Print initial message
54
+ print Rainbow("◯ #{message}").cyan
55
+ $stdout.flush
56
+
57
+ # Start spinner in background thread
58
+ spinner_thread = Thread.new do
59
+ i = 0
60
+ until stop_spinner
61
+ print "\r#{Rainbow(spinner_chars[i % spinner_chars.length]).cyan} #{Rainbow(message).cyan}"
62
+ $stdout.flush
63
+ sleep 0.1
64
+ i += 1
65
+ end
66
+ end
67
+
68
+ # Execute the block
69
+ result = yield
70
+
71
+ # Stop spinner
72
+ stop_spinner = true
73
+ spinner_thread&.join(0.1)
74
+
75
+ # Clear line and print result
76
+ if result.is_a?(Hash)
77
+ if result[:success]
78
+ puts "\r#{Rainbow(result[:message] || "✓").green} #{message}"
79
+ else
80
+ puts "\r#{Rainbow(result[:message] || "✗").red} #{message}"
81
+ raise result[:error] if result[:error] && result[:raise_error] != false
82
+ end
83
+ else
84
+ puts "\r#{Rainbow("✓").green} #{message}"
85
+ end
86
+
87
+ result
88
+ rescue
89
+ stop_spinner = true
90
+ spinner_thread&.join(0.1)
91
+ puts "\r#{Rainbow("✗").red} #{message}"
92
+ raise
93
+ ensure
94
+ stop_spinner = true
95
+ spinner_thread&.kill if spinner_thread&.alive?
96
+ end
97
+ end
98
+
99
+ def simple_action(message)
100
+ return yield if ENV["CI"] || ENV["NO_SPINNER"] || !$stdout.tty? || ENV["QUIET_SETUP"]
101
+
102
+ require "rainbow"
103
+ print Rainbow(" → #{message}...").cyan
104
+ $stdout.flush
105
+
106
+ begin
107
+ result = yield
108
+ puts Rainbow(" ✓").green
109
+ result
110
+ rescue
111
+ puts Rainbow(" ✗").red
112
+ raise
113
+ end
114
+ end
115
+
116
+ def system!(*args)
117
+ require "open3"
118
+ command_str = args.join(" ")
119
+
120
+ # Create a more readable message for the spinner
121
+ spinner_message = if command_str.length > 80
122
+ if args.first.is_a?(Hash)
123
+ # Skip env hash in display
124
+ cmd_args = args[1..]
125
+ base_cmd = cmd_args.take(3).join(" ")
126
+ else
127
+ base_cmd = args.take(3).join(" ")
128
+ end
129
+ "Executing #{base_cmd}..."
130
+ else
131
+ "Executing #{command_str}"
132
+ end
133
+
134
+ result = with_spinner(spinner_message) do
135
+ stdout, stderr, status = Open3.capture3(*args)
136
+
137
+ if status.success?
138
+ # Log output if there is any (for debugging)
139
+ logger&.debug("Output: #{stdout}") if stdout && !stdout.empty?
140
+ {success: true, message: "✓"}
141
+ elsif args.first.to_s.include?("docker")
142
+ logger&.debug("Error: #{stderr}") if stderr && !stderr.empty?
143
+ {success: false, message: "✗ (Docker command failed)", raise_error: false}
144
+ else
145
+ {success: false, message: "✗", error: "#{command_str} failed: #{stderr}"}
146
+ end
147
+ end
148
+
149
+ # Handle the case when spinner is disabled
150
+ if result.is_a?(Hash) && !result[:success] && result[:raise_error] != false
151
+ raise result[:error]
152
+ end
153
+
154
+ result
155
+ end
156
+
157
+ def system_quiet(*args)
158
+ require "open3"
159
+ stdout, _stderr, status = Open3.capture3(*args)
160
+ logger&.debug("Quietly executed #{args.join(" ")} - success: #{status.success?}")
161
+ logger&.debug("Output: #{stdout}") if stdout && !stdout.empty? && logger
162
+ status.success?
163
+ end
164
+
165
+ def ask_to_install(description)
166
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
167
+ puts "You do not currently use #{description}.\n ===> If you want to, type Y\nOtherwise hit any key to ignore."
168
+ end
169
+ if gets.chomp == "Y"
170
+ yield
171
+ end
172
+ end
173
+
174
+ def proceed_with(task)
175
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
176
+ puts "Proceed with #{task}?\n ===> Type Y to proceed\nOtherwise hit any key to ignore."
177
+ end
178
+ if gets.chomp == "Y"
179
+ yield
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class BrewCommand < BaseCommand
9
+ def execute
10
+ proceed_with "brew bundle" do
11
+ log "Ensuring brew dependencies"
12
+ system! "brew bundle"
13
+ end
14
+ end
15
+
16
+ def can_execute?
17
+ File.exist?("Brewfile")
18
+ end
19
+
20
+ def description
21
+ "Install Homebrew dependencies"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class BundlerCommand < BaseCommand
9
+ def execute
10
+ log "Installing dependencies"
11
+ system! "gem install bundler --conservative"
12
+ system_quiet("bundle check") || system!("bundle install")
13
+ end
14
+
15
+ def can_execute?
16
+ File.exist?("Gemfile")
17
+ end
18
+
19
+ def description
20
+ "Install Ruby dependencies"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require "fileutils"
5
+
6
+ module Discharger
7
+ module SetupRunner
8
+ module Commands
9
+ class ConfigCommand < BaseCommand
10
+ def execute
11
+ log "Ensuring configuration files are present"
12
+
13
+ # Copy database.yml if needed
14
+ database_yml = File.join(app_root, "config/database.yml")
15
+ database_yml_example = File.join(app_root, "config/database.yml.example")
16
+
17
+ if !File.exist?(database_yml) && File.exist?(database_yml_example)
18
+ FileUtils.cp(database_yml_example, database_yml)
19
+ log "Copied config/database.yml.example to config/database.yml"
20
+ end
21
+
22
+ # Copy Procfile.dev to Procfile if needed
23
+ procfile = File.join(app_root, "Procfile")
24
+ procfile_dev = File.join(app_root, "Procfile.dev")
25
+
26
+ if !File.exist?(procfile) && File.exist?(procfile_dev)
27
+ FileUtils.cp(procfile_dev, procfile)
28
+ log "Copied Procfile.dev to Procfile"
29
+ end
30
+
31
+ # Copy any other example config files
32
+ Dir.glob(File.join(app_root, "config/**/*.example")).each do |example_file|
33
+ config_file = example_file.sub(/\.example$/, "")
34
+ unless File.exist?(config_file)
35
+ FileUtils.cp(example_file, config_file)
36
+ log "Copied #{example_file.sub(app_root + "/", "")} to #{config_file.sub(app_root + "/", "")}"
37
+ end
38
+ end
39
+ end
40
+
41
+ def can_execute?
42
+ true
43
+ end
44
+
45
+ def description
46
+ "Setup configuration files"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../condition_evaluator"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
8
+ class CustomCommand < BaseCommand
9
+ attr_reader :step_config
10
+
11
+ def initialize(config, app_root, logger, step_config)
12
+ super(config, app_root, logger)
13
+ @step_config = step_config
14
+ end
15
+
16
+ def execute
17
+ command = step_config["command"]
18
+ description = step_config["description"] || command
19
+ condition = step_config["condition"]
20
+
21
+ # Check condition if provided using safe evaluator
22
+ if condition && !ConditionEvaluator.evaluate(condition)
23
+ log "Skipping #{description} (condition not met)"
24
+ return
25
+ end
26
+
27
+ log "Running: #{description}"
28
+ system!(command)
29
+ end
30
+
31
+ def can_execute?
32
+ step_config["command"].present?
33
+ end
34
+
35
+ def description
36
+ step_config["description"] || "Custom command: #{step_config["command"]}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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