From 4491cc60c2112c9e2d5d7427d3c5e5b852d86d5e Mon Sep 17 00:00:00 2001 From: nkkko Date: Fri, 12 Jun 2026 11:05:29 +0200 Subject: [PATCH] feat(cli): surface project environment in config and doctor Users had no way to tell which project (environment) their API key is scoped to, or whether it is production. The API exposes this via GET /projects (name, slug, isProduction), so: - add src/api/projects.rs: /projects binding, response parsing, and current-project resolution (single project wins; among several, a sole default wins; ambiguity resolves to nothing rather than a guess) - steel doctor: new "Project" check after auth, fetched concurrently with the auth probe and silently skipped on error so doctor never degrades because of an informational check - steel config: print project name/slug and environment label, best effort with a 2s timeout so the command stays usable offline Co-Authored-By: Claude Fable 5 --- src/api/mod.rs | 1 + src/api/projects.rs | 231 +++++++++++++++++++++++++++++++++++++++++ src/commands/config.rs | 34 +++++- src/commands/doctor.rs | 123 +++++++++++++++++++--- 4 files changed, 374 insertions(+), 15 deletions(-) create mode 100644 src/api/projects.rs 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