kettle-dev 2.4.5 → 2.5.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.
@@ -27,7 +27,8 @@ module Kettle
27
27
  # @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
28
28
  # @param update_prep [Boolean] when true, update the most recent prepared release section in place
29
29
  # @param version [String, nil] explicit version override for gems without a literal VERSION constant
30
- def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false)
30
+ # @param yes [Boolean] when true, approve the selected release plan without prompting
31
+ def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false)
31
32
  @root = root
32
33
  @changelog_path = File.join(@root, "CHANGELOG.md")
33
34
  @coverage_path = File.join(@root, "coverage", "coverage.json")
@@ -36,6 +37,7 @@ module Kettle
36
37
  @update_prep = update_prep
37
38
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
38
39
  @refresh_cache = refresh_cache
40
+ @yes = !!yes
39
41
  end
40
42
 
41
43
  # Main entry point to update CHANGELOG.md
@@ -250,8 +252,8 @@ module Kettle
250
252
  branch = default_branch_ref
251
253
  return nil unless tag && branch
252
254
 
253
- stdout, _stderr, status = Open3.capture3("git", "rev-list", "--count", "#{tag}..#{branch}", chdir: @root)
254
- status.success? ? stdout.to_i : nil
255
+ stdout, ok = git_capture(["rev-list", "--count", "#{tag}..#{branch}"])
256
+ ok ? stdout.to_i : nil
255
257
  end
256
258
 
257
259
  def release_tag_for_version(version)
@@ -261,15 +263,15 @@ module Kettle
261
263
  end
262
264
 
263
265
  def default_branch_ref
264
- stdout, _stderr, status = Open3.capture3("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", chdir: @root)
265
- return stdout.strip if status.success? && !stdout.strip.empty?
266
+ stdout, ok = git_capture(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])
267
+ return stdout.strip if ok && !stdout.strip.empty?
266
268
 
267
269
  %w[main master HEAD].find { |ref| git_ref_exists?(ref) }
268
270
  end
269
271
 
270
272
  def git_ref_exists?(ref)
271
- _stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--verify", "--quiet", ref, chdir: @root)
272
- status.success?
273
+ _stdout, ok = git_capture(["rev-parse", "--verify", "--quiet", ref])
274
+ ok
273
275
  end
274
276
 
275
277
  def detect_plan(changelog, version)
@@ -280,7 +282,7 @@ module Kettle
280
282
  gem_name = detect_gem_name
281
283
  latest_overall, latest_for_series = latest_released_versions(gem_name, version)
282
284
  rescue => e
283
- warn("[kettle-changelog] gem.coop release check failed: #{e.class}: #{e.message}")
285
+ warn("[kettle-changelog] RubyGems.org release check failed: #{e.class}: #{e.message}")
284
286
  warn("Proceeding without live release info.")
285
287
  end
286
288
 
@@ -345,6 +347,11 @@ module Kettle
345
347
  puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
346
348
  puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
347
349
  puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
350
+ if @yes
351
+ puts("Continue with this plan? [y/N]: y")
352
+ return
353
+ end
354
+
348
355
  print("Continue with this plan? [y/N]: ")
349
356
  ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
350
357
  return if ans == "y" || ans == "yes"
@@ -400,7 +407,7 @@ module Kettle
400
407
  end
401
408
 
402
409
  def latest_released_versions(gem_name, current_version)
403
- data = Kettle::Dev::GemCoopVersions.fetch(gem_name, version_hint: current_version, refresh: @refresh_cache)
410
+ data = Kettle::Dev::RubyGemsVersions.fetch(gem_name, version_hint: current_version, refresh: @refresh_cache)
404
411
  return [nil, nil, nil] unless data.is_a?(Array)
405
412
 
406
413
  versions = data.map { |h| h["number"] }.compact
@@ -736,7 +743,7 @@ module Kettle
736
743
  "K_SOUP_COV_DO" => "true",
737
744
  "K_SOUP_COV_FORMATTERS" => "json",
738
745
  "K_SOUP_COV_MIN_HARD" => @enforce_coverage_thresholds ? "true" : "false",
739
- "K_SOUP_COV_MULTI_FORMATTERS" => "true",
746
+ "K_SOUP_COV_MULTI_FORMATTERS" => "false",
740
747
  "K_SOUP_COV_OPEN_BIN" => ""
741
748
  }
742
749
  end
@@ -766,30 +773,59 @@ module Kettle
766
773
  end
767
774
  end
768
775
 
769
- begin
770
- # Run the canonical docs task to get the documentation percentage.
771
- out = +""
772
- commands.each do |command|
773
- prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
774
- output, _status = Open3.capture2(*command, {chdir: @root})
775
- out << output
776
- line = documented_percent_line(output)
777
- return line if line
776
+ # Run the canonical docs task to get the documentation percentage.
777
+ commands.each do |command|
778
+ prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
779
+ output, status = capture_yard_command(command)
780
+ unless command_successful?(status)
781
+ return handle_yard_documentation_failure(yard_command_failure_message(command, output, status))
778
782
  end
783
+ line = documented_percent_line(output)
784
+ return line if line
785
+ end
779
786
 
780
- if @strict
781
- raise "Could not find documented percentage in bin/rake yard output"
782
- else
783
- warn("Could not find documented percentage in bin/rake yard output.")
784
- nil
785
- end
786
- rescue => e
787
- if @strict
788
- raise "Failed to run bin/rake yard: #{e.class}: #{e.message}"
789
- else
790
- warn("Failed to run bin/rake yard: #{e.class}: #{e.message}")
791
- nil
792
- end
787
+ handle_yard_documentation_failure("Could not find documented percentage in YARD output")
788
+ end
789
+
790
+ def capture_yard_command(command)
791
+ Open3.capture2e(*command, {chdir: @root})
792
+ rescue => e
793
+ ["#{e.class}: #{e.message}", false]
794
+ end
795
+
796
+ def handle_yard_documentation_failure(message)
797
+ if @strict
798
+ raise message
799
+ else
800
+ warn(message)
801
+ nil
802
+ end
803
+ end
804
+
805
+ def command_successful?(status)
806
+ return status if status == true || status == false
807
+
808
+ !status.respond_to?(:success?) || status.success?
809
+ end
810
+
811
+ def yard_command_failure_message(command, output, status)
812
+ exit_status = status.respond_to?(:exitstatus) ? status.exitstatus : nil
813
+ message = "Failed to run #{yard_command_label(command)}"
814
+ message = "#{message} (exit #{exit_status})" if exit_status
815
+ output = output.to_s.strip
816
+ message = "#{message}: #{output}" unless output.empty?
817
+ message
818
+ end
819
+
820
+ def yard_command_label(command)
821
+ command = Array(command)
822
+ bin = command.first.to_s
823
+ if bin == File.join(@root, "bin", "rake")
824
+ "bin/rake #{command.drop(1).join(" ")}".strip
825
+ elsif bin == File.join(@root, "bin", "yard")
826
+ "bin/yard"
827
+ else
828
+ command.join(" ")
793
829
  end
794
830
  end
795
831
 
@@ -1111,13 +1147,16 @@ module Kettle
1111
1147
  # Return the root commit SHA of the current repository, or nil on failure.
1112
1148
  # Uses the generic GitAdapter#capture escape hatch so tests can stub it.
1113
1149
  def git_root_commit
1114
- adapter = Kettle::Dev::GitAdapter.new
1115
- out, ok = adapter.capture(["rev-list", "--max-parents=0", "HEAD"])
1150
+ out, ok = git_capture(["rev-list", "--max-parents=0", "HEAD"])
1116
1151
  sha = out.to_s.lines.last&.strip # take last line in case of multiple root commits
1117
1152
  (ok && sha && !sha.empty?) ? sha : nil
1118
1153
  rescue
1119
1154
  nil
1120
1155
  end
1156
+
1157
+ def git_capture(args)
1158
+ Kettle::Dev::GitAdapter.new(@root).capture(args)
1159
+ end
1121
1160
  end
1122
1161
  end
1123
1162
  end
@@ -267,8 +267,6 @@ module Kettle
267
267
  end
268
268
  module_function :collect_gitlab
269
269
 
270
- # -- internals (abort-on-failure legacy paths used elsewhere) --
271
-
272
270
  def monitor_github_internal!(restart_hint:, workflows: nil)
273
271
  root = Kettle::Dev::CIHelpers.project_root
274
272
  workflows = Array(workflows).empty? ? Kettle::Dev::CIHelpers.workflows_list(root) : Array(workflows)
@@ -22,10 +22,11 @@ module Kettle
22
22
  # - Creates an "all" remote that fetches from origin only and pushes to all three forges
23
23
  # - Attempts to fetch from each forge to determine availability and updates README federation summary
24
24
  #
25
- # @example Non-interactive run with defaults (origin: github, protocol: ssh)
25
+ # Examples:
26
+ # Non-interactive run with defaults (origin: github, protocol: ssh)
26
27
  # kettle-dvcs --force my-org my-repo
27
28
  #
28
- # @example Use GitLab as origin and HTTPS URLs
29
+ # Use GitLab as origin and HTTPS URLs
29
30
  # kettle-dvcs --origin gitlab --protocol https my-org my-repo
30
31
  class DvcsCLI
31
32
  DEFAULTS = {
@@ -14,7 +14,9 @@ module Kettle
14
14
  exit(0)
15
15
  end
16
16
 
17
- def print_header(script_basename)
17
+ def print_header(script_basename, argv = ARGV)
18
+ return unless consume_verbose!(argv)
19
+
18
20
  puts header(script_basename)
19
21
  end
20
22
 
@@ -22,6 +24,18 @@ module Kettle
22
24
  "== #{script_basename} v#{Kettle::Dev::Version::VERSION} =="
23
25
  end
24
26
 
27
+ def verbose?(argv)
28
+ argv.any? { |arg| arg == "--verbose" }
29
+ end
30
+
31
+ def consume_verbose!(argv)
32
+ index = argv.index("--verbose")
33
+ return false unless index
34
+
35
+ argv.delete_at(index)
36
+ true
37
+ end
38
+
25
39
  def requested?(argv, value_option: false)
26
40
  argv.each_with_index.any? do |arg, index|
27
41
  arg == "-v" || bare_long_version?(argv, arg, index, value_option: value_option)
@@ -28,7 +28,7 @@ module Kettle
28
28
  false
29
29
  end
30
30
  else
31
- out, st = Open3.capture2("git", "status", "--porcelain")
31
+ out, st = git_capture2("status", "--porcelain")
32
32
  st.success? && out.strip.empty?
33
33
  end
34
34
  rescue => e
@@ -43,16 +43,106 @@ module Kettle
43
43
  # @param args [Array<String>]
44
44
  # @return [Array<(String, Boolean)>] [output, success]
45
45
  def capture(args)
46
- out, status = Open3.capture2("git", *args)
46
+ out, status = git_capture2(*args)
47
47
  [out.strip, status.success?]
48
48
  rescue => e
49
49
  Kettle::Dev.debug_error(e, __method__)
50
50
  ["", false]
51
51
  end
52
52
 
53
+ # Stage all changes in the repository.
54
+ #
55
+ # @return [Boolean]
56
+ def add_all
57
+ git_system("add", "-A")
58
+ rescue => e
59
+ Kettle::Dev.debug_error(e, __method__)
60
+ false
61
+ end
62
+
63
+ # Stage selected paths.
64
+ #
65
+ # @param paths [Array<String>]
66
+ # @return [Boolean]
67
+ def add_paths(paths)
68
+ git_system("add", "--", *Array(paths).map(&:to_s))
69
+ rescue => e
70
+ Kettle::Dev.debug_error(e, __method__)
71
+ false
72
+ end
73
+
74
+ # Commit all staged/tracked changes with a message.
75
+ #
76
+ # @param message [String]
77
+ # @return [Boolean]
78
+ def commit_all(message)
79
+ git_system("commit", "-am", message.to_s)
80
+ rescue => e
81
+ Kettle::Dev.debug_error(e, __method__)
82
+ false
83
+ end
84
+
85
+ # Amend the current commit without changing its message.
86
+ #
87
+ # @return [Boolean]
88
+ def commit_amend_no_edit
89
+ git_system("commit", "--amend", "--no-edit")
90
+ rescue => e
91
+ Kettle::Dev.debug_error(e, __method__)
92
+ false
93
+ end
94
+
95
+ # Return whether a tracked path has no unstaged changes.
96
+ #
97
+ # @param path [String]
98
+ # @return [Boolean]
99
+ def diff_quiet?(path)
100
+ _out, status = git_capture2("diff", "--quiet", "--", path.to_s)
101
+ status.success?
102
+ rescue => e
103
+ Kettle::Dev.debug_error(e, __method__)
104
+ false
105
+ end
106
+
107
+ # Return whether a tracked path has no changes compared with HEAD.
108
+ #
109
+ # @param path [String]
110
+ # @return [Boolean]
111
+ def diff_head_quiet?(path)
112
+ _out, status = git_capture2("diff", "--quiet", "HEAD", "--", path.to_s)
113
+ status.success?
114
+ rescue => e
115
+ Kettle::Dev.debug_error(e, __method__)
116
+ false
117
+ end
118
+
119
+ # Create an annotated tag.
120
+ #
121
+ # @param tag [String]
122
+ # @param message [String]
123
+ # @return [Boolean]
124
+ def tag_annotated(tag, message)
125
+ git_system("tag", "-a", tag.to_s, "-m", message.to_s)
126
+ rescue => e
127
+ Kettle::Dev.debug_error(e, __method__)
128
+ false
129
+ end
130
+
131
+ # Soft reset to a ref.
132
+ #
133
+ # @param ref [String]
134
+ # @return [Boolean]
135
+ def reset_soft(ref)
136
+ git_system("reset", "--soft", ref.to_s)
137
+ rescue => e
138
+ Kettle::Dev.debug_error(e, __method__)
139
+ false
140
+ end
141
+
53
142
  # Create a new adapter rooted at the current working directory.
54
143
  # @return [void]
55
- def initialize
144
+ def initialize(root = Dir.pwd)
145
+ @root = File.expand_path(root.to_s)
56
146
  # Allow users/CI to opt out of using the 'git' gem even when available.
57
147
  # Set KETTLE_DEV_DISABLE_GIT_GEM to a truthy value ("1", "true", "yes") to force CLI backend.
58
148
  env_val = ENV["KETTLE_DEV_DISABLE_GIT_GEM"]
@@ -63,7 +153,7 @@ module Kettle
63
153
  else
64
154
  Kernel.require "git"
65
155
  @backend = :gem
66
- @git = ::Git.open(Dir.pwd)
156
+ @git = ::Git.open(@root)
67
157
  end
68
158
  rescue LoadError => e
69
159
  Kettle::Dev.debug_error(e, __method__, backtrace: false)
@@ -118,14 +208,14 @@ module Kettle
118
208
  # The ruby-git gem does not expose a dedicated API for "--tags" consistently across versions.
119
209
  # Use a shell fallback even when the gem backend is active. Tests should stub this method.
120
210
  if remote && !remote.to_s.empty?
121
- system("git", "push", remote.to_s, "--tags")
211
+ git_system("push", remote.to_s, "--tags")
122
212
  else
123
- system("git", "push", "--tags")
213
+ git_system("push", "--tags")
124
214
  end
125
215
  elsif remote && !remote.to_s.empty?
126
- system("git", "push", remote.to_s, "--tags")
216
+ git_system("push", remote.to_s, "--tags")
127
217
  else
128
- system("git", "push", "--tags")
218
+ git_system("push", "--tags")
129
219
  end
130
220
  rescue => e
131
221
  Kettle::Dev.debug_error(e, __method__)
@@ -137,7 +227,7 @@ module Kettle
137
227
  if @backend == :gem
138
228
  @git.current_branch
139
229
  else
140
- out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
230
+ out, status = git_capture2("rev-parse", "--abbrev-ref", "HEAD")
141
231
  status.success? ? out.strip : nil
142
232
  end
143
233
  rescue => e
@@ -157,7 +247,7 @@ module Kettle
157
247
  []
158
248
  end
159
249
  else
160
- out, status = Open3.capture2("git", "ls-files")
250
+ out, status = git_capture2("ls-files")
161
251
  status.success? ? out.split(/\r?\n/).reject(&:empty?) : []
162
252
  end
163
253
  rescue => e
@@ -174,7 +264,7 @@ module Kettle
174
264
  # @param path [String] path to the file, relative to the repository root
175
265
  # @return [String] raw porcelain blame output, or "" on error / untracked file
176
266
  def blame_porcelain(path)
177
- out, status = Open3.capture2("git", "blame", "--porcelain", path.to_s)
267
+ out, status = git_capture2("blame", "--porcelain", path.to_s)
178
268
  status.success? ? out : ""
179
269
  rescue => e
180
270
  Kettle::Dev.debug_error(e, __method__)
@@ -186,7 +276,7 @@ module Kettle
186
276
  if @backend == :gem
187
277
  @git.remotes.map(&:name)
188
278
  else
189
- out, status = Open3.capture2("git", "remote")
279
+ out, status = git_capture2("remote")
190
280
  status.success? ? out.split(/\r?\n/).map(&:strip).reject(&:empty?) : []
191
281
  end
192
282
  rescue => e
@@ -206,7 +296,7 @@ module Kettle
206
296
  end
207
297
  end
208
298
  else
209
- out, status = Open3.capture2("git", "remote", "-v")
299
+ out, status = git_capture2("remote", "-v")
210
300
  return {} unless status.success?
211
301
 
212
302
  urls = {}
@@ -230,7 +320,7 @@ module Kettle
230
320
  r = @git.remotes.find { |x| x.name == name }
231
321
  r&.url
232
322
  else
233
- out, status = Open3.capture2("git", "config", "--get", "remote.#{name}.url")
323
+ out, status = git_capture2("config", "--get", "remote.#{name}.url")
234
324
  status.success? ? out.strip : nil
235
325
  end
236
326
  rescue => e
@@ -246,7 +336,7 @@ module Kettle
246
336
  @git.checkout(branch)
247
337
  true
248
338
  else
249
- system("git", "checkout", branch.to_s)
339
+ git_system("checkout", branch.to_s)
250
340
  end
251
341
  rescue => e
252
342
  Kettle::Dev.debug_error(e, __method__)
@@ -262,7 +352,7 @@ module Kettle
262
352
  @git.pull(remote, branch)
263
353
  true
264
354
  else
265
- system("git", "pull", remote.to_s, branch.to_s)
355
+ git_system("pull", remote.to_s, branch.to_s)
266
356
  end
267
357
  rescue => e
268
358
  Kettle::Dev.debug_error(e, __method__)
@@ -282,14 +372,32 @@ module Kettle
282
372
  end
283
373
  true
284
374
  elsif ref
285
- system("git", "fetch", remote.to_s, ref.to_s)
375
+ git_system("fetch", remote.to_s, ref.to_s)
286
376
  else
287
- system("git", "fetch", remote.to_s)
377
+ git_system("fetch", remote.to_s)
288
378
  end
289
379
  rescue => e
290
380
  Kettle::Dev.debug_error(e, __method__)
291
381
  false
292
382
  end
383
+
384
+ private
385
+
386
+ def git_capture2(*args)
387
+ if @root == Dir.pwd
388
+ Open3.capture2("git", *args)
389
+ else
390
+ Open3.capture2("git", *args, chdir: @root)
391
+ end
392
+ end
393
+
394
+ def git_system(*args)
395
+ if @root == Dir.pwd
396
+ system("git", *args)
397
+ else
398
+ system("git", *args, chdir: @root)
399
+ end
400
+ end
293
401
  end
294
402
  end
295
403
  end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Kettle
6
+ module Dev
7
+ class InteractiveReleaseCommand
8
+ def initialize(secrets_provider:, input: $stdin, output: $stdout, error: $stderr)
9
+ @secrets_provider = secrets_provider
10
+ @input = input
11
+ @output = output
12
+ @error = error
13
+ @gem_signing_passphrase = nil
14
+ end
15
+
16
+ def call(env, cmd)
17
+ return call_with_pty(env, cmd) if pty_available?
18
+
19
+ call_with_open3(env, cmd)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :secrets_provider
25
+
26
+ def call_with_pty(env, cmd)
27
+ stdout_str = +""
28
+ status = nil
29
+ PTY.spawn(env, cmd) do |output, input, pid|
30
+ begin
31
+ loop do
32
+ chunk = output.readpartial(1024)
33
+ stdout_str << chunk
34
+ @output.print(chunk)
35
+ handle_prompt(input, chunk)
36
+ end
37
+ rescue Errno::EIO
38
+ # PTY raises EIO when the child process exits after closing the slave.
39
+ rescue Kettle::Dev::Error
40
+ Process.kill("TERM", pid)
41
+ raise
42
+ ensure
43
+ _, status = Process.wait2(pid)
44
+ end
45
+ end
46
+ [stdout_str, "", status]
47
+ end
48
+
49
+ def call_with_open3(env, cmd)
50
+ stdout_str = +""
51
+ stderr_str = +""
52
+ status = nil
53
+ Open3.popen3(env, cmd) do |input, output, error, wait_thread|
54
+ readers = [output, error]
55
+ until readers.empty?
56
+ ready = IO.select(readers)
57
+ ready.first.each do |reader|
58
+ begin
59
+ chunk = reader.readpartial(1024)
60
+ if reader.equal?(output)
61
+ stdout_str << chunk
62
+ @output.print(chunk)
63
+ else
64
+ stderr_str << chunk
65
+ @error.print(chunk)
66
+ end
67
+ handle_prompt(input, chunk)
68
+ rescue EOFError
69
+ readers.delete(reader)
70
+ end
71
+ end
72
+ end
73
+ status = wait_thread.value
74
+ end
75
+ [stdout_str, stderr_str, status]
76
+ end
77
+
78
+ def handle_prompt(input, chunk)
79
+ if otp_prompt?(chunk)
80
+ write_secret(input, secrets_provider.rubygems_otp, label: "RubyGems MFA code") if otp_response_prompt?(chunk)
81
+ return
82
+ end
83
+
84
+ write_secret(input, gem_signing_passphrase, label: "gem signing passphrase") if signing_password_prompt?(chunk)
85
+ end
86
+
87
+ def gem_signing_passphrase
88
+ @gem_signing_passphrase ||= secrets_provider.gem_signing_passphrase.to_s
89
+ end
90
+
91
+ def write_secret(input, value, label:)
92
+ secret = value.to_s
93
+ if secret.empty?
94
+ raise Kettle::Dev::Error, "#{label} prompt reached, but the configured release secrets provider returned no value. Aborting because secret prompts are not allowed when a secrets provider is configured."
95
+ end
96
+
97
+ @output.puts("#{label} loaded from configured secrets provider.")
98
+ input.write("#{secret}\n")
99
+ input.flush
100
+ end
101
+
102
+ def otp_prompt?(chunk)
103
+ chunk.match?(/(?:multi-factor authentication|OTP code|one-time password|\bCode:\s*)/i)
104
+ end
105
+
106
+ def otp_response_prompt?(chunk)
107
+ chunk.match?(/(?:OTP code|one-time password|\bCode:\s*)/i)
108
+ end
109
+
110
+ def signing_password_prompt?(chunk)
111
+ chunk.match?(/(?:enter\s+)?(?:PEM\s+)?pass(?:\s|-)?phrase\s*(?:for\s+[^:]+)?[:?]\s*\z/i) ||
112
+ chunk.match?(/(?:PEM|private key) password\s*[:?]\s*\z/i)
113
+ end
114
+
115
+ def pty_available?
116
+ return false unless RUBY_ENGINE == "ruby"
117
+
118
+ require "pty"
119
+ true
120
+ rescue LoadError
121
+ false
122
+ end
123
+ end
124
+ end
125
+ end