diff --git a/Cargo.lock b/Cargo.lock index 4e93e2a..31ef405 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -861,15 +861,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "ark-ff" version = "0.3.0" @@ -1533,19 +1524,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "console" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.61.2", -] - [[package]] name = "const-hex" version = "1.18.1" @@ -1593,35 +1571,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -1742,33 +1691,6 @@ dependencies = [ "phf", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version 0.4.1", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling" version = "0.21.3" @@ -1890,7 +1812,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "pem-rfc7468", "zeroize", ] @@ -1915,17 +1836,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2050,31 +1960,6 @@ dependencies = [ "spki", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "signature", - "subtle", - "zeroize", -] - [[package]] name = "educe" version = "0.6.0" @@ -2116,21 +2001,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -2234,12 +2104,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "filedescriptor" version = "0.8.3" @@ -2320,21 +2184,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2698,22 +2547,6 @@ dependencies = [ "webpki-roots 1.0.6", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -2924,19 +2757,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - [[package]] name = "indoc" version = "2.0.7" @@ -3284,26 +3104,9 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nest-cli" -version = "0.2.0" +version = "0.2.1" dependencies = [ "alloy", "chrono", @@ -3313,12 +3116,14 @@ dependencies = [ "dirs", "dotenvy", "eyre", + "flate2", "hex", "ratatui", "reqwest 0.13.2", - "self_update", "serde", "serde_json", + "sha2", + "tar", "tokio", "wiremock", ] @@ -3467,50 +3272,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3589,15 +3356,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3751,12 +3509,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "plain" version = "0.2.3" @@ -3879,15 +3631,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -4228,20 +3971,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4252,7 +3990,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -4625,43 +4362,6 @@ dependencies = [ "libc", ] -[[package]] -name = "self-replace" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" -dependencies = [ - "fastrand", - "tempfile", - "windows-sys 0.52.0", -] - -[[package]] -name = "self_update" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6644febaa58f323b28f7321d04e24d0020d117c27619ab869d6abdf76be9aac6" -dependencies = [ - "either", - "flate2", - "http", - "indicatif", - "log", - "quick-xml", - "regex", - "reqwest 0.12.28", - "self-replace", - "semver 1.0.27", - "serde", - "serde_json", - "tar", - "tempfile", - "ureq", - "urlencoding", - "zip", - "zipsign-api", -] - [[package]] name = "semver" version = "0.11.0" @@ -4923,17 +4623,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "spki" version = "0.7.3" @@ -5280,16 +4969,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5559,53 +5238,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" -dependencies = [ - "base64", - "cookie_store", - "der", - "encoding_rs", - "flate2", - "log", - "native-tls", - "percent-encoding", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "ureq-proto", - "utf-8", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -5619,12 +5257,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -5661,12 +5293,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -6559,30 +6185,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.13.0", - "memchr", - "time", -] - -[[package]] -name = "zipsign-api" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" -dependencies = [ - "base64", - "ed25519-dalek", - "thiserror 2.0.18", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 4ba43ac..b399c1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nest-cli" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "CLI and TUI for Nest Vaults on Plume Network" license = "MIT" @@ -30,15 +30,15 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -# Self-update -self_update = { version = "0.43", features = ["archive-tar", "archive-zip", "compression-flate2"] } - # Utilities dirs = "6" dotenvy = "0.15" eyre = "0.6" color-eyre = "0.6" hex = "0.4" +sha2 = "0.10" +flate2 = "1" +tar = "0.4" chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] diff --git a/src/cli.rs b/src/cli.rs index 7e68045..8d90912 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -206,8 +206,8 @@ pub struct ComplianceArgs { #[derive(Args)] pub struct HistoryArgs { /// Vault slug - #[arg(long)] - pub vault_slug: String, + #[arg(long, visible_alias = "vault-slug")] + pub vault: String, /// Days of history #[arg(long, default_value_t = 30)] @@ -278,8 +278,9 @@ pub struct ClaimPendingArgs { pub redemption_asset: Option, #[arg(long, default_value = "plume")] pub chain: String, - #[arg(long)] - pub user: Option, + /// Wallet address (overrides address derived from --private-key) + #[arg(long, visible_alias = "user")] + pub address: Option, } #[derive(Args)] pub struct ClaimSubmitArgs { @@ -289,8 +290,9 @@ pub struct ClaimSubmitArgs { pub redemption_asset: String, #[arg(long, default_value = "plume")] pub chain: String, - #[arg(long)] - pub user: Option, + /// Wallet address (overrides address derived from --private-key) + #[arg(long, visible_alias = "user")] + pub address: Option, #[arg(long)] pub skip_simulation: bool, } @@ -336,8 +338,9 @@ pub struct InstantRedeemSubmitArgs { pub shares: String, #[arg(long, default_value = "plume")] pub chain: String, - #[arg(long)] - pub user: Option, + /// Wallet address (overrides address derived from --private-key) + #[arg(long, visible_alias = "user")] + pub address: Option, #[arg(long)] pub receiver: Option, #[arg(long)] @@ -362,8 +365,9 @@ pub struct UpdateRedeemQuery { pub redemption_asset: String, #[arg(long, default_value = "plume")] pub chain: String, - #[arg(long)] - pub user: Option, + /// Wallet address (overrides address derived from --private-key) + #[arg(long, visible_alias = "user")] + pub address: Option, } #[derive(Args)] pub struct UpdateRedeemSubmit { @@ -373,8 +377,9 @@ pub struct UpdateRedeemSubmit { pub redemption_asset: String, #[arg(long, default_value = "plume")] pub chain: String, - #[arg(long)] - pub user: Option, + /// Wallet address (overrides address derived from --private-key) + #[arg(long, visible_alias = "user")] + pub address: Option, /// Final reduced pending-share total (reduce-only) #[arg(long)] pub new_shares: String, @@ -397,8 +402,148 @@ pub enum AutoClaimAction { pub struct AutoClaimQuery { #[arg(long, default_value = "plume")] pub chain: String, - #[arg(long)] - pub user: Option, + /// Wallet address (overrides address derived from --private-key) + #[arg(long, visible_alias = "user")] + pub address: Option, #[arg(long)] pub skip_simulation: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("parse failed") + } + + #[test] + fn claim_pending_accepts_user_alias() { + // Both the new --address and the legacy --user resolve to the same field. + for flag in ["--user", "--address"] { + let cli = parse(&["nest", "claim", "pending", flag, "0xABC"]); + let Some(Commands::Claim(a)) = cli.command else { + panic!("expected claim") + }; + let ClaimAction::Pending(q) = a.action else { + panic!("expected pending") + }; + assert_eq!(q.address.as_deref(), Some("0xABC")); + } + } + + #[test] + fn claim_submit_accepts_user_alias() { + let cli = parse(&[ + "nest", + "claim", + "submit", + "--vault", + "v", + "--redemption-asset", + "0xR", + "--user", + "0xABC", + ]); + let Some(Commands::Claim(a)) = cli.command else { + panic!() + }; + let ClaimAction::Submit(s) = a.action else { + panic!() + }; + assert_eq!(s.address.as_deref(), Some("0xABC")); + } + + #[test] + fn instant_redeem_submit_accepts_user_alias() { + let cli = parse(&[ + "nest", + "instant-redeem", + "submit", + "--vault", + "v", + "--redemption-asset", + "0xR", + "--shares", + "1", + "--user", + "0xABC", + ]); + let Some(Commands::InstantRedeem(a)) = cli.command else { + panic!() + }; + let InstantRedeemAction::Submit(s) = a.action else { + panic!() + }; + assert_eq!(s.address.as_deref(), Some("0xABC")); + } + + #[test] + fn update_redeem_pending_accepts_user_alias() { + let cli = parse(&[ + "nest", + "update-redeem", + "pending", + "--vault", + "v", + "--redemption-asset", + "0xR", + "--user", + "0xABC", + ]); + let Some(Commands::UpdateRedeem(a)) = cli.command else { + panic!() + }; + let UpdateRedeemAction::Pending(q) = a.action else { + panic!() + }; + assert_eq!(q.address.as_deref(), Some("0xABC")); + } + + #[test] + fn update_redeem_submit_accepts_user_alias() { + let cli = parse(&[ + "nest", + "update-redeem", + "submit", + "--vault", + "v", + "--redemption-asset", + "0xR", + "--new-shares", + "1", + "--user", + "0xABC", + ]); + let Some(Commands::UpdateRedeem(a)) = cli.command else { + panic!() + }; + let UpdateRedeemAction::Submit(s) = a.action else { + panic!() + }; + assert_eq!(s.address.as_deref(), Some("0xABC")); + } + + #[test] + fn auto_claim_status_accepts_user_alias() { + let cli = parse(&["nest", "auto-claim", "status", "--user", "0xABC"]); + let Some(Commands::AutoClaim(a)) = cli.command else { + panic!() + }; + let AutoClaimAction::Status(q) = a.action else { + panic!() + }; + assert_eq!(q.address.as_deref(), Some("0xABC")); + } + + #[test] + fn history_accepts_vault_slug_alias() { + for flag in ["--vault", "--vault-slug"] { + let cli = parse(&["nest", "history", flag, "myvault"]); + let Some(Commands::History(h)) = cli.command else { + panic!() + }; + assert_eq!(h.vault, "myvault"); + } + } +} diff --git a/src/commands/auto_claim.rs b/src/commands/auto_claim.rs index 0e88234..31a40b7 100644 --- a/src/commands/auto_claim.rs +++ b/src/commands/auto_claim.rs @@ -17,7 +17,7 @@ pub async fn run(cfg: &AppConfig, args: AutoClaimArgs) -> Result<()> { async fn status(cfg: &AppConfig, q: AutoClaimQuery) -> Result<()> { let chain_id = resolve_chain_id(&q.chain)?; ensure_actions_chain(chain_id)?; - let account = resolve_account(cfg, &q.user)?; + let account = resolve_account(cfg, &q.address)?; let actions = EvmActionsClient::new(&cfg.evm_actions_api_url); let req = AutoClaimStatusRequest { chain_id, @@ -34,7 +34,7 @@ async fn status(cfg: &AppConfig, q: AutoClaimQuery) -> Result<()> { async fn set(cfg: &AppConfig, q: AutoClaimQuery, enabled: bool) -> Result<()> { let chain_id = resolve_chain_id(&q.chain)?; ensure_actions_chain(chain_id)?; - let account = resolve_account(cfg, &q.user)?; + let account = resolve_account(cfg, &q.address)?; let actions = EvmActionsClient::new(&cfg.evm_actions_api_url); let req = AutoClaimBuildTxRequest { chain_id, diff --git a/src/commands/claim.rs b/src/commands/claim.rs index 89d43fa..4f92e6f 100644 --- a/src/commands/claim.rs +++ b/src/commands/claim.rs @@ -21,7 +21,7 @@ pub async fn run(cfg: &AppConfig, args: ClaimArgs) -> Result<()> { async fn pending(cfg: &AppConfig, q: ClaimPendingArgs) -> Result<()> { let chain_id = resolve_chain_id(&q.chain)?; - let account = resolve_account(cfg, &q.user)?; + let account = resolve_account(cfg, &q.address)?; match q.vault { // Single vault → the API claim/pending route (structured response). @@ -143,7 +143,7 @@ async fn sweep_all(cfg: &AppConfig, chain_id: u64, account: Address) -> Result<( async fn submit(cfg: &AppConfig, s: ClaimSubmitArgs) -> Result<()> { let chain_id = resolve_chain_id(&s.chain)?; ensure_actions_chain(chain_id)?; - let account = resolve_account(cfg, &s.user)?; + let account = resolve_account(cfg, &s.address)?; let asset: Address = s .redemption_asset .parse() diff --git a/src/commands/history.rs b/src/commands/history.rs index 4de1c93..c6b0287 100644 --- a/src/commands/history.rs +++ b/src/commands/history.rs @@ -17,13 +17,13 @@ pub async fn run(cfg: &AppConfig, args: HistoryArgs) -> Result<()> { let api = NestApiClient::new(&cfg.api_url); let history = api - .get_performance_history(&args.vault_slug, start_date, now) + .get_performance_history(&args.vault, start_date, now) .await?; let points = &history.history; if points.is_empty() { - println!("No history data for '{}'.", args.vault_slug); + println!("No history data for '{}'.", args.vault); return Ok(()); } diff --git a/src/commands/instant_redeem.rs b/src/commands/instant_redeem.rs index d31a4c8..4605dcd 100644 --- a/src/commands/instant_redeem.rs +++ b/src/commands/instant_redeem.rs @@ -87,7 +87,7 @@ async fn liquidity(cfg: &AppConfig, a: InstantRedeemLiquidityArgs) -> Result<()> async fn submit(cfg: &AppConfig, a: InstantRedeemSubmitArgs) -> Result<()> { let chain_id = resolve_chain_id(&a.chain)?; ensure_actions_chain(chain_id)?; - let account = resolve_account(cfg, &a.user)?; + let account = resolve_account(cfg, &a.address)?; let asset: Address = a .redemption_asset .parse() diff --git a/src/commands/update.rs b/src/commands/update.rs index 27b3765..8fa828a 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -1,58 +1,215 @@ +use std::path::Path; + use eyre::{Result, WrapErr}; -use self_update::cargo_crate_version; +use sha2::{Digest, Sha256}; use crate::cli::UpdateArgs; +use crate::updater::{Manifest, fetch_manifest, host_triple, is_newer, manifest_url}; + +const INSTALL_HINT: &str = "Re-install with: curl -fsSL https://nestagents.io/cli/install.sh | sh"; pub async fn run(args: UpdateArgs) -> Result<()> { - let current = cargo_crate_version!(); - eprintln!("Current version: {current}"); - - // `self_update` uses a blocking reqwest client with its own runtime. Run it on - // a plain OS thread so that runtime is not created/dropped inside our async - // (#[tokio::main]) context, which would panic ("Cannot drop a runtime ..."). - let check_only = args.check; - std::thread::spawn(move || run_blocking(current, check_only)) - .join() - .map_err(|_| eyre::eyre!("update worker thread panicked"))? -} + let current = env!("CARGO_PKG_VERSION"); + eprintln!("Current version: v{current}"); -fn run_blocking(current: &'static str, check_only: bool) -> Result<()> { - let updater = self_update::backends::github::Update::configure() - .repo_owner("plumenetwork") - .repo_name("nest-cli") - .bin_name("nest") - .current_version(current) - .build() - .wrap_err("failed to configure updater")?; + let manifest = fetch_manifest(&manifest_url()) + .await + .wrap_err_with(|| format!("could not check for updates. {INSTALL_HINT}"))?; - let latest_release = updater - .get_latest_release() - .wrap_err("failed to check for updates")?; + match decide(current, &manifest) { + UpdateDecision::UpToDate => { + eprintln!("Up to date (v{current})."); + return Ok(()); + } + UpdateDecision::Available(latest) => { + eprintln!("Update available: v{current} → v{latest}"); + if args.check { + return Ok(()); + } + } + } - let latest = &latest_release.version; + let triple = host_triple() + .ok_or_else(|| eyre::eyre!("unsupported platform for self-update. {INSTALL_HINT}"))?; - if latest == current { - eprintln!("You are on the latest version ({current})."); + // 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(()); } - eprintln!("Update available: {current} → {latest}"); + let artifact = manifest.artifacts.get(triple).ok_or_else(|| { + eyre::eyre!("no release artifact for `{triple}` in the manifest. {INSTALL_HINT}") + })?; - if check_only { - return Ok(()); + eprintln!("Downloading {} ...", artifact.url); + let bytes = download(&artifact.url) + .await + .wrap_err_with(|| format!("download failed. {INSTALL_HINT}"))?; + + verify_sha256(&bytes, &artifact.sha256) + .wrap_err_with(|| format!("checksum verification failed. {INSTALL_HINT}"))?; + + let current_exe = + std::env::current_exe().wrap_err("could not locate the current executable")?; + install_archive(&bytes, ¤t_exe) + .wrap_err_with(|| format!("install failed. {INSTALL_HINT}"))?; + + eprintln!( + "Updated to v{}. Restart nest to use the new version.", + manifest.version + ); + Ok(()) +} + +enum UpdateDecision { + UpToDate, + Available(String), +} + +fn decide(current: &str, manifest: &Manifest) -> UpdateDecision { + if is_newer(current, &manifest.version) { + UpdateDecision::Available(manifest.version.clone()) + } else { + UpdateDecision::UpToDate + } +} + +async fn download(url: &str) -> Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build()?; + let resp = client.get(url).send().await?; + if !resp.status().is_success() { + eyre::bail!("HTTP {} downloading {url}", resp.status()); + } + Ok(resp.bytes().await?.to_vec()) +} + +fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let got = hex::encode(hasher.finalize()); + let expected = expected_hex.trim().to_lowercase(); + if got != expected { + eyre::bail!("sha256 mismatch: expected {expected}, got {got}"); + } + 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. +fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> { + let gz = flate2::read::GzDecoder::new(archive_bytes); + let mut archive = tar::Archive::new(gz); + + let staged = dest.with_extension("new"); + let mut found = false; + for entry in archive.entries().wrap_err("failed to read archive")? { + let mut entry = entry.wrap_err("failed to read archive entry")?; + let is_nest = entry + .path() + .ok() + .and_then(|p| p.file_name().map(|f| f == "nest")) + .unwrap_or(false); + if is_nest { + entry + .unpack(&staged) + .wrap_err("failed to unpack 'nest' from archive")?; + found = true; + break; + } + } + if !found { + eyre::bail!("archive did not contain a 'nest' binary"); } - // Proceed with update. - self_update::backends::github::Update::configure() - .repo_owner("plumenetwork") - .repo_name("nest-cli") - .bin_name("nest") - .current_version(current) - .build() - .wrap_err("failed to configure updater")? - .update() - .wrap_err("failed to apply update")?; + #[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)?; + } - eprintln!("Updated to {latest}. Restart nest-cli to use the new version."); + std::fs::rename(&staged, dest).wrap_err("failed to replace the current binary")?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::io::Write; + + fn manifest(version: &str) -> Manifest { + Manifest { + version: version.to_string(), + artifacts: HashMap::new(), + } + } + + #[test] + fn decide_up_to_date_and_available() { + let current = env!("CARGO_PKG_VERSION"); + assert!(matches!( + decide(current, &manifest(current)), + UpdateDecision::UpToDate + )); + assert!(matches!( + decide(current, &manifest("999.0.0")), + UpdateDecision::Available(v) if v == "999.0.0" + )); + } + + #[test] + fn verify_sha256_matches_and_rejects() { + let data = b"hello nest"; + let mut hasher = Sha256::new(); + hasher.update(data); + let good = hex::encode(hasher.finalize()); + assert!(verify_sha256(data, &good).is_ok()); + assert!(verify_sha256(data, &good.to_uppercase()).is_ok()); // case-insensitive + assert!(verify_sha256(data, "deadbeef").is_err()); + } + + /// Build a .tar.gz containing a `nest` file, then verify install_archive + /// extracts + atomically replaces a destination file (not the real binary). + #[test] + fn install_archive_extracts_and_replaces() { + let tmp = std::env::temp_dir().join(format!("nest-update-test-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + // Build the tarball: a single `nest` entry with known content. + let payload = b"#!/bin/sh\necho new-nest\n"; + let mut tar_buf = Vec::new(); + { + let enc = flate2::write::GzEncoder::new(&mut tar_buf, flate2::Compression::default()); + let mut builder = tar::Builder::new(enc); + let mut header = tar::Header::new_gnu(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "nest", &payload[..]) + .unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + } + + // Pre-create the destination with old content. + let dest = tmp.join("nest"); + { + let mut f = std::fs::File::create(&dest).unwrap(); + f.write_all(b"old-nest").unwrap(); + } + + install_archive(&tar_buf, &dest).unwrap(); + + let installed = std::fs::read(&dest).unwrap(); + assert_eq!(installed, payload); + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src/commands/update_redeem.rs b/src/commands/update_redeem.rs index bbc8484..d5422f0 100644 --- a/src/commands/update_redeem.rs +++ b/src/commands/update_redeem.rs @@ -21,7 +21,7 @@ pub async fn run(cfg: &AppConfig, args: UpdateRedeemArgs) -> Result<()> { async fn pending(cfg: &AppConfig, q: UpdateRedeemQuery) -> Result<()> { let chain_id = resolve_chain_id(&q.chain)?; ensure_actions_chain(chain_id)?; - let account = resolve_account(cfg, &q.user)?; + let account = resolve_account(cfg, &q.address)?; let asset: Address = q .redemption_asset .parse() @@ -54,7 +54,7 @@ async fn pending(cfg: &AppConfig, q: UpdateRedeemQuery) -> Result<()> { async fn submit(cfg: &AppConfig, s: UpdateRedeemSubmit) -> Result<()> { let chain_id = resolve_chain_id(&s.chain)?; ensure_actions_chain(chain_id)?; - let account = resolve_account(cfg, &s.user)?; + let account = resolve_account(cfg, &s.address)?; let asset: Address = s .redemption_asset .parse() diff --git a/src/updater.rs b/src/updater.rs index ef07d74..5994087 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,19 +1,94 @@ +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; +use eyre::{Result, WrapErr}; +use serde::Deserialize; + const CACHE_LAST_CHECK_FILE: &str = "last_check"; const CACHE_VERSION_FILE: &str = "latest_version"; -/// Only check GitHub for updates once every 24 hours. +/// Only check for updates once every 24 hours. const UPDATE_CHECK_INTERVAL: u64 = 24 * 60 * 60; -/// Returns the cache directory for nest (~/.cache/nest or platform equivalent). +/// Public version manifest. Overridable via `NEST_UPDATE_MANIFEST_URL` (used by tests). +const DEFAULT_MANIFEST_URL: &str = "https://nestagents.io/downloads/version.json"; + +/// The version manifest served at `nestagents.io/downloads/version.json`. +#[derive(Debug, Clone, Deserialize)] +pub struct Manifest { + pub version: String, + #[serde(default)] + pub artifacts: HashMap, +} + +/// A single platform artifact entry in the manifest. +#[derive(Debug, Clone, Deserialize)] +pub struct Artifact { + pub url: String, + pub sha256: String, +} + +/// Resolve the manifest URL (env override → default public host). +pub fn manifest_url() -> String { + std::env::var("NEST_UPDATE_MANIFEST_URL").unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string()) +} + +/// Fetch and parse the version manifest. +pub async fn fetch_manifest(url: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + let resp = client + .get(url) + .send() + .await + .wrap_err_with(|| format!("failed to reach version manifest at {url}"))?; + if !resp.status().is_success() { + eyre::bail!("version manifest returned HTTP {}", resp.status()); + } + resp.json::() + .await + .wrap_err("failed to parse version manifest JSON") +} + +/// Map the host OS/arch to the release artifact triple, if self-update supports it. +pub fn host_triple() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Some("aarch64-apple-darwin"), + ("macos", "x86_64") => Some("x86_64-apple-darwin"), + ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), + ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), + _ => None, + } +} + +/// Returns true if `latest` is a newer semver than `current`. +pub fn is_newer(current: &str, latest: &str) -> bool { + let parse = |v: &str| -> Option<(u64, u64, u64)> { + let v = v.strip_prefix('v').unwrap_or(v); + let mut parts = v.split('.'); + Some(( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + )) + }; + match (parse(current), parse(latest)) { + (Some(c), Some(l)) => l > c, + _ => false, + } +} + +// --------------------------------------------------------------------------- +// Startup update notification (cached for 24h, shown on --version / --help) +// --------------------------------------------------------------------------- + fn cache_dir() -> Option { dirs::cache_dir().map(|d| d.join("nest")) } -/// Returns the current unix timestamp. fn current_time() -> Option { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -21,7 +96,6 @@ fn current_time() -> Option { .map(|d| d.as_secs()) } -/// Returns true if enough time has passed since the last update check. fn should_check(last_check_file: &PathBuf) -> bool { let Ok(contents) = fs::read_to_string(last_check_file) else { return true; @@ -35,7 +109,16 @@ fn should_check(last_check_file: &PathBuf) -> bool { now.saturating_sub(last) >= UPDATE_CHECK_INTERVAL } -/// Fetches the latest version from GitHub releases and caches the result. +fn read_cached_version(version_file: &PathBuf) -> Option { + fs::read_to_string(version_file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Fetch the latest version from the manifest, cached for 24h. The HTTP fetch runs +/// on a dedicated thread with its own runtime so it never touches the outer +/// `#[tokio::main]` runtime (this is called synchronously during clap parsing). fn fetch_and_cache_latest() -> Option { let cache = cache_dir()?; fs::create_dir_all(&cache).ok()?; @@ -44,70 +127,39 @@ fn fetch_and_cache_latest() -> Option { let version_file = cache.join(CACHE_VERSION_FILE); if should_check(&last_check_file) { - // Fetch latest release from GitHub. `self_update` uses a blocking reqwest - // client with its own runtime; run it on a plain OS thread so that runtime - // is not dropped inside our async (#[tokio::main]) context, which panics. let fetched = std::thread::spawn(|| { - self_update::backends::github::Update::configure() - .repo_owner("plumenetwork") - .repo_name("nest-cli") - .bin_name("nest") - .current_version(env!("CARGO_PKG_VERSION")) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() .build() - .ok() - .and_then(|u| u.get_latest_release().ok()) - .map(|r| r.version) + .ok()?; + rt.block_on(async { fetch_manifest(&manifest_url()).await.ok() }) + .map(|m| m.version) }) .join() .ok() .flatten(); - if let Some(ref latest) = fetched { - // Cache the result. - if let Some(now) = current_time() { - let _ = fs::write(&last_check_file, now.to_string()); - let _ = fs::write(&version_file, latest); - } + if let Some(ref latest) = fetched + && let Some(now) = current_time() + { + let _ = fs::write(&last_check_file, now.to_string()); + let _ = fs::write(&version_file, latest); } - - // Return fetched version, or fall back to cached version. fetched.or_else(|| read_cached_version(&version_file)) } else { - // Read cached version. read_cached_version(&version_file) } } -fn read_cached_version(version_file: &PathBuf) -> Option { - fs::read_to_string(version_file) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -/// Returns true if `latest` is a newer version than `current`. -fn is_newer(current: &str, latest: &str) -> bool { - let parse = |v: &str| -> Option<(u64, u64, u64)> { - let v = v.strip_prefix('v').unwrap_or(v); - let mut parts = v.split('.'); - Some(( - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - )) - }; - match (parse(current), parse(latest)) { - (Some(c), Some(l)) => l > c, - _ => false, - } -} - -/// Check for updates and return a notification string if one is available. -/// This is designed to be called once at startup; results are cached via OnceLock. +/// Returns an update-available notice if the cached latest version is newer. pub fn check_for_updates() -> Option { + // Never hit the network during tests (clap builds the Command — and thus + // evaluates `version`/`long_about` — on every parse, including unit tests). + if cfg!(test) { + return None; + } let current = env!("CARGO_PKG_VERSION"); let latest = fetch_and_cache_latest()?; - if is_newer(current, &latest) { Some(format!( "\n A new version of nest is available: v{current} → v{latest}\n Run `nest update` to install.\n" @@ -141,3 +193,51 @@ pub fn help_with_update_notification() -> &'static str { } }) } + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn is_newer_semver() { + assert!(is_newer("0.2.0", "0.2.1")); + assert!(is_newer("0.2.0", "0.3.0")); + assert!(is_newer("v0.2.0", "v1.0.0")); + assert!(!is_newer("0.2.1", "0.2.1")); + assert!(!is_newer("0.3.0", "0.2.9")); + } + + #[test] + fn host_triple_is_known_on_this_platform() { + // CI runs on macos/linux x86_64/aarch64 — all supported. + assert!(host_triple().is_some()); + } + + #[tokio::test] + async fn fetch_manifest_parses_version_and_artifacts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "version": "0.3.0", + "tag": "v0.3.0", + "released_at": "2026-05-27T00:00:00Z", + "artifacts": { + "x86_64-unknown-linux-gnu": { + "url": "https://example.test/nest-cli-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "abc123" + } + } + }))) + .mount(&server) + .await; + + let url = format!("{}/version.json", server.uri()); + let manifest = fetch_manifest(&url).await.unwrap(); + assert_eq!(manifest.version, "0.3.0"); + let art = manifest.artifacts.get("x86_64-unknown-linux-gnu").unwrap(); + assert_eq!(art.sha256, "abc123"); + } +}