From 67a19de42097e61c11747b38a822b777db181bc0 Mon Sep 17 00:00:00 2001 From: nas Date: Fri, 12 Jun 2026 02:10:19 -0400 Subject: [PATCH 1/6] feat(auth): add device flow and project management --- src/api/device_auth.rs | 326 +++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + src/commands/init/mod.rs | 109 ++++++++++- src/commands/login.rs | 401 ++++++++++++++++----------------------- src/commands/logout.rs | 15 +- src/commands/mod.rs | 20 +- src/commands/projects.rs | 312 ++++++++++++++++++++++++++++++ src/config/auth.rs | 31 +++ src/config/mod.rs | 4 +- src/config/settings.rs | 48 +++++ 10 files changed, 1020 insertions(+), 247 deletions(-) create mode 100644 src/api/device_auth.rs create mode 100644 src/commands/projects.rs diff --git a/src/api/device_auth.rs b/src/api/device_auth.rs new file mode 100644 index 0000000..5055e90 --- /dev/null +++ b/src/api/device_auth.rs @@ -0,0 +1,326 @@ +//! Device authorization (RFC 8628) + account-token-backed project management. +//! +//! The device + token endpoints are unauthenticated and use an OAuth-style error +//! envelope, so they are called with `reqwest` directly to inspect the `error` code. +//! Project / API-key / logout calls are authenticated with the account token and go +//! through [`SteelClient`]. + +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::json; + +use crate::api::client::SteelClient; +use crate::config::auth; +use crate::config::settings::ApiMode; + +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceAuthorization { + 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)] +pub struct OrgPayload { + pub id: String, + #[serde(default)] + pub slug: Option, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceTokenSuccess { + pub access_token: String, + #[allow(dead_code)] + pub token_name: String, + pub org: OrgPayload, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPayload { + pub id: String, + pub slug: String, + pub name: String, + #[serde(default)] + pub is_default: bool, +} + +/// Start a device authorization request. Returns the user code and verification URLs. +pub async fn request_device_authorization(base_url: &str) -> anyhow::Result { + let client = reqwest::Client::builder().user_agent("steel-cli").build()?; + let response = client + .post(format!("{base_url}/auth/device")) + .header("Content-Type", "application/json") + .json(&json!({ "client_id": "cli" })) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + anyhow::bail!( + "Could not start device login ({}). Please try again shortly.", + status.as_u16() + ); + } + + Ok(response.json().await?) +} + +/// Poll the token endpoint until the device is approved, denied, or expires. +/// +/// Honors the RFC 8628 `interval` and `slow_down` backoff and stops after `expires_in`. +pub async fn poll_for_token( + base_url: &str, + device_code: &str, + device_name: &str, + interval: u64, + expires_in: u64, +) -> anyhow::Result { + let client = reqwest::Client::builder().user_agent("steel-cli").build()?; + let mut wait = interval.max(1); + let deadline = Instant::now() + Duration::from_secs(expires_in.max(1)); + + loop { + if Instant::now() >= deadline { + anyhow::bail!("Device login expired before it was approved. Run `steel login` again."); + } + + tokio::time::sleep(Duration::from_secs(wait)).await; + + let response = client + .post(format!("{base_url}/auth/device/token")) + .header("Content-Type", "application/json") + .json(&json!({ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": "cli", + "device_name": device_name, + })) + .send() + .await?; + + let status = response.status(); + let body: serde_json::Value = response.json().await.unwrap_or(serde_json::Value::Null); + + if status.is_success() { + return Ok(serde_json::from_value(body)?); + } + + match classify_token_error(&body) { + TokenPoll::Pending => continue, + TokenPoll::SlowDown => { + wait += 5; + continue; + } + TokenPoll::Denied => { + anyhow::bail!("Device login was denied in the browser. Run `steel login` again.") + } + TokenPoll::Expired => { + anyhow::bail!("The device code expired. Run `steel login` again.") + } + TokenPoll::Failed(message) => anyhow::bail!("Device login failed: {message}"), + } + } +} + +enum TokenPoll { + Pending, + SlowDown, + Denied, + Expired, + Failed(String), +} + +fn classify_token_error(body: &serde_json::Value) -> TokenPoll { + let error = body.get("error").and_then(|v| v.as_str()).unwrap_or(""); + match error { + "authorization_pending" => TokenPoll::Pending, + "slow_down" => TokenPoll::SlowDown, + "access_denied" => TokenPoll::Denied, + "expired_token" => TokenPoll::Expired, + other => { + let description = body + .get("error_description") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(if other.is_empty() { + "unknown error" + } else { + other + }); + TokenPoll::Failed(description.to_string()) + } + } +} + +/// List projects for the organization tied to the account token. +pub async fn list_projects( + base_url: &str, + mode: ApiMode, + account_token: &str, +) -> anyhow::Result> { + let client = SteelClient::new()?; + let data = client + .request( + base_url, + mode, + reqwest::Method::GET, + "/projects", + None, + &auth::account_token_auth(account_token), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let projects = data + .get("projects") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + Ok(projects + .into_iter() + .filter_map(|p| serde_json::from_value(p).ok()) + .collect()) +} + +/// Create a new project. Requires an org admin account token (API returns 403 otherwise). +pub async fn create_project( + base_url: &str, + mode: ApiMode, + account_token: &str, + name: &str, +) -> anyhow::Result { + let client = SteelClient::new()?; + let data = client + .request( + base_url, + mode, + reqwest::Method::POST, + "/projects", + Some(json!({ "name": name })), + &auth::account_token_auth(account_token), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Unexpected project response: {e}")) +} + +/// Mint a project-scoped API key (used for browser/session work). +pub async fn create_project_api_key( + base_url: &str, + mode: ApiMode, + account_token: &str, + project_id: &str, + name: &str, +) -> anyhow::Result { + let client = SteelClient::new()?; + let data = client + .request( + base_url, + mode, + reqwest::Method::POST, + "/api-keys", + Some(json!({ "name": name, "projectId": project_id })), + &auth::account_token_auth(account_token), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + data.get("key") + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("API key response did not include a key")) +} + +/// Revoke the account token used for this request (server-side logout). +pub async fn revoke_account_token( + base_url: &str, + mode: ApiMode, + account_token: &str, +) -> anyhow::Result<()> { + let client = SteelClient::new()?; + client + .request( + base_url, + mode, + reqwest::Method::POST, + "/auth/cli-tokens/logout", + None, + &auth::account_token_auth(account_token), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_error_is_classified() { + let body = json!({ "error": "authorization_pending" }); + assert!(matches!(classify_token_error(&body), TokenPoll::Pending)); + } + + #[test] + fn slow_down_error_is_classified() { + let body = json!({ "error": "slow_down" }); + assert!(matches!(classify_token_error(&body), TokenPoll::SlowDown)); + } + + #[test] + fn denied_error_is_classified() { + let body = json!({ "error": "access_denied" }); + assert!(matches!(classify_token_error(&body), TokenPoll::Denied)); + } + + #[test] + fn expired_error_is_classified() { + let body = json!({ "error": "expired_token" }); + assert!(matches!(classify_token_error(&body), TokenPoll::Expired)); + } + + #[test] + fn unknown_error_uses_description() { + let body = json!({ "error": "server_error", "error_description": "boom" }); + match classify_token_error(&body) { + TokenPoll::Failed(msg) => assert_eq!(msg, "boom"), + _ => panic!("expected Failed"), + } + } + + #[test] + fn device_token_success_deserializes() { + let body = json!({ + "access_token": "ste-cli-abc", + "token_name": "My Mac", + "org": { "id": "org-1", "slug": "acme", "name": "Acme" } + }); + let parsed: DeviceTokenSuccess = serde_json::from_value(body).unwrap(); + assert_eq!(parsed.access_token, "ste-cli-abc"); + assert_eq!(parsed.org.id, "org-1"); + assert_eq!(parsed.org.name, "Acme"); + } + + #[test] + fn project_payload_deserializes_camel_case() { + let body = json!({ + "id": "proj-1", + "slug": "dev", + "name": "Dev", + "isDefault": true, + "isProduction": false + }); + let parsed: ProjectPayload = serde_json::from_value(body).unwrap(); + assert_eq!(parsed.id, "proj-1"); + assert!(parsed.is_default); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 00ceb84..15ae101 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod device_auth; pub mod generated; pub mod projects; pub mod session; diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 285cdb0..f302d15 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -1,7 +1,12 @@ use clap::Parser; +use dialoguer::{Confirm, Input}; -use crate::commands::{doctor, login, skills}; +use crate::commands::{doctor, login, projects, skills}; +use crate::config; +use crate::config::auth; +use crate::config::settings::read_config_from; use crate::status; +use crate::util::{api, output}; #[derive(Parser)] pub struct Args { @@ -28,14 +33,38 @@ pub async fn run(args: Args) -> anyhow::Result<()> { } status!(""); - // Step 1: login (no-ops if already logged in). - login::run(login::Args {}).await?; + let (mode, base_url) = api::resolve(); - // Step 2: preflight check. + // Step 1: authenticate (account-level CLI token). + let mut just_authenticated = false; + let account_token = match auth::resolve_account_token() { + Some(token) => { + status!("Already logged in."); + token + } + None => { + let outcome = login::authenticate(&base_url).await?; + status!("Logged in to {}.", outcome.org_label()); + just_authenticated = true; + outcome.account_token + } + }; + + // Step 2: Terms of Service. Only prompt during first-time login; an + // already-authenticated user has already accepted them. + if just_authenticated && !confirm_tos(args.agent)? { + anyhow::bail!("You must accept the Terms of Service to continue."); + } + + // Step 3: project (create a named one, or reuse the active one). + status!(""); + setup_project(&base_url, mode, &account_token, args.agent).await?; + + // Step 4: preflight check (verifies the new project API key works). status!(""); doctor::run(doctor::Args { preflight: true }).await?; - // Step 3: install Steel skills. + // Step 5: install Steel skills. status!(""); install_skills(&args).await?; @@ -56,6 +85,76 @@ pub async fn run(args: Args) -> anyhow::Result<()> { Ok(()) } +async fn setup_project( + base_url: &str, + mode: crate::config::settings::ApiMode, + account_token: &str, + agent: bool, +) -> anyhow::Result<()> { + let cfg = read_config_from(&config::config_path()).unwrap_or_default(); + let device_name = cfg + .name + .clone() + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(projects::default_project_name); + + // Reuse an already-configured project if one is active. + if let (Some(project), Some(_)) = (cfg.project.as_ref(), cfg.api_key.as_ref()) { + status!( + "Using existing project: {}", + project.name.as_deref().unwrap_or(&project.id) + ); + return Ok(()); + } + + if agent || !interactive() { + let project = + projects::ensure_active_project(base_url, mode, account_token, &device_name).await?; + status!( + "Active project: {}", + project.name.as_deref().unwrap_or(&project.id) + ); + return Ok(()); + } + + let name: String = Input::new() + .with_prompt("Project name") + .default(projects::default_project_name()) + .interact_text()?; + + let project = + projects::create_and_activate(base_url, mode, account_token, &name, &device_name).await?; + status!( + "Created project: {}", + project.name.as_deref().unwrap_or(&project.id) + ); + + Ok(()) +} + +fn confirm_tos(agent: bool) -> anyhow::Result { + let url = config::TOS_URL; + + if agent { + status!("Accepting the Terms of Service at {url}."); + return Ok(true); + } + + if !interactive() { + status!("By continuing you agree to the Terms of Service at {url}."); + return Ok(true); + } + + Ok(Confirm::new() + .with_prompt(format!("Do you agree to our Terms of Service at {url}?")) + .default(true) + .interact()?) +} + +fn interactive() -> bool { + output::is_tty() && !output::is_json() +} + async fn install_skills(args: &Args) -> anyhow::Result<()> { if args.no_skills { status!("Skipping Steel skill install (--no-skills)."); diff --git a/src/commands/login.rs b/src/commands/login.rs index 72d88c3..eb6bc42 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,291 +1,220 @@ -use std::io::{BufRead, BufReader, Write as IoWrite}; - use clap::Parser; -use tokio::net::TcpListener; +use dialoguer::{Confirm, Input, Select}; +use crate::api::device_auth::{self, OrgPayload}; +use crate::commands::projects; use crate::config; use crate::config::auth; -use crate::config::settings::{Config, read_config_from, write_config_to}; +use crate::config::settings::{OrgInfo, read_config_from, write_config_to}; use crate::status; +use crate::util::{api, output}; #[derive(Parser)] pub struct Args {} -const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60); +/// Result of authenticating: the account token and the org it belongs to. +pub struct AuthOutcome { + pub account_token: String, + pub device_name: String, + pub org: OrgPayload, +} + +impl AuthOutcome { + pub fn org_label(&self) -> String { + self.org.name.clone() + } +} pub async fn run(_args: Args) -> anyhow::Result<()> { - // Check if already logged in - let existing_auth = auth::resolve_auth(); - if existing_auth.api_key.is_some() { - status!("You are already logged in."); + if auth::resolve_account_token().is_some() { + status!("You are already logged in. Run `steel logout` first to switch accounts."); return Ok(()); } - status!("Launching browser for authentication..."); + let (mode, base_url) = api::resolve(); - let (api_key, name) = login_flow().await?; + if interactive() { + let choice = Select::new() + .with_prompt("Welcome to Steel! Would you like to log in?") + .items([ + "Use Steel locally (no account)", + "Log in or create an account", + ]) + .default(1) + .interact()?; - save_api_key(&api_key, &name)?; + if choice == 0 { + print_local_guidance(); + return Ok(()); + } + } + + let outcome = authenticate(&base_url).await?; + + let project = projects::ensure_active_project( + &base_url, + mode, + &outcome.account_token, + &outcome.device_name, + ) + .await?; crate::telemetry::track_event("login_completed", serde_json::Map::new()); - status!("Authentication successful! Your API key has been saved."); + status!("Logged in to {}.", outcome.org_label()); + if let Some(name) = project.name.as_deref() { + status!("Active project: {name}"); + } + status!("Saved credentials to {}", config::config_path().display()); Ok(()) } -async fn login_flow() -> anyhow::Result<(String, String)> { - let state = generate_random_hex(16); +/// Run the device authorization flow and persist the account token. +/// +/// Shared by `steel login` and `steel init`. Prints the verification URL + code +/// even in non-interactive / JSON mode so the user can always complete the flow. +pub async fn authenticate(base_url: &str) -> anyhow::Result { + let is_interactive = interactive(); + + let device_name = if is_interactive { + Input::new() + .with_prompt("Device name") + .default(default_device_name()) + .interact_text()? + } else { + default_device_name() + }; - // Bind to random port on localhost - let listener = TcpListener::bind("127.0.0.1:0").await?; - let port = listener.local_addr()?.port(); + let authz = device_auth::request_device_authorization(base_url).await?; - let auth_url = format!( - "{}?cli_redirect=true&port={}&state={}", - config::LOGIN_URL, - port, - state + // Essential instructions: printed directly so they are visible regardless of + // output mode (status! is suppressed when JSON output is active). + eprintln!(); + eprintln!("Visit {} to finish logging in.", authz.verification_uri); + eprintln!( + "Enter this code (expires in {} seconds): {}", + authz.expires_in, authz.user_code ); + eprintln!(); + + let open_browser = if is_interactive { + Confirm::new() + .with_prompt("Open the browser?") + .default(true) + .interact()? + } else { + false + }; - status!("Opening your browser for authentication..."); - status!("If it does not open automatically, please click:"); - status!("{auth_url}"); - - if let Err(e) = open::that(&auth_url) { - status!("Warning: could not open browser automatically: {e}"); - status!("Please open the URL above manually."); - } - - // Wait for the callback with timeout - let result = tokio::time::timeout(LOGIN_TIMEOUT, async { - let (stream, _) = listener.accept().await?; - stream.readable().await?; - - let std_stream = stream.into_std()?; - let mut reader = BufReader::new(&std_stream); - - let mut request_line = String::new(); - reader.read_line(&mut request_line)?; - - // Parse query params from GET /callback?jwt=...&state=... - let query = parse_query_from_request(&request_line); - - let received_state = query - .iter() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.as_str()); - - if received_state != Some(&state) { - send_response(&std_stream, 400, "Error: Invalid state parameter.")?; - anyhow::bail!("Invalid state parameter. Possible CSRF attack."); + if open_browser { + if let Err(e) = open::that(&authz.verification_uri_complete) { + eprintln!("Could not open the browser automatically: {e}"); + eprintln!( + "Open this URL manually: {}", + authz.verification_uri_complete + ); } + } else if !is_interactive { + eprintln!( + "Open this URL to authorize: {}", + authz.verification_uri_complete + ); + } - let jwt = query - .iter() - .find(|(k, _)| k == "jwt") - .map(|(_, v)| v.clone()); + status!("Waiting for authorization..."); - let Some(jwt) = jwt else { - send_response(&std_stream, 400, "Error: JWT not found in callback.")?; - anyhow::bail!("Callback did not include a JWT."); - }; + let token = device_auth::poll_for_token( + base_url, + &authz.device_code, + &device_name, + authz.interval, + authz.expires_in, + ) + .await?; - // Redirect browser to success page - let redirect = format!( - "HTTP/1.1 302 Found\r\nLocation: {}\r\nConnection: close\r\n\r\n", - config::SUCCESS_URL - ); - (&std_stream).write_all(redirect.as_bytes())?; - (&std_stream).flush()?; + save_account(&token.access_token, &device_name, &token.org)?; - // Exchange JWT for API key - create_api_key_using_jwt(&jwt).await + Ok(AuthOutcome { + account_token: token.access_token, + device_name, + org: token.org, }) - .await; - - match result { - Ok(inner) => inner, - Err(_) => anyhow::bail!("Login timed out. Please try again."), - } } -async fn create_api_key_using_jwt(jwt: &str) -> anyhow::Result<(String, String)> { - let client = reqwest::Client::new(); - let response = client - .post(config::API_KEYS_URL) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {jwt}")) - .json(&serde_json::json!({"name": "CLI"})) - .send() - .await?; - - if !response.status().is_success() { - anyhow::bail!( - "Failed to get API key: {} {}", - response.status().as_u16(), - response.status().canonical_reason().unwrap_or("") - ); - } - - let data: serde_json::Value = response.json().await?; - let key = data - .get("key") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("No API key found in response"))?; - let name = data - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("No name found in response"))?; - - Ok((key.to_string(), name.to_string())) +fn save_account(token: &str, device_name: &str, org: &OrgPayload) -> anyhow::Result<()> { + let path = config::config_path(); + let mut cfg = read_config_from(&path).unwrap_or_default(); + cfg.account_token = Some(token.to_string()); + cfg.name = Some(device_name.to_string()); + cfg.org = Some(OrgInfo { + id: org.id.clone(), + slug: org.slug.clone(), + name: Some(org.name.clone()), + }); + cfg.instance = Some("cloud".to_string()); + write_config_to(&path, &cfg)?; + Ok(()) } -fn save_api_key(api_key: &str, name: &str) -> anyhow::Result<()> { - let config_dir = config::config_dir(); - std::fs::create_dir_all(&config_dir)?; - let config_path = config::config_path_in(&config_dir); - - let mut config = read_config_from(&config_path).unwrap_or_else(|_| Config::default()); - config.api_key = Some(api_key.to_string()); - config.name = Some(name.to_string()); - - write_config_to(&config_path, &config)?; +fn interactive() -> bool { + output::is_tty() && !output::is_json() +} - Ok(()) +fn print_local_guidance() { + status!("Running Steel locally - no account needed."); + status!(""); + status!(" steel dev install Install the local Steel Browser runtime"); + status!(" steel dev start Start the local runtime"); + status!(" steel --local ... Run any command against the local runtime"); + status!(""); + status!("Run `steel login` again any time to connect a cloud account."); } -fn parse_query_from_request(request_line: &str) -> Vec<(String, String)> { - // "GET /callback?jwt=xxx&state=yyy HTTP/1.1" - let parts: Vec<&str> = request_line.split_whitespace().collect(); - if parts.len() < 2 { - return vec![]; +/// Best-effort device name from the host machine. Avoids `unsafe`/extra deps by +/// shelling out to `hostname`, then falling back to env vars. +fn default_device_name() -> String { + if let Ok(output) = std::process::Command::new("hostname").output() + && output.status.success() + { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !name.is_empty() { + return name; + } } - let path = parts[1]; - let query_start = match path.find('?') { - Some(i) => i + 1, - None => return vec![], - }; - - let query_str = &path[query_start..]; - query_str - .split('&') - .filter_map(|pair| { - let mut kv = pair.splitn(2, '='); - let key = kv.next()?; - let value = kv.next().unwrap_or(""); - Some(( - urlencoding::decode(key).unwrap_or_default().to_string(), - urlencoding::decode(value).unwrap_or_default().to_string(), - )) - }) - .collect() -} - -fn send_response(stream: &std::net::TcpStream, status: u16, body: &str) -> anyhow::Result<()> { - let response = format!( - "HTTP/1.1 {status} Error\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{body}" - ); - let mut stream = stream; - stream.write_all(response.as_bytes())?; - stream.flush()?; - Ok(()) -} + for key in ["HOSTNAME", "COMPUTERNAME", "USER", "USERNAME"] { + if let Ok(value) = std::env::var(key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } -fn generate_random_hex(bytes: usize) -> String { - let mut buf = vec![0u8; bytes]; - getrandom::fill(&mut buf).expect("failed to generate random bytes"); - buf.iter().map(|b| format!("{b:02x}")).collect() + "Steel CLI".to_string() } #[cfg(test)] mod tests { use super::*; - // --- parse_query_from_request --- - - #[test] - fn parse_query_normal_get() { - let query = parse_query_from_request("GET /callback?code=abc&state=xyz HTTP/1.1\r\n"); - assert_eq!(query.len(), 2); - assert_eq!(query[0], ("code".to_string(), "abc".to_string())); - assert_eq!(query[1], ("state".to_string(), "xyz".to_string())); - } - - #[test] - fn parse_query_no_query_string() { - let query = parse_query_from_request("GET /callback HTTP/1.1\r\n"); - assert!(query.is_empty()); - } - - #[test] - fn parse_query_empty_input() { - let query = parse_query_from_request(""); - assert!(query.is_empty()); - } - - #[test] - fn parse_query_single_word() { - let query = parse_query_from_request("GET"); - assert!(query.is_empty()); - } - - #[test] - fn parse_query_url_encoded() { - let query = parse_query_from_request("GET /cb?msg=hello%20world HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("msg".to_string(), "hello world".to_string())); - } - - #[test] - fn parse_query_empty_value() { - let query = parse_query_from_request("GET /cb?key= HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("key".to_string(), "".to_string())); - } - #[test] - fn parse_query_key_without_equals() { - let query = parse_query_from_request("GET /cb?flag HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("flag".to_string(), "".to_string())); + fn default_device_name_is_non_empty() { + assert!(!default_device_name().is_empty()); } #[test] - fn parse_query_multiple_params() { - let query = parse_query_from_request("GET /cb?a=1&b=2&c=3 HTTP/1.1\r\n"); - assert_eq!(query.len(), 3); - assert_eq!(query[0].0, "a"); - assert_eq!(query[1].0, "b"); - assert_eq!(query[2].0, "c"); - } - - #[test] - fn parse_query_value_with_equals() { - let query = parse_query_from_request("GET /cb?q=a=b HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("q".to_string(), "a=b".to_string())); - } - - // --- generate_random_hex --- - - #[test] - fn random_hex_length() { - assert_eq!(generate_random_hex(16).len(), 32); - assert_eq!(generate_random_hex(1).len(), 2); - } - - #[test] - fn random_hex_chars_only() { - let hex = generate_random_hex(16); - assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn random_hex_uniqueness() { - let a = generate_random_hex(16); - let b = generate_random_hex(16); - assert_ne!(a, b); + fn org_label_uses_name() { + let outcome = AuthOutcome { + account_token: "ste-cli-x".into(), + device_name: "Mac".into(), + org: OrgPayload { + id: "org-1".into(), + slug: Some("acme".into()), + name: "Acme".into(), + }, + }; + assert_eq!(outcome.org_label(), "Acme"); } } diff --git a/src/commands/logout.rs b/src/commands/logout.rs index 5bcb48b..aa23d0a 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -1,8 +1,10 @@ use clap::Parser; +use crate::api::device_auth; use crate::config; use crate::config::settings::{read_config_from, write_config_to}; use crate::status; +use crate::util::api; #[derive(Parser)] pub struct Args {} @@ -18,13 +20,24 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { } }; - if cfg.api_key.is_none() { + if cfg.account_token.is_none() && cfg.api_key.is_none() { status!("You are not logged in."); return Ok(()); } + // Best-effort server-side revocation of the account token. + if let Some(token) = cfg.account_token.clone() { + let (mode, base_url) = api::resolve(); + if let Err(e) = device_auth::revoke_account_token(&base_url, mode, &token).await { + status!("Warning: could not revoke the CLI token on the server: {e}"); + } + } + + cfg.account_token = None; cfg.api_key = None; cfg.name = None; + cfg.org = None; + cfg.project = None; write_config_to(&config_path, &cfg)?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 37d0325..eb0261a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod login; pub mod logout; pub mod pdf; pub mod profile; +pub mod projects; pub mod scrape; pub mod screenshot; pub mod sessions; @@ -191,8 +192,14 @@ Development: -d, --docker-check Only verify Docker, then exit steel dev stop Stop local runtime +Projects: + steel projects list List projects in your organization + steel projects create [name] Create a new project and make it active + steel projects select [id|slug] Switch the active project + steel projects current Show the currently active project + Other: - steel login Login to Steel (alias: auth) + steel login Login to Steel via device flow (alias: auth) steel logout Logout from Steel steel config Show current configuration steel doctor Check environment, auth, and connectivity @@ -203,7 +210,8 @@ Other: steel completion Generate shell completion script (bash, zsh, fish, powershell, elvish) Environment: - STEEL_API_KEY API key for Steel (overrides login) + STEEL_API_KEY Project API key for Steel (overrides login) + STEEL_ACCOUNT_TOKEN Account-level CLI token (overrides login) STEEL_API_URL Steel API endpoint STEEL_BROWSER_API_URL Steel Browser API endpoint STEEL_LOCAL_API_URL Local runtime API endpoint @@ -300,6 +308,12 @@ pub enum Command { /// Logout from Steel CLI Logout(logout::Args), + /// Manage projects in your organization + Projects { + #[command(subcommand)] + command: projects::Command, + }, + /// Manage stored credentials Credentials { #[command(subcommand)] @@ -354,6 +368,7 @@ fn telemetry_command_path(command: &Command) -> Option { Command::Init(_) => Some("init".to_string()), Command::Login(_) => Some("login".to_string()), Command::Logout(_) => Some("logout".to_string()), + Command::Projects { command } => Some(format!("projects.{}", command.telemetry_name())), Command::Credentials { command } => { Some(format!("credentials.{}", command.telemetry_name())) } @@ -401,6 +416,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { Command::Init(args) => init::run(args).await, Command::Login(args) => login::run(args).await, Command::Logout(args) => logout::run(args).await, + Command::Projects { command } => projects::run(command).await, Command::Credentials { command } => credentials::run(command).await, Command::Dev { command } => dev::run(command).await, Command::Forge(args) => forge::run(args).await, diff --git a/src/commands/projects.rs b/src/commands/projects.rs new file mode 100644 index 0000000..84bfe97 --- /dev/null +++ b/src/commands/projects.rs @@ -0,0 +1,312 @@ +use clap::{Parser, Subcommand}; +use dialoguer::{Input, Select}; +use serde_json::json; + +use crate::api::device_auth::{self, ProjectPayload}; +use crate::config; +use crate::config::auth; +use crate::config::settings::{ApiMode, ProjectInfo, read_config_from, write_config_to}; +use crate::status; +use crate::util::{api, output}; + +#[derive(Subcommand)] +pub enum Command { + /// List projects in your organization + List, + + /// Create a new project and make it active + Create(CreateArgs), + + /// Switch the active project + Select(SelectArgs), + + /// Show the currently active project + Current, +} + +impl Command { + pub const fn telemetry_name(&self) -> &'static str { + match self { + Self::List => "list", + Self::Create(_) => "create", + Self::Select(_) => "select", + Self::Current => "current", + } + } +} + +#[derive(Parser)] +pub struct CreateArgs { + /// Name for the new project + pub name: Option, +} + +#[derive(Parser)] +pub struct SelectArgs { + /// Project id or slug to make active + pub project: Option, +} + +pub async fn run(command: Command) -> anyhow::Result<()> { + match command { + Command::List => run_list().await, + Command::Create(args) => run_create(args).await, + Command::Select(args) => run_select(args).await, + Command::Current => run_current(), + } +} + +/// Resolve the account token or fail with a clear "log in first" message. +pub fn require_account_token() -> anyhow::Result { + auth::resolve_account_token() + .ok_or_else(|| anyhow::anyhow!("You are not logged in. Run `steel login` first.")) +} + +async fn run_list() -> anyhow::Result<()> { + let account_token = require_account_token()?; + let (mode, base_url) = api::resolve(); + let projects = device_auth::list_projects(&base_url, mode, &account_token).await?; + + let active = read_config_from(&config::config_path()) + .ok() + .and_then(|c| c.project) + .map(|p| p.id); + + if output::is_json() { + output::success_data(json!( + projects + .iter() + .map(|p| json!({ + "id": p.id, + "slug": p.slug, + "name": p.name, + "isDefault": p.is_default, + "active": active.as_deref() == Some(p.id.as_str()), + })) + .collect::>() + )); + } else if projects.is_empty() { + status!("No projects found."); + } else { + for p in &projects { + let marker = if active.as_deref() == Some(p.id.as_str()) { + "* " + } else { + " " + }; + let default_tag = if p.is_default { " (default)" } else { "" }; + println!("{marker}{} [{}]{default_tag}", p.name, p.slug); + } + } + + Ok(()) +} + +async fn run_create(args: CreateArgs) -> anyhow::Result<()> { + let account_token = require_account_token()?; + let (mode, base_url) = api::resolve(); + + let name = match args.name { + Some(name) if !name.trim().is_empty() => name, + _ => { + if output::is_tty() && !output::is_json() { + Input::new() + .with_prompt("Project name") + .default(default_project_name()) + .interact_text()? + } else { + anyhow::bail!("Missing project name. Usage: steel projects create "); + } + } + }; + + let device_name = stored_device_name(); + let project = create_and_activate(&base_url, mode, &account_token, &name, &device_name).await?; + + if output::is_json() { + output::success_data(json!({ + "id": project.id, + "slug": project.slug, + "name": project.name, + })); + } else { + status!( + "Created project '{}' and set it as active.", + project.name.as_deref().unwrap_or(&project.id) + ); + } + + Ok(()) +} + +async fn run_select(args: SelectArgs) -> anyhow::Result<()> { + let account_token = require_account_token()?; + let (mode, base_url) = api::resolve(); + let projects = device_auth::list_projects(&base_url, mode, &account_token).await?; + + if projects.is_empty() { + anyhow::bail!("No projects to select. Create one with `steel projects create `."); + } + + let chosen = match args.project { + Some(ref needle) => projects + .iter() + .find(|p| p.id == *needle || p.slug == *needle) + .cloned() + .ok_or_else(|| anyhow::anyhow!("No project matching '{needle}'"))?, + None => { + if !output::is_tty() || output::is_json() { + anyhow::bail!("Missing project. Usage: steel projects select "); + } + let labels: Vec = projects + .iter() + .map(|p| format!("{} [{}]", p.name, p.slug)) + .collect(); + let selection = Select::new() + .with_prompt("Select active project") + .items(&labels) + .default(0) + .interact()?; + projects[selection].clone() + } + }; + + let device_name = stored_device_name(); + let info = activate_project(&base_url, mode, &account_token, &chosen, &device_name).await?; + + if output::is_json() { + output::success_data(json!({ + "id": info.id, + "slug": info.slug, + "name": info.name, + })); + } else { + status!( + "Active project is now '{}'.", + info.name.as_deref().unwrap_or(&info.id) + ); + } + + Ok(()) +} + +fn run_current() -> anyhow::Result<()> { + let cfg = read_config_from(&config::config_path()).unwrap_or_default(); + match cfg.project { + Some(project) => { + if output::is_json() { + output::success_data(json!({ + "id": project.id, + "slug": project.slug, + "name": project.name, + })); + } else { + println!( + "{} [{}]", + project.name.as_deref().unwrap_or(&project.id), + project.slug.as_deref().unwrap_or("") + ); + } + } + None => { + status!("No active project. Run `steel projects select` or `steel projects create`."); + } + } + Ok(()) +} + +// --- Shared helpers used by login / init --- + +/// Create a named project, mint a project API key, and make it the active project. +pub async fn create_and_activate( + base_url: &str, + mode: ApiMode, + account_token: &str, + name: &str, + device_name: &str, +) -> anyhow::Result { + let project = device_auth::create_project(base_url, mode, account_token, name).await?; + activate_project(base_url, mode, account_token, &project, device_name).await +} + +/// Mint a project API key for `project` and persist it as the active project. +pub async fn activate_project( + base_url: &str, + mode: ApiMode, + account_token: &str, + project: &ProjectPayload, + device_name: &str, +) -> anyhow::Result { + let key = device_auth::create_project_api_key( + base_url, + mode, + account_token, + &project.id, + &format!("CLI ({device_name})"), + ) + .await?; + + let info = ProjectInfo { + id: project.id.clone(), + slug: Some(project.slug.clone()), + name: Some(project.name.clone()), + }; + save_active_project(&info, &key)?; + Ok(info) +} + +/// Ensure there is an active project + API key, selecting the default (or first) +/// project, or creating one if the organization somehow has none. +pub async fn ensure_active_project( + base_url: &str, + mode: ApiMode, + account_token: &str, + device_name: &str, +) -> anyhow::Result { + let cfg = read_config_from(&config::config_path()).unwrap_or_default(); + if let (Some(project), Some(_)) = (cfg.project.clone(), cfg.api_key.clone()) { + return Ok(project); + } + + let projects = device_auth::list_projects(base_url, mode, account_token).await?; + let chosen = projects + .iter() + .find(|p| p.is_default) + .or_else(|| projects.first()) + .cloned(); + + let project = match chosen { + Some(project) => project, + None => { + device_auth::create_project(base_url, mode, account_token, "Default project").await? + } + }; + + activate_project(base_url, mode, account_token, &project, device_name).await +} + +fn save_active_project(project: &ProjectInfo, api_key: &str) -> anyhow::Result<()> { + let path = config::config_path(); + let mut cfg = read_config_from(&path).unwrap_or_default(); + cfg.project = Some(project.clone()); + cfg.api_key = Some(api_key.to_string()); + write_config_to(&path, &cfg)?; + Ok(()) +} + +fn stored_device_name() -> String { + read_config_from(&config::config_path()) + .ok() + .and_then(|c| c.name) + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(default_project_name) +} + +/// Default project name derived from the current directory. +pub fn default_project_name() -> String { + std::env::current_dir() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(|| "Default project".to_string()) +} diff --git a/src/config/auth.rs b/src/config/auth.rs index 3546b2c..a31fac0 100644 --- a/src/config/auth.rs +++ b/src/config/auth.rs @@ -66,6 +66,14 @@ pub fn read_api_key_from_config(path: &Path) -> Option { .filter(|k| !k.trim().is_empty()) } +/// Read the account token from a config file at the given path. +pub fn read_account_token_from_config(path: &Path) -> Option { + settings::read_config_from(path) + .ok() + .and_then(|c| c.account_token) + .filter(|t| !t.trim().is_empty()) +} + /// Resolve API key from environment and default config path. pub fn resolve_auth() -> Auth { let env_key = std::env::var("STEEL_API_KEY").ok(); @@ -74,6 +82,29 @@ pub fn resolve_auth() -> Auth { resolve_auth_with(env_key.as_deref(), config_key.as_deref()) } +/// Resolve the account-level CLI token. +/// +/// Priority: `STEEL_ACCOUNT_TOKEN` env var -> config file. +pub fn resolve_account_token() -> Option { + if let Ok(token) = std::env::var("STEEL_ACCOUNT_TOKEN") { + let trimmed = token.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + read_account_token_from_config(&super::config_path()) +} + +/// Build an `Auth` value that carries the account token in the API-key slot, so +/// management requests authenticate via the `Steel-Api-Key` header (the API routes +/// account tokens by prefix). +pub fn account_token_auth(token: &str) -> Auth { + Auth { + api_key: Some(token.to_string()), + source: AuthSource::Config, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config/mod.rs b/src/config/mod.rs index 4e9b4a8..ba00d18 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,9 +5,7 @@ use std::path::{Path, PathBuf}; pub const DEFAULT_API_URL: &str = "https://api.steel.dev/v1"; pub const DEFAULT_LOCAL_API_URL: &str = "http://localhost:3000/v1"; -pub const LOGIN_URL: &str = "https://app.steel.dev/sign-in"; -pub const SUCCESS_URL: &str = "https://app.steel.dev/sign-in/cli-success"; -pub const API_KEYS_URL: &str = "https://api.steel.dev/v1/api-keys"; +pub const TOS_URL: &str = "https://steel.dev/terms"; pub const REPO_URL: &str = "https://github.com/steel-dev/steel-browser.git"; /// Resolve the Steel config directory. diff --git a/src/config/settings.rs b/src/config/settings.rs index b084110..fd625d5 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -102,18 +102,49 @@ fn normalize_api_url(url: &str) -> String { #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "camelCase")] pub struct Config { + /// Active project API key, used for browser/session work. #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, + /// Account-level CLI token (manages projects and project API keys). + #[serde(skip_serializing_if = "Option::is_none")] + pub account_token: Option, + /// Device name associated with the account token. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub instance: Option, + /// Organization the account token belongs to. + #[serde(skip_serializing_if = "Option::is_none")] + pub org: Option, + /// Currently selected project. + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, #[serde(skip_serializing_if = "Option::is_none")] pub browser: Option, #[serde(skip_serializing_if = "Option::is_none")] pub telemetry: Option, } +#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OrgInfo { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectInfo { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "camelCase")] pub struct BrowserConfig { @@ -192,8 +223,19 @@ mod tests { let config = Config { api_key: Some("sk-test-123".into()), + account_token: Some("ste-cli-abc".into()), name: Some("CLI".into()), instance: Some("cloud".into()), + org: Some(OrgInfo { + id: "org-1".into(), + slug: Some("acme".into()), + name: Some("Acme".into()), + }), + project: Some(ProjectInfo { + id: "proj-1".into(), + slug: Some("dev".into()), + name: Some("Dev".into()), + }), browser: Some(BrowserConfig { api_url: Some("http://localhost:4000/v1".into()), }), @@ -206,8 +248,14 @@ mod tests { let loaded = read_config_from(&path).unwrap(); assert_eq!(loaded.api_key.as_deref(), Some("sk-test-123")); + assert_eq!(loaded.account_token.as_deref(), Some("ste-cli-abc")); assert_eq!(loaded.name.as_deref(), Some("CLI")); assert_eq!(loaded.instance.as_deref(), Some("cloud")); + assert_eq!(loaded.org.as_ref().map(|o| o.id.as_str()), Some("org-1")); + assert_eq!( + loaded.project.as_ref().map(|p| p.id.as_str()), + Some("proj-1") + ); assert_eq!(loaded.local_api_url(), Some("http://localhost:4000/v1")); assert!(loaded.telemetry_disabled()); } From 95174173edccad1be5bd3c18b0eb444f2e13df54 Mon Sep 17 00:00:00 2001 From: nas Date: Fri, 12 Jun 2026 22:04:07 -0400 Subject: [PATCH 2/6] feat(cli): add styled terminal output and spinners --- Cargo.lock | 25 ++++- Cargo.toml | 12 ++- src/api/device_auth.rs | 2 + src/commands/doctor.rs | 24 ++--- src/commands/forge.rs | 12 ++- src/commands/init/mod.rs | 44 ++++++--- src/commands/login.rs | 87 +++++++++++++--- src/commands/logout.rs | 15 ++- src/commands/pdf.rs | 7 +- src/commands/profile.rs | 2 +- src/commands/projects.rs | 6 +- src/commands/scrape.rs | 7 +- src/commands/screenshot.rs | 7 +- src/commands/sessions.rs | 47 ++++++++- src/commands/settings.rs | 2 +- src/commands/update.rs | 16 +-- src/util/mod.rs | 1 + src/util/output.rs | 4 +- src/util/style.rs | 196 +++++++++++++++++++++++++++++++++++++ 19 files changed, 434 insertions(+), 82 deletions(-) create mode 100644 src/util/style.rs diff --git a/Cargo.lock b/Cargo.lock index 9e30277..868f600 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,9 +129,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -1382,6 +1382,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2709,6 +2722,7 @@ dependencies = [ "aes", "aes-gcm", "agent-browser", + "anstyle", "anyhow", "base64", "cbc", @@ -2719,6 +2733,7 @@ dependencies = [ "futures-util", "getrandom 0.4.2", "hmac", + "indicatif", "jiff", "libc", "open", @@ -3142,6 +3157,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 5e29e27..9c6f37b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,11 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", + "multipart", +] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } @@ -49,8 +53,12 @@ jiff = "0.2" parking_lot = "0.12" serde_yaml = "0.9" -tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.29.0", features = [ + "rustls-tls-webpki-roots", +] } futures-util = "0.3.32" +indicatif = "0.18.4" +anstyle = "1.0.14" [dev-dependencies] wiremock = "0.6" diff --git a/src/api/device_auth.rs b/src/api/device_auth.rs index 5055e90..d3f68cf 100644 --- a/src/api/device_auth.rs +++ b/src/api/device_auth.rs @@ -38,6 +38,8 @@ pub struct DeviceTokenSuccess { #[allow(dead_code)] pub token_name: String, pub org: OrgPayload, + #[serde(default)] + pub tos_required: bool, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 1cd9dd6..0968d9f 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -8,7 +8,7 @@ use crate::api::projects::{environment_label, parse_projects, resolve_current_pr use crate::browser::daemon::process; use crate::config::auth::Auth; use crate::config::settings::ApiMode; -use crate::util::output; +use crate::util::{output, style}; #[derive(Parser)] pub struct Args { @@ -365,7 +365,6 @@ async fn check_version() -> Vec { } fn print_text(checks: &[Check]) { - let use_color = !output::no_color(); let mut current_category = ""; for check in checks { @@ -373,27 +372,20 @@ fn print_text(checks: &[Check]) { if !current_category.is_empty() { println!(); } - println!(" {}", format_category(check.category)); + println!(" {}", style::bold(format_category(check.category))); current_category = check.category; } - let symbol = match (check.status, use_color) { - ("pass", true) => "\x1b[32m✓\x1b[0m", - ("degraded", true) => "\x1b[33m!\x1b[0m", - ("fail", true) => "\x1b[31m✗\x1b[0m", - ("pass", false) => "✓", - ("degraded", false) => "!", - ("fail", false) => "✗", - _ => "?", + let symbol = match check.status { + "pass" => style::tick(), + "degraded" => style::bang(), + "fail" => style::cross(), + _ => "?".to_string(), }; println!(" {symbol} {}", check.name); if let Some(fix) = check.fix { - if use_color { - println!(" \x1b[2m→ {fix}\x1b[0m"); - } else { - println!(" → {fix}"); - } + println!(" {}", style::dim(&format!("→ {fix}"))); } } println!(); diff --git a/src/commands/forge.rs b/src/commands/forge.rs index e0366e6..b6d9b25 100644 --- a/src/commands/forge.rs +++ b/src/commands/forge.rs @@ -9,7 +9,7 @@ use serde_json::json; use zip::ZipArchive; use crate::status; -use crate::util::output; +use crate::util::{output, style}; const COOKBOOK_REPO: &str = "steel-dev/steel-cookbook"; // To bump: run scripts/bump-cookbook.sh [] (defaults to upstream main). @@ -157,7 +157,7 @@ async fn ensure_cookbook_at(cache: &Path, repo: &str, git_ref: &str) -> anyhow:: } let short = &git_ref[..12.min(git_ref.len())]; - status!("Fetching templates from {repo}@{short}..."); + let spinner = style::spinner(format!("Fetching templates from {repo}@{short}…")); let url = format!("https://codeload.github.com/{repo}/zip/{git_ref}"); let bytes = reqwest::get(&url) @@ -206,6 +206,7 @@ async fn ensure_cookbook_at(cache: &Path, repo: &str, git_ref: &str) -> anyhow:: Err(e) => return Err(e.into()), } + spinner.finish_and_clear(); prune_old_cache_siblings(cache); Ok(()) @@ -292,7 +293,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { .map(|r| format!("{} [{}]", r.title, r.language)) .collect(); - let selection = Select::new() + let selection = Select::with_theme(&*style::prompt_theme()) .with_prompt("Select a template") .items(&items) .default(0) @@ -306,7 +307,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { let project_name = if let Some(ref name) = args.name { name.clone() } else { - Input::new() + Input::with_theme(&*style::prompt_theme()) .with_prompt("Project name") .default(slug.to_string()) .interact_text()? @@ -334,7 +335,8 @@ pub async fn run(args: Args) -> anyhow::Result<()> { copy_dir(&template_src, &target_dir)?; status!( - "Project '{}' created at {}", + "{} Project '{}' created at {}", + style::tick(), project_name, target_dir.display() ); diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index f302d15..f8478ca 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -6,7 +6,7 @@ use crate::config; use crate::config::auth; use crate::config::settings::read_config_from; use crate::status; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Parser)] pub struct Args { @@ -25,34 +25,36 @@ pub struct Args { } pub async fn run(args: Args) -> anyhow::Result<()> { - status!("Steel CLI setup"); + status!(""); + status!("{}", style::bold("Steel CLI setup")); + status!("{}", style::dim("Browser automation for AI agents")); if let Ok(from) = std::env::var("STEEL_ONBOARDING_FROM") && !from.is_empty() { - status!("Onboarding source: {from}"); + status!("{}", style::dim(&format!("Onboarding source: {from}"))); } status!(""); let (mode, base_url) = api::resolve(); // Step 1: authenticate (account-level CLI token). - let mut just_authenticated = false; + let mut tos_required = false; let account_token = match auth::resolve_account_token() { Some(token) => { - status!("Already logged in."); + status!("{} Already logged in.", style::tick()); token } None => { let outcome = login::authenticate(&base_url).await?; - status!("Logged in to {}.", outcome.org_label()); - just_authenticated = true; + status!("{} Logged in to {}.", style::tick(), outcome.org_label()); + tos_required = outcome.tos_required; outcome.account_token } }; // Step 2: Terms of Service. Only prompt during first-time login; an // already-authenticated user has already accepted them. - if just_authenticated && !confirm_tos(args.agent)? { + if tos_required && !confirm_tos(args.agent)? { anyhow::bail!("You must accept the Terms of Service to continue."); } @@ -76,10 +78,24 @@ pub async fn run(args: Args) -> anyhow::Result<()> { status!(" • Take a screenshot. Ask for a URL, then run: steel screenshot "); status!(" • Start an interactive browser session: steel browser start --session demo"); } else { - status!("Setup complete. Try one of these:"); - status!(" steel scrape https://example.com"); - status!(" steel browser start --session hello"); - status!(" steel --help"); + status!("{} {}", style::tick(), style::bold("Setup complete!")); + status!(""); + status!("{}", style::dim("Try one of these:")); + status!( + " {} {}", + style::cyan(&format!("{:<36}", "steel scrape https://example.com")), + style::dim("Scrape a page to markdown") + ); + status!( + " {} {}", + style::cyan(&format!("{:<36}", "steel browser start --session hello")), + style::dim("Start a browser session") + ); + status!( + " {} {}", + style::cyan(&format!("{:<36}", "steel --help")), + style::dim("See all commands") + ); } Ok(()) @@ -117,7 +133,7 @@ async fn setup_project( return Ok(()); } - let name: String = Input::new() + let name: String = Input::with_theme(&*style::prompt_theme()) .with_prompt("Project name") .default(projects::default_project_name()) .interact_text()?; @@ -145,7 +161,7 @@ fn confirm_tos(agent: bool) -> anyhow::Result { return Ok(true); } - Ok(Confirm::new() + Ok(Confirm::with_theme(&*style::prompt_theme()) .with_prompt(format!("Do you agree to our Terms of Service at {url}?")) .default(true) .interact()?) diff --git a/src/commands/login.rs b/src/commands/login.rs index eb6bc42..ef16423 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -7,7 +7,7 @@ use crate::config; use crate::config::auth; use crate::config::settings::{OrgInfo, read_config_from, write_config_to}; use crate::status; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Parser)] pub struct Args {} @@ -17,6 +17,7 @@ pub struct AuthOutcome { pub account_token: String, pub device_name: String, pub org: OrgPayload, + pub tos_required: bool, } impl AuthOutcome { @@ -34,7 +35,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { let (mode, base_url) = api::resolve(); if interactive() { - let choice = Select::new() + let choice = Select::with_theme(&*style::prompt_theme()) .with_prompt("Welcome to Steel! Would you like to log in?") .items([ "Use Steel locally (no account)", @@ -61,7 +62,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { crate::telemetry::track_event("login_completed", serde_json::Map::new()); - status!("Logged in to {}.", outcome.org_label()); + status!("{} Logged in to {}.", style::tick(), outcome.org_label()); if let Some(name) = project.name.as_deref() { status!("Active project: {name}"); } @@ -78,7 +79,7 @@ pub async fn authenticate(base_url: &str) -> anyhow::Result { let is_interactive = interactive(); let device_name = if is_interactive { - Input::new() + Input::with_theme(&*style::prompt_theme()) .with_prompt("Device name") .default(default_device_name()) .interact_text()? @@ -89,17 +90,22 @@ pub async fn authenticate(base_url: &str) -> anyhow::Result { let authz = device_auth::request_device_authorization(base_url).await?; // Essential instructions: printed directly so they are visible regardless of - // output mode (status! is suppressed when JSON output is active). - eprintln!(); - eprintln!("Visit {} to finish logging in.", authz.verification_uri); - eprintln!( - "Enter this code (expires in {} seconds): {}", - authz.expires_in, authz.user_code - ); - eprintln!(); + // output mode (status! is suppressed when JSON output is active). Humans get + // a spotlighted block; non-interactive/CI gets plain, parseable lines. + if is_interactive { + print_auth_prompt(&authz); + } else { + eprintln!(); + eprintln!("Visit {} to finish logging in.", authz.verification_uri); + eprintln!( + "Enter this code (expires in {} seconds): {}", + authz.expires_in, authz.user_code + ); + eprintln!(); + } let open_browser = if is_interactive { - Confirm::new() + Confirm::with_theme(&*style::prompt_theme()) .with_prompt("Open the browser?") .default(true) .interact()? @@ -122,8 +128,7 @@ pub async fn authenticate(base_url: &str) -> anyhow::Result { ); } - status!("Waiting for authorization..."); - + let spinner = style::spinner("Waiting for authorization…"); let token = device_auth::poll_for_token( base_url, &authz.device_code, @@ -131,7 +136,9 @@ pub async fn authenticate(base_url: &str) -> anyhow::Result { authz.interval, authz.expires_in, ) - .await?; + .await; + spinner.finish_and_clear(); + let token = token?; save_account(&token.access_token, &device_name, &token.org)?; @@ -139,6 +146,7 @@ pub async fn authenticate(base_url: &str) -> anyhow::Result { account_token: token.access_token, device_name, org: token.org, + tos_required: token.tos_required, }) } @@ -161,6 +169,42 @@ fn interactive() -> bool { output::is_tty() && !output::is_json() } +/// Pretty, interactive-only device-login instructions: a styled URL and the +/// one-time code spotlighted in a box so it's easy to spot and copy. +fn print_auth_prompt(authz: &device_auth::DeviceAuthorization) { + eprintln!(); + eprintln!( + " To finish logging in, visit {}", + style::link(&authz.verification_uri) + ); + eprintln!( + " and enter this code {}:", + style::dim(&format!( + "(expires in {})", + humanize_duration(authz.expires_in) + )) + ); + eprintln!(); + eprintln!("{}", style::code_box(&authz.user_code, " ")); + eprintln!(); +} + +/// Render a second count as a friendly duration, e.g. 900 -> "15 minutes". +fn humanize_duration(secs: u64) -> String { + if secs >= 60 { + let mins = secs / 60; + let unit = if mins == 1 { "minute" } else { "minutes" }; + if secs.is_multiple_of(60) { + format!("{mins} {unit}") + } else { + format!("~{mins} {unit}") + } + } else { + let unit = if secs == 1 { "second" } else { "seconds" }; + format!("{secs} {unit}") + } +} + fn print_local_guidance() { status!("Running Steel locally - no account needed."); status!(""); @@ -204,6 +248,16 @@ mod tests { assert!(!default_device_name().is_empty()); } + #[test] + fn humanize_duration_formats() { + assert_eq!(humanize_duration(900), "15 minutes"); + assert_eq!(humanize_duration(60), "1 minute"); + assert_eq!(humanize_duration(90), "~1 minute"); + assert_eq!(humanize_duration(150), "~2 minutes"); + assert_eq!(humanize_duration(45), "45 seconds"); + assert_eq!(humanize_duration(1), "1 second"); + } + #[test] fn org_label_uses_name() { let outcome = AuthOutcome { @@ -214,6 +268,7 @@ mod tests { slug: Some("acme".into()), name: "Acme".into(), }, + tos_required: false, }; assert_eq!(outcome.org_label(), "Acme"); } diff --git a/src/commands/logout.rs b/src/commands/logout.rs index aa23d0a..88c8043 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -4,7 +4,7 @@ use crate::api::device_auth; use crate::config; use crate::config::settings::{read_config_from, write_config_to}; use crate::status; -use crate::util::api; +use crate::util::{api, style}; #[derive(Parser)] pub struct Args {} @@ -25,11 +25,16 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { return Ok(()); } + let org_label = cfg.org.as_ref().and_then(|o| o.name.clone()); + // Best-effort server-side revocation of the account token. if let Some(token) = cfg.account_token.clone() { let (mode, base_url) = api::resolve(); if let Err(e) = device_auth::revoke_account_token(&base_url, mode, &token).await { - status!("Warning: could not revoke the CLI token on the server: {e}"); + status!( + "{} Could not revoke the CLI token on the server: {e}", + style::bang() + ); } } @@ -41,7 +46,11 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { write_config_to(&config_path, &cfg)?; - status!("Successfully logged out. Have a great day!"); + match org_label { + Some(name) => status!("{} Logged out of {name}.", style::tick()), + None => status!("{} Logged out.", style::tick()), + } + status!("{}", style::dim("Run steel login to sign back in.")); Ok(()) } diff --git a/src/commands/pdf.rs b/src/commands/pdf.rs index 688e01b..a3aeb92 100644 --- a/src/commands/pdf.rs +++ b/src/commands/pdf.rs @@ -3,7 +3,7 @@ use clap::Parser; use crate::api::client::SteelClient; use crate::api::top_level::get_hosted_url; use crate::util::url::resolve_tool_url; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Parser)] pub struct Args { @@ -29,6 +29,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); let client = SteelClient::new()?; + let spinner = style::spinner(format!("Generating PDF of {url}…")); let response = client .pdf( &base_url, @@ -39,7 +40,9 @@ pub async fn run(args: Args) -> anyhow::Result<()> { args.use_proxy, args.region.as_deref(), ) - .await?; + .await; + spinner.finish_and_clear(); + let response = response?; if output::is_json() { output::success_data(response); diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 7703153..253e671 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -284,7 +284,7 @@ async fn run_import(args: ImportArgs) -> anyhow::Result<()> { }) .collect(); - let selection = dialoguer::Select::new() + let selection = dialoguer::Select::with_theme(&*crate::util::style::prompt_theme()) .with_prompt("Select profile") .items(&items) .default(0) diff --git a/src/commands/projects.rs b/src/commands/projects.rs index 84bfe97..a5d5286 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -7,7 +7,7 @@ use crate::config; use crate::config::auth; use crate::config::settings::{ApiMode, ProjectInfo, read_config_from, write_config_to}; use crate::status; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Subcommand)] pub enum Command { @@ -110,7 +110,7 @@ async fn run_create(args: CreateArgs) -> anyhow::Result<()> { Some(name) if !name.trim().is_empty() => name, _ => { if output::is_tty() && !output::is_json() { - Input::new() + Input::with_theme(&*style::prompt_theme()) .with_prompt("Project name") .default(default_project_name()) .interact_text()? @@ -162,7 +162,7 @@ async fn run_select(args: SelectArgs) -> anyhow::Result<()> { .iter() .map(|p| format!("{} [{}]", p.name, p.slug)) .collect(); - let selection = Select::new() + let selection = Select::with_theme(&*style::prompt_theme()) .with_prompt("Select active project") .items(&labels) .default(0) diff --git a/src/commands/scrape.rs b/src/commands/scrape.rs index 9ac838e..be0fb70 100644 --- a/src/commands/scrape.rs +++ b/src/commands/scrape.rs @@ -3,7 +3,7 @@ use clap::Parser; use crate::api::client::SteelClient; use crate::api::top_level::{get_scrape_output_text, parse_scrape_formats}; use crate::util::url::resolve_tool_url; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Parser)] pub struct Args { @@ -46,6 +46,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); let client = SteelClient::new()?; + let spinner = style::spinner(format!("Scraping {url}…")); let response = client .scrape( &base_url, @@ -59,7 +60,9 @@ pub async fn run(args: Args) -> anyhow::Result<()> { args.use_proxy, args.region.as_deref(), ) - .await?; + .await; + spinner.finish_and_clear(); + let response = response?; if output::is_json() { output::success_data(response); diff --git a/src/commands/screenshot.rs b/src/commands/screenshot.rs index f6683a2..e8b9452 100644 --- a/src/commands/screenshot.rs +++ b/src/commands/screenshot.rs @@ -3,7 +3,7 @@ use clap::Parser; use crate::api::client::SteelClient; use crate::api::top_level::get_hosted_url; use crate::util::url::resolve_tool_url; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Parser)] pub struct Args { @@ -33,6 +33,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); let client = SteelClient::new()?; + let spinner = style::spinner(format!("Capturing screenshot of {url}…")); let response = client .screenshot( &base_url, @@ -44,7 +45,9 @@ pub async fn run(args: Args) -> anyhow::Result<()> { args.use_proxy, args.region.as_deref(), ) - .await?; + .await; + spinner.finish_and_clear(); + let response = response?; if output::is_json() { output::success_data(response); diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index e9da261..02020c6 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -16,7 +16,7 @@ use crate::browser::daemon::protocol::{DaemonCommand, SessionInfo}; use crate::config::auth::Auth; use crate::config::settings::ApiMode; use crate::status; -use crate::util::{api, output}; +use crate::util::{api, output, style}; #[derive(Subcommand)] pub enum Command { @@ -497,7 +497,10 @@ fn print_sessions(data: &Value) { .max() .unwrap_or(2) .max(2); - println!("{: String { + match status { + "live" => style::green(padded), + "failed" => style::red(padded), + "released" => style::dim(padded), + _ => padded.to_string(), } } @@ -587,7 +603,30 @@ fn format_event_line(event: &Value) -> String { .map(str::to_string) .unwrap_or_else(|| compact_json(event)); - format!("{timestamp} {kind:<18} {detail}") + let timestamp = style::dim(timestamp); + let kind = colorize_kind(kind, &format!("{kind:<18}")); + format!("{timestamp} {kind} {detail}") +} + +/// Color an event/trace type by semantics so failures and warnings stand out in +/// a fast-scrolling stream. The label is pre-padded so columns stay aligned +/// whether or not color is emitted. +fn colorize_kind(kind: &str, padded: &str) -> String { + let lower = kind.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("fail") || lower.contains("exception") { + style::red(padded) + } else if lower.contains("warn") { + style::yellow(padded) + } else if lower.contains("nav") + || lower.contains("request") + || lower.contains("response") + || lower.contains("network") + || lower.contains("goto") + { + style::blue(padded) + } else { + style::cyan(padded) + } } fn compact_json(value: &Value) -> String { diff --git a/src/commands/settings.rs b/src/commands/settings.rs index a75c666..12179d5 100644 --- a/src/commands/settings.rs +++ b/src/commands/settings.rs @@ -30,7 +30,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { ), ]; - let selection = Select::new() + let selection = Select::with_theme(&*crate::util::style::prompt_theme()) .with_prompt("Select instance type") .items(&items) .default(if current_instance == "local" { 1 } else { 0 }) diff --git a/src/commands/update.rs b/src/commands/update.rs index 05f2651..3119fdb 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -1,6 +1,7 @@ use clap::Parser; use crate::status; +use crate::util::style; #[derive(Parser)] pub struct Args { @@ -14,19 +15,19 @@ pub struct Args { } pub async fn run(args: Args) -> anyhow::Result<()> { - status!("Checking for updates..."); - let client = reqwest::Client::new(); let current_version = env!("CARGO_PKG_VERSION"); - // Check GitHub releases for latest version - let latest_version = match client + let spinner = style::spinner("Checking for updates…"); + let response = client .get("https://api.github.com/repos/steel-dev/cli/releases/latest") .header("Accept", "application/vnd.github+json") .header("User-Agent", format!("steel-cli/{current_version}")) .send() - .await - { + .await; + + // Check GitHub releases for latest version + let latest_version = match response { Ok(resp) if resp.status().is_success() => { let data: serde_json::Value = resp.json().await.unwrap_or_default(); data.get("tag_name") @@ -35,6 +36,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { } _ => None, }; + spinner.finish_and_clear(); let Some(latest) = latest_version else { status!("Could not check for updates. Please check your network connection."); @@ -72,7 +74,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { match status { Ok(s) if s.success() => { - status!("Updated to v{latest}"); + status!("{} Updated to v{latest}", style::tick()); } _ => { anyhow::bail!( diff --git a/src/util/mod.rs b/src/util/mod.rs index b2a2124..7cb8923 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ pub mod api; pub mod output; +pub mod style; pub mod url; diff --git a/src/util/output.rs b/src/util/output.rs index 8d8c08d..5fa0416 100644 --- a/src/util/output.rs +++ b/src/util/output.rs @@ -203,9 +203,9 @@ pub fn handle_error(err: &anyhow::Error) -> ! { } println!("{obj}"); } else { - eprintln!("Error: {msg}"); + eprintln!("{} {msg}", crate::util::style::error_label("Error:")); if let Some(h) = hint { - eprintln!("Hint: {h}"); + eprintln!("{}", crate::util::style::dim(&format!("Hint: {h}"))); } } diff --git a/src/util/style.rs b/src/util/style.rs new file mode 100644 index 0000000..afdae18 --- /dev/null +++ b/src/util/style.rs @@ -0,0 +1,196 @@ +//! Terminal styling: ANSI colors, status glyphs, and spinners. +//! +//! Everything here is **purely cosmetic** and routes through a single gate +//! (`color_enabled` / the spinner's interactivity check) so it never corrupts +//! machine-readable output. Rules: +//! +//! - Color is emitted only when stdout is a TTY and `NO_COLOR` is unset. +//! - Spinners render only in interactive human mode and draw to **stderr**, so +//! they never touch the bytes a caller pipes from stdout (or parses as JSON). + +use std::borrow::Cow; +use std::time::Duration; + +use anstyle::{AnsiColor, Color, Style}; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::util::output; + +const GREEN: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green))); +const RED: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red))); +const YELLOW: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow))); +const CYAN: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))); +const BLUE: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue))); +const MAGENTA: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Magenta))); +const DIM: Style = Style::new().dimmed(); +const BOLD: Style = Style::new().bold(); +const ERROR_LABEL: Style = Style::new() + .fg_color(Some(Color::Ansi(AnsiColor::Red))) + .bold(); +const LINK: Style = Style::new() + .fg_color(Some(Color::Ansi(AnsiColor::Cyan))) + .underline(); + +/// True when ANSI styling should be emitted: stdout is a TTY and `NO_COLOR` +/// is unset. Mirrors the gate the rest of the CLI uses for human output. +pub fn color_enabled() -> bool { + output::is_tty() && !output::no_color() +} + +fn paint(style: Style, text: &str) -> String { + if color_enabled() { + format!("{}{text}{}", style.render(), style.render_reset()) + } else { + text.to_string() + } +} + +// --- Named colors --- + +pub fn green(text: &str) -> String { + paint(GREEN, text) +} + +pub fn red(text: &str) -> String { + paint(RED, text) +} + +pub fn yellow(text: &str) -> String { + paint(YELLOW, text) +} + +pub fn cyan(text: &str) -> String { + paint(CYAN, text) +} + +pub fn blue(text: &str) -> String { + paint(BLUE, text) +} + +pub fn magenta(text: &str) -> String { + paint(MAGENTA, text) +} + +pub fn dim(text: &str) -> String { + paint(DIM, text) +} + +pub fn bold(text: &str) -> String { + paint(BOLD, text) +} + +/// Bold red label, e.g. the `Error:` prefix on failures. +pub fn error_label(text: &str) -> String { + paint(ERROR_LABEL, text) +} + +/// Cyan underlined text for URLs. +pub fn link(text: &str) -> String { + paint(LINK, text) +} + +// --- Status glyphs (colored, with the symbol baked in) --- + +/// Green check mark for success states. +pub fn tick() -> String { + green("✓") +} + +/// Red cross for failure states. +pub fn cross() -> String { + red("✗") +} + +/// Yellow bang for warnings / degraded states. +pub fn bang() -> String { + yellow("!") +} + +/// Dim arrow used to prefix follow-up hints / fixes. +pub fn arrow() -> String { + dim("→") +} + +// --- Interactive prompts --- + +/// Theme for `dialoguer` prompts: colorful when color is enabled, plain +/// otherwise. Use as `Input::with_theme(&*style::prompt_theme())`. +pub fn prompt_theme() -> Box { + if color_enabled() { + Box::new(dialoguer::theme::ColorfulTheme::default()) + } else { + Box::new(dialoguer::theme::SimpleTheme) + } +} + +// --- Boxes --- + +/// Render `content` inside a rounded single-line box, the content bold and the +/// border dim. Lines are prefixed with `indent`. Used to spotlight a value the +/// user needs to copy (e.g. a device login code). +pub fn code_box(content: &str, indent: &str) -> String { + const PAD: usize = 2; + let rule = "─".repeat(content.chars().count() + PAD * 2); + let pad = " ".repeat(PAD); + let bar = dim("│"); + let top = dim(&format!("╭{rule}╮")); + let bot = dim(&format!("╰{rule}╯")); + let mid = format!("{bar}{pad}{}{pad}{bar}", bold(content)); + format!("{indent}{top}\n{indent}{mid}\n{indent}{bot}") +} + +// --- Spinners --- + +/// A best-effort spinner. In non-interactive contexts (piped stdout, `--json`, +/// non-TTY) it is a no-op so call sites stay uniform. Drawn to stderr. +pub struct Spinner(Option); + +/// Start a spinner with `message`. No-op (but still a valid handle) when output +/// is not an interactive human terminal. +pub fn spinner(message: impl Into>) -> Spinner { + if !output::is_tty() || output::is_json() { + return Spinner(None); + } + + let template = if output::no_color() { + "{spinner} {msg}" + } else { + "{spinner:.cyan} {msg}" + }; + let style = ProgressStyle::with_template(template) + .unwrap_or_else(|_| ProgressStyle::default_spinner()) + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "); + + let pb = ProgressBar::new_spinner(); + pb.set_style(style); + pb.set_message(message); + pb.enable_steady_tick(Duration::from_millis(80)); + Spinner(Some(pb)) +} + +impl Spinner { + /// Update the spinner's message in place. + pub fn set_message(&self, message: impl Into>) { + if let Some(pb) = &self.0 { + pb.set_message(message); + } + } + + /// Stop the spinner and erase its line. Call this before writing results to + /// stdout so the cleared line and the output don't interleave. + pub fn finish_and_clear(self) { + if let Some(pb) = &self.0 { + pb.finish_and_clear(); + } + } +} + +impl Drop for Spinner { + fn drop(&mut self) { + // Safety net for early returns (`?`) so a failed command never leaves a + // dangling spinner line behind. Idempotent with `finish_and_clear`. + if let Some(pb) = &self.0 { + pb.finish_and_clear(); + } + } +} From e0b6e4a5522f8a2954d512a48fd72098e2b71d52 Mon Sep 17 00:00:00 2001 From: nas Date: Fri, 12 Jun 2026 22:12:18 -0400 Subject: [PATCH 3/6] feat(init): add interactive project selection --- src/commands/init/mod.rs | 12 +++---- src/commands/projects.rs | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index f8478ca..6b26a3a 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -1,5 +1,5 @@ use clap::Parser; -use dialoguer::{Confirm, Input}; +use dialoguer::Confirm; use crate::commands::{doctor, login, projects, skills}; use crate::config; @@ -133,15 +133,11 @@ async fn setup_project( return Ok(()); } - let name: String = Input::with_theme(&*style::prompt_theme()) - .with_prompt("Project name") - .default(projects::default_project_name()) - .interact_text()?; - let project = - projects::create_and_activate(base_url, mode, account_token, &name, &device_name).await?; + projects::choose_project_interactive(base_url, mode, account_token, &device_name).await?; status!( - "Created project: {}", + "{} Using project: {}", + style::tick(), project.name.as_deref().unwrap_or(&project.id) ); diff --git a/src/commands/projects.rs b/src/commands/projects.rs index a5d5286..5b1460b 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -285,6 +285,75 @@ pub async fn ensure_active_project( activate_project(base_url, mode, account_token, &project, device_name).await } +/// Interactive project selection for `steel init`. +pub async fn choose_project_interactive( + base_url: &str, + mode: ApiMode, + account_token: &str, + device_name: &str, +) -> anyhow::Result { + let projects = device_auth::list_projects(base_url, mode, account_token).await?; + + let Some(default_project) = projects + .iter() + .find(|p| p.is_default) + .or_else(|| projects.first()) + else { + let name = prompt_project_name(&default_project_name())?; + return create_and_activate(base_url, mode, account_token, &name, device_name).await; + }; + + // Multiple projects: pick an existing one or create a new one. + if projects.len() > 1 { + let mut labels: Vec = projects + .iter() + .map(|p| { + let tag = if p.is_default { " (default)" } else { "" }; + format!("{} [{}]{tag}", p.name, p.slug) + }) + .collect(); + let create_index = labels.len(); + labels.push("Create a new project".to_string()); + + let default_index = projects.iter().position(|p| p.is_default).unwrap_or(0); + let selection = Select::with_theme(&*style::prompt_theme()) + .with_prompt("Select a project") + .items(&labels) + .default(default_index) + .interact()?; + + if selection == create_index { + let name = prompt_project_name(&default_project_name())?; + return create_and_activate(base_url, mode, account_token, &name, device_name).await; + } + + return activate_project( + base_url, + mode, + account_token, + &projects[selection], + device_name, + ) + .await; + } + + // Exactly one project (the default): reuse it unless the user renames. + let default_name = default_project.name.clone(); + let name = prompt_project_name(&default_name)?; + if name.trim().eq_ignore_ascii_case(default_name.trim()) { + return activate_project(base_url, mode, account_token, default_project, device_name).await; + } + + create_and_activate(base_url, mode, account_token, &name, device_name).await +} + +fn prompt_project_name(default: &str) -> anyhow::Result { + Ok(Input::with_theme(&*style::prompt_theme()) + .with_prompt("Project name") + .default(default.to_string()) + .interact_text()?) +} + fn save_active_project(project: &ProjectInfo, api_key: &str) -> anyhow::Result<()> { let path = config::config_path(); let mut cfg = read_config_from(&path).unwrap_or_default(); From ee86870d74374d4d2ff8ac724e1f159d13c175e3 Mon Sep 17 00:00:00 2001 From: nas Date: Fri, 12 Jun 2026 22:55:09 -0400 Subject: [PATCH 4/6] refactor(projects): use cached project info from config --- src/api/device_auth.rs | 49 +++------------------ src/api/projects.rs | 63 ++------------------------- src/commands/config.rs | 40 ++++++----------- src/commands/doctor.rs | 94 +++++++++++++++------------------------- src/commands/projects.rs | 6 ++- src/config/settings.rs | 3 ++ 6 files changed, 63 insertions(+), 192 deletions(-) diff --git a/src/api/device_auth.rs b/src/api/device_auth.rs index d3f68cf..46241c5 100644 --- a/src/api/device_auth.rs +++ b/src/api/device_auth.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use serde_json::json; use crate::api::client::SteelClient; +use crate::api::projects::{Project, parse_projects}; use crate::config::auth; use crate::config::settings::ApiMode; @@ -42,16 +43,6 @@ pub struct DeviceTokenSuccess { pub tos_required: bool, } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectPayload { - pub id: String, - pub slug: String, - pub name: String, - #[serde(default)] - pub is_default: bool, -} - /// Start a device authorization request. Returns the user code and verification URLs. pub async fn request_device_authorization(base_url: &str) -> anyhow::Result { let client = reqwest::Client::builder().user_agent("steel-cli").build()?; @@ -165,30 +156,14 @@ pub async fn list_projects( base_url: &str, mode: ApiMode, account_token: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let client = SteelClient::new()?; let data = client - .request( - base_url, - mode, - reqwest::Method::GET, - "/projects", - None, - &auth::account_token_auth(account_token), - ) + .get_projects(base_url, mode, &auth::account_token_auth(account_token)) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let projects = data - .get("projects") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - Ok(projects - .into_iter() - .filter_map(|p| serde_json::from_value(p).ok()) - .collect()) + Ok(parse_projects(&data)) } /// Create a new project. Requires an org admin account token (API returns 403 otherwise). @@ -197,7 +172,7 @@ pub async fn create_project( mode: ApiMode, account_token: &str, name: &str, -) -> anyhow::Result { +) -> anyhow::Result { let client = SteelClient::new()?; let data = client .request( @@ -311,18 +286,4 @@ mod tests { assert_eq!(parsed.org.id, "org-1"); assert_eq!(parsed.org.name, "Acme"); } - - #[test] - fn project_payload_deserializes_camel_case() { - let body = json!({ - "id": "proj-1", - "slug": "dev", - "name": "Dev", - "isDefault": true, - "isProduction": false - }); - let parsed: ProjectPayload = serde_json::from_value(body).unwrap(); - assert_eq!(parsed.id, "proj-1"); - assert!(parsed.is_default); - } } diff --git a/src/api/projects.rs b/src/api/projects.rs index 834be10..063b256 100644 --- a/src/api/projects.rs +++ b/src/api/projects.rs @@ -29,19 +29,6 @@ pub fn parse_projects(data: &Value) -> Vec { .unwrap_or_default() } -/// Pick the project an API key is scoped to. -/// A single project wins outright; among several, a sole default wins. -pub fn resolve_current_project(projects: &[Project]) -> Option<&Project> { - if let [only] = projects { - return Some(only); - } - let mut defaults = projects.iter().filter(|p| p.is_default); - match (defaults.next(), defaults.next()) { - (Some(default), None) => Some(default), - _ => None, - } -} - /// Human label for a project's production flag. pub const fn environment_label(is_production: bool) -> &'static str { if is_production { @@ -76,16 +63,6 @@ mod tests { use super::*; use serde_json::json; - fn project(name: &str, is_production: bool, is_default: bool) -> Project { - Project { - id: format!("id-{name}"), - name: name.into(), - slug: name.to_lowercase(), - is_production, - is_default, - } - } - // --- parse_projects --- #[test] @@ -141,40 +118,6 @@ mod tests { assert!(!projects[0].is_default); } - // --- resolve_current_project --- - - #[test] - fn resolve_single_project() { - let projects = vec![project("Solo", true, false)]; - assert_eq!(resolve_current_project(&projects).unwrap().name, "Solo"); - } - - #[test] - fn resolve_prefers_sole_default() { - let projects = vec![ - project("Staging", false, false), - project("Main", true, true), - ]; - assert_eq!(resolve_current_project(&projects).unwrap().name, "Main"); - } - - #[test] - fn resolve_ambiguous_returns_none() { - let projects = vec![project("A", false, false), project("B", true, false)]; - assert!(resolve_current_project(&projects).is_none()); - } - - #[test] - fn resolve_two_defaults_returns_none() { - let projects = vec![project("A", false, true), project("B", true, true)]; - assert!(resolve_current_project(&projects).is_none()); - } - - #[test] - fn resolve_empty_returns_none() { - assert!(resolve_current_project(&[]).is_none()); - } - // --- environment_label --- #[test] @@ -224,8 +167,8 @@ mod tests { .unwrap(); let projects = parse_projects(&data); - let current = resolve_current_project(&projects).unwrap(); - assert_eq!(current.slug, "default"); - assert_eq!(environment_label(current.is_production), "development"); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].slug, "default"); + assert_eq!(environment_label(projects[0].is_production), "development"); } } diff --git a/src/commands/config.rs b/src/commands/config.rs index 29acac7..7c222e8 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,11 +1,8 @@ -use std::time::Duration; - use clap::Parser; -use crate::api::client::SteelClient; -use crate::api::projects::{environment_label, parse_projects, resolve_current_project}; -use crate::config::auth::{self, Auth}; -use crate::config::settings::{ApiMode, read_config}; +use crate::api::projects::environment_label; +use crate::config::auth; +use crate::config::settings::{Config, read_config}; #[derive(Parser)] pub struct Args {} @@ -29,7 +26,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { println!("source: {}", auth.source); } - print_project_info(&auth).await; + print_project_info(config.as_ref()); if let Some(ref cfg) = config && let Some(ref browser) = cfg.browser @@ -45,26 +42,15 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { Ok(()) } -/// Print the project (environment) the API key is scoped to. -/// Best-effort: skipped in local mode, without a key, or on any fetch error, -/// so `steel config` stays usable offline. -async fn print_project_info(auth: &Auth) { - let (mode, base_url) = crate::util::api::resolve(); - if mode != ApiMode::Cloud || auth.api_key.is_none() { - return; - } - let Ok(client) = SteelClient::new() else { - return; - }; - - let fetch = client.get_projects(&base_url, mode, auth); - let Ok(Ok(data)) = tokio::time::timeout(Duration::from_secs(2), fetch).await else { +/// Print the active project (and its environment) recorded at login / selection. +fn print_project_info(config: Option<&Config>) { + let Some(project) = config.and_then(|c| c.project.as_ref()) else { return; }; - - let projects = parse_projects(&data); - if let Some(project) = resolve_current_project(&projects) { - println!("project: {} ({})", project.name, project.slug); - println!("environment: {}", environment_label(project.is_production)); - } + let name = project.name.as_deref().unwrap_or(&project.id); + println!( + "project: {name} ({})", + project.slug.as_deref().unwrap_or("") + ); + println!("environment: {}", environment_label(project.is_production)); } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 0968d9f..1c7e848 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -4,10 +4,10 @@ use clap::Parser; use serde_json::{Value, json}; use crate::api::client::{ApiError, SteelClient}; -use crate::api::projects::{environment_label, parse_projects, resolve_current_project}; +use crate::api::projects::environment_label; use crate::browser::daemon::process; use crate::config::auth::Auth; -use crate::config::settings::ApiMode; +use crate::config::settings::{ApiMode, ProjectInfo, read_config}; use crate::util::{output, style}; #[derive(Parser)] @@ -150,16 +150,7 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec { @@ -180,7 +171,11 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec Vec) -> Option { - let projects = parse_projects(data?); - let current = resolve_current_project(&projects)?; +/// Build the project/environment check from the active project recorded in config. +/// Returns `None` when no project has been selected yet. +fn project_check(project: Option<&ProjectInfo>) -> Option { + let project = project?; + let name = project.name.as_deref().unwrap_or(&project.id); + let slug = project.slug.as_deref().unwrap_or(""); Some(Check { category: "project", name: format!( - "Project \"{}\" ({}) — {}", - current.name, - current.slug, - environment_label(current.is_production) + "Project \"{name}\" ({slug}) — {}", + environment_label(project.is_production) ), status: "pass", detail: json!({ - "project_id": current.id, - "name": current.name, - "slug": current.slug, - "is_production": current.is_production, + "project_id": project.id, + "name": project.name, + "slug": project.slug, + "is_production": project.is_production, }), fix: None, transient: false, @@ -461,19 +455,20 @@ mod tests { assert_eq!(format_category("version"), "Version"); } + fn project_info(id: &str, slug: &str, name: &str, is_production: bool) -> ProjectInfo { + ProjectInfo { + id: id.into(), + slug: Some(slug.into()), + name: Some(name.into()), + is_production, + } + } + #[test] - fn project_check_resolves_single_project() { - let data = json!({ - "projects": [{ - "id": "p1", - "name": "Default project", - "slug": "default", - "isProduction": false, - "isDefault": true - }] - }); + fn project_check_renders_active_project() { + let project = project_info("p1", "default", "Default project", false); - let check = project_check(Some(&data)).unwrap(); + let check = project_check(Some(&project)).unwrap(); assert_eq!(check.category, "project"); assert_eq!(check.status, "pass"); assert_eq!( @@ -486,35 +481,16 @@ mod tests { #[test] fn project_check_production_label() { - let data = json!({ - "projects": [{ - "id": "p2", - "name": "Main", - "slug": "main", - "isProduction": true - }] - }); - - let check = project_check(Some(&data)).unwrap(); + let project = project_info("p2", "main", "Main", true); + let check = project_check(Some(&project)).unwrap(); assert_eq!(check.name, "Project \"Main\" (main) — production"); } #[test] - fn project_check_none_without_data() { + fn project_check_none_without_project() { assert!(project_check(None).is_none()); } - #[test] - fn project_check_none_when_ambiguous() { - let data = json!({ - "projects": [ - {"id": "a", "name": "A", "slug": "a"}, - {"id": "b", "name": "B", "slug": "b"} - ] - }); - assert!(project_check(Some(&data)).is_none()); - } - #[test] fn check_sessions_empty() { // When no daemons exist, should return a single pass check diff --git a/src/commands/projects.rs b/src/commands/projects.rs index 5b1460b..6165fd1 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -2,7 +2,8 @@ use clap::{Parser, Subcommand}; use dialoguer::{Input, Select}; use serde_json::json; -use crate::api::device_auth::{self, ProjectPayload}; +use crate::api::device_auth; +use crate::api::projects::Project; use crate::config; use crate::config::auth; use crate::config::settings::{ApiMode, ProjectInfo, read_config_from, write_config_to}; @@ -234,7 +235,7 @@ pub async fn activate_project( base_url: &str, mode: ApiMode, account_token: &str, - project: &ProjectPayload, + project: &Project, device_name: &str, ) -> anyhow::Result { let key = device_auth::create_project_api_key( @@ -250,6 +251,7 @@ pub async fn activate_project( id: project.id.clone(), slug: Some(project.slug.clone()), name: Some(project.name.clone()), + is_production: project.is_production, }; save_active_project(&info, &key)?; Ok(info) diff --git a/src/config/settings.rs b/src/config/settings.rs index fd625d5..a9a3bfb 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -143,6 +143,8 @@ pub struct ProjectInfo { pub slug: Option, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, + #[serde(default)] + pub is_production: bool, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -235,6 +237,7 @@ mod tests { id: "proj-1".into(), slug: Some("dev".into()), name: Some("Dev".into()), + is_production: false, }), browser: Some(BrowserConfig { api_url: Some("http://localhost:4000/v1".into()), From 289892516a2ef3733f16086e0e21ef17dd1b68bb Mon Sep 17 00:00:00 2001 From: nas Date: Fri, 12 Jun 2026 23:06:40 -0400 Subject: [PATCH 5/6] feat(commands): add colorized cli output --- src/commands/config.rs | 42 ++++++++++++++++++++++++++++++------- src/commands/projects.rs | 45 +++++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/commands/config.rs b/src/commands/config.rs index 7c222e8..1d1503e 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -3,6 +3,7 @@ use clap::Parser; use crate::api::projects::environment_label; use crate::config::auth; use crate::config::settings::{Config, read_config}; +use crate::util::style; #[derive(Parser)] pub struct Args {} @@ -16,14 +17,19 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { return Ok(()); } + if style::color_enabled() { + println!("{}", style::bold("Configuration")); + println!(); + } + if let Some(ref key) = auth.api_key { let masked = if key.len() > 7 { format!("{}...", &key[..7]) } else { key.clone() }; - println!("apiKey: {masked}"); - println!("source: {}", auth.source); + row("API key", "apiKey", &masked); + row("Source", "source", &auth.source.to_string()); } print_project_info(config.as_ref()); @@ -32,25 +38,45 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { && let Some(ref browser) = cfg.browser && let Some(ref url) = browser.api_url { - println!("browser.apiUrl: {url}"); + row("Browser API", "browser.apiUrl", url); } if let Some(ref cfg) = config { - println!("telemetry.disabled: {}", cfg.telemetry_disabled()); + let disabled = cfg.telemetry_disabled(); + if style::color_enabled() { + let state = if disabled { "disabled" } else { "enabled" }; + row("Telemetry", "telemetry.disabled", state); + } else { + // Keep the machine-parseable `key: value` form when piped. + println!("telemetry.disabled: {disabled}"); + } } Ok(()) } +/// Print one config row. In an interactive terminal the key is a dim, aligned +/// label; when piped it stays the original `key: value` form so scripts that +/// grep the output keep working. +fn row(label: &str, key: &str, value: &str) { + if style::color_enabled() { + println!(" {} {value}", style::dim(&format!("{label:<13}"))); + } else { + println!("{key}: {value}"); + } +} + /// Print the active project (and its environment) recorded at login / selection. fn print_project_info(config: Option<&Config>) { let Some(project) = config.and_then(|c| c.project.as_ref()) else { return; }; let name = project.name.as_deref().unwrap_or(&project.id); - println!( - "project: {name} ({})", - project.slug.as_deref().unwrap_or("") + let slug = project.slug.as_deref().unwrap_or(""); + row("Project", "project", &format!("{name} ({slug})")); + row( + "Environment", + "environment", + environment_label(project.is_production), ); - println!("environment: {}", environment_label(project.is_production)); } diff --git a/src/commands/projects.rs b/src/commands/projects.rs index 6165fd1..2f2fb0c 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -90,13 +90,24 @@ async fn run_list() -> anyhow::Result<()> { status!("No projects found."); } else { for p in &projects { - let marker = if active.as_deref() == Some(p.id.as_str()) { - "* " + let is_active = active.as_deref() == Some(p.id.as_str()); + let marker = if is_active { + style::green("●") } else { - " " + " ".to_string() }; - let default_tag = if p.is_default { " (default)" } else { "" }; - println!("{marker}{} [{}]{default_tag}", p.name, p.slug); + let name = if is_active { + style::bold(&p.name) + } else { + p.name.clone() + }; + let slug = style::dim(&format!("[{}]", p.slug)); + let default_tag = if p.is_default { + style::dim(" (default)") + } else { + String::new() + }; + println!("{marker} {name} {slug}{default_tag}"); } } @@ -132,9 +143,11 @@ async fn run_create(args: CreateArgs) -> anyhow::Result<()> { })); } else { status!( - "Created project '{}' and set it as active.", - project.name.as_deref().unwrap_or(&project.id) + "{} Created project {}.", + style::tick(), + style::bold(project.name.as_deref().unwrap_or(&project.id)) ); + status!("{}", style::dim("Now your active project.")); } Ok(()) @@ -183,8 +196,9 @@ async fn run_select(args: SelectArgs) -> anyhow::Result<()> { })); } else { status!( - "Active project is now '{}'.", - info.name.as_deref().unwrap_or(&info.id) + "{} Active project is now {}.", + style::tick(), + style::bold(info.name.as_deref().unwrap_or(&info.id)) ); } @@ -203,9 +217,16 @@ fn run_current() -> anyhow::Result<()> { })); } else { println!( - "{} [{}]", - project.name.as_deref().unwrap_or(&project.id), - project.slug.as_deref().unwrap_or("") + "{} {}", + style::bold(project.name.as_deref().unwrap_or(&project.id)), + style::dim(&format!("[{}]", project.slug.as_deref().unwrap_or(""))) + ); + println!( + "{}", + style::dim(&format!( + "environment: {}", + crate::api::projects::environment_label(project.is_production) + )) ); } } From a80ca1cf729d88c20f7b91ade547922dcc44d88d Mon Sep 17 00:00:00 2001 From: nas Date: Mon, 15 Jun 2026 18:26:05 -0400 Subject: [PATCH 6/6] feat(auth): rework init flow --- src/api/device_auth.rs | 61 +++++++++++++++++++++++++++++++++++-- src/commands/init/mod.rs | 37 ++++------------------- src/commands/login.rs | 65 ++++++++++++++++++++++++++++++++++++++++ src/commands/logout.rs | 1 + src/commands/projects.rs | 56 +++++++++++++++++++++++----------- src/config/settings.rs | 4 +++ 6 files changed, 171 insertions(+), 53 deletions(-) diff --git a/src/api/device_auth.rs b/src/api/device_auth.rs index 46241c5..13c888d 100644 --- a/src/api/device_auth.rs +++ b/src/api/device_auth.rs @@ -189,6 +189,12 @@ pub async fn create_project( serde_json::from_value(data).map_err(|e| anyhow::anyhow!("Unexpected project response: {e}")) } +#[derive(Debug, Clone)] +pub struct CreatedProjectKey { + pub id: String, + pub key: String, +} + /// Mint a project-scoped API key (used for browser/session work). pub async fn create_project_api_key( base_url: &str, @@ -196,7 +202,7 @@ pub async fn create_project_api_key( account_token: &str, project_id: &str, name: &str, -) -> anyhow::Result { +) -> anyhow::Result { let client = SteelClient::new()?; let data = client .request( @@ -210,10 +216,59 @@ pub async fn create_project_api_key( .await .map_err(|e| anyhow::anyhow!("{e}"))?; - data.get("key") + let key = data + .get("key") .and_then(|v| v.as_str()) .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("API key response did not include a key")) + .ok_or_else(|| anyhow::anyhow!("API key response did not include a key"))?; + let id = data + .get("id") + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("API key response did not include an id"))?; + + Ok(CreatedProjectKey { id, key }) +} + +pub async fn revoke_project_api_key( + base_url: &str, + mode: ApiMode, + account_token: &str, + api_key_id: &str, +) -> anyhow::Result<()> { + let client = SteelClient::new()?; + client + .request( + base_url, + mode, + reqwest::Method::DELETE, + &format!("/api-keys/{api_key_id}"), + None, + &auth::account_token_auth(account_token), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(()) +} + +pub async fn record_tos_acceptance( + base_url: &str, + mode: ApiMode, + account_token: &str, +) -> anyhow::Result<()> { + let client = SteelClient::new()?; + client + .request( + base_url, + mode, + reqwest::Method::POST, + "/auth/tos", + None, + &auth::account_token_auth(account_token), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(()) } /// Revoke the account token used for this request (server-side logout). diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 6b26a3a..0808baf 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -1,5 +1,4 @@ use clap::Parser; -use dialoguer::Confirm; use crate::commands::{doctor, login, projects, skills}; use crate::config; @@ -37,8 +36,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { let (mode, base_url) = api::resolve(); - // Step 1: authenticate (account-level CLI token). - let mut tos_required = false; + // Step 1: authenticate. let account_token = match auth::resolve_account_token() { Some(token) => { status!("{} Already logged in.", style::tick()); @@ -47,26 +45,20 @@ pub async fn run(args: Args) -> anyhow::Result<()> { None => { let outcome = login::authenticate(&base_url).await?; status!("{} Logged in to {}.", style::tick(), outcome.org_label()); - tos_required = outcome.tos_required; + login::enforce_tos(&base_url, mode, &outcome, args.agent).await?; outcome.account_token } }; - // Step 2: Terms of Service. Only prompt during first-time login; an - // already-authenticated user has already accepted them. - if tos_required && !confirm_tos(args.agent)? { - anyhow::bail!("You must accept the Terms of Service to continue."); - } - - // Step 3: project (create a named one, or reuse the active one). + // Step 2: project (create a named one, or reuse the active one). status!(""); setup_project(&base_url, mode, &account_token, args.agent).await?; - // Step 4: preflight check (verifies the new project API key works). + // Step 3: preflight check (verifies the new project API key works). status!(""); doctor::run(doctor::Args { preflight: true }).await?; - // Step 5: install Steel skills. + // Step 4: install Steel skills. status!(""); install_skills(&args).await?; @@ -144,25 +136,6 @@ async fn setup_project( Ok(()) } -fn confirm_tos(agent: bool) -> anyhow::Result { - let url = config::TOS_URL; - - if agent { - status!("Accepting the Terms of Service at {url}."); - return Ok(true); - } - - if !interactive() { - status!("By continuing you agree to the Terms of Service at {url}."); - return Ok(true); - } - - Ok(Confirm::with_theme(&*style::prompt_theme()) - .with_prompt(format!("Do you agree to our Terms of Service at {url}?")) - .default(true) - .interact()?) -} - fn interactive() -> bool { output::is_tty() && !output::is_json() } diff --git a/src/commands/login.rs b/src/commands/login.rs index ef16423..50d7cbe 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -52,6 +52,8 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { let outcome = authenticate(&base_url).await?; + enforce_tos(&base_url, mode, &outcome, false).await?; + let project = projects::ensure_active_project( &base_url, mode, @@ -165,6 +167,69 @@ fn save_account(token: &str, device_name: &str, org: &OrgPayload) -> anyhow::Res Ok(()) } +pub async fn enforce_tos( + base_url: &str, + mode: crate::config::settings::ApiMode, + outcome: &AuthOutcome, + auto_accept: bool, +) -> anyhow::Result<()> { + if !outcome.tos_required { + return Ok(()); + } + + if accept_tos_decision(auto_accept)? { + if let Err(e) = + device_auth::record_tos_acceptance(base_url, mode, &outcome.account_token).await + { + let _ = device_auth::revoke_account_token(base_url, mode, &outcome.account_token).await; + clear_saved_account()?; + anyhow::bail!( + "Could not record your Terms of Service acceptance ({e}). Run `steel login` to try again." + ); + } + return Ok(()); + } + + let _ = device_auth::revoke_account_token(base_url, mode, &outcome.account_token).await; + clear_saved_account()?; + anyhow::bail!( + "You must accept the Terms of Service to continue. Run `steel login` to try again." + ); +} + +fn accept_tos_decision(auto_accept: bool) -> anyhow::Result { + let url = config::TOS_URL; + + if auto_accept { + status!("Accepting the Terms of Service at {url}."); + return Ok(true); + } + + if !interactive() { + status!("By continuing you agree to the Terms of Service at {url}."); + return Ok(true); + } + + Ok(Confirm::with_theme(&*style::prompt_theme()) + .with_prompt(format!("Do you agree to our Terms of Service at {url}?")) + .default(true) + .interact()?) +} + +/// Clear the saved account token + derived credentials (used when ToS is declined). +fn clear_saved_account() -> anyhow::Result<()> { + let path = config::config_path(); + let mut cfg = read_config_from(&path).unwrap_or_default(); + cfg.account_token = None; + cfg.api_key = None; + cfg.api_key_id = None; + cfg.name = None; + cfg.org = None; + cfg.project = None; + write_config_to(&path, &cfg)?; + Ok(()) +} + fn interactive() -> bool { output::is_tty() && !output::is_json() } diff --git a/src/commands/logout.rs b/src/commands/logout.rs index 88c8043..3642af6 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -40,6 +40,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { cfg.account_token = None; cfg.api_key = None; + cfg.api_key_id = None; cfg.name = None; cfg.org = None; cfg.project = None; diff --git a/src/commands/projects.rs b/src/commands/projects.rs index 2f2fb0c..963ab97 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -251,7 +251,6 @@ pub async fn create_and_activate( activate_project(base_url, mode, account_token, &project, device_name).await } -/// Mint a project API key for `project` and persist it as the active project. pub async fn activate_project( base_url: &str, mode: ApiMode, @@ -259,7 +258,25 @@ pub async fn activate_project( project: &Project, device_name: &str, ) -> anyhow::Result { - let key = device_auth::create_project_api_key( + let path = config::config_path(); + let mut cfg = read_config_from(&path).unwrap_or_default(); + + let info = ProjectInfo { + id: project.id.clone(), + slug: Some(project.slug.clone()), + name: Some(project.name.clone()), + is_production: project.is_production, + }; + + // Re-activating the project that's already active: keep the existing key. + let already_active = cfg.project.as_ref().map(|p| p.id.as_str()) == Some(project.id.as_str()); + if already_active && cfg.api_key.as_deref().is_some_and(|k| !k.trim().is_empty()) { + cfg.project = Some(info.clone()); + write_config_to(&path, &cfg)?; + return Ok(info); + } + + let created = device_auth::create_project_api_key( base_url, mode, account_token, @@ -268,13 +285,25 @@ pub async fn activate_project( ) .await?; - let info = ProjectInfo { - id: project.id.clone(), - slug: Some(project.slug.clone()), - name: Some(project.name.clone()), - is_production: project.is_production, - }; - save_active_project(&info, &key)?; + let previous_key_id = cfg.api_key_id.clone(); + + cfg.project = Some(info.clone()); + cfg.api_key = Some(created.key); + cfg.api_key_id = Some(created.id.clone()); + write_config_to(&path, &cfg)?; + + // Best-effort: revoke the key we just replaced. Never fail activation over it. + if let Some(prev_id) = previous_key_id + && prev_id != created.id + && let Err(e) = + device_auth::revoke_project_api_key(base_url, mode, account_token, &prev_id).await + { + status!( + "{} Could not revoke the previous project key: {e}", + style::bang() + ); + } + Ok(info) } @@ -377,15 +406,6 @@ fn prompt_project_name(default: &str) -> anyhow::Result { .interact_text()?) } -fn save_active_project(project: &ProjectInfo, api_key: &str) -> anyhow::Result<()> { - let path = config::config_path(); - let mut cfg = read_config_from(&path).unwrap_or_default(); - cfg.project = Some(project.clone()); - cfg.api_key = Some(api_key.to_string()); - write_config_to(&path, &cfg)?; - Ok(()) -} - fn stored_device_name() -> String { read_config_from(&config::config_path()) .ok() diff --git a/src/config/settings.rs b/src/config/settings.rs index a9a3bfb..89dd2ca 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -105,6 +105,8 @@ pub struct Config { /// Active project API key, used for browser/session work. #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_id: Option, /// Account-level CLI token (manages projects and project API keys). #[serde(skip_serializing_if = "Option::is_none")] pub account_token: Option, @@ -225,6 +227,7 @@ mod tests { let config = Config { api_key: Some("sk-test-123".into()), + api_key_id: Some("key-1".into()), account_token: Some("ste-cli-abc".into()), name: Some("CLI".into()), instance: Some("cloud".into()), @@ -251,6 +254,7 @@ mod tests { let loaded = read_config_from(&path).unwrap(); assert_eq!(loaded.api_key.as_deref(), Some("sk-test-123")); + assert_eq!(loaded.api_key_id.as_deref(), Some("key-1")); assert_eq!(loaded.account_token.as_deref(), Some("ste-cli-abc")); assert_eq!(loaded.name.as_deref(), Some("CLI")); assert_eq!(loaded.instance.as_deref(), Some("cloud"));