Skip to content

Commit a491622

Browse files
authored
Merge pull request #243 from HerodotusDev/feat/hdp-server-integration
Hdp server integration for module uploads
2 parents e43cc78 + 74593f0 commit a491622

File tree

2 files changed

+245
-1
lines changed

2 files changed

+245
-1
lines changed

crates/cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ tokio.workspace = true
2323
types.workspace = true
2424
tracing.workspace = true
2525
tracing-subscriber.workspace = true
26+
reqwest = { version = "0.12", features = ["multipart"] }
27+
toml = "0.8"
28+
walkdir = "2.4"
2629

2730
[features]
2831
default = ["fetcher/progress_bars"]

crates/cli/src/main.rs

Lines changed: 242 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
#![forbid(unsafe_code)]
55

66
use std::{
7+
collections::HashMap,
78
io::{Read, Write},
8-
path::PathBuf,
9+
path::{Path, PathBuf},
910
process::{Command, Stdio},
1011
};
1112

@@ -54,6 +55,28 @@ pub struct UpdateArgs {
5455
local: bool,
5556
}
5657

58+
#[derive(Parser, Debug)]
59+
pub struct UploadArgs {
60+
/// API key for authentication
61+
#[arg(short = 'k', long = "api-key")]
62+
api_key: Option<String>,
63+
/// HDP server URL (defaults to HDP_SERVER_URL env var or http://localhost:3001)
64+
#[arg(short = 'u', long = "url")]
65+
server_url: Option<String>,
66+
/// Module description
67+
#[arg(long = "description")]
68+
description: Option<String>,
69+
/// Tags (comma-separated)
70+
#[arg(long = "tags")]
71+
tags: Option<String>,
72+
/// License
73+
#[arg(long = "license")]
74+
license: Option<String>,
75+
/// Version changelog
76+
#[arg(long = "changelog")]
77+
changelog: Option<String>,
78+
}
79+
5780
#[derive(Subcommand, Debug)]
5881
enum Commands {
5982
/// Run the dry-run functionality
@@ -85,6 +108,12 @@ enum Commands {
85108
/// Print the path to the HDP repository directory
86109
#[command(name = "pwd")]
87110
Pwd,
111+
/// Upload a module to the HDP server
112+
///
113+
/// Builds the module, collects source files, and uploads everything to the HDP server.
114+
/// Must be run from the module root directory (where Scarb.toml is located).
115+
#[command(name = "upload")]
116+
Upload(UploadArgs),
88117
}
89118

90119
#[tokio::main]
@@ -219,6 +248,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
219248
let hdp_path = get_hdp_path()?;
220249
println!("{}", hdp_path.display());
221250
}
251+
Commands::Upload(upload_args) => {
252+
upload_module(upload_args).await?;
253+
}
222254
}
223255

224256
Ok(())
@@ -301,3 +333,212 @@ fn setup_tracing(log_level: Option<&String>, debug: bool) -> Result<(), Box<dyn
301333

302334
Ok(())
303335
}
336+
337+
async fn upload_module(args: UploadArgs) -> Result<(), Box<dyn std::error::Error>> {
338+
use walkdir::WalkDir;
339+
340+
let UploadArgs {
341+
api_key,
342+
server_url,
343+
description,
344+
tags,
345+
license,
346+
changelog,
347+
} = args;
348+
349+
info!("📦 Starting module upload...");
350+
351+
// Get current directory
352+
let current_dir = std::env::current_dir().map_err(Error::IO)?;
353+
let scarb_toml_path = current_dir.join("Scarb.toml");
354+
355+
if !scarb_toml_path.exists() {
356+
return Err("Scarb.toml not found in current directory. Please run this command from the module root directory.".into());
357+
}
358+
359+
// Read Scarb.toml
360+
let scarb_toml_content = std::fs::read_to_string(&scarb_toml_path).map_err(Error::IO)?;
361+
let scarb_toml: toml::Value = toml::from_str(&scarb_toml_content)?;
362+
363+
let package = scarb_toml
364+
.get("package")
365+
.ok_or("Missing [package] section in Scarb.toml")?;
366+
367+
let module_name = package
368+
.get("name")
369+
.and_then(|v| v.as_str())
370+
.ok_or("Missing 'name' field in [package] section")?
371+
.to_string();
372+
373+
let module_version = package
374+
.get("version")
375+
.and_then(|v| v.as_str())
376+
.ok_or("Missing 'version' field in [package] section")?
377+
.to_string();
378+
379+
info!("📋 Module: {} v{}", module_name, module_version);
380+
381+
// Build the module with scarb
382+
info!("🔨 Building module with scarb...");
383+
let build_output = Command::new("scarb")
384+
.arg("build")
385+
.current_dir(&current_dir)
386+
.output()
387+
.map_err(|e| Error::IO(std::io::Error::new(
388+
std::io::ErrorKind::NotFound,
389+
format!("Failed to run scarb build: {}. Make sure scarb is installed and in PATH.", e),
390+
)))?;
391+
392+
if !build_output.status.success() {
393+
let stderr = String::from_utf8_lossy(&build_output.stderr);
394+
return Err(format!("Scarb build failed:\n{}", stderr).into());
395+
}
396+
397+
info!("✅ Build successful");
398+
399+
// Find the compiled contract class file
400+
// Scarb builds to target/dev/<package_name>_<target_name>.compiled_contract_class.json
401+
let target_dir = current_dir.join("target/dev");
402+
403+
let mut compiled_file_path: Option<PathBuf> = None;
404+
if target_dir.exists() {
405+
for entry in std::fs::read_dir(&target_dir).map_err(Error::IO)? {
406+
let entry = entry.map_err(Error::IO)?;
407+
let file_name = entry.file_name();
408+
let file_name_str = file_name.to_string_lossy();
409+
if file_name_str.ends_with(".compiled_contract_class.json") {
410+
compiled_file_path = Some(entry.path());
411+
break;
412+
}
413+
}
414+
}
415+
416+
let compiled_file_path = compiled_file_path.ok_or("Compiled contract class file not found. Make sure the module has a [[target.starknet-contract]] section in Scarb.toml")?;
417+
info!("📄 Found compiled program: {}", compiled_file_path.display());
418+
419+
// Read compiled program
420+
let compiled_program_bytes = std::fs::read(&compiled_file_path).map_err(Error::IO)?;
421+
let compiled_program_json: serde_json::Value = serde_json::from_slice(&compiled_program_bytes)?;
422+
423+
// Extract compiler version from compiled program
424+
let compiler_version = compiled_program_json
425+
.get("compiler_version")
426+
.and_then(|v| v.as_str())
427+
.ok_or("Missing compiler_version in compiled program")?
428+
.to_string();
429+
430+
// Try to find ABI file (usually in target/dev/<package_name>_<target_name>.contract_class.json)
431+
let abi_file_path = compiled_file_path
432+
.to_string_lossy()
433+
.replace(".compiled_contract_class.json", ".contract_class.json");
434+
let abi_file_path = PathBuf::from(abi_file_path);
435+
436+
let abi_json: Option<serde_json::Value> = if abi_file_path.exists() {
437+
let abi_content = std::fs::read_to_string(&abi_file_path).map_err(Error::IO)?;
438+
let contract_class: serde_json::Value = serde_json::from_str(&abi_content)?;
439+
contract_class.get("abi").cloned()
440+
} else {
441+
None
442+
};
443+
444+
// Collect all source files from src directory
445+
let src_dir = current_dir.join("src");
446+
if !src_dir.exists() {
447+
return Err("src directory not found".into());
448+
}
449+
450+
let mut source_files: HashMap<String, String> = HashMap::new();
451+
for entry in WalkDir::new(&src_dir) {
452+
let entry = entry.map_err(Error::IO)?;
453+
let path = entry.path();
454+
455+
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("cairo") {
456+
let relative_path = path
457+
.strip_prefix(&current_dir)
458+
.map_err(|e| Error::IO(std::io::Error::other(format!("Failed to get relative path: {}", e))))?
459+
.to_string_lossy()
460+
.to_string();
461+
462+
let content = std::fs::read_to_string(path).map_err(Error::IO)?;
463+
source_files.insert(relative_path, content);
464+
}
465+
}
466+
467+
info!("📁 Collected {} source files", source_files.len());
468+
469+
// Get API key
470+
let api_key = api_key
471+
.or_else(|| std::env::var("HERODOTUS_CLOUD_API_KEY").ok())
472+
.ok_or("API key required. Provide via --api-key flag or HERODOTUS_CLOUD_API_KEY environment variable")?;
473+
474+
// Get server URL
475+
let server_url = server_url
476+
.or_else(|| std::env::var("HDP_SERVER_URL").ok())
477+
.unwrap_or_else(|| "http://localhost:3001".to_string());
478+
479+
info!("🚀 Uploading to {}...", server_url);
480+
481+
// Build multipart form
482+
let client = reqwest::Client::new();
483+
let mut form = reqwest::multipart::Form::new();
484+
485+
// Add compiled module
486+
form = form.part("module", reqwest::multipart::Part::bytes(compiled_program_bytes)
487+
.file_name("module.json")
488+
.mime_str("application/json")?);
489+
490+
// Add required fields
491+
form = form.text("name", module_name.clone());
492+
form = form.text("compiler_version", compiler_version);
493+
form = form.text("version", module_version.clone());
494+
495+
// Add optional fields
496+
if let Some(desc) = description {
497+
form = form.text("description", desc);
498+
}
499+
if let Some(tags_str) = tags {
500+
form = form.text("tags", tags_str);
501+
}
502+
if let Some(lic) = license {
503+
form = form.text("license", lic);
504+
}
505+
if let Some(changelog_str) = changelog {
506+
form = form.text("version_changelog", changelog_str);
507+
}
508+
509+
// Add source files as JSON
510+
let source_files_json = serde_json::to_string(&source_files)?;
511+
form = form.text("source_files", source_files_json);
512+
513+
// Add ABI if available
514+
if let Some(abi) = abi_json {
515+
let abi_str = serde_json::to_string(&abi)?;
516+
form = form.text("abi", abi_str);
517+
}
518+
519+
// Add Scarb.toml
520+
form = form.text("scarb_toml", scarb_toml_content);
521+
522+
// Upload
523+
let response = client
524+
.post(format!("{}/modules/upload", server_url))
525+
.header("X-API-KEY", api_key)
526+
.multipart(form)
527+
.send()
528+
.await?;
529+
530+
if !response.status().is_success() {
531+
let error_text = response.text().await?;
532+
return Err(format!("Upload failed ({}): {}", response.status(), error_text).into());
533+
}
534+
535+
let result: serde_json::Value = response.json().await?;
536+
info!("✅ Module uploaded successfully!");
537+
info!(" Module ID: {}", result.get("id").and_then(|v| v.as_str()).unwrap_or("N/A"));
538+
info!(" Program Hash: {}", result.get("programHash").and_then(|v| v.as_str()).unwrap_or("N/A"));
539+
540+
println!();
541+
println!("✅ Successfully uploaded module '{}' v{}", module_name, module_version);
542+
543+
Ok(())
544+
}

0 commit comments

Comments
 (0)