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
27const 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
58pub 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 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
134fn 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
196async 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 .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}