From 3578a39d2718fbbdf127be39d860eebe54fd6e36 Mon Sep 17 00:00:00 2001 From: Yoshihiro OKUMURA Date: Fri, 17 Apr 2026 20:15:19 +0900 Subject: [PATCH] feat: implement selfupdate command - Fetch latest release from Gitea API using existing reqwest client - Match release asset by BUILD_TARGET triple (supports .tar.gz and .zip) - Compare versions; show confirmation prompt (skippable with -y/--yes) - Download archive, extract binary, atomically replace self via self-replace - Support private repositories via GITEA_TOKEN environment variable - Expose BUILD_TARGET in build.rs for compile-time target triple detection - Add .gitea/workflows/release.yml for multi-platform release builds on tag push Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitea/workflows/release.yml | 139 +++++++++++ Cargo.lock | 452 ++++++++++++++++++++++++++++++++++- Cargo.toml | 5 + build.rs | 5 + src/commands/mod.rs | 1 + src/commands/selfupdate.rs | 218 +++++++++++++++++ src/main.rs | 13 + 7 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 src/commands/selfupdate.rs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..cbba6e6 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,139 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + create-release: + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.create.outputs.release_id }} + steps: + - name: Create Gitea release + id: create + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + RESPONSE=$(curl -sf -X POST \ + -H "Authorization: Bearer ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ + -d "{\"tag_name\": \"${GITHUB_REF_NAME}\", \"name\": \"${GITHUB_REF_NAME}\"}") + echo "release_id=$(echo "$RESPONSE" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')" >> "$GITHUB_OUTPUT" + + build-linux-x86_64: + needs: create-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust stable + run: rustup update stable && rustup default stable + - name: Add musl target + run: rustup target add x86_64-unknown-linux-musl + - name: Install musl tools + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Build + run: cargo build --release --target x86_64-unknown-linux-musl + - name: Create archive + run: | + VERSION=${GITHUB_REF_NAME#v} + TARGET=x86_64-unknown-linux-musl + ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" + tar -czf "${ARCHIVE}" -C target/${TARGET}/release mdrs + echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" + - name: Upload asset + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -sf -X POST \ + -H "Authorization: Bearer ${GITEA_TOKEN}" \ + -F "attachment=@${ARCHIVE}" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${{ needs.create-release.outputs.release_id }}/assets" + + build-linux-aarch64: + needs: create-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust stable + run: rustup update stable && rustup default stable + - name: Install cross + run: cargo install cross --locked + - name: Build + run: cross build --release --target aarch64-unknown-linux-musl + - name: Create archive + run: | + VERSION=${GITHUB_REF_NAME#v} + TARGET=aarch64-unknown-linux-musl + ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" + tar -czf "${ARCHIVE}" -C target/${TARGET}/release mdrs + echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" + - name: Upload asset + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -sf -X POST \ + -H "Authorization: Bearer ${GITEA_TOKEN}" \ + -F "attachment=@${ARCHIVE}" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${{ needs.create-release.outputs.release_id }}/assets" + + build-macos: + needs: create-release + runs-on: macos-latest + strategy: + matrix: + target: + - x86_64-apple-darwin + - aarch64-apple-darwin + steps: + - uses: actions/checkout@v4 + - name: Install Rust stable + run: rustup update stable && rustup default stable + - name: Add target + run: rustup target add ${{ matrix.target }} + - name: Build + run: cargo build --release --target ${{ matrix.target }} + - name: Create archive + run: | + VERSION=${GITHUB_REF_NAME#v} + TARGET=${{ matrix.target }} + ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" + tar -czf "${ARCHIVE}" -C target/${TARGET}/release mdrs + echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" + - name: Upload asset + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -sf -X POST \ + -H "Authorization: Bearer ${GITEA_TOKEN}" \ + -F "attachment=@${ARCHIVE}" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${{ needs.create-release.outputs.release_id }}/assets" + + build-windows: + needs: create-release + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust stable + run: rustup update stable && rustup default stable + - name: Build + run: cargo build --release --target x86_64-pc-windows-msvc + - name: Create archive + shell: pwsh + run: | + $VERSION = $env:GITHUB_REF_NAME -replace '^v', '' + $TARGET = "x86_64-pc-windows-msvc" + $ARCHIVE = "mdrs-${VERSION}-${TARGET}.zip" + Compress-Archive -Path "target/${TARGET}/release/mdrs.exe" -DestinationPath $ARCHIVE + echo "ARCHIVE=$ARCHIVE" >> $env:GITHUB_ENV + - name: Upload asset + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + shell: bash + run: | + curl -sf -X POST \ + -H "Authorization: Bearer ${GITEA_TOKEN}" \ + -F "attachment=@${ARCHIVE}" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${{ needs.create-release.outputs.release_id }}/assets" diff --git a/Cargo.lock b/Cargo.lock index 19a1963..bb314f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -87,6 +104,15 @@ 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 = "arrayvec" version = "0.7.6" @@ -232,6 +258,25 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" version = "1.2.60" @@ -239,6 +284,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -254,6 +301,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -315,6 +372,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57e3272f0190c3f1584272d613719ba5fc7df7f4942fe542e63d949cf3a649b" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cow-utils" version = "0.1.3" @@ -330,12 +393,42 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -363,6 +456,32 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[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 = "digest" version = "0.10.7" @@ -371,6 +490,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -495,12 +615,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -701,6 +848,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -922,6 +1078,15 @@ dependencies = [ "hashbrown 0.17.0", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -950,6 +1115,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -974,7 +1149,10 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -983,6 +1161,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1019,6 +1203,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mdrs-client-rust" version = "0.1.1" @@ -1030,17 +1235,22 @@ dependencies = [ "ctrlc", "dirs", "dotenvy", + "flate2", "fs2", "futures", "os_info", "reqwest", "rpassword", + "self-replace", "serde", "serde_json", "sha2", + "tar", + "tempfile", "tokio", "unicode-normalization", "validators", + "zip", ] [[package]] @@ -1071,6 +1281,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1116,6 +1336,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -1342,11 +1568,21 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1379,6 +1615,18 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "postcard" version = "1.1.3" @@ -1401,6 +1649,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1601,6 +1855,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1824,6 +2087,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.38" @@ -1895,6 +2171,17 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[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 = "semver" version = "1.0.28" @@ -1960,6 +2247,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1987,6 +2285,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -2122,6 +2426,30 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2162,6 +2490,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinystr" version = "0.8.3" @@ -2842,6 +3189,25 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.2" @@ -2911,6 +3277,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -2945,8 +3325,78 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 30ed9d5..4653a5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,8 @@ ctrlc = "3" os_info = "3" dotenvy = "0.15" unicode-normalization = "0.1" +self-replace = "1" +tar = "0.4" +flate2 = "1" +zip = "2" +tempfile = "3" diff --git a/build.rs b/build.rs index 0635212..a58e568 100644 --- a/build.rs +++ b/build.rs @@ -13,5 +13,10 @@ fn main() { .unwrap_or("unknown") .to_string(); println!("cargo:rustc-env=RUSTC_VERSION={}", version); + // Expose the build target triple so selfupdate can match release assets. + println!( + "cargo:rustc-env=BUILD_TARGET={}", + std::env::var("TARGET").unwrap_or_default() + ); println!("cargo:rerun-if-changed=build.rs"); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e957ae5..9a0a45a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -18,3 +18,4 @@ pub mod shared; pub mod upload; pub mod version; pub mod whoami; +pub mod selfupdate; diff --git a/src/commands/selfupdate.rs b/src/commands/selfupdate.rs new file mode 100644 index 0000000..ae87ba8 --- /dev/null +++ b/src/commands/selfupdate.rs @@ -0,0 +1,218 @@ +use anyhow::{anyhow, bail}; +use reqwest::header::{AUTHORIZATION, USER_AGENT}; +use serde::Deserialize; +use std::env; +use std::io::{self, Write}; +use std::path::Path; + +const GITEA_HOST: &str = "https://git.ni.riken.jp"; +const REPO_OWNER: &str = "niu"; +const REPO_NAME: &str = "mdrs-client-rust"; + +/// Current build target triple, captured at compile time via build.rs. +const BUILD_TARGET: &str = env!("BUILD_TARGET"); + +#[derive(Deserialize)] +struct GiteaRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct GiteaAsset { + name: String, + browser_download_url: String, +} + +/// Returns true if `latest` is strictly greater than `current` (semver-like comparison). +fn is_newer(current: &str, latest: &str) -> bool { + let parse = |s: &str| -> Vec { + s.trim_start_matches('v') + .split('.') + .map(|p| p.parse::().unwrap_or(0)) + .collect() + }; + let cur = parse(current); + let lat = parse(latest); + let len = cur.len().max(lat.len()); + for i in 0..len { + let c = cur.get(i).copied().unwrap_or(0); + let l = lat.get(i).copied().unwrap_or(0); + if l > c { + return true; + } + if l < c { + return false; + } + } + false +} + +/// Extract the binary named `bin_name` from a `.tar.gz` archive at `archive_path` +/// and write it to `dest_path`. +fn extract_from_tar_gz(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> { + use flate2::read::GzDecoder; + use tar::Archive; + + let file = std::fs::File::open(archive_path)?; + let gz = GzDecoder::new(file); + let mut archive = Archive::new(gz); + + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?; + // Match by file name only (ignore directory prefix in archive). + if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) { + entry.unpack(dest_path)?; + return Ok(()); + } + } + bail!("Binary '{}' not found in archive", bin_name) +} + +/// Extract the binary named `bin_name` from a `.zip` archive at `archive_path` +/// and write it to `dest_path`. +fn extract_from_zip(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> { + use std::io::Read; + + let file = std::fs::File::open(archive_path)?; + let mut archive = zip::ZipArchive::new(file)?; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let entry_name = entry.name().to_owned(); + let file_name = Path::new(&entry_name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if file_name == bin_name { + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(dest_path, &buf)?; + return Ok(()); + } + } + bail!("Binary '{}' not found in archive", bin_name) +} + +pub async fn selfupdate(yes: bool) -> anyhow::Result<()> { + let current_version = env!("CARGO_PKG_VERSION"); + + println!("Checking for updates (current version: {current_version}, target: {BUILD_TARGET})..."); + + let api_url = format!( + "{GITEA_HOST}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases?limit=1" + ); + + let client = reqwest::Client::new(); + let mut req = client + .get(&api_url) + .header(USER_AGENT, format!("mdrs/{current_version}")); + + if let Ok(token) = env::var("GITEA_TOKEN") { + req = req.header(AUTHORIZATION, format!("Bearer {token}")); + } + + let resp = req.send().await?; + if !resp.status().is_success() { + bail!("Failed to fetch release info: HTTP {}", resp.status()); + } + + let releases: Vec = resp.json().await?; + let release = releases + .into_iter() + .next() + .ok_or_else(|| anyhow!("No releases found"))?; + + let latest_version = release.tag_name.trim_start_matches('v'); + + if !is_newer(current_version, latest_version) { + println!("Already up-to-date ({current_version})."); + return Ok(()); + } + + println!("New version available: {latest_version}"); + + // Find the asset matching the current build target. + let asset = release + .assets + .iter() + .find(|a| a.name.contains(BUILD_TARGET)) + .ok_or_else(|| { + let names: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect(); + anyhow!( + "No release asset found for target '{BUILD_TARGET}'. \ + Available assets: {}", + names.join(", ") + ) + })?; + + println!("Asset: {}", asset.name); + + if !yes { + print!("Update to version {latest_version}? [y/N] "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("Update cancelled."); + return Ok(()); + } + } + + // Download the asset to a temporary directory. + let tmp_dir = tempfile::Builder::new() + .prefix("mdrs-selfupdate-") + .tempdir()?; + let archive_path = tmp_dir.path().join(&asset.name); + + println!("Downloading {}...", asset.browser_download_url); + + let mut download_req = client + .get(&asset.browser_download_url) + .header(USER_AGENT, format!("mdrs/{current_version}")); + + if let Ok(token) = env::var("GITEA_TOKEN") { + download_req = download_req.header(AUTHORIZATION, format!("Bearer {token}")); + } + + let download_resp = download_req.send().await?; + if !download_resp.status().is_success() { + bail!( + "Failed to download asset: HTTP {}", + download_resp.status() + ); + } + + let bytes = download_resp.bytes().await?; + std::fs::write(&archive_path, &bytes)?; + + // Extract the binary from the archive. + let bin_name = if cfg!(windows) { "mdrs.exe" } else { "mdrs" }; + let new_bin = tmp_dir.path().join(bin_name); + let name = asset.name.as_str(); + + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + extract_from_tar_gz(&archive_path, bin_name, &new_bin)?; + } else if name.ends_with(".zip") { + extract_from_zip(&archive_path, bin_name, &new_bin)?; + } else { + bail!("Unsupported archive format: {}", asset.name); + } + + // Make the extracted binary executable on Unix. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&new_bin)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&new_bin, perms)?; + } + + // Atomically replace the current executable. + self_replace::self_replace(&new_bin)?; + + println!("Successfully updated to version {latest_version}."); + Ok(()) +} + diff --git a/src/main.rs b/src/main.rs index daa0764..c7136d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,6 +111,13 @@ enum Commands { }, /// Show the version of this tool Version, + /// Update this binary to the latest release + #[command(name = "selfupdate")] + SelfUpdate { + /// Skip the confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, } /// Print the error message in Python-compatible format and exit with code 2. @@ -349,5 +356,11 @@ fn main() { Commands::Version => { commands::version::version(); } + Commands::SelfUpdate { yes } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(commands::selfupdate::selfupdate(*yes)) { + handle_error(e.into()); + } + } } }