auths_cli/commands/id/
claim.rs1use std::path::Path;
2use std::sync::Arc;
3
4use anyhow::{Context, Result};
5use auths_infra_http::{
6 HttpGistPublisher, HttpGitHubOAuthProvider, HttpNpmAuthProvider, HttpRegistryClaimClient,
7};
8use auths_sdk::core_config::EnvironmentConfig;
9use auths_sdk::signing::PassphraseProvider;
10use auths_sdk::workflows::platform::{
11 GitHubClaimConfig, NpmClaimConfig, PypiClaimConfig, claim_github_identity, claim_npm_identity,
12 claim_pypi_identity,
13};
14use clap::{Parser, Subcommand};
15use console::style;
16
17use crate::factories::storage::build_auths_context;
18use crate::ux::format::{JsonResponse, is_json_mode};
19
20const DEFAULT_GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
21
22#[allow(clippy::disallowed_methods)]
23fn github_client_id() -> String {
24 std::env::var("AUTHS_GITHUB_CLIENT_ID").unwrap_or_else(|_| DEFAULT_GITHUB_CLIENT_ID.to_string())
25}
26
27#[derive(Parser, Debug, Clone)]
28#[command(about = "Add a platform claim to an already-registered identity.")]
29pub struct ClaimCommand {
30 #[command(subcommand)]
31 pub platform: ClaimPlatform,
32}
33
34#[derive(Subcommand, Debug, Clone)]
35pub enum ClaimPlatform {
36 Github {
38 #[arg(long, env = "AUTHS_REGISTRY_URL")]
40 registry: Option<String>,
41 },
42 Npm {
44 #[arg(long, env = "AUTHS_REGISTRY_URL")]
46 registry: Option<String>,
47 },
48 Pypi {
50 #[arg(long, env = "AUTHS_REGISTRY_URL")]
52 registry: Option<String>,
53 },
54}
55
56pub fn handle_claim(
57 cmd: &ClaimCommand,
58 repo_path: &Path,
59 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
60 env_config: &EnvironmentConfig,
61 now: chrono::DateTime<chrono::Utc>,
62) -> Result<()> {
63 let registry_url = match &cmd.platform {
64 ClaimPlatform::Github { registry }
65 | ClaimPlatform::Npm { registry }
66 | ClaimPlatform::Pypi { registry } => registry.clone(),
67 };
68
69 let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))
70 .context("Failed to build auths context")?;
71
72 let registry_client = HttpRegistryClaimClient::new();
73
74 match &cmd.platform {
75 ClaimPlatform::Github { .. } => {
76 let oauth = HttpGitHubOAuthProvider::new();
77 let publisher = HttpGistPublisher::new();
78
79 let registry_url = crate::commands::verify_helpers::require_registry(registry_url)?;
80 let config = GitHubClaimConfig {
81 client_id: github_client_id(),
82 registry_url,
83 scopes: "read:user gist".to_string(),
84 };
85
86 let on_device_code = |code: &auths_sdk::ports::platform::DeviceCodeResponse| {
87 println!();
88 println!(" Copy this code: {}", style(&code.user_code).bold().cyan());
89 println!(" At: {}", style(&code.verification_uri).cyan());
90 println!();
91 println!(
92 " {}",
93 style("Press 'enter' to open GitHub after copying the code above").blue()
94 );
95 let _ = std::io::stdin().read_line(&mut String::new());
96 println!();
97 if let Err(e) = open::that(&code.verification_uri) {
98 println!(
99 " {}",
100 style(format!("Could not open browser: {e}")).yellow()
101 );
102 println!(" Please open the URL above manually.");
103 } else {
104 println!(" Browser opened — waiting for authorization...");
105 }
106 };
107
108 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
109 let response = rt
110 .block_on(claim_github_identity(
111 &oauth,
112 &publisher,
113 ®istry_client,
114 &ctx,
115 config,
116 now,
117 &on_device_code,
118 ))
119 .map_err(anyhow::Error::from)?;
120
121 print_response(&response.message)?;
122 }
123
124 ClaimPlatform::Npm { .. } => {
125 println!();
126 println!(" {}", style("npm platform claim").bold());
127 println!();
128 println!(
129 " Create a read-only access token at:\n {}",
130 style("https://www.npmjs.com/settings/~/tokens").cyan()
131 );
132 println!();
133
134 let npm_token = rpassword::prompt_password(" Enter your npm access token: ")
135 .context("Failed to read npm token")?;
136
137 if npm_token.trim().is_empty() {
138 return Err(anyhow::anyhow!("npm access token cannot be empty"));
139 }
140
141 println!(" Verifying token...");
142
143 let npm_provider = HttpNpmAuthProvider::new();
144 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
145
146 let profile = rt
147 .block_on(npm_provider.verify_token(npm_token.trim()))
148 .map_err(anyhow::Error::from)?;
149
150 println!(
151 " {} Authenticated as {}",
152 style("✓").green(),
153 style(&profile.login).bold()
154 );
155
156 let config = NpmClaimConfig {
157 registry_url: crate::commands::verify_helpers::require_registry(registry_url)?,
158 };
159
160 let response = rt
161 .block_on(claim_npm_identity(
162 &profile.login,
163 npm_token.trim(),
164 ®istry_client,
165 &ctx,
166 config,
167 now,
168 ))
169 .map_err(anyhow::Error::from)?;
170
171 print_response(&response.message)?;
172 }
173
174 ClaimPlatform::Pypi { .. } => {
175 println!();
176 println!(" {}", style("PyPI platform claim").bold());
177 println!();
178 println!(
179 " Enter your PyPI username (visible at {}):",
180 style("https://pypi.org/account/").cyan()
181 );
182 println!();
183 println!(
184 " {}",
185 style("Note: ownership is verified when you claim a package namespace,").dim()
186 );
187 println!(
188 " {}",
189 style("not at this step. The PyPI JSON API confirms you are a maintainer.").dim()
190 );
191 println!();
192
193 print!(" PyPI username: ");
194 std::io::Write::flush(&mut std::io::stdout()).context("flush")?;
195 let mut pypi_username = String::new();
196 std::io::stdin()
197 .read_line(&mut pypi_username)
198 .context("Failed to read PyPI username")?;
199
200 let trimmed = pypi_username.trim();
201 if trimmed.is_empty() {
202 return Err(anyhow::anyhow!("PyPI username cannot be empty"));
203 }
204
205 println!(" Claiming PyPI identity as {}...", style(trimmed).bold());
206
207 let config = PypiClaimConfig {
208 registry_url: crate::commands::verify_helpers::require_registry(registry_url)?,
209 };
210
211 let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
212 let response = rt
213 .block_on(claim_pypi_identity(
214 trimmed,
215 ®istry_client,
216 &ctx,
217 config,
218 now,
219 ))
220 .map_err(anyhow::Error::from)?;
221
222 print_response(&response.message)?;
223 }
224 }
225
226 Ok(())
227}
228
229fn print_response(message: &str) -> Result<()> {
230 if is_json_mode() {
231 let json_resp = JsonResponse::success("id claim", message);
232 json_resp.print()?;
233 } else {
234 println!(" {}", style(message).green());
235 }
236 Ok(())
237}