use anyhow::{Context, Result};
use std::path::Path;
use super::config::{self, InstanceState};
use super::ssh;
use super::vast::VastClient;
const REMOTE_HRMW: &str = "/root/.local/bin/hrmw";
const REMOTE_WALLET: &str = "/root/cloudminer_webcash.db";
pub fn print_offers_table(offers: &[super::vast::Offer]) {
println!();
println!(
"{:<3} {:<5} {:<16} {:>10} {:>8} {:>10} {:>10}",
"#", "GPUs", "GPU", "TFLOPS", "$/hr", "FLOPS/$", "Offer ID"
);
println!("{}", "-".repeat(75));
for (i, o) in offers.iter().enumerate() {
println!(
"{:<3} {:<5} {:<16} {:>10.1} {:>8.2} {:>10.1} {:>10}",
i + 1,
format!("{}x", o.num_gpus),
o.gpu_name,
o.tflops(),
o.dph_total,
o.flops_per_dollar(),
o.id,
);
}
println!();
}
pub fn prompt_offer_selection(offers: &[super::vast::Offer]) -> Result<&super::vast::Offer> {
if offers.is_empty() {
anyhow::bail!("No GPU offers found matching criteria");
}
if offers.len() == 1 {
println!("Only one offer available — selecting it.");
return Ok(&offers[0]);
}
print!("Select offer [1-{}] (default 1): ", offers.len());
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim();
let idx = if input.is_empty() {
0
} else {
input
.parse::<usize>()
.context("invalid selection")?
.checked_sub(1)
.context("selection must be >= 1")?
};
offers
.get(idx)
.ok_or_else(|| anyhow::anyhow!("selection out of range"))
}
pub async fn start(
label: &str,
machine_id: Option<u64>,
wallet_db_path: &Path,
ssh_key: &ed25519_dalek::SigningKey,
) -> Result<InstanceState> {
let cfg = config::load_config()?;
let api_key = config::resolve_api_key(&cfg)?;
let client = VastClient::new(&api_key);
println!("[1/6] Uploading SSH key to Vast.ai...");
let pubkey = ssh::ssh_public_key_string(ssh_key);
client.upload_ssh_key(&pubkey).await?;
let offer_id = if let Some(id) = machine_id {
println!("[2/6] Using specified machine: {id}");
id
} else {
println!("[2/6] Searching for best GPU offers...");
let offers = client.find_best_offers().await?;
if offers.is_empty() {
anyhow::bail!("No GPU offers found. Check Vast.ai availability.");
}
print_offers_table(&offers);
let selected = prompt_offer_selection(&offers)?;
println!(
" Selected: {}x {} (${:.2}/hr, {:.1} TFLOPS)",
selected.num_gpus,
selected.gpu_name,
selected.dph_total,
selected.tflops()
);
selected.id
};
println!("[3/6] Creating instance...");
let instance_id = client.create_instance(offer_id, "").await?;
println!(" Instance: {instance_id}");
println!("[4/6] Waiting for instance to start...");
let instance = wait_for_running(&client, instance_id).await?;
let (ssh_host, ssh_port) = instance
.ssh_connection()
.ok_or_else(|| anyhow::anyhow!("No SSH connection info"))?;
println!(" SSH: root@{ssh_host}:{ssh_port}");
wait_for_ssh(ssh_key, &ssh_host, ssh_port).await?;
println!("[5/6] Installing hrmw...");
install_hrmw_remote(ssh_key, &ssh_host, ssh_port).await?;
match ssh::exec(
ssh_key,
&ssh_host,
ssh_port,
&format!("{REMOTE_HRMW} webminer list-devices"),
) {
Ok(output) => {
for line in output.trim().lines() {
println!(" {line}");
}
}
Err(_) => println!(" GPU detection unavailable"),
}
println!(" Uploading mining wallet...");
upload_file(ssh_key, &ssh_host, ssh_port, wallet_db_path, REMOTE_WALLET)?;
println!("[6/6] Starting miner...");
let _ = ssh::exec(
ssh_key,
&ssh_host,
ssh_port,
"mkdir -p /root/.harmoniis/wallet",
);
let cmd = format!("{REMOTE_HRMW} webminer run --accept-terms --webcash-wallet {REMOTE_WALLET}");
ssh::exec_background(ssh_key, &ssh_host, ssh_port, &cmd)?;
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
let check = ssh::exec(ssh_key, &ssh_host, ssh_port, "pgrep -a hrmw")?;
if check.trim().is_empty() {
let log =
ssh::exec(ssh_key, &ssh_host, ssh_port, "cat /root/miner.log").unwrap_or_default();
let _ = client.destroy_instance(instance_id).await;
config::clear_state()?;
anyhow::bail!("Miner failed to start. Instance destroyed.\n{log}");
}
if let Ok(log) = ssh::exec(ssh_key, &ssh_host, ssh_port, "head -25 /root/miner.log") {
println!();
for line in log.trim().lines() {
println!(" {line}");
}
}
let state = InstanceState {
instance_id,
offer_id,
label: label.to_string(),
ssh_host: ssh_host.clone(),
ssh_port,
gpu_name: instance.gpu_name.unwrap_or_else(|| "Unknown".to_string()),
num_gpus: instance.num_gpus.unwrap_or(0),
cost_per_hour: instance.dph_total.unwrap_or(0.0),
started_at: chrono::Utc::now().to_rfc3339(),
};
config::save_state(&state)?;
println!();
println!("Mining started.");
println!(" GPU: {}x {}", state.num_gpus, state.gpu_name);
println!(" Cost: ${:.2}/hr", state.cost_per_hour);
println!(" Status: hrmw webminer cloud status");
println!(" Stop: hrmw webminer cloud stop");
Ok(state)
}
pub async fn stop(state: &InstanceState, ssh_key: &ed25519_dalek::SigningKey) -> Result<()> {
println!("Stopping miner on instance {}...", state.instance_id);
let _ = ssh::exec(
ssh_key,
&state.ssh_host,
state.ssh_port,
"kill $(pgrep hrmw) 2>/dev/null || true",
);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
println!("Miner stopped.");
println!();
println!("Recovering mined webcash locally...");
println!();
println!("WARNING: Instance is still running. Vast.ai is still charging.");
println!(" Use `hrmw webminer cloud destroy` to stop charges.");
Ok(())
}
pub async fn destroy(state: &InstanceState) -> Result<()> {
let cfg = config::load_config()?;
let api_key = config::resolve_api_key(&cfg)?;
let client = VastClient::new(&api_key);
println!("Destroying instance {}...", state.instance_id);
client.destroy_instance(state.instance_id).await?;
config::clear_state()?;
println!("Instance destroyed. Charges stopped.");
Ok(())
}
pub async fn status(state: &InstanceState, ssh_key: &ed25519_dalek::SigningKey) -> Result<()> {
let cfg = config::load_config()?;
let api_key = config::resolve_api_key(&cfg)?;
let client = VastClient::new(&api_key);
let instance = client.get_instance(state.instance_id).await?;
println!("Cloud Mining Status");
println!(" Instance: {}", state.instance_id);
println!(
" Status: {}",
instance.actual_status.as_deref().unwrap_or("unknown")
);
println!(" GPU: {}x {}", state.num_gpus, state.gpu_name);
println!(" Cost: ${:.2}/hr", state.cost_per_hour);
println!(" Started: {}", state.started_at);
println!();
match ssh::exec(
ssh_key,
&state.ssh_host,
state.ssh_port,
"pgrep -a hrmw || echo 'NOT_RUNNING'",
) {
Ok(output) if output.contains("NOT_RUNNING") => println!("Miner: NOT RUNNING"),
Ok(_) => println!("Miner: RUNNING"),
Err(e) => println!("Miner check: {e}"),
}
if let Ok(log) = ssh::exec(
ssh_key,
&state.ssh_host,
state.ssh_port,
"tail -10 /root/miner.log 2>/dev/null",
) {
println!();
println!("Last 10 lines:");
for line in log.trim().lines() {
println!(" {line}");
}
}
Ok(())
}
pub fn info(label: &str) {
println!("Mining label: {label}");
println!("Wallet: {label}_webcash.db");
println!("Check balance: hrmw webcash info --label {label}");
println!("Recover mined: hrmw webcash recover --label {label}");
if let Ok(Some(state)) = config::load_state() {
println!();
println!("Active instance: {}", state.instance_id);
println!(" GPU: {}x {}", state.num_gpus, state.gpu_name);
println!(" Cost: ${:.2}/hr", state.cost_per_hour);
} else {
println!("No active cloud mining instance.");
}
}
fn upload_file(
ssh_key: &ed25519_dalek::SigningKey,
host: &str,
port: u16,
local_path: &Path,
remote_path: &str,
) -> Result<()> {
let key_file = ssh::write_temp_key_file(ssh_key)?;
let status = std::process::Command::new("scp")
.args([
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR",
"-i",
&key_file.to_string_lossy(),
"-P",
&port.to_string(),
&local_path.to_string_lossy(),
&format!("root@{host}:{remote_path}"),
])
.status()
.context("scp failed")?;
if !status.success() {
anyhow::bail!("SCP upload failed");
}
Ok(())
}
async fn wait_for_running(client: &VastClient, instance_id: u64) -> Result<super::vast::Instance> {
for i in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
match client.get_instance(instance_id).await {
Ok(inst) if inst.is_running() => return Ok(inst),
Ok(inst) => {
let s = inst.actual_status.as_deref().unwrap_or("unknown");
if i % 3 == 0 {
println!(" Status: {s}...");
}
}
Err(e) if i > 5 => {
let _ = client.destroy_instance(instance_id).await;
return Err(e);
}
Err(_) => {}
}
}
println!(" Instance did not start in 5 minutes — destroying...");
let _ = client.destroy_instance(instance_id).await;
config::clear_state()?;
anyhow::bail!("Instance did not start. Destroyed to stop charges. Try a different offer.")
}
async fn wait_for_ssh(ssh_key: &ed25519_dalek::SigningKey, host: &str, port: u16) -> Result<()> {
for _ in 0..30 {
if let Ok(out) = ssh::exec(ssh_key, host, port, "echo ok") {
if out.contains("ok") {
return Ok(());
}
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
anyhow::bail!("SSH not ready after 2.5 minutes")
}
async fn install_hrmw_remote(
ssh_key: &ed25519_dalek::SigningKey,
host: &str,
port: u16,
) -> Result<()> {
println!(" Installing hrmw...");
ssh::exec(
ssh_key,
host,
port,
"mkdir -p /root/.harmoniis/wallet /root/.local/bin && curl --proto '=https' --tlsv1.2 -sSf https://harmoniis.com/wallet/install | sh",
)
.context("hrmw install failed")?;
let version = ssh::exec(ssh_key, host, port, &format!("{REMOTE_HRMW} --version"))
.context("hrmw verification failed")?;
println!(" {}", version.trim());
Ok(())
}