From bac1232d436b9cb1c3b955bb8c3618493a56f595 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 17 Aug 2026 16:30:53 +0800 Subject: [PATCH] [feature] nine CLIs that patchbay refused to verify are now verified --- CHANGELOG.md | 29 ++ crates/patchbay-core/src/probes/cli_verify.rs | 334 +++++++++++++++++ crates/patchbay-core/src/probes/doctl.rs | 342 +++++++++++++++++- crates/patchbay-core/src/probes/firebase.rs | 233 +++++++++++- crates/patchbay-core/src/probes/flyctl.rs | 204 ++++++++++- .../patchbay-core/src/probes/huggingface.rs | 337 ++++++++++++++++- crates/patchbay-core/src/probes/mod.rs | 1 + crates/patchbay-core/src/probes/neon.rs | 240 +++++++++++- crates/patchbay-core/src/probes/stripe.rs | 329 ++++++++++++++++- crates/patchbay-core/src/probes/supabase.rs | 278 +++++++++++++- crates/patchbay-core/src/probes/vercel.rs | 184 +++++++++- crates/patchbay-core/src/probes/wrangler.rs | 263 +++++++++++++- 12 files changed, 2717 insertions(+), 57 deletions(-) create mode 100644 crates/patchbay-core/src/probes/cli_verify.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a6f6c..1b4315a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Nine CLIs patchbay refused to verify are now verified.** `wrangler`, + `vercel`, `neon`, `supabase`, `flyctl`, `doctl`, `huggingface`, `stripe` and + `firebase` used to answer `verify` with an excuse and a command to paste — + "the CLI is node-based and slow to start", "that is a network call". Slow is + not a reason: `verify` only runs when you press the button or type `pb + verify`. Each now runs the tool's own check and reports the identity it + answers with, so you can hold it against what the board claimed: the + Cloudflare accounts behind a wrangler token, the Vercel username, the Neon + account and plan, the Supabase projects and org, the Fly and DigitalOcean + accounts, the Hub user and orgs, the Stripe account and key expiry, the + firebase-tools accounts. + + Failures say one actionable sentence rather than pasting the tool's error + paragraph, and they distinguish three states that used to be one: logged out, + credential rejected, and *the network was unreachable* — the last of which is + no longer reported as a bad login, because nothing about the credential was + established. Where a check is local rather than a round trip (stripe and + firebase have no read-only command that both names the account and exercises + the credential) the answer says so instead of letting a tick imply more than + it proved. + + Two hazards are handled rather than discovered later: `neon me` starts a + browser login when there is no credential, so that state is answered from the + tier-1 read without executing anything; and `fly auth whoami` offers an + interactive login unless `--json` is passed, so it is. + ### Changed - **Notes carry a severity.** `ToolStatus.notes` was a `Vec` — an @@ -71,6 +99,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 to write another project's config — a deliberate boundary, not a failure. It now renders as a quiet notice. + ## [0.3.4] - 2026-08-17 ### Added diff --git a/crates/patchbay-core/src/probes/cli_verify.rs b/crates/patchbay-core/src/probes/cli_verify.rs new file mode 100644 index 0000000..bc727b8 --- /dev/null +++ b/crates/patchbay-core/src/probes/cli_verify.rs @@ -0,0 +1,334 @@ +//! Shared shape for the tier-2 `verify` paths that shell out to a vendor CLI. +//! +//! Every probe that runs somebody else's binary faces the same three problems, +//! and solving them nine different ways is how nine subtly different answers +//! get shipped: +//! +//! 1. **Which failure is this?** "logged out", "the server said no" and "there +//! is no network" want three different sentences, and calling the third one +//! an invalid login is a lie that sends the user off to re-authenticate a +//! credential that was fine. [`classify`] separates them. +//! 2. **What do we quote?** A CLI answers a failure with a paragraph. The +//! [`crate::types::VerifyOutcome`] detail is one sentence, and +//! [`crate::util::CmdOutput::message`] is the wrong tool for it — it joins +//! *every* line with `; `, so "take the headline" written on top of it +//! silently takes the whole paragraph. [`headline`] takes the first line and +//! strips the prefix that only names the command patchbay just ran. +//! 3. **Where is the message?** stderr, unless it is empty. [`failure_text`]. +//! +//! Nothing here executes anything: this is pure text handling, so it is tested +//! directly rather than through nine `FakeExec` fixtures. + +use crate::types::VerifyOutcome; +use crate::util::CmdOutput; + +/// Why a vendor CLI refused, at the granularity a user can act on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Failure { + /// No credential at all — the tool has never been logged in, or was logged + /// out. The fix is to log in. + LoggedOut, + /// A credential exists and the server rejected it: 401, expired, revoked. + /// The fix is also to log in, but the sentence is a different one, because + /// the user's mental model ("I *am* logged in") is what needs correcting. + Rejected, + /// Nothing was proved either way: DNS, TLS, timeouts, refused connections. + /// **Never** report this as a bad credential. + Unreachable, + /// The CLI failed for a reason patchbay has no opinion about. + Other, +} + +/// DNS / TLS / connection markers. Checked first and deliberately generous: +/// mistaking an outage for a rejected token is the expensive error, and +/// mistaking a rejected token for an outage only costs the user a retry. +const UNREACHABLE: &[&str] = &[ + "dial tcp", + // supabase dials through its own DoH resolver and says so. + "failed to dial", + "no such host", + "network is unreachable", + "connection refused", + "connection reset", + "connection timed out", + "i/o timeout", + "timed out", + "temporary failure in name resolution", + "could not resolve host", + "name resolution", + "getaddrinfo", + "enotfound", + "econnrefused", + "econnreset", + "etimedout", + "eai_again", + "max retries exceeded", + "failed to establish a new connection", + "tls handshake", + "x509", + "certificate verify failed", + "unable to get local issuer", + "self signed certificate", + "proxyconnect", +]; + +/// "The server looked at your credential and said no." +const REJECTED: &[&str] = &[ + "401", + "403", + "unauthorized", + "unauthenticated", + "invalid token", + "invalid access token", + "invalid api key", + "invalid_grant", + "invalid credentials", + "token is invalid", + "expired", + "revoked", + "forbidden", + "permission denied", + "unable to authenticate", + "authentication failed", +]; + +/// "There is no credential here." +const LOGGED_OUT: &[&str] = &[ + "not logged in", + "not authenticated", + "no existing credentials", + "no credentials", + "credentials not found", + "access token not provided", + "access token is required", + "no access token", + "no api token", + "you need to be logged", + "please log in", + "please login", + "must be logged in", + "run `login`", + "login first", + // stripe: "You have not configured API keys yet." + "not configured", +]; + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|n| haystack.contains(n)) +} + +/// "invalid" and "token" in the same breath, however the CLI words it. +/// +/// A literal-phrase list cannot keep up here: `hf` says "Invalid user token.", +/// stripe says "The API key for the default profile has expired", others say +/// "the token stored is invalid". What they share is a negation next to the +/// noun, so that pairing is matched instead of the sentence around it. +fn says_invalid_credential(text: &str) -> bool { + let negated = text.contains("invalid") || text.contains("not valid"); + let subject = text.contains("token") || text.contains("key") || text.contains("credential"); + negated && subject +} + +/// stderr when it has anything to say, else stdout. Several of these CLIs put +/// their whole diagnostic on stdout and exit non-zero, so neither stream can be +/// the only one read. +pub(super) fn failure_text(out: &CmdOutput) -> &str { + if out.stderr.trim().is_empty() { + out.stdout.trim() + } else { + out.stderr.trim() + } +} + +/// Sort a CLI's complaint into something a sentence can be built from. +/// +/// Order matters, and it is transport first: if the text carries evidence that +/// the request never completed, nothing the payload says about credentials was +/// ever established. Rejection beats logged-out next, because a 401 is a fact +/// about a credential that exists, while "run login" is advice both states +/// print. +pub(super) fn classify(out: &CmdOutput) -> Failure { + let text = failure_text(out).to_lowercase(); + if contains_any(&text, UNREACHABLE) { + return Failure::Unreachable; + } + if contains_any(&text, REJECTED) || says_invalid_credential(&text) { + return Failure::Rejected; + } + if contains_any(&text, LOGGED_OUT) { + return Failure::LoggedOut; + } + Failure::Other +} + +/// Some CLIs report "you are logged out" on a **successful** exit — `hf auth +/// whoami` prints `Not logged in` and exits 0, `wrangler whoami` prints "You +/// are not authenticated." and exits 0. Reading only the exit code files those +/// as working logins. +pub(super) fn says_logged_out(text: &str) -> bool { + contains_any(&text.to_lowercase(), LOGGED_OUT) +} + +/// The first line worth reading, without the prefix that repeats the command. +/// +/// The bug this exists to avoid: [`crate::util::CmdOutput::message`] joins +/// every non-empty line with `; `, so a "headline" built on it is the CLI's +/// entire error paragraph on one line. This takes the *first* line, which is +/// what "headline" has to mean. +pub(super) fn headline(text: &str) -> String { + let line = text + .lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.chars().all(|c| c == '-' || c == '=' || c == '─')) + .unwrap_or("the command failed without saying why"); + // `Error: `, `ERROR: `, `error: ` — every Go and Node CLI here uses one. + let line = line + .strip_prefix("Error: ") + .or_else(|| line.strip_prefix("ERROR: ")) + .or_else(|| line.strip_prefix("error: ")) + .unwrap_or(line); + // gcloud-style `(gcloud.auth.print-access-token) real message`. + match line + .strip_prefix('(') + .and_then(|rest| rest.split_once(") ")) + { + Some((_command, rest)) => rest.trim().to_string(), + None => line.to_string(), + } +} + +/// The whole failure branch, in the one shape all nine probes want. +/// +/// `login_command` is what ends a logged-out or rejected state; `service` names +/// what could not be reached, so the network sentence says which host mattered. +pub(super) fn failure_outcome( + tool: &'static str, + out: &CmdOutput, + service: &str, + login_command: &str, +) -> VerifyOutcome { + let text = failure_text(out); + match classify(out) { + Failure::LoggedOut => VerifyOutcome::Invalid { + tool: tool.to_string(), + detail: format!("not logged in — run `{login_command}`"), + }, + Failure::Rejected => VerifyOutcome::Invalid { + tool: tool.to_string(), + detail: format!("{service} rejected the stored credential — run `{login_command}`"), + }, + // Not Invalid: nothing about the credential was established. + Failure::Unreachable => VerifyOutcome::Unsupported { + tool: tool.to_string(), + reason: format!( + "could not reach {service}, so the credential was not tested ({})", + headline(text) + ), + hint: Some(login_command.to_string()), + }, + Failure::Other => VerifyOutcome::Invalid { + tool: tool.to_string(), + detail: headline(text), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn out(ok: bool, stdout: &str, stderr: &str) -> CmdOutput { + CmdOutput { + ok, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + } + } + + #[test] + fn test_a_network_failure_is_never_a_bad_credential() { + let dns = out( + false, + "", + "Get \"https://api.digitalocean.com/v2/account\": dial tcp: lookup api.digitalocean.com: no such host", + ); + assert_eq!(classify(&dns), Failure::Unreachable); + match failure_outcome("doctl", &dns, "DigitalOcean", "doctl auth init") { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach DigitalOcean"), "{reason}"); + assert!(reason.contains("not tested"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_rejected_and_logged_out_get_different_sentences() { + let rejected = out(false, "", "Error: 401 Unauthorized"); + let logged_out = out(false, "", "Error: No existing credentials found."); + assert_eq!(classify(&rejected), Failure::Rejected); + assert_eq!(classify(&logged_out), Failure::LoggedOut); + + let a = failure_outcome("vercel", &rejected, "Vercel", "vercel login"); + let b = failure_outcome("vercel", &logged_out, "Vercel", "vercel login"); + assert_ne!(a, b); + for outcome in [a, b] { + match outcome { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("vercel login"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + } + + #[test] + fn test_headline_takes_the_first_line_not_all_of_them() { + // The bug: `CmdOutput::message` joins every line with "; ", so a + // headline built on it is the whole paragraph. + let paragraph = "ERROR: (gcloud.auth) There was a problem.\nPlease run:\n\n $ gcloud auth login\n\nto obtain new credentials.\n"; + let line = headline(paragraph); + assert_eq!(line, "There was a problem."); + assert!(!line.contains("Please run"), "{line}"); + assert_eq!(line.lines().count(), 1); + } + + #[test] + fn test_headline_never_panics_on_empty_or_decorative_output() { + assert_eq!(headline(""), "the command failed without saying why"); + assert_eq!( + headline("\n\n────────────\n"), + "the command failed without saying why" + ); + assert_eq!(headline("error: nope"), "nope"); + } + + #[test] + fn test_stdout_is_read_when_stderr_is_silent() { + let stdout_only = out(false, "Error: not logged in\n", ""); + assert_eq!(failure_text(&stdout_only), "Error: not logged in"); + assert_eq!(classify(&stdout_only), Failure::LoggedOut); + } + + #[test] + fn test_a_zero_exit_can_still_say_logged_out() { + assert!(says_logged_out("Not logged in")); + assert!(says_logged_out( + "You are not authenticated. Please run `wrangler login`." + )); + assert!(!says_logged_out("dev@example.com")); + } + + #[test] + fn test_an_unclassifiable_failure_keeps_the_cli_s_own_first_line() { + let weird = out(false, "", "Error: unable to parse config at line 3\nstack…"); + assert_eq!(classify(&weird), Failure::Other); + match failure_outcome("stripe", &weird, "Stripe", "stripe login") { + VerifyOutcome::Invalid { detail, .. } => { + assert_eq!(detail, "unable to parse config at line 3"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } +} diff --git a/crates/patchbay-core/src/probes/doctl.rs b/crates/patchbay-core/src/probes/doctl.rs index 7b4c272..972cff4 100644 --- a/crates/patchbay-core/src/probes/doctl.rs +++ b/crates/patchbay-core/src/probes/doctl.rs @@ -30,6 +30,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unknown_profile, unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{Expiry, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome}; use crate::util::read_text; @@ -61,6 +62,138 @@ impl DoctlProbe { pub fn new(paths: Paths) -> Self { Self { paths } } + + /// `doctl account get -o json`, optionally pinned to one auth context. + fn run_account_get(&self, context: Option<&str>) -> anyhow::Result { + if !self.paths.may_exec() || !self.paths.has_binary("doctl") { + return Ok(unsupported_verify( + Self::TOOL, + "the doctl CLI is not available on PATH", + Some("doctl account get"), + )); + } + + let mut args = vec!["account", "get", "-o", "json"]; + if let Some(context) = context { + args.extend_from_slice(&["--context", context]); + } + let out = self.paths.run("doctl", &args)?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &Self::surface_error(&out), + "DigitalOcean", + "doctl auth init", + )); + } + + let named = match context { + Some(context) => format!("context `{context}`: "), + None => String::new(), + }; + Ok(match Account::parse(&out.stdout) { + Some(account) => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!( + "{named}DigitalOcean accepted the token for {}", + account.describe() + ), + }, + // Exit 0: DigitalOcean answered, so the token is live. Only the + // shape of the answer is unfamiliar. + None => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!( + "{named}DigitalOcean accepted the token, but `doctl account get -o json` did \ + not parse" + ), + }, + }) + } + + /// Lift doctl's error out of wherever `-o json` put it. + /// + /// **The trap.** With `-o json` doctl stops writing `Error: …` to stderr + /// and writes `{"errors":[{"detail":"…"}]}` to **stdout** instead. The + /// shared classifier reads stderr first and only falls back to stdout, so + /// without this it would be classifying a JSON envelope rather than the + /// message inside it — and `{"errors":…}` matches none of the markers, so + /// every failure would come back as "unclassified" with a brace for a + /// headline. The detail is hoisted into the stderr slot so both output + /// modes classify the same way. + fn surface_error(out: &crate::util::CmdOutput) -> crate::util::CmdOutput { + if !out.stderr.trim().is_empty() { + return out.clone(); + } + let detail = serde_json::from_str::(out.stdout.trim()) + .ok() + .and_then(|v| { + v.get("errors")? + .as_array()? + .iter() + .filter_map(|e| e.get("detail")?.as_str()) + .map(str::to_string) + .next() + }); + match detail { + Some(detail) => crate::util::CmdOutput { + ok: out.ok, + stdout: String::new(), + stderr: detail, + }, + None => out.clone(), + } + } +} + +/// The identity half of `doctl account get -o json`. Limits and counters are +/// deliberately absent: they change hourly and say nothing about who you are. +#[derive(Deserialize, Default)] +struct Account { + #[serde(default)] + email: Option, + #[serde(default)] + uuid: Option, + #[serde(default)] + status: Option, + #[serde(default)] + team: Option, +} + +#[derive(Deserialize, Default)] +struct Team { + #[serde(default)] + name: Option, +} + +impl Account { + /// doctl's displayer has emitted both a bare object and a one-element array + /// across versions; accept either rather than betting on one. + fn parse(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + let value = match value { + serde_json::Value::Array(items) => items.into_iter().next()?, + other => other, + }; + let account: Self = serde_json::from_value(value).ok()?; + // An object with none of the fields is not an answer. + (account.email.is_some() || account.uuid.is_some()).then_some(account) + } + + fn describe(&self) -> String { + let who = self + .email + .clone() + .or_else(|| self.uuid.clone()) + .unwrap_or_else(|| "an account it would not name".to_string()); + let team = self.team.as_ref().and_then(|t| t.name.clone()); + match (team, &self.status) { + (Some(team), Some(status)) => format!("{who} (team {team}, {status})"), + (Some(team), None) => format!("{who} (team {team})"), + (None, Some(status)) => format!("{who} ({status})"), + (None, None) => who, + } + } } impl Probe for DoctlProbe { @@ -171,11 +304,35 @@ impl Probe for DoctlProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run doctl yet; `doctl account get` is a network call", - Some("doctl account get"), - )) + let status = self.status()?; + match status.active.clone() { + // Without --context doctl answers about whichever context the file + // names, so naming it explicitly is what keeps a per-row answer + // about that row. + Some(active) => self.verify_profile(&active), + None => self.run_account_get(None), + } + } + + /// One named auth context, checked as itself. + /// + /// `--context` is a persistent flag: it selects the token for a single + /// invocation without rewriting config.yaml, which is exactly the + /// difference between checking a profile and switching to it. + fn verify_profile(&self, profile_id: &str) -> anyhow::Result { + let status = self.status()?; + if !status.profiles.is_empty() && !status.profiles.iter().any(|p| p.id == profile_id) { + let available: Vec<_> = status.profiles.iter().map(|p| p.id.as_str()).collect(); + return Ok(unsupported_verify( + Self::TOOL, + &format!( + "no auth context called `{profile_id}`; contexts: {}", + available.join(", ") + ), + Some("doctl auth list"), + )); + } + self.run_account_get(Some(profile_id)) } fn permissions(&self) -> anyhow::Result { @@ -320,4 +477,179 @@ mod tests { SwitchOutcome::Unsupported { .. } )); } + + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> DoctlProbe { + DoctlProbe::new(Paths::for_test(home).with_exec(exec)) + } + + const ACCOUNT: &str = r#"{ + "droplet_limit": 25, + "email": "dev@example.com", + "uuid": "0a1b2c3d0000444488880000abcdefab", + "email_verified": true, + "status": "active", + "team": { "name": "Pathors", "uuid": "team-0001" } + }"#; + + const TWO_CONTEXTS: &str = "context: pathors-team\n\ + access-token: dop_v1_fakefixturedefault\n\ + auth-contexts:\n pathors-team: dop_v1_fakefixtureteam\n"; + + #[test] + fn test_verify_asks_about_the_active_context_by_name() { + let (_dir, home) = fixture(TWO_CONTEXTS); + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("account get", true, ACCOUNT, "")); + let outcome = probe_with(&home, exec.clone()).verify().unwrap(); + // Without --context doctl answers about the file's context whatever row + // the panel thinks it is asking about. + assert_eq!( + exec.last().unwrap().line(), + "doctl account get -o json --context pathors-team" + ); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("dev@example.com"), "{detail}"); + assert!(detail.contains("Pathors"), "{detail}"); + assert!(detail.contains("pathors-team"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_verify_profile_pins_the_context_it_was_asked_about() { + let (_dir, home) = fixture(TWO_CONTEXTS); + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("account get", true, ACCOUNT, "")); + let probe = probe_with(&home, exec.clone()); + probe.verify_profile("default").unwrap(); + assert!( + exec.last().unwrap().args.contains(&"default".to_string()), + "{:?}", + exec.last().unwrap().args + ); + + match probe.verify_profile("ghost").unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("pathors-team"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_a_json_array_of_one_parses_like_the_bare_object() { + // doctl's displayer has emitted both across versions. + let wrapped = format!("[{ACCOUNT}]"); + let (_dir, home) = fixture(TWO_CONTEXTS); + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("account get", true, &wrapped, "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("dev@example.com"), "{detail}") + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_logged_out_revoked_and_offline_get_three_different_answers() { + let (_dir, home) = fixture(TWO_CONTEXTS); + + let cases: [(&str, &dyn Fn(VerifyOutcome)); 3] = [ + ( + "Error: unable to initialize DigitalOcean API client: access token is required. (hint: run 'doctl auth init')\n", + &|outcome| match outcome { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("not logged in"), "{detail}"); + assert!(detail.contains("doctl auth init"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + }, + ), + ( + "Error: GET https://api.digitalocean.com/v2/account: 401 (request \"abc\") Unable to authenticate you\n", + &|outcome| match outcome { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("rejected"), "{detail}"); + assert!(detail.contains("doctl auth init"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + }, + ), + ( + "Error: Get \"https://api.digitalocean.com/v2/account\": dial tcp: lookup api.digitalocean.com: no such host\n", + &|outcome| match outcome { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach DigitalOcean"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + }, + ), + ]; + + for (stderr, check) in cases { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "account get", + false, + "", + stderr, + )); + check(probe_with(&home, exec).verify().unwrap()); + } + } + + #[test] + fn test_a_json_error_envelope_on_stdout_classifies_like_the_text_one() { + // With `-o json` doctl writes the error to stdout as JSON and leaves + // stderr empty; classifying the envelope instead of the message would + // turn every failure into an unreadable "unclassified" answer. + let (_dir, home) = fixture(TWO_CONTEXTS); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "account get", + false, + "{\"errors\":[{\"detail\":\"GET https://api.digitalocean.com/v2/account: 401 (request \\\"abc\\\") Unable to authenticate you\"}]}", + "", + )); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("rejected"), "{detail}"); + assert!(!detail.contains("errors"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_unparseable_json_degrades_instead_of_panicking() { + let (_dir, home) = fixture(TWO_CONTEXTS); + for junk in ["", "not json at all", "{}", "[]", "null", "[[[["] { + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("account get", true, junk, "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("did not parse"), "{junk:?} -> {detail}"); + } + other => panic!("expected Valid for {junk:?}, got {other:?}"), + } + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let (_dir, home) = fixture(TWO_CONTEXTS); + match DoctlProbe::new(Paths::for_test(&home)).verify().unwrap() { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("doctl account get")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } } diff --git a/crates/patchbay-core/src/probes/firebase.rs b/crates/patchbay-core/src/probes/firebase.rs index fa245d7..c70499c 100644 --- a/crates/patchbay-core/src/probes/firebase.rs +++ b/crates/patchbay-core/src/probes/firebase.rs @@ -28,6 +28,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{ Expiry, Note, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, }; @@ -91,6 +92,69 @@ impl FirebaseProbe { Self { paths } } + /// What a `login:list` answer is and — just as importantly — is not. The + /// caveat travels with every success detail rather than being left for the + /// user to infer from a green tick. + const CHECK_CAVEAT: &'static str = + "firebase-tools names the account it will use (`login:list` reads the local store, so it \ + does not prove Google still accepts the grant)"; + + /// `firebase login:list`, reduced to the email addresses it names. + /// + /// `Ok(Err(outcome))` is the "already answered" case — no binary, or the + /// CLI failed — so callers can return it unchanged. + /// + /// **Why not `--json`.** `firebase login:list --json` does answer, and its + /// answer embeds the live `access_token` and `refresh_token` of every + /// account. Parsing it would put Google refresh tokens in patchbay's memory + /// and one careless error path away from a log line. The plain text prints + /// email addresses and nothing else, so the text is what gets parsed. + fn login_list(&self) -> anyhow::Result, VerifyOutcome>> { + if !self.paths.may_exec() || !self.paths.has_binary("firebase") { + return Ok(Err(unsupported_verify( + Self::TOOL, + "the firebase CLI is not available on PATH", + Some("firebase login:list"), + ))); + } + // `--non-interactive` is belt and braces: login:list never prompts, but + // a future firebase-tools that decides to must fail rather than block a + // spinner forever. + let out = self + .paths + .run("firebase", &["login:list", "--non-interactive"])?; + if !out.ok { + return Ok(Err(cli_verify::failure_outcome( + Self::TOOL, + &out, + "firebase-tools", + "firebase login", + ))); + } + Ok(Ok(Self::parse_login_list(&out.stdout))) + } + + /// The emails out of `login:list`, primary first. + /// + /// The real output is `Logged in as a@b.com`, optionally followed by an + /// `Other accounts:` block of indented addresses. Rather than depend on + /// that layout surviving, this takes every `@`-shaped word in order and + /// de-duplicates — `Logged in as` is simply where the first one appears. + fn parse_login_list(stdout: &str) -> Vec { + let mut emails: Vec = Vec::new(); + for word in stdout.split_whitespace() { + let candidate = word.trim_matches(|c: char| !c.is_ascii_graphic() || c == ','); + let is_email = candidate.contains('@') + && !candidate.starts_with('@') + && !candidate.ends_with('@') + && candidate.contains('.'); + if is_email && !emails.iter().any(|e| e == candidate) { + emails.push(candidate.to_string()); + } + } + emails + } + fn profile(email: &str, tokens: Option<&Tokens>) -> Profile { // The hour is only the truth for a grant that cannot refresh itself. let refreshable = tokens.is_some_and(Tokens::refreshable); @@ -200,11 +264,52 @@ impl Probe for FirebaseProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run firebase yet; the CLI is node-based and slow to start", - Some("firebase login:list"), - )) + let accounts = match self.login_list()? { + Ok(accounts) => accounts, + Err(outcome) => return Ok(outcome), + }; + let Some((primary, others)) = accounts.split_first() else { + return Ok(VerifyOutcome::Invalid { + tool: Self::TOOL.to_string(), + detail: "firebase-tools holds no account — run `firebase login`".to_string(), + }); + }; + let also = if others.is_empty() { + String::new() + } else { + format!(" (also {})", others.join(", ")) + }; + Ok(VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!("{}: {primary}{also}", Self::CHECK_CAVEAT), + }) + } + + /// One named account, checked against the list the CLI itself keeps. + /// + /// firebase-tools has no global active account — `--account` picks one per + /// command — so the question worth answering per profile is whether the CLI + /// still holds a login for that address at all. + fn verify_profile(&self, profile_id: &str) -> anyhow::Result { + let accounts = match self.login_list()? { + Ok(accounts) => accounts, + Err(outcome) => return Ok(outcome), + }; + Ok( + if accounts.iter().any(|a| a.eq_ignore_ascii_case(profile_id)) { + VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!("{}: {profile_id}", Self::CHECK_CAVEAT), + } + } else { + VerifyOutcome::Invalid { + tool: Self::TOOL.to_string(), + detail: format!( + "firebase-tools holds no login for {profile_id} — run `firebase login:add`" + ), + } + }, + ) } fn permissions(&self) -> anyhow::Result { @@ -392,6 +497,124 @@ mod tests { .any(|n| n.kind == NoteKind::Problem && n.text.contains("not valid JSON"))); } + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> FirebaseProbe { + FirebaseProbe::new(Paths::for_test(home).with_exec(exec)) + } + + #[test] + fn test_verify_reports_the_accounts_the_cli_itself_lists() { + let (_dir, home) = fixture(STORE); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "login:list", + true, + "Logged in as dev@example.com\n\nOther accounts:\n ops@example.com\n", + "", + )); + let outcome = probe_with(&home, exec.clone()).verify().unwrap(); + assert_eq!( + exec.last().unwrap().line(), + "firebase login:list --non-interactive" + ); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("dev@example.com"), "{detail}"); + assert!(detail.contains("ops@example.com"), "{detail}"); + // The caveat travels with the tick: this is a local read. + assert!(detail.contains("local store"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_verify_profile_answers_for_the_account_it_was_asked_about() { + let (_dir, home) = fixture(STORE); + let listing = "Logged in as dev@example.com\n\nOther accounts:\n ops@example.com\n"; + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("login:list", true, listing, "")); + let probe = probe_with(&home, exec); + assert!(matches!( + probe.verify_profile("ops@example.com").unwrap(), + VerifyOutcome::Valid { .. } + )); + match probe.verify_profile("stranger@example.com").unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("firebase login:add"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_an_empty_listing_is_a_logged_out_answer() { + let (_dir, home) = fixture(STORE); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "login:list", + true, + "No accounts to list\n", + "", + )); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("firebase login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_a_failing_cli_becomes_one_sentence_not_a_stack_trace() { + let (_dir, home) = fixture(STORE); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "login:list", + false, + "", + "Error: Failed to authenticate, have you run firebase login?\n at requireAuth (/usr/lib/node_modules/firebase-tools/lib/requireAuth.js:52:11)\n at async Command.prepare\n", + )); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("firebase login"), "{detail}"); + // Not a paste of somebody else's stack. + assert!(!detail.contains("at requireAuth"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + let (_dir, home) = fixture(STORE); + for junk in ["", "@\n@@\n", "Logged in as\n", "\u{0}\u{1}\u{2}"] { + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("login:list", true, junk, "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("firebase login"), "{junk:?} -> {detail}"); + } + other => panic!("expected Invalid for {junk:?}, got {other:?}"), + } + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let (_dir, home) = fixture(STORE); + match FirebaseProbe::new(Paths::for_test(&home)).verify().unwrap() { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("firebase login:list")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + #[test] fn test_permissions_reports_the_local_grant() { let (_dir, home) = fixture(STORE); diff --git a/crates/patchbay-core/src/probes/flyctl.rs b/crates/patchbay-core/src/probes/flyctl.rs index fa5dfd3..91ef872 100644 --- a/crates/patchbay-core/src/probes/flyctl.rs +++ b/crates/patchbay-core/src/probes/flyctl.rs @@ -29,6 +29,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{ ActiveConcept, Expiry, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, }; @@ -58,6 +59,44 @@ impl FlyctlProbe { pub fn new(paths: Paths) -> Self { Self { paths } } + + /// Whichever name is on PATH. `fly` is the one Fly's own docs and installer + /// use; `flyctl` is the package name and is still what some installs leave + /// behind, so both are tried. + fn binary(&self) -> Option<&'static str> { + if !self.paths.may_exec() { + return None; + } + ["fly", "flyctl"] + .into_iter() + .find(|bin| self.paths.has_binary(bin)) + } + + /// The identity out of `fly auth whoami`, in either shape. + /// + /// With `--json` the answer is `{"email": "…"}`; without it, or on a + /// version that ignores the flag, it is the bare address on one line. Both + /// are accepted so that the flag's real job — suppressing the interactive + /// login — does not also become a parsing dependency. + fn parse_whoami(stdout: &str) -> Option { + if let Some(email) = serde_json::from_str::(stdout.trim()) + .ok() + .and_then(|v| v.get("email")?.as_str().map(str::to_string)) + .filter(|e| !e.trim().is_empty()) + { + return Some(email); + } + let line = stdout.lines().map(str::trim).find(|l| !l.is_empty())?; + let line = line + .split_once(':') + .map(|(_, rest)| rest.trim()) + .filter(|rest| !rest.is_empty()) + .unwrap_or(line); + // A line of pure punctuation is not a name. + line.chars() + .any(char::is_alphanumeric) + .then(|| line.to_string()) + } } impl Probe for FlyctlProbe { @@ -143,11 +182,44 @@ impl Probe for FlyctlProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run flyctl yet; `fly auth whoami` is a network call", - Some("fly auth whoami"), - )) + let Some(bin) = self.binary() else { + return Ok(unsupported_verify( + Self::TOOL, + "the fly CLI is not available on PATH", + Some("fly auth whoami"), + )); + }; + + // **`--json` is here to stop a prompt, not to make parsing nicer.** + // `fly auth whoami` runs through `RequireSession`, which offers an + // interactive browser login when there is no token; the literal flag is + // one of the three things that disarm that gate (a non-TTY and `CI=1` + // being the others, neither of which patchbay can rely on). A verify + // that opens a browser and waits is worse than no verify. + let out = self.paths.run(bin, &["auth", "whoami", "--json"])?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "Fly.io", + "fly auth login", + )); + } + + Ok(match Self::parse_whoami(&out.stdout) { + Some(who) => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!("Fly.io accepted the macaroon for {who}"), + }, + // Exit 0 means Fly answered, so the token is live; only the name is + // missing. `whoami` says nothing about org membership either way — + // the org comes from --org at run time, so none is claimed here. + None => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: "Fly.io accepted the macaroon, but `fly auth whoami` named no user" + .to_string(), + }, + }) } fn permissions(&self) -> anyhow::Result { @@ -284,4 +356,126 @@ mod tests { .expect("the logged-out state is explained"); assert_eq!(logged_out.kind, NoteKind::Info); } + + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> FlyctlProbe { + FlyctlProbe::new(Paths::for_test(home).with_exec(exec)) + } + + #[test] + fn test_verify_reports_the_account_fly_names() { + let (_dir, home) = fixture(CONFIG); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "auth whoami", + true, + "{\n \"email\": \"dev@example.com\"\n}\n", + "", + )); + let outcome = probe_with(&home, exec.clone()).verify().unwrap(); + // The flag is load-bearing: without it `whoami` may offer a browser + // login instead of failing. + assert_eq!(exec.last().unwrap().line(), "fly auth whoami --json"); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("dev@example.com"), "{detail}"); + // No org may be invented: fly records none, and --org decides + // it at run time. + assert!(!detail.to_lowercase().contains("org"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_both_answer_shapes_reduce_to_the_identity() { + for shape in [ + "{\"email\": \"dev@example.com\"}", + "Current user: dev@example.com\n", + "dev@example.com\n", + ] { + assert_eq!( + FlyctlProbe::parse_whoami(shape), + Some("dev@example.com".to_string()), + "{shape}" + ); + } + assert_eq!(FlyctlProbe::parse_whoami("{\"email\": \"\"}"), None); + } + + #[test] + fn test_logged_out_revoked_and_offline_get_three_different_answers() { + let (_dir, home) = fixture(CONFIG); + + let logged_out = std::sync::Arc::new(crate::util::FakeExec::new().on( + "auth whoami", + false, + "", + "Error: No access token available. Please login with 'flyctl auth login'\n", + )); + match probe_with(&home, logged_out).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("not logged in"), "{detail}"); + assert!(detail.contains("fly auth login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let revoked = std::sync::Arc::new(crate::util::FakeExec::new().on( + "auth whoami", + false, + "", + "Error: failed to fetch user: 401 Unauthorized\n", + )); + match probe_with(&home, revoked).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("rejected"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let offline = std::sync::Arc::new(crate::util::FakeExec::new().on( + "auth whoami", + false, + "", + "Error: Post \"https://api.fly.io/graphql\": dial tcp: lookup api.fly.io: no such host\n", + )); + match probe_with(&home, offline).verify().unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach Fly.io"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + let (_dir, home) = fixture(CONFIG); + for junk in ["", "\n \n", ":"] { + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("auth whoami", true, junk, "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("named no user"), "{junk:?} -> {detail}"); + } + other => panic!("expected Valid for {junk:?}, got {other:?}"), + } + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let (_dir, home) = fixture(CONFIG); + match FlyctlProbe::new(Paths::for_test(&home)).verify().unwrap() { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("fly auth whoami")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } } diff --git a/crates/patchbay-core/src/probes/huggingface.rs b/crates/patchbay-core/src/probes/huggingface.rs index 6e887be..fc5bdf5 100644 --- a/crates/patchbay-core/src/probes/huggingface.rs +++ b/crates/patchbay-core/src/probes/huggingface.rs @@ -22,6 +22,7 @@ use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{Expiry, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome}; use crate::util::{read_text, Ini}; @@ -37,6 +38,117 @@ impl HuggingfaceProbe { pub fn new(paths: Paths) -> Self { Self { paths } } + + /// The whoami invocation for whichever CLI generation is installed. + /// + /// huggingface_hub 1.0 renamed the binary and moved the verb: `hf auth + /// whoami` replaced `huggingface-cli whoami`. Machines mid-upgrade have + /// only one of them, so the command is chosen rather than assumed. + /// + /// **`--format json` is not an optimisation, it is a correctness fix.** The + /// modern CLI defaults to `--format auto`, which sniffs environment + /// variables to decide whether it is talking to a human or to an AI agent + /// harness and *changes the output shape accordingly*. patchbay is + /// frequently launched from exactly such a harness, so leaving the format + /// to auto-detection means parsing a different answer depending on who + /// started the panel. The flag pins it. + fn whoami_command(&self) -> Option<(&'static str, Vec<&'static str>)> { + if !self.paths.may_exec() { + return None; + } + if self.paths.has_binary("hf") { + Some(("hf", vec!["auth", "whoami", "--format", "json"])) + } else if self.paths.has_binary("huggingface-cli") { + // The pre-1.0 CLI has neither the `auth` verb nor `--format`. + Some(("huggingface-cli", vec!["whoami"])) + } else { + None + } + } + + /// `(username, orgs)` out of whoami, in either dialect it speaks. + /// + /// JSON first: `{"user": "...", "orgs": "a,b", "endpoint": null}` — note + /// that `orgs` is a **comma-joined string**, not a list, and is `null` when + /// there are none. Anything that is not JSON falls through to the legacy + /// text shape, which is the username on its own line and an optional + /// `orgs: a,b` beneath it. + fn parse_whoami(stdout: &str) -> Option<(String, Vec)> { + if let Some(parsed) = Self::parse_whoami_json(stdout) { + return Some(parsed); + } + Self::parse_whoami_text(stdout) + } + + fn parse_whoami_json(stdout: &str) -> Option<(String, Vec)> { + let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?; + let user = value.get("user")?.as_str()?.trim().to_string(); + if user.is_empty() { + return None; + } + let orgs = value + .get("orgs") + .and_then(|o| o.as_str()) + .map(|o| { + o.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + Some((user, orgs)) + } + + fn parse_whoami_text(stdout: &str) -> Option<(String, Vec)> { + let mut user = None; + let mut orgs = Vec::new(); + for line in stdout.lines() { + // NO_COLOR is set on every child, but a bold escape that slips + // through must not become part of a username. + let line = strip_ansi(line); + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.to_lowercase().strip_prefix("orgs:") { + let start = line.len() - rest.len(); + orgs = line[start..] + .split(',') + .map(|o| o.trim().to_string()) + .filter(|o| !o.is_empty()) + .collect(); + continue; + } + // A private-endpoint notice follows the username; do not take it. + if line.starts_with("Authenticated through") { + continue; + } + if user.is_none() { + user = Some(line.to_string()); + } + } + user.map(|user| (user, orgs)) + } +} + +/// Drop CSI escape sequences. Deliberately tiny: this only ever sees one short +/// line of a CLI's own output, and a dependency for that would be absurd. +fn strip_ansi(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + // ESC [ … + for c in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&c) && c != '[' { + break; + } + } + } + out } impl Probe for HuggingfaceProbe { @@ -158,11 +270,53 @@ impl Probe for HuggingfaceProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run the hf CLI yet; `hf auth whoami` is a network call", - Some("hf auth whoami"), - )) + let Some((bin, args)) = self.whoami_command() else { + return Ok(unsupported_verify( + Self::TOOL, + "the hf CLI is not available on PATH", + Some("hf auth whoami"), + )); + }; + + let out = self.paths.run(bin, &args)?; + if !out.ok { + // No token and a rejected token both exit 1 here — `Error: Not + // logged in` versus `Error: Invalid user token.` — so the text is + // what separates them, which is precisely what `classify` does. + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "the Hugging Face Hub", + "hf auth login", + )); + } + + // Belt and braces for older hub releases, which printed `Not logged in` + // and exited **zero**: on those, the exit code alone files a logged-out + // machine as a working login. + if cli_verify::says_logged_out(&out.stdout) { + return Ok(VerifyOutcome::Invalid { + tool: Self::TOOL.to_string(), + detail: "not logged in — run `hf auth login`".to_string(), + }); + } + + Ok(match Self::parse_whoami(&out.stdout) { + Some((user, orgs)) => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: match orgs.is_empty() { + true => format!("the Hub accepted the token for {user}"), + false => format!( + "the Hub accepted the token for {user} (orgs: {})", + orgs.join(", ") + ), + }, + }, + None => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: "the Hub accepted the token, but `whoami` named no user".to_string(), + }, + }) } fn permissions(&self) -> anyhow::Result { @@ -316,6 +470,179 @@ mod tests { assert!(!json.contains("hf_fakefixtureenv"), "{json}"); } + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> HuggingfaceProbe { + HuggingfaceProbe::new(Paths::for_test(home).with_exec(exec)) + } + + #[test] + fn test_verify_pins_the_output_format_and_reports_user_and_orgs() { + let tmp = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + r#"{"user": "pathors", "orgs": "pathors-ai,cerana", "endpoint": null}"#, + "", + )); + let outcome = probe_with(tmp.path(), exec.clone()).verify().unwrap(); + // `--format auto` sniffs the environment for an AI-agent harness and + // changes shape; patchbay is often started from one, so the format is + // pinned rather than detected. + assert_eq!(exec.last().unwrap().line(), "hf auth whoami --format json"); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("pathors"), "{detail}"); + // `orgs` arrives as one comma-joined string, not a list. + assert!(detail.contains("pathors-ai, cerana"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_the_legacy_text_shape_still_parses() { + let tmp = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + "pathors\norgs: pathors-ai,cerana\n", + "", + )); + match probe_with(tmp.path(), exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("pathors-ai, cerana"), "{detail}") + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_no_token_and_a_bad_token_both_exit_one_and_are_told_apart() { + // Both are exit 1 with an `Error: …` on stderr, so only the text + // separates "you never logged in" from "your token was rejected". + let tmp = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "Error: Not logged in\n", + )); + match probe_with(tmp.path(), exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("not logged in"), "{detail}"); + assert!(detail.contains("hf auth login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "Error: Invalid user token. The token stored is invalid. Please run `hf auth login --force` to set a new token.\n", + )); + match probe_with(tmp.path(), exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("rejected"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_not_logged_in_on_a_zero_exit_is_still_not_logged_in() { + // Older hub releases printed this and exited **0**. + let tmp = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + "Not logged in\n", + "", + )); + match probe_with(tmp.path(), exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("hf auth login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_a_revoked_token_and_an_outage_are_told_apart() { + let tmp = tempfile::tempdir().unwrap(); + let revoked = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "401 Client Error: Unauthorized for url: https://huggingface.co/api/whoami-v2\n{\"error\":\"Invalid credentials in Authorization header\"}\n", + "", + )); + match probe_with(tmp.path(), revoked).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("hf auth login"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let offline = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "requests.exceptions.ConnectionError: HTTPSConnectionPool(host='huggingface.co', port=443): Max retries exceeded\n", + )); + match probe_with(tmp.path(), offline).verify().unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + let tmp = tempfile::tempdir().unwrap(); + for junk in ["", "\n\n\n", "\u{1b}[1m\u{1b}[0m"] { + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, junk, "")); + match probe_with(tmp.path(), exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("named no user"), "{junk:?} -> {detail}"); + } + other => panic!("expected Valid for {junk:?}, got {other:?}"), + } + } + } + + #[test] + fn test_the_legacy_binary_uses_the_verb_it_understands() { + // `hf auth whoami` did not exist before huggingface_hub 1.0. + assert_eq!( + HuggingfaceProbe::parse_whoami("pathors\n").map(|(u, _)| u), + Some("pathors".to_string()) + ); + assert_eq!(strip_ansi("\u{1b}[1morgs: \u{1b}[0m a"), "orgs: a"); + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let tmp = tempfile::tempdir().unwrap(); + match HuggingfaceProbe::new(Paths::for_test(tmp.path())) + .verify() + .unwrap() + { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("hf auth whoami")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + #[test] fn test_absent_and_empty_cache() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/patchbay-core/src/probes/mod.rs b/crates/patchbay-core/src/probes/mod.rs index 6a76baa..aa8b784 100644 --- a/crates/patchbay-core/src/probes/mod.rs +++ b/crates/patchbay-core/src/probes/mod.rs @@ -3,6 +3,7 @@ pub mod aws; pub mod az; pub mod claude; +mod cli_verify; pub mod cloudflared; pub mod docker; pub mod doctl; diff --git a/crates/patchbay-core/src/probes/neon.rs b/crates/patchbay-core/src/probes/neon.rs index e04c795..751fdda 100644 --- a/crates/patchbay-core/src/probes/neon.rs +++ b/crates/patchbay-core/src/probes/neon.rs @@ -27,6 +27,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{ Expiry, Note, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, }; @@ -62,6 +63,54 @@ impl NeonProbe { raw.map(|s| s.split_whitespace().map(str::to_string).collect()) .unwrap_or_default() } + + /// Whichever of the two names is installed — see the rename trap in the + /// module header. `neon` first, because that is the current one. + fn binary(&self) -> Option<&'static str> { + if !self.paths.may_exec() { + return None; + } + ["neon", "neonctl"] + .into_iter() + .find(|bin| self.paths.has_binary(bin)) + } +} + +/// The half of `neon me --output json` worth naming. The response also carries +/// an avatar URL per auth account, which is bulk with no bearing on identity, +/// and `auth_accounts`, which repeats the same person once per provider. +#[derive(Deserialize, Default)] +struct Me { + #[serde(default)] + email: Option, + #[serde(default)] + login: Option, + #[serde(default)] + name: Option, + #[serde(default)] + id: Option, + #[serde(default)] + plan: Option, +} + +impl Me { + /// The identity in one clause, however much of it Neon actually sent. + fn describe(&self) -> String { + let who = self + .email + .clone() + .or_else(|| self.login.clone()) + .or_else(|| self.name.clone()) + .or_else(|| self.id.clone()) + .unwrap_or_else(|| "an account it would not name".to_string()); + match (&self.login, &self.plan) { + (Some(login), Some(plan)) if Some(login) != self.email.as_ref() => { + format!("{who} (login {login}, {plan} plan)") + } + (_, Some(plan)) => format!("{who} ({plan} plan)"), + _ => who, + } + } } impl Probe for NeonProbe { @@ -150,12 +199,57 @@ impl Probe for NeonProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run neon yet; `neon me` is a network call and the local expiry is \ - usually enough", - Some("neon me"), - )) + let Some(bin) = self.binary() else { + return Ok(unsupported_verify( + Self::TOOL, + "the neon CLI is not available on PATH", + Some("neon me"), + )); + }; + + // **The reason this gate exists.** `neon me` does not fail when there + // is no credential: the CLI starts its OAuth flow, opens a browser and + // waits for the callback. A verify that hangs a spinner until the user + // logs in is worse than no verify, so the one state that would trigger + // it is answered from tier 1 instead — exactly the shape gcloud uses + // for an uncredentialed account. + let credentialed = self.paths.neon_dir().join("credentials.json").is_file() + || self.paths.env("NEON_API_KEY").is_some(); + if !credentialed { + return Ok(VerifyOutcome::Invalid { + tool: Self::TOOL.to_string(), + detail: "no stored Neon credential on this machine — run `neon auth`".to_string(), + }); + } + + let out = self.paths.run(bin, &["me", "--output", "json"])?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "Neon", + "neon auth", + )); + } + + let me: Me = match serde_json::from_str(&out.stdout) { + Ok(me) => me, + Err(e) => { + // Exit 0, so Neon answered and the grant works; only the shape + // of the answer is unfamiliar. + return Ok(VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!( + "Neon accepted the login, but `neon me --output json` did not parse ({e})" + ), + }); + } + }; + + Ok(VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!("Neon accepted the login for {}", me.describe()), + }) } fn permissions(&self) -> anyhow::Result { @@ -342,6 +436,140 @@ mod tests { .contains("expires_at is present but is not a usable timestamp"))); } + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> NeonProbe { + NeonProbe::new(Paths::for_test(home).with_exec(exec)) + } + + /// `neon me --output json` on this machine, minus the avatar URLs. + const ME: &str = r#"{ + "email": "dev@example.com", + "id": "0a1b2c3d-0000-4444-8888-abcdefabcdef", + "login": "devlogin", + "name": "Dev", + "projects_limit": 0, + "plan": "free" + }"#; + + #[test] + fn test_verify_reports_the_account_neon_names() { + let (_dir, home) = fixture(&credentials(1785611828464)); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("me", true, ME, "")); + let outcome = probe_with(&home, exec.clone()).verify().unwrap(); + assert_eq!(exec.last().unwrap().line(), "neon me --output json"); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("dev@example.com"), "{detail}"); + assert!(detail.contains("devlogin"), "{detail}"); + assert!(detail.contains("free"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_no_stored_credential_is_answered_without_opening_a_browser() { + // `neon me` with nothing on disk starts the OAuth flow and waits for a + // browser callback. Tier 1 already knows the answer, so nothing runs. + let dir = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("me", true, ME, "")); + match probe_with(dir.path(), exec.clone()).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("neon auth"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + assert!(exec.calls().is_empty(), "nothing may be executed here"); + } + + #[test] + fn test_an_api_key_in_the_environment_is_credential_enough_to_run() { + let dir = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("me", true, ME, "")); + let paths = Paths::for_test(dir.path()) + .with_env("NEON_API_KEY", "fake-fixture-key") + .with_exec(exec.clone()); + assert!(matches!( + NeonProbe::new(paths).verify().unwrap(), + VerifyOutcome::Valid { .. } + )); + assert_eq!(exec.calls().len(), 1); + } + + #[test] + fn test_a_revoked_grant_and_an_outage_get_different_answers() { + let (_dir, home) = fixture(&credentials(1785611828464)); + + let revoked = std::sync::Arc::new(crate::util::FakeExec::new().on( + "me", + false, + "", + "ERROR: Authentication failed: 401 Unauthorized\n", + )); + match probe_with(&home, revoked).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("neon auth"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let offline = std::sync::Arc::new(crate::util::FakeExec::new().on( + "me", + false, + "", + "ERROR: request to https://console.neon.tech/api/v2/users/me failed: getaddrinfo EAI_AGAIN console.neon.tech\n", + )); + match probe_with(&home, offline).verify().unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach Neon"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unparseable_json_degrades_instead_of_panicking() { + let (_dir, home) = fixture(&credentials(1785611828464)); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "me", + true, + "Warning: a new version is available\n{ not json", + "", + )); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("did not parse"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + + // Valid JSON of an unexpected shape is not a crash either. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("me", true, "{}", "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("would not name"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let (_dir, home) = fixture(&credentials(1785611828464)); + match NeonProbe::new(Paths::for_test(&home)).verify().unwrap() { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("neon me")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + #[test] fn test_credentials_without_an_expiry_degrade_to_unknown() { let (_dir, home) = fixture(r#"{ "access_token": "fake", "scope": "openid" }"#); diff --git a/crates/patchbay-core/src/probes/stripe.rs b/crates/patchbay-core/src/probes/stripe.rs index e5b51a3..5c5ace6 100644 --- a/crates/patchbay-core/src/probes/stripe.rs +++ b/crates/patchbay-core/src/probes/stripe.rs @@ -27,6 +27,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{ ActiveConcept, Expiry, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, }; @@ -66,6 +67,24 @@ impl StripeProbe { Self { paths } } + /// What `stripe whoami` is, and is not. + /// + /// Stripe's CLI has no read-only command that both names the account and + /// exercises the key: `whoami` is explicit that it "reads credentials from + /// the config file and keychain — no API calls are made". So the tick means + /// "this is the key the CLI will send", not "Stripe still honours it", and + /// the detail says so rather than letting a green row imply the stronger + /// claim. + /// + /// **Why `whoami` and not `config --list`.** `config --list` prints the + /// config file back, and that file holds `test_mode_api_key` in plain text. + /// It is the same local read with a live secret in the output and no + /// `authenticated` flag to read. `whoami` reports key *availability* and + /// expiry without ever printing key material. + const CHECK_CAVEAT: &'static str = + "the stripe CLI names the key it will use (`whoami` reads the local config and keychain, \ + so it does not prove Stripe still accepts it)"; + /// Stripe writes `YYYY-MM-DD` with no time and no zone. Treat it as end of /// that day UTC, so a key is not called expired hours early. fn parse_expiry(raw: &str) -> Option> { @@ -74,6 +93,74 @@ impl StripeProbe { } } +/// `stripe whoami --format json`. +/// +/// Every field is optional on purpose — the schema is documented as stable, but +/// a verify that fails because one key moved is worse than a thinner sentence. +/// Note what is *not* here: the key values. `whoami` reports availability and +/// expiry only, which is exactly why it is the command patchbay runs. +#[derive(Deserialize, Default)] +struct Whoami { + #[serde(default)] + authenticated: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + account_id: Option, + #[serde(default)] + device_name: Option, + #[serde(default)] + test_mode_key: Option, + #[serde(default)] + live_mode_key: Option, +} + +#[derive(Deserialize, Default)] +struct KeyState { + #[serde(default)] + available: Option, + #[serde(default)] + expires_at: Option, +} + +impl Whoami { + fn parse(stdout: &str) -> Option { + serde_json::from_str(stdout.trim()).ok() + } + + fn describe(&self) -> String { + let who = self + .display_name + .clone() + .or_else(|| self.account_id.clone()) + .unwrap_or_else(|| "an account it would not name".to_string()); + let mut parts = Vec::new(); + if let Some(account) = self.account_id.clone().filter(|a| *a != who) { + parts.push(account); + } + if let Some(device) = self.device_name.clone() { + parts.push(format!("device {device}")); + } + for (label, key) in [ + ("test key", self.test_mode_key.as_ref()), + ("live key", self.live_mode_key.as_ref()), + ] { + let Some(key) = key else { continue }; + if key.available == Some(false) { + continue; + } + match key.expires_at.as_deref().map(str::trim) { + Some(when) if !when.is_empty() => parts.push(format!("{label} to {when}")), + _ => parts.push(label.to_string()), + } + } + match parts.is_empty() { + true => who, + false => format!("{who} ({})", parts.join(", ")), + } + } +} + impl Probe for StripeProbe { fn tool(&self) -> &'static str { Self::TOOL @@ -216,11 +303,66 @@ impl Probe for StripeProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run stripe yet; the stored key expiry in status is the cheap answer", - Some("stripe config --list"), - )) + let status = self.status()?; + self.verify_profile(status.active.as_deref().unwrap_or(Self::ACTIVE_TABLE)) + } + + /// One profile, as the CLI itself resolves it. + /// + /// `--project-name` is the CLI's own per-invocation profile selector, so + /// asking about a profile never rewrites the file the way + /// `config --set-default` would. + fn verify_profile(&self, profile_id: &str) -> anyhow::Result { + if !self.paths.may_exec() || !self.paths.has_binary("stripe") { + return Ok(unsupported_verify( + Self::TOOL, + "the stripe CLI is not available on PATH", + Some("stripe whoami"), + )); + } + + let mut args = vec!["whoami", "--format", "json"]; + if profile_id != Self::ACTIVE_TABLE { + args.extend_from_slice(&["--project-name", profile_id]); + } + // The CLI fires a telemetry beacon and then *waits up to three seconds* + // for it before exiting. patchbay's verify should not pay that, and + // should not phone home on the user's behalf either. + let out = self.paths.run_env( + "stripe", + &args, + &[("STRIPE_CLI_TELEMETRY_OPTOUT", "1"), ("DO_NOT_TRACK", "1")], + )?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "the Stripe CLI", + "stripe login", + )); + } + + let who = Whoami::parse(&out.stdout); + if who.as_ref().is_some_and(|w| w.authenticated == Some(false)) { + return Ok(VerifyOutcome::Invalid { + tool: Self::TOOL.to_string(), + detail: format!( + "the stripe CLI holds no key for profile `{profile_id}` — run `stripe login`" + ), + }); + } + Ok(VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: match who { + Some(who) => format!("{}: {}", Self::CHECK_CAVEAT, who.describe()), + // `whoami` exits non-zero when it is not authenticated, so a + // zero exit is still an answer even when the shape is strange. + None => format!( + "{}, but `stripe whoami --format json` did not parse", + Self::CHECK_CAVEAT + ), + }, + }) } fn permissions(&self) -> anyhow::Result { @@ -383,6 +525,183 @@ test_mode_key_expires_at = "2020-01-01" assert_eq!(explanation.kind, NoteKind::Info); } + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> StripeProbe { + StripeProbe::new(Paths::for_test(home).with_exec(exec)) + } + + /// `stripe whoami --format json`, authenticated. Note what a real answer + /// does *not* contain: any key value. + const WHOAMI: &str = r#"{ + "authenticated": true, + "profile_name": "default", + "display_name": "Pathors Ltd", + "account_id": "acct_1FAKEFIXTURE", + "device_name": "fixture-mbp", + "test_mode_key": { "available": true, "expires_at": "2030-11-11" }, + "live_mode_key": { "available": true, "expires_at": "2030-12-01" }, + "api_version": "2026-03-31" + }"#; + + #[test] + fn test_verify_names_the_account_and_never_quotes_a_key() { + let (_dir, home) = fixture(CONFIG); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, WHOAMI, "")); + let outcome = probe_with(&home, exec.clone()).verify().unwrap(); + let call = exec.last().unwrap(); + assert_eq!(call.line(), "stripe whoami --format json"); + // The CLI waits up to three seconds on a telemetry beacon otherwise. + assert!( + call.env + .iter() + .any(|(k, v)| k == "STRIPE_CLI_TELEMETRY_OPTOUT" && v == "1"), + "{:?}", + call.env + ); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("Pathors Ltd"), "{detail}"); + assert!(detail.contains("acct_1FAKEFIXTURE"), "{detail}"); + assert!(detail.contains("test key to 2030-11-11"), "{detail}"); + // The caveat travels with the tick: this is a local read. + assert!(detail.contains("does not prove"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_verify_profile_pins_the_profile_it_was_asked_about() { + let (_dir, home) = fixture(CONFIG); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, WHOAMI, "")); + let probe = probe_with(&home, exec.clone()); + probe.verify_profile("staging").unwrap(); + let args = exec.last().unwrap().args; + assert!( + args.contains(&"--project-name".to_string()) && args.contains(&"staging".to_string()), + "{args:?}" + ); + + // The default profile is the one the CLI already uses; naming it adds + // nothing and `--project-name default` is not how it is spelled. + probe.verify_profile("default").unwrap(); + assert!( + !exec + .last() + .unwrap() + .args + .contains(&"--project-name".to_string()), + "{:?}", + exec.last().unwrap().args + ); + } + + #[test] + fn test_an_unauthenticated_answer_is_a_logged_out_answer() { + let (_dir, home) = fixture(CONFIG); + // `whoami` exits 1 in this state, but the flag is honoured either way. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + r#"{ "authenticated": false, "profile_name": "default" }"#, + "", + )); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("stripe login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_no_key_expired_key_and_an_outage_get_three_different_answers() { + let (_dir, home) = fixture(CONFIG); + + let none = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "You have not configured API keys yet.\n", + "", + )); + match probe_with(&home, none).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("stripe login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let expired = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "The API key for the default profile has expired. Run `stripe login` to re-authenticate.\nYou can also set the STRIPE_API_KEY environment variable.\n", + )); + match probe_with(&home, expired).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("rejected"), "{detail}"); + // One sentence, not both of the CLI's lines. + assert_eq!(detail.lines().count(), 1, "{detail}"); + assert!(!detail.contains("STRIPE_API_KEY"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let offline = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "Get \"https://api.stripe.com/v1/account\": dial tcp: lookup api.stripe.com: no such host\n", + )); + match probe_with(&home, offline).verify().unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + let (_dir, home) = fixture(CONFIG); + for junk in ["", "not json", "[[[[", "null"] { + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, junk, "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("did not parse"), "{junk:?} -> {detail}"); + } + other => panic!("expected Valid for {junk:?}, got {other:?}"), + } + } + + // Valid JSON of an unexpected shape is not a crash either. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, "{}", "")); + match probe_with(&home, exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("would not name"), "{detail}") + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let (_dir, home) = fixture(CONFIG); + match StripeProbe::new(Paths::for_test(&home)).verify().unwrap() { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("stripe whoami")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + #[test] fn test_expiry_parser_rejects_rfc3339_shaped_junk() { assert!(StripeProbe::parse_expiry("2030-11-11").is_some()); diff --git a/crates/patchbay-core/src/probes/supabase.rs b/crates/patchbay-core/src/probes/supabase.rs index 6e83b53..83f1008 100644 --- a/crates/patchbay-core/src/probes/supabase.rs +++ b/crates/patchbay-core/src/probes/supabase.rs @@ -20,8 +20,11 @@ //! account. There is no expiry anywhere: Supabase tokens are long-lived with //! server-side revocation (ADR 0008). +use serde::Deserialize; + use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{ ActiveConcept, Expiry, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, }; @@ -39,6 +42,74 @@ impl SupabaseProbe { pub fn new(paths: Paths) -> Self { Self { paths } } + + /// What the token can see, in one clause. + /// + /// A Supabase token has no "whoami": it carries the full rights of the + /// account that made it, and the only identity the API will hand back is + /// the set of organisations and projects it reaches. So that is what is + /// reported — the org slug where the projects agree on one, and enough + /// project names to recognise the account by. + /// + /// **An empty list is a success, not a failure.** The CLI builds the array + /// by appending to a nil slice, so a brand-new account serialises as `null` + /// rather than `[]`; both mean "the token worked and owns no projects", and + /// reading either as a broken login would be wrong. + fn describe_projects(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?; + let projects: Vec = match value { + serde_json::Value::Null => Vec::new(), + serde_json::Value::Array(items) => items + .into_iter() + .filter_map(|item| serde_json::from_value(item).ok()) + .collect(), + _ => return None, + }; + if projects.is_empty() { + return Some("no projects on this account".to_string()); + } + + let mut orgs: Vec<&str> = projects + .iter() + .filter_map(|p| p.organization_slug.as_deref()) + .collect(); + orgs.sort_unstable(); + orgs.dedup(); + + let named: Vec<&str> = projects + .iter() + .filter_map(|p| p.name.as_deref()) + .take(3) + .collect(); + let count = projects.len(); + let plural = if count == 1 { "" } else { "s" }; + let mut summary = format!("{count} project{plural}"); + if !named.is_empty() { + let more = count.saturating_sub(named.len()); + summary.push_str(&format!(" ({}", named.join(", "))); + if more > 0 { + summary.push_str(&format!(", +{more}")); + } + summary.push(')'); + } + match orgs.as_slice() { + [only] => summary.push_str(&format!(" in org {only}")), + [] => {} + many => summary.push_str(&format!(" across {} orgs", many.len())), + } + Some(summary) + } +} + +/// The two fields of a project row that say whose account this is. Everything +/// else the CLI returns — region, status, database host, timestamps — is +/// inventory, not identity. +#[derive(Deserialize)] +struct Project { + #[serde(default)] + name: Option, + #[serde(default)] + organization_slug: Option, } impl Probe for SupabaseProbe { @@ -132,15 +203,43 @@ impl Probe for SupabaseProbe { } fn verify(&self) -> anyhow::Result { - // `supabase projects list` is the cheapest authenticated call, but it - // is a network round trip against an account patchbay has not been - // asked to touch. Left to the human until it earns its place. - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run supabase yet; the token is in the keyring, so only the CLI can \ - answer whether it still works", - Some("supabase projects list"), - )) + if !self.paths.may_exec() || !self.paths.has_binary("supabase") { + return Ok(unsupported_verify( + Self::TOOL, + "the supabase CLI is not available on PATH", + Some("supabase projects list"), + )); + } + + // This is the one probe where verify is not a nicety: the token + // normally lives in the OS keyring, which patchbay does not read, so + // tier 1 genuinely cannot say whether there is a login at all. Only the + // CLI can answer, and `projects list` is the cheapest thing that makes + // it try. `--output json` is a global flag on this CLI. + let out = self + .paths + .run("supabase", &["projects", "list", "--output", "json"])?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "Supabase", + "supabase login", + )); + } + + Ok(match Self::describe_projects(&out.stdout) { + Some(summary) => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!("Supabase accepted the token: {summary}"), + }, + None => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: "Supabase accepted the token, but `supabase projects list --output json` \ + did not parse" + .to_string(), + }, + }) } fn permissions(&self) -> anyhow::Result { @@ -258,4 +357,165 @@ mod tests { assert!(status.installed); assert_eq!(status.profiles.len(), 1); } + + // ---------------------------------------------------------------- verify + + fn probe_with( + home: &std::path::Path, + exec: std::sync::Arc, + ) -> SupabaseProbe { + SupabaseProbe::new(Paths::for_test(home).with_exec(exec)) + } + + const PROJECTS: &str = r#"[ + { "id": "aaaa", "name": "pathors-prod", "organization_id": "org1", + "organization_slug": "pathors", "region": "ap-northeast-1", + "status": "ACTIVE_HEALTHY", "linked": true }, + { "id": "bbbb", "name": "pathors-stage", "organization_id": "org1", + "organization_slug": "pathors", "region": "ap-northeast-1", + "status": "ACTIVE_HEALTHY", "linked": false } + ]"#; + + #[test] + fn test_verify_answers_the_question_tier_one_cannot() { + // The token normally lives in the OS keyring, so status has no profile + // to show; only the CLI can say whether there is a login. + let dir = tempfile::tempdir().unwrap(); + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + true, + PROJECTS, + "", + )); + let outcome = probe_with(dir.path(), exec.clone()).verify().unwrap(); + assert_eq!( + exec.last().unwrap().line(), + "supabase projects list --output json" + ); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("2 projects"), "{detail}"); + assert!(detail.contains("pathors-prod"), "{detail}"); + assert!(detail.contains("in org pathors"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_an_account_with_no_projects_is_a_working_token() { + // The Go CLI appends into a nil slice, so an empty result serialises as + // `null`. Reading either shape as a broken login would be wrong. + let dir = tempfile::tempdir().unwrap(); + for empty in ["null", "[]"] { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + true, + empty, + "", + )); + match probe_with(dir.path(), exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("no projects"), "{empty} -> {detail}"); + } + other => panic!("expected Valid for {empty}, got {other:?}"), + } + } + } + + #[test] + fn test_logged_out_rejected_and_offline_get_three_different_answers() { + let dir = tempfile::tempdir().unwrap(); + + let logged_out = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + false, + "", + "Access token not provided. Supply an access token by running supabase login or setting the SUPABASE_ACCESS_TOKEN environment variable.\n", + )); + match probe_with(dir.path(), logged_out).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("not logged in"), "{detail}"); + assert!(detail.contains("supabase login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let rejected = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + false, + "", + "Unexpected error retrieving projects: {\"message\":\"Unauthorized\"}\n", + )); + match probe_with(dir.path(), rejected).verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("rejected"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + // The CLI resolves through its own DNS-over-HTTPS dialer and says so. + let offline = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + false, + "", + "failed to list projects: failed to dial native: dial tcp: lookup api.supabase.com: no such host\nfailed to dial fallback: context deadline exceeded\n", + )); + match probe_with(dir.path(), offline).verify().unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach Supabase"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + let dir = tempfile::tempdir().unwrap(); + for junk in ["", "not json", "{\"unexpected\": true}", "[[[["] { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + true, + junk, + "", + )); + match probe_with(dir.path(), exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("did not parse"), "{junk:?} -> {detail}"); + } + other => panic!("expected Valid for {junk:?}, got {other:?}"), + } + } + + // Rows of an unfamiliar shape are skipped, not fatal. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "projects list", + true, + "[{\"id\": \"aaaa\"}]", + "", + )); + match probe_with(dir.path(), exec).verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("1 project"), "{detail}") + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let dir = tempfile::tempdir().unwrap(); + match SupabaseProbe::new(Paths::for_test(dir.path())) + .verify() + .unwrap() + { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("supabase projects list")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } } diff --git a/crates/patchbay-core/src/probes/vercel.rs b/crates/patchbay-core/src/probes/vercel.rs index 3af706e..fd4b138 100644 --- a/crates/patchbay-core/src/probes/vercel.rs +++ b/crates/patchbay-core/src/probes/vercel.rs @@ -20,6 +20,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{Expiry, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome}; use crate::util::read_text; @@ -55,6 +56,29 @@ impl VercelProbe { Self { paths } } + /// The username out of `vercel whoami`. + /// + /// Verified against Vercel CLI 42: the `▲ Vercel CLI ` banner goes + /// to **stderr** and the bare username to stdout, so this only ever reads + /// stdout. The banner filter is belt and braces — older CLIs printed it on + /// stdout, and a `> ` progress line still shows up there under some flags. + fn parse_whoami(stdout: &str) -> Option { + stdout + .lines() + .map(str::trim) + // Searching from the end: the username is the last thing said, + // after any preamble. + .rfind(|line| { + !line.is_empty() + && !line.starts_with("Vercel CLI") + && !line.starts_with('>') + && !line.starts_with('▲') + && !line.starts_with("WARN") + && !line.starts_with("NOTE") + }) + .map(str::to_string) + } + /// First config directory that actually holds an `auth.json` or a /// `config.json`, in the order [`Paths::vercel_dirs`] lists them. fn config_dir(&self) -> Option { @@ -162,11 +186,37 @@ impl Probe for VercelProbe { } fn verify(&self) -> anyhow::Result { - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run vercel yet; the CLI is node-based and slow to start", - Some("vercel whoami"), - )) + if !self.paths.may_exec() || !self.paths.has_binary("vercel") { + return Ok(unsupported_verify( + Self::TOOL, + "the vercel CLI is not available on PATH", + Some("vercel whoami"), + )); + } + + let out = self.paths.run("vercel", &["whoami"])?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "Vercel", + "vercel login", + )); + } + + Ok(match Self::parse_whoami(&out.stdout) { + Some(who) => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: format!("Vercel accepted the token for {who}"), + }, + // Exit 0 means Vercel answered, so the token is good; only the name + // is missing. Reporting that as a dead login would be a lie. + None => VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: "Vercel accepted the token, but `vercel whoami` printed no username" + .to_string(), + }, + }) } fn permissions(&self) -> anyhow::Result { @@ -293,17 +343,131 @@ mod tests { } #[test] - fn test_switch_and_verify_are_honest_about_not_being_supported() { + fn test_switch_and_permissions_are_honest_about_not_being_supported() { let dir = tempfile::tempdir().unwrap(); let probe = VercelProbe::new(Paths::for_test(dir.path())); assert!(matches!( probe.switch("team_x").unwrap(), SwitchOutcome::Unsupported { .. } )); - assert!(matches!( - probe.verify().unwrap(), - VerifyOutcome::Unsupported { .. } - )); assert!(!probe.permissions().unwrap().supported); } + + // ---------------------------------------------------------------- verify + + fn probe_with(exec: std::sync::Arc) -> (tempfile::TempDir, VercelProbe) { + let dir = tempfile::tempdir().unwrap(); + let probe = VercelProbe::new(Paths::for_test(dir.path()).with_exec(exec)); + (dir, probe) + } + + #[test] + fn test_verify_reports_the_username_vercel_answers_with() { + // Verified against Vercel CLI 42: banner on stderr, username on stdout. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + "yjack0000\n", + "Vercel CLI 42.2.0\n", + )); + let (_dir, probe) = probe_with(exec.clone()); + let outcome = probe.verify().unwrap(); + assert_eq!(exec.last().unwrap().line(), "vercel whoami"); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("yjack0000"), "{detail}"); + // The banner is not an identity. + assert!(!detail.contains("Vercel CLI 42"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_a_banner_on_stdout_is_still_not_mistaken_for_the_username() { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + "Vercel CLI 28.0.0\n> Fetching user\nyjack0000\n", + "", + )); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.ends_with("yjack0000"), "{detail}") + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_logged_out_names_the_command_that_fixes_it() { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "Error: No existing credentials found. Please run `vercel login` or pass \"--token\"\n", + )); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("vercel login"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_an_outage_is_not_reported_as_a_dead_token() { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "Error: Failed to fetch user: FetchError: request to https://api.vercel.com/v2/user failed, reason: getaddrinfo ENOTFOUND api.vercel.com\n", + )); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach Vercel"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, "\n\n", "")); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("no username"), "{detail}") + } + other => panic!("expected Valid, got {other:?}"), + } + + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", false, "", "")); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("without saying why"), "{detail}") + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let dir = tempfile::tempdir().unwrap(); + match VercelProbe::new(Paths::for_test(dir.path())) + .verify() + .unwrap() + { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("vercel whoami")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } } diff --git a/crates/patchbay-core/src/probes/wrangler.rs b/crates/patchbay-core/src/probes/wrangler.rs index d5eb45c..d739b1d 100644 --- a/crates/patchbay-core/src/probes/wrangler.rs +++ b/crates/patchbay-core/src/probes/wrangler.rs @@ -22,6 +22,7 @@ use serde::Deserialize; use crate::paths::Paths; use crate::probe::{unsupported_switch, unsupported_verify, Probe}; +use crate::probes::cli_verify; use crate::types::{ Expiry, Note, PermissionsReport, Profile, SwitchOutcome, ToolStatus, VerifyOutcome, }; @@ -53,6 +54,84 @@ impl WranglerProbe { pub fn new(paths: Paths) -> Self { Self { paths } } + + /// One sentence out of `wrangler whoami`'s several screens. + /// + /// The real output is a version banner, a progress line, an English + /// sentence naming the token type and email, a box-drawn table of accounts, + /// and a scope list — none of which belongs in a `detail` verbatim. What + /// the user needs is the identity, so they can hold it against what the + /// board claimed: the email, the token type, and the accounts it reaches. + /// + /// Every part is optional. Cloudflare has reworded this output before, and + /// a reword must cost a thinner sentence, never a failed verify. + fn describe_whoami(text: &str) -> String { + let mut parts = Vec::new(); + if let Some(kind) = Self::token_kind(text) { + parts.push(kind); + } + if let Some(email) = Self::email(text) { + parts.push(format!("for {email}")); + } + let accounts = Self::accounts(text); + if !accounts.is_empty() { + parts.push(format!("on {}", accounts.join(", "))); + } + if parts.is_empty() { + // Exit 0 means Cloudflare answered, so the token does work; only + // the identity is missing. Saying so beats inventing either half. + return "Cloudflare accepted the token, but patchbay could not read an account out of \ + `wrangler whoami`" + .to_string(); + } + format!("Cloudflare accepted the {}", parts.join(" ")) + } + + /// `You are logged in with an OAuth Token, associated with the email x@y.` + fn email(text: &str) -> Option { + let (_, rest) = text.split_once("associated with the email")?; + let email = rest + .split_whitespace() + .next()? + .trim_end_matches(['.', '!', ',', '"', '\'']) + .to_string(); + (!email.is_empty() && email.contains('@')).then_some(email) + } + + /// "OAuth Token" / "API Token", lower-cased, from the same sentence. + fn token_kind(text: &str) -> Option { + let lower = text.to_lowercase(); + if lower.contains("oauth token") { + Some("OAuth token".to_string()) + } else if lower.contains("api token") { + Some("API token".to_string()) + } else { + None + } + } + + /// Account names out of the box-drawn `Account Name | Account ID` table. + /// + /// Parsed defensively: a row is a `│`-delimited line with exactly two + /// non-empty cells, the header is dropped by name, and anything else is + /// simply not a row. Account **ids** are read past and never reported — + /// they are the noisy half and the sentence has no room for them. + fn accounts(text: &str) -> Vec { + text.lines() + .filter(|line| line.contains('│')) + .filter_map(|line| { + let cells: Vec<&str> = line + .split('│') + .map(str::trim) + .filter(|c| !c.is_empty()) + .collect(); + match cells.as_slice() { + [name, _id] if *name != "Account Name" => Some((*name).to_string()), + _ => None, + } + }) + .collect() + } } impl Probe for WranglerProbe { @@ -157,13 +236,41 @@ impl Probe for WranglerProbe { } fn verify(&self) -> anyhow::Result { - // `wrangler whoami` is a network call that also happens to be slow to - // start (node). Left unsupported until it earns its place. - Ok(unsupported_verify( - Self::TOOL, - "patchbay does not run wrangler yet; the local expiry in status is usually enough", - Some("wrangler whoami"), - )) + if !self.paths.may_exec() || !self.paths.has_binary("wrangler") { + return Ok(unsupported_verify( + Self::TOOL, + "the wrangler CLI is not available on PATH", + Some("wrangler whoami"), + )); + } + + let out = self.paths.run("wrangler", &["whoami"])?; + if !out.ok { + return Ok(cli_verify::failure_outcome( + Self::TOOL, + &out, + "Cloudflare", + "wrangler login", + )); + } + + // wrangler puts the banner on stderr and the report on stdout, but + // which half lands where has moved between majors; read both. + let text = format!("{}\n{}", out.stdout, out.stderr); + + // Exit 0 is not the answer on its own: logged out, `wrangler whoami` + // prints "You are not authenticated." and still succeeds. + if cli_verify::says_logged_out(&text) { + return Ok(VerifyOutcome::Invalid { + tool: Self::TOOL.to_string(), + detail: "not logged in — run `wrangler login`".to_string(), + }); + } + + Ok(VerifyOutcome::Valid { + tool: Self::TOOL.to_string(), + detail: Self::describe_whoami(&text), + }) } fn permissions(&self) -> anyhow::Result { @@ -400,6 +507,148 @@ mod tests { .any(|n| n.text.contains("not logged in"))); } + // ---------------------------------------------------------------- verify + + /// verify never reads the filesystem, so the fixture home stays empty and + /// only the scripted exec decides the answer. + fn probe_with( + exec: std::sync::Arc, + ) -> (tempfile::TempDir, WranglerProbe) { + let dir = tempfile::tempdir().unwrap(); + let probe = WranglerProbe::new(Paths::for_test(dir.path()).with_exec(exec)); + (dir, probe) + } + + /// `wrangler whoami` on this machine, verbatim apart from the ids. + const WHOAMI: &str = "\n ⛅️ wrangler 4.105.0\n\ + ───────────────────────────────\n\ + Getting User settings...\n\ + 👋 You are logged in with an OAuth Token, associated with the email dev@example.com.\n\ + ┌──────────────┬──────────────────────────────────┐\n\ + │ Account Name │ Account ID │\n\ + ├──────────────┼──────────────────────────────────┤\n\ + │ Cerana │ 00000000000000000000000000000001 │\n\ + ├──────────────┼──────────────────────────────────┤\n\ + │ Pathors │ 00000000000000000000000000000002 │\n\ + └──────────────┴──────────────────────────────────┘\n\ + 🔓 Token Permissions:\n\ + - account (read)\n"; + + #[test] + fn test_verify_reports_the_email_and_the_accounts_wrangler_names() { + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, WHOAMI, "")); + let (_dir, probe) = probe_with(exec.clone()); + let outcome = probe.verify().unwrap(); + assert_eq!(exec.last().unwrap().line(), "wrangler whoami"); + match outcome { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("dev@example.com"), "{detail}"); + assert!(detail.contains("OAuth token"), "{detail}"); + assert!( + detail.contains("Cerana") && detail.contains("Pathors"), + "{detail}" + ); + // The header row is not an account. + assert!(!detail.contains("Account Name"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn test_a_zero_exit_that_says_not_authenticated_is_not_a_working_login() { + // The trap: `wrangler whoami` succeeds when logged out. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + true, + "You are not authenticated. Please run `wrangler login`.\n", + "", + )); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("wrangler login"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_a_rejected_token_and_an_outage_are_told_apart() { + let rejected = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "✘ [ERROR] A request to the Cloudflare API failed.\n Unable to authenticate request [code: 10000]\n", + )); + let (_dir, probe) = probe_with(rejected); + match probe.verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("wrangler login"), "{detail}"); + assert_eq!(detail.lines().count(), 1, "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + + let offline = std::sync::Arc::new(crate::util::FakeExec::new().on( + "whoami", + false, + "", + "✘ [ERROR] getaddrinfo ENOTFOUND api.cloudflare.com\n", + )); + let (_dir, probe) = probe_with(offline); + match probe.verify().unwrap() { + // An outage must never be filed as a dead login. + VerifyOutcome::Unsupported { reason, .. } => { + assert!(reason.contains("could not reach Cloudflare"), "{reason}"); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn test_unreadable_output_degrades_instead_of_panicking() { + // Cloudflare rewords this output regularly; a reword costs a thinner + // sentence, not a crash and not a false negative. + for junk in ["", "\u{0}\u{1}│││", "┌─┐\n│ only one cell │\n└─┘"] { + let exec = + std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", true, junk, "")); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Valid { detail, .. } => { + assert!(detail.contains("could not read an account"), "{detail}"); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + // Same on the failure side: no message, still a sentence. + let exec = std::sync::Arc::new(crate::util::FakeExec::new().on("whoami", false, "", "")); + let (_dir, probe) = probe_with(exec); + match probe.verify().unwrap() { + VerifyOutcome::Invalid { detail, .. } => { + assert!(detail.contains("without saying why"), "{detail}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn test_verify_without_the_binary_stays_unsupported() { + let dir = tempfile::tempdir().unwrap(); + match WranglerProbe::new(Paths::for_test(dir.path())) + .verify() + .unwrap() + { + VerifyOutcome::Unsupported { reason, hint, .. } => { + assert!(reason.contains("not available on PATH"), "{reason}"); + assert_eq!(hint.as_deref(), Some("wrangler whoami")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + #[test] fn test_permissions_reads_scopes_from_the_local_grant() { let dir = tempfile::tempdir().unwrap();