Skip to content

Commit 075f0c1

Browse files
committed
Modules management CLI
1 parent d2d3574 commit 075f0c1

1 file changed

Lines changed: 212 additions & 7 deletions

File tree

crates/cli/src/main.rs

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,32 @@ pub struct ExecuteArgs {
9696
injected_state: Option<String>,
9797
}
9898

99+
#[derive(Parser, Debug)]
100+
pub struct ListModulesArgs {
101+
/// API key for authentication (required unless --all)
102+
#[arg(short = 'k', long = "api-key")]
103+
api_key: Option<String>,
104+
/// HDP server URL (defaults to HDP_SERVER_URL env var or http://localhost:3001)
105+
#[arg(short = 'u', long = "url")]
106+
server_url: Option<String>,
107+
/// List all modules (not only current user's modules)
108+
#[arg(long = "all")]
109+
all: bool,
110+
}
111+
112+
#[derive(Parser, Debug)]
113+
pub struct ModuleVersionsArgs {
114+
/// Module id
115+
#[arg(short = 'm', long = "module-id")]
116+
module_id: String,
117+
/// API key for authentication (optional; forwarded as X-API-KEY when provided)
118+
#[arg(short = 'k', long = "api-key")]
119+
api_key: Option<String>,
120+
/// HDP server URL (defaults to HDP_SERVER_URL env var or http://localhost:3001)
121+
#[arg(short = 'u', long = "url")]
122+
server_url: Option<String>,
123+
}
124+
99125
#[derive(Subcommand, Debug)]
100126
enum Commands {
101127
/// Run the dry-run functionality
@@ -127,6 +153,16 @@ enum Commands {
127153
/// Print the path to the HDP repository directory
128154
#[command(name = "pwd")]
129155
Pwd,
156+
/// Cloud-related commands (upload, execute, list modules, module versions)
157+
#[command(name = "cloud")]
158+
Cloud {
159+
#[command(subcommand)]
160+
command: CloudCommands,
161+
},
162+
}
163+
164+
#[derive(Subcommand, Debug)]
165+
enum CloudCommands {
130166
/// Upload a module to the HDP server
131167
///
132168
/// Builds the module, collects source files, and uploads everything to the HDP server.
@@ -138,6 +174,14 @@ enum Commands {
138174
/// Builds the module and submits an HDP task directly with input.compiled_class in the JSON payload.
139175
#[command(name = "execute")]
140176
Execute(ExecuteArgs),
177+
/// List modules in a clean table format
178+
///
179+
/// By default lists current user's modules. Use --all to list all modules.
180+
#[command(name = "list-modules")]
181+
ListModules(ListModulesArgs),
182+
/// List all versions of a given module
183+
#[command(name = "module-versions")]
184+
ModuleVersions(ModuleVersionsArgs),
141185
}
142186

143187
#[tokio::main]
@@ -279,11 +323,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
279323
let hdp_path = get_hdp_path()?;
280324
println!("{}", hdp_path.display());
281325
}
282-
Commands::Upload(upload_args) => {
283-
upload_module(upload_args).await?;
284-
}
285-
Commands::Execute(execute_args) => {
286-
execute_task(execute_args).await?;
326+
Commands::Cloud { command } => match command {
327+
CloudCommands::Upload(upload_args) => {
328+
upload_module(upload_args).await?;
329+
}
330+
CloudCommands::Execute(execute_args) => {
331+
execute_task(execute_args).await?;
332+
}
333+
CloudCommands::ListModules(args) => {
334+
list_modules(args).await?;
335+
}
336+
CloudCommands::ModuleVersions(args) => {
337+
list_module_versions(args).await?;
338+
}
287339
}
288340
}
289341

@@ -569,7 +621,6 @@ async fn upload_module(args: UploadArgs) -> Result<(), Box<dyn std::error::Error
569621
"https://herodotus.cloud/en/hdp/module/{}?program_hash={}",
570622
module_id, program_hash
571623
);
572-
info!(" Herodotus Cloud: {}", module_link);
573624
println!("🔗 Module page: {}", module_link);
574625
}
575626

@@ -710,7 +761,6 @@ async fn execute_task(args: ExecuteArgs) -> Result<(), Box<dyn std::error::Error
710761
info!(" Task UUID: {}", task_uuid);
711762
if task_uuid != "N/A" {
712763
let task_link = format!("https://herodotus.cloud/en/hdp/task/{}", task_uuid);
713-
info!(" Herodotus Cloud: {}", task_link);
714764
println!("🔗 Task page: {}", task_link);
715765
}
716766
println!();
@@ -721,6 +771,161 @@ async fn execute_task(args: ExecuteArgs) -> Result<(), Box<dyn std::error::Error
721771
Ok(())
722772
}
723773

774+
fn resolve_server_url(server_url: Option<String>) -> String {
775+
server_url
776+
.or_else(|| std::env::var("HDP_SERVER_URL").ok())
777+
.unwrap_or_else(|| "http://localhost:3001".to_string())
778+
}
779+
780+
fn print_table(headers: &[&str], rows: &[Vec<String>]) {
781+
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
782+
for row in rows {
783+
for (idx, cell) in row.iter().enumerate() {
784+
if idx < widths.len() {
785+
widths[idx] = widths[idx].max(cell.len());
786+
}
787+
}
788+
}
789+
790+
let border = widths
791+
.iter()
792+
.map(|w| "-".repeat(*w + 2))
793+
.collect::<Vec<_>>()
794+
.join("+");
795+
println!("+{}+", border);
796+
797+
let header_line = headers
798+
.iter()
799+
.enumerate()
800+
.map(|(idx, h)| format!(" {:<width$} ", h, width = widths[idx]))
801+
.collect::<Vec<_>>()
802+
.join("|");
803+
println!("|{}|", header_line);
804+
println!("+{}+", border);
805+
806+
for row in rows {
807+
let line = row
808+
.iter()
809+
.enumerate()
810+
.map(|(idx, c)| format!(" {:<width$} ", c, width = widths[idx]))
811+
.collect::<Vec<_>>()
812+
.join("|");
813+
println!("|{}|", line);
814+
}
815+
println!("+{}+", border);
816+
}
817+
818+
async fn list_modules(args: ListModulesArgs) -> Result<(), Box<dyn std::error::Error>> {
819+
let server_url = resolve_server_url(args.server_url);
820+
let client = reqwest::Client::new();
821+
822+
info!("📦 Fetching modules from {}...", server_url);
823+
let resp = if args.all {
824+
client.get(format!("{}/modules", server_url)).send().await?
825+
} else {
826+
let api_key = args
827+
.api_key
828+
.or_else(|| std::env::var("HERODOTUS_CLOUD_API_KEY").ok())
829+
.ok_or("API key required. Provide via --api-key or HERODOTUS_CLOUD_API_KEY (or use --all)")?;
830+
client
831+
.get(format!("{}/modules/my", server_url))
832+
.header("X-API-KEY", api_key)
833+
.send()
834+
.await?
835+
};
836+
if !resp.status().is_success() {
837+
let status = resp.status();
838+
let err = resp.text().await?;
839+
return Err(format!("Failed to list modules ({}): {}", status, err).into());
840+
}
841+
842+
let body: serde_json::Value = resp.json().await?;
843+
let modules = body
844+
.get("modules")
845+
.and_then(|v| v.as_array())
846+
.ok_or("Invalid response format: missing modules array")?;
847+
848+
if modules.is_empty() {
849+
println!("No modules found.");
850+
return Ok(());
851+
}
852+
853+
let rows = modules
854+
.iter()
855+
.map(|m| {
856+
vec![
857+
m.get("id").and_then(|v| v.as_str()).unwrap_or("-").to_string(),
858+
m.get("name").and_then(|v| v.as_str()).unwrap_or("-").to_string(),
859+
m.get("latestModuleVersionProgramHash")
860+
.and_then(|v| v.as_str())
861+
.unwrap_or("-")
862+
.to_string(),
863+
m.get("creatorUser")
864+
.and_then(|v| v.as_str())
865+
.unwrap_or("-")
866+
.to_string(),
867+
m.get("publishedOnMarketplace")
868+
.and_then(|v| v.as_bool())
869+
.map(|v| if v { "yes" } else { "no" })
870+
.unwrap_or("-")
871+
.to_string(),
872+
]
873+
})
874+
.collect::<Vec<_>>();
875+
876+
println!();
877+
print_table(&["MODULE_ID", "NAME", "LATEST_PROGRAM_HASH", "CREATOR_USER", "MARKETPLACE"], &rows);
878+
Ok(())
879+
}
880+
881+
async fn list_module_versions(args: ModuleVersionsArgs) -> Result<(), Box<dyn std::error::Error>> {
882+
let server_url = resolve_server_url(args.server_url);
883+
let client = reqwest::Client::new();
884+
let url = format!("{}/modules/{}/versions", server_url, args.module_id);
885+
let api_key = args.api_key.or_else(|| std::env::var("HERODOTUS_CLOUD_API_KEY").ok());
886+
887+
info!("📚 Fetching module versions from {}...", server_url);
888+
let mut request = client.get(url);
889+
if let Some(key) = api_key {
890+
request = request.header("X-API-KEY", key);
891+
}
892+
let resp = request.send().await?;
893+
if !resp.status().is_success() {
894+
let status = resp.status();
895+
let err = resp.text().await?;
896+
return Err(format!("Failed to list module versions ({}): {}", status, err).into());
897+
}
898+
899+
let versions: serde_json::Value = resp.json().await?;
900+
let versions = versions
901+
.as_array()
902+
.ok_or("Invalid response format: expected versions array")?;
903+
904+
if versions.is_empty() {
905+
println!("No versions found for this module.");
906+
return Ok(());
907+
}
908+
909+
let rows = versions
910+
.iter()
911+
.map(|v| {
912+
vec![
913+
v.get("version").and_then(|x| x.as_str()).unwrap_or("-").to_string(),
914+
v.get("hash").and_then(|x| x.as_str()).unwrap_or("-").to_string(),
915+
v.get("usageCount")
916+
.and_then(|x| x.as_i64())
917+
.map(|x| x.to_string())
918+
.unwrap_or_else(|| "-".to_string()),
919+
v.get("createdAt").and_then(|x| x.as_str()).unwrap_or("-").to_string(),
920+
]
921+
})
922+
.collect::<Vec<_>>();
923+
924+
println!();
925+
print_table(&["VERSION", "PROGRAM_HASH", "USAGE_COUNT", "CREATED_AT"], &rows);
926+
Ok(())
927+
}
928+
724929
fn find_compiled_contract_class_file(current_dir: &Path, module_name: &str) -> Result<Option<PathBuf>, Error> {
725930
let mut search_dirs = Vec::new();
726931
let mut cursor = Some(current_dir.to_path_buf());

0 commit comments

Comments
 (0)