This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-cli/src/http.rs
T
naps62-yolo efaa8bfe13 feat(maestro): wire maestro-cli to unified config (NAP-233)
CLI now reads `[shared].registry_url` and `[cli].cookie` from the unified
file via maestro-config instead of `~/.config/maestro/cli.toml`. The
two persistence paths are now distinct:

- `maestro set-registry <url>` → writes `[shared].registry_url`
- captured Set-Cookie on first contact → writes `[cli].cookie`

Both go through read-modify-write so they preserve any `[daemon]` /
`[registry]` content already in the file.

Env-var renames on the CLI flag layer:
- `--registry-url` reads `MAESTRO_SHARED_REGISTRY_URL` (was `MAESTRO_REGISTRY_URL`)
- `--config`       reads `MAESTRO_CONFIG`               (was `MAESTRO_CLI_CONFIG`)

Default config path is now `~/.config/maestro/config.toml`.
2026-05-26 09:24:51 +00:00

175 lines
5.5 KiB
Rust

//! Thin HTTP client wrapper around `reqwest` that persists the
//! `maestro_session` cookie issued by the registry's cookie middleware.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::config::{CliConfig, ConfigError};
const COOKIE_NAME: &str = "maestro_session";
#[derive(Debug, thiserror::Error)]
pub enum HttpError {
#[error("http: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("server returned {status}: {body}")]
ServerError { status: StatusCode, body: String },
#[error("decode: {0}")]
Json(#[from] serde_json::Error),
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("invalid header: {0}")]
InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
}
/// HTTP client bound to a single registry. Persists the auth cookie back to
/// disk when the registry sets one (first-contact bootstrap from
/// `crates/maestro-registry/src/auth.rs`).
#[derive(Debug)]
pub struct RegistryClient {
base: String,
inner: reqwest::Client,
/// Cookie value (the JWT body), shared with the config-persister.
cookie: Arc<Mutex<Option<String>>>,
/// Where to write the config back when the cookie changes.
config_path: PathBuf,
}
impl RegistryClient {
pub fn new(base: String, cookie: Option<String>, config_path: PathBuf) -> Self {
let inner = reqwest::Client::builder()
.user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION")))
.build()
.expect("build reqwest client");
Self {
base: base.trim_end_matches('/').to_string(),
inner,
cookie: Arc::new(Mutex::new(cookie)),
config_path,
}
}
pub fn base(&self) -> &str {
&self.base
}
/// Snapshot of the current cookie (mostly for tests).
pub fn current_cookie(&self) -> Option<String> {
self.cookie.lock().unwrap().clone()
}
/// Send a request, attaching the persisted cookie. If the response sets a
/// new cookie (first contact, or a rotation), persist it back to disk.
async fn send(
&self,
method: Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<Response, HttpError> {
let url = format!("{}{}", self.base, path);
let mut headers = HeaderMap::new();
if let Some(c) = self.cookie.lock().unwrap().as_ref() {
headers.insert(
COOKIE,
HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?,
);
}
let mut req = self.inner.request(method, &url).headers(headers);
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
self.capture_cookie(resp.headers())?;
Ok(resp)
}
fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> {
let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else {
return Ok(());
};
let Some(val) = extract_cookie_value(raw, COOKIE_NAME) else {
return Ok(());
};
let mut guard = self.cookie.lock().unwrap();
if guard.as_deref() == Some(val) {
return Ok(());
}
*guard = Some(val.to_string());
// Persist; on failure, log but don't abort the request.
if let Err(e) = CliConfig::save_cookie(&self.config_path, val) {
tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie");
}
Ok(())
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
let resp = self.send(Method::GET, path, None).await?;
ok_or_body(resp)
.await?
.json::<T>()
.await
.map_err(Into::into)
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, HttpError> {
let v = serde_json::to_value(body)?;
let resp = self.send(Method::POST, path, Some(v)).await?;
ok_or_body(resp)
.await?
.json::<T>()
.await
.map_err(Into::into)
}
pub async fn delete(&self, path: &str) -> Result<(), HttpError> {
let resp = self.send(Method::DELETE, path, None).await?;
ok_or_body(resp).await?;
Ok(())
}
}
async fn ok_or_body(resp: Response) -> Result<Response, HttpError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
Err(HttpError::ServerError { status, body })
}
/// Extract `name=value` from a `Set-Cookie:` value. Ignores attributes.
pub fn extract_cookie_value<'a>(raw: &'a str, name: &str) -> Option<&'a str> {
let first = raw.split(';').next()?.trim();
let (k, v) = first.split_once('=')?;
(k.trim() == name).then(|| v.trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_named_cookie_value() {
let raw = "maestro_session=abc.def.ghi; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000";
assert_eq!(
extract_cookie_value(raw, "maestro_session"),
Some("abc.def.ghi")
);
}
#[test]
fn returns_none_for_other_cookie_name() {
let raw = "other=1; HttpOnly";
assert_eq!(extract_cookie_value(raw, "maestro_session"), None);
}
}