use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use bee::swarm::{BatchId, PrivateKey, Reference, Topic};
use bee::{Client, Error};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct State {
topic_name: String,
topic_hex: String,
owner_hex: String,
feed_manifest_ref: String,
history: Vec<HistoryEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct HistoryEntry {
timestamp: u64,
site_ref: String,
note: String,
}
const STATE_PATH: &str = ".swarmdeploy/state.json";
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Error> {
let url = env::var("BEE_URL").unwrap_or_else(|_| "http://localhost:1633".into());
let mut args = env::args().skip(1);
let cmd = args
.next()
.ok_or_else(|| Error::argument("usage: swarm-deploy <init|push|history|rollback> ..."))?;
let client = Client::new(&url)?;
match cmd.as_str() {
"init" => {
let topic_name = args
.next()
.ok_or_else(|| Error::argument("usage: swarm-deploy init <topic-name>"))?;
cmd_init(&client, &url, &topic_name).await
}
"push" => {
let dir = args
.next()
.ok_or_else(|| Error::argument("usage: swarm-deploy push <local-dir>"))?;
let note = args.next().unwrap_or_default();
cmd_push(&client, &url, &dir, ¬e).await
}
"history" => cmd_history(),
"rollback" => {
let idx_str = args
.next()
.ok_or_else(|| Error::argument("usage: swarm-deploy rollback <index>"))?;
let idx: usize = idx_str
.parse()
.map_err(|e| Error::argument(format!("invalid index: {e}")))?;
cmd_rollback(&client, &url, idx).await
}
other => Err(Error::argument(format!("unknown command: {other}"))),
}
}
async fn cmd_init(client: &Client, url: &str, topic_name: &str) -> Result<(), Error> {
if PathBuf::from(STATE_PATH).exists() {
return Err(Error::argument(format!(
"{STATE_PATH} already exists — already initialised"
)));
}
let batch_id = env_batch()?;
let signer = env_signer()?;
let owner = signer.public_key()?.address();
let topic = Topic::from_string(topic_name);
let feed_manifest = client
.file()
.create_feed_manifest(&batch_id, &owner, &topic)
.await?;
let state = State {
topic_name: topic_name.into(),
topic_hex: topic.to_hex(),
owner_hex: owner.to_hex(),
feed_manifest_ref: feed_manifest.to_hex(),
history: vec![],
};
save_state(&state)?;
let trimmed = url.trim_end_matches('/');
println!("Initialised swarm-deploy for {topic_name:?}");
println!(" feed manifest: {}", feed_manifest.to_hex());
println!(" stable URL: {trimmed}/bzz/{}/", feed_manifest.to_hex());
println!("\n(Empty until first `swarm-deploy push <dir>`.)");
Ok(())
}
async fn cmd_push(client: &Client, url: &str, dir: &str, note: &str) -> Result<(), Error> {
let mut state = load_state()?;
let batch_id = env_batch()?;
let signer = env_signer()?;
let topic = Topic::from_hex(&state.topic_hex)?;
println!("Uploading {dir}...");
let result = client
.file()
.upload_collection(&batch_id, dir, None)
.await?;
let site_ref = result.reference.clone();
println!(" site ref: {}", site_ref.to_hex());
println!("Updating feed pointer...");
client
.file()
.update_feed_with_reference(&batch_id, &signer, &topic, &site_ref, None)
.await?;
let ts = now_secs();
state.history.push(HistoryEntry {
timestamp: ts,
site_ref: site_ref.to_hex(),
note: note.into(),
});
save_state(&state)?;
let trimmed = url.trim_end_matches('/');
println!("\nDeployed v{}", state.history.len());
println!(" stable URL: {trimmed}/bzz/{}/", state.feed_manifest_ref);
println!(" this rev: {trimmed}/bzz/{}/", site_ref.to_hex());
Ok(())
}
fn cmd_history() -> Result<(), Error> {
let state = load_state()?;
if state.history.is_empty() {
println!("(no deploys yet)");
return Ok(());
}
println!("topic: {}", state.topic_name);
println!("feed manifest: {}", state.feed_manifest_ref);
println!("\n# {:<10} {:<64} note", "ts", "site_ref");
for (i, e) in state.history.iter().enumerate() {
println!(
"{:<2} {:<10} {:<64} {}",
i, e.timestamp, e.site_ref, e.note
);
}
Ok(())
}
async fn cmd_rollback(client: &Client, url: &str, idx: usize) -> Result<(), Error> {
let mut state = load_state()?;
let target = state
.history
.get(idx)
.ok_or_else(|| Error::argument(format!("no version at index {idx}")))?
.clone();
let batch_id = env_batch()?;
let signer = env_signer()?;
let topic = Topic::from_hex(&state.topic_hex)?;
let site_ref = Reference::from_hex(&target.site_ref)?;
client
.file()
.update_feed_with_reference(&batch_id, &signer, &topic, &site_ref, None)
.await?;
state.history.push(HistoryEntry {
timestamp: now_secs(),
site_ref: target.site_ref.clone(),
note: format!("rollback to #{idx}"),
});
save_state(&state)?;
let trimmed = url.trim_end_matches('/');
println!("Rolled back to #{idx}: {}", target.site_ref);
println!(" stable URL: {trimmed}/bzz/{}/", state.feed_manifest_ref);
Ok(())
}
fn env_batch() -> Result<BatchId, Error> {
let h = env::var("BEE_BATCH_ID").map_err(|_| Error::argument("BEE_BATCH_ID is required"))?;
BatchId::from_hex(&h)
}
fn env_signer() -> Result<PrivateKey, Error> {
let h =
env::var("BEE_SIGNER_HEX").map_err(|_| Error::argument("BEE_SIGNER_HEX is required"))?;
PrivateKey::from_hex(&h)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn save_state(s: &State) -> Result<(), Error> {
if let Some(parent) = PathBuf::from(STATE_PATH).parent() {
fs::create_dir_all(parent).map_err(|e| Error::argument(format!("mkdir: {e}")))?;
}
let bytes = serde_json::to_vec_pretty(s)?;
fs::write(STATE_PATH, bytes).map_err(|e| Error::argument(format!("write state: {e}")))?;
Ok(())
}
fn load_state() -> Result<State, Error> {
let bytes = fs::read(STATE_PATH)
.map_err(|_| Error::argument(format!("{STATE_PATH} not found — run `init` first")))?;
let s: State = serde_json::from_slice(&bytes)?;
Ok(s)
}