|
4 | 4 | #![forbid(unsafe_code)] |
5 | 5 |
|
6 | 6 | use std::{ |
| 7 | + collections::HashMap, |
7 | 8 | io::{Read, Write}, |
8 | | - path::PathBuf, |
| 9 | + path::{Path, PathBuf}, |
9 | 10 | process::{Command, Stdio}, |
10 | 11 | }; |
11 | 12 |
|
@@ -54,6 +55,28 @@ pub struct UpdateArgs { |
54 | 55 | local: bool, |
55 | 56 | } |
56 | 57 |
|
| 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 | + |
57 | 80 | #[derive(Subcommand, Debug)] |
58 | 81 | enum Commands { |
59 | 82 | /// Run the dry-run functionality |
@@ -85,6 +108,12 @@ enum Commands { |
85 | 108 | /// Print the path to the HDP repository directory |
86 | 109 | #[command(name = "pwd")] |
87 | 110 | 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), |
88 | 117 | } |
89 | 118 |
|
90 | 119 | #[tokio::main] |
@@ -219,6 +248,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { |
219 | 248 | let hdp_path = get_hdp_path()?; |
220 | 249 | println!("{}", hdp_path.display()); |
221 | 250 | } |
| 251 | + Commands::Upload(upload_args) => { |
| 252 | + upload_module(upload_args).await?; |
| 253 | + } |
222 | 254 | } |
223 | 255 |
|
224 | 256 | Ok(()) |
@@ -301,3 +333,212 @@ fn setup_tracing(log_level: Option<&String>, debug: bool) -> Result<(), Box<dyn |
301 | 333 |
|
302 | 334 | Ok(()) |
303 | 335 | } |
| 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(¤t_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(¤t_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