diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs new file mode 100644 index 00000000..78cd1097 --- /dev/null +++ b/crates/client/src/backend_auth.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendAuthTarget { + pub base_url: String, +} + +impl BackendAuthTarget { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + } + } + + fn api_url(&self, path: &str) -> String { + format!( + "{}{}", + self.base_url.trim_end_matches('/'), + path.strip_prefix('/') + .map(|path| format!("/{path}")) + .unwrap_or_else(|| path.to_string()) + ) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DeviceLoginStartResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DeviceLoginPollResponse { + pub status: String, + pub access_token: Option, + pub token_type: Option, +} + +#[derive(Debug)] +pub enum BackendAuthClientError { + Http(reqwest::Error), + BackendStatus { status: u16, body: String }, + MissingAccessToken, +} + +impl fmt::Display for BackendAuthClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http(error) => write!(f, "Backend auth request failed: {error}"), + Self::BackendStatus { status, body } => { + write!(f, "Backend auth returned HTTP {status}: {body}") + } + Self::MissingAccessToken => { + f.write_str("Backend approved device login without an access token") + } + } + } +} + +impl std::error::Error for BackendAuthClientError {} + +impl From for BackendAuthClientError { + fn from(value: reqwest::Error) -> Self { + Self::Http(value) + } +} + +#[derive(Debug, Serialize)] +struct DeviceLoginStartRequest<'a> { + client_name: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +struct DeviceLoginPollRequest<'a> { + device_code: &'a str, +} + +pub async fn start_device_login( + target: &BackendAuthTarget, + client_name: Option<&str>, +) -> Result { + let client = reqwest::Client::new(); + let response = client + .post(target.api_url("/api/auth/device-login/start")) + .json(&DeviceLoginStartRequest { client_name }) + .send() + .await?; + parse_json_response(response).await +} + +pub async fn poll_device_login( + target: &BackendAuthTarget, + device_code: &str, +) -> Result { + let client = reqwest::Client::new(); + let response = client + .post(target.api_url("/api/auth/device-login/poll")) + .json(&DeviceLoginPollRequest { device_code }) + .send() + .await?; + parse_json_response(response).await +} + +pub async fn wait_for_device_login( + target: &BackendAuthTarget, + device_code: &str, + interval: Duration, + expires_in: Duration, +) -> Result { + let started = std::time::Instant::now(); + loop { + let response = poll_device_login(target, device_code).await?; + match response.status.as_str() { + "approved" => { + return response + .access_token + .ok_or(BackendAuthClientError::MissingAccessToken); + } + "expired" => { + return Err(BackendAuthClientError::BackendStatus { + status: 410, + body: "device login expired".to_string(), + }); + } + "consumed" => { + return Err(BackendAuthClientError::BackendStatus { + status: 409, + body: "device login was already consumed".to_string(), + }); + } + _ => {} + } + if started.elapsed() >= expires_in { + return Err(BackendAuthClientError::BackendStatus { + status: 408, + body: "timed out waiting for device login approval".to_string(), + }); + } + tokio::time::sleep(interval).await; + } +} + +async fn parse_json_response Deserialize<'de>>( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(BackendAuthClientError::BackendStatus { + status: status.as_u16(), + body, + }); + } + Ok(response.json::().await?) +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 5d58181a..9eeb164b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -8,12 +8,17 @@ //! //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 +pub mod backend_auth; pub mod backend_runtime; pub mod runtime_command; pub mod spawn; pub mod ticket_role; mod worker_client; +pub use backend_auth::{ + BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse, + poll_device_login, start_device_login, wait_for_device_login, +}; pub use backend_runtime::{ BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 91263bc3..a9786394 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -12,10 +12,14 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; +use std::time::Duration; -use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; +use client::{ + BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand, + start_device_login, wait_for_device_login, +}; use memory_lint::{LintCliOptions, LintStatus}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use session_store::SegmentId; use tui::{LaunchMode, LaunchOptions}; @@ -36,6 +40,10 @@ enum Mode { subcommand: String, args: Vec, }, + Login { + backend_url: String, + no_wait: bool, + }, WorkerRuntime(Vec), Keys, SetupModel, @@ -85,6 +93,16 @@ async fn main() -> ExitCode { ExitCode::SUCCESS } Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args), + Mode::Login { + backend_url, + no_wait, + } => match run_login(&backend_url, no_wait).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("yoi login: {e}"); + ExitCode::FAILURE + } + }, Mode::MemoryLint(options) => match memory_lint::run(&options) { Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Failed) => ExitCode::FAILURE, @@ -241,6 +259,9 @@ fn parse_args_slice(args: &[String]) -> Result { "workspace" => { return parse_workspace_args(&args[1..]); } + "login" => { + return parse_login_args(&args[1..]); + } "mcp" => { let mcp_cli = parse_mcp_args(&args[1..])?; return Ok(Mode::Mcp(mcp_cli)); @@ -859,15 +880,7 @@ fn read_client_config() -> Result, ParseError> { } fn client_config_path() -> Option { - if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { - return Some(PathBuf::from(home).join("yoi").join("client.toml")); - } - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".config") - .join("yoi") - .join("client.toml") - }) + yoi_config_dir().map(|dir| dir.join("client.toml")) } fn client_config_missing_message(workspace_id: Option<&str>) -> String { @@ -879,6 +892,137 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String { } } +fn parse_login_args(args: &[String]) -> Result { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(ParseError( + "yoi login usage: yoi login [--backend ] [--no-wait]".to_string(), + )); + } + let mut backend_url = None; + let mut no_wait = false; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--backend" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--backend requires a URL".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + backend_url = Some(value.clone()); + i += 2; + } + arg if arg.starts_with("--backend=") => { + let value = arg.trim_start_matches("--backend="); + if value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + backend_url = Some(value.to_string()); + i += 1; + } + "--no-wait" => { + no_wait = true; + i += 1; + } + other => { + return Err(ParseError(format!( + "unknown yoi login argument '{other}' (try 'yoi login --help')" + ))); + } + } + } + let backend_url = resolve_backend_url(backend_url, None)?; + Ok(Mode::Login { + backend_url, + no_wait, + }) +} + +async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> { + let target = BackendAuthTarget::new(backend_url.to_string()); + let start = start_device_login(&target, Some("yoi cli")) + .await + .map_err(|error| ParseError(error.to_string()))?; + println!("Open this URL in your browser to approve Yoi CLI login:"); + println!(" {}", start.verification_uri_complete); + println!(); + println!("User code: {}", start.user_code); + println!("Expires in: {} seconds", start.expires_in); + if no_wait { + println!("Device code: {}", start.device_code); + println!( + "Run without --no-wait after approving, or poll the Backend device endpoint manually." + ); + return Ok(()); + } + let token = wait_for_device_login( + &target, + &start.device_code, + Duration::from_secs(start.interval.max(1)), + Duration::from_secs(start.expires_in.max(1)), + ) + .await + .map_err(|error| ParseError(error.to_string()))?; + save_backend_token(backend_url, &token)?; + println!("Saved Backend API token for {backend_url}"); + Ok(()) +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct BackendTokenFile { + #[serde(default)] + tokens: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackendTokenEntry { + token_type: String, + access_token: String, +} + +fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> { + let path = backend_token_path().ok_or_else(|| { + ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string()) + })?; + let mut file = if path.is_file() { + let contents = fs::read_to_string(&path) + .map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?; + serde_json::from_str::(&contents) + .map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))? + } else { + BackendTokenFile::default() + }; + file.tokens.insert( + backend_url.trim_end_matches('/').to_string(), + BackendTokenEntry { + token_type: "Bearer".to_string(), + access_token: access_token.to_string(), + }, + ); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + ParseError(format!("failed to create {}: {error}", parent.display())) + })?; + } + let serialized = serde_json::to_string_pretty(&file) + .map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?; + fs::write(&path, format!("{serialized}\n")) + .map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?; + Ok(()) +} + +fn backend_token_path() -> Option { + yoi_config_dir().map(|dir| dir.join("backend-tokens.json")) +} + +fn yoi_config_dir() -> Option { + if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(home).join("yoi")); + } + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi")) +} + fn parse_workspace_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError(