heddle-cli 0.21.0

An AI-native version control system
// SPDX-License-Identifier: Apache-2.0
//! `heddle env` — broker-backed confidential-runtime profiles.

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),
    }
}