bruh 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/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/exe/haskbrew +7 -0
- data/lib/bruh/bottle.rb +209 -0
- data/lib/bruh/cabal.rb +77 -0
- data/lib/bruh/changelog.rb +62 -0
- data/lib/bruh/cli.rb +191 -0
- data/lib/bruh/config.rb +134 -0
- data/lib/bruh/hackage.rb +159 -0
- data/lib/bruh/homebrew.rb +223 -0
- data/lib/bruh/version.rb +5 -0
- data/lib/bruh.rb +68 -0
- metadata +242 -0
data/lib/bruh/cli.rb
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thor'
|
4
|
+
require 'bruh/version'
|
5
|
+
require 'bruh/cabal'
|
6
|
+
require 'bruh/hackage'
|
7
|
+
require 'bruh/homebrew'
|
8
|
+
require 'bruh/bottle'
|
9
|
+
require 'bruh/config'
|
10
|
+
require 'bruh/changelog'
|
11
|
+
|
12
|
+
module Bruh
|
13
|
+
# CLI interface for Bruh commands
|
14
|
+
class CLI < Thor
|
15
|
+
desc 'version', 'Display Bruh version'
|
16
|
+
def version
|
17
|
+
puts "Bruh version #{Bruh::VERSION}"
|
18
|
+
end
|
19
|
+
|
20
|
+
desc 'release', 'Release a new version of a Haskell package to Hackage and Homebrew'
|
21
|
+
option :non_interactive, type: :boolean, desc: 'Run in non-interactive mode (for CI, testing)'
|
22
|
+
option :version, type: :string, desc: 'Version to release (for non-interactive mode)'
|
23
|
+
option :skip_hackage, type: :boolean, desc: 'Skip uploading to Hackage'
|
24
|
+
option :skip_bottles, type: :boolean, desc: 'Skip building Homebrew bottles'
|
25
|
+
option :skip_github, type: :boolean, desc: 'Skip uploading to GitHub'
|
26
|
+
def release
|
27
|
+
interactive = !options[:non_interactive]
|
28
|
+
|
29
|
+
project_root = Dir.pwd
|
30
|
+
cabal_file = find_cabal_file(project_root)
|
31
|
+
|
32
|
+
if cabal_file.nil?
|
33
|
+
puts 'Error: No .cabal file found in current directory or subdirectories'
|
34
|
+
exit 1
|
35
|
+
end
|
36
|
+
|
37
|
+
cabal = Cabal.new(cabal_file)
|
38
|
+
|
39
|
+
current_version = cabal.version
|
40
|
+
puts "Current version: #{current_version}"
|
41
|
+
|
42
|
+
# Determine new version
|
43
|
+
if options[:version] && !options[:version].empty?
|
44
|
+
new_version = options[:version]
|
45
|
+
elsif interactive
|
46
|
+
suggested_version = increment_version(current_version)
|
47
|
+
new_version = prompt('Enter new version', suggested_version)
|
48
|
+
else
|
49
|
+
new_version = increment_version(current_version)
|
50
|
+
end
|
51
|
+
|
52
|
+
puts "Preparing release for version #{new_version}"
|
53
|
+
|
54
|
+
# Update cabal version
|
55
|
+
cabal.update_version(new_version)
|
56
|
+
puts "Updated .cabal file to version #{new_version}"
|
57
|
+
|
58
|
+
# Update changelog
|
59
|
+
if File.exist?('CHANGELOG.md')
|
60
|
+
Changelog.update(new_version, project_root, interactive)
|
61
|
+
puts 'Updated CHANGELOG.md with new version entry'
|
62
|
+
end
|
63
|
+
|
64
|
+
# Run tests
|
65
|
+
puts 'Running tests to verify everything works...'
|
66
|
+
unless system('bundle exec rake test')
|
67
|
+
puts 'Tests failed. Aborting release process.'
|
68
|
+
exit 1 unless yes_no_prompt('Continue anyway?', default_no: false) && interactive
|
69
|
+
end
|
70
|
+
|
71
|
+
# Commit changes if in interactive mode
|
72
|
+
if interactive && yes_no_prompt('Commit version bump and changelog?', default_no: true)
|
73
|
+
system("git add #{cabal_file} CHANGELOG.md")
|
74
|
+
system("git commit -m \"Bump version to #{new_version}\"")
|
75
|
+
|
76
|
+
system('git push origin main') if yes_no_prompt('Push changes to origin?', default_no: true)
|
77
|
+
|
78
|
+
if yes_no_prompt("Create tag v#{new_version}?", default_no: true)
|
79
|
+
if system("git rev-parse v#{new_version} >/dev/null 2>&1")
|
80
|
+
if yes_no_prompt('Tag already exists. Update it?', default_no: true)
|
81
|
+
system("git tag -fa v#{new_version} -m \"Release version #{new_version}\"")
|
82
|
+
end
|
83
|
+
else
|
84
|
+
system("git tag -a v#{new_version} -m \"Release version #{new_version}\"")
|
85
|
+
end
|
86
|
+
|
87
|
+
system("git push origin v#{new_version}") if yes_no_prompt('Push tag to origin?', default_no: true)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Upload to Hackage
|
92
|
+
unless options[:skip_hackage]
|
93
|
+
puts 'Preparing for Hackage release...'
|
94
|
+
hackage = Hackage.new(interactive)
|
95
|
+
|
96
|
+
if interactive
|
97
|
+
uploaded = hackage.publish(new_version)
|
98
|
+
else
|
99
|
+
# In non-interactive mode, use credentials from config
|
100
|
+
config = Config.load
|
101
|
+
uploaded = hackage.publish_non_interactive(new_version, config[:hackage_username], config[:hackage_password])
|
102
|
+
end
|
103
|
+
|
104
|
+
if uploaded
|
105
|
+
puts 'Successfully published to Hackage!'
|
106
|
+
|
107
|
+
# Calculate package SHA for Homebrew
|
108
|
+
sha256 = hackage.calculate_package_sha256(new_version)
|
109
|
+
|
110
|
+
# Update Homebrew formula
|
111
|
+
if sha256
|
112
|
+
puts 'Updating Homebrew formula...'
|
113
|
+
formula = Homebrew.new(interactive)
|
114
|
+
formula.update(new_version, sha256)
|
115
|
+
|
116
|
+
# Build bottle if requested
|
117
|
+
unless options[:skip_bottles]
|
118
|
+
puts 'Building Homebrew bottle...'
|
119
|
+
bottle = Bottle.new(interactive)
|
120
|
+
bottle_info = bottle.build(new_version)
|
121
|
+
|
122
|
+
if bottle_info && !options[:skip_github]
|
123
|
+
puts 'Uploading bottle to GitHub...'
|
124
|
+
bottle.upload_to_github(new_version, bottle_info)
|
125
|
+
|
126
|
+
puts 'Updating formula with bottle information...'
|
127
|
+
formula.update_bottle_info(bottle_info)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
puts 'Release process complete!'
|
135
|
+
end
|
136
|
+
|
137
|
+
desc 'config_setup', 'Set up configuration'
|
138
|
+
def config_setup
|
139
|
+
Config.setup_credentials(true)
|
140
|
+
puts 'Configuration setup complete.'
|
141
|
+
end
|
142
|
+
|
143
|
+
desc 'config_get', 'Get a configuration value'
|
144
|
+
def config_get(key)
|
145
|
+
value = Config.get(key.to_sym)
|
146
|
+
puts "#{key}: #{value}"
|
147
|
+
end
|
148
|
+
|
149
|
+
desc 'config_set', 'Set a configuration value'
|
150
|
+
def config_set(key, value)
|
151
|
+
Config.set(key.to_sym, value)
|
152
|
+
puts "Set #{key} = #{value}"
|
153
|
+
end
|
154
|
+
|
155
|
+
private
|
156
|
+
|
157
|
+
def find_cabal_file(dir)
|
158
|
+
Dir.glob("#{dir}/**/*.cabal").first
|
159
|
+
end
|
160
|
+
|
161
|
+
def increment_version(version)
|
162
|
+
if version =~ /(\d+)\.(\d+)\.(\d+)/
|
163
|
+
major = ::Regexp.last_match(1).to_i
|
164
|
+
minor = ::Regexp.last_match(2).to_i
|
165
|
+
patch = ::Regexp.last_match(3).to_i
|
166
|
+
"#{major}.#{minor}.#{patch + 1}"
|
167
|
+
else
|
168
|
+
"#{version}.1"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def prompt(message, default = nil)
|
173
|
+
if default
|
174
|
+
print "#{message} [#{default}]: "
|
175
|
+
else
|
176
|
+
print "#{message}: "
|
177
|
+
end
|
178
|
+
input = $stdin.gets.chomp.strip
|
179
|
+
input.empty? ? default : input
|
180
|
+
end
|
181
|
+
|
182
|
+
def yes_no_prompt(message, default_no: true)
|
183
|
+
default = default_no ? '[y/N]' : '[Y/n]'
|
184
|
+
print "#{message} #{default} "
|
185
|
+
response = $stdin.gets.chomp.downcase
|
186
|
+
return response.start_with?('y') if default_no
|
187
|
+
|
188
|
+
!response.start_with?('n')
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
data/lib/bruh/config.rb
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'fileutils'
|
5
|
+
require 'toml-rb'
|
6
|
+
require 'sorbet-runtime'
|
7
|
+
|
8
|
+
module Bruh
|
9
|
+
# Manages configuration for Bruh
|
10
|
+
class Config
|
11
|
+
extend T::Sig
|
12
|
+
|
13
|
+
CONFIG_DIR = T.let(File.expand_path('~/.config/bruh').freeze, String)
|
14
|
+
CONFIG_FILE = T.let(File.join(CONFIG_DIR, 'config.toml').freeze, String)
|
15
|
+
|
16
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
17
|
+
def self.load
|
18
|
+
ensure_config_exists
|
19
|
+
|
20
|
+
begin
|
21
|
+
config = TomlRB.load_file(CONFIG_FILE)
|
22
|
+
symbolize_keys(config)
|
23
|
+
rescue StandardError => e
|
24
|
+
puts "Error loading config: #{e.message}"
|
25
|
+
{}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
sig { params(config: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
30
|
+
def self.save(config)
|
31
|
+
ensure_config_exists
|
32
|
+
|
33
|
+
begin
|
34
|
+
File.write(CONFIG_FILE, TomlRB.dump(stringify_keys(config)))
|
35
|
+
true
|
36
|
+
rescue StandardError => e
|
37
|
+
puts "Error saving config: #{e.message}"
|
38
|
+
false
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
sig { params(key: Symbol, value: T.untyped).returns(T::Boolean) }
|
43
|
+
def self.set(key, value)
|
44
|
+
config = load
|
45
|
+
config[key] = value
|
46
|
+
save(config)
|
47
|
+
end
|
48
|
+
|
49
|
+
sig { params(key: Symbol).returns(T.untyped) }
|
50
|
+
def self.get(key)
|
51
|
+
config = load
|
52
|
+
config[key]
|
53
|
+
end
|
54
|
+
|
55
|
+
sig { params(interactive: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
|
56
|
+
def self.setup_credentials(interactive: true)
|
57
|
+
config = load
|
58
|
+
|
59
|
+
if interactive
|
60
|
+
# Prompt for credentials
|
61
|
+
puts 'Setting up Hackage credentials:'
|
62
|
+
print 'Hackage username: '
|
63
|
+
username = gets.chomp
|
64
|
+
|
65
|
+
print 'Hackage password (input will be hidden): '
|
66
|
+
system('stty -echo')
|
67
|
+
password = gets.chomp
|
68
|
+
system('stty echo')
|
69
|
+
puts
|
70
|
+
|
71
|
+
puts 'Setting up GitHub credentials:'
|
72
|
+
print 'GitHub token (for bottle uploads): '
|
73
|
+
system('stty -echo')
|
74
|
+
github_token = gets.chomp
|
75
|
+
system('stty echo')
|
76
|
+
puts
|
77
|
+
|
78
|
+
# Save credentials
|
79
|
+
config[:hackage_username] = username
|
80
|
+
config[:hackage_password] = password
|
81
|
+
config[:github_token] = github_token
|
82
|
+
|
83
|
+
save(config)
|
84
|
+
end
|
85
|
+
|
86
|
+
config
|
87
|
+
end
|
88
|
+
|
89
|
+
sig { returns(NilClass) }
|
90
|
+
def self.ensure_config_exists
|
91
|
+
FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
92
|
+
|
93
|
+
unless File.exist?(CONFIG_FILE)
|
94
|
+
default_config = {
|
95
|
+
hackage_username: '',
|
96
|
+
hackage_password: '',
|
97
|
+
github_token: '',
|
98
|
+
default_formula_template: 'standard',
|
99
|
+
bottle_platforms: ['arm64_monterey']
|
100
|
+
}
|
101
|
+
|
102
|
+
File.write(CONFIG_FILE, TomlRB.dump(default_config))
|
103
|
+
end
|
104
|
+
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
109
|
+
def self.symbolize_keys(hash)
|
110
|
+
result = {}
|
111
|
+
hash.each do |key, value|
|
112
|
+
result[key.to_sym] = if value.is_a?(Hash)
|
113
|
+
symbolize_keys(value)
|
114
|
+
else
|
115
|
+
value
|
116
|
+
end
|
117
|
+
end
|
118
|
+
result
|
119
|
+
end
|
120
|
+
|
121
|
+
sig { params(hash: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
|
122
|
+
def self.stringify_keys(hash)
|
123
|
+
result = {}
|
124
|
+
hash.each do |key, value|
|
125
|
+
result[key.to_s] = if value.is_a?(Hash)
|
126
|
+
stringify_keys(value)
|
127
|
+
else
|
128
|
+
value
|
129
|
+
end
|
130
|
+
end
|
131
|
+
result
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
data/lib/bruh/hackage.rb
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'net/http'
|
5
|
+
require 'digest'
|
6
|
+
require 'sorbet-runtime'
|
7
|
+
|
8
|
+
module Bruh
|
9
|
+
# Handles interaction with Hackage package manager
|
10
|
+
class Hackage
|
11
|
+
extend T::Sig
|
12
|
+
|
13
|
+
sig { params(interactive: T::Boolean).void }
|
14
|
+
def initialize(interactive: true)
|
15
|
+
@interactive = interactive
|
16
|
+
end
|
17
|
+
|
18
|
+
sig { params(version: String).returns(T::Boolean) }
|
19
|
+
def publish(version)
|
20
|
+
puts '=== Ready to upload to Hackage ==='
|
21
|
+
|
22
|
+
package_path = build_package(version)
|
23
|
+
return false unless package_path
|
24
|
+
|
25
|
+
if @interactive
|
26
|
+
puts 'The following command will upload the package to Hackage:'
|
27
|
+
puts " cabal upload --publish #{package_path}"
|
28
|
+
|
29
|
+
if yes_no_prompt('Do you want to upload to Hackage now?')
|
30
|
+
publish_package(package_path)
|
31
|
+
else
|
32
|
+
puts 'Skipping Hackage upload. Run the command manually when ready.'
|
33
|
+
false
|
34
|
+
end
|
35
|
+
else
|
36
|
+
# In non-interactive mode, just publish
|
37
|
+
publish_package(package_path)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
sig { params(version: String, username: String, password: String).returns(T::Boolean) }
|
42
|
+
def publish_non_interactive(version, username, password)
|
43
|
+
package_path = build_package(version)
|
44
|
+
return false unless package_path
|
45
|
+
|
46
|
+
# Use HTTP Basic auth with credentials
|
47
|
+
cmd = "curl -X PUT --data-binary \"@#{package_path}\" " \
|
48
|
+
"-H 'Content-Type: application/x-tar' " \
|
49
|
+
"-H 'Content-Encoding: gzip' " \
|
50
|
+
"--user \"#{username}:#{password}\" " \
|
51
|
+
'"https://hackage.haskell.org/packages/upload"'
|
52
|
+
|
53
|
+
T.must(system(cmd))
|
54
|
+
end
|
55
|
+
|
56
|
+
sig { params(version: String).returns(T.nilable(String)) }
|
57
|
+
def calculate_package_sha256(version)
|
58
|
+
puts '=== Calculating SHA256 for Hackage package ==='
|
59
|
+
|
60
|
+
# Wait for package to be available
|
61
|
+
puts 'Waiting for Hackage to process the package (10 seconds)...'
|
62
|
+
sleep 10 if @interactive
|
63
|
+
|
64
|
+
package_name = File.basename(Dir.pwd)
|
65
|
+
hackage_url = "https://hackage.haskell.org/package/#{package_name}-#{version}/#{package_name}-#{version}.tar.gz"
|
66
|
+
|
67
|
+
max_attempts = 3
|
68
|
+
attempt = 1
|
69
|
+
sha256 = T.let(nil, T.nilable(String))
|
70
|
+
|
71
|
+
while attempt <= max_attempts && sha256.nil?
|
72
|
+
puts "Attempt #{attempt} of #{max_attempts} to calculate SHA256..."
|
73
|
+
|
74
|
+
begin
|
75
|
+
uri = URI(hackage_url)
|
76
|
+
response = Net::HTTP.get_response(uri)
|
77
|
+
|
78
|
+
if response.is_a?(Net::HTTPSuccess) && !response.body.empty?
|
79
|
+
sha256 = Digest::SHA256.hexdigest(response.body)
|
80
|
+
|
81
|
+
# Validate SHA256 format (64 hex chars)
|
82
|
+
if sha256 =~ /^[0-9a-f]{64}$/
|
83
|
+
puts "Valid SHA256 obtained: #{sha256}"
|
84
|
+
break
|
85
|
+
else
|
86
|
+
puts 'Invalid SHA256 obtained. Retrying...'
|
87
|
+
sha256 = T.let(nil, T.nilable(String))
|
88
|
+
end
|
89
|
+
else
|
90
|
+
puts 'Package not available yet or empty response.'
|
91
|
+
end
|
92
|
+
rescue StandardError => e
|
93
|
+
puts "Error downloading package: #{e.message}"
|
94
|
+
end
|
95
|
+
|
96
|
+
# Wait before retrying
|
97
|
+
wait_time = attempt * 5
|
98
|
+
puts "Waiting #{wait_time} seconds before retry..."
|
99
|
+
sleep wait_time
|
100
|
+
attempt += 1
|
101
|
+
end
|
102
|
+
|
103
|
+
if sha256.nil?
|
104
|
+
puts "Error: Failed to calculate SHA256 after #{max_attempts} attempts."
|
105
|
+
puts 'The package might not be available on Hackage yet.'
|
106
|
+
nil
|
107
|
+
else
|
108
|
+
sha256
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
sig { params(version: String).returns(T.nilable(String)) }
|
115
|
+
def build_package(version)
|
116
|
+
# Run necessary steps to build the package
|
117
|
+
puts 'Building documentation for Hackage...'
|
118
|
+
unless system('cabal haddock --haddock-for-hackage')
|
119
|
+
puts 'Failed to build documentation'
|
120
|
+
return nil
|
121
|
+
end
|
122
|
+
|
123
|
+
puts 'Creating source distribution...'
|
124
|
+
unless system('cabal sdist')
|
125
|
+
puts 'Failed to create source distribution'
|
126
|
+
return nil
|
127
|
+
end
|
128
|
+
|
129
|
+
# Find the generated package file
|
130
|
+
package_name = File.basename(Dir.pwd)
|
131
|
+
package_path = "dist-newstyle/sdist/#{package_name}-#{version}.tar.gz"
|
132
|
+
|
133
|
+
unless File.exist?(package_path)
|
134
|
+
puts "Generated package not found at #{package_path}"
|
135
|
+
return nil
|
136
|
+
end
|
137
|
+
|
138
|
+
package_path
|
139
|
+
end
|
140
|
+
|
141
|
+
sig { params(package_path: String).returns(T::Boolean) }
|
142
|
+
def publish_package(package_path)
|
143
|
+
puts 'Uploading package to Hackage...'
|
144
|
+
T.must(system("cabal upload --publish \"#{package_path}\""))
|
145
|
+
end
|
146
|
+
|
147
|
+
sig { params(message: String, default_no: T::Boolean).returns(T::Boolean) }
|
148
|
+
def yes_no_prompt(message, default_no: true)
|
149
|
+
return true unless @interactive
|
150
|
+
|
151
|
+
default = default_no ? '[y/N]' : '[Y/n]'
|
152
|
+
print "#{message} #{default} "
|
153
|
+
response = gets.chomp.downcase
|
154
|
+
return response.start_with?('y') if default_no
|
155
|
+
|
156
|
+
!response.start_with?('n')
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'sorbet-runtime'
|
5
|
+
|
6
|
+
module Bruh
|
7
|
+
# Manages Homebrew formula creation and updates
|
8
|
+
class Homebrew
|
9
|
+
extend T::Sig
|
10
|
+
|
11
|
+
sig { params(interactive: T::Boolean).void }
|
12
|
+
def initialize(interactive: true)
|
13
|
+
@interactive = interactive
|
14
|
+
@tap_dir = T.let(find_homebrew_tap, T.nilable(String))
|
15
|
+
@formula_path = T.let(find_formula_path, T.nilable(String))
|
16
|
+
end
|
17
|
+
|
18
|
+
sig { params(version: String, sha256: String).returns(T::Boolean) }
|
19
|
+
def update(version, sha256)
|
20
|
+
unless @formula_path && File.exist?(@formula_path)
|
21
|
+
puts "Error: Formula file not found at #{@formula_path}"
|
22
|
+
return false
|
23
|
+
end
|
24
|
+
|
25
|
+
puts "Updating Homebrew formula with new version #{version} and SHA256 #{sha256}..."
|
26
|
+
|
27
|
+
# Read current formula
|
28
|
+
formula_content = File.read(@formula_path)
|
29
|
+
|
30
|
+
# Verify markers exist
|
31
|
+
unless formula_content.include?('url') && formula_content.include?('sha256')
|
32
|
+
puts 'ERROR: Required fields not found in formula. Cannot update safely.'
|
33
|
+
return false
|
34
|
+
end
|
35
|
+
|
36
|
+
# Update version in URL
|
37
|
+
formula_content.gsub!(/url\s+"[^"]+"/, "url \"https://hackage.haskell.org/package/#{package_name}-#{version}/#{package_name}-#{version}.tar.gz\"")
|
38
|
+
|
39
|
+
# Update SHA256
|
40
|
+
formula_content.gsub!(/sha256\s+"[a-f0-9]+"/, "sha256 \"#{sha256}\"")
|
41
|
+
|
42
|
+
# Write updated formula
|
43
|
+
File.write(@formula_path, formula_content)
|
44
|
+
|
45
|
+
# Verify the update was successful
|
46
|
+
updated_content = File.read(@formula_path)
|
47
|
+
if !updated_content.include?("#{package_name}-#{version}.tar.gz") ||
|
48
|
+
!updated_content.include?("sha256 \"#{sha256}\"")
|
49
|
+
puts 'ERROR: Formula update verification failed.'
|
50
|
+
return false
|
51
|
+
end
|
52
|
+
|
53
|
+
puts 'Formula updated successfully with new version and SHA256.'
|
54
|
+
|
55
|
+
# Commit the changes if interactive
|
56
|
+
if @interactive && yes_no_prompt('Commit formula changes?')
|
57
|
+
Dir.chdir(File.dirname(@formula_path)) do
|
58
|
+
system("git add #{File.basename(@formula_path)}")
|
59
|
+
system("git commit -m \"Update formula to version #{version}\"")
|
60
|
+
|
61
|
+
system('git push origin main') if yes_no_prompt('Push formula changes to origin?', default_no: true)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
true
|
66
|
+
end
|
67
|
+
|
68
|
+
sig { params(bottle_info: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
69
|
+
def update_bottle_info(bottle_info)
|
70
|
+
unless @formula_path && File.exist?(@formula_path)
|
71
|
+
puts 'Error: Formula file not found'
|
72
|
+
return false
|
73
|
+
end
|
74
|
+
|
75
|
+
macos_version = bottle_info[:macos_version]
|
76
|
+
bottle_sha = bottle_info[:sha256]
|
77
|
+
rebuild_num = bottle_info[:rebuild] || 0
|
78
|
+
|
79
|
+
formula_content = File.read(@formula_path)
|
80
|
+
|
81
|
+
# Check if bottle block exists
|
82
|
+
if formula_content.include?('bottle do')
|
83
|
+
# Update existing bottle block
|
84
|
+
|
85
|
+
# Update rebuild directive if needed
|
86
|
+
if rebuild_num.positive?
|
87
|
+
if formula_content =~ /\s+rebuild\s+\d+/
|
88
|
+
formula_content.gsub!(/(\s+rebuild\s+)\d+/, "\\1#{rebuild_num}")
|
89
|
+
else
|
90
|
+
formula_content.gsub!(/(\s+root_url.*)$/, "\\1\n rebuild #{rebuild_num}")
|
91
|
+
end
|
92
|
+
else
|
93
|
+
# Remove rebuild directive if it exists
|
94
|
+
formula_content.gsub!(/\s+rebuild\s+\d+\n/, "\n")
|
95
|
+
end
|
96
|
+
|
97
|
+
# Update SHA256 for the specific platform
|
98
|
+
if formula_content =~ /sha256\s+cellar:[^,]+,\s+arm64_[^:]+:/
|
99
|
+
formula_content.gsub!(/sha256\s+cellar:[^,]+,\s+arm64_[^:]+:\s+"[a-f0-9]+"/,
|
100
|
+
"sha256 cellar: :any, arm64_#{macos_version}: \"#{bottle_sha}\"")
|
101
|
+
else
|
102
|
+
# Add line for this platform
|
103
|
+
formula_content.gsub!(/(\s+root_url\s+"[^"]+".*$)/,
|
104
|
+
"\\1\n sha256 cellar: :any, arm64_#{macos_version}: \"#{bottle_sha}\"")
|
105
|
+
end
|
106
|
+
else
|
107
|
+
# Create bottle block after sha256 line
|
108
|
+
bottle_block = <<~BOTTLE
|
109
|
+
|
110
|
+
bottle do
|
111
|
+
root_url "https://github.com/#{repo_owner}/#{repo_name}/releases/download/v#{bottle_info[:version]}"
|
112
|
+
sha256 cellar: :any, arm64_#{macos_version}: "#{bottle_sha}"
|
113
|
+
end
|
114
|
+
BOTTLE
|
115
|
+
|
116
|
+
formula_content.gsub!(/sha256\s+"[a-f0-9]+"/, "\\0#{bottle_block}")
|
117
|
+
end
|
118
|
+
|
119
|
+
# Write updated formula
|
120
|
+
File.write(@formula_path, formula_content)
|
121
|
+
|
122
|
+
# Verify the update was successful
|
123
|
+
updated_content = File.read(@formula_path)
|
124
|
+
unless updated_content.include?("arm64_#{macos_version}: \"#{bottle_sha}\"")
|
125
|
+
puts 'ERROR: Bottle SHA update failed.'
|
126
|
+
return false
|
127
|
+
end
|
128
|
+
|
129
|
+
puts 'Bottle information updated successfully.'
|
130
|
+
|
131
|
+
# Commit the changes if interactive
|
132
|
+
if @interactive && yes_no_prompt('Commit bottle changes?')
|
133
|
+
Dir.chdir(File.dirname(@formula_path)) do
|
134
|
+
system("git add #{File.basename(@formula_path)}")
|
135
|
+
system("git commit -m \"Add bottle for version #{bottle_info[:version]}\"")
|
136
|
+
|
137
|
+
system('git push origin main') if yes_no_prompt('Push bottle changes to origin?', default_no: true)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
true
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
sig { returns(T.nilable(String)) }
|
147
|
+
def find_homebrew_tap
|
148
|
+
# Try common locations
|
149
|
+
potential_paths = [
|
150
|
+
'../homebrew-tap',
|
151
|
+
'~/homebrew-tap',
|
152
|
+
'~/Projects/homebrew-tap'
|
153
|
+
]
|
154
|
+
|
155
|
+
potential_paths.each do |path|
|
156
|
+
expanded = File.expand_path(path)
|
157
|
+
return expanded if Dir.exist?(expanded)
|
158
|
+
end
|
159
|
+
|
160
|
+
if @interactive
|
161
|
+
puts 'Homebrew tap directory not found in common locations.'
|
162
|
+
tap_dir = gets.chomp.strip
|
163
|
+
return tap_dir if !tap_dir.empty? && Dir.exist?(tap_dir)
|
164
|
+
end
|
165
|
+
|
166
|
+
nil
|
167
|
+
end
|
168
|
+
|
169
|
+
sig { returns(T.nilable(String)) }
|
170
|
+
def find_formula_path
|
171
|
+
return nil unless @tap_dir
|
172
|
+
|
173
|
+
# Try to find the formula file
|
174
|
+
formula_name = package_name
|
175
|
+
formula_path = File.join(@tap_dir, 'Formula', "#{formula_name}.rb")
|
176
|
+
|
177
|
+
return formula_path if File.exist?(formula_path)
|
178
|
+
|
179
|
+
# If not found in standard location, try to find elsewhere
|
180
|
+
Dir.glob(File.join(@tap_dir, '**', "#{formula_name}.rb")).first
|
181
|
+
end
|
182
|
+
|
183
|
+
sig { returns(String) }
|
184
|
+
def package_name
|
185
|
+
# Extract package name from current directory
|
186
|
+
File.basename(Dir.pwd)
|
187
|
+
end
|
188
|
+
|
189
|
+
sig { returns(T::Array[String]) }
|
190
|
+
def repo_info
|
191
|
+
# Extract owner and repo from git remote
|
192
|
+
remote = `git remote get-url origin`.chomp
|
193
|
+
if remote =~ %r{github\.com[:/]([^/]+)/([^/]+)\.git}
|
194
|
+
owner = T.let(T.must(::Regexp.last_match(1)), String)
|
195
|
+
repo = T.let(T.must(::Regexp.last_match(2)), String)
|
196
|
+
return [owner, repo]
|
197
|
+
end
|
198
|
+
|
199
|
+
%w[user repo]
|
200
|
+
end
|
201
|
+
sig { returns(String) }
|
202
|
+
def repo_owner
|
203
|
+
T.must(repo_info[0])
|
204
|
+
end
|
205
|
+
|
206
|
+
sig { returns(String) }
|
207
|
+
def repo_name
|
208
|
+
T.must(repo_info[1])
|
209
|
+
end
|
210
|
+
|
211
|
+
sig { params(message: String, default_no: T::Boolean).returns(T::Boolean) }
|
212
|
+
def yes_no_prompt(message, default_no: true)
|
213
|
+
return true unless @interactive
|
214
|
+
|
215
|
+
default = default_no ? '[y/N]' : '[Y/n]'
|
216
|
+
print "#{message} #{default} "
|
217
|
+
response = gets.chomp.downcase
|
218
|
+
return response.start_with?('y') if default_no
|
219
|
+
|
220
|
+
!response.start_with?('n')
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|