kettle-dev 1.0.9 → 1.0.10
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.envrc +4 -3
- data/.github/workflows/coverage.yml +3 -3
- data/.junie/guidelines.md +4 -3
- data/.simplecov +5 -1
- data/Appraisals +3 -0
- data/CHANGELOG.md +22 -1
- data/CONTRIBUTING.md +6 -0
- data/README.md +18 -5
- data/Rakefile +7 -11
- data/exe/kettle-commit-msg +9 -143
- data/exe/kettle-readme-backers +7 -353
- data/exe/kettle-release +8 -702
- data/lib/kettle/dev/ci_helpers.rb +1 -0
- data/lib/kettle/dev/commit_msg.rb +39 -0
- data/lib/kettle/dev/exit_adapter.rb +36 -0
- data/lib/kettle/dev/git_adapter.rb +120 -0
- data/lib/kettle/dev/git_commit_footer.rb +130 -0
- data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
- data/lib/kettle/dev/rakelib/bench.rake +2 -7
- data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
- data/lib/kettle/dev/rakelib/ci.rake +4 -396
- data/lib/kettle/dev/rakelib/install.rake +1 -295
- data/lib/kettle/dev/rakelib/reek.rake +2 -0
- data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
- data/lib/kettle/dev/rakelib/template.rake +3 -465
- data/lib/kettle/dev/readme_backers.rb +340 -0
- data/lib/kettle/dev/release_cli.rb +672 -0
- data/lib/kettle/dev/tasks/ci_task.rb +334 -0
- data/lib/kettle/dev/tasks/install_task.rb +298 -0
- data/lib/kettle/dev/tasks/template_task.rb +491 -0
- data/lib/kettle/dev/template_helpers.rb +4 -4
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +30 -1
- data/lib/kettle-dev.rb +2 -3
- data/sig/kettle/dev/ci_helpers.rbs +8 -17
- data/sig/kettle/dev/commit_msg.rbs +8 -0
- data/sig/kettle/dev/exit_adapter.rbs +8 -0
- data/sig/kettle/dev/git_adapter.rbs +15 -0
- data/sig/kettle/dev/git_commit_footer.rbs +16 -0
- data/sig/kettle/dev/readme_backers.rbs +20 -0
- data/sig/kettle/dev/release_cli.rbs +8 -0
- data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
- data/sig/kettle/dev/tasks/install_task.rbs +10 -0
- data/sig/kettle/dev/tasks/template_task.rbs +10 -0
- data/sig/kettle/dev/tasks.rbs +0 -0
- data/sig/kettle/dev/version.rbs +0 -0
- data/sig/kettle/emoji_regex.rbs +5 -0
- data/sig/kettle-dev.rbs +0 -0
- data.tar.gz.sig +0 -0
- metadata +55 -5
- metadata.gz.sig +0 -0
@@ -0,0 +1,334 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# External
|
4
|
+
require "kettle/dev/exit_adapter"
|
5
|
+
require "open3"
|
6
|
+
require "net/http"
|
7
|
+
require "json"
|
8
|
+
require "uri"
|
9
|
+
|
10
|
+
module Kettle
|
11
|
+
module Dev
|
12
|
+
module Tasks
|
13
|
+
module CITask
|
14
|
+
module_function
|
15
|
+
|
16
|
+
# Local abort indirection to enable mocking via ExitAdapter
|
17
|
+
def abort(msg)
|
18
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
19
|
+
end
|
20
|
+
module_function :abort
|
21
|
+
|
22
|
+
# Runs `act` for a selected workflow. Option can be a short code or workflow basename.
|
23
|
+
# Mirrors the behavior previously implemented in the ci:act rake task.
|
24
|
+
# @param opt [String, nil]
|
25
|
+
def act(opt = nil)
|
26
|
+
require "io/console"
|
27
|
+
choice = opt&.strip
|
28
|
+
|
29
|
+
root_dir = Kettle::Dev::CIHelpers.project_root
|
30
|
+
workflows_dir = File.join(root_dir, ".github", "workflows")
|
31
|
+
|
32
|
+
# Build mapping dynamically from workflow files; short code = first three letters of filename stem
|
33
|
+
mapping = {}
|
34
|
+
|
35
|
+
existing_files = if Dir.exist?(workflows_dir)
|
36
|
+
Dir[File.join(workflows_dir, "*.yml")] + Dir[File.join(workflows_dir, "*.yaml")]
|
37
|
+
else
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
existing_basenames = existing_files.map { |p| File.basename(p) }
|
41
|
+
|
42
|
+
exclusions = Kettle::Dev::CIHelpers.exclusions
|
43
|
+
candidate_files = existing_basenames.uniq - exclusions
|
44
|
+
candidate_files.sort.each do |fname|
|
45
|
+
stem = fname.sub(/\.(ya?ml)\z/, "")
|
46
|
+
code = stem[0, 3].to_s.downcase
|
47
|
+
next if code.empty?
|
48
|
+
mapping[code] ||= fname
|
49
|
+
end
|
50
|
+
|
51
|
+
dynamic_files = candidate_files - mapping.values
|
52
|
+
display_code_for = {}
|
53
|
+
mapping.keys.each { |k| display_code_for[k] = k }
|
54
|
+
dynamic_files.each { |f| display_code_for[f] = "" }
|
55
|
+
|
56
|
+
status_emoji = proc do |status, conclusion|
|
57
|
+
case status
|
58
|
+
when "queued" then "⏳️"
|
59
|
+
when "in_progress" then "👟"
|
60
|
+
when "completed" then ((conclusion == "success") ? "✅" : "🍅")
|
61
|
+
else "⏳️"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
fetch_and_print_status = proc do |workflow_file|
|
66
|
+
branch = Kettle::Dev::CIHelpers.current_branch
|
67
|
+
org_repo = Kettle::Dev::CIHelpers.repo_info
|
68
|
+
unless branch && org_repo
|
69
|
+
puts "GHA status: (skipped; missing git branch or remote)"
|
70
|
+
next
|
71
|
+
end
|
72
|
+
owner, repo = org_repo
|
73
|
+
uri = URI("https://api.github.com/repos/#{owner}/#{repo}/actions/workflows/#{workflow_file}/runs?branch=#{URI.encode_www_form_component(branch)}&per_page=1")
|
74
|
+
req = Net::HTTP::Get.new(uri)
|
75
|
+
req["User-Agent"] = "ci:act rake task"
|
76
|
+
token = Kettle::Dev::CIHelpers.default_token
|
77
|
+
req["Authorization"] = "token #{token}" if token && !token.empty?
|
78
|
+
begin
|
79
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
|
80
|
+
if res.is_a?(Net::HTTPSuccess)
|
81
|
+
data = JSON.parse(res.body)
|
82
|
+
run = data["workflow_runs"]&.first
|
83
|
+
if run
|
84
|
+
status = run["status"]
|
85
|
+
conclusion = run["conclusion"]
|
86
|
+
emoji = status_emoji.call(status, conclusion)
|
87
|
+
details = [status, conclusion].compact.join("/")
|
88
|
+
puts "Latest GHA (#{branch}) for #{workflow_file}: #{emoji} (#{details})"
|
89
|
+
else
|
90
|
+
puts "Latest GHA (#{branch}) for #{workflow_file}: none"
|
91
|
+
end
|
92
|
+
else
|
93
|
+
puts "GHA status: request failed (#{res.code})"
|
94
|
+
end
|
95
|
+
rescue StandardError => e
|
96
|
+
puts "GHA status: error #{e.class}: #{e.message}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
run_act_for = proc do |file_path|
|
101
|
+
ok = system("act", "-W", file_path)
|
102
|
+
abort("ci:act failed: 'act' command not found or exited with failure") unless ok
|
103
|
+
end
|
104
|
+
|
105
|
+
if choice && !choice.empty?
|
106
|
+
file = if mapping.key?(choice)
|
107
|
+
mapping.fetch(choice)
|
108
|
+
elsif !!(/\.(yml|yaml)\z/ =~ choice)
|
109
|
+
choice
|
110
|
+
else
|
111
|
+
cand_yml = File.join(workflows_dir, "#{choice}.yml")
|
112
|
+
cand_yaml = File.join(workflows_dir, "#{choice}.yaml")
|
113
|
+
if File.file?(cand_yml)
|
114
|
+
"#{choice}.yml"
|
115
|
+
elsif File.file?(cand_yaml)
|
116
|
+
"#{choice}.yaml"
|
117
|
+
else
|
118
|
+
"#{choice}.yml"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
file_path = File.join(workflows_dir, file)
|
122
|
+
unless File.file?(file_path)
|
123
|
+
puts "Unknown option or missing workflow file: #{choice} -> #{file}"
|
124
|
+
puts "Available options:"
|
125
|
+
mapping.each { |k, v| puts " #{k.ljust(3)} => #{v}" }
|
126
|
+
unless dynamic_files.empty?
|
127
|
+
puts " (others) =>"
|
128
|
+
dynamic_files.each { |v| puts " #{v}" }
|
129
|
+
end
|
130
|
+
abort("ci:act aborted")
|
131
|
+
end
|
132
|
+
fetch_and_print_status.call(file)
|
133
|
+
run_act_for.call(file_path)
|
134
|
+
return
|
135
|
+
end
|
136
|
+
|
137
|
+
# Interactive menu
|
138
|
+
require "thread"
|
139
|
+
tty = $stdout.tty?
|
140
|
+
options = mapping.to_a + dynamic_files.map { |f| [f, f] }
|
141
|
+
quit_code = "q"
|
142
|
+
options_with_quit = options + [[quit_code, "(quit)"]]
|
143
|
+
idx_by_code = {}
|
144
|
+
options_with_quit.each_with_index { |(k, _v), i| idx_by_code[k] = i }
|
145
|
+
|
146
|
+
branch = Kettle::Dev::CIHelpers.current_branch
|
147
|
+
org = Kettle::Dev::CIHelpers.repo_info
|
148
|
+
owner, repo = org if org
|
149
|
+
token = Kettle::Dev::CIHelpers.default_token
|
150
|
+
|
151
|
+
upstream = begin
|
152
|
+
out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
153
|
+
status.success? ? out.strip : nil
|
154
|
+
rescue StandardError
|
155
|
+
nil
|
156
|
+
end
|
157
|
+
sha = begin
|
158
|
+
out, status = Open3.capture2("git", "rev-parse", "--short", "HEAD")
|
159
|
+
status.success? ? out.strip : nil
|
160
|
+
rescue StandardError
|
161
|
+
nil
|
162
|
+
end
|
163
|
+
if org && branch
|
164
|
+
puts "Repo: #{owner}/#{repo}"
|
165
|
+
elsif org
|
166
|
+
puts "Repo: #{owner}/#{repo}"
|
167
|
+
else
|
168
|
+
puts "Repo: n/a"
|
169
|
+
end
|
170
|
+
puts "Upstream: #{upstream || "n/a"}"
|
171
|
+
puts "HEAD: #{sha || "n/a"}"
|
172
|
+
puts
|
173
|
+
puts "Select a workflow to run with 'act':"
|
174
|
+
|
175
|
+
placeholder = "[…]"
|
176
|
+
options_with_quit.each_with_index do |(k, v), idx|
|
177
|
+
status_col = (k == quit_code) ? "" : placeholder
|
178
|
+
disp = (k == quit_code) ? k : display_code_for[k]
|
179
|
+
line = format("%2d) %-3s => %-20s %s", idx + 1, disp, v, status_col)
|
180
|
+
puts line
|
181
|
+
end
|
182
|
+
|
183
|
+
puts "(Fetching latest GHA status for branch #{branch || "n/a"} — you can type your choice and press Enter)"
|
184
|
+
prompt = "Enter number or code (or 'q' to quit): "
|
185
|
+
print(prompt)
|
186
|
+
$stdout.flush
|
187
|
+
|
188
|
+
selected = nil
|
189
|
+
input_thread = Thread.new do
|
190
|
+
begin
|
191
|
+
selected = $stdin.gets&.strip
|
192
|
+
rescue Exception => error
|
193
|
+
# Catch all exceptions in background thread, including SystemExit
|
194
|
+
# NOTE: look into refactoring to minimize potential SystemExit.
|
195
|
+
puts "Error in background thread: #{error.class}: #{error.message}" if Kettle::Dev::DEBUGGING
|
196
|
+
selected = nil
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
status_q = Queue.new
|
201
|
+
workers = []
|
202
|
+
start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
203
|
+
|
204
|
+
options.each do |code, file|
|
205
|
+
workers << Thread.new(code, file, owner, repo, branch, token, start_at) do |c, f, ow, rp, br, tk, st_at|
|
206
|
+
begin
|
207
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
208
|
+
delay = 0.12 - (now - st_at)
|
209
|
+
sleep(delay) if delay && delay > 0
|
210
|
+
|
211
|
+
if ow.nil? || rp.nil? || br.nil?
|
212
|
+
status_q << [c, f, "n/a"]
|
213
|
+
Thread.exit
|
214
|
+
end
|
215
|
+
uri = URI("https://api.github.com/repos/#{ow}/#{rp}/actions/workflows/#{f}/runs?branch=#{URI.encode_www_form_component(br)}&per_page=1")
|
216
|
+
poll_interval = Integer(ENV["CI_ACT_POLL_INTERVAL"] || 5)
|
217
|
+
loop do
|
218
|
+
begin
|
219
|
+
req = Net::HTTP::Get.new(uri)
|
220
|
+
req["User-Agent"] = "ci:act rake task"
|
221
|
+
req["Authorization"] = "token #{tk}" if tk && !tk.empty?
|
222
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
|
223
|
+
if res.is_a?(Net::HTTPSuccess)
|
224
|
+
data = JSON.parse(res.body)
|
225
|
+
run = data["workflow_runs"]&.first
|
226
|
+
if run
|
227
|
+
st = run["status"]
|
228
|
+
con = run["conclusion"]
|
229
|
+
emoji = case st
|
230
|
+
when "queued" then "⏳️"
|
231
|
+
when "in_progress" then "👟"
|
232
|
+
when "completed" then ((con == "success") ? "✅" : "🍅")
|
233
|
+
else "⏳️"
|
234
|
+
end
|
235
|
+
details = [st, con].compact.join("/")
|
236
|
+
status_q << [c, f, "#{emoji} (#{details})"]
|
237
|
+
break if st == "completed"
|
238
|
+
else
|
239
|
+
status_q << [c, f, "none"]
|
240
|
+
break
|
241
|
+
end
|
242
|
+
else
|
243
|
+
status_q << [c, f, "fail #{res.code}"]
|
244
|
+
end
|
245
|
+
rescue Exception
|
246
|
+
# Catch all exceptions to prevent crashing the process from a worker thread
|
247
|
+
status_q << [c, f, "err"]
|
248
|
+
end
|
249
|
+
sleep(poll_interval)
|
250
|
+
end
|
251
|
+
rescue Exception
|
252
|
+
# Catch all exceptions in the worker thread boundary, including SystemExit
|
253
|
+
status_q << [c, f, "err"]
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
statuses = Hash.new(placeholder)
|
259
|
+
|
260
|
+
loop do
|
261
|
+
if selected
|
262
|
+
break
|
263
|
+
end
|
264
|
+
|
265
|
+
begin
|
266
|
+
code, file_name, display = status_q.pop(true)
|
267
|
+
statuses[code] = display
|
268
|
+
|
269
|
+
if tty
|
270
|
+
idx = idx_by_code[code]
|
271
|
+
if idx.nil?
|
272
|
+
puts "status #{code}: #{display}"
|
273
|
+
print(prompt)
|
274
|
+
else
|
275
|
+
move_up = options_with_quit.size - idx + 1
|
276
|
+
$stdout.print("\e[#{move_up}A\r\e[2K")
|
277
|
+
disp = (code == quit_code) ? code : display_code_for[code]
|
278
|
+
$stdout.print(format("%2d) %-3s => %-20s %s\n", idx + 1, disp, file_name, display))
|
279
|
+
$stdout.print("\e[#{move_up - 1}B\r")
|
280
|
+
$stdout.print(prompt)
|
281
|
+
end
|
282
|
+
$stdout.flush
|
283
|
+
else
|
284
|
+
puts "status #{code}: #{display}"
|
285
|
+
end
|
286
|
+
rescue ThreadError
|
287
|
+
sleep(0.05)
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
begin
|
292
|
+
workers.each { |t| t.kill if t&.alive? }
|
293
|
+
rescue StandardError
|
294
|
+
end
|
295
|
+
begin
|
296
|
+
input_thread.kill if input_thread&.alive?
|
297
|
+
rescue StandardError
|
298
|
+
end
|
299
|
+
|
300
|
+
input = selected
|
301
|
+
abort("ci:act aborted: no selection") if input.nil? || input.empty?
|
302
|
+
|
303
|
+
chosen_file = nil
|
304
|
+
if !!(/^\d+$/ =~ input)
|
305
|
+
idx = input.to_i - 1
|
306
|
+
if idx < 0 || idx >= options_with_quit.length
|
307
|
+
abort("ci:act aborted: invalid selection #{input}")
|
308
|
+
end
|
309
|
+
code, val = options_with_quit[idx]
|
310
|
+
if code == quit_code
|
311
|
+
puts "ci:act: quit"
|
312
|
+
return
|
313
|
+
else
|
314
|
+
chosen_file = val
|
315
|
+
end
|
316
|
+
else
|
317
|
+
code = input
|
318
|
+
if ["q", "quit", "exit"].include?(code.downcase)
|
319
|
+
puts "ci:act: quit"
|
320
|
+
return
|
321
|
+
end
|
322
|
+
chosen_file = mapping[code]
|
323
|
+
abort("ci:act aborted: unknown code '#{code}'") unless chosen_file
|
324
|
+
end
|
325
|
+
|
326
|
+
file_path = File.join(workflows_dir, chosen_file)
|
327
|
+
abort("ci:act aborted: workflow not found: #{file_path}") unless File.file?(file_path)
|
328
|
+
fetch_and_print_status.call(chosen_file)
|
329
|
+
run_act_for.call(file_path)
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
@@ -0,0 +1,298 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "kettle/dev/exit_adapter"
|
4
|
+
|
5
|
+
module Kettle
|
6
|
+
module Dev
|
7
|
+
module Tasks
|
8
|
+
module InstallTask
|
9
|
+
module_function
|
10
|
+
|
11
|
+
# Abort wrapper that avoids terminating the entire process during specs
|
12
|
+
def task_abort(msg)
|
13
|
+
if defined?(RSpec)
|
14
|
+
raise Kettle::Dev::Error, msg
|
15
|
+
else
|
16
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def run
|
21
|
+
helpers = Kettle::Dev::TemplateHelpers
|
22
|
+
project_root = helpers.project_root
|
23
|
+
|
24
|
+
# Run file templating via dedicated task first
|
25
|
+
Rake::Task["kettle:dev:template"].invoke
|
26
|
+
|
27
|
+
# .tool-versions cleanup offers
|
28
|
+
tool_versions_path = File.join(project_root, ".tool-versions")
|
29
|
+
if File.file?(tool_versions_path)
|
30
|
+
rv = File.join(project_root, ".ruby-version")
|
31
|
+
rg = File.join(project_root, ".ruby-gemset")
|
32
|
+
to_remove = [rv, rg].select { |p| File.exist?(p) }
|
33
|
+
unless to_remove.empty?
|
34
|
+
if helpers.ask("Remove #{to_remove.map { |p| File.basename(p) }.join(" and ")} (managed by .tool-versions)?", true)
|
35
|
+
to_remove.each { |p| FileUtils.rm_f(p) }
|
36
|
+
puts "Removed #{to_remove.map { |p| File.basename(p) }.join(" and ")}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
puts
|
42
|
+
puts "Next steps:"
|
43
|
+
puts "1) Configure a shared git hooks path (optional, recommended):"
|
44
|
+
puts " git config --global core.hooksPath .git-hooks"
|
45
|
+
puts
|
46
|
+
puts "2) Install binstubs for this gem so the commit-msg tool is available in ./bin:"
|
47
|
+
puts " bundle binstubs kettle-dev --path bin"
|
48
|
+
puts " # After running, you should have bin/kettle-commit-msg (wrapper)."
|
49
|
+
puts
|
50
|
+
# Step 3: direnv and .envrc
|
51
|
+
envrc_path = File.join(project_root, ".envrc")
|
52
|
+
puts "3) Install direnv (if not already):"
|
53
|
+
puts " brew install direnv"
|
54
|
+
if helpers.modified_by_template?(envrc_path)
|
55
|
+
puts " Your .envrc was created/updated by kettle:dev:template."
|
56
|
+
puts " It includes PATH_add bin so that executables in ./bin are on PATH when direnv is active."
|
57
|
+
puts " This allows running tools without the bin/ prefix inside the project directory."
|
58
|
+
else
|
59
|
+
begin
|
60
|
+
current = File.file?(envrc_path) ? File.read(envrc_path) : ""
|
61
|
+
rescue StandardError
|
62
|
+
current = ""
|
63
|
+
end
|
64
|
+
has_path_add = current.lines.any? { |l| l.strip =~ /^PATH_add\s+bin\b/ }
|
65
|
+
if has_path_add
|
66
|
+
puts " Your .envrc already contains PATH_add bin."
|
67
|
+
else
|
68
|
+
puts " Adding PATH_add bin to your project's .envrc is recommended to expose ./bin on PATH."
|
69
|
+
if helpers.ask("Add PATH_add bin to #{envrc_path}?", false)
|
70
|
+
content = current.dup
|
71
|
+
insertion = "# Run any command in this project's bin/ without the bin/ prefix\nPATH_add bin\n"
|
72
|
+
if content.empty?
|
73
|
+
content = insertion
|
74
|
+
else
|
75
|
+
content = insertion + "\n" + content unless content.start_with?(insertion)
|
76
|
+
end
|
77
|
+
# Ensure a stale directory at .envrc is removed so the file can be written
|
78
|
+
FileUtils.rm_rf(envrc_path) if File.directory?(envrc_path)
|
79
|
+
File.open(envrc_path, "w") { |f| f.write(content) }
|
80
|
+
puts " Updated #{envrc_path} with PATH_add bin"
|
81
|
+
updated_envrc_by_install = true
|
82
|
+
else
|
83
|
+
puts " Skipping modification of .envrc. You may add 'PATH_add bin' manually at the top."
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Warn about .env.local and offer to add it to .gitignore
|
89
|
+
puts
|
90
|
+
puts "WARNING: Do not commit .env.local; it often contains machine-local secrets."
|
91
|
+
puts "Ensure your .gitignore includes:"
|
92
|
+
puts " # direnv - brew install direnv"
|
93
|
+
puts " .env.local"
|
94
|
+
|
95
|
+
gitignore_path = File.join(project_root, ".gitignore")
|
96
|
+
unless helpers.modified_by_template?(gitignore_path)
|
97
|
+
begin
|
98
|
+
gitignore_current = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
99
|
+
rescue StandardError
|
100
|
+
gitignore_current = ""
|
101
|
+
end
|
102
|
+
has_env_local = gitignore_current.lines.any? { |l| l.strip == ".env.local" }
|
103
|
+
unless has_env_local
|
104
|
+
puts
|
105
|
+
puts "Would you like to add '.env.local' to #{gitignore_path}?"
|
106
|
+
print("Add to .gitignore now [Y/n]: ")
|
107
|
+
answer = $stdin.gets&.strip
|
108
|
+
add_it = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
|
109
|
+
true
|
110
|
+
else
|
111
|
+
answer.nil? || answer.empty? || answer =~ /\Ay(es)?\z/i
|
112
|
+
end
|
113
|
+
if add_it
|
114
|
+
FileUtils.mkdir_p(File.dirname(gitignore_path))
|
115
|
+
mode = File.exist?(gitignore_path) ? "a" : "w"
|
116
|
+
File.open(gitignore_path, mode) do |f|
|
117
|
+
f.write("\n") unless gitignore_current.empty? || gitignore_current.end_with?("\n")
|
118
|
+
unless gitignore_current.lines.any? { |l| l.strip == "# direnv - brew install direnv" }
|
119
|
+
f.write("# direnv - brew install direnv\n")
|
120
|
+
end
|
121
|
+
f.write(".env.local\n")
|
122
|
+
end
|
123
|
+
puts "Added .env.local to #{gitignore_path}"
|
124
|
+
else
|
125
|
+
puts "Skipping modification of .gitignore. Remember to add .env.local to avoid committing it."
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Validate gemspec homepage points to GitHub and is a non-interpolated string
|
131
|
+
begin
|
132
|
+
gemspecs = Dir.glob(File.join(project_root, "*.gemspec"))
|
133
|
+
if gemspecs.empty?
|
134
|
+
puts
|
135
|
+
puts "No .gemspec found in #{project_root}; skipping homepage check."
|
136
|
+
else
|
137
|
+
gemspec_path = gemspecs.first
|
138
|
+
if gemspecs.size > 1
|
139
|
+
puts
|
140
|
+
puts "Multiple gemspecs found; defaulting to #{File.basename(gemspec_path)} for homepage check."
|
141
|
+
end
|
142
|
+
|
143
|
+
content = File.read(gemspec_path)
|
144
|
+
homepage_line = content.lines.find { |l| l =~ /\bspec\.homepage\s*=\s*/ }
|
145
|
+
if homepage_line.nil?
|
146
|
+
puts
|
147
|
+
puts "WARNING: spec.homepage not found in #{File.basename(gemspec_path)}."
|
148
|
+
puts "This gem should declare a GitHub homepage: https://github.com/<org>/<repo>"
|
149
|
+
else
|
150
|
+
assigned = homepage_line.split("=", 2).last.to_s.strip
|
151
|
+
interpolated = assigned.include?('#{')
|
152
|
+
|
153
|
+
if assigned.start_with?("\"", "'")
|
154
|
+
begin
|
155
|
+
assigned = assigned[1..-2]
|
156
|
+
rescue
|
157
|
+
# leave as-is
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
github_repo_from_url = lambda do |url|
|
162
|
+
return unless url
|
163
|
+
url = url.strip
|
164
|
+
m = url.match(%r{github\.com[/:]([^/\s:]+)/([^/\s]+?)(?:\.git)?/?\z}i)
|
165
|
+
return unless m
|
166
|
+
[m[1], m[2]]
|
167
|
+
end
|
168
|
+
|
169
|
+
github_homepage_literal = lambda do |val|
|
170
|
+
return false unless val
|
171
|
+
return false if val.include?('#{')
|
172
|
+
v = val.to_s.strip
|
173
|
+
if (v.start_with?("\"") && v.end_with?("\"")) || (v.start_with?("'") && v.end_with?("'"))
|
174
|
+
v = begin
|
175
|
+
v[1..-2]
|
176
|
+
rescue
|
177
|
+
v
|
178
|
+
end
|
179
|
+
end
|
180
|
+
return false unless v =~ %r{\Ahttps?://github\.com/}i
|
181
|
+
!!github_repo_from_url.call(v)
|
182
|
+
end
|
183
|
+
|
184
|
+
valid_literal = github_homepage_literal.call(assigned)
|
185
|
+
|
186
|
+
if interpolated || !valid_literal
|
187
|
+
puts
|
188
|
+
puts "Checking git remote 'origin' to derive GitHub homepage..."
|
189
|
+
origin_url = nil
|
190
|
+
begin
|
191
|
+
origin_cmd = ["git", "-C", project_root.to_s, "remote", "get-url", "origin"]
|
192
|
+
origin_out = IO.popen(origin_cmd, &:read)
|
193
|
+
origin_out = origin_out.read if origin_out.respond_to?(:read)
|
194
|
+
origin_url = origin_out.to_s.strip
|
195
|
+
rescue StandardError
|
196
|
+
origin_url = ""
|
197
|
+
end
|
198
|
+
|
199
|
+
org_repo = github_repo_from_url.call(origin_url)
|
200
|
+
unless org_repo
|
201
|
+
puts "ERROR: git remote 'origin' is not a GitHub URL (or not found): #{origin_url.empty? ? "(none)" : origin_url}"
|
202
|
+
puts "To complete installation: set your GitHub repository as the 'origin' remote, and move any other forge to an alternate name."
|
203
|
+
puts "Example:"
|
204
|
+
puts " git remote rename origin something_else"
|
205
|
+
puts " git remote add origin https://github.com/<org>/<repo>.git"
|
206
|
+
puts "After fixing, re-run: rake kettle:dev:install"
|
207
|
+
task_abort("Aborting: homepage cannot be corrected without a GitHub origin remote.")
|
208
|
+
end
|
209
|
+
|
210
|
+
org, repo = org_repo
|
211
|
+
suggested = "https://github.com/#{org}/#{repo}"
|
212
|
+
|
213
|
+
puts "Current spec.homepage appears #{interpolated ? "interpolated" : "invalid"}: #{assigned}"
|
214
|
+
puts "Suggested literal homepage: \"#{suggested}\""
|
215
|
+
print("Update #{File.basename(gemspec_path)} to use this homepage? [Y/n]: ")
|
216
|
+
ans = $stdin.gets&.strip
|
217
|
+
do_update = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
|
218
|
+
true
|
219
|
+
else
|
220
|
+
ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
|
221
|
+
end
|
222
|
+
|
223
|
+
if do_update
|
224
|
+
new_line = homepage_line.sub(/=.*/, "= \"#{suggested}\"\n")
|
225
|
+
new_content = content.sub(homepage_line, new_line)
|
226
|
+
File.open(gemspec_path, "w") { |f| f.write(new_content) }
|
227
|
+
puts "Updated spec.homepage in #{File.basename(gemspec_path)} to #{suggested}"
|
228
|
+
else
|
229
|
+
puts "Skipping update of spec.homepage. You should set it to: #{suggested}"
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
rescue StandardError => e
|
235
|
+
# Do not swallow intentional task aborts signaled via Kettle::Dev::Error
|
236
|
+
raise if e.is_a?(Kettle::Dev::Error)
|
237
|
+
puts "WARNING: An error occurred while checking gemspec homepage: #{e.class}: #{e.message}"
|
238
|
+
end
|
239
|
+
|
240
|
+
if defined?(updated_envrc_by_install) && updated_envrc_by_install
|
241
|
+
allowed_truthy = ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
|
242
|
+
force_truthy = ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
|
243
|
+
if allowed_truthy || force_truthy
|
244
|
+
reason = allowed_truthy ? "allowed=true" : "force=true"
|
245
|
+
puts "Proceeding after .envrc update because #{reason}."
|
246
|
+
else
|
247
|
+
puts
|
248
|
+
puts "IMPORTANT: .envrc was updated during kettle:dev:install."
|
249
|
+
puts "Please review it and then run:"
|
250
|
+
puts " direnv allow"
|
251
|
+
puts
|
252
|
+
puts "After that, re-run to resume:"
|
253
|
+
puts " bundle exec rake kettle:dev:install allowed=true"
|
254
|
+
task_abort("Aborting: direnv allow required after .envrc changes.")
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Summary of templating changes
|
259
|
+
begin
|
260
|
+
results = helpers.template_results
|
261
|
+
meaningful = results.select { |_, rec| [:create, :replace, :dir_create, :dir_replace].include?(rec[:action]) }
|
262
|
+
puts
|
263
|
+
puts "Summary of templating changes:"
|
264
|
+
if meaningful.empty?
|
265
|
+
puts " (no files were created or replaced by kettle:dev:template)"
|
266
|
+
else
|
267
|
+
action_labels = {
|
268
|
+
create: "Created",
|
269
|
+
replace: "Replaced",
|
270
|
+
dir_create: "Directory created",
|
271
|
+
dir_replace: "Directory replaced",
|
272
|
+
}
|
273
|
+
[:create, :replace, :dir_create, :dir_replace].each do |sym|
|
274
|
+
items = meaningful.select { |_, rec| rec[:action] == sym }.map { |path, _| path }
|
275
|
+
next if items.empty?
|
276
|
+
puts " #{action_labels[sym]}:"
|
277
|
+
items.sort.each do |abs|
|
278
|
+
rel = begin
|
279
|
+
abs.start_with?(project_root.to_s) ? abs.sub(/^#{Regexp.escape(project_root.to_s)}\/?/, "") : abs
|
280
|
+
rescue
|
281
|
+
abs
|
282
|
+
end
|
283
|
+
puts " - #{rel}"
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
rescue StandardError => e
|
288
|
+
puts
|
289
|
+
puts "Summary of templating changes: (unavailable: #{e.class}: #{e.message})"
|
290
|
+
end
|
291
|
+
|
292
|
+
puts
|
293
|
+
puts "kettle:dev:install complete."
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|