Skip to main content

runx_cli/
publish.rs

1// rust-style-allow: large-file - publish keeps CLI parsing, HTTP request
2// construction, and user-facing output together until the public receipt API
3// stabilizes.
4use std::collections::BTreeMap;
5use std::ffi::OsString;
6use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::ExitCode;
10
11use runx_contracts::JsonValue;
12use runx_runtime::ConfigError;
13use runx_runtime::registry::{
14    HttpMethod, HttpRequest, RuntimeHttpError, RuntimeHttpHeader, Transport,
15};
16use serde::{Deserialize, Serialize};
17
18use crate::cli_args::{flag_value, os_arg, split_flag};
19
20#[derive(Debug, Eq, PartialEq)]
21pub struct PublishPlan {
22    pub receipt_path: PathBuf,
23    pub api_base_url: Option<String>,
24    pub token: Option<String>,
25    pub allow_local_api: bool,
26    pub json: bool,
27}
28
29#[derive(Debug)]
30pub enum PublishCliError {
31    MissingReceipt,
32    ExtraReceipt,
33    UnknownFlag(String),
34    ReadReceipt { path: String, message: String },
35    InvalidReceiptJson { path: String, message: String },
36    MissingToken,
37    TransportInit(RuntimeHttpError),
38    Config(ConfigError),
39    Publish(PublishError),
40    Serialize(String),
41}
42
43impl fmt::Display for PublishCliError {
44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::MissingReceipt => write!(formatter, "runx publish requires a receipt JSON path"),
47            Self::ExtraReceipt => write!(
48                formatter,
49                "runx publish accepts exactly one receipt JSON path"
50            ),
51            Self::UnknownFlag(flag) => write!(formatter, "unknown publish flag {flag}"),
52            Self::ReadReceipt { path, message } => {
53                write!(formatter, "failed to read receipt {path}: {message}")
54            }
55            Self::InvalidReceiptJson { path, message } => {
56                write!(formatter, "receipt {path} is not valid JSON: {message}")
57            }
58            Self::MissingToken => write!(
59                formatter,
60                "missing public API token; run `runx login`, pass --token, or set RUNX_PUBLIC_API_TOKEN"
61            ),
62            Self::TransportInit(error) => {
63                write!(formatter, "failed to initialize HTTP transport: {error}")
64            }
65            Self::Config(error) => write!(formatter, "{error}"),
66            Self::Publish(error) => write!(formatter, "{error}"),
67            Self::Serialize(message) => {
68                write!(formatter, "failed to serialize publish result: {message}")
69            }
70        }
71    }
72}
73
74impl std::error::Error for PublishCliError {}
75
76impl From<PublishError> for PublishCliError {
77    fn from(error: PublishError) -> Self {
78        Self::Publish(error)
79    }
80}
81
82impl From<ConfigError> for PublishCliError {
83    fn from(error: ConfigError) -> Self {
84        Self::Config(error)
85    }
86}
87
88#[derive(Debug)]
89pub enum PublishError {
90    RuntimeHttp(RuntimeHttpError),
91    HttpStatus {
92        status: u16,
93        body: String,
94    },
95    InvalidJson(String),
96    RunxApi {
97        code: String,
98        detail: String,
99        hint: Option<String>,
100        retry_after_seconds: Option<u32>,
101    },
102}
103
104impl fmt::Display for PublishError {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::RuntimeHttp(error) => write!(formatter, "{error}"),
108            Self::HttpStatus { status, body } => {
109                write!(formatter, "runx-api publish returned HTTP {status}: {body}")
110            }
111            Self::InvalidJson(message) => {
112                write!(
113                    formatter,
114                    "runx-api publish returned invalid JSON: {message}"
115                )
116            }
117            Self::RunxApi {
118                code, detail, hint, ..
119            } => {
120                write!(
121                    formatter,
122                    "{}",
123                    publish_error_message(code, detail, hint.as_deref())
124                )
125            }
126        }
127    }
128}
129
130impl std::error::Error for PublishError {}
131
132impl From<RuntimeHttpError> for PublishError {
133    fn from(error: RuntimeHttpError) -> Self {
134        Self::RuntimeHttp(error)
135    }
136}
137
138fn publish_error_message(code: &str, detail: &str, hint: Option<&str>) -> String {
139    if code == "missing_scope" && detail.contains("receipts:write") {
140        return [
141            "This token can publish skills but not receipts.",
142            "The receipt notary requires `receipts:write`.",
143            "Use `runx publish --token <receipt-token> <receipt.json>` or set `RUNX_PUBLIC_API_TOKEN` to a receipt-capable token.",
144            "Your stored login token is still valid for the scopes it was issued with.",
145        ]
146        .join(" ");
147    }
148    let mut message = format!("runx-api publish returned error [{code}]: {detail}");
149    if let Some(hint) = hint.filter(|value| !value.trim().is_empty()) {
150        message.push_str(&format!(" Hint: {hint}"));
151    }
152    message
153}
154
155#[derive(Clone, Debug)]
156struct PublishOptions<'a> {
157    base_url: &'a str,
158    token: &'a str,
159    receipt: &'a JsonValue,
160}
161
162#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
163pub struct ReceiptPublishResponse {
164    pub status: String,
165    #[serde(default)]
166    pub replay_status: Option<String>,
167    pub digest: String,
168    pub public_hash: String,
169    pub mode: String,
170    pub published: bool,
171    #[serde(default)]
172    pub public_url: Option<String>,
173    #[serde(default)]
174    pub receipt_id: Option<String>,
175    #[serde(default)]
176    pub verdict: Option<JsonValue>,
177}
178
179pub fn parse_publish_plan(args: &[OsString]) -> Result<PublishPlan, String> {
180    let mut receipt_path = None;
181    let mut api_base_url = None;
182    let mut token = None;
183    let mut allow_local_api = false;
184    let mut json = false;
185    let mut index = 1;
186    while index < args.len() {
187        let arg = os_arg(args, index, "publish")?;
188        if !arg.starts_with('-') {
189            if receipt_path.is_some() {
190                return Err(PublishCliError::ExtraReceipt.to_string());
191            }
192            receipt_path = Some(PathBuf::from(arg));
193            index += 1;
194            continue;
195        }
196        let (flag, inline_value) = split_flag(arg);
197        match flag {
198            "--json" | "-j" => {
199                if inline_value.is_some() {
200                    return Err("--json does not take a value".to_owned());
201                }
202                json = true;
203                index += 1;
204            }
205            "--api-base-url" => {
206                let (value, next_index) = flag_value(args, index, flag, inline_value, "publish")?;
207                api_base_url = Some(value);
208                index = next_index;
209            }
210            "--token" => {
211                let (value, next_index) = flag_value(args, index, flag, inline_value, "publish")?;
212                token = Some(value);
213                index = next_index;
214            }
215            "--allow-local-api" => {
216                if inline_value.is_some() {
217                    return Err("--allow-local-api does not take a value".to_owned());
218                }
219                allow_local_api = true;
220                index += 1;
221            }
222            _ => return Err(PublishCliError::UnknownFlag(flag.to_owned()).to_string()),
223        }
224    }
225    Ok(PublishPlan {
226        receipt_path: receipt_path.ok_or_else(|| PublishCliError::MissingReceipt.to_string())?,
227        api_base_url,
228        token,
229        allow_local_api,
230        json,
231    })
232}
233
234pub fn run_native_publish(plan: PublishPlan) -> ExitCode {
235    let cwd = match std::env::current_dir() {
236        Ok(cwd) => cwd,
237        Err(error) => {
238            let _ignored = crate::cli_io::write_stderr(&format!(
239                "runx publish: failed to resolve cwd: {error}\n"
240            ));
241            return ExitCode::from(1);
242        }
243    };
244    match run_publish_command(&plan, &crate::history::env_map(), &cwd) {
245        Ok(output) => crate::cli_io::write_stdout_code(&output, 0),
246        Err(error) => {
247            if plan.json {
248                let body = serde_json::json!({
249                    "status": "failure",
250                    "error": {
251                        "message": error.to_string(),
252                        "code": "publish_failed",
253                    },
254                });
255                let serialized = serde_json::to_string_pretty(&body)
256                    .unwrap_or_else(|_| "{\"status\":\"failure\"}".to_owned());
257                return crate::cli_io::write_stdout_code(&format!("{serialized}\n"), 1);
258            }
259            let _ignored = crate::cli_io::write_stderr(&format!("runx publish: {error}\n"));
260            ExitCode::from(1)
261        }
262    }
263}
264
265fn run_publish_command(
266    plan: &PublishPlan,
267    env: &BTreeMap<String, String>,
268    cwd: &Path,
269) -> Result<String, PublishCliError> {
270    let receipt = read_receipt_json(&plan.receipt_path)?;
271    let base_url = resolve_public_api_base_url(plan, env);
272    let token = resolve_publish_token(plan, env, cwd)?.ok_or(PublishCliError::MissingToken)?;
273    let transport = crate::public_api::transport(allow_local_api(plan, env))
274        .map_err(PublishCliError::TransportInit)?;
275    let response = publish_receipt(
276        &transport,
277        &PublishOptions {
278            base_url: &base_url,
279            token: &token,
280            receipt: &receipt,
281        },
282    )?;
283    render_publish_result(plan.json, &response)
284}
285
286fn allow_local_api(plan: &PublishPlan, env: &BTreeMap<String, String>) -> bool {
287    crate::public_api::private_network_allowed(
288        plan.allow_local_api,
289        env,
290        "RUNX_PUBLISH_ALLOW_LOCAL_API",
291    )
292}
293
294fn read_receipt_json(path: &PathBuf) -> Result<JsonValue, PublishCliError> {
295    let text = fs::read_to_string(path).map_err(|error| PublishCliError::ReadReceipt {
296        path: path.display().to_string(),
297        message: error.to_string(),
298    })?;
299    serde_json::from_str(&text).map_err(|error| PublishCliError::InvalidReceiptJson {
300        path: path.display().to_string(),
301        message: error.to_string(),
302    })
303}
304
305fn resolve_public_api_base_url(plan: &PublishPlan, env: &BTreeMap<String, String>) -> String {
306    crate::public_api::resolve_base_url(plan.api_base_url.as_deref(), env)
307}
308
309fn resolve_publish_token(
310    plan: &PublishPlan,
311    env: &BTreeMap<String, String>,
312    cwd: &Path,
313) -> Result<Option<String>, PublishCliError> {
314    crate::public_api_token::resolve(plan.token.as_deref(), env, cwd).map_err(PublishCliError::from)
315}
316
317fn publish_receipt<T: Transport>(
318    transport: &T,
319    options: &PublishOptions<'_>,
320) -> Result<ReceiptPublishResponse, PublishError> {
321    let body = serde_json::json!({
322        "publish": true,
323        "receipt": options.receipt,
324    })
325    .to_string();
326    let response = transport.send(HttpRequest {
327        method: HttpMethod::Post,
328        url: format!(
329            "{}/v1/receipts/notarize",
330            options.base_url.trim_end_matches('/')
331        ),
332        headers: vec![
333            RuntimeHttpHeader::new("authorization", format!("Bearer {}", options.token)),
334            RuntimeHttpHeader::new("content-type", "application/json"),
335        ],
336        body: Some(body),
337    })?;
338    if !(200..=299).contains(&response.status) {
339        if let Some(error) = crate::public_api::parse_error(&response.body) {
340            return Err(PublishError::RunxApi {
341                code: error.code,
342                detail: error.detail,
343                hint: error.hint,
344                retry_after_seconds: error.retry_after_seconds,
345            });
346        }
347        return Err(PublishError::HttpStatus {
348            status: response.status,
349            body: response.body,
350        });
351    }
352    serde_json::from_str(&response.body)
353        .map_err(|error| PublishError::InvalidJson(error.to_string()))
354}
355
356fn render_publish_result(
357    json: bool,
358    response: &ReceiptPublishResponse,
359) -> Result<String, PublishCliError> {
360    if json {
361        return serde_json::to_string_pretty(response)
362            .map(|value| format!("{value}\n"))
363            .map_err(|error| PublishCliError::Serialize(error.to_string()));
364    }
365    let mut out = String::new();
366    let verb = if response.published {
367        "published"
368    } else {
369        "notarized"
370    };
371    out.push_str(&format!(
372        "{verb} receipt {} ({})\n",
373        response.digest, response.mode
374    ));
375    out.push_str(&format!("  status:      {}\n", response.status));
376    out.push_str(&format!("  published:   {}\n", response.published));
377    out.push_str(&format!("  public hash: {}\n", response.public_hash));
378    if let Some(receipt_id) = &response.receipt_id {
379        out.push_str(&format!("  receipt id:  {receipt_id}\n"));
380    }
381    if let Some(url) = &response.public_url {
382        out.push_str(&format!("  public url:  {url}\n"));
383    }
384    if let Some(replay_status) = &response.replay_status {
385        out.push_str(&format!("  replay:      {replay_status}\n"));
386    }
387    if let Some(verdict) = &response.verdict {
388        out.push_str(&format!(
389            "  verdict:     {}\n",
390            compact_json(verdict).map_err(|error| PublishCliError::Serialize(error.to_string()))?
391        ));
392    }
393    Ok(out)
394}
395
396fn compact_json(value: &JsonValue) -> Result<String, serde_json::Error> {
397    serde_json::to_string(value)
398}
399
400#[cfg(test)]
401#[path = "publish_tests.rs"]
402mod publish_tests;