mirror of
https://github.com/quantum5/ntfy-run.git
synced 2025-04-24 05:31:58 -04:00
Initial commit
This commit is contained in:
commit
95f516cb87
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# RustRover
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
1380
Cargo.lock
generated
Normal file
1380
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "ntfy-run"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.5.20", features = ["derive", "env"] }
|
||||||
|
itertools = "0.13.0"
|
||||||
|
reqwest = "0.12.8"
|
||||||
|
tokio = { version = "1.40.0", features = ["io-std", "io-util", "macros", "process", "rt-multi-thread"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
215
src/main.rs
Normal file
215
src/main.rs
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
use crate::runner::CaptureError;
|
||||||
|
use clap::Parser;
|
||||||
|
use runner::CapturedOutput;
|
||||||
|
|
||||||
|
mod quote;
|
||||||
|
mod runner;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
/// Tool to run a command, capture its output, and send it to ntfy.
|
||||||
|
struct Cli {
|
||||||
|
/// URL of the ntfy server and topic, e.g. https://ntfy.sh/topic
|
||||||
|
#[arg(short = 'n', long = "ntfy-url", env = "NTFY_URL", alias = "url")]
|
||||||
|
url: String,
|
||||||
|
|
||||||
|
/// Access token to use with ntfy
|
||||||
|
#[arg(short, long, env = "NTFY_TOKEN")]
|
||||||
|
token: Option<String>,
|
||||||
|
|
||||||
|
/// User to use with ntfy
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
env = "NTFY_USER",
|
||||||
|
conflicts_with = "token",
|
||||||
|
requires = "password"
|
||||||
|
)]
|
||||||
|
user: Option<String>,
|
||||||
|
|
||||||
|
/// Password to use with nfty
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
env = "NTFY_PASSWORD",
|
||||||
|
conflicts_with = "token",
|
||||||
|
requires = "user"
|
||||||
|
)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Notify even when the output is empty
|
||||||
|
#[arg(short = 'N', long, env = "NTFY_ALWAYS_NOTIFY")]
|
||||||
|
always_notify: bool,
|
||||||
|
|
||||||
|
/// Notify only when command fails
|
||||||
|
#[arg(
|
||||||
|
short = 'o',
|
||||||
|
long,
|
||||||
|
env = "NTFY_FAILURE_ONLY",
|
||||||
|
conflicts_with = "always_notify"
|
||||||
|
)]
|
||||||
|
only_failures: bool,
|
||||||
|
|
||||||
|
/// Message title, will be prefixed with "Success" or "Failure".
|
||||||
|
/// Defaults to command line.
|
||||||
|
#[arg(short = 'T', long, env = "NTFY_TITLE")]
|
||||||
|
title: Option<String>,
|
||||||
|
|
||||||
|
/// Message title upon successful executions
|
||||||
|
#[arg(short = 's', long, env = "NTFY_SUCCESS_TITLE")]
|
||||||
|
success_title: Option<String>,
|
||||||
|
|
||||||
|
/// Message title upon failed executions
|
||||||
|
#[arg(short = 'f', long, env = "NTFY_FAILURE_TITLE")]
|
||||||
|
failure_title: Option<String>,
|
||||||
|
|
||||||
|
/// Message priority upon successful executions
|
||||||
|
#[arg(short = 'S', long, env = "NTFY_SUCCESS_PRIORITY")]
|
||||||
|
success_priority: Option<String>,
|
||||||
|
|
||||||
|
/// Message priority upon failed executions
|
||||||
|
#[arg(short = 'F', long, env = "NTFY_FAILURE_PRIORITY")]
|
||||||
|
failure_priority: Option<String>,
|
||||||
|
|
||||||
|
/// Message tags/emojis upon successful executions
|
||||||
|
#[arg(short = 'a', long, env = "NTFY_SUCCESS_TAGS")]
|
||||||
|
success_tags: Option<String>,
|
||||||
|
|
||||||
|
/// Message tags/emojis upon failed executions
|
||||||
|
#[arg(short = 'A', long, env = "NTFY_FAILURE_TAGS")]
|
||||||
|
failure_tags: Option<String>,
|
||||||
|
|
||||||
|
/// An optional email for ntfy to notify
|
||||||
|
#[arg(short, long, env = "NTFY_EMAIL")]
|
||||||
|
email: Option<String>,
|
||||||
|
|
||||||
|
/// URL to icon to display in notification
|
||||||
|
#[arg(short, long, env = "NTFY_ICON")]
|
||||||
|
icon: Option<String>,
|
||||||
|
|
||||||
|
/// The command line to execute (no shell used).
|
||||||
|
/// If shell is desired, pass `bash -c 'command line'`.
|
||||||
|
#[arg(trailing_var_arg = true, required = true)]
|
||||||
|
cmdline: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_post_body(output: CapturedOutput) -> String {
|
||||||
|
let mut fragments: Vec<String> = vec![match output.status {
|
||||||
|
Some(status) => status.to_string(),
|
||||||
|
None => "Did not run.".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
if !output.errors.is_empty() {
|
||||||
|
fragments.push("==================== Errors ====================".to_string());
|
||||||
|
for error in &output.errors {
|
||||||
|
fragments.push(match error {
|
||||||
|
CaptureError::Spawn(error) => format!("Spawn error: {}", error),
|
||||||
|
CaptureError::Stdout(error) => format!("Error while reading stdout: {}", error),
|
||||||
|
CaptureError::Stderr(error) => format!("Error while reading stderr: {}", error),
|
||||||
|
CaptureError::Wait(error) => format!("Error while waiting for process: {}", error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fragments.push("\n".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
fragments.push("==================== STDOUT ====================".to_string());
|
||||||
|
fragments.push(output.stdout);
|
||||||
|
fragments.push("\n".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
fragments.push("==================== STDERR ====================".to_string());
|
||||||
|
fragments.push(output.stderr);
|
||||||
|
fragments.push("\n".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fragments.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let args = Cli::parse();
|
||||||
|
|
||||||
|
let result = runner::run_forward_and_capture(&args.cmdline).await;
|
||||||
|
let status = result.status;
|
||||||
|
|
||||||
|
let success = match status {
|
||||||
|
Some(status) => status.success(),
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !args.always_notify && success && result.is_empty() {
|
||||||
|
return;
|
||||||
|
} else if args.only_failures && success {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback_title = match args.title {
|
||||||
|
Some(title) => title,
|
||||||
|
None => quote::quote_cmdline(&args.cmdline),
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = if success {
|
||||||
|
match args.success_title {
|
||||||
|
Some(title) => title,
|
||||||
|
None => format!("Success: {}", fallback_title),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match args.failure_title {
|
||||||
|
Some(title) => title,
|
||||||
|
None => format!("Failure: {}", fallback_title),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let priority = if success {
|
||||||
|
args.success_priority
|
||||||
|
} else {
|
||||||
|
args.failure_priority
|
||||||
|
};
|
||||||
|
|
||||||
|
let tags = if success {
|
||||||
|
args.success_tags
|
||||||
|
} else {
|
||||||
|
args.failure_tags
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = format_post_body(result);
|
||||||
|
|
||||||
|
let request = reqwest::Client::new()
|
||||||
|
.post(&args.url)
|
||||||
|
.header("title", title)
|
||||||
|
.body(body);
|
||||||
|
|
||||||
|
let request = match priority {
|
||||||
|
Some(priority) => request.header("priority", priority),
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = match tags {
|
||||||
|
Some(tags) => request.header("tags", tags),
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = match args.email {
|
||||||
|
Some(email) => request.header("email", email),
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = match args.icon {
|
||||||
|
Some(icon) => request.header("icon", icon),
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = if let Some(token) = args.token {
|
||||||
|
request.bearer_auth(token)
|
||||||
|
} else if let Some(user) = args.user {
|
||||||
|
request.basic_auth(user, args.password)
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.send().await.and_then(|r| r.error_for_status()) {
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(error) => eprintln!("Failed to send request to ntfy: {}", error),
|
||||||
|
}
|
||||||
|
}
|
18
src/quote.rs
Normal file
18
src/quote.rs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
use itertools::Itertools;
|
||||||
|
|
||||||
|
fn quote_argument(arg: &str) -> String {
|
||||||
|
let has_single = arg.contains('\'');
|
||||||
|
let has_double = arg.contains('"');
|
||||||
|
let has_space = arg.contains(' ');
|
||||||
|
|
||||||
|
match (has_space, has_single, has_double) {
|
||||||
|
(false, false, false) => arg.to_string(),
|
||||||
|
(_, true, false) => format!("\"{}\"", arg),
|
||||||
|
(_, false, _) => format!("'{}'", arg),
|
||||||
|
_ => format!("'{}'", arg.replace('\'', "\\'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quote_cmdline<T: AsRef<str>>(cmdline: &[T]) -> String {
|
||||||
|
cmdline.iter().map(|s| quote_argument(s.as_ref())).join(" ")
|
||||||
|
}
|
86
src/runner.rs
Normal file
86
src/runner.rs
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
use std::process::{ExitStatus, Stdio};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::select;
|
||||||
|
|
||||||
|
pub enum CaptureError {
|
||||||
|
Spawn(std::io::Error),
|
||||||
|
Stdout(std::io::Error),
|
||||||
|
Stderr(std::io::Error),
|
||||||
|
Wait(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CapturedOutput {
|
||||||
|
pub status: Option<ExitStatus>,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
pub errors: Vec<CaptureError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapturedOutput {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.errors.is_empty() && self.stdout.is_empty() && self.stderr.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_forward_and_capture(cmdline: &Vec<String>) -> CapturedOutput {
|
||||||
|
let command = cmdline.first().unwrap();
|
||||||
|
let ref args = cmdline[1..];
|
||||||
|
|
||||||
|
let mut child = match Command::new(command)
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(process) => process,
|
||||||
|
Err(error) => {
|
||||||
|
return CapturedOutput {
|
||||||
|
status: None,
|
||||||
|
stdout: "".to_string(),
|
||||||
|
stderr: "".to_string(),
|
||||||
|
errors: vec![CaptureError::Spawn(error)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stdout_stream = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||||
|
let mut stderr_stream = BufReader::new(child.stderr.take().unwrap()).lines();
|
||||||
|
|
||||||
|
let mut stdout_buffer = Vec::new();
|
||||||
|
let mut stderr_buffer = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
let status = loop {
|
||||||
|
select! {
|
||||||
|
line = stdout_stream.next_line() => match line {
|
||||||
|
Ok(Some(line)) => {
|
||||||
|
println!("{}", line);
|
||||||
|
stdout_buffer.push(line);
|
||||||
|
},
|
||||||
|
Ok(None) => (),
|
||||||
|
Err(error) => errors.push(CaptureError::Stdout(error)),
|
||||||
|
},
|
||||||
|
line = stderr_stream.next_line() => match line {
|
||||||
|
Ok(Some(line)) => {
|
||||||
|
eprintln!("{}", line);
|
||||||
|
stderr_buffer.push(line);
|
||||||
|
},
|
||||||
|
Ok(None) => (),
|
||||||
|
Err(error) => errors.push(CaptureError::Stderr(error)),
|
||||||
|
},
|
||||||
|
status = child.wait() => match status {
|
||||||
|
Ok(status) => break status,
|
||||||
|
Err(error) => errors.push(CaptureError::Wait(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CapturedOutput {
|
||||||
|
status: Some(status),
|
||||||
|
stdout: stdout_buffer.join("\n").to_string(),
|
||||||
|
stderr: stderr_buffer.join("\n").to_string(),
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue