Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181//! Global non-interactive mode: --no-input, ATGC_NO_INPUT=1, CI, or a//! stdin that is not a terminal.//!//! atgc's rule is that it never prompts — see the account module for the//! reasoning — but a few commands still assume a human is nearby: `auth//! login` opens a browser and waits for it, `browse` and the `--web` flags//! open pages, and git subprocesses inherit stdio, where a credential//! helper or SSH can stop on a question nothing will answer. In a pipe, a//! CI job or an agent harness, "stop on a question" means "hang until//! killed". This module decides once whether anybody is there, and those//! sites consult it: refuse with instructions instead of waiting, print//! the URL instead of opening it, and tell git to fail rather than ask.//!//! The decision reads only universal signals — the flag, the env var,//! `CI`, and whether stdin is a terminal. Vendor-specific "an agent is//! running" variables are deliberately ignored: agents run both with and//! without terminals, and behavior that varies by which product spawned//! the process is behavior nobody can debug.//!//! Both variables are read through [`crate::env::switch`], which is the one//! reading every boolean atgc takes from the environment. `CI=false` is//! therefore *not* CI, which is the only reading of that spelling anybody//! means; this module used to treat it as being in CI because the variable//! was set at all.
use std::io::IsTerminal;use std::sync::OnceLock;
static WHY: OnceLock<Option<&'static str>> = OnceLock::new();static CI: OnceLock<bool> = OnceLock::new();
pub fn init(flag: bool) { let ci = crate::env::switch("CI"); let why = decide( flag, crate::env::switch("ATGC_NO_INPUT"), ci, std::io::stdin().is_terminal(), ); let _ = WHY.set(why); let _ = CI.set(ci); if let Some(why) = why { crate::logging::debug::log(format!("non-interactive: {why}")); }}
/// The one decision, kept pure so the tests can hold it still. Any single/// signal is enough; the precedence order only chooses which *reason* an/// error message repeats back, so the most explicit signal — the thing the/// user actually did — wins.fn decide(flag: bool, env: bool, ci: bool, stdin_tty: bool) -> Option<&'static str> { if flag { Some("--no-input") } else if env { Some("ATGC_NO_INPUT is set") } else if ci { Some("CI is set") } else if !stdin_tty { Some("stdin is not a terminal") } else { None }}
/// Whether this invocation must never wait on a human.pub fn active() -> bool { why().is_some()}
/// What made the session non-interactive, for error messages.pub fn why() -> Option<&'static str> { WHY.get().copied().flatten()}
/// Whether this is a CI job, on its own rather than folded into [`why`].////// The one signal here that says something [`why`] cannot. The other three/// mean "nobody is at this terminal", which is not the same as "nothing will/// act on what atgc prints": a pipe usually has a program on the far end, and/// `cmd::auth::login` now depends on that — it prints an authorization URL and/// waits five minutes for something to open it.////// `CI` is different in kind. A CI job has no desktop, no browser and nobody/// to consent, so an authorization URL printed into one leads nowhere, and the/// only possible ending is the timeout. That makes it worth refusing where the/// others are not, and it is the *only* one worth refusing: `--no-input` and/// `ATGC_NO_INPUT` are how a person at a terminal says "open no browser at/// me", which they are entitled to say while still meaning to open the page/// themselves.////// Kept apart rather than read at the call site so that both answers come from/// the same reading of the same variable at the same moment — `CI=false` is/// not CI here for the same reason it is not there.pub fn in_ci() -> bool { CI.get().copied().unwrap_or(false)}
/// What became of a URL handed to a browser.////// [`Opened`](BrowserOpen::Opened) is the launcher's word rather than the/// user's eyes: it means the platform's opener — `xdg-open` and friends —/// was spawned and exited happily, which is as much as any process can/// know from here. A caller that shows a link for the case where nothing/// appeared should keep showing it on `Opened` too.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum BrowserOpen { /// A browser was launched for the URL. Opened, /// A browser was tried and would not start, or this build has no /// browser support compiled in. Failed, /// Nothing was tried: the session is non-interactive. Skipped,}
impl BrowserOpen { /// The one spelling of each outcome, for the OAuth log. pub fn label(self) -> &'static str { match self { Self::Opened => "opened", Self::Failed => "failed", Self::Skipped => "skipped", } }}
/// Open `url` in a browser, unless nobody is there to see it. Callers that/// print the URL anyway lose nothing by the skip, while opening a browser/// from a CI job or an agent sandbox is at best a stray window on someone/// else's desktop.////// The return value is for callers whose next line of output depends on/// what happened — see `cmd::auth::login`, which offers a link only because it/// cannot be sure the browser arrived.pub fn open_in_browser(url: &str) -> BrowserOpen { if let Some(why) = why() { crate::logging::debug::log(format!("not opening a browser ({why}): {url}")); return BrowserOpen::Skipped; } if jacquard::oauth::loopback::try_open_in_browser(url) { crate::logging::debug::log(format!("opened a browser for {url}")); BrowserOpen::Opened } else { crate::logging::debug::log(format!("could not open a browser for {url}")); BrowserOpen::Failed }}
#[cfg(test)]mod tests { use super::decide;
/// A terminal on stdin and no other signal is the interactive case, /// and the only one: every signal alone is enough to switch modes. #[test] fn any_single_signal_is_enough() { assert_eq!(decide(false, false, false, true), None); assert!(decide(true, false, false, true).is_some()); assert!(decide(false, true, false, true).is_some()); assert!(decide(false, false, true, true).is_some()); assert!(decide(false, false, false, false).is_some()); }
/// The reason is repeated back in refusals, so it must name the most /// explicit thing the user did, not whichever signal happened to be /// checked first. `--no-input` in CI should say `--no-input`. #[test] fn the_reason_names_the_most_explicit_signal() { assert_eq!(decide(true, true, true, false), Some("--no-input")); assert_eq!( decide(false, true, true, false), Some("ATGC_NO_INPUT is set") ); assert_eq!(decide(false, false, true, false), Some("CI is set")); assert_eq!( decide(false, false, false, false), Some("stdin is not a terminal") ); }}