Skip to main content

nomoreide_core/
github_auth.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use tokio::process::Command;
4use tokio::time::{timeout, Duration};
5
6use super::config::{Config, GithubCredentialSelection};
7use super::process_manager::service_path;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct GithubCliAccount {
12    pub host: String,
13    pub login: String,
14    pub active: bool,
15    pub state: String,
16}
17
18#[derive(Debug, Clone, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct GithubCliAccounts {
21    pub available: bool,
22    pub accounts: Vec<GithubCliAccount>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub error: Option<String>,
25}
26
27/// Every failure below the discovery call reads the same way on purpose.
28///
29/// The reference wraps the whole of `listAccounts` — the run *and* the parse —
30/// in one `catch`, and reports whatever it caught through a single translator
31/// that only ever distinguishes "not installed" from everything else. So a
32/// `gh` that exited non-zero, timed out, or answered with something that is not
33/// the account JSON all reach the user as the same sentence, and the parse
34/// failure never surfaces its own wording.
35const GH_UNAVAILABLE: &str =
36    "GitHub CLI account discovery is unavailable. Update gh and run gh auth login.";
37const GH_NOT_INSTALLED: &str = "GitHub CLI is not installed or is not available on PATH.";
38
39pub async fn list_accounts() -> GithubCliAccounts {
40    let discovered = match run_gh(&["auth", "status", "--json", "hosts"]).await {
41        Ok(stdout) => parse_accounts(&stdout).map_err(|_| GH_UNAVAILABLE.to_string()),
42        Err(error) => Err(error),
43    };
44    match discovered {
45        Ok(accounts) => GithubCliAccounts {
46            available: true,
47            accounts,
48            error: None,
49        },
50        Err(error) => GithubCliAccounts {
51            available: false,
52            accounts: vec![],
53            error: Some(error),
54        },
55    }
56}
57
58/// The token `gh` holds for one account.
59///
60/// An empty answer fails the same way a failed call does. The reference raises
61/// its own "returned an empty token" inside the `try` that replaces every
62/// failure with the sentence below, so that wording never reaches a caller —
63/// and a caller told two different things about one broken account would go
64/// looking for two different problems.
65pub async fn token(host: &str, login: &str) -> Result<String, String> {
66    validate_identity(host, login)?;
67    let unusable = || {
68        format!(
69        "GitHub CLI could not provide credentials for @{login} on {host}. Re-authenticate with gh auth login or choose another account."
70    )
71    };
72    let value = run_gh(&["auth", "token", "--hostname", host, "--user", login])
73        .await
74        .map_err(|_| unusable())?;
75    let trimmed = value.trim();
76    if trimmed.is_empty() {
77        return Err(unusable());
78    }
79    Ok(trimmed.to_string())
80}
81
82pub async fn resolve(
83    config: &Config,
84    repository: Option<&str>,
85    remote_host: &str,
86) -> Result<(String, String, GithubCredentialSelection), String> {
87    let selection = repository
88        .and_then(|name| {
89            config
90                .git_repositories
91                .iter()
92                .find(|repo| repo.name == name)
93        })
94        .and_then(|repo| repo.github_credential.clone());
95
96    match selection {
97        Some(GithubCredentialSelection::Gh { host, login }) => {
98            if host != remote_host {
99                return Err(format!("The selected GitHub credential is for {host}, but this repository uses {remote_host}."));
100            }
101            let value = token(&host, &login).await?;
102            Ok((
103                value,
104                host.clone(),
105                GithubCredentialSelection::Gh { host, login },
106            ))
107        }
108        Some(GithubCredentialSelection::Stored { host }) => {
109            if host != remote_host {
110                return Err(format!("The selected GitHub credential is for {host}, but this repository uses {remote_host}."));
111            }
112            // Named in the refusal, because a repository that picked this host
113            // is telling the user which one to connect.
114            let value = stored_token(config, &host, true)?;
115            Ok((
116                value,
117                host.clone(),
118                GithubCredentialSelection::Stored { host },
119            ))
120        }
121        None => {
122            let value = stored_token(config, remote_host, false)?;
123            Ok((
124                value,
125                remote_host.to_string(),
126                GithubCredentialSelection::Stored {
127                    host: remote_host.to_string(),
128                },
129            ))
130        }
131    }
132}
133
134/// The stored token for `host`.
135///
136/// `name_the_host` decides how a missing one reads: a repository that chose a
137/// stored credential is told which host it chose, and a repository that chose
138/// nothing is only told that nothing is connected — naming a host it never
139/// picked would look like a setting it had got wrong.
140fn stored_token(config: &Config, host: &str, name_the_host: bool) -> Result<String, String> {
141    config
142        .github_tokens
143        .iter()
144        .find(|entry| entry.host == host)
145        .map(|entry| entry.token.clone())
146        .ok_or_else(|| {
147            let suffix = if name_the_host {
148                format!(" for {host}")
149            } else {
150                String::new()
151            };
152            format!(
153                "No stored GitHub token configured{suffix}. Choose a GitHub CLI account or connect GitHub."
154            )
155        })
156}
157
158fn parse_accounts(raw: &str) -> Result<Vec<GithubCliAccount>, String> {
159    let value: Value = serde_json::from_str(raw)
160        .map_err(|_| "GitHub CLI returned an unsupported account response.".to_string())?;
161    let hosts = value
162        .get("hosts")
163        .and_then(Value::as_object)
164        .ok_or("GitHub CLI returned an unsupported account response.")?;
165    let mut result = Vec::new();
166    for (host, entries) in hosts {
167        let Some(entries) = entries.as_array() else {
168            continue;
169        };
170        for entry in entries {
171            let Some(login) = entry.get("login").and_then(Value::as_str) else {
172                continue;
173            };
174            let entry_host = entry.get("host").and_then(Value::as_str).unwrap_or(host);
175            if login.trim().is_empty() || entry_host.trim().is_empty() {
176                continue;
177            }
178            result.push(GithubCliAccount {
179                host: entry_host.to_string(),
180                login: login.to_string(),
181                active: entry
182                    .get("active")
183                    .and_then(Value::as_bool)
184                    .unwrap_or(false),
185                state: entry
186                    .get("state")
187                    .and_then(Value::as_str)
188                    .unwrap_or("unknown")
189                    .to_string(),
190            });
191        }
192    }
193    Ok(result)
194}
195
196/// One `gh` invocation, with the same outcomes `execFile` gives the reference.
197///
198/// A non-zero exit is a failure **whatever it printed**: `execFile` rejects on
199/// the exit code alone and never looks at stdout, so a `gh` that answered with
200/// both an error status and a body must not be read as if it had succeeded.
201/// Output is decoded lossily for the same reason — `execFile`'s utf8 encoding
202/// substitutes rather than failing, and a byte we cannot decode is not a
203/// different kind of problem.
204async fn run_gh(args: &[&str]) -> Result<String, String> {
205    let mut command = Command::new("gh");
206    command
207        .args(args)
208        .env("PATH", service_path())
209        .env("GH_PROMPT_DISABLED", "1")
210        .env_remove("GH_TOKEN")
211        .env_remove("GITHUB_TOKEN")
212        .env_remove("GH_ENTERPRISE_TOKEN")
213        .env_remove("GITHUB_ENTERPRISE_TOKEN");
214    let output = timeout(Duration::from_secs(5), command.output())
215        .await
216        // A killed-on-timeout child is a rejection carrying no `ENOENT`, so the
217        // reference reports it as the generic failure rather than as a missing
218        // binary.
219        .map_err(|_| GH_UNAVAILABLE.to_string())?
220        .map_err(|error| {
221            if error.kind() == std::io::ErrorKind::NotFound {
222                GH_NOT_INSTALLED.to_string()
223            } else {
224                GH_UNAVAILABLE.to_string()
225            }
226        })?;
227    if !output.status.success() {
228        return Err(GH_UNAVAILABLE.to_string());
229    }
230    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
231}
232
233fn validate_identity(host: &str, login: &str) -> Result<(), String> {
234    if host.trim().is_empty()
235        || login.trim().is_empty()
236        || host.chars().chain(login.chars()).any(|c| c.is_control())
237    {
238        return Err("Invalid GitHub host or account login.".into());
239    }
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::config::{GitRepoDef, GithubTokenDef};
247
248    #[test]
249    fn parses_multiple_accounts() {
250        let accounts = parse_accounts(r#"{"hosts":{"github.com":[{"login":"work","host":"github.com","active":true,"state":"success"},{"login":"personal","active":false,"state":"error"}]}}"#).unwrap();
251        assert_eq!(accounts.len(), 2);
252        assert_eq!(accounts[0].login, "work");
253        assert_eq!(accounts[1].host, "github.com");
254    }
255
256    #[tokio::test]
257    async fn legacy_fallback_uses_the_remote_host_not_token_order() {
258        let config = Config {
259            github_tokens: vec![
260                GithubTokenDef {
261                    host: "enterprise.example".into(),
262                    token: "enterprise".into(),
263                    login: None,
264                    avatar_url: None,
265                },
266                GithubTokenDef {
267                    host: "github.com".into(),
268                    token: "public".into(),
269                    login: None,
270                    avatar_url: None,
271                },
272            ],
273            ..Config::default()
274        };
275        let resolved = resolve(&config, None, "github.com").await.unwrap();
276        assert_eq!(resolved.0, "public");
277        assert_eq!(resolved.1, "github.com");
278    }
279
280    #[tokio::test]
281    async fn explicit_selection_rejects_a_different_remote_host() {
282        let mut config = Config::default();
283        config.git_repositories.push(GitRepoDef {
284            name: "app".into(),
285            path: "/tmp/app".into(),
286            active_worktree_path: None,
287            github_credential: Some(GithubCredentialSelection::Stored {
288                host: "enterprise.example".into(),
289            }),
290            provider_projects: None,
291            legacy_vercel_project_id: None,
292        });
293        let error = resolve(&config, Some("app"), "github.com")
294            .await
295            .unwrap_err();
296        assert!(error.contains("enterprise.example"));
297        assert!(error.contains("github.com"));
298    }
299}