Skip to content

Commit 16784e5

Browse files
committed
feat(cli): add ustapi login — device code OAuth for RustAPI Cloud
1 parent 17c2d50 commit 16784e5

6 files changed

Lines changed: 275 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ clap = { version = "4.5", features = ["derive", "color"] }
9191
dialoguer = "0.11"
9292
indicatif = "0.18"
9393
console = "0.16"
94+
open = "5"
95+
chrono = { version = "0.4", features = ["serde"] }
9496

9597
# Internal crates
9698
rustapi-rs = { path = "crates/rustapi-rs", version = "0.1.501", default-features = false }

crates/cargo-rustapi/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ rustapi-openapi = { workspace = true, optional = true }
4848
tracing = { workspace = true }
4949
tracing-subscriber = { workspace = true }
5050
anyhow = "1.0"
51+
open = { workspace = true }
52+
chrono = { workspace = true }
5153

5254
[dev-dependencies]
5355
tempfile = "3.26"

crates/cargo-rustapi/src/cli.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
#[cfg(feature = "replay")]
44
use crate::commands::ReplayArgs;
55
use crate::commands::{
6-
self, AddArgs, BenchArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, MigrateArgs,
7-
NewArgs, ObservabilityArgs, RunArgs, WatchArgs,
6+
self, AddArgs, BenchArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, LoginArgs,
7+
MigrateArgs, NewArgs, ObservabilityArgs, RunArgs, WatchArgs,
88
};
99

