runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
// rust-style-allow: large-file - publish keeps CLI parsing, HTTP request
// construction, and user-facing output together until the public receipt API
// stabilizes.
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use runx_contracts::JsonValue;
use runx_runtime::ConfigError;
use runx_runtime::registry::{
    HttpMethod, HttpRequest, RuntimeHttpError, RuntimeHttpHeader, Transport,
};
use serde::{Deserialize, Serialize};

use crate::cli_args::{flag_value, os_arg, split_flag};

#[derive(Debug, Eq, PartialEq)]
pub struct PublishPlan {
    pub receipt_path: PathBuf,
    pub api_base_url: Option<String>,
    pub token: Option<String>,
    pub allow_local_api: bool,
    pub json: bool,
}

#[derive(Debug)]
pub enum PublishCliError {
    MissingReceipt,
    ExtraReceipt,
    UnknownFlag(String),
    ReadReceipt { path: String, message: String },
    InvalidReceiptJson { path: String, message: String },
    MissingToken,
    TransportInit(RuntimeHttpError),
    Config(ConfigError),
    Publish(PublishError),
    Serialize(String),
}

impl fmt::Display for PublishCliError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingReceipt => write!(formatter, "runx publish requires a receipt JSON path"),
            Self::ExtraReceipt => write!(
                formatter,
                "runx publish accepts exactly one receipt JSON path"
            ),
            Self::UnknownFlag(flag) => write!(formatter, "unknown publish flag {flag}"),
            Self::ReadReceipt { path, message } => {
                write!(formatter, "failed to read receipt {path}: {message}")
            }
            Self::InvalidReceiptJson { path, message } => {
                write!(formatter, "receipt {path} is not valid JSON: {message}")
            }
            Self::MissingToken => write!(
                formatter,
                "missing public API token; run `runx login`, pass --token, or set RUNX_PUBLIC_API_TOKEN"
            ),
            Self::TransportInit(error) => {
                write!(formatter, "failed to initialize HTTP transport: {error}")
            }
            Self::Config(error) => write!(formatter, "{error}"),
            Self::Publish(error) => write!(formatter, "{error}"),
            Self::Serialize(message) => {
                write!(formatter, "failed to serialize publish result: {message}")
            }
        }
    }
}

impl std::error::Error for PublishCliError {}

impl From<PublishError> for PublishCliError {
    fn from(error: PublishError) -> Self {
        Self::Publish(error)
    }
}

impl From<ConfigError> for PublishCliError {
    fn from(error: ConfigError) -> Self {
        Self::Config(error)
    }
}

#[derive(Debug)]
pub enum PublishError {
    RuntimeHttp(RuntimeHttpError),
    HttpStatus {
        status: u16,
        body: String,
    },
    InvalidJson(String),
    RunxApi {
        code: String,
        detail: String,
        hint: Option<String>,
        retry_after_seconds: Option<u32>,
    },
}

impl fmt::Display for PublishError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::RuntimeHttp(error) => write!(formatter, "{error}"),
            Self::HttpStatus { status, body } => {
                write!(formatter, "runx-api publish returned HTTP {status}: {body}")
            }
            Self::InvalidJson(message) => {
                write!(
                    formatter,
                    "runx-api publish returned invalid JSON: {message}"
                )
            }
            Self::RunxApi {
                code, detail, hint, ..
            } => {
                write!(
                    formatter,
                    "{}",
                    publish_error_message(code, detail, hint.as_deref())
                )
            }
        }
    }
}

impl std::error::Error for PublishError {}

impl From<RuntimeHttpError> for PublishError {
    fn from(error: RuntimeHttpError) -> Self {
        Self::RuntimeHttp(error)
    }
}

fn publish_error_message(code: &str, detail: &str, hint: Option<&str>) -> String {
    if code == "missing_scope" && detail.contains("receipts:write") {
        return [
            "This token can publish skills but not receipts.",
            "The receipt notary requires `receipts:write`.",
            "Use `runx publish --token <receipt-token> <receipt.json>` or set `RUNX_PUBLIC_API_TOKEN` to a receipt-capable token.",
            "Your stored login token is still valid for the scopes it was issued with.",
        ]
        .join(" ");
    }
    let mut message = format!("runx-api publish returned error [{code}]: {detail}");
    if let Some(hint) = hint.filter(|value| !value.trim().is_empty()) {
        message.push_str(&format!(" Hint: {hint}"));
    }
    message
}

