1use 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;