use std::io::Write;
use anyhow::{Context, Result, bail};
use clap::{Arg, ArgAction, ArgMatches, Command};
use termcolor::{ColorChoice, StandardStream, WriteColor};
use super::{CliCommand, declared::confirm_if_undeclared, push::reconstruct_env_content};
use crate::{
constants::{ERROR_FAILED_TO_SEND_REQUEST, get_platform_management_api_url},
core::{
command::command,
env::{EnvFileItem, parse_env_items_from_str},
http_client,
validate::{require_auth, require_integration, require_manifest},
},
};
#[derive(Debug)]
pub(crate) struct SetCommand;
impl SetCommand {
pub(crate) fn new() -> Self {
Self {}
}
}
fn header_matches_scope(header: &str, scope: &str) -> bool {
let name = header
.trim_start_matches('#')
.trim()
.split(' ')
.next()
.unwrap_or("");
name == scope
}
fn drop_valueless_entries(items: Vec<EnvFileItem>, keep: &str) -> Vec<EnvFileItem> {
items
.into_iter()
.filter(|item| match item {
EnvFileItem::KeyValue(k, v) => k == keep || !v.trim().is_empty(),
EnvFileItem::SectionHeader(_) => true,
})
.collect()
}
impl CliCommand for SetCommand {
fn command(&self) -> Command {
command(
"set",
"Set a single environment variable without touching the rest of the scope",
)
.arg(
Arg::new("pair")
.required(true)
.help("KEY=VALUE to set (quote values containing spaces)"),
)
.arg(
Arg::new("region")
.short('r')
.long("region")
.required(true)
.help("Region (e.g. us-east-1)"),
)
.arg(
Arg::new("environment")
.short('e')
.long("environment")
.required(true)
.help("Environment name (e.g. production, staging)"),
)
.arg(
Arg::new("service")
.short('s')
.long("service")
.help("Scope to a specific service/worker (defaults to application scope)"),
)
.arg(
Arg::new("force")
.long("force")
.short('f')
.help("Set the variable even when no component declares it")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("base_path")
.long("path")
.short('p')
.help("Path to application root (optional)"),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let _token = require_auth()?;
let (app_root, manifest) = require_manifest(matches)?;
let app = require_integration(&manifest)?;
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let pair = matches.get_one::<String>("pair").expect("pair required");
let Some((key, value)) = pair.split_once('=') else {
bail!("Expected KEY=VALUE, got '{}'", pair);
};
let key = key.trim();
if key.is_empty() {
bail!("Variable name cannot be empty");
}
if value.trim().is_empty() {
bail!(
"'{}=' has no value. To clear {} and mark it unset, run `forklaunch config unset {} -e {} -r {}{}` — it asks before destroying the stored value.",
key,
key,
key,
matches
.get_one::<String>("environment")
.map(String::as_str)
.unwrap_or("<env>"),
matches
.get_one::<String>("region")
.map(String::as_str)
.unwrap_or("<region>"),
matches
.get_one::<String>("service")
.map(|s| format!(" -s {}", s))
.unwrap_or_default()
);
}
if value.split_once('=').is_some_and(|(maybe_key, _)| {
maybe_key
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_')
&& !maybe_key.is_empty()
}) {
bail!(
"Value '{}' looks like another KEY=VALUE assignment — this is usually a paste error. Quote the value if it is intentional.",
value
);
}
let region = matches.get_one::<String>("region").expect("required");
let environment = matches.get_one::<String>("environment").expect("required");
let scope = matches
.get_one::<String>("service")
.cloned()
.unwrap_or_else(|| "application".to_string());
let pull_url = format!(
"{}/config/pull?applicationId={}®ion={}&environment={}",
get_platform_management_api_url(),
app,
region,
environment
);
let pull_response =
http_client::get(&pull_url).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
if !pull_response.status().is_success() {
bail!(
"Failed to pull current config: {}",
pull_response.text().unwrap_or_default()
);
}
let current = pull_response.text()?;
confirm_if_undeclared(
&mut stdout,
key,
&app_root,
¤t,
environment,
region,
matches.get_flag("force"),
)?;
let items = parse_env_items_from_str(¤t);
let mut scoped: Vec<EnvFileItem> = Vec::new();
let mut in_scope = scope == "application"; let mut scope_seen = false;
let mut replaced = false;
for item in items {
match item {
EnvFileItem::SectionHeader(header) => {
in_scope = header_matches_scope(&header, &scope);
if in_scope {
scope_seen = true;
scoped.push(EnvFileItem::SectionHeader(header));
}
}
EnvFileItem::KeyValue(k, v) => {
if in_scope {
if k == key {
scoped.push(EnvFileItem::KeyValue(k, value.to_string()));
replaced = true;
} else {
scoped.push(EnvFileItem::KeyValue(k, v));
}
}
}
}
}
if !scope_seen && scope != "application" {
bail!(
"Scope '{}' not found in the current configuration. Known scopes appear as '# <name> (id)' section headers in `forklaunch config pull` output.",
scope
);
}
if scoped.is_empty() {
scoped.push(EnvFileItem::SectionHeader(format!("# {}", scope)));
}
if !replaced {
scoped.push(EnvFileItem::KeyValue(key.to_string(), value.to_string()));
}
let content = reconstruct_env_content(drop_valueless_entries(scoped, key));
let push_url = format!("{}/config/push", get_platform_management_api_url());
let body = serde_json::json!({
"applicationId": app,
"region": region,
"environment": environment,
"content": content
});
let response =
http_client::post(&push_url, body).with_context(|| ERROR_FAILED_TO_SEND_REQUEST)?;
if response.status().is_success() {
if replaced {
log_ok!(
stdout,
"UPDATED {} in scope '{}' for {} ({})",
key,
scope,
environment,
region
);
log_info!(stdout, "The previous value of {} was replaced.", key);
} else {
log_ok!(
stdout,
"ADDED {} in scope '{}' for {} ({})",
key,
scope,
environment,
region
);
log_info!(
stdout,
"This created a new variable — scope '{}' had nothing named {} before.",
scope,
key
);
}
log_info!(
stdout,
"Running tasks keep their existing environment — redeploy to apply."
);
Ok(())
} else {
let err_text = response.text()?;
bail!("Failed to set variable: {}", err_text);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kv(key: &str, value: &str) -> EnvFileItem {
EnvFileItem::KeyValue(key.to_string(), value.to_string())
}
fn header(text: &str) -> EnvFileItem {
EnvFileItem::SectionHeader(text.to_string())
}
fn keys(items: &[EnvFileItem]) -> Vec<String> {
items
.iter()
.filter_map(|item| match item {
EnvFileItem::KeyValue(k, _) => Some(k.clone()),
EnvFileItem::SectionHeader(_) => None,
})
.collect()
}
#[test]
fn test_header_matches_scope_application() {
assert!(header_matches_scope("# application", "application"));
assert!(!header_matches_scope("# payments (svc-1)", "application"));
}
#[test]
fn test_header_matches_scope_named_component() {
assert!(header_matches_scope("# payments (svc-1)", "payments"));
assert!(!header_matches_scope("# payments-api (svc-1)", "payments"));
}
#[test]
fn test_drop_valueless_entries_removes_unfilled_required_vars() {
let scoped = vec![
header("# application"),
kv("DB_HOST", "db.example.com"),
kv("ECS_AGENT_URI", ""),
kv("STRIPE_API_KEY", ""),
];
let kept = drop_valueless_entries(scoped, "DB_HOST");
assert_eq!(keys(&kept), vec!["DB_HOST".to_string()]);
}
#[test]
fn test_drop_valueless_entries_keeps_the_targeted_key() {
let scoped = vec![header("# application"), kv("DB_HOST", "")];
let kept = drop_valueless_entries(scoped, "DB_HOST");
assert_eq!(keys(&kept), vec!["DB_HOST".to_string()]);
}
#[test]
fn test_drop_valueless_entries_treats_whitespace_as_valueless() {
let scoped = vec![kv("A", " "), kv("B", "value")];
let kept = drop_valueless_entries(scoped, "B");
assert_eq!(keys(&kept), vec!["B".to_string()]);
}
#[test]
fn test_drop_valueless_entries_preserves_section_headers() {
let scoped = vec![header("# payments (svc-1)"), kv("A", "")];
let kept = drop_valueless_entries(scoped, "OTHER");
assert_eq!(kept.len(), 1);
assert!(matches!(kept[0], EnvFileItem::SectionHeader(_)));
}
#[test]
fn test_drop_valueless_entries_keeps_every_valued_key() {
let scoped = vec![kv("A", "1"), kv("B", "2"), kv("C", "3")];
let kept = drop_valueless_entries(scoped, "A");
assert_eq!(keys(&kept), vec!["A", "B", "C"]);
}
}