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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.junie/guidelines.md +4 -3
  6. data/.simplecov +5 -1
  7. data/Appraisals +3 -0
  8. data/CHANGELOG.md +22 -1
  9. data/CONTRIBUTING.md +6 -0
  10. data/README.md +18 -5
  11. data/Rakefile +7 -11
  12. data/exe/kettle-commit-msg +9 -143
  13. data/exe/kettle-readme-backers +7 -353
  14. data/exe/kettle-release +8 -702
  15. data/lib/kettle/dev/ci_helpers.rb +1 -0
  16. data/lib/kettle/dev/commit_msg.rb +39 -0
  17. data/lib/kettle/dev/exit_adapter.rb +36 -0
  18. data/lib/kettle/dev/git_adapter.rb +120 -0
  19. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  20. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  21. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  22. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  23. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  24. data/lib/kettle/dev/rakelib/install.rake +1 -295
  25. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  26. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  27. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  28. data/lib/kettle/dev/rakelib/template.rake +3 -465
  29. data/lib/kettle/dev/readme_backers.rb +340 -0
  30. data/lib/kettle/dev/release_cli.rb +672 -0
  31. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  32. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  33. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  34. data/lib/kettle/dev/template_helpers.rb +4 -4
  35. data/lib/kettle/dev/version.rb +1 -1
  36. data/lib/kettle/dev.rb +30 -1
  37. data/lib/kettle-dev.rb +2 -3
  38. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  39. data/sig/kettle/dev/commit_msg.rbs +8 -0
  40. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  41. data/sig/kettle/dev/git_adapter.rbs +15 -0
  42. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  43. data/sig/kettle/dev/readme_backers.rbs +20 -0
  44. data/sig/kettle/dev/release_cli.rbs +8 -0
  45. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  46. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  47. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  48. data/sig/kettle/dev/tasks.rbs +0 -0
  49. data/sig/kettle/dev/version.rbs +0 -0
  50. data/sig/kettle/emoji_regex.rbs +5 -0
  51. data/sig/kettle-dev.rbs +0 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +55 -5
  54. 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