Skip to content

Commit e74558e

Browse files
feat: auto-resolve org slug from API token (#32)
When SOCKET_API_TOKEN is set but no org slug is provided (via --org or SOCKET_ORG_SLUG), automatically resolve it by querying GET /v0/organizations. This removes the hard error in get and scan commands, and enables telemetry in apply, rollback, and remove to use the authenticated endpoint. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad6465e commit e74558e

File tree

8 files changed

+123
-50
lines changed

8 files changed

+123
-50
lines changed

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ pub struct ApplyArgs {
4949
}
5050

5151
pub async fn run(args: ApplyArgs) -> i32 {
52-
let api_token = std::env::var("SOCKET_API_TOKEN").ok();
53-
let org_slug = std::env::var("SOCKET_ORG_SLUG").ok();
52+
let (telemetry_client, _) = get_api_client_from_env(None).await;
53+
let api_token = telemetry_client.api_token().cloned();
54+
let org_slug = telemetry_client.org_slug().cloned();
5455

5556
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
5657
PathBuf::from(&args.manifest_path)
@@ -156,7 +157,7 @@ async fn apply_patches_inner(
156157
println!("Downloading {} missing blob(s)...", missing_blobs.len());
157158
}
158159

159-
let (client, _) = get_api_client_from_env(None);
160+
let (client, _) = get_api_client_from_env(None).await;
160161
let fetch_result = fetch_missing_blobs(&manifest, &blobs_path, &client, None).await;
161162

162163
if !args.silent {

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,12 @@ pub async fn run(args: GetArgs) -> i32 {
121121
std::env::set_var("SOCKET_API_TOKEN", token);
122122
}
123123

124-
let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref());
124+
let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref()).await;
125125

126-
if !use_public_proxy && args.org.is_none() {
127-
eprintln!("Error: --org is required when using SOCKET_API_TOKEN. Provide an organization slug.");
128-
return 1;
129-
}
130-
131-
let effective_org_slug = if use_public_proxy {
126+
let effective_org_slug: Option<&str> = if use_public_proxy {
132127
None
133128
} else {
134-
args.org.as_deref()
129+
None // org slug is already stored in the client
135130
};
136131

137132
// Determine identifier type
@@ -517,12 +512,8 @@ async fn save_and_apply_patch(
517512
_org_slug: Option<&str>,
518513
) -> i32 {
519514
// For UUID mode, fetch and save
520-
let (api_client, _) = get_api_client_from_env(args.org.as_deref());
521-
let effective_org = if args.org.is_some() {
522-
args.org.as_deref()
523-
} else {
524-
None
525-
};
515+
let (api_client, _) = get_api_client_from_env(args.org.as_deref()).await;
516+
let effective_org: Option<&str> = None; // org slug is already stored in the client
526517

527518
let patch = match api_client.fetch_patch(effective_org, uuid).await {
528519
Ok(Some(p)) => p,

crates/socket-patch-cli/src/commands/remove.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ pub struct RemoveArgs {
3535
}
3636

3737
pub async fn run(args: RemoveArgs) -> i32 {
38-
let api_token = std::env::var("SOCKET_API_TOKEN").ok();
39-
let org_slug = std::env::var("SOCKET_ORG_SLUG").ok();
38+
let (telemetry_client, _) =
39+
socket_patch_core::api::client::get_api_client_from_env(None).await;
40+
let api_token = telemetry_client.api_token().cloned();
41+
let org_slug = telemetry_client.org_slug().cloned();
4042

4143
let manifest_path = if Path::new(&args.manifest_path).is_absolute() {
4244
PathBuf::from(&args.manifest_path)

crates/socket-patch-cli/src/commands/repair.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async fn repair_inner(args: &RepairArgs, manifest_path: &Path) -> Result<(), Str
7878
}
7979
} else {
8080
println!("\nDownloading missing blobs...");
81-
let (client, _) = get_api_client_from_env(None);
81+
let (client, _) = get_api_client_from_env(None).await;
8282
let fetch_result = fetch_missing_blobs(&manifest, &blobs_path, &client, None).await;
8383
println!("{}", format_fetch_result(&fetch_result));
8484
}

crates/socket-patch-cli/src/commands/rollback.rs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -136,29 +136,24 @@ async fn get_missing_before_blobs(
136136
}
137137

138138
pub async fn run(args: RollbackArgs) -> i32 {
139-
let api_token = args
140-
.api_token
141-
.clone()
142-
.or_else(|| std::env::var("SOCKET_API_TOKEN").ok());
143-
let org_slug = args
144-
.org
145-
.clone()
146-
.or_else(|| std::env::var("SOCKET_ORG_SLUG").ok());
147-
148-
// Validate one-off requires identifier
149-
if args.one_off && args.identifier.is_none() {
150-
eprintln!("Error: --one-off requires an identifier (UUID or PURL)");
151-
return 1;
152-
}
153-
154-
// Override env vars if CLI options provided
139+
// Override env vars if CLI options provided (before building client)
155140
if let Some(ref url) = args.api_url {
156141
std::env::set_var("SOCKET_API_URL", url);
157142
}
158143
if let Some(ref token) = args.api_token {
159144
std::env::set_var("SOCKET_API_TOKEN", token);
160145
}
161146

147+
let (telemetry_client, _) = get_api_client_from_env(args.org.as_deref()).await;
148+
let api_token = telemetry_client.api_token().cloned();
149+
let org_slug = telemetry_client.org_slug().cloned();
150+
151+
// Validate one-off requires identifier
152+
if args.one_off && args.identifier.is_none() {
153+
eprintln!("Error: --one-off requires an identifier (UUID or PURL)");
154+
return 1;
155+
}
156+
162157
// Handle one-off mode
163158
if args.one_off {
164159
// One-off mode not fully implemented yet - placeholder
@@ -314,7 +309,7 @@ async fn rollback_patches_inner(
314309
println!("Downloading {} missing blob(s)...", missing_blobs.len());
315310
}
316311

317-
let (client, _) = get_api_client_from_env(None);
312+
let (client, _) = get_api_client_from_env(None).await;
318313
let fetch_result = fetch_blobs_by_hash(&missing_blobs, &blobs_path, &client, None).await;
319314

320315
if !args.silent {

crates/socket-patch-cli/src/commands/scan.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,12 @@ pub async fn run(args: ScanArgs) -> i32 {
5151
std::env::set_var("SOCKET_API_TOKEN", token);
5252
}
5353

54-
let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref());
54+
let (api_client, use_public_proxy) = get_api_client_from_env(args.org.as_deref()).await;
5555

56-
if !use_public_proxy && args.org.is_none() {
57-
eprintln!("Error: --org is required when using SOCKET_API_TOKEN. Provide an organization slug.");
58-
return 1;
59-
}
60-
61-
let effective_org_slug = if use_public_proxy {
56+
let effective_org_slug: Option<&str> = if use_public_proxy {
6257
None
6358
} else {
64-
args.org.as_deref()
59+
None // org slug is already stored in the client
6560
};
6661

6762
let crawler_options = CrawlerOptions {

crates/socket-patch-core/src/api/client.rs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ impl ApiClient {
111111
}
112112
}
113113

114+
/// Returns the API token, if set.
115+
pub fn api_token(&self) -> Option<&String> {
116+
self.api_token.as_ref()
117+
}
118+
119+
/// Returns the org slug, if set.
120+
pub fn org_slug(&self) -> Option<&String> {
121+
self.org_slug.as_ref()
122+
}
123+
114124
// ── Internal helpers ──────────────────────────────────────────────
115125

116126
/// Internal GET that deserialises JSON. Returns `Ok(None)` on 404.
@@ -397,6 +407,46 @@ impl ApiClient {
397407
})
398408
}
399409

410+
/// Fetch organizations accessible to the current API token.
411+
pub async fn fetch_organizations(
412+
&self,
413+
) -> Result<Vec<crate::api::types::OrganizationInfo>, ApiError> {
414+
let path = "/v0/organizations";
415+
match self
416+
.get_json::<crate::api::types::OrganizationsResponse>(path)
417+
.await?
418+
{
419+
Some(resp) => Ok(resp.organizations.into_values().collect()),
420+
None => Ok(Vec::new()),
421+
}
422+
}
423+
424+
/// Resolve the org slug from the API token by querying `/v0/organizations`.
425+
///
426+
/// If there is exactly one org, returns its slug.
427+
/// If there are multiple, picks the first and prints a warning.
428+
/// If there are none, returns an error.
429+
pub async fn resolve_org_slug(&self) -> Result<String, ApiError> {
430+
let orgs = self.fetch_organizations().await?;
431+
match orgs.len() {
432+
0 => Err(ApiError::Other(
433+
"No organizations found for this API token.".into(),
434+
)),
435+
1 => Ok(orgs.into_iter().next().unwrap().slug),
436+
_ => {
437+
let slugs: Vec<_> = orgs.iter().map(|o| o.slug.as_str()).collect();
438+
let first = orgs[0].slug.clone();
439+
eprintln!(
440+
"Multiple organizations found: {}. Using \"{}\". \
441+
Pass --org to select a different one.",
442+
slugs.join(", "),
443+
first
444+
);
445+
Ok(first)
446+
}
447+
}
448+
}
449+
400450
/// Fetch a blob by its SHA-256 hash.
401451
///
402452
/// Returns the raw binary content, or `Ok(None)` if not found.
@@ -490,6 +540,10 @@ impl ApiClient {
490540
/// API proxy which provides free access to free-tier patches without
491541
/// authentication.
492542
///
543+
/// When `SOCKET_API_TOKEN` is set but no org slug is provided (neither via
544+
/// argument nor `SOCKET_ORG_SLUG` env var), the function will attempt to
545+
/// auto-resolve the org slug by querying `GET /v0/organizations`.
546+
///
493547
/// # Environment variables
494548
///
495549
/// | Variable | Purpose |
@@ -500,7 +554,7 @@ impl ApiClient {
500554
/// | `SOCKET_ORG_SLUG` | Organization slug |
501555
///
502556
/// Returns `(client, use_public_proxy)`.
503-
pub fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool) {
557+
pub async fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool) {
504558
let api_token = std::env::var("SOCKET_API_TOKEN").ok();
505559
let resolved_org_slug = org_slug
506560
.map(String::from)
@@ -524,11 +578,30 @@ pub fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool) {
524578
let api_url =
525579
std::env::var("SOCKET_API_URL").unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string());
526580

581+
// Auto-resolve org slug if not provided
582+
let final_org_slug = if resolved_org_slug.is_some() {
583+
resolved_org_slug
584+
} else {
585+
let temp_client = ApiClient::new(ApiClientOptions {
586+
api_url: api_url.clone(),
587+
api_token: api_token.clone(),
588+
use_public_proxy: false,
589+
org_slug: None,
590+
});
591+
match temp_client.resolve_org_slug().await {
592+
Ok(slug) => Some(slug),
593+
Err(e) => {
594+
eprintln!("Warning: Could not auto-detect organization: {e}");
595+
None
596+
}
597+
}
598+
};
599+
527600
let client = ApiClient::new(ApiClientOptions {
528601
api_url,
529602
api_token,
530603
use_public_proxy: false,
531-
org_slug: resolved_org_slug,
604+
org_slug: final_org_slug,
532605
});
533606
(client, false)
534607
}
@@ -714,11 +787,11 @@ mod tests {
714787
assert_eq!(info.title, "Test vulnerability");
715788
}
716789

717-
#[test]
718-
fn test_get_api_client_from_env_no_token() {
790+
#[tokio::test]
791+
async fn test_get_api_client_from_env_no_token() {
719792
// Clear token to ensure public proxy mode
720793
std::env::remove_var("SOCKET_API_TOKEN");
721-
let (client, is_public) = get_api_client_from_env(None);
794+
let (client, is_public) = get_api_client_from_env(None).await;
722795
assert!(is_public);
723796
assert!(client.use_public_proxy);
724797
}

crates/socket-patch-core/src/api/types.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
use serde::{Deserialize, Serialize};
22
use std::collections::HashMap;
33

4+
/// Organization info returned by the `/v0/organizations` endpoint.
5+
#[derive(Debug, Clone, Deserialize)]
6+
pub struct OrganizationInfo {
7+
pub id: String,
8+
pub name: Option<String>,
9+
pub image: Option<String>,
10+
pub plan: String,
11+
pub slug: String,
12+
}
13+
14+
/// Response from `GET /v0/organizations`.
15+
#[derive(Debug, Clone, Deserialize)]
16+
pub struct OrganizationsResponse {
17+
pub organizations: HashMap<String, OrganizationInfo>,
18+
}
19+
420
/// Full patch response with blob content (from view endpoint).
521
#[derive(Debug, Clone, Serialize, Deserialize)]
622
#[serde(rename_all = "camelCase")]

0 commit comments

Comments
 (0)