feat(nu-cli): initial Faz 0 scaffold
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0e506c7bc0
11 changed files with 315 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
target/
|
||||||
|
*.rs.bk
|
||||||
|
.env
|
||||||
|
~/.nu/keystore/
|
||||||
35
CLAUDE.md
Normal file
35
CLAUDE.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# nu-cli — CLAUDE.md
|
||||||
|
|
||||||
|
Geliştirici CLI. Binary adı `nu`.
|
||||||
|
|
||||||
|
## Komutlar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nu wallet new [--label default] # Yeni keypair üret, ~/.nu/keystore/ altına kaydet
|
||||||
|
nu wallet address [--label default] # Adres göster
|
||||||
|
nu wallet send --to <addr> --amount <n>
|
||||||
|
|
||||||
|
nu node info # nu_chainInfo RPC
|
||||||
|
nu node stake --amount <n>
|
||||||
|
nu node unstake --amount <n>
|
||||||
|
|
||||||
|
nu story submit --story-id <id> --parent-id <id> --ipfs-hash <cid>
|
||||||
|
nu story register --node-id <id> # Oylama için kayıt
|
||||||
|
nu story vote --node-id <id> --approve true/false
|
||||||
|
nu story show --story-id <id>
|
||||||
|
|
||||||
|
nu genesis init --chain-id <id> # genesis.toml üret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keystore
|
||||||
|
|
||||||
|
Keyler `~/.nu/keystore/<label>.key` altında saklanır.
|
||||||
|
**Faz 0:** hex plaintext (geliştirme kolaylığı)
|
||||||
|
**Faz 1:** password-encrypted (production güvenliği)
|
||||||
|
|
||||||
|
## Geliştirme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --bin nu -- wallet new
|
||||||
|
cargo run --bin nu -- node info --rpc http://localhost:8545
|
||||||
|
```
|
||||||
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "nu-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nu"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
k256 = { version = "0.13", features = ["ecdsa"] } # secp256k1
|
||||||
|
rand = "0.8"
|
||||||
|
hex = "0.4"
|
||||||
|
dirs = "5"
|
||||||
25
src/commands/genesis.rs
Normal file
25
src/commands/genesis.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Subcommand;
|
||||||
|
use crate::rpc::Client;
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum GenesisCmd {
|
||||||
|
/// Initialize genesis block configuration
|
||||||
|
Init {
|
||||||
|
#[arg(long)] chain_id: String,
|
||||||
|
#[arg(long, default_value = "genesis.toml")] output: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(cmd: GenesisCmd, _rpc: &Client) -> Result<()> {
|
||||||
|
match cmd {
|
||||||
|
GenesisCmd::Init { chain_id, output } => {
|
||||||
|
let template = format!(
|
||||||
|
"[genesis]\nchain_id = \"{chain_id}\"\n\n[bootstrap_peers]\n# Add validator multiaddrs here\n"
|
||||||
|
);
|
||||||
|
std::fs::write(&output, template)?;
|
||||||
|
println!("Genesis config written to {output}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
4
src/commands/mod.rs
Normal file
4
src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod wallet;
|
||||||
|
pub mod node;
|
||||||
|
pub mod story;
|
||||||
|
pub mod genesis;
|
||||||
25
src/commands/node.rs
Normal file
25
src/commands/node.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Subcommand;
|
||||||
|
use crate::rpc::Client;
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum NodeCmd {
|
||||||
|
/// Show chain info
|
||||||
|
Info,
|
||||||
|
/// Register as validator
|
||||||
|
Stake { #[arg(long)] amount: u64 },
|
||||||
|
/// Unstake from validator set
|
||||||
|
Unstake { #[arg(long)] amount: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(cmd: NodeCmd, rpc: &Client) -> Result<()> {
|
||||||
|
match cmd {
|
||||||
|
NodeCmd::Info => {
|
||||||
|
let info = rpc.call("nu_chainInfo", serde_json::json!({})).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&info)?);
|
||||||
|
}
|
||||||
|
NodeCmd::Stake { amount } => println!("TODO: stake {amount} NUT"),
|
||||||
|
NodeCmd::Unstake { amount } => println!("TODO: unstake {amount} NUT"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
45
src/commands/story.rs
Normal file
45
src/commands/story.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Subcommand;
|
||||||
|
use crate::rpc::Client;
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum StoryCmd {
|
||||||
|
/// Submit a new story node
|
||||||
|
Submit {
|
||||||
|
#[arg(long)] story_id: String,
|
||||||
|
#[arg(long)] parent_id: String,
|
||||||
|
#[arg(long)] ipfs_hash: String,
|
||||||
|
},
|
||||||
|
/// Register to vote on a node
|
||||||
|
Register {
|
||||||
|
#[arg(long)] node_id: String,
|
||||||
|
},
|
||||||
|
/// Cast a vote
|
||||||
|
Vote {
|
||||||
|
#[arg(long)] node_id: String,
|
||||||
|
#[arg(long)] approve: bool,
|
||||||
|
},
|
||||||
|
/// Show story DAG
|
||||||
|
Show {
|
||||||
|
#[arg(long)] story_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(cmd: StoryCmd, rpc: &Client) -> Result<()> {
|
||||||
|
match cmd {
|
||||||
|
StoryCmd::Submit { story_id, parent_id, ipfs_hash } => {
|
||||||
|
println!("TODO: submit node story={story_id} parent={parent_id} hash={ipfs_hash}");
|
||||||
|
}
|
||||||
|
StoryCmd::Register { node_id } => {
|
||||||
|
println!("TODO: register vote for node={node_id}");
|
||||||
|
}
|
||||||
|
StoryCmd::Vote { node_id, approve } => {
|
||||||
|
println!("TODO: vote node={node_id} approve={approve}");
|
||||||
|
}
|
||||||
|
StoryCmd::Show { story_id } => {
|
||||||
|
let result = rpc.call("nu_getStory", serde_json::json!({ "story_id": story_id })).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
56
src/commands/wallet.rs
Normal file
56
src/commands/wallet.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Subcommand;
|
||||||
|
use k256::ecdsa::SigningKey;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum WalletCmd {
|
||||||
|
/// Generate a new keypair
|
||||||
|
New {
|
||||||
|
/// Label for the keystore file
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
/// Show address for a keystore entry
|
||||||
|
Address {
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
/// Send NUT tokens
|
||||||
|
Send {
|
||||||
|
#[arg(long)] to: String,
|
||||||
|
#[arg(long)] amount: u64,
|
||||||
|
#[arg(long, default_value = "default")] from: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(cmd: WalletCmd) -> Result<()> {
|
||||||
|
match cmd {
|
||||||
|
WalletCmd::New { label } => {
|
||||||
|
let key = SigningKey::random(&mut OsRng);
|
||||||
|
let bytes = key.to_bytes();
|
||||||
|
let hex_key = hex::encode(bytes);
|
||||||
|
|
||||||
|
let dir = crate::config::keystore_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let path = dir.join(format!("{label}.key"));
|
||||||
|
// Store as hex — Faz 1: add password encryption
|
||||||
|
std::fs::write(&path, &hex_key)?;
|
||||||
|
|
||||||
|
let verifying = key.verifying_key();
|
||||||
|
println!("Created keystore: {}", path.display());
|
||||||
|
println!("Address (pubkey compressed): {}", hex::encode(verifying.to_sec1_bytes()));
|
||||||
|
}
|
||||||
|
WalletCmd::Address { label } => {
|
||||||
|
let dir = crate::config::keystore_dir();
|
||||||
|
let raw = std::fs::read_to_string(dir.join(format!("{label}.key")))?;
|
||||||
|
let bytes = hex::decode(raw.trim())?;
|
||||||
|
let key = SigningKey::from_bytes(bytes.as_slice().into())?;
|
||||||
|
println!("{}", hex::encode(key.verifying_key().to_sec1_bytes()));
|
||||||
|
}
|
||||||
|
WalletCmd::Send { to, amount, from } => {
|
||||||
|
println!("TODO: nu wallet send --to {to} --amount {amount} (from: {from})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
8
src/config.rs
Normal file
8
src/config.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn keystore_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".nu")
|
||||||
|
.join("keystore")
|
||||||
|
}
|
||||||
56
src/main.rs
Normal file
56
src/main.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
mod commands;
|
||||||
|
mod config;
|
||||||
|
mod rpc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "nu", version, about = "Narrative Union CLI")]
|
||||||
|
struct Cli {
|
||||||
|
/// RPC endpoint (overrides config)
|
||||||
|
#[arg(long, env = "NU_RPC_URL", default_value = "http://localhost:8545")]
|
||||||
|
rpc: String,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Wallet management
|
||||||
|
Wallet {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: commands::wallet::WalletCmd,
|
||||||
|
},
|
||||||
|
/// Node operations
|
||||||
|
Node {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: commands::node::NodeCmd,
|
||||||
|
},
|
||||||
|
/// Story operations
|
||||||
|
Story {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: commands::story::StoryCmd,
|
||||||
|
},
|
||||||
|
/// Genesis operations (dev only)
|
||||||
|
Genesis {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: commands::genesis::GenesisCmd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let rpc = rpc::Client::new(&cli.rpc);
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Command::Wallet { action } => commands::wallet::run(action).await?,
|
||||||
|
Command::Node { action } => commands::node::run(action, &rpc).await?,
|
||||||
|
Command::Story { action } => commands::story::run(action, &rpc).await?,
|
||||||
|
Command::Genesis{ action } => commands::genesis::run(action, &rpc).await?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
37
src/rpc.rs
Normal file
37
src/rpc.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
endpoint: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(endpoint: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn call(&self, method: &str, params: Value) -> Result<Value> {
|
||||||
|
let body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
"id": 1,
|
||||||
|
});
|
||||||
|
let resp: Value = self.http
|
||||||
|
.post(&self.endpoint)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(err) = resp.get("error") {
|
||||||
|
anyhow::bail!("RPC error: {}", err);
|
||||||
|
}
|
||||||
|
Ok(resp["result"].clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue