use std::process::Command;
use anyhow::{Context, Result, anyhow};
use crypto::Signer;
use env_store::{EnvStore, PolicyBroker, SlotWrite};
use repo::Repository;
use serde::Serialize;
use super::{
advice::RecoveryAdvice,
next_action::{NextActionValidationContext, write_full_command_json},
};
use crate::cli::{Cli, EnvCommands, EnvCreateArgs, EnvListArgs, EnvRunArgs, should_output_json};
pub fn cmd_env(cli: &Cli, command: EnvCommands) -> Result<()> {
let repo = cli.open_repo()?;
match command {
EnvCommands::Create(args) => cmd_env_create(cli, &repo, args),
EnvCommands::List(_) => cmd_env_list(cli, &repo, EnvListArgs {}),
EnvCommands::Run(args) => cmd_env_run(&repo, args),
}
}
#[derive(Serialize)]
struct EnvCreateOutput {
output_kind: &'static str,
profile: String,
slots: Vec<String>,
}
#[derive(Serialize)]
struct EnvListOutput {
output_kind: &'static str,
profiles: Vec<EnvProfileRow>,
}
#[derive(Serialize)]
struct EnvProfileRow {
name: String,
slots: Vec<String>,
lifecycle: String,
}
fn cmd_env_create(cli: &Cli, repo: &Repository, args: EnvCreateArgs) -> Result<()> {
let store = EnvStore::open(repo.heddle_dir()).map_err(map_profile_error)?;
let signer = require_signer(repo)?;
let attribution = repo
.get_attribution()
.context("resolve current attribution")?;
let recipient = store
.default_or_create_software_recipient(&signer)
.map_err(map_profile_error)?;
let mut slots = Vec::new();
for name in &args.from_env {
let value = std::env::var(name).map_err(|_| {
anyhow!(RecoveryAdvice::safety_refusal(
"env_store_slot_not_found",
format!("environment variable {name} is not set"),
format!("Export `{name}` in this process, then retry `heddle env create`."),
format!("{name} was not present in the creating process"),
"no runtime profile would be written",
"the worktree and existing profiles were left unchanged",
"heddle env list",
vec!["heddle env list".to_string()],
))
})?;
slots.push(SlotWrite {
name: name.clone(),
value: value.into_bytes(),
});
}
let slot_names: Vec<String> = slots.iter().map(|slot| slot.name.clone()).collect();
let profile = store
.create_profile(
&args.name,
slots,
recipient.recipient_id,
attribution,
&signer,
)
.map_err(map_profile_error)?;
if should_output_json(cli, Some(repo.config())) {
return write_full_command_json(
&EnvCreateOutput {
output_kind: "env_create",
profile: profile.name,
slots: slot_names,
},
NextActionValidationContext::new(&["env", "create"], repo.capability()),
);
}
eprintln!(
"created {} ({})",
profile.name,
if slot_names.is_empty() {
"no slots".to_string()
} else {
slot_names.join(" ")
}
);
Ok(())
}
fn cmd_env_list(cli: &Cli, repo: &Repository, _args: EnvListArgs) -> Result<()> {
let store = EnvStore::open(repo.heddle_dir()).map_err(map_profile_error)?;
let profiles = store.list_profiles().map_err(map_profile_error)?;
if should_output_json(cli, Some(repo.config())) {
return write_full_command_json(
&EnvListOutput {
output_kind: "env_list",
profiles: profiles
.iter()
.map(|profile| EnvProfileRow {
name: profile.name.clone(),
slots: profile.slot_names.clone(),
lifecycle: profile.lifecycle.to_string(),
})
.collect(),
},
NextActionValidationContext::new(&["env", "list"], repo.capability()),
);
}
if profiles.is_empty() {
eprintln!("no runtime profiles");
return Ok(());
}
for profile in profiles {
eprintln!(
"{} {} ({})",
profile.name,
profile.slot_names.join(" "),
profile.lifecycle
);
}
Ok(())
}
fn cmd_env_run(repo: &Repository, args: EnvRunArgs) -> Result<()> {
if args.command.is_empty() {
return Err(anyhow!(RecoveryAdvice::invalid_usage(
"env_run_missing_command",
"env run requires a child command after `--`",
"Retry as `heddle env run --profile <name> -- <command>`.",
"heddle env run --profile <name> -- <command>",
)));
}
let store = EnvStore::open(repo.heddle_dir()).map_err(map_profile_error)?;
let signer = require_signer(repo)?;
let attribution = repo
.get_attribution()
.context("resolve current attribution")?;
let mut broker = PolicyBroker::new(store, attribution);
let mut child = Command::new(&args.command[0]);
child.args(&args.command[1..]);
child.current_dir(repo.root());
let status = broker
.run(&args.profile, &args.slots, &signer, child)
.map_err(map_profile_error)
.context("spawn env run child")?;
match status.code() {
Some(0) => Ok(()),
Some(code) => std::process::exit(code),
None => Err(anyhow!(RecoveryAdvice::safety_refusal(
"env_run_child_signaled",
"child was terminated by a signal",
"Inspect the child process and retry `heddle env run`.",
"the spawned command was terminated by a signal",
"the command did not complete",
"env-store contents were left unchanged",
"heddle env run --profile <name> -- <command>",
vec!["heddle env run --profile <name> -- <command>".to_string()],
))),
}
}
fn require_signer(repo: &Repository) -> Result<Box<dyn Signer>> {
let local = repo.heddle_dir().join(repo::identity::LOCAL_IDENTITY_FILE);
let device = repo::identity::device_identity_path();
repo::identity::resolve_signer(&local, &device).ok_or_else(|| {
anyhow!(RecoveryAdvice::invalid_usage(
"env_run_missing_signer",
"runtime profiles require a protected local signing identity",
"Create a local signing identity, then retry `heddle env run`.",
"heddle env run --profile <name> -- <command>",
))
})
}
fn map_profile_error(err: env_store::EnvStoreError) -> anyhow::Error {
let message = err.to_string();
match err {
env_store::EnvStoreError::NoProviderHandle(_) => {
anyhow!(RecoveryAdvice::safety_refusal(
"env_store_denied",
message,
"Ask for a named profile and slots this broker holds, then retry `heddle env run`.",
"the broker could not resolve a provider handle for the requested slot",
"no plaintext would be returned",
"the worktree and store were left unchanged",
"heddle env list",
vec!["heddle env list".to_string()],
))
}
env_store::EnvStoreError::ProfileNotFound(_) => anyhow!(RecoveryAdvice::safety_refusal(
"env_store_not_found",
message,
"Create the profile with `heddle env create --name <name> --from-env SLOT`, then retry.",
"no runtime profile with that name exists",
"no child would start",
"the worktree and store were left unchanged",
"heddle env create --name <name> --from-env SLOT",
vec!["heddle env create --name <name> --from-env SLOT".to_string()],
)),
env_store::EnvStoreError::SlotNotFound(_) => anyhow!(RecoveryAdvice::safety_refusal(
"env_store_slot_not_found",
message,
"List slot names with `heddle env list`, then pass a slot that exists.",
"the named slot is not on the profile head",
"no child would start",
"the worktree and store were left unchanged",
"heddle env list",
vec!["heddle env list".to_string()],
)),
other => anyhow!(other),
}
}