#[derive(Clone, Debug)]
struct PublishOptions<'a> {
    base_url: &'a str,
    token: &'a str,
    receipt: &'a JsonValue,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct ReceiptPublishResponse {
    pub status: String,
    #[serde(default)]
    pub replay_status: Option<String>,
    pub digest: String,
    pub public_hash: String,
    pub mode: String,
    pub published: bool,
    #[serde(default)]
    pub public_url: Option<String>,
    #[serde(default)]
    pub receipt_id: Option<String>,
    #[serde(default)]
    pub verdict: Option<JsonValue>,
}

pub fn parse_publish_plan(args: &[OsString]) -> Result<PublishPlan, String> {
    let mut receipt_path = None;
    let mut api_base_url = None;
    let mut token = None;
    let mut allow_local_api = false;
    let mut json = false;
    let mut index = 1;
    while index < args.len() {
        let arg = os_arg(args, index, "publish")?;
        if !arg.starts_with('-') {
            if receipt_path.is_some() {
                return Err(PublishCliError::ExtraReceipt.to_string());
            }
            receipt_path = Some(PathBuf::from(arg));
            index += 1;
            continue;
        }
        let (flag, inline_value) = split_flag(arg);
        match flag {
            "--json" | "-j" => {
                if inline_value.is_some() {
                    return Err("--json does not take a value".to_owned());
                }
                json = true;
                index += 1;
            }
            "--api-base-url" => {
                let (value, next_index) = flag_value(args, index, flag, inline_value, "publish")?;
                api_base_url = Some(value);
                index = next_index;
            }
            "--token" => {
                let (value, next_index) = flag_value(args, index, flag, inline_value, "publish")?;
                token = Some(value);
                index = next_index;
            }
            "--allow-local-api" => {
                if inline_value.is_some() {
                    return Err("--allow-local-api does not take a value".to_owned());
                }
                allow_local_api = true;
                index += 1;
            }
            _ => return Err(PublishCliError::UnknownFlag(flag.to_owned()).to_string()),
        }
    }
    Ok(PublishPlan {
        receipt_path: receipt_path.ok_or_else(|| PublishCliError::MissingReceipt.to_string())?,
        api_base_url,
        token,
        allow_local_api,
        json,
    })
}

pub fn run_native_publish(plan: PublishPlan) -> ExitCode {
    let cwd = match std::env::current_dir() {
        Ok(cwd) => cwd,
        Err(error) => {
            let _ignored = crate::cli_io::write_stderr(&format!(
                "runx publish: failed to resolve cwd: {error}\n"
            ));
            return ExitCode::from(1);
        }
    };
    match run_publish_command(&plan, &crate::history::env_map(), &cwd) {
        Ok(output) => crate::cli_io::write_stdout_code(&output, 0),
        Err(error) => {
            if plan.json {
                let body = serde_json::json!({
                    "status": "failure",
                    "error": {
                        "message": error.to_string(),
                        "code": "publish_failed",
                    },
                });
                let serialized = serde_json::to_string_pretty(&body)
                    .unwrap_or_else(|_| "{\"status\":\"failure\"}".to_owned());
                return crate::cli_io::write_stdout_code(&format!("{serialized}\n"), 1);
            }
            let _ignored = crate::cli_io::write_stderr(&format!("runx publish: {error}\n"));
            ExitCode::from(1)
        }
    }
}

fn run_publish_command(
    plan: &PublishPlan,
    env: &BTreeMap<String, String>,
    cwd: &Path,
) -> Result<String, PublishCliError> {
    let receipt = read_receipt_json(&plan.receipt_path)?;
    let base_url = resolve_public_api_base_url(plan, env);
    let token = resolve_publish_token(plan, env, cwd)?.ok_or(PublishCliError::MissingToken)?;
    let transport = crate::public_api::transport(allow_local_api(plan, env))
        .map_err(PublishCliError::TransportInit)?;
    let response = publish_receipt(
        &transport,
        &PublishOptions {
            base_url: &base_url,
            token: &token,
            receipt: &receipt,
        },
    )?;
    render_publish_result(plan.json, &response)
}

