diff --git a/src/api/mod.rs b/src/api/mod.rs index 1c8c5f5..00ceb84 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod client; pub mod generated; +pub mod projects; pub mod session; pub mod top_level; diff --git a/src/api/projects.rs b/src/api/projects.rs new file mode 100644 index 0000000..834be10 --- /dev/null +++ b/src/api/projects.rs @@ -0,0 +1,231 @@ +// ABOUTME: Steel API /projects bindings and current-environment resolution. +// ABOUTME: Reports which project an API key is scoped to and whether it is production. + +use serde::Deserialize; +use serde_json::Value; + +use crate::api::client::{ApiError, SteelClient}; +use crate::config::auth::Auth; +use crate::config::settings::ApiMode; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Project { + pub id: String, + pub name: String, + pub slug: String, + #[serde(default)] + pub is_production: bool, + #[serde(default)] + pub is_default: bool, +} + +/// Parse the `/projects` response body into a list of projects. +/// Returns an empty list when the body has no parsable `projects` array. +pub fn parse_projects(data: &Value) -> Vec { + data.get("projects") + .cloned() + .and_then(|v| serde_json::from_value(v).ok()) + .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 { + "production" + } else { + "development" + } +} + +impl SteelClient { + /// Fetch the projects visible to the current API key. + pub async fn get_projects( + &self, + base_url: &str, + mode: ApiMode, + auth: &Auth, + ) -> Result { + self.request( + base_url, + mode, + reqwest::Method::GET, + "/projects", + None, + auth, + ) + .await + } +} + +#[cfg(test)] +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] + fn parse_full_body() { + let data = json!({ + "projects": [{ + "id": "b4b8ce24-70cc-45aa-9233-c5e01fe24168", + "name": "Default project", + "slug": "default", + "isProduction": false, + "isDefault": true, + "promotedToProductionAt": null, + "createdAt": "2026-06-07T12:14:56.913Z", + "updatedAt": "2026-06-07T12:14:56.913Z" + }] + }); + + let projects = parse_projects(&data); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "Default project"); + assert_eq!(projects[0].slug, "default"); + assert!(!projects[0].is_production); + assert!(projects[0].is_default); + } + + #[test] + fn parse_empty_list() { + let data = json!({"projects": []}); + assert!(parse_projects(&data).is_empty()); + } + + #[test] + fn parse_missing_key() { + let data = json!({"sessions": []}); + assert!(parse_projects(&data).is_empty()); + } + + #[test] + fn parse_malformed_entries() { + let data = json!({"projects": [{"id": 42}]}); + assert!(parse_projects(&data).is_empty()); + } + + #[test] + fn parse_missing_flags_default_false() { + let data = json!({ + "projects": [{"id": "x", "name": "Bare", "slug": "bare"}] + }); + + let projects = parse_projects(&data); + assert_eq!(projects.len(), 1); + assert!(!projects[0].is_production); + 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] + fn label_production() { + assert_eq!(environment_label(true), "production"); + } + + #[test] + fn label_development() { + assert_eq!(environment_label(false), "development"); + } + + // --- get_projects against a mock server --- + + use crate::config::auth::AuthSource; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn get_projects_success() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/projects")) + .and(header("Steel-Api-Key", "test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "projects": [{ + "id": "p1", + "name": "Default project", + "slug": "default", + "isProduction": false, + "isDefault": true + }] + }))) + .mount(&server) + .await; + + let client = SteelClient::new().unwrap(); + let auth = Auth { + api_key: Some("test-key".into()), + source: AuthSource::Env, + }; + + let data = client + .get_projects(&server.uri(), ApiMode::Cloud, &auth) + .await + .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"); + } +} diff --git a/src/commands/config.rs b/src/commands/config.rs index ce1c9ef..29acac7 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,7 +1,11 @@ +use std::time::Duration; + use clap::Parser; -use crate::config::auth; -use crate::config::settings::read_config; +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}; #[derive(Parser)] pub struct Args {} @@ -25,6 +29,8 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { println!("source: {}", auth.source); } + print_project_info(&auth).await; + if let Some(ref cfg) = config && let Some(ref browser) = cfg.browser && let Some(ref url) = browser.api_url @@ -38,3 +44,27 @@ 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 { + 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)); + } +} diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index b3ba6e0..1cd9dd6 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -4,6 +4,7 @@ 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::browser::daemon::process; use crate::config::auth::Auth; use crate::config::settings::ApiMode; @@ -41,7 +42,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { }; // Sort by category order - const ORDER: &[&str] = &["auth", "api", "sessions", "version"]; + const ORDER: &[&str] = &["auth", "project", "api", "sessions", "version"]; checks.sort_by_key(|c| ORDER.iter().position(|&o| o == c.category).unwrap_or(99)); let has_fail = checks.iter().any(|c| c.status == "fail"); @@ -135,18 +136,30 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec { @@ -167,6 +180,9 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec { @@ -224,6 +240,31 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec) -> Option { + let projects = parse_projects(data?); + let current = resolve_current_project(&projects)?; + Some(Check { + category: "project", + name: format!( + "Project \"{}\" ({}) — {}", + current.name, + current.slug, + environment_label(current.is_production) + ), + status: "pass", + detail: json!({ + "project_id": current.id, + "name": current.name, + "slug": current.slug, + "is_production": current.is_production, + }), + fix: None, + transient: false, + }) +} + fn check_sessions() -> Vec { let names = process::list_daemon_names(); let mut active = Vec::new(); @@ -361,6 +402,7 @@ fn print_text(checks: &[Check]) { fn format_category(s: &str) -> &str { match s { "auth" => "Auth", + "project" => "Project", "api" => "API", "sessions" => "Sessions", "version" => "Version", @@ -421,11 +463,66 @@ mod tests { #[test] fn format_categories() { assert_eq!(format_category("auth"), "Auth"); + assert_eq!(format_category("project"), "Project"); assert_eq!(format_category("api"), "API"); assert_eq!(format_category("sessions"), "Sessions"); assert_eq!(format_category("version"), "Version"); } + #[test] + fn project_check_resolves_single_project() { + let data = json!({ + "projects": [{ + "id": "p1", + "name": "Default project", + "slug": "default", + "isProduction": false, + "isDefault": true + }] + }); + + let check = project_check(Some(&data)).unwrap(); + assert_eq!(check.category, "project"); + assert_eq!(check.status, "pass"); + assert_eq!( + check.name, + "Project \"Default project\" (default) — development" + ); + assert_eq!(check.detail["is_production"], json!(false)); + assert_eq!(check.detail["project_id"], json!("p1")); + } + + #[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(); + assert_eq!(check.name, "Project \"Main\" (main) — production"); + } + + #[test] + fn project_check_none_without_data() { + 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