Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "nest-cli"
version = "0.2.2"
version = "0.2.3"
edition = "2024"
description = "CLI and TUI for Nest Vaults on Plume Network"
license = "MIT"
Expand All @@ -16,6 +16,8 @@ clap = { version = "4.5", features = ["derive", "env"] }
# TUI
ratatui = "0.30"
crossterm = "0.29"
tui-input = "0.15"
throbber-widgets-tui = "0.11"

# EVM / blockchain
alloy = { version = "1.7", features = ["full", "sol-types"] }
Expand All @@ -38,7 +40,9 @@ hex = "0.4"
sha2 = "0.10"
flate2 = "1"
tar = "0.4"
zip = { version = "0.6", default-features = false, features = ["deflate"] }
chrono = { version = "0.4", features = ["serde"] }
semver = "1"

[dev-dependencies]
wiremock = "0.6"
5 changes: 1 addition & 4 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ use std::sync::OnceLock;

use clap::{Args, Parser, Subcommand, ValueEnum};

use crate::updater;

/// Returns help text for --private-key that shows the wallet address (never the raw key).
fn private_key_help() -> &'static str {
static HELP: OnceLock<String> = OnceLock::new();
Expand All @@ -30,8 +28,7 @@ fn derive_address(key_hex: &str) -> Option<String> {
#[derive(Parser)]
#[command(
name = "nest",
version = updater::version_with_update_notification(),
long_about = updater::help_with_update_notification(),
version = env!("CARGO_PKG_VERSION"),
about = "Interact with Nest Vaults on Plume Network",
after_help = "Powered by Plume | https://plume.org"
)]
Expand Down
75 changes: 56 additions & 19 deletions src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,6 @@ pub async fn run(args: UpdateArgs) -> Result<()> {
let triple = host_triple()
.ok_or_else(|| eyre::eyre!("unsupported platform for self-update. {INSTALL_HINT}"))?;

// Self-replacing a running .exe is fiddly; not supported in v0.2.x.
if triple == "x86_64-pc-windows-msvc" {
eprintln!(
"Windows update isn't supported in v0.2.x — re-download the latest zip from https://nestagents.io/downloads/"
);
return Ok(());
}

let artifact = manifest.artifacts.get(triple).ok_or_else(|| {
eyre::eyre!("no release artifact for `{triple}` in the manifest. {INSTALL_HINT}")
})?;
Expand All @@ -57,8 +49,12 @@ pub async fn run(args: UpdateArgs) -> Result<()> {
install_archive(&bytes, &current_exe)
.wrap_err_with(|| format!("install failed. {INSTALL_HINT}"))?;

// Refresh the notifier's cache so the next invocation of the freshly-installed
// binary doesn't show a stale "update available" notice. Best-effort.
let _ = crate::version_check::record_installed_version(&manifest.version);

eprintln!(
"Updated to v{}. Restart nest to use the new version.",
"Updated to v{}. The next `nest` invocation uses the new binary.",
manifest.version
);
Ok(())
Expand Down Expand Up @@ -99,9 +95,12 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
Ok(())
}

/// Extract the `nest` binary from a `.tar.gz` and atomically replace `dest`.
/// Stages the new binary alongside `dest` (same filesystem) and renames over it,
/// which is atomic on Unix and safe even while the old binary is running.
/// Extract the `nest` binary from the release archive and replace `dest`.
/// - Unix: `.tar.gz` → stage alongside `dest` (same filesystem) → `rename` (atomic).
/// - Windows: `.zip` → rename `dest` → `dest.old` (you can't overwrite a running
/// `.exe` but you *can* rename it) → write new bytes as `dest`. The `.old`
/// file is swept by `version_check::cleanup_old_exe` on the next invocation.
#[cfg(not(target_os = "windows"))]
fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> {
let gz = flate2::read::GzDecoder::new(archive_bytes);
let mut archive = tar::Archive::new(gz);
Expand All @@ -127,18 +126,55 @@ fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> {
eyre::bail!("archive did not contain a 'nest' binary");
}

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&staged)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&staged, perms)?;
}
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&staged)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&staged, perms)?;

std::fs::rename(&staged, dest).wrap_err("failed to replace the current binary")?;
Ok(())
}

#[cfg(target_os = "windows")]
fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> {
use std::io::{Cursor, Read, Write};

let mut archive =
zip::ZipArchive::new(Cursor::new(archive_bytes)).wrap_err("failed to read zip archive")?;

// Find the `nest.exe` entry — accept any path inside the zip.
let mut found_idx = None;
for i in 0..archive.len() {
let entry = archive.by_index(i).wrap_err("failed to read zip entry")?;
if entry.is_file() && entry.name().replace('\\', "/").ends_with("nest.exe") {
found_idx = Some(i);
break;
}
}
let idx =
found_idx.ok_or_else(|| eyre::eyre!("archive did not contain a 'nest.exe' binary"))?;

let mut entry = archive.by_index(idx).wrap_err("failed to open zip entry")?;
let mut new_bytes = Vec::new();
entry
.read_to_end(&mut new_bytes)
.wrap_err("failed to read 'nest.exe' from archive")?;
drop(entry);
drop(archive);

// Rename trick: a running `.exe` cannot be deleted/overwritten but it CAN
// be renamed. We sweep any stale `.exe.old` from a previous update first.
let old_path = dest.with_extension("exe.old");
let _ = std::fs::remove_file(&old_path);
std::fs::rename(dest, &old_path).wrap_err("failed to rename the current binary to .exe.old")?;

let mut f = std::fs::File::create(dest).wrap_err("failed to create new nest.exe")?;
f.write_all(&new_bytes)
.wrap_err("failed to write new nest.exe")?;
drop(f);
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -178,6 +214,7 @@ mod tests {

/// Build a .tar.gz containing a `nest` file, then verify install_archive
/// extracts + atomically replaces a destination file (not the real binary).
#[cfg(not(target_os = "windows"))]
#[test]
fn install_archive_extracts_and_replaces() {
let tmp = std::env::temp_dir().join(format!("nest-update-test-{}", std::process::id()));
Expand Down
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod tui;
mod tx_bundle;
mod updater;
mod util;
mod version_check;

#[tokio::main]
async fn main() {
Expand All @@ -25,9 +26,23 @@ async fn main() {
async fn run() -> eyre::Result<()> {
dotenvy::dotenv().ok();

// Windows: sweep any `nest.exe.old` left behind by a previous self-update.
// No-op on other platforms.
version_check::cleanup_old_exe();

let cli = cli::Cli::parse();
let cfg = config::AppConfig::from_cli(&cli)?;

// Passive update notifier — skipped for `update` (would be confusing) and
// `dashboard` (alt-screen swallows stderr). Never blocks; writes only to
// stderr so `-o json` stdout stays clean.
if let Some(ref cmd) = cli.command {
let skip = matches!(cmd, cli::Commands::Update(_) | cli::Commands::Dashboard);
if !skip {
version_check::check_and_notify(cli.no_color);
}
}

// No subcommand → print help and exit, even when a global env flag like
// PRIVATE_KEY is set (which would otherwise defeat arg_required_else_help).
let Some(command) = cli.command else {
Expand Down
Loading
Loading