fn allow_local_api(plan: &PublishPlan, env: &BTreeMap<String, String>) -> bool {
    crate::public_api::private_network_allowed(
        plan.allow_local_api,
        env,
        "RUNX_PUBLISH_ALLOW_LOCAL_API",
    )
}

fn read_receipt_json(path: &PathBuf) -> Result<JsonValue, PublishCliError> {
    let text = fs::read_to_string(path).map_err(|error| PublishCliError::ReadReceipt {
        path: path.display().to_string(),
        message: error.to_string(),
    })?;
    serde_json::from_str(&text).map_err(|error| PublishCliError::InvalidReceiptJson {
        path: path.display().to_string(),
        message: error.to_string(),
    })
}

fn resolve_public_api_base_url(plan: &PublishPlan, env: &BTreeMap<String, String>) -> String {
    crate::public_api::resolve_base_url(plan.api_base_url.as_deref(), env)
}

fn resolve_publish_token(
    plan: &PublishPlan,
    env: &BTreeMap<String, String>,
    cwd: &Path,
) -> Result<Option<String>, PublishCliError> {
    crate::public_api_token::resolve(plan.token.as_deref(), env, cwd).map_err(PublishCliError::from)
}

fn publish_receipt<T: Transport>(
    transport: &T,
    options: &PublishOptions<'_>,
) -> Result<ReceiptPublishResponse, PublishError> {
    let body = serde_json::json!({
        "publish": true,
        "receipt": options.receipt,
    })
    .to_string();
    let response = transport.send(HttpRequest {
        method: HttpMethod::Post,
        url: format!(
            "{}/v1/receipts/notarize",
            options.base_url.trim_end_matches('/')
        ),
        headers: vec![
            RuntimeHttpHeader::new("authorization", format!("Bearer {}", options.token)),
            RuntimeHttpHeader::new("content-type", "application/json"),
        ],
        body: Some(body),
    })?;
    if !(200..=299).contains(&response.status) {
        if let Some(error) = crate::public_api::parse_error(&response.body) {
            return Err(PublishError::RunxApi {
                code: error.code,
                detail: error.detail,
                hint: error.hint,
                retry_after_seconds: error.retry_after_seconds,
            });
        }
        return Err(PublishError::HttpStatus {
            status: response.status,
            body: response.body,
        });
    }
    serde_json::from_str(&response.body)
        .map_err(|error| PublishError::InvalidJson(error.to_string()))
}

fn render_publish_result(
    json: bool,
    response: &ReceiptPublishResponse,
) -> Result<String, PublishCliError> {
    if json {
        return serde_json::to_string_pretty(response)
            .map(|value| format!("{value}\n"))
            .map_err(|error| PublishCliError::Serialize(error.to_string()));
    }
    let mut out = String::new();
    let verb = if response.published {
        "published"
    } else {
        "notarized"
    };
    out.push_str(&format!(
        "{verb} receipt {} ({})\n",
        response.digest, response.mode
    ));
    out.push_str(&format!("  status:      {}\n", response.status));
    out.push_str(&format!("  published:   {}\n", response.published));
    out.push_str(&format!("  public hash: {}\n", response.public_hash));
    if let Some(receipt_id) = &response.receipt_id {
        out.push_str(&format!("  receipt id:  {receipt_id}\n"));
    }
    if let Some(url) = &response.public_url {
        out.push_str(&format!("  public url:  {url}\n"));
    }
    if let Some(replay_status) = &response.replay_status {
        out.push_str(&format!("  replay:      {replay_status}\n"));
    }
    if let Some(verdict) = &response.verdict {
        out.push_str(&format!(
            "  verdict:     {}\n",
            compact_json(verdict).map_err(|error| PublishCliError::Serialize(error.to_string()))?
        ));
    }
    Ok(out)
}

fn compact_json(value: &JsonValue) -> Result<String, serde_json::Error> {
    serde_json::to_string(value)
}

#[cfg(test)]
#[path = "publish_tests.rs"]
mod publish_tests;