From 31ab4c3025896f8825ed2c57cb5c2083f23bc7ff Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sun, 4 Jan 2026 09:59:56 -0800 Subject: [PATCH 1/3] add ricochet item toml --- src/client.rs | 57 ++++++++++++++++++++++++++++++++++++--- src/commands/item/mod.rs | 1 + src/commands/item/toml.rs | 36 +++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 22 +++++++++++++++ 5 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/commands/item/mod.rs create mode 100644 src/commands/item/toml.rs diff --git a/src/client.rs b/src/client.rs index 47a5f96..67c5ef6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,12 +1,15 @@ use crate::config::{Config, parse_server_url}; use anyhow::{Context, Result}; +use colored::Colorize; use reqwest::{Client, Response, StatusCode}; use ricochet_core::content::ContentItem; use serde::de::DeserializeOwned; -use std::fs::read_to_string; -use std::path::Path; -use std::pin::Pin; -use std::task::{Context as TaskContext, Poll}; +use std::{ + fs::read_to_string, + path::Path, + pin::Pin, + task::{Context as TaskContext, Poll}, +}; use tokio::io::{AsyncRead, ReadBuf}; use url::Url; @@ -110,6 +113,29 @@ impl RicochetClient { Ok(response.status() == StatusCode::OK) } + /// Check if a key is expired and report if so + /// Use this as a pre-flight check for all API calls where appropriate + pub async fn preflight_key_check(&self) -> Result<()> { + match self.validate_key().await { + Ok(v) => { + if !v { + anyhow::bail!( + "{} Existing credentials are invalid or expired.", + "⚠".yellow() + ); + } else { + Ok(()) + } + } + Err(e) => { + anyhow::bail!( + "{} Failed to validate credential with error {e}", + "!".bright_red() + ); + } + } + } + pub async fn list_items(&self) -> Result> { let mut url = self.base_url.clone(); url.set_path("/api/v0/user/items"); @@ -382,4 +408,27 @@ impl RicochetClient { Ok(()) } + + pub async fn get_ricochet_toml(&self, id: &str) -> Result { + let mut url = self.base_url.clone(); + url.set_path(&format!("/api/v0/content/{}/toml", id)); + + let response = self + .client + .get(url) + .header("Authorization", format!("Key {}", self.api_key)) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + anyhow::bail!("Failed to fetch ricochet.toml: {}", error_text) + } + + let toml_content = response.text().await?; + Ok(toml_content) + } } diff --git a/src/commands/item/mod.rs b/src/commands/item/mod.rs new file mode 100644 index 0000000..47cc331 --- /dev/null +++ b/src/commands/item/mod.rs @@ -0,0 +1 @@ +pub mod toml; diff --git a/src/commands/item/toml.rs b/src/commands/item/toml.rs new file mode 100644 index 0000000..e593ef2 --- /dev/null +++ b/src/commands/item/toml.rs @@ -0,0 +1,36 @@ +use crate::{client::RicochetClient, config::Config}; +use colored::Colorize; +use ricochet_core::content::ContentItem; +use std::{fs::read_to_string, path::PathBuf}; + +pub async fn get_toml( + config: &Config, + id: Option, + path: Option, +) -> anyhow::Result<()> { + let client = RicochetClient::new(config)?; + client.preflight_key_check().await?; + + let id = match id { + Some(id) => id, + None => { + let toml_path = path.unwrap_or(PathBuf::from("_ricochet.toml")); + if !toml_path.exists() { + anyhow::bail!( + "{} Provide either an item ID or a path to a `_ricochet.toml` file.", + "⚠".yellow() + ); + } + let toml = read_to_string(toml_path)?; + let item = ContentItem::from_toml(&toml)?; + let Some(id) = item.content.id else { + anyhow::bail!("Provided _ricochet.toml does not have an item ID") + }; + + id + } + }; + + println!("{}", client.get_ricochet_toml(&id).await?); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 236cb67..818b8a1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,4 +4,5 @@ pub mod delete; pub mod deploy; pub mod init; pub mod invoke; +pub mod item; pub mod list; diff --git a/src/main.rs b/src/main.rs index a4c5e21..556a12a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,11 +109,28 @@ enum Commands { #[arg(long)] dry_run: bool, }, + /// Manage deployed content items + Item { + #[command(subcommand)] + command: ItemCommands, + }, /// Generate markdown documentation (hidden command) #[command(hide = true)] GenerateDocs, } +#[derive(Subcommand)] +enum ItemCommands { + /// Fetch the remote _ricochet.toml for an item + Toml { + /// Content item ID (ULID). If not provided, will read from local _ricochet.toml + id: Option, + /// Path to _ricochet.toml file + #[arg(short = 'p', long)] + path: Option, + }, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -181,6 +198,11 @@ async fn main() -> Result<()> { }) => { commands::init::init_rico_toml(&path, overwrite, dry_run)?; } + Some(Commands::Item { command }) => match command { + ItemCommands::Toml { id, path } => { + commands::item::toml::get_toml(&config, id, path).await?; + } + }, Some(Commands::GenerateDocs) => { let markdown = clap_markdown::help_markdown::(); println!("{}", markdown); From 3277be7c0c405867aac1a2d119df0acaefd76923 Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Sun, 4 Jan 2026 10:00:45 -0800 Subject: [PATCH 2/3] updae docs --- docs/cli-commands.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/cli-commands.md b/docs/cli-commands.md index 7566ebf..d041ace 100644 --- a/docs/cli-commands.md +++ b/docs/cli-commands.md @@ -13,6 +13,8 @@ This document contains the help content for the `ricochet` command-line program. * [`ricochet invoke`↴](#ricochet-invoke) * [`ricochet config`↴](#ricochet-config) * [`ricochet init`↴](#ricochet-init) +* [`ricochet item`↴](#ricochet-item) +* [`ricochet item toml`↴](#ricochet-item-toml) ## `ricochet` @@ -30,6 +32,7 @@ Ricochet CLI * `invoke` — Invoke a task * `config` — Show configuration * `init` — Initialize a new Ricochet deployment +* `item` — Manage deployed content items ###### **Options:** @@ -157,6 +160,34 @@ Initialize a new Ricochet deployment +## `ricochet item` + +Manage deployed content items + +**Usage:** `ricochet item ` + +###### **Subcommands:** + +* `toml` — Fetch the remote _ricochet.toml for an item + + + +## `ricochet item toml` + +Fetch the remote _ricochet.toml for an item + +**Usage:** `ricochet item toml [OPTIONS] [ID]` + +###### **Arguments:** + +* `` — Content item ID (ULID). If not provided, will read from local _ricochet.toml + +###### **Options:** + +* `-p`, `--path ` — Path to _ricochet.toml file + + +
From 5ad04559d0ed17878c1d13c7517f5432b37180ab Mon Sep 17 00:00:00 2001 From: Josiah Parry Date: Mon, 5 Jan 2026 07:09:47 -0800 Subject: [PATCH 3/3] add serial_test to prevent async-related errors --- Cargo.lock | 53 ++++++++++ Cargo.toml | 1 + tests/content_toml.rs | 228 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 tests/content_toml.rs diff --git a/Cargo.lock b/Cargo.lock index cbe5dbf..485fa48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1824,6 +1835,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "serial_test", "tar", "tempfile", "tokio", @@ -1945,12 +1957,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "serde" version = "1.0.228" @@ -2054,6 +2081,32 @@ dependencies = [ "version_check", ] +[[package]] +name = "serial_test" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shell-words" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5fc9333..95d4b74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ webbrowser = "1.0" [dev-dependencies] mockito = "1.5" +serial_test = "3.3" tempfile = "3.8" tokio-test = "0.4" diff --git a/tests/content_toml.rs b/tests/content_toml.rs new file mode 100644 index 0000000..f743c49 --- /dev/null +++ b/tests/content_toml.rs @@ -0,0 +1,228 @@ +use mockito::Server; +use ricochet_cli::{commands::item::toml::get_toml, config::Config}; +use std::{env, fs}; +use tempfile::TempDir; + +const TEST_TOML_RESPONSE: &str = r#"[content] +id = "01KE52BY41EQ7NE89K7Z5MMZ84" +name = "example-app" +entrypoint = "app.R" +access_type = "external" +content_type = "shiny" + +[language] +name = "r" +packages = "renv.lock" + +[serve] +min_instances = 0 +max_instances = 5 +spawn_threshold = 80 +max_connections = 10 +"#; + +const LOCAL_TOML_CONTENT: &str = r#"[content] +id = "01KE52BY41EQ7NE89K7Z5MMZ84" +name = "local-app" +entrypoint = "app.R" +access_type = "external" +content_type = "shiny" + +[language] +name = "r" +packages = "renv.lock" +"#; + +/// Helper to clean up environment variables after tests +fn cleanup_env() { + unsafe { + env::remove_var("RICOCHET_API_KEY"); + env::remove_var("RICOCHET_SERVER"); + } +} + +/// Helper to create a config with mock server and API key +fn create_test_config(server_url: String) -> Config { + let mut config = Config::default(); + config.server = url::Url::parse(&server_url).unwrap(); + config.api_key = Some("rico_test_key_123".to_string()); + config +} + +/// Mock the check_key endpoint +async fn mock_check_key(server: &mut Server) -> mockito::Mock { + server + .mock("GET", "/api/v0/check_key") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid": true}"#) + .create_async() + .await +} + +/// Mock the toml endpoint +async fn mock_get_toml(server: &mut Server, id: &str) -> mockito::Mock { + server + .mock("GET", format!("/api/v0/content/{}/toml", id).as_str()) + .with_status(200) + .with_header("content-type", "application/toml") + .with_body(TEST_TOML_RESPONSE) + .create_async() + .await +} + +#[tokio::test] +#[serial_test::serial] +async fn test_get_toml_with_provided_id() { + cleanup_env(); + let mut server = Server::new_async().await; + + let _mock_check = mock_check_key(&mut server).await; + let _mock_toml = mock_get_toml(&mut server, "01KE52BY41EQ7NE89K7Z5MMZ84").await; + + let config = create_test_config(server.url()); + + // Test with provided ID + let result = get_toml( + &config, + Some("01KE52BY41EQ7NE89K7Z5MMZ84".to_string()), + None, + ) + .await; + + assert!(result.is_ok(), "get_toml should succeed with provided ID"); + + cleanup_env(); +} + +#[tokio::test] +#[serial_test::serial] +async fn test_get_toml_with_default_path() { + cleanup_env(); + let mut server = Server::new_async().await; + + let _mock_check = mock_check_key(&mut server).await; + let _mock_toml = mock_get_toml(&mut server, "01KE52BY41EQ7NE89K7Z5MMZ84").await; + + let config = create_test_config(server.url()); + + // Create a temporary directory and write _ricochet.toml + let temp_dir = TempDir::new().unwrap(); + let toml_path = temp_dir.path().join("_ricochet.toml"); + fs::write(&toml_path, LOCAL_TOML_CONTENT).unwrap(); + + // Change to temp directory + let original_dir = env::current_dir().unwrap(); + env::set_current_dir(temp_dir.path()).unwrap(); + + // Test without ID or path (should read from ./_ricochet.toml) + let result = get_toml(&config, None, None).await; + + // Restore original directory + env::set_current_dir(original_dir).unwrap(); + + assert!( + result.is_ok(), + "get_toml should succeed reading from default _ricochet.toml" + ); + + cleanup_env(); +} + +#[tokio::test] +#[serial_test::serial] +async fn test_get_toml_with_specified_path() { + cleanup_env(); + let mut server = Server::new_async().await; + + let _mock_check = mock_check_key(&mut server).await; + let _mock_toml = mock_get_toml(&mut server, "01KE52BY41EQ7NE89K7Z5MMZ84").await; + + let config = create_test_config(server.url()); + + // Create a temporary directory structure: ./some/other/path/_ricochet.toml + let temp_dir = TempDir::new().unwrap(); + let nested_path = temp_dir.path().join("some").join("other").join("path"); + fs::create_dir_all(&nested_path).unwrap(); + let toml_path = nested_path.join("_ricochet.toml"); + fs::write(&toml_path, LOCAL_TOML_CONTENT).unwrap(); + + // Test with specified path + let result = get_toml(&config, None, Some(toml_path)).await; + + assert!( + result.is_ok(), + "get_toml should succeed with specified path" + ); + + cleanup_env(); +} + +#[tokio::test] +#[serial_test::serial] +async fn test_get_toml_missing_id_and_file() { + cleanup_env(); + let mut server = Server::new_async().await; + + let _mock_check = mock_check_key(&mut server).await; + + let config = create_test_config(server.url()); + + // Create a temporary directory without _ricochet.toml + let temp_dir = TempDir::new().unwrap(); + let original_dir = env::current_dir().unwrap(); + env::set_current_dir(temp_dir.path()).unwrap(); + + // Test without ID or path and no _ricochet.toml should fail + let result = get_toml(&config, None, None).await; + + env::set_current_dir(original_dir).unwrap(); + + assert!( + result.is_err(), + "get_toml should fail when no ID provided and no _ricochet.toml found" + ); + + cleanup_env(); +} + +#[tokio::test] +#[serial_test::serial] +async fn test_get_toml_file_without_id() { + cleanup_env(); + let mut server = Server::new_async().await; + + let _mock_check = mock_check_key(&mut server).await; + + let config = create_test_config(server.url()); + + // Create _ricochet.toml without an id field + let temp_dir = TempDir::new().unwrap(); + let toml_path = temp_dir.path().join("_ricochet.toml"); + let toml_without_id = r#"[content] +name = "app-without-id" +entrypoint = "app.R" +access_type = "external" +content_type = "shiny" + +[language] +name = "r" +packages = "renv.lock" +"#; + fs::write(&toml_path, toml_without_id).unwrap(); + + let original_dir = env::current_dir().unwrap(); + env::set_current_dir(temp_dir.path()).unwrap(); + + // Test should fail because _ricochet.toml has no id + let result = get_toml(&config, None, None).await; + + env::set_current_dir(original_dir).unwrap(); + + assert!( + result.is_err(), + "get_toml should fail when _ricochet.toml has no id field" + ); + + cleanup_env(); +}