use anyhow::Result;
use colored::Colorize;
use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use crate::api_client::ApiClient;
use crate::config;
use crate::ui;
fn credentials_path() -> String {
dirs::home_dir()
.map(|home| {
home.join(".arete")
.join("credentials.toml")
.display()
.to_string()
})
.unwrap_or_else(|| "~/.arete/credentials.toml".to_string())
}
pub fn login(api_key: Option<String>) -> Result<()> {
let api_url = config::get_api_url(None);
let api_key = if let Some(key) = api_key {
key
} else {
if !ui::interactive() {
anyhow::bail!(
"Missing --key and no terminal to prompt on. Pass: a4 auth login --key <a4_ak_...> (or register as an agent: a4 auth signup)"
);
}
println!("{}", "Login to Arete".bold());
println!();
println!("Target API: {}", api_url.yellow());
println!();
print!("API Key: ");
io::stdout().flush()?;
let mut key = String::new();
io::stdin().read_line(&mut key)?;
key.trim().to_string()
};
if api_key.is_empty() {
anyhow::bail!("API key cannot be empty");
}
ApiClient::save_api_key(&api_key, Some(&api_url))?;
let spinner = ui::create_spinner("Verifying API key...");
let client = ApiClient::new()?;
match client.list_specs() {
Ok(_) => {
spinner.finish_and_clear();
ui::print_success("API key saved and verified!");
println!();
println!(" Credentials: {}", credentials_path().dimmed());
println!();
println!("You are now ready to use Arete!");
}
Err(e) => {
spinner.finish_and_clear();
let _ = ApiClient::delete_api_key_for_url(&api_url);
anyhow::bail!("Invalid API key: {}", e);
}
}
Ok(())
}
pub fn logout() -> Result<()> {
let api_url = config::get_api_url(None);
let spinner = ui::create_spinner("Logging out...");
match ApiClient::delete_api_key_for_url(&api_url) {
Ok(_) => {
spinner.finish_and_clear();
ui::print_success(&format!("Logged out from {}", api_url));
println!(" Your credentials have been removed from this device.");
}
Err(_) => {
let _ = ApiClient::delete_all_api_keys();
spinner.finish_and_clear();
ui::print_success("Logged out successfully");
println!(" Your credentials have been removed from this device.");
}
}
Ok(())
}
pub fn logout_all() -> Result<()> {
let spinner = ui::create_spinner("Logging out from all environments...");
ApiClient::delete_all_api_keys()?;
spinner.finish_and_clear();
ui::print_success("Logged out from all environments!");
println!(" All credentials have been removed from this device.");
Ok(())
}
pub fn status() -> Result<()> {
let api_url = config::get_api_url(None);
println!("{}", "Authentication Status".bold());
println!();
println!("Current target API: {}", api_url.yellow());
println!();
match ApiClient::load_api_key_for_url(&api_url) {
Ok(api_key) => {
println!(
"{} {}",
ui::symbols::SUCCESS.green().bold(),
"Authenticated".green().bold()
);
println!();
println!(
" API key: {}...{}",
&api_key[..8.min(api_key.len())],
if api_key.len() > 12 {
&api_key[api_key.len() - 4..]
} else {
""
}
);
println!(" Credentials: {}", credentials_path().dimmed());
println!();
println!(
" Run {} to verify with the server.",
"a4 auth whoami".cyan()
);
}
Err(_) => {
println!(
"{} {}",
ui::symbols::FAILURE.red().bold(),
"Not authenticated".red().bold()
);
println!();
println!("Run 'a4 auth login' to authenticate.");
}
}
match ApiClient::list_credentials() {
Ok(creds) if !creds.is_empty() => {
println!();
println!("{}", "Stored credentials:".dimmed());
for (url, _masked_key) in creds {
let is_current = url == api_url
|| (api_url.contains("localhost")
&& (url.contains("localhost") || url.contains("127.0.0.1")));
let marker = if is_current { "→ " } else { " " };
println!(
"{}{} {}",
marker,
url,
if is_current {
"(current)".green()
} else {
"".normal()
}
);
}
}
_ => {}
}
Ok(())
}
pub fn whoami() -> Result<()> {
let api_url = config::get_api_url(None);
let api_key = match ApiClient::load_api_key_for_url(&api_url) {
Ok(key) => key,
Err(_) => {
ui::print_error("Not authenticated");
println!();
println!(" Run {} to authenticate.", "a4 auth login".cyan());
return Ok(());
}
};
let spinner = ui::create_spinner("Verifying authentication...");
let client = ApiClient::new()?;
match client.list_specs() {
Ok(specs) => {
spinner.finish_and_clear();
println!(
"{} {}",
ui::symbols::SUCCESS.green().bold(),
"Authenticated".green().bold()
);
println!();
println!(
" API key: {}...{}",
&api_key[..8.min(api_key.len())],
&api_key[api_key.len().saturating_sub(4)..]
);
println!(" Stacks: {}", specs.len());
println!(" Target API: {}", api_url.yellow());
println!(" Credentials: {}", credentials_path().dimmed());
}
Err(e) => {
spinner.finish_and_clear();
ui::print_error("API key invalid or expired");
println!();
println!(" Error: {}", e);
println!();
println!(" Run {} to re-authenticate.", "a4 auth login".cyan());
}
}
Ok(())
}
pub fn list_keys(json: bool) -> Result<()> {
let client = ApiClient::new()?;
let spinner = (!json).then(|| ui::create_spinner("Fetching API keys..."));
let result = client.list_api_keys();
if let Some(spinner) = spinner {
spinner.finish_and_clear();
}
match result {
Ok(keys) => {
if json {
let keys = keys
.iter()
.map(|key| {
serde_json::json!({
"id": key.id,
"name": key.name,
"keyClass": key.key_class,
"origins": key.origin_allowlist.clone().unwrap_or_default(),
"expiresAt": key.expires_at,
"lastUsedAt": key.last_used_at,
"createdAt": key.created_at,
})
})
.collect::<Vec<_>>();
let payload = serde_json::json!({ "schemaVersion": 1, "keys": keys });
println!("{}", serde_json::to_string_pretty(&payload)?);
return Ok(());
}
if keys.is_empty() {
println!("{}", "No API keys found.".yellow());
println!();
println!(
" Run {} to create a publishable key for browser use.",
"a4 auth keys create-publishable".cyan()
);
return Ok(());
}
println!("{}", "API Keys:".bold());
println!();
for key in keys {
let key_type = match key.key_class.as_str() {
"publishable" => "publishable".green(),
"secret" => "secret".cyan(),
_ => key.key_class.normal(),
};
println!(
" {} {}",
"•".bold(),
key.name.unwrap_or_else(|| "Unnamed".to_string())
);
println!(" ID: {}", key.id);
println!(" Type: {}", key_type);
if let Some(origins) = key.origin_allowlist {
if !origins.is_empty() {
println!(" Origins: {}", origins.join(", "));
}
}
if let Some(expires) = key.expires_at {
println!(
" Expires: {}",
expires.split('T').next().unwrap_or(&expires)
);
}
if let Some(last_used) = key.last_used_at {
println!(
" Last used: {}",
last_used.split('T').next().unwrap_or(&last_used)
);
}
println!();
}
}
Err(error) => {
let message = format!("Failed to list keys: {error}");
return Err(error.context(message));
}
}
Ok(())
}
pub struct CreatePublishableArgs {
pub name: Option<String>,
pub origins: Vec<String>,
pub expiry_days: Option<i64>,
pub env_file: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Framework {
NextJs,
Vite,
Generic,
}
impl Framework {
fn id(self) -> &'static str {
match self {
Framework::NextJs => "nextjs",
Framework::Vite => "vite",
Framework::Generic => "generic",
}
}
fn env_var(self) -> &'static str {
match self {
Framework::NextJs => "NEXT_PUBLIC_ARETE_PUBLISHABLE_KEY",
Framework::Vite => "VITE_ARETE_PUBLISHABLE_KEY",
Framework::Generic => "ARETE_PUBLISHABLE_KEY",
}
}
fn how(self) -> &'static str {
match self {
Framework::NextJs => "Next.js exposes NEXT_PUBLIC_* variables to browser code",
Framework::Vite => "Vite exposes VITE_* variables to browser code as import.meta.env",
Framework::Generic => "read it from the environment and pass it as auth.publishableKey",
}
}
}
const NEXT_CONFIGS: [&str; 5] = [
"next.config.js",
"next.config.mjs",
"next.config.cjs",
"next.config.ts",
"next.config.mts",
];
const VITE_CONFIGS: [&str; 6] = [
"vite.config.js",
"vite.config.mjs",
"vite.config.cjs",
"vite.config.ts",
"vite.config.mts",
"vite.config.cts",
];
fn detect_framework(root: &Path) -> Framework {
let package_deps = std::fs::read_to_string(root.join("package.json"))
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.map(|package| {
["dependencies", "devDependencies"]
.iter()
.filter_map(|key| package.get(*key).and_then(serde_json::Value::as_object))
.flat_map(|deps| deps.keys().cloned())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let has_dep = |name: &str| package_deps.iter().any(|dep| dep == name);
let has_file = |names: &[&str]| names.iter().any(|name| root.join(name).is_file());
if has_file(&NEXT_CONFIGS) || has_dep("next") {
Framework::NextJs
} else if has_file(&VITE_CONFIGS) || has_dep("vite") {
Framework::Vite
} else {
Framework::Generic
}
}
fn validate_origins(origins: &[String]) -> Result<String> {
let origin = match origins {
[origin] => origin.trim(),
[] => anyhow::bail!(
"A publishable key needs exactly one origin (e.g. https://example.com or http://localhost:5173)"
),
many => anyhow::bail!(
"A publishable key allows exactly one origin; got {} ({}). Create one key per origin.",
many.len(),
many.join(", ")
),
};
if !origin.starts_with("https://") && !origin.starts_with("http://") {
anyhow::bail!("Invalid origin '{origin}'. Origins must start with https:// or http://");
}
let parsed = url::Url::parse(origin)
.map_err(|error| anyhow::anyhow!("Invalid origin '{origin}': {error}"))?;
let normalized = parsed.origin().ascii_serialization();
if normalized != origin {
anyhow::bail!(
"Invalid origin '{origin}'. Browsers send the origin as scheme://host[:port] with no path or trailing slash; use --origin {normalized}"
);
}
Ok(normalized)
}
fn resolve_env_file(root: &Path, raw: &str) -> Result<PathBuf> {
let raw = raw.trim();
if raw.is_empty() {
anyhow::bail!("--env-file must not be empty");
}
let path = Path::new(raw);
if path.is_absolute() {
if is_symlink(path) {
return std::fs::canonicalize(path).map_err(|error| {
anyhow::anyhow!("--env-file {raw} is a symlink that cannot be followed: {error}")
});
}
return Ok(path.to_path_buf());
}
let project = std::fs::canonicalize(root).map_err(|error| {
anyhow::anyhow!(
"Failed to resolve the project directory {}: {error}",
root.display()
)
})?;
let outside = || {
anyhow::anyhow!(
"--env-file {raw} points outside the project directory ({}). Pass an absolute path to write there explicitly.",
project.display()
)
};
let mut parts = Vec::new();
for component in path.components() {
match component {
Component::Normal(part) => parts.push(part),
Component::CurDir => {}
_ => return Err(outside()),
}
}
let Some(file_name) = parts.pop() else {
anyhow::bail!("--env-file {raw} must name a file");
};
let parent = parts
.iter()
.fold(root.to_path_buf(), |directory, part| directory.join(part));
let directory = match std::fs::canonicalize(&parent) {
Ok(directory) => directory,
Err(error) if error.kind() == io::ErrorKind::NotFound => anyhow::bail!(
"--env-file {raw}: directory {} does not exist",
parent.display()
),
Err(error) => anyhow::bail!("Failed to resolve --env-file {raw}: {error}"),
};
if !directory.starts_with(&project) {
return Err(outside());
}
let resolved = directory.join(file_name);
if is_symlink(&resolved) {
anyhow::bail!(
"--env-file {raw} is a symlink; pass the absolute path of the file it points to"
);
}
Ok(resolved)
}
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink())
}
fn read_env_file(path: &Path) -> Result<Option<String>> {
if path.is_dir() {
anyhow::bail!("--env-file {} is a directory", path.display());
}
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
if !parent.is_dir() {
anyhow::bail!(
"--env-file {}: directory {} does not exist",
path.display(),
parent.display()
);
}
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(anyhow::anyhow!(
"Failed to read --env-file {}: {error}",
path.display()
)),
}
}
fn write_env_file(path: &Path, content: &str) -> io::Result<()> {
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut temporary_name = std::ffi::OsString::from(".");
temporary_name.push(path.file_name().unwrap_or_default());
temporary_name.push(format!(".{}.tmp", uuid::Uuid::new_v4()));
let temporary = directory.join(temporary_name);
let permissions = match std::fs::metadata(path) {
Ok(metadata) => Some(metadata.permissions()),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
let result = (|| {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if permissions.is_some() {
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
}
let mut file = options.open(&temporary)?;
file.write_all(content.as_bytes())?;
if let Some(permissions) = permissions {
file.set_permissions(permissions)?;
}
file.sync_all()?;
drop(file);
std::fs::rename(&temporary, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnvChange {
Created,
Replaced,
Appended,
Unchanged,
}
impl EnvChange {
fn as_str(self) -> &'static str {
match self {
EnvChange::Created => "created",
EnvChange::Replaced => "replaced",
EnvChange::Appended => "appended",
EnvChange::Unchanged => "unchanged",
}
}
}
fn assigns(line: &str, name: &str) -> bool {
let line = line.trim_start();
let line = line
.strip_prefix("export")
.filter(|rest| rest.starts_with(char::is_whitespace))
.map(str::trim_start)
.unwrap_or(line);
line.strip_prefix(name)
.is_some_and(|rest| rest.trim_start().starts_with('='))
}
fn upsert_env_var(existing: Option<&str>, name: &str, value: &str) -> (String, EnvChange) {
let Some(existing) = existing else {
return (format!("{name}={value}\n"), EnvChange::Created);
};
let mut output = String::with_capacity(existing.len() + name.len() + value.len() + 2);
let mut replaced = false;
for line in existing.split_inclusive('\n') {
let (body, ending) = match line.strip_suffix("\r\n") {
Some(body) => (body, "\r\n"),
None => match line.strip_suffix('\n') {
Some(body) => (body, "\n"),
None => (line, ""),
},
};
if assigns(body, name) {
let indent_len = body.len() - body.trim_start().len();
let exported = body.trim_start().starts_with("export");
output.push_str(&body[..indent_len]);
if exported {
output.push_str("export ");
}
output.push_str(&format!("{name}={value}{ending}"));
replaced = true;
} else {
output.push_str(line);
}
}
if replaced {
let change = if output == existing {
EnvChange::Unchanged
} else {
EnvChange::Replaced
};
return (output, change);
}
let newline = if existing.contains("\r\n") {
"\r\n"
} else {
"\n"
};
if !output.is_empty() && !output.ends_with('\n') {
output.push_str(newline);
}
output.push_str(&format!("{name}={value}{newline}"));
(output, EnvChange::Appended)
}
pub fn create_publishable_key(
args: CreatePublishableArgs,
config_path: &str,
json: bool,
) -> Result<()> {
let origin = validate_origins(&args.origins)?;
let root = super::init::project_root(config_path);
let framework = detect_framework(&root);
let env_var = framework.env_var();
let env_target = match args.env_file.as_deref() {
Some(raw) => {
let path = resolve_env_file(&root, raw)?;
let existing = read_env_file(&path)?;
Some((path, existing))
}
None => None,
};
let client = ApiClient::new()?;
let spinner = (!json).then(|| ui::create_spinner("Creating publishable key..."));
let result =
client.create_publishable_key(args.name.clone(), vec![origin.clone()], args.expiry_days);
if let Some(spinner) = spinner {
spinner.finish_and_clear();
}
let response = match result {
Ok(response) => response,
Err(error) => {
let message = format!("Failed to create publishable key: {error}");
return Err(error.context(message));
}
};
let written = env_target.map(|(path, existing)| {
let (content, change) = upsert_env_var(existing.as_deref(), env_var, &response.key);
let result = if change == EnvChange::Unchanged {
Ok(())
} else {
write_env_file(&path, &content)
};
(path, change, result)
});
let (env_file, env_change, write_error) = match written {
Some((path, change, Ok(()))) => (Some(path), Some(change), None),
Some((path, _, Err(error))) => (
None,
None,
Some(anyhow::anyhow!(
"Created the key, but failed to write {}: {error}. Set {env_var} yourself from the key above.",
path.display()
)),
),
None => (None, None, None),
};
let name = response.name.clone().or(args.name);
if json {
let payload = serde_json::json!({
"schemaVersion": 1,
"id": response.id,
"name": name,
"keyClass": response.key_class,
"origins": [origin],
"expiresAt": response.expires_at,
"key": response.key,
"framework": framework.id(),
"envVar": env_var,
"envFile": env_file.as_ref().map(|path| path.display().to_string()),
"envFileChange": env_change.map(EnvChange::as_str),
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
println!("{}", "✓ Publishable key created".green().bold());
println!();
println!(
"{}",
"Save this key now; it won't be shown again."
.yellow()
.bold()
);
println!();
if let Some(name) = &name {
println!(" Name: {}", name);
}
println!(" Key ID: {}", response.id);
println!(" Type: {}", "publishable".green());
println!(" Origin: {}", origin);
println!(
" Expires: {}",
response
.expires_at
.split('T')
.next()
.unwrap_or(&response.expires_at)
);
println!();
println!(" {}", "Publishable Key:".bold());
println!(" {}", response.key.green().bold());
println!();
match (&env_file, env_change) {
(Some(path), Some(change)) => println!(
" {} {env_var} in {} ({})",
match change {
EnvChange::Created | EnvChange::Appended => "Wrote",
EnvChange::Replaced => "Replaced",
EnvChange::Unchanged => "Kept",
},
path.display(),
framework.how()
),
_ => println!(
" Set {} in your environment ({}), or rerun with --env-file .env.local",
format!("{env_var}=<key>").cyan(),
framework.how()
),
}
println!();
println!(
"{}",
"This key is safe in browser code. Each publishable key allows exactly one origin;"
.dimmed()
);
println!(
"{}",
"create another key for every other origin (e.g. production and localhost).".dimmed()
);
}
match write_error {
Some(error) => Err(error),
None => Ok(()),
}
}
#[derive(Debug)]
struct SignupOutcome {
slug: String,
display_name: String,
api_key: String,
credentials_path: std::path::PathBuf,
message: Option<String>,
}
fn perform_signup(
client: &ApiClient,
api_url: &str,
name: Option<&str>,
force: bool,
) -> Result<SignupOutcome> {
if !force && ApiClient::load_optional_api_key_for_url(api_url)?.is_some() {
anyhow::bail!(
"Credentials already exist for {api_url}. Run: a4 auth status (or pass --force to replace them)"
);
}
let response = client.agent_signup(name)?;
ApiClient::save_api_key(&response.api_key, Some(api_url))?;
let credentials_path = ApiClient::credentials_file_path()?;
Ok(SignupOutcome {
slug: response.slug,
display_name: response.display_name,
api_key: response.api_key,
credentials_path,
message: response.message,
})
}
pub fn signup(name: Option<String>, force: bool, json: bool) -> Result<()> {
let api_url = config::get_api_url(None);
let client = ApiClient::new()?;
let spinner = (!json).then(|| ui::create_spinner("Registering agent..."));
let outcome = perform_signup(&client, &api_url, name.as_deref(), force);
if let Some(spinner) = spinner {
spinner.finish_and_clear();
}
let outcome = outcome?;
if json {
let payload = serde_json::json!({
"schemaVersion": 1,
"slug": outcome.slug,
"displayName": outcome.display_name,
"credentialsPath": outcome.credentials_path.display().to_string(),
"apiKey": outcome.api_key,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
eprintln!(
"note: apiKey is a secret; it is already stored in {}. Do not paste it into logs or chat.",
outcome.credentials_path.display()
);
return Ok(());
}
ui::print_success(&format!(
"Registered agent {} ({})",
outcome.slug.bold(),
outcome.display_name
));
println!(" Target API: {}", api_url.yellow());
println!(
" Credentials: {}",
outcome.credentials_path.display().to_string().dimmed()
);
if let Some(message) = outcome.message.filter(|m| !m.trim().is_empty()) {
println!(" {}", message.dimmed());
}
println!();
println!("Next: {}", "a4 explore --json".cyan());
Ok(())
}
#[cfg(test)]
mod publishable_tests {
use super::*;
use crate::api_client::test_support::{MockServer, ENV_LOCK};
#[test]
fn framework_detection_prefers_next_then_vite() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(detect_framework(dir.path()), Framework::Generic);
std::fs::write(
dir.path().join("package.json"),
r#"{"devDependencies":{"vite":"^5"}}"#,
)
.unwrap();
assert_eq!(detect_framework(dir.path()), Framework::Vite);
assert_eq!(Framework::Vite.env_var(), "VITE_ARETE_PUBLISHABLE_KEY");
std::fs::write(dir.path().join("next.config.mjs"), "export default {}").unwrap();
assert_eq!(detect_framework(dir.path()), Framework::NextJs);
assert_eq!(
Framework::NextJs.env_var(),
"NEXT_PUBLIC_ARETE_PUBLISHABLE_KEY"
);
assert_eq!(Framework::Generic.env_var(), "ARETE_PUBLISHABLE_KEY");
}
#[test]
fn exactly_one_bare_origin_is_accepted() {
assert_eq!(
validate_origins(&["http://localhost:5173".into()]).unwrap(),
"http://localhost:5173"
);
let many = validate_origins(&["https://a.test".into(), "https://b.test".into()])
.unwrap_err()
.to_string();
assert!(many.contains("exactly one origin"), "{many}");
assert!(validate_origins(&[]).is_err());
assert!(validate_origins(&["example.com".into()]).is_err());
let slash = validate_origins(&["https://example.com/".into()])
.unwrap_err()
.to_string();
assert!(slash.contains("--origin https://example.com"), "{slash}");
}
#[test]
fn env_file_paths_stay_in_the_project_unless_absolute() {
let dir = tempfile::tempdir().unwrap();
let project = std::fs::canonicalize(dir.path()).unwrap();
assert_eq!(
resolve_env_file(dir.path(), ".env.local").unwrap(),
project.join(".env.local")
);
std::fs::create_dir_all(dir.path().join("apps/web")).unwrap();
assert_eq!(
resolve_env_file(dir.path(), "./apps/web/.env").unwrap(),
project.join("apps/web/.env")
);
assert!(resolve_env_file(dir.path(), "../.env").is_err());
assert!(resolve_env_file(dir.path(), "web/../../.env").is_err());
assert!(resolve_env_file(dir.path(), " ").is_err());
assert!(resolve_env_file(dir.path(), ".").is_err());
let absolute = dir.path().join("elsewhere.env");
assert_eq!(
resolve_env_file(Path::new("/unrelated"), absolute.to_str().unwrap()).unwrap(),
absolute
);
}
#[cfg(unix)]
#[test]
fn env_file_paths_cannot_leave_the_project_through_a_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let project = std::fs::canonicalize(dir.path()).unwrap();
symlink(outside.path(), dir.path().join("web")).unwrap();
for raw in ["web/.env", "./web/.env"] {
let error = resolve_env_file(dir.path(), raw).unwrap_err().to_string();
assert!(error.contains("outside the project directory"), "{error}");
}
std::fs::create_dir_all(dir.path().join("apps/site")).unwrap();
symlink(dir.path().join("apps"), dir.path().join("linked")).unwrap();
assert_eq!(
resolve_env_file(dir.path(), "linked/site/.env").unwrap(),
project.join("apps/site/.env"),
"a symlinked directory that stays inside the project is fine"
);
symlink(outside.path().join(".env"), dir.path().join(".env.local")).unwrap();
let error = resolve_env_file(dir.path(), ".env.local")
.unwrap_err()
.to_string();
assert!(error.contains("is a symlink"), "{error}");
std::fs::write(outside.path().join(".env"), "A=1\n").unwrap();
let linked = dir.path().join(".env.local");
assert_eq!(
resolve_env_file(dir.path(), linked.to_str().unwrap()).unwrap(),
std::fs::canonicalize(outside.path().join(".env")).unwrap()
);
}
#[cfg(unix)]
#[test]
fn rewriting_an_env_file_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env.local");
for mode in [0o600, 0o640, 0o604] {
std::fs::write(&path, "SECRET=keep\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
write_env_file(&path, "SECRET=keep\nA=1\n").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"SECRET=keep\nA=1\n"
);
let actual = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(actual, mode, "{actual:o} != {mode:o}");
}
let entries = std::fs::read_dir(dir.path()).unwrap().count();
assert_eq!(entries, 1, "no temporary file is left behind");
}
#[test]
fn env_upsert_touches_only_the_variable() {
let name = "VITE_ARETE_PUBLISHABLE_KEY";
assert_eq!(
upsert_env_var(None, name, "hspk_new"),
(format!("{name}=hspk_new\n"), EnvChange::Created)
);
let existing = "SECRET=keep\r\n export VITE_ARETE_PUBLISHABLE_KEY = hspk_old\r\nVITE_ARETE_PUBLISHABLE_KEY_OTHER=x\r\n";
let (content, change) = upsert_env_var(Some(existing), name, "hspk_new");
assert_eq!(change, EnvChange::Replaced);
assert_eq!(
content,
"SECRET=keep\r\n export VITE_ARETE_PUBLISHABLE_KEY=hspk_new\r\nVITE_ARETE_PUBLISHABLE_KEY_OTHER=x\r\n"
);
let (content, change) = upsert_env_var(Some("A=1"), name, "hspk_new");
assert_eq!(change, EnvChange::Appended);
assert_eq!(content, format!("A=1\n{name}=hspk_new\n"));
let same = format!("{name}=hspk_new\n");
assert_eq!(
upsert_env_var(Some(&same), name, "hspk_new").1,
EnvChange::Unchanged
);
}
struct Sandbox {
_guard: std::sync::MutexGuard<'static, ()>,
dir: tempfile::TempDir,
}
impl Sandbox {
fn new(server: &MockServer) -> Self {
let guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let dir = tempfile::tempdir().unwrap();
let credentials = dir.path().join("credentials.toml");
std::fs::write(
&credentials,
format!("[keys]\n\"{}\" = \"a4_sk_owner\"\n", server.base_url()),
)
.unwrap();
std::env::set_var("ARETE_API_URL", server.base_url());
std::env::set_var("ARETE_CREDENTIALS_PATH", &credentials);
std::fs::write(
dir.path().join("package.json"),
r#"{"dependencies":{"vite":"^5"}}"#,
)
.unwrap();
Self { _guard: guard, dir }
}
fn config(&self) -> String {
self.dir.path().join("arete.toml").display().to_string()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
std::env::remove_var("ARETE_API_URL");
std::env::remove_var("ARETE_CREDENTIALS_PATH");
}
}
fn args(env_file: Option<&str>) -> CreatePublishableArgs {
CreatePublishableArgs {
name: Some("web".into()),
origins: vec!["http://localhost:5173".into()],
expiry_days: None,
env_file: env_file.map(str::to_string),
}
}
#[test]
fn api_failure_is_an_error_and_writes_nothing() {
let server = MockServer::json(
400,
r#"{"error":"A publishable key must have exactly one allowed origin","code":"origin-allowlist-too-many"}"#,
);
let sandbox = Sandbox::new(&server);
let env = sandbox.dir.path().join(".env.local");
std::fs::write(&env, "OTHER=1\n").unwrap();
let error = create_publishable_key(args(Some(".env.local")), &sandbox.config(), true)
.unwrap_err()
.to_string();
assert!(
error.contains("Failed to create publishable key")
&& error.contains("origin-allowlist-too-many"),
"{error}"
);
assert_eq!(std::fs::read_to_string(&env).unwrap(), "OTHER=1\n");
}
#[test]
fn created_key_is_written_to_the_env_file_for_the_detected_framework() {
let server = MockServer::json(
201,
r#"{"id":7,"key":"hspk_fresh","name":"web","key_class":"publishable","expires_at":"2027-09-25T00:00:00Z","message":"ok"}"#,
);
let sandbox = Sandbox::new(&server);
let env = sandbox.dir.path().join(".env.local");
std::fs::write(&env, "OTHER=1\nVITE_ARETE_PUBLISHABLE_KEY=hspk_old\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&env, std::fs::Permissions::from_mode(0o600)).unwrap();
}
create_publishable_key(args(Some(".env.local")), &sandbox.config(), true).unwrap();
assert_eq!(
std::fs::read_to_string(&env).unwrap(),
"OTHER=1\nVITE_ARETE_PUBLISHABLE_KEY=hspk_fresh\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&env).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "a private env file stays private");
}
let body: serde_json::Value = serde_json::from_str(&server.request().body).unwrap();
assert_eq!(
body["origin_allowlist"],
serde_json::json!(["http://localhost:5173"])
);
}
#[test]
fn a_bad_env_file_path_fails_before_any_key_is_created() {
let server = MockServer::json(500, r#"{"error":"must not be called"}"#);
let sandbox = Sandbox::new(&server);
let error = create_publishable_key(args(Some("missing-dir/.env")), &sandbox.config(), true)
.unwrap_err()
.to_string();
assert!(error.contains("does not exist"), "{error}");
let error = create_publishable_key(args(Some("../.env")), &sandbox.config(), true)
.unwrap_err()
.to_string();
assert!(error.contains("outside the project"), "{error}");
#[cfg(unix)]
{
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), sandbox.dir.path().join("web")).unwrap();
let error = create_publishable_key(args(Some("web/.env")), &sandbox.config(), true)
.unwrap_err()
.to_string();
assert!(error.contains("outside the project"), "{error}");
assert!(!outside.path().join(".env").exists());
}
}
}
#[cfg(test)]
mod signup_tests {
use super::*;
use crate::api_client::test_support::MockServer;
use crate::api_client::SIGNUP_RATE_LIMIT_MESSAGE;
use std::sync::MutexGuard;
use crate::api_client::test_support::ENV_LOCK;
struct CredentialsSandbox {
_guard: MutexGuard<'static, ()>,
dir: tempfile::TempDir,
}
impl CredentialsSandbox {
fn new() -> Self {
let guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let dir = tempfile::tempdir().expect("tempdir");
std::env::set_var(
"ARETE_CREDENTIALS_PATH",
dir.path().join("creds").join("credentials.toml"),
);
CredentialsSandbox { _guard: guard, dir }
}
fn credentials_path(&self) -> std::path::PathBuf {
self.dir.path().join("creds").join("credentials.toml")
}
}
impl Drop for CredentialsSandbox {
fn drop(&mut self) {
std::env::remove_var("ARETE_CREDENTIALS_PATH");
}
}
const OK_BODY: &str =
r#"{"slug":"agent-7f3a","display_name":"Robo","api_key":"a4_ak_fresh","message":"hi"}"#;
#[test]
fn signup_stores_key_for_api_url_and_reports_slug() {
let sandbox = CredentialsSandbox::new();
let server = MockServer::json(200, OK_BODY);
let client = ApiClient::with_base_url(server.base_url());
let outcome = perform_signup(&client, server.base_url(), Some("Robo"), false)
.expect("signup succeeds");
assert_eq!(outcome.slug, "agent-7f3a");
assert_eq!(outcome.display_name, "Robo");
assert_eq!(outcome.api_key, "a4_ak_fresh");
assert_eq!(outcome.message.as_deref(), Some("hi"));
assert_eq!(outcome.credentials_path, sandbox.credentials_path());
assert_eq!(
ApiClient::load_optional_api_key_for_url(server.base_url())
.expect("credentials readable")
.as_deref(),
Some("a4_ak_fresh")
);
let body: serde_json::Value =
serde_json::from_str(&server.request().body).expect("json body");
assert_eq!(body, serde_json::json!({"display_name": "Robo"}));
}
#[test]
fn signup_refuses_to_replace_existing_credentials_unless_forced() {
let _sandbox = CredentialsSandbox::new();
let server = MockServer::json(200, OK_BODY);
let api_url = server.base_url().to_string();
ApiClient::save_api_key("a4_ak_old", Some(&api_url)).expect("seed credentials");
let client = ApiClient::with_base_url(&api_url);
let err = perform_signup(&client, &api_url, None, false).expect_err("must refuse");
assert_eq!(
err.to_string(),
format!(
"Credentials already exist for {api_url}. Run: a4 auth status (or pass --force to replace them)"
)
);
assert_eq!(
ApiClient::load_optional_api_key_for_url(&api_url)
.unwrap()
.as_deref(),
Some("a4_ak_old"),
"refusal must not touch the stored key"
);
let outcome = perform_signup(&client, &api_url, None, true).expect("--force replaces");
assert_eq!(outcome.api_key, "a4_ak_fresh");
assert_eq!(
ApiClient::load_optional_api_key_for_url(&api_url)
.unwrap()
.as_deref(),
Some("a4_ak_fresh")
);
let body: serde_json::Value =
serde_json::from_str(&server.request().body).expect("json body");
assert_eq!(
body,
serde_json::json!({}),
"display_name omitted when None"
);
}
#[test]
fn signup_surfaces_rate_limit_message_and_stores_nothing() {
let _sandbox = CredentialsSandbox::new();
let server = MockServer::json(429, r#"{"error":"slow down"}"#);
let client = ApiClient::with_base_url(server.base_url());
let err = perform_signup(&client, server.base_url(), None, false).expect_err("429");
assert_eq!(err.to_string(), SIGNUP_RATE_LIMIT_MESSAGE);
assert_eq!(
ApiClient::load_optional_api_key_for_url(server.base_url()).unwrap(),
None
);
}
}