use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
const BASE: &str = "https://app.launchdarkly.com/api/v2";
const PROJECT_KEY: &str = "hvac";
const PROJECT_NAME: &str = "hvac";
const ENV_KEY: &str = "production";
pub fn run(api_key: &str) -> Result<()> {
let (sdk_key, project_action) = find_or_create_project(api_key)?;
let (created, skipped) = ensure_flags(api_key)?;
eprintln!("Project: {} ({})", PROJECT_KEY, project_action);
eprintln!("Environment: {}", ENV_KEY);
eprintln!("SDK key: {}", sdk_key);
if !created.is_empty() {
eprintln!("Flags created: {}", created.join(", "));
}
if !skipped.is_empty() {
eprintln!("Flags skipped: {}", skipped.join(", "));
}
eprintln!(
"\nToggle pause: https://app.launchdarkly.com/{}/production/features/pause-transcoding",
PROJECT_KEY
);
println!("SDK key: {}", sdk_key);
eprintln!();
eprintln!("Use it on the next run with:");
eprintln!(" hvac --launchdarkly-sdk-key {} /path/to/media", sdk_key);
eprintln!();
eprintln!("Hold the key — anyone with it can pause/halt your transcodes.");
Ok(())
}
fn find_or_create_project(api_key: &str) -> Result<(String, &'static str)> {
match api_get(api_key, &format!("/projects/{PROJECT_KEY}"))? {
Some(_) => {
let env = api_get(
api_key,
&format!("/projects/{PROJECT_KEY}/environments/{ENV_KEY}"),
)?
.ok_or_else(|| {
anyhow::anyhow!("environment '{ENV_KEY}' not found in project '{PROJECT_KEY}'")
})?;
let sdk_key = extract_sdk_key(&env)?;
Ok((sdk_key, "found"))
}
None => {
let body = json!({
"key": PROJECT_KEY,
"name": PROJECT_NAME,
"environments": [{
"key": ENV_KEY,
"name": "Production",
"color": "417505"
}]
});
let resp = api_post(api_key, "/projects", &body)?;
let sdk_key = resp
.get("environments")
.and_then(|e| e.as_array())
.and_then(|arr| {
arr.iter()
.find(|e| e.get("key").and_then(|k| k.as_str()) == Some(ENV_KEY))
})
.and_then(|e| e.get("apiKey"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("no SDK key in created project response: {}", resp))
.and_then(|s| {
if s.contains("***") {
bail!("SDK key masked — check API key permissions (needs Writer role)")
} else {
Ok(s.to_string())
}
})?;
Ok((sdk_key, "created"))
}
}
}
fn extract_sdk_key(env: &Value) -> Result<String> {
let key = env
.get("apiKey")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("no apiKey in environment response"))?;
if key.contains("***") {
bail!("SDK key masked — check API key permissions (needs Writer role)");
}
Ok(key.to_string())
}
fn ensure_flags(api_key: &str) -> Result<(Vec<String>, Vec<String>)> {
let existing: std::collections::HashSet<String> =
api_get(api_key, &format!("/flags/{PROJECT_KEY}"))?
.and_then(|v| v.get("items").and_then(|i| i.as_array()).cloned())
.unwrap_or_default()
.iter()
.filter_map(|f| f.get("key")?.as_str().map(String::from))
.collect();
let mut created = Vec::new();
let mut skipped = Vec::new();
for spec in flag_specs() {
let key = spec["key"].as_str().unwrap().to_string();
if existing.contains(&key) {
skipped.push(key);
} else {
api_post(api_key, &format!("/flags/{PROJECT_KEY}"), &spec)?;
created.push(key);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
Ok((created, skipped))
}
fn api_get(api_key: &str, path: &str) -> Result<Option<Value>> {
let url = format!("{BASE}{path}");
let output = std::process::Command::new("curl")
.args(["-s", "-m", "30", "-w", "\n%{http_code}"])
.arg("-H")
.arg(format!("Authorization: {api_key}"))
.arg(&url)
.output()
.context("curl not found in PATH")?;
let text = String::from_utf8_lossy(&output.stdout);
let (body, status_str) = text.rsplit_once('\n').unwrap_or((&text, "0"));
match status_str.trim().parse::<u16>().unwrap_or(0) {
200..=299 => serde_json::from_str(body)
.with_context(|| format!("invalid JSON from GET {path}"))
.map(Some),
404 => Ok(None),
code => bail!("GET {path} → {code}: {}", ld_message(body)),
}
}
fn api_post(api_key: &str, path: &str, body: &Value) -> Result<Value> {
let url = format!("{BASE}{path}");
let body_str = serde_json::to_string(body)?;
let output = std::process::Command::new("curl")
.args(["-s", "-m", "30", "-w", "\n%{http_code}", "-X", "POST"])
.arg("-H")
.arg(format!("Authorization: {api_key}"))
.arg("-H")
.arg("Content-Type: application/json")
.arg("-d")
.arg(&body_str)
.arg(&url)
.output()
.context("curl not found in PATH")?;
let text = String::from_utf8_lossy(&output.stdout);
let (resp_body, status_str) = text.rsplit_once('\n').unwrap_or((&text, "0"));
match status_str.trim().parse::<u16>().unwrap_or(0) {
200..=299 => serde_json::from_str(resp_body)
.with_context(|| format!("invalid JSON from POST {path}")),
code => bail!("POST {path} → {code}: {}", ld_message(resp_body)),
}
}
fn ld_message(body: &str) -> String {
serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or_else(|| body.to_string())
}
fn flag_specs() -> Vec<Value> {
vec![
json!({
"key": "pause-transcoding",
"name": "Pause Transcoding",
"kind": "boolean",
"description": "Halt all active workers mid-run. Toggle back to resume. In-flight encodes finish before pausing.",
"temporary": false,
"variations": [{"value": true, "name": "Paused"}, {"value": false, "name": "Running"}],
"defaults": {"onVariation": 0, "offVariation": 1}
}),
json!({
"key": "enable-transcoding",
"name": "Enable Transcoding",
"kind": "boolean",
"description": "Master kill-switch. Set false to stop workers from picking up new files.",
"temporary": false,
"variations": [{"value": true, "name": "Enabled"}, {"value": false, "name": "Disabled"}],
"defaults": {"onVariation": 0, "offVariation": 0}
}),
json!({
"key": "max-parallel-jobs",
"name": "Max Parallel Jobs",
"kind": "multivariate",
"description": "Override parallel job count while the run is in flight. 0 = auto-detect from GPU.",
"temporary": false,
"variations": [
{"value": 0, "name": "Auto (default)"},
{"value": 1, "name": "1"},
{"value": 2, "name": "2"},
{"value": 4, "name": "4"}
],
"defaults": {"onVariation": 0, "offVariation": 0}
}),
]
}