Skip to content

Commit ae768b8

Browse files
feat: add desktop notifications for task interactions and status updates
- Introduced `notify-rust` dependency for desktop notifications. - Updated configuration to include `desktop_notifications` option. - Implemented `NotificationManager` for handling various notifications: - Interactive input detection - Task timeout alerts - Task failure notifications - Sudo password prompts - Completion notifications for all tasks - Enhanced `TaskExecutor` to utilize notifications during task execution. - Updated README and example configuration to reflect new notification features.
1 parent 507c1c7 commit ae768b8

8 files changed

Lines changed: 1129 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 698 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ tokio = { version = "1.47.1", features = ["full"] }
2626
futures = "0.3.31"
2727
chrono = "0.4.42"
2828
reqwest = { version = "0.12.24", features = ["blocking"] }
29+
notify-rust = "4.11.3"

example-full-config.toml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,36 @@ show_progress = true # Show progress bars for tasks
1010
parallel_execution = false # Enable parallel execution globally
1111
parallel_limit = 4 # Max number of parallel tasks
1212
skip_optional_on_error = false # Skip optional tasks if a required task fails
13-
keychain_label = "tide-sudo" # For storing sudo password in keychain
13+
keychain_label = "tide-sudo" # For storing sudo password in keychain (macOS Keychain)
1414
use_colors = true # Enable colored output
1515
verbose = false # Show detailed output
1616
log_file = "" # Optional: Path to log file
17+
desktop_notifications = true # Enable macOS desktop notifications
18+
19+
# ============================================================================
20+
# IMPORTANT: Timeout and Sudo Configuration
21+
# ============================================================================
22+
#
23+
# All tasks have a default timeout of 300 seconds (5 minutes) to prevent
24+
# hanging on password prompts or other interactive input.
25+
#
26+
# If a command needs sudo, SET "sudo = true" in the task config!
27+
# This prevents the command from hanging on password prompts.
28+
#
29+
# Example of a command that would hang WITHOUT proper configuration:
30+
# command = ["some-script.sh"] # internally calls sudo
31+
# # This would HANG! Solution: set "sudo = true"
32+
#
33+
# Stdin is automatically redirected to /dev/null for all non-sudo commands
34+
# to prevent blocking on interactive input.
35+
#
36+
# Desktop Notifications (desktop_notifications):
37+
# When enabled, Tide will send macOS notifications for:
38+
# - Tasks waiting for interactive input (detected via timeout)
39+
# - Sudo password required (check your terminal!)
40+
# - Task failures (required tasks only)
41+
# - All tasks completed successfully
42+
# ============================================================================
1743

1844
# ============================================================================
1945
# SYSTEM & CORE UPDATES

readme.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ Tide coordinates macOS software updates, Homebrew cleanups, and any custom shell
4040
- Optional fail-fast behaviour that halts optional work after a required task fails.
4141
- Verbose logging for debugging plus quiet mode for automation owners.
4242

43+
### Desktop Notifications 🔔
44+
45+
- **Interactive Input Detection** – Get notified when a task appears to be waiting for input (timeout detected).
46+
- **Sudo Password Required** – Desktop alert when sudo authentication is needed (check your terminal!).
47+
- **Task Failures** – Instant notification when required tasks fail with error preview.
48+
- **Completion Summary** – Success notification when all tasks complete successfully.
49+
- **Configurable** – Can be disabled via `desktop_notifications = false` in config or `--quiet` flag.
50+
4351
## Requirements
4452

4553
- macOS (tested on Apple Silicon; Intel should work as long as the commands you call are available).
@@ -102,6 +110,7 @@ skip_optional_on_error = false
102110
keychain_label = "tide-sudo"
103111
verbose = false
104112
log_file = "" # Optional: capture command output
113+
desktop_notifications = true # Enable macOS desktop notifications
105114

106115
[[groups]]
107116
name = "System Updates"
@@ -136,10 +145,63 @@ parallel = false
136145
- `sudo` – Tide handles authentication and optional Keychain storage.
137146
- `enabled` – Toggle tasks on/off without deleting them.
138147
- `check_command` / `check_path` – Skip tasks automatically when prerequisites are missing.
139-
- `timeout` – Abort long-running commands (seconds).
148+
- `timeout` – Abort long-running commands (seconds). Default: 300 seconds (5 minutes).
140149
- `env` – Command-specific environment overrides.
141150
- `working_dir` – Set the working directory (supports `~`).
142151

152+
### Protection Against Hanging Commands
153+
154+
Tide includes built-in protections to prevent tasks from hanging:
155+
156+
1. **Stdin Redirection**: All regular commands have stdin redirected to `/dev/null`, preventing them from blocking on password prompts or other interactive input.
157+
158+
2. **Default Timeout**: Commands without an explicit `timeout` value will be automatically terminated after 5 minutes to prevent indefinite hanging.
159+
160+
3. **Proactive Sudo Pre-Authentication**: Tide **always** attempts to pre-authenticate sudo at startup (unless in dry-run mode). This protects against scripts that internally call sudo without being marked with `sudo: true`.
161+
162+
```bash
163+
# At startup, you'll see:
164+
🔐 Some tasks may require sudo privileges.
165+
Enter sudo password (or press Ctrl+C to skip):
166+
```
167+
168+
- If you have the password in keychain, it's used automatically
169+
- You can skip authentication (Ctrl+C or empty password)
170+
- Password can be saved to macOS Keychain for future runs
171+
172+
4. **Heuristic Warnings**: In verbose mode, Tide warns if a command contains "sudo" but isn't marked with `sudo: true`.
173+
174+
5. **Helpful Error Messages**: If a command times out, Tide provides actionable error messages suggesting to set `sudo: true` or adjust the `timeout` value.
175+
176+
**Important Use Cases:**
177+
178+
**Script with internal sudo** - Works even without `sudo: true` thanks to proactive auth:
179+
180+
```toml
181+
[[groups.tasks]]
182+
name = "Maintenance Script"
183+
command = ["./scripts/cleanup.sh"] # internally calls sudo
184+
# Works because sudo is pre-authenticated!
185+
```
186+
187+
**Explicit sudo task** - Best practice for clarity:
188+
189+
```toml
190+
[[groups.tasks]]
191+
name = "System Update"
192+
command = ["brew", "upgrade"]
193+
sudo = true # Clear and explicit
194+
```
195+
196+
**Interactive command** - Will timeout:
197+
198+
```toml
199+
[[groups.tasks]]
200+
name = "Bad Example"
201+
command = ["./interactive-tool"] # asks questions
202+
# Will hang and timeout after 5 minutes!
203+
```
204+
143205
## Examples
144206

145207
Parallel developer tooling refresh:

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub struct Settings {
3939
pub verbose: bool,
4040
#[serde(default)]
4141
pub log_file: Option<String>,
42+
#[serde(default = "default_true")]
43+
pub desktop_notifications: bool,
4244
}
4345

4446
impl Default for Settings {
@@ -55,6 +57,7 @@ impl Default for Settings {
5557
use_colors: true,
5658
verbose: false,
5759
log_file: None,
60+
desktop_notifications: true,
5861
}
5962
}
6063
}

src/executor.rs

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use std::time::{Duration, Instant};
1010

1111
use crate::config::TaskConfig;
1212
use crate::keychain;
13+
use crate::notifications::NotificationManager;
1314

1415
/// Task execution result
1516
#[derive(Debug)]
@@ -36,18 +37,110 @@ pub struct TaskExecutor {
3637
pub multi_progress: Arc<MultiProgress>,
3738
pub dry_run: bool,
3839
pub verbose: bool,
40+
pub notifier: Arc<NotificationManager>,
3941
}
4042

4143
impl TaskExecutor {
4244
/// Create a new task executor
43-
pub fn new(dry_run: bool, verbose: bool) -> Self {
45+
pub fn new(dry_run: bool, verbose: bool, notifications_enabled: bool) -> Self {
4446
Self {
4547
multi_progress: Arc::new(MultiProgress::new()),
4648
dry_run,
4749
verbose,
50+
notifier: Arc::new(NotificationManager::new(notifications_enabled)),
4851
}
4952
}
5053

54+
/// Ensure sudo authentication is valid before executing tasks
55+
/// This prevents tasks from hanging on password prompts
56+
/// Returns Ok if auth succeeded or was already valid
57+
/// Returns Err only if user provided wrong password
58+
pub async fn ensure_sudo_auth(&self, keychain_label: &str) -> Result<()> {
59+
// Check if sudo timestamp is already cached
60+
if Command::new("sudo")
61+
.arg("-n")
62+
.arg("true")
63+
.stdout(Stdio::null())
64+
.stderr(Stdio::null())
65+
.status()
66+
.map(|s| s.success())
67+
.unwrap_or(false)
68+
{
69+
if self.verbose {
70+
println!("{}", "✓ Sudo timestamp already valid".green());
71+
}
72+
return Ok(());
73+
}
74+
75+
// Try keychain password to refresh sudo timestamp
76+
if let Ok(password) = keychain::get_password(keychain_label) {
77+
if authenticate_sudo(&password).await? {
78+
if self.verbose {
79+
println!("{}", "✓ Sudo authenticated via keychain".green());
80+
}
81+
return Ok(());
82+
} else {
83+
// Keychain password is wrong/outdated - we'll prompt
84+
if self.verbose {
85+
println!(
86+
"{}",
87+
"⚠️ Keychain password is outdated, prompting for new password".yellow()
88+
);
89+
}
90+
}
91+
}
92+
93+
// Prompt user for password
94+
println!(
95+
"{}",
96+
"🔐 Some tasks may require sudo privileges.".bright_blue()
97+
);
98+
99+
// Send desktop notification
100+
let _ = self.notifier.notify_sudo_required();
101+
102+
let password = match Password::with_theme(&ColorfulTheme::default())
103+
.with_prompt("Enter sudo password (or press Ctrl+C to skip)")
104+
.allow_empty_password(true)
105+
.interact()
106+
{
107+
Ok(pwd) if pwd.is_empty() => {
108+
println!("{}", "Skipping sudo authentication.".yellow());
109+
return Err(anyhow::anyhow!("User skipped sudo authentication"));
110+
}
111+
Ok(pwd) => pwd,
112+
Err(_) => {
113+
println!("{}", "Sudo authentication cancelled.".yellow());
114+
return Err(anyhow::anyhow!("User cancelled sudo authentication"));
115+
}
116+
};
117+
118+
if !authenticate_sudo(&password).await? {
119+
return Err(anyhow::anyhow!("Invalid sudo password"));
120+
}
121+
122+
if self.verbose {
123+
println!("{}", "✓ Sudo authenticated successfully".green());
124+
}
125+
126+
// Optionally save password into keychain
127+
if !keychain::entry_exists(keychain_label) {
128+
if Confirm::with_theme(&ColorfulTheme::default())
129+
.with_prompt("Save password to keychain for future use?")
130+
.default(true)
131+
.interact()?
132+
{
133+
keychain::save_password(keychain_label, &password)?;
134+
println!(
135+
"{}",
136+
"✓ Password saved to keychain (service: tide-sudo)".green()
137+
);
138+
}
139+
}
140+
141+
Ok(())
142+
}
143+
51144
/// Create a configured spinner progress bar
52145
pub fn new_spinner(&self) -> ProgressBar {
53146
let pb = self.multi_progress.add(ProgressBar::new_spinner());
@@ -144,16 +237,37 @@ impl TaskExecutor {
144237
cmd.insert(0, "sudo".to_string());
145238
}
146239

240+
// Warn if command might internally call sudo (heuristic check)
241+
if !task.sudo && self.verbose && !cmd.is_empty() {
242+
let cmd_str = cmd.join(" ").to_lowercase();
243+
if cmd_str.contains("sudo") {
244+
pb.println(format!(
245+
"{}",
246+
format!(
247+
"⚠️ Task '{}' may call sudo internally. Consider setting 'sudo: true'",
248+
task_name
249+
)
250+
.yellow()
251+
));
252+
}
253+
}
254+
147255
// Execute command
148256
let result = if cmd.get(0).map(|s| s.as_str()) == Some("sudo") {
149257
self.run_sudo_command(&cmd[1..], keychain_label).await
150258
} else {
151-
self.run_command(&cmd, &task).await
259+
self.run_command(&cmd, &task, &task_name, &group_name).await
152260
};
153261

154262
let (status, output) = match result {
155263
Ok(output) => (TaskStatus::Success, Some(output)),
156-
Err(e) if task.required => (TaskStatus::Failed, Some(e.to_string())),
264+
Err(e) if task.required => {
265+
// Send notification for failed required task
266+
let _ = self
267+
.notifier
268+
.notify_task_failed(&task_name, &group_name, &e.to_string());
269+
(TaskStatus::Failed, Some(e.to_string()))
270+
}
157271
Err(e) => (TaskStatus::Skipped, Some(e.to_string())),
158272
};
159273

@@ -182,7 +296,13 @@ impl TaskExecutor {
182296
}
183297

184298
/// Run a regular command
185-
async fn run_command(&self, cmd: &[String], task: &TaskConfig) -> Result<String> {
299+
async fn run_command(
300+
&self,
301+
cmd: &[String],
302+
task: &TaskConfig,
303+
task_name: &str,
304+
group_name: &str,
305+
) -> Result<String> {
186306
if cmd.is_empty() {
187307
return Err(anyhow::anyhow!("Empty command"));
188308
}
@@ -201,11 +321,38 @@ impl TaskExecutor {
201321
command.env(key, value);
202322
}
203323

324+
// CRITICAL: Redirect stdin to /dev/null to prevent blocking on password prompts
325+
// This prevents commands from hanging if they internally require interactive input
326+
command.stdin(Stdio::null());
327+
204328
if !self.verbose {
205329
command.stdout(Stdio::piped()).stderr(Stdio::piped());
206330
}
207331

208-
let output = command.output()?;
332+
// Apply timeout if specified in task config
333+
let command_future = tokio::task::spawn_blocking(move || command.output());
334+
let timeout_secs = task.timeout.unwrap_or(300);
335+
336+
let output = match tokio::time::timeout(Duration::from_secs(timeout_secs), command_future)
337+
.await
338+
{
339+
Ok(Ok(result)) => result?,
340+
Ok(Err(e)) => return Err(anyhow::anyhow!("Command execution error: {}", e)),
341+
Err(_) => {
342+
// Send notification that task timed out (likely waiting for input)
343+
let _ = self
344+
.notifier
345+
.notify_interactive_input_detected(task_name, group_name);
346+
let _ = self
347+
.notifier
348+
.notify_task_timeout(task_name, group_name, timeout_secs);
349+
350+
return Err(anyhow::anyhow!(
351+
"Command timed out after {} seconds. This may indicate the command is waiting for input (like sudo password). Consider setting 'sudo: true' or 'timeout: <seconds>' in the task config.",
352+
timeout_secs
353+
));
354+
}
355+
};
209356

210357
if output.status.success() {
211358
Ok(String::from_utf8_lossy(&output.stdout).to_string())

0 commit comments

Comments
 (0)