Skip to content

Commit abd3fae

Browse files
committed
install/upgrade: add --non-interactive and --timeout-wait-for-user; env: HOMEBREW_NON_INTERACTIVE, HOMEBREW_PROMPT_TIMEOUT_SECS; system_command: non-interactive sudo and prompt-aware timeout with skip reporting
1 parent dbe68ef commit abd3fae

File tree

5 files changed

+121
-5
lines changed

5 files changed

+121
-5
lines changed

Library/Homebrew/cmd/install.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ class InstallCmd < AbstractCommand
4747
description: "Ask for confirmation before downloading and installing formulae. " \
4848
"Print download and install sizes of bottles and dependencies.",
4949
env: :ask
50+
switch "--non-interactive",
51+
description: "Fail fast on any interactive prompt and use non-interactive sudo where applicable.",
52+
env: :non_interactive
53+
flag "--timeout-wait-for-user=",
54+
description: "Wait this many seconds when an interactive prompt is detected; then skip the item.",
55+
env: :prompt_timeout_secs
5056
[
5157
[:switch, "--formula", "--formulae", {
5258
description: "Treat all named arguments as formulae.",
@@ -176,6 +182,14 @@ class InstallCmd < AbstractCommand
176182

177183
sig { override.void }
178184
def run
185+
# Apply context for non-interactive/verbosity here so lower layers can query Context
186+
if args.non_interactive?
187+
ENV["HOMEBREW_NON_INTERACTIVE"] = "1"
188+
end
189+
if args.timeout_wait_for_user.present?
190+
ENV["HOMEBREW_PROMPT_TIMEOUT_SECS"] = args.timeout_wait_for_user
191+
end
192+
179193
if args.env.present?
180194
# Can't use `replacement: false` because `install_args` are used by
181195
# `build.rb`. Instead, `hide_from_man_page` and don't do anything with
@@ -267,7 +281,7 @@ def run
267281
end
268282

269283
new_casks.each do |cask|
270-
Cask::Installer.new(
284+
installer = Cask::Installer.new(
271285
cask,
272286
adopt: args.adopt?,
273287
binaries: args.binaries?,
@@ -277,7 +291,14 @@ def run
277291
require_sha: args.require_sha?,
278292
skip_cask_deps: args.skip_cask_deps?,
279293
verbose: args.verbose?,
280-
).install
294+
)
295+
begin
296+
installer.install
297+
rescue Timeout::Error => e
298+
opoo "Timed out waiting for user input in cask #{cask.full_name}. Skipping."
299+
Homebrew.messages.record_skipped_prompt(cask.full_name, e.message)
300+
next
301+
end
281302
end
282303

283304
if !Homebrew::EnvConfig.no_install_upgrade? && installed_casks.any?

Library/Homebrew/cmd/upgrade.rb

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ class UpgradeCmd < AbstractCommand
4343
description: "Ask for confirmation before downloading and upgrading formulae. " \
4444
"Print download, install and net install sizes of bottles and dependencies.",
4545
env: :ask
46+
switch "--non-interactive",
47+
description: "Fail fast on any interactive prompt and use non-interactive sudo where applicable.",
48+
env: :non_interactive
49+
flag "--timeout-wait-for-user=",
50+
description: "Wait this many seconds when an interactive prompt is detected; then skip the item.",
51+
env: :prompt_timeout_secs
4652
[
4753
[:switch, "--formula", "--formulae", {
4854
description: "Treat all named arguments as formulae. If no named arguments " \
@@ -125,6 +131,12 @@ class UpgradeCmd < AbstractCommand
125131

126132
sig { override.void }
127133
def run
134+
if args.non_interactive?
135+
ENV["HOMEBREW_NON_INTERACTIVE"] = "1"
136+
end
137+
if args.timeout_wait_for_user.present?
138+
ENV["HOMEBREW_PROMPT_TIMEOUT_SECS"] = args.timeout_wait_for_user
139+
end
128140
if args.build_from_source? && args.named.empty?
129141
raise ArgumentError, "--build-from-source requires at least one formula"
130142
end
@@ -286,7 +298,8 @@ def upgrade_outdated_casks!(casks)
286298

287299
Install.ask_casks casks if args.ask?
288300

289-
Cask::Upgrade.upgrade_casks!(
301+
begin
302+
Cask::Upgrade.upgrade_casks!(
290303
*casks,
291304
force: args.force?,
292305
greedy: args.greedy?,
@@ -300,7 +313,13 @@ def upgrade_outdated_casks!(casks)
300313
verbose: args.verbose?,
301314
quiet: args.quiet?,
302315
args:,
303-
)
316+
)
317+
rescue Timeout::Error => e
318+
# When multiple casks are processed, `upgrade_casks!` handles each sequentially
319+
# Skips are recorded inside the installer loop where errors are rescued
320+
opoo "Timed out waiting for user input while upgrading casks. Some items may be skipped."
321+
Homebrew.messages.record_skipped_prompt("cask-upgrade", e.message)
322+
end
304323
end
305324
end
306325
end

Library/Homebrew/env_config.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,16 @@ module EnvConfig
469469
"fetching Git repositories over SSH.",
470470
default_text: "`~/.ssh/config`",
471471
},
472+
HOMEBREW_NON_INTERACTIVE: {
473+
description: "If set, treat Homebrew commands as non-interactive: use `sudo -n` when elevating and fail " \
474+
"immediately on any interactive prompt.",
475+
boolean: true,
476+
},
477+
HOMEBREW_PROMPT_TIMEOUT_SECS: {
478+
description: "If set, when an interactive prompt (e.g., sudo/password) is detected during installation, " \
479+
"wait this many seconds and then skip/fail the current item while continuing others. " \
480+
"Unset to wait indefinitely.",
481+
},
472482
HOMEBREW_SUDO_THROUGH_SUDO_USER: {
473483
description: "If set, Homebrew will use the `$SUDO_USER` environment variable to define the user to " \
474484
"`sudo`(8) through when running `sudo`(8).",

Library/Homebrew/messages.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ class Messages
1313
sig { returns(T::Array[T::Hash[String, Float]]) }
1414
attr_reader :install_times
1515

16+
sig { returns(T::Array[T::Hash[Symbol, String]]) }
17+
attr_reader :skipped_prompts
18+
1619
sig { void }
1720
def initialize
1821
@caveats = T.let([], T::Array[T::Hash[Symbol, Symbol]])
1922
@completions_and_elisp = T.let(Set.new, T::Set[String])
2023
@package_count = T.let(0, Integer)
2124
@install_times = T.let([], T::Array[T::Hash[String, Float]])
25+
@skipped_prompts = T.let([], T::Array[T::Hash[Symbol, String]])
2226
end
2327

2428
sig { params(package: String, caveats: T.any(String, Caveats)).void }
@@ -40,9 +44,25 @@ def package_installed(package, elapsed_time)
4044
sig { params(force_caveats: T::Boolean, display_times: T::Boolean).void }
4145
def display_messages(force_caveats: false, display_times: false)
4246
display_caveats(force: force_caveats)
47+
display_skipped_prompts
4348
display_install_times if display_times
4449
end
4550

51+
sig { params(package: String, reason: String).void }
52+
def record_skipped_prompt(package, reason)
53+
@skipped_prompts.push(package:, reason:)
54+
end
55+
56+
sig { void }
57+
def display_skipped_prompts
58+
return if @skipped_prompts.empty?
59+
60+
oh1 "Skipped items due to interactive prompts"
61+
@skipped_prompts.each do |entry|
62+
puts "#{entry[:package]}: #{entry[:reason]}"
63+
end
64+
end
65+
4666
sig { params(force: T::Boolean).void }
4767
def display_caveats(force: false)
4868
return if @package_count.zero?

Library/Homebrew/system_command.rb

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ def run!
4848

4949
@output = []
5050

51+
@prompt_timeout_secs = nil
52+
begin
53+
prompt_timeout_env = Homebrew::EnvConfig.prompt_timeout_secs
54+
if prompt_timeout_env && !prompt_timeout_env.strip.empty?
55+
@prompt_timeout_secs = Integer(prompt_timeout_env) rescue Float(prompt_timeout_env)
56+
end
57+
rescue ArgumentError, TypeError
58+
@prompt_timeout_secs = nil
59+
end
60+
61+
@prompt_detected_at = nil
62+
@terminated_due_to_prompt_timeout = false
63+
5164
each_output_line do |type, line|
5265
case type
5366
when :stdout
@@ -67,9 +80,17 @@ def run!
6780
end
6881
@output << [:stderr, line]
6982
end
83+
84+
if @prompt_timeout_secs
85+
# Detect common interactive prompt patterns
86+
if line =~ /[Pp]assword:/ || line.include?("a password is required") || line =~ /installer: .*authorization/i
87+
@prompt_detected_at ||= Time.now
88+
end
89+
end
7090
end
7191

7292
result = Result.new(command, @output, @status, secrets: @secrets)
93+
raise Timeout::Error, "Interactive prompt timeout" if @terminated_due_to_prompt_timeout
7394
result.assert_success! if must_succeed?
7495
result
7596
end
@@ -199,6 +220,7 @@ def homebrew_sudo_user
199220
sig { returns(T::Array[String]) }
200221
def sudo_prefix
201222
askpass_flags = ENV.key?("SUDO_ASKPASS") ? ["-A"] : []
223+
non_interactive_flags = Homebrew::EnvConfig.non_interactive? ? ["-n"] : []
202224
user_flags = []
203225
if Homebrew::EnvConfig.sudo_through_sudo_user?
204226
if homebrew_sudo_user.blank?
@@ -211,7 +233,7 @@ def sudo_prefix
211233
"--", "/usr/bin/sudo"]
212234
end
213235
user_flags += ["-u", "root"] if sudo_as_root?
214-
["/usr/bin/sudo", *user_flags, *askpass_flags, "-E", *env_args, "--"]
236+
["/usr/bin/sudo", *user_flags, *askpass_flags, *non_interactive_flags, "-E", *env_args, "--"]
215237
end
216238

217239
sig { returns(T::Array[String]) }
@@ -252,6 +274,29 @@ def each_output_line(&block)
252274

253275
raw_stdin, raw_stdout, raw_stderr, raw_wait_thr = exec3(env, executable, *args, **options)
254276

277+
monitor_thread = nil
278+
if @prompt_timeout_secs
279+
monitor_thread = Thread.new do
280+
loop do
281+
break unless raw_wait_thr.alive?
282+
if @prompt_detected_at && (Time.now - @prompt_detected_at) >= @prompt_timeout_secs
283+
begin
284+
# Try TERM first, then KILL if needed
285+
Process.kill("TERM", raw_wait_thr.pid)
286+
sleep 0.5
287+
Process.kill("KILL", raw_wait_thr.pid) if raw_wait_thr.alive?
288+
rescue StandardError
289+
# ignore
290+
ensure
291+
@terminated_due_to_prompt_timeout = true
292+
end
293+
break
294+
end
295+
sleep 0.2
296+
end
297+
end
298+
end
299+
255300
write_input_to(raw_stdin)
256301
raw_stdin.close_write
257302

@@ -285,6 +330,7 @@ def each_output_line(&block)
285330
thread_done_queue << true
286331
line_thread.join
287332
end
333+
monitor_thread&.kill
288334
raw_stdin&.close
289335
raw_stdout&.close
290336
raw_stderr&.close

0 commit comments

Comments
 (0)