use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::Path;
use std::process::Command;
mod content;
use content::templates;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
project_name: String,
},
Build,
Test,
Deploy,
Clean {
#[arg(long, help = "Remove all files including keypair files")]
no_preserve: bool,
},
Add {
package_name: String,
},
Search {
query: Option<String>,
},
#[command(name = "--help")]
Help,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Commands::Init { project_name } => {
init_project(project_name)?;
}
Commands::Build => {
println!("Building program");
let status = Command::new("cargo")
.arg("build-sbf")
.spawn()?
.wait()
.with_context(|| "Failed to build project")?;
if !status.success() {
anyhow::bail!("Build failed with exit code: {:?}", status.code());
} else {
println!("Build completed successfully!");
}
}
Commands::Test => {
println!("Testing program");
let status = Command::new("cargo")
.arg("test")
.spawn()?
.wait()
.with_context(|| "Failed to test project")?;
if !status.success() {
anyhow::bail!("Test failed with exit code: {:?}", status.code());
} else {
println!("Tested successfully!");
}
}
Commands::Deploy => {
println!("Deploying program");
let target_deploy_dir = Path::new("target/deploy");
if !target_deploy_dir.exists() {
anyhow::bail!("target/deploy directory not found. Please run 'pinoc build' first.");
}
let mut so_file = None;
for entry in fs::read_dir(target_deploy_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("so") {
so_file = Some(path);
break;
}
}
let so_path = so_file.ok_or_else(|| {
anyhow::anyhow!(
"No .so file found in target/deploy. Please run 'pinoc build' first."
)
})?;
let status = Command::new("solana")
.arg("program")
.arg("deploy")
.arg(&so_path)
.spawn()?
.wait()
.with_context(|| "Failed to deploy program")?;
if !status.success() {
anyhow::bail!("Deploy failed with exit code: {:?}", status.code());
} else {
println!("Program deployed successfully!");
}
}
Commands::Clean { no_preserve } => {
clean_project(*no_preserve)?;
}
Commands::Add { package_name } => {
add_package(package_name)?;
}
Commands::Search { query } => {
search_packages(query.as_deref())?;
}
Commands::Help => {
display_help_banner()?;
}
}
Ok(())
}
fn display_help_banner() -> Result<()> {
println!(
r#"
_
_ __ (_)_ __ ___ ___
| '_ \| | '_ \ / _ \ / __|
| |_) | | | | | (_) | (__
| .__/|_|_| |_|\___/ \___|
|_|
"#
);
println!("๐พ Setup your pinocchio project blazingly fast๐จ");
println!("\n๐๏ธ AVAILABLE COMMANDS:");
println!(" pinoc init <project_name> - Initialize a new Pinocchio project");
println!(" pinoc build - Build the project");
println!(" pinoc test - Run project tests");
println!(" pinoc deploy - Deploy the project");
println!(
" pinoc clean [--no-preserve] - Clean target directory (preserves keypairs by default)"
);
println!(" pinoc add <package_name> - Add a package to the project");
println!(" pinoc search [query] - Search for pinocchio packages on crates.io");
Ok(())
}
fn init_project(project_name: &str) -> Result<()> {
if !is_valid_project_name(project_name) {
anyhow::bail!(
"Invalid project name '{}'. Project names can only contain letters, numbers, and underscores (_). \
Hyphens (-) and other special characters are not allowed.",
project_name
);
}
println!(
r#"
_
_ __ (_)_ __ ___ ___
| '_ \| | '_ \ / _ \ / __|
| |_) | | | | | (_) | (__
| .__/|_|_| |_|\___/ \___|
|_|
"#
);
println!("๐ง๐ปโ๐ณ Initializing your pinocchio project: {}", project_name);
println!(""); let project_dir = Path::new(project_name);
fs::create_dir_all(project_dir)
.with_context(|| format!("Failed to create project directory: {}", project_name))?;
let output = Command::new("cargo")
.arg("init")
.arg("--lib")
.arg("--name")
.arg(project_name)
.current_dir(project_dir)
.output()
.with_context(|| "Failed to run 'cargo init'")?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to initialize Cargo project: {}", error);
}
let deploy_dir = project_dir.join("target").join("deploy");
fs::create_dir_all(&deploy_dir)?;
let keypair_path = format!("./target/deploy/{}-keypair.json", project_name);
let keygen_output = Command::new("solana-keygen")
.arg("new")
.arg("-o")
.arg(&keypair_path)
.arg("--no-bip39-passphrase") .current_dir(project_dir)
.output()
.with_context(|| "Failed to generate keypair")?;
if !keygen_output.status.success() {
let error = String::from_utf8_lossy(&keygen_output.stderr);
anyhow::bail!("Failed to generate keypair: {}", error);
}
let address_output = Command::new("solana")
.arg("address")
.arg("-k")
.arg(&keypair_path)
.current_dir(project_dir)
.output()
.with_context(|| "Failed to read keypair address")?;
let program_address: String;
if address_output.status.success() {
program_address = String::from_utf8_lossy(&address_output.stdout)
.trim()
.to_string();
println!("Generated program address: {}", program_address);
} else {
let error = String::from_utf8_lossy(&address_output.stderr);
anyhow::bail!("Failed to get program address from keypair: {}", error);
}
let user_address_output = Command::new("solana")
.arg("address")
.current_dir(project_dir)
.output()
.with_context(|| "Failed to get user address")?;
let user_address: String;
if user_address_output.status.success() {
user_address = String::from_utf8_lossy(&user_address_output.stdout)
.trim()
.to_string();
} else {
let error = String::from_utf8_lossy(&user_address_output.stderr);
println!("Failed to get user Solana address: {}", error);
user_address = String::new();
}
create_project_structure(project_dir, user_address, program_address.clone())?;
update_cargo_toml(project_dir, project_name)?;
init_git_repo(project_dir, project_name)?;
println!("");
println!(
"โ
Pinocchio Project '{}' initialized successfully!",
project_name
);
println!("\n๐ Next steps:");
println!("$ cd {}", project_name);
println!("$ pinoc build");
println!("$ pinoc test");
println!("$ pinoc deploy");
println!("");
Ok(())
}
fn is_valid_project_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
name.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn init_git_repo(project_dir: &Path, project_name: &str) -> Result<()> {
let git_init_output = Command::new("git")
.arg("init")
.current_dir(project_dir)
.output()
.with_context(|| "Failed to initialize git repository")?;
if !git_init_output.status.success() {
let error = String::from_utf8_lossy(&git_init_output.stderr);
println!("Warning: Failed to initialize git repository: {}", error);
return Ok(());
}
let git_add_output = Command::new("git")
.arg("add")
.arg(".")
.current_dir(project_dir)
.output()
.with_context(|| "Failed to add files to git")?;
if !git_add_output.status.success() {
let error = String::from_utf8_lossy(&git_add_output.stderr);
println!("Warning: Failed to add files to git: {}", error);
return Ok(());
}
let commit_message = format!("Initial commit: Setup Pinocchio project '{}'", project_name);
let git_commit_output = Command::new("git")
.arg("commit")
.arg("-m")
.arg(&commit_message)
.current_dir(project_dir)
.output()
.with_context(|| "Failed to make initial commit")?;
if !git_commit_output.status.success() {
let error = String::from_utf8_lossy(&git_commit_output.stderr);
println!("Warning: Failed to make initial commit: {}", error);
if error.contains("user.email") || error.contains("user.name") {
println!("Hint: Set your git config with:");
println!(" git config --global user.email \"you@example.com\"");
println!(" git config --global user.name \"Your Name\"");
}
return Ok(());
}
Ok(())
}
fn create_project_structure(
project_dir: &Path,
user_address: String,
program_address: String,
) -> Result<()> {
fs::write(project_dir.join("README.md"), templates::readme_md())?;
fs::write(project_dir.join(".gitignore"), templates::gitignore())?;
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir)?;
fs::write(
src_dir.join("lib.rs"),
templates::lib_rs(program_address.as_str()),
)?;
fs::write(src_dir.join("entrypoint.rs"), templates::entrypoint_rs())?;
fs::write(src_dir.join("errors.rs"), templates::errors_rs())?;
let instructions_dir = src_dir.join("instructions");
fs::create_dir_all(&instructions_dir)?;
fs::write(
instructions_dir.join("mod.rs"),
templates::instructions::instructions_mod_rs(),
)?;
fs::write(
instructions_dir.join("initialize.rs"),
templates::instructions::initialize(),
)?;
let states_dir = src_dir.join("states");
fs::create_dir_all(&states_dir)?;
fs::write(
states_dir.join("mod.rs"),
templates::states::states_mod_rs(),
)?;
fs::write(states_dir.join("utils.rs"), templates::states::utils_rs())?;
fs::write(states_dir.join("state.rs"), templates::states::state_rs())?;
let test_dir = project_dir.join("tests");
fs::create_dir_all(&test_dir)?;
let test_address = &user_address;
let project_name = project_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("project");
fs::write(
test_dir.join("tests.rs"),
templates::unit_tests::unit_test_rs(test_address, &program_address, project_name),
)?;
Ok(())
}
fn update_cargo_toml(project_dir: &Path, project_name: &str) -> Result<()> {
let cargo_toml = format!(
r#"[package]
name = "{}"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
pinocchio = "0.8.4"
pinocchio-log = "0.4.0"
pinocchio-pubkey = "0.2.4"
pinocchio-system = "0.2.3"
shank = "0.4.2"
[dev-dependencies]
solana-sdk = "2.2.1"
mollusk-svm = "0.2.0"
mollusk-svm-bencher = "0.2.0"
[features]
no-entrypoint = []
std = []
test-default = ["no-entrypoint", "std"]
"#,
project_name
);
fs::write(project_dir.join("Cargo.toml"), cargo_toml)?;
Ok(())
}
fn add_package(package_name: &str) -> Result<()> {
let cargo_toml_path = Path::new("Cargo.toml");
if !cargo_toml_path.exists() {
anyhow::bail!(
"Cargo.toml not found. Please run this command from the project root directory."
);
}
println!("๐ฆ Adding package: {}", package_name);
let status = Command::new("cargo")
.arg("add")
.arg(package_name)
.spawn()?
.wait()
.with_context(|| format!("Failed to add package: {}", package_name))?;
if !status.success() {
anyhow::bail!(
"Failed to add package '{}' with exit code: {:?}",
package_name,
status.code()
);
} else {
println!("โ
Package '{}' added successfully!", package_name);
}
Ok(())
}
fn search_packages(query: Option<&str>) -> Result<()> {
let search_term = match query {
Some(q) => format!("pinocchio {}", q),
None => "pinocchio".to_string(),
};
println!("๐ Searching for packages matching '{}'...\n", search_term);
let output = Command::new("cargo")
.arg("search")
.arg(&search_term)
.arg("--limit")
.arg("20")
.output()
.with_context(|| "Failed to run 'cargo search'. Make sure cargo is installed.")?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("cargo search failed: {}", error);
}
let search_results = String::from_utf8_lossy(&output.stdout);
let packages = parse_cargo_search_output(&search_results)?;
if packages.is_empty() {
println!("No packages found matching '{}'.", search_term);
println!("๐ก Try a different search term or check https://crates.io for more packages.");
return Ok(());
}
println!("๐ฆ Found {} package(s):\n", packages.len());
for package in packages {
println!("๐น {}", package.name);
println!(" Description: {}", package.description);
println!(" Version: {}", package.version);
println!(" Install: pinoc add {}", package.name);
println!();
}
Ok(())
}
#[derive(Debug)]
struct SearchResult {
name: String,
description: String,
version: String,
}
fn parse_cargo_search_output(output: &str) -> Result<Vec<SearchResult>> {
let mut packages = Vec::new();
for line in output.lines() {
if line.trim().is_empty() || line.starts_with("...") {
continue;
}
if let Some(equals_pos) = line.find(" = ") {
let name = line[..equals_pos].trim().to_string();
let rest = &line[equals_pos + 3..];
if let Some(quote_end) = rest[1..].find('"') {
let version = rest[1..quote_end + 1].to_string();
let description = if let Some(hash_pos) = rest.find(" # ") {
rest[hash_pos + 3..].trim().to_string()
} else {
"No description available".to_string()
};
packages.push(SearchResult {
name,
description,
version,
});
}
}
}
Ok(packages)
}
fn clean_project(no_preserve: bool) -> Result<()> {
println!("๐งน Cleaning project...");
let target_dir = Path::new("target");
if !target_dir.exists() {
println!("โ
No target directory found. Nothing to clean.");
return Ok(());
}
let deploy_dir = target_dir.join("deploy");
let mut preserved_keypairs = Vec::new();
if !no_preserve && deploy_dir.exists() {
for entry in fs::read_dir(&deploy_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(file_name) = path.file_name() {
if let Some(name_str) = file_name.to_str() {
if name_str.ends_with("-keypair.json") {
let keypair_name = name_str.to_string();
let keypair_content = fs::read(&path)
.with_context(|| format!("Failed to read keypair: {}", name_str))?;
preserved_keypairs.push((keypair_name, keypair_content));
println!("๐ Preserving keypair: {}", name_str);
}
}
}
}
}
fs::remove_dir_all(target_dir).with_context(|| "Failed to remove target directory")?;
if !no_preserve {
fs::create_dir_all(&deploy_dir)
.with_context(|| "Failed to recreate target/deploy directory")?;
let keypair_count = preserved_keypairs.len();
for (keypair_name, keypair_content) in preserved_keypairs {
let new_path = deploy_dir.join(&keypair_name);
fs::write(&new_path, keypair_content)
.with_context(|| format!("Failed to restore keypair: {}", keypair_name))?;
}
println!("โ
Project cleaned successfully!");
if keypair_count > 0 {
println!("๐ Preserved {} keypair file(s)", keypair_count);
} else {
println!("โ
Project cleaned successfully!");
}
} else {
println!("โ
Project cleaned successfully! (keypairs not preserved)");
}
Ok(())
}