use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const DEFAULT_API_BASE: &str = "https://api.offlineprotocol.com/api/v1";
pub const DEFAULT_PORTAL_BASE: &str = "https://dev.offlineprotocol.com";
pub const LOCAL_API_BASE: &str = "http://localhost:3010/api/v1";
pub const LOCAL_PORTAL_BASE: &str = "http://localhost:3000";
pub const ENV_API_KEY: &str = "OFFLINE_API_KEY";
pub const ENV_ACCESS_TOKEN: &str = "OFFLINE_ACCESS_TOKEN";
pub const ENV_APP_ID: &str = "OFFLINE_APP_ID";
pub const ENV_ORG_ID: &str = "OFFLINE_ORG_ID";
pub const ENV_API_BASE: &str = "OFFLINE_API_BASE";
pub const ENV_PORTAL_BASE: &str = "OFFLINE_PORTAL_BASE";
pub const ENV_CONFIG_DIR: &str = "OFFLINE_CONFIG_DIR";
pub const ENV_ENV: &str = "OFFLINE_ENV";
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct CliConfig {
#[serde(default, skip_serializing_if = "Option::is_none", alias = "apiKey")]
pub api_key: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "accessToken"
)]
pub access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "devId")]
pub dev_id: Option<i32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "appId",
alias = "applicationId",
alias = "application_id",
alias = "projectId",
alias = "project_id"
)]
pub app_id: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "orgId",
alias = "organizationId",
alias = "organization_id",
alias = "teamId",
alias = "team_id"
)]
pub org_id: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub app_keys: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "apiBase")]
pub api_base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "portalBase")]
pub portal_base: Option<String>,
}
impl CliConfig {
pub fn dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(ENV_CONFIG_DIR) {
return Ok(PathBuf::from(dir));
}
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".offline"))
}
pub fn path() -> Result<PathBuf> {
Ok(Self::dir()?.join("config.json"))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
reject_symlink(&path)?;
if !path.exists() {
return Ok(Self::default());
}
let data = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&data).with_context(|| format!("parsing {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let dir = Self::dir()?;
prepare_config_dir(&dir, std::env::var_os(ENV_CONFIG_DIR).is_none())?;
let path = Self::path()?;
reject_symlink(&path)?;
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join(format!(
".config.json.{}.{:016x}.tmp",
std::process::id(),
rand::random::<u64>()
));
let written = write_new_private_file(&tmp, data.as_bytes()).and_then(|()| {
std::fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))
});
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
pub fn delete_config_file() -> Result<()> {
let path = Self::path()?;
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
}
Ok(())
}
pub fn effective_api_key(&self) -> Option<String> {
std::env::var(ENV_API_KEY)
.ok()
.filter(|k| !k.is_empty())
.or_else(|| self.api_key.clone())
}
pub fn effective_access_token(&self) -> Option<String> {
std::env::var(ENV_ACCESS_TOKEN)
.ok()
.filter(|k| !k.is_empty())
.or_else(|| self.access_token.clone())
}
pub fn effective_app_id(&self) -> Option<String> {
std::env::var(ENV_APP_ID)
.ok()
.filter(|k| !k.is_empty())
.or_else(|| self.app_id.clone())
}
pub fn effective_org_id(&self) -> Option<String> {
std::env::var(ENV_ORG_ID)
.ok()
.filter(|k| !k.is_empty())
.or_else(|| self.org_id.clone())
}
pub fn effective_api_base(&self) -> String {
if let Ok(base) = std::env::var(ENV_API_BASE) {
if !base.is_empty() {
return normalize_api_base(&base);
}
}
if is_local_env() {
return LOCAL_API_BASE.to_string();
}
normalize_api_base(
&self
.api_base
.clone()
.unwrap_or_else(|| DEFAULT_API_BASE.to_string()),
)
}
pub fn effective_portal_base(&self) -> String {
if let Ok(base) = std::env::var(ENV_PORTAL_BASE) {
if !base.is_empty() {
return base;
}
}
if is_local_env() {
return LOCAL_PORTAL_BASE.to_string();
}
self.portal_base
.clone()
.unwrap_or_else(|| DEFAULT_PORTAL_BASE.to_string())
}
pub fn has_stored_credentials(&self) -> bool {
self.api_key.is_some() || self.access_token.is_some()
}
pub fn has_api_credentials(&self) -> bool {
self.effective_api_key().is_some() && self.effective_app_id().is_some()
}
}
fn reject_symlink(path: &std::path::Path) -> Result<()> {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
anyhow::bail!(
"{} is a symbolic link; the CLI will not read or write credentials through it. \
Remove it and sign in again.",
path.display()
);
}
}
Ok(())
}
fn prepare_config_dir(dir: &std::path::Path, tighten: bool) -> Result<()> {
if !dir.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("creating {}", dir.display()))?;
}
#[cfg(not(unix))]
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let meta = std::fs::metadata(dir).with_context(|| format!("reading {}", dir.display()))?;
if !meta.is_dir() {
anyhow::bail!("{} is not a directory", dir.display());
}
let uid = unsafe { libc::geteuid() };
if meta.uid() != uid {
anyhow::bail!(
"{} belongs to another user; the CLI will not store credentials there",
dir.display()
);
}
let mode = meta.permissions().mode() & 0o777;
if tighten && mode & 0o077 != 0 {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("restricting {}", dir.display()))?;
} else if mode & 0o022 != 0 {
anyhow::bail!(
"{} is writable by other users (mode {:o}); run `chmod go-w {}` or choose another OFFLINE_CONFIG_DIR",
dir.display(),
mode,
dir.display()
);
}
}
#[cfg(not(unix))]
let _ = tighten;
Ok(())
}
fn write_new_private_file(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("creating {}", path.display()))?;
file.write_all(data)
.and_then(|()| file.sync_all())
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn normalize_api_base(base: &str) -> String {
let base = base.trim_end_matches('/');
if base.ends_with("/api/v1") {
base.to_string()
} else {
format!("{base}/api/v1")
}
}
pub fn is_local_env() -> bool {
matches!(
std::env::var(ENV_ENV)
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.as_deref(),
Some("dev") | Some("local") | Some("development")
)
}
#[cfg(test)]
pub(crate) mod test_sync {
use std::sync::{Mutex, MutexGuard};
static LOCK: Mutex<()> = Mutex::new(());
pub fn env_lock() -> MutexGuard<'static, ()> {
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_sync::env_lock;
#[test]
fn config_persists_credentials() {
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
let config = CliConfig {
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
access_token: Some("jwt-token".into()),
dev_id: Some(123),
app_id: Some("app_abc".into()),
org_id: Some("org_xyz".into()),
email: Some("dev@example.com".into()),
..Default::default()
};
config.save().unwrap();
let loaded = CliConfig::load().unwrap();
assert_eq!(
loaded.api_key.as_deref(),
Some("550e8400-e29b-41d4-a716-446655440000")
);
assert_eq!(loaded.access_token.as_deref(), Some("jwt-token"));
assert_eq!(loaded.dev_id, Some(123));
assert_eq!(loaded.app_id.as_deref(), Some("app_abc"));
assert_eq!(loaded.org_id.as_deref(), Some("org_xyz"));
assert_eq!(loaded.email.as_deref(), Some("dev@example.com"));
let raw = std::fs::read_to_string(CliConfig::path().unwrap()).unwrap();
assert!(raw.contains("\"api_key\""));
assert!(raw.contains("\"access_token\""));
assert!(raw.contains("\"app_id\""));
assert!(raw.contains("\"org_id\""));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(CliConfig::path().unwrap())
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
std::env::remove_var(ENV_CONFIG_DIR);
}
#[cfg(unix)]
#[test]
fn save_is_atomic_owner_only_and_refuses_symlinks() {
use std::os::unix::fs::PermissionsExt;
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
let cfg_dir = dir.path().join("cfg");
std::env::set_var(ENV_CONFIG_DIR, &cfg_dir);
let config = CliConfig {
api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
..Default::default()
};
config.save().unwrap();
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&cfg_dir), 0o700, "new config directory is owner-only");
assert_eq!(mode(&CliConfig::path().unwrap()), 0o600);
config.save().unwrap();
let names: Vec<_> = std::fs::read_dir(&cfg_dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, vec!["config.json".to_string()]);
let target = dir.path().join("elsewhere.json");
std::fs::write(&target, "{}").unwrap();
std::fs::remove_file(CliConfig::path().unwrap()).unwrap();
std::os::unix::fs::symlink(&target, CliConfig::path().unwrap()).unwrap();
assert!(config
.save()
.unwrap_err()
.to_string()
.contains("symbolic link"));
assert!(CliConfig::load()
.unwrap_err()
.to_string()
.contains("symbolic link"));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "{}");
std::env::remove_var(ENV_CONFIG_DIR);
}
#[cfg(unix)]
#[test]
fn refuses_a_config_dir_writable_by_others() {
use std::os::unix::fs::PermissionsExt;
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
let err = CliConfig::default().save().unwrap_err().to_string();
assert!(err.contains("writable by other users"), "{err}");
assert!(!CliConfig::path().unwrap().exists());
assert_eq!(
std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777,
0o777
);
std::env::remove_var(ENV_CONFIG_DIR);
}
#[test]
fn legacy_project_id_deserializes_as_app_id() {
let raw = r#"{"project_id":"app_legacy","team_id":"org_legacy"}"#;
let config: CliConfig = serde_json::from_str(raw).unwrap();
assert_eq!(config.app_id.as_deref(), Some("app_legacy"));
assert_eq!(config.org_id.as_deref(), Some("org_legacy"));
}
#[test]
fn delete_config_file_removes_credentials() {
let _lock = env_lock();
let dir = tempfile::tempdir().unwrap();
std::env::set_var(ENV_CONFIG_DIR, dir.path());
let config = CliConfig {
api_key: Some("off_live_test".into()),
..Default::default()
};
config.save().unwrap();
assert!(CliConfig::path().unwrap().exists());
CliConfig::delete_config_file().unwrap();
assert!(!CliConfig::path().unwrap().exists());
std::env::remove_var(ENV_CONFIG_DIR);
}
#[test]
fn offline_env_selects_api_base() {
let _lock = env_lock();
std::env::remove_var(ENV_API_BASE);
std::env::remove_var(ENV_ENV);
assert_eq!(CliConfig::default().effective_api_base(), DEFAULT_API_BASE);
std::env::set_var(ENV_ENV, "prod");
assert_eq!(CliConfig::default().effective_api_base(), DEFAULT_API_BASE);
std::env::set_var(ENV_ENV, "dev");
assert_eq!(CliConfig::default().effective_api_base(), LOCAL_API_BASE);
std::env::set_var(ENV_ENV, "local");
std::env::set_var(ENV_API_BASE, "http://custom.example");
let config = CliConfig::default();
assert_eq!(config.effective_api_base(), "http://custom.example/api/v1");
assert_eq!(
normalize_api_base("https://api.offlineprotocol.com/api/v1"),
"https://api.offlineprotocol.com/api/v1"
);
assert_eq!(
normalize_api_base("http://localhost:3010"),
"http://localhost:3010/api/v1"
);
std::env::remove_var(ENV_ENV);
std::env::remove_var(ENV_API_BASE);
}
}