1010
#[cfg(feature = "mcp")]
@@ -74,6 +74,9 @@ enum Commands {
7474
#[command(subcommand)]
7575
Mcp(McpCommands),
7676

77+
/// Login to RustAPI Cloud
78+
Login(LoginArgs),
79+
7780
/// Deploy to various platforms
7881
#[command(subcommand)]
7982
Deploy(DeployArgs),
@@ -101,6 +104,7 @@ impl Cli {
101104
Commands::Client(args) => commands::client(args).await,
102105
#[cfg(feature = "mcp")]
103106
Commands::Mcp(McpCommands::Generate(args)) => commands::mcp_generate(args).await,
107+
Commands::Login(args) => commands::login(args).await,
104108
Commands::Deploy(args) => commands::deploy(args).await,
105109
#[cfg(feature = "replay")]
106110
Commands::Replay(args) => commands::replay(args).await,
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
use anyhow::{anyhow, Context};
2+
use clap::Args;
3+
use indicatif::{ProgressBar, ProgressStyle};
4+
use reqwest::Client;
5+
use serde::{Deserialize, Serialize};
6+
use std::path::PathBuf;
7+
use std::time::Duration;
8+
9+
const DEFAULT_CLOUD_URL: &str = "https://api.rustapi.cloud";
10+
const POLL_INTERVAL_SECS: u64 = 3;
11+
const POLL_TIMEOUT_SECS: u64 = 900; // 15 minutes
12+
13+
#[derive(Args, Debug, Clone)]
14+
pub struct LoginArgs {
15+
/// RustAPI Cloud API URL
16+
#[arg(long, default_value = DEFAULT_CLOUD_URL)]
17+
pub cloud_url: String,
18+
19+
/// Do not open browser automatically
20+
#[arg(long)]
21+
pub no_browser: bool,
22+
}
23+
24+
#[derive(Deserialize)]
25+
struct DeviceResponse {
26+
device_code: String,
27+
user_code: String,
28+
#[allow(dead_code)]
29+
verification_uri: String,
30+
verification_uri_complete: String,
31+
#[allow(dead_code)]
32+
expires_in: u32,
33+
interval: u32,
34+
}
35+
36+
#[derive(Serialize)]
37+
struct TokenRequest {
38+
grant_type: String,
39+
device_code: String,
40+
}
41+
42+
#[derive(Deserialize)]
43+
#[serde(untagged)]
44+
enum TokenResponse {
45+
Success {
46+
access_token: String,
47+
refresh_token: String,
48+
#[allow(dead_code)]
49+
token_type: String,
50+
#[allow(dead_code)]
51+
expires_in: u32,
52+
},
53+
Error {
54+
error: String,
55+
#[serde(default)]
56+
error_description: Option<String>,
57+
},
58+
}
59+
60+
#[derive(Serialize, Deserialize)]
61+
struct ConfigFile {
62+
token: Option<String>,
63+
refresh_token: Option<String>,
64+
user: Option<UserInfo>,
65+
last_login: Option<String>,
66+
cloud_url: Option<String>,
67+
}
68+
69+
#[derive(Serialize, Deserialize)]
70+
struct UserInfo {
71+
login: String,
72+
tier: String,
73+
avatar_url: Option<String>,
74+
}
75+
76+
pub async fn login(args: LoginArgs) -> anyhow::Result<()> {
77+
let client = Client::new();
78+
let cloud_url = args.cloud_url.trim_end_matches('/');
79+
80+
// Step 1: Request device code
81+
let device_resp: DeviceResponse = client
82+
.post(format!("{}/auth/device", cloud_url))
83+
.send()
84+
.await
85+
.context("Failed to connect to RustAPI Cloud")?
86+
.json()
87+
.await
88+
.context("Invalid response from /auth/device")?;
89+
90+
// Step 2: Show activation instructions
91+
println!();
92+
println!(" \x1b[1mRustAPI Cloud Login\x1b[0m");
93+
println!(" ─────────────────────");
94+
println!();
95+
println!(" Open this URL in your browser:");
96+
println!();
97+
println!(" \x1b[36m{}\x1b[0m", device_resp.verification_uri_complete);
98+
println!();
99+
println!(
100+
" And enter this code: \x1b[1;33m{}\x1b[0m",
101+
device_resp.user_code
102+
);
103+
println!();
104+
105+
// Step 3: Open browser
106+
if !args.no_browser {
107+
let _ = open::that(&device_resp.verification_uri_complete);
108+
}
109+
110+
// Step 4: Poll for token
111+
let spinner = ProgressBar::new_spinner();
112+
spinner.set_style(
113+
ProgressStyle::with_template(" {spinner} Waiting for authorization...").unwrap(),
114+
);
115+
spinner.enable_steady_tick(Duration::from_millis(100));
116+
117+
let start = std::time::Instant::now();
118+
let interval = Duration::from_secs(std::cmp::max(
119+
device_resp.interval as u64,
120+
POLL_INTERVAL_SECS,
121+
));
122+
123+
loop {
124+
tokio::time::sleep(interval).await;
125+
126+
if start.elapsed().as_secs() > POLL_TIMEOUT_SECS {
127+
spinner.finish_with_message("Login timed out");
128+
return Err(anyhow!(
129+
"Device code expired. Please run `rustapi login` again."
130+
));
131+
}
132+
133+
let token_resp: TokenResponse = match client
134+
.post(format!("{}/auth/token", cloud_url))
135+
.json(&TokenRequest {
136+
grant_type: "urn:ietf:params:oauth:grant-type:device_code".into(),
137+
device_code: device_resp.device_code.clone(),
138+
})
139+
.send()
140+
.await
141+
{
142+
Ok(resp) => match resp.json().await {
143+
Ok(body) => body,
144+
Err(_) => continue,
145+
},
146+
Err(_) => continue,
147+
};
148+
149+
match token_resp {
150+
TokenResponse::Success {
151+
access_token,
152+
refresh_token,
153+
..
154+
} => {
155+
spinner.finish_and_clear();
156+
157+
// Step 5: Get user info
158+
let user_info: UserInfo = match client
159+
.post(format!("{}/auth/whoami", cloud_url))
160+
.json(&serde_json::json!({ "grant_type": "", "device_code": access_token }))
161+
.send()
162+
.await
163+
{
164+
Ok(resp) => match resp.json::<serde_json::Value>().await {
165+
Ok(v) if v.get("sub").is_some() => UserInfo {
166+
login: v["login"].as_str().unwrap_or("unknown").into(),
167+
tier: v["tier"].as_str().unwrap_or("hobby").into(),
168+
avatar_url: v["avatar_url"].as_str().map(String::from),
169+
},
170+
_ => continue,
171+
},
172+
Err(_) => continue,
173+
};
174+
175+
// Step 6: Save config
176+
let config = ConfigFile {
177+
token: Some(access_token),
178+
refresh_token: Some(refresh_token),
179+
user: Some(user_info),
180+
last_login: Some(chrono::Utc::now().to_rfc3339()),
181+
cloud_url: Some(cloud_url.to_string()),
182+
};
183+
184+
save_config(&config)?;
185+
186+
println!(
187+
" \x1b[32m✓ Logged in as {}\x1b[0m",
188+
config.user.as_ref().unwrap().login
189+
);
190+
println!(" Tier: {}", config.user.as_ref().unwrap().tier);
191+
println!();
192+
return Ok(());
193+
}
194+
TokenResponse::Error {
195+
error,
196+
error_description,
197+
} => {
198+
if error == "authorization_pending" {
199+
continue;
200+
}
201+
let desc = error_description.unwrap_or_default();
202+
spinner.finish_with_message(format!("Error: {} - {}", error, desc));
203+
return Err(anyhow!("{}: {}", error, desc));
204+
}
205+
}
206+
}
207+
}
208+
209+
fn config_path() -> PathBuf {
210+
let home = std::env::var("USERPROFILE")
211+
.or_else(|_| std::env::var("HOME"))
212+
.unwrap_or_else(|_| ".".into());
213+
214+
PathBuf::from(home).join(".rustapi").join("config.json")
215+
}
216+
217+
fn save_config(config: &ConfigFile) -> anyhow::Result<()> {
218+
let path = config_path();
219+
if let Some(parent) = path.parent() {
220+
std::fs::create_dir_all(parent).context("Failed to create ~/.rustapi directory")?;
221+
}
222+
let json = serde_json::to_string_pretty(config).context("Failed to serialize config")?;
223+
std::fs::write(&path, json).context("Failed to write config file")?;
224+
Ok(())
225+
}

crates/cargo-rustapi/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod deploy;
77
mod docs;
88
mod doctor;
99
mod generate;
10+
mod login;
1011
mod migrate;
1112
mod new;
1213
mod observability;
@@ -20,6 +21,7 @@ pub use deploy::{deploy, DeployArgs};
2021
pub use docs::open_docs;
2122
pub use doctor::{doctor, DoctorArgs};
2223
pub use generate::{generate, GenerateArgs};
24+
pub use login::{login, LoginArgs};
2325
pub use migrate::{migrate, MigrateArgs};
2426
pub use new::{new_project, NewArgs};
2527
pub use observability::{observability, ObservabilityArgs};

0 commit comments

Comments
 (0)