kettle-dev 1.0.9 → 1.0.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.github/workflows/coverage.yml.example +127 -0
  6. data/.github/workflows/discord-notifier.yml +2 -1
  7. data/.github/workflows/truffle.yml +0 -8
  8. data/.junie/guidelines.md +4 -3
  9. data/.simplecov +5 -1
  10. data/Appraisals +5 -0
  11. data/Appraisals.example +102 -0
  12. data/CHANGELOG.md +80 -25
  13. data/CHANGELOG.md.example +4 -4
  14. data/CONTRIBUTING.md +43 -1
  15. data/Gemfile +3 -0
  16. data/README.md +65 -14
  17. data/README.md.example +515 -0
  18. data/{Rakefile → Rakefile.example} +17 -35
  19. data/exe/kettle-changelog +401 -0
  20. data/exe/kettle-commit-msg +11 -143
  21. data/exe/kettle-readme-backers +8 -352
  22. data/exe/kettle-release +7 -706
  23. data/gemfiles/modular/optional.gemfile +5 -0
  24. data/lib/kettle/dev/ci_helpers.rb +1 -0
  25. data/lib/kettle/dev/commit_msg.rb +39 -0
  26. data/lib/kettle/dev/exit_adapter.rb +36 -0
  27. data/lib/kettle/dev/git_adapter.rb +185 -0
  28. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  29. data/lib/kettle/dev/input_adapter.rb +40 -0
  30. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  31. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  32. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  33. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  34. data/lib/kettle/dev/rakelib/install.rake +1 -295
  35. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  36. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  37. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  38. data/lib/kettle/dev/rakelib/template.rake +3 -465
  39. data/lib/kettle/dev/readme_backers.rb +340 -0
  40. data/lib/kettle/dev/release_cli.rb +674 -0
  41. data/lib/kettle/dev/tasks/ci_task.rb +337 -0
  42. data/lib/kettle/dev/tasks/install_task.rb +516 -0
  43. data/lib/kettle/dev/tasks/template_task.rb +593 -0
  44. data/lib/kettle/dev/template_helpers.rb +65 -12
  45. data/lib/kettle/dev/version.rb +1 -1
  46. data/lib/kettle/dev/versioning.rb +68 -0
  47. data/lib/kettle/dev.rb +30 -1
  48. data/lib/kettle-dev.rb +2 -3
  49. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  50. data/sig/kettle/dev/commit_msg.rbs +8 -0
  51. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  52. data/sig/kettle/dev/git_adapter.rbs +15 -0
  53. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  54. data/sig/kettle/dev/input_adapter.rbs +8 -0
  55. data/sig/kettle/dev/readme_backers.rbs +20 -0
  56. data/sig/kettle/dev/release_cli.rbs +8 -0
  57. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  58. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  59. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  60. data/sig/kettle/dev/tasks.rbs +0 -0
  61. data/sig/kettle/dev/template_helpers.rbs +3 -1
  62. data/sig/kettle/dev/version.rbs +0 -0
  63. data/sig/kettle/emoji_regex.rbs +5 -0
  64. data/sig/kettle-dev.rbs +0 -0
  65. data.tar.gz.sig +0 -0
  66. metadata +59 -10
  67. metadata.gz.sig +0 -0
  68. data/.gitlab-ci.yml +0 -45
@@ -0,0 +1,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/exit_adapter"
4
+ require "kettle/dev/input_adapter"
5
+
6
+ module Kettle
7
+ module Dev
8
+ module Tasks
9
+ module InstallTask
10
+ module_function
11
+
12
+ # Abort wrapper that avoids terminating the entire process during specs
13
+ def task_abort(msg)
14
+ if defined?(RSpec)
15
+ raise Kettle::Dev::Error, msg
16
+ else
17
+ Kettle::Dev::ExitAdapter.abort(msg)
18
+ end
19
+ end
20
+
21
+ def run
22
+ helpers = Kettle::Dev::TemplateHelpers
23
+ project_root = helpers.project_root
24
+
25
+ # Run file templating via dedicated task first
26
+ Rake::Task["kettle:dev:template"].invoke
27
+
28
+ # .tool-versions cleanup offers
29
+ tool_versions_path = File.join(project_root, ".tool-versions")
30
+ if File.file?(tool_versions_path)
31
+ rv = File.join(project_root, ".ruby-version")
32
+ rg = File.join(project_root, ".ruby-gemset")
33
+ to_remove = [rv, rg].select { |p| File.exist?(p) }
34
+ unless to_remove.empty?
35
+ if helpers.ask("Remove #{to_remove.map { |p| File.basename(p) }.join(" and ")} (managed by .tool-versions)?", true)
36
+ to_remove.each { |p| FileUtils.rm_f(p) }
37
+ puts "Removed #{to_remove.map { |p| File.basename(p) }.join(" and ")}"
38
+ end
39
+ end
40
+ end
41
+
42
+ # Trim MRI Ruby version badges in README.md to >= required_ruby_version from gemspec
43
+ begin
44
+ readme_path = File.join(project_root, "README.md")
45
+ if File.file?(readme_path)
46
+ md = helpers.gemspec_metadata(project_root)
47
+ min_ruby = md[:min_ruby].to_s.strip
48
+ if !min_ruby.empty?
49
+ # Compare using Gem::Version on major.minor
50
+ require "rubygems"
51
+ min_ver = begin
52
+ Gem::Version.new(min_ruby.sub(/\A(~>\s*|>=\s*)/, ""))
53
+ rescue
54
+ nil
55
+ end
56
+ if min_ver
57
+ content = File.read(readme_path)
58
+
59
+ # Detect all MRI ruby badge labels present
60
+ removed_labels = []
61
+
62
+ content.scan(/\[(?<label>💎ruby-(?<ver>\d+\.\d+)i)\]/) do |arr|
63
+ label, ver_s = arr
64
+ begin
65
+ ver = Gem::Version.new(ver_s)
66
+ if ver < min_ver
67
+ # Remove occurrences of badges using this label
68
+ label_re = Regexp.escape(label)
69
+ # Linked form: [![...][label]][...]
70
+ content = content.gsub(/\[!\[[^\]]*?\]\s*\[#{label_re}\]\s*\]\s*\[[^\]]+\]/, "")
71
+ # Unlinked form: ![...][label]
72
+ content = content.gsub(/!\[[^\]]*?\]\s*\[#{label_re}\]/, "")
73
+ removed_labels << label
74
+ end
75
+ rescue StandardError
76
+ # ignore
77
+ end
78
+ end
79
+
80
+ # Remove any MRI Ruby rows that are left with no badges/links after trimming
81
+ content = content.lines.reject { |ln|
82
+ if ln.start_with?("| Works with MRI Ruby")
83
+ cells = ln.split("|", -1)
84
+ # cells[0] is empty (leading |), cells[1] = label cell, cells[2] = badges cell
85
+ badge_cell = cells[2] || ""
86
+ badge_cell.strip.empty?
87
+ else
88
+ false
89
+ end
90
+ }.join
91
+
92
+ # Clean up extra repeated whitespace only when it appears between word characters, and only for non-table lines.
93
+ # This preserves Markdown table alignment and spacing around punctuation/symbols.
94
+ content = content.lines.map do |ln|
95
+ if ln.start_with?("|")
96
+ ln
97
+ else
98
+ # Squish only runs of spaces/tabs between word characters
99
+ ln.gsub(/(\w)[ \t]{2,}(\w)/u, "\\1 \\2")
100
+ end
101
+ end.join
102
+
103
+ # Remove reference definitions for removed labels that are no longer used
104
+ unless removed_labels.empty?
105
+ # Unique
106
+ removed_labels.uniq!
107
+ # Determine which labels are still referenced after edits
108
+ still_referenced = {}
109
+ removed_labels.each do |lbl|
110
+ lbl_re = Regexp.escape(lbl)
111
+ # Consider a label referenced only when it appears not as a definition (i.e., not followed by colon)
112
+ still_referenced[lbl] = !!(content =~ /\[#{lbl_re}\](?!:)/)
113
+ end
114
+
115
+ new_lines = content.lines.map do |line|
116
+ if line =~ /^\[(?<lab>[^\]]+)\]:/ && removed_labels.include?(Regexp.last_match(:lab))
117
+ # Only drop if not referenced anymore
118
+ still_referenced[Regexp.last_match(:lab)] ? line : nil
119
+ else
120
+ line
121
+ end
122
+ end.compact
123
+ content = new_lines.join
124
+ end
125
+
126
+ File.open(readme_path, "w") { |f| f.write(content) }
127
+ end
128
+ end
129
+ end
130
+ rescue StandardError => e
131
+ puts "WARNING: Skipped trimming MRI Ruby badges in README.md due to #{e.class}: #{e.message}"
132
+ end
133
+
134
+ # Synchronize leading grapheme (emoji) between README H1 and gemspec summary/description
135
+ begin
136
+ readme_path = File.join(project_root, "README.md")
137
+ gemspecs = Dir.glob(File.join(project_root, "*.gemspec"))
138
+ if File.file?(readme_path) && !gemspecs.empty?
139
+ gemspec_path = gemspecs.first
140
+ readme = File.read(readme_path)
141
+ first_h1_idx = readme.lines.index { |ln| ln =~ /^#\s+/ }
142
+ chosen_grapheme = nil
143
+ if first_h1_idx
144
+ lines = readme.split("\n", -1)
145
+ h1 = lines[first_h1_idx]
146
+ tail = h1.sub(/^#\s+/, "")
147
+ begin
148
+ emoji_re = Kettle::EmojiRegex::REGEX
149
+ # Extract first emoji grapheme cluster if present
150
+ if tail =~ /\A#{emoji_re.source}/u
151
+ cluster = tail[/\A\X/u]
152
+ chosen_grapheme = cluster unless cluster.to_s.empty?
153
+ end
154
+ rescue StandardError
155
+ # Fallback: take first Unicode grapheme if any non-space char
156
+ chosen_grapheme ||= tail[/\A\X/u]
157
+ end
158
+ end
159
+
160
+ # If no grapheme found in README H1, ask the user which to use
161
+ if chosen_grapheme.nil? || chosen_grapheme.empty?
162
+ puts "No grapheme found after README H1. Enter a grapheme (emoji/symbol) to use for README, summary, and description:"
163
+ print("Grapheme: ")
164
+ ans = Kettle::Dev::InputAdapter.gets&.strip.to_s
165
+ chosen_grapheme = ans[/\A\X/u].to_s
166
+ # If still empty, skip synchronization silently
167
+ chosen_grapheme = nil if chosen_grapheme.empty?
168
+ end
169
+
170
+ if chosen_grapheme
171
+ # 1) Normalize README H1 to exactly one grapheme + single space after '#'
172
+ begin
173
+ lines = readme.split("\n", -1)
174
+ idx = lines.index { |ln| ln =~ /^#\s+/ }
175
+ if idx
176
+ rest = lines[idx].sub(/^#\s+/, "")
177
+ begin
178
+ emoji_re = Kettle::EmojiRegex::REGEX
179
+ # Remove any leading emojis from the H1 by peeling full grapheme clusters
180
+ tmp = rest.dup
181
+ while tmp =~ /\A#{emoji_re.source}/u
182
+ cluster = tmp[/\A\X/u]
183
+ tmp = tmp[cluster.length..-1].to_s
184
+ end
185
+ rest_wo_emoji = tmp.sub(/\A\s+/, "")
186
+ rescue StandardError
187
+ rest_wo_emoji = rest.sub(/\A\s+/, "")
188
+ end
189
+ # Build H1 with single spaces only around separators; preserve inner spacing in rest_wo_emoji
190
+ new_line = ["#", chosen_grapheme, rest_wo_emoji].join(" ").sub(/^#\s+/, "# ")
191
+ lines[idx] = new_line
192
+ new_readme = lines.join("\n")
193
+ File.open(readme_path, "w") { |f| f.write(new_readme) }
194
+ end
195
+ rescue StandardError
196
+ # ignore README normalization errors
197
+ end
198
+
199
+ # 2) Update gemspec summary and description to start with grapheme + single space
200
+ begin
201
+ gspec = File.read(gemspec_path)
202
+
203
+ normalize_field = lambda do |text, field|
204
+ # Match the assignment line and the first quoted string
205
+ text.gsub(/(\b#{Regexp.escape(field)}\s*=\s*)(["'])([^\"']*)(\2)/) do
206
+ pre = Regexp.last_match(1)
207
+ q = Regexp.last_match(2)
208
+ body = Regexp.last_match(3)
209
+ # Strip existing leading emojis and spaces
210
+ begin
211
+ emoji_re = Kettle::EmojiRegex::REGEX
212
+ tmp = body.dup
213
+ tmp = tmp.sub(/\A\s+/, "")
214
+ while tmp =~ /\A#{emoji_re.source}/u
215
+ cluster = tmp[/\A\X/u]
216
+ tmp = tmp[cluster.length..-1].to_s
217
+ end
218
+ tmp = tmp.sub(/\A\s+/, "")
219
+ body_wo = tmp
220
+ rescue StandardError
221
+ body_wo = body.sub(/\A\s+/, "")
222
+ end
223
+ pre + q + ("#{chosen_grapheme} " + body_wo) + q
224
+ end
225
+ end
226
+
227
+ gspec2 = normalize_field.call(gspec, "spec.summary")
228
+ gspec3 = normalize_field.call(gspec2, "spec.description")
229
+ if gspec3 != gspec
230
+ File.open(gemspec_path, "w") { |f| f.write(gspec3) }
231
+ end
232
+ rescue StandardError
233
+ # ignore gemspec edits on error
234
+ end
235
+ end
236
+ end
237
+ rescue StandardError => e
238
+ puts "WARNING: Skipped grapheme synchronization due to #{e.class}: #{e.message}"
239
+ end
240
+
241
+ # Perform final whitespace normalization for README: only squish whitespace between word characters (non-table lines)
242
+ begin
243
+ readme_path = File.join(project_root, "README.md")
244
+ if File.file?(readme_path)
245
+ content = File.read(readme_path)
246
+ content = content.lines.map do |ln|
247
+ if ln.start_with?("|")
248
+ ln
249
+ else
250
+ ln.gsub(/(\w)[ \t]{2,}(\w)/u, "\\1 \\2")
251
+ end
252
+ end.join
253
+ File.open(readme_path, "w") { |f| f.write(content) }
254
+ end
255
+ rescue StandardError
256
+ # ignore whitespace normalization errors
257
+ end
258
+
259
+ # Validate gemspec homepage points to GitHub and is a non-interpolated string
260
+ begin
261
+ gemspecs = Dir.glob(File.join(project_root, "*.gemspec"))
262
+ if gemspecs.empty?
263
+ puts
264
+ puts "No .gemspec found in #{project_root}; skipping homepage check."
265
+ else
266
+ gemspec_path = gemspecs.first
267
+ if gemspecs.size > 1
268
+ puts
269
+ puts "Multiple gemspecs found; defaulting to #{File.basename(gemspec_path)} for homepage check."
270
+ end
271
+
272
+ content = File.read(gemspec_path)
273
+ homepage_line = content.lines.find { |l| l =~ /\bspec\.homepage\s*=\s*/ }
274
+ if homepage_line.nil?
275
+ puts
276
+ puts "WARNING: spec.homepage not found in #{File.basename(gemspec_path)}."
277
+ puts "This gem should declare a GitHub homepage: https://github.com/<org>/<repo>"
278
+ else
279
+ assigned = homepage_line.split("=", 2).last.to_s.strip
280
+ interpolated = assigned.include?('#{')
281
+
282
+ if assigned.start_with?("\"", "'")
283
+ begin
284
+ assigned = assigned[1..-2]
285
+ rescue
286
+ # leave as-is
287
+ end
288
+ end
289
+
290
+ github_repo_from_url = lambda do |url|
291
+ return unless url
292
+ url = url.strip
293
+ m = url.match(%r{github\.com[/:]([^/\s:]+)/([^/\s]+?)(?:\.git)?/?\z}i)
294
+ return unless m
295
+ [m[1], m[2]]
296
+ end
297
+
298
+ github_homepage_literal = lambda do |val|
299
+ return false unless val
300
+ return false if val.include?('#{')
301
+ v = val.to_s.strip
302
+ if (v.start_with?("\"") && v.end_with?("\"")) || (v.start_with?("'") && v.end_with?("'"))
303
+ v = begin
304
+ v[1..-2]
305
+ rescue
306
+ v
307
+ end
308
+ end
309
+ return false unless v =~ %r{\Ahttps?://github\.com/}i
310
+ !!github_repo_from_url.call(v)
311
+ end
312
+
313
+ valid_literal = github_homepage_literal.call(assigned)
314
+
315
+ if interpolated || !valid_literal
316
+ puts
317
+ puts "Checking git remote 'origin' to derive GitHub homepage..."
318
+ origin_url = nil
319
+ begin
320
+ origin_cmd = ["git", "-C", project_root.to_s, "remote", "get-url", "origin"]
321
+ origin_out = IO.popen(origin_cmd, &:read)
322
+ origin_out = origin_out.read if origin_out.respond_to?(:read)
323
+ origin_url = origin_out.to_s.strip
324
+ rescue StandardError
325
+ origin_url = ""
326
+ end
327
+
328
+ org_repo = github_repo_from_url.call(origin_url)
329
+ unless org_repo
330
+ puts "ERROR: git remote 'origin' is not a GitHub URL (or not found): #{origin_url.empty? ? "(none)" : origin_url}"
331
+ puts "To complete installation: set your GitHub repository as the 'origin' remote, and move any other forge to an alternate name."
332
+ puts "Example:"
333
+ puts " git remote rename origin something_else"
334
+ puts " git remote add origin https://github.com/<org>/<repo>.git"
335
+ puts "After fixing, re-run: rake kettle:dev:install"
336
+ task_abort("Aborting: homepage cannot be corrected without a GitHub origin remote.")
337
+ end
338
+
339
+ org, repo = org_repo
340
+ suggested = "https://github.com/#{org}/#{repo}"
341
+
342
+ puts "Current spec.homepage appears #{interpolated ? "interpolated" : "invalid"}: #{assigned}"
343
+ puts "Suggested literal homepage: \"#{suggested}\""
344
+ print("Update #{File.basename(gemspec_path)} to use this homepage? [Y/n]: ")
345
+ ans = Kettle::Dev::InputAdapter.gets&.strip
346
+ do_update = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
347
+ true
348
+ else
349
+ ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
350
+ end
351
+
352
+ if do_update
353
+ new_line = homepage_line.sub(/=.*/, "= \"#{suggested}\"\n")
354
+ new_content = content.sub(homepage_line, new_line)
355
+ File.open(gemspec_path, "w") { |f| f.write(new_content) }
356
+ puts "Updated spec.homepage in #{File.basename(gemspec_path)} to #{suggested}"
357
+ else
358
+ puts "Skipping update of spec.homepage. You should set it to: #{suggested}"
359
+ end
360
+ end
361
+ end
362
+ end
363
+ rescue StandardError => e
364
+ # Do not swallow intentional task aborts signaled via Kettle::Dev::Error
365
+ raise if e.is_a?(Kettle::Dev::Error)
366
+ puts "WARNING: An error occurred while checking gemspec homepage: #{e.class}: #{e.message}"
367
+ end
368
+
369
+ # Summary of templating changes
370
+ begin
371
+ results = helpers.template_results
372
+ meaningful = results.select { |_, rec| [:create, :replace, :dir_create, :dir_replace].include?(rec[:action]) }
373
+ puts
374
+ puts "Summary of templating changes:"
375
+ if meaningful.empty?
376
+ puts " (no files were created or replaced by kettle:dev:template)"
377
+ else
378
+ action_labels = {
379
+ create: "Created",
380
+ replace: "Replaced",
381
+ dir_create: "Directory created",
382
+ dir_replace: "Directory replaced",
383
+ }
384
+ [:create, :replace, :dir_create, :dir_replace].each do |sym|
385
+ items = meaningful.select { |_, rec| rec[:action] == sym }.map { |path, _| path }
386
+ next if items.empty?
387
+ puts " #{action_labels[sym]}:"
388
+ items.sort.each do |abs|
389
+ rel = begin
390
+ abs.start_with?(project_root.to_s) ? abs.sub(/^#{Regexp.escape(project_root.to_s)}\/?/, "") : abs
391
+ rescue
392
+ abs
393
+ end
394
+ puts " - #{rel}"
395
+ end
396
+ end
397
+ end
398
+ rescue StandardError => e
399
+ puts
400
+ puts "Summary of templating changes: (unavailable: #{e.class}: #{e.message})"
401
+ end
402
+
403
+ puts
404
+ puts "Next steps:"
405
+ puts "1) Configure a shared git hooks path (optional, recommended):"
406
+ puts " git config --global core.hooksPath .git-hooks"
407
+ puts
408
+ puts "2) Install binstubs for this gem so the commit-msg tool is available in ./bin:"
409
+ puts " bundle binstubs kettle-dev --path bin"
410
+ puts " # After running, you should have bin/kettle-commit-msg (wrapper)."
411
+ puts
412
+ # Step 3: direnv and .envrc
413
+ envrc_path = File.join(project_root, ".envrc")
414
+ puts "3) Install direnv (if not already):"
415
+ puts " brew install direnv"
416
+ if helpers.modified_by_template?(envrc_path)
417
+ puts " Your .envrc was created/updated by kettle:dev:template."
418
+ puts " It includes PATH_add bin so that executables in ./bin are on PATH when direnv is active."
419
+ puts " This allows running tools without the bin/ prefix inside the project directory."
420
+ else
421
+ begin
422
+ current = File.file?(envrc_path) ? File.read(envrc_path) : ""
423
+ rescue StandardError
424
+ current = ""
425
+ end
426
+ has_path_add = current.lines.any? { |l| l.strip =~ /^PATH_add\s+bin\b/ }
427
+ if has_path_add
428
+ puts " Your .envrc already contains PATH_add bin."
429
+ else
430
+ puts " Adding PATH_add bin to your project's .envrc is recommended to expose ./bin on PATH."
431
+ if helpers.ask("Add PATH_add bin to #{envrc_path}?", false)
432
+ content = current.dup
433
+ insertion = "# Run any command in this project's bin/ without the bin/ prefix\nPATH_add bin\n"
434
+ if content.empty?
435
+ content = insertion
436
+ else
437
+ content = insertion + "\n" + content unless content.start_with?(insertion)
438
+ end
439
+ # Ensure a stale directory at .envrc is removed so the file can be written
440
+ FileUtils.rm_rf(envrc_path) if File.directory?(envrc_path)
441
+ File.open(envrc_path, "w") { |f| f.write(content) }
442
+ puts " Updated #{envrc_path} with PATH_add bin"
443
+ updated_envrc_by_install = true
444
+ else
445
+ puts " Skipping modification of .envrc. You may add 'PATH_add bin' manually at the top."
446
+ end
447
+ end
448
+ end
449
+
450
+ if defined?(updated_envrc_by_install) && updated_envrc_by_install
451
+ allowed_truthy = ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
452
+ force_truthy = ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
453
+ if allowed_truthy || force_truthy
454
+ reason = allowed_truthy ? "allowed=true" : "force=true"
455
+ puts "Proceeding after .envrc update because #{reason}."
456
+ else
457
+ puts
458
+ puts "IMPORTANT: .envrc was updated during kettle:dev:install."
459
+ puts "Please review it and then run:"
460
+ puts " direnv allow"
461
+ puts
462
+ puts "After that, re-run to resume:"
463
+ puts " bundle exec rake kettle:dev:install allowed=true"
464
+ task_abort("Aborting: direnv allow required after .envrc changes.")
465
+ end
466
+ end
467
+
468
+ # Warn about .env.local and offer to add it to .gitignore
469
+ puts
470
+ puts "WARNING: Do not commit .env.local; it often contains machine-local secrets."
471
+ puts "Ensure your .gitignore includes:"
472
+ puts " # direnv - brew install direnv"
473
+ puts " .env.local"
474
+
475
+ gitignore_path = File.join(project_root, ".gitignore")
476
+ unless helpers.modified_by_template?(gitignore_path)
477
+ begin
478
+ gitignore_current = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
479
+ rescue StandardError
480
+ gitignore_current = ""
481
+ end
482
+ has_env_local = gitignore_current.lines.any? { |l| l.strip == ".env.local" }
483
+ unless has_env_local
484
+ puts
485
+ puts "Would you like to add '.env.local' to #{gitignore_path}?"
486
+ print("Add to .gitignore now [Y/n]: ")
487
+ answer = Kettle::Dev::InputAdapter.gets&.strip
488
+ add_it = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
489
+ true
490
+ else
491
+ answer.nil? || answer.empty? || answer =~ /\Ay(es)?\z/i
492
+ end
493
+ if add_it
494
+ FileUtils.mkdir_p(File.dirname(gitignore_path))
495
+ mode = File.exist?(gitignore_path) ? "a" : "w"
496
+ File.open(gitignore_path, mode) do |f|
497
+ f.write("\n") unless gitignore_current.empty? || gitignore_current.end_with?("\n")
498
+ unless gitignore_current.lines.any? { |l| l.strip == "# direnv - brew install direnv" }
499
+ f.write("# direnv - brew install direnv\n")
500
+ end
501
+ f.write(".env.local\n")
502
+ end
503
+ puts "Added .env.local to #{gitignore_path}"
504
+ else
505
+ puts "Skipping modification of .gitignore. Remember to add .env.local to avoid committing it."
506
+ end
507
+ end
508
+ end
509
+
510
+ puts
511
+ puts "kettle:dev:install complete."
512
+ end
513
+ end
514
+ end
515
+ end
516
+ end