From 544f75c33bed45deb28b809e7f116a9d4910eced Mon Sep 17 00:00:00 2001 From: LiHaohua Date: Sat, 9 May 2026 15:03:06 +0800 Subject: [PATCH] feat(czdev): add login, publish, unpublish commands - czdev login: GitHub OAuth Device Flow authentication - czdev publish: upload .deb to CardputerZero/packages via PR - Preflight: .desktop check, email match, version bump check - czdev unpublish: ownership-verified removal PR - release-czdev.yml: cross-compile for macOS/Linux/Windows Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release-czdev.yml | 83 ++++++ Cargo.lock | 401 +++++++++++++++++++++++++++- crates/czdev/Cargo.toml | 5 + crates/czdev/src/auth.rs | 175 ++++++++++++ crates/czdev/src/github.rs | 367 +++++++++++++++++++++++++ crates/czdev/src/main.rs | 37 +++ crates/czdev/src/publish.rs | 340 +++++++++++++++++++++++ crates/czdev/src/unpublish.rs | 124 +++++++++ 8 files changed, 1520 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/release-czdev.yml create mode 100644 crates/czdev/src/auth.rs create mode 100644 crates/czdev/src/github.rs create mode 100644 crates/czdev/src/publish.rs create mode 100644 crates/czdev/src/unpublish.rs diff --git a/.github/workflows/release-czdev.yml b/.github/workflows/release-czdev.yml new file mode 100644 index 0000000..6ba581e --- /dev/null +++ b/.github/workflows/release-czdev.yml @@ -0,0 +1,83 @@ +name: Release czdev + +on: + push: + tags: ['czdev-v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + artifact: czdev-linux-x86_64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + artifact: czdev-linux-aarch64 + - target: x86_64-apple-darwin + os: macos-latest + artifact: czdev-macos-x86_64 + - target: aarch64-apple-darwin + os: macos-latest + artifact: czdev-macos-aarch64 + - target: x86_64-pc-windows-msvc + os: windows-latest + artifact: czdev-windows-x86_64.exe + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux aarch64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml + echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml + + - name: Build + run: cargo build --release --target ${{ matrix.target }} -p czdev + + - name: Rename artifact (Unix) + if: runner.os != 'Windows' + run: cp target/${{ matrix.target }}/release/czdev ${{ matrix.artifact }} + + - name: Rename artifact (Windows) + if: runner.os == 'Windows' + run: cp target/${{ matrix.target }}/release/czdev.exe ${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.artifact }} + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: dist/* diff --git a/Cargo.lock b/Cargo.lock index 0d57332..69b31ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,9 +679,14 @@ name = "czdev" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "clap", + "dirs 5.0.1", + "open", + "reqwest 0.12.28", "serde", "serde_json", + "sha2", ] [[package]] @@ -770,13 +775,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -787,7 +813,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1086,6 +1112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1266,8 +1293,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1277,9 +1306,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1554,6 +1585,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2005,6 +2052,12 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2489,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand", + "rand 0.8.6", ] [[package]] @@ -2728,6 +2781,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2762,8 +2870,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2773,7 +2891,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2785,6 +2913,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2800,6 +2937,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2869,6 +3017,46 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -2927,6 +3115,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -2966,7 +3168,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -2988,12 +3190,53 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3190,6 +3433,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -3435,6 +3690,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3573,7 +3834,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3594,7 +3855,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -3623,7 +3884,7 @@ checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4000,6 +4261,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4204,7 +4475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4296,6 +4567,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4550,6 +4827,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4606,6 +4893,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4836,6 +5132,24 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4878,6 +5192,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4935,6 +5264,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4953,6 +5288,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4971,6 +5312,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5001,6 +5348,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5019,6 +5372,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5037,6 +5396,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5055,6 +5420,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5211,7 +5582,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5339,6 +5710,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/crates/czdev/Cargo.toml b/crates/czdev/Cargo.toml index 76198ae..ba85f0c 100644 --- a/crates/czdev/Cargo.toml +++ b/crates/czdev/Cargo.toml @@ -16,3 +16,8 @@ clap = { version = "4.5", features = ["derive"] } anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } +base64 = "0.22" +sha2 = "0.10" +dirs = "5" +open = "5" diff --git a/crates/czdev/src/auth.rs b/crates/czdev/src/auth.rs new file mode 100644 index 0000000..b79d03f --- /dev/null +++ b/crates/czdev/src/auth.rs @@ -0,0 +1,175 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use crate::github::GitHubClient; + +// Register your own OAuth App at https://github.com/settings/applications/new +// Device flow does not require a client secret. +const GITHUB_CLIENT_ID: &str = "REPLACE_WITH_REAL_CLIENT_ID"; +const DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; + +#[derive(Serialize, Deserialize)] +pub struct Credentials { + pub github_token: String, + pub github_username: String, + pub created_at: String, +} + +pub fn credentials_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; + Ok(home.join(".czdev").join("credentials")) +} + +pub fn load_token() -> Result { + let path = credentials_path()?; + if !path.exists() { + return Err(anyhow!( + "not logged in. Run `czdev login` first." + )); + } + let data = fs::read_to_string(&path).context("reading credentials")?; + let creds: Credentials = serde_json::from_str(&data).context("parsing credentials")?; + Ok(creds.github_token) +} + +pub fn load_credentials() -> Result { + let path = credentials_path()?; + if !path.exists() { + return Err(anyhow!("not logged in. Run `czdev login` first.")); + } + let data = fs::read_to_string(&path).context("reading credentials")?; + serde_json::from_str(&data).context("parsing credentials") +} + +fn save_credentials(creds: &Credentials) -> Result<()> { + let path = credentials_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("creating ~/.czdev")?; + } + let json = serde_json::to_string_pretty(creds)?; + fs::write(&path, &json).context("writing credentials")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[derive(Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + interval: u64, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + error: Option, +} + +pub fn login() -> Result<()> { + let client = reqwest::blocking::Client::new(); + + println!("Requesting device code from GitHub..."); + let resp: DeviceCodeResponse = client + .post(DEVICE_CODE_URL) + .header("Accept", "application/json") + .form(&[ + ("client_id", GITHUB_CLIENT_ID), + ("scope", "public_repo"), + ]) + .send() + .context("requesting device code")? + .json() + .context("parsing device code response")?; + + println!(); + println!(" Open: {}", resp.verification_uri); + println!(" Code: {}", resp.user_code); + println!(); + + let _ = open::that(&resp.verification_uri); + + println!("Waiting for authorization (press Ctrl-C to cancel)..."); + + let token = loop { + thread::sleep(Duration::from_secs(resp.interval)); + + let token_resp: TokenResponse = client + .post(ACCESS_TOKEN_URL) + .header("Accept", "application/json") + .form(&[ + ("client_id", GITHUB_CLIENT_ID), + ("device_code", resp.device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .context("polling for token")? + .json() + .context("parsing token response")?; + + if let Some(token) = token_resp.access_token { + break token; + } + + match token_resp.error.as_deref() { + Some("authorization_pending") => continue, + Some("slow_down") => { + thread::sleep(Duration::from_secs(5)); + continue; + } + Some("expired_token") => return Err(anyhow!("device code expired, please try again")), + Some(e) => return Err(anyhow!("OAuth error: {e}")), + None => continue, + } + }; + + let gh = GitHubClient::new(&token); + let user = gh.get_user().context("verifying token")?; + + let creds = Credentials { + github_token: token, + github_username: user.login.clone(), + created_at: chrono_now(), + }; + save_credentials(&creds)?; + + println!(); + println!("✓ Logged in as {} ({})", user.login, user.email.unwrap_or_default()); + println!(" Token saved to {:?}", credentials_path()?); + Ok(()) +} + +pub fn logout() -> Result<()> { + let path = credentials_path()?; + if path.exists() { + let creds = load_credentials().ok(); + fs::remove_file(&path).context("removing credentials")?; + if let Some(c) = creds { + println!("Removed credentials for {}.", c.github_username); + } else { + println!("Credentials removed."); + } + } else { + println!("Not logged in."); + } + Ok(()) +} + +fn chrono_now() -> String { + // Simple ISO-8601 without pulling in chrono crate + use std::time::SystemTime; + let dur = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", dur.as_secs()) +} diff --git a/crates/czdev/src/github.rs b/crates/czdev/src/github.rs new file mode 100644 index 0000000..3588b58 --- /dev/null +++ b/crates/czdev/src/github.rs @@ -0,0 +1,367 @@ +use anyhow::{anyhow, Context, Result}; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; +use serde::{Deserialize, Serialize}; + +const GITHUB_API: &str = "https://api.github.com"; + +pub struct GitHubClient { + token: String, + client: Client, +} + +#[derive(Deserialize, Debug)] +pub struct User { + pub login: String, + pub email: Option, +} + +#[derive(Deserialize, Debug)] +pub struct UserEmail { + pub email: String, + pub verified: bool, + pub primary: bool, +} + +#[derive(Deserialize, Debug)] +struct PermissionResponse { + permission: String, +} + +#[derive(Deserialize, Debug)] +struct RefObject { + sha: String, +} + +#[derive(Deserialize, Debug)] +struct RefResponse { + object: RefObject, +} + +#[derive(Deserialize, Debug)] +struct CommitTreeRef { + sha: String, +} + +#[derive(Deserialize, Debug)] +struct CommitResponse { + sha: String, + tree: CommitTreeRef, +} + +#[derive(Deserialize, Debug)] +struct BlobResponse { + sha: String, +} + +#[derive(Deserialize, Debug)] +struct TreeResponse { + sha: String, +} + +#[derive(Deserialize, Debug)] +struct CreateCommitResponse { + sha: String, +} + +#[derive(Deserialize, Debug)] +pub struct PullRequestResponse { + pub html_url: String, + pub number: u64, +} + +#[derive(Serialize)] +struct BlobRequest { + content: String, + encoding: String, +} + +#[derive(Serialize)] +struct TreeEntry { + path: String, + mode: String, + #[serde(rename = "type")] + entry_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, +} + +#[derive(Serialize)] +struct CreateTreeRequest { + base_tree: String, + tree: Vec, +} + +#[derive(Serialize)] +struct CreateCommitRequest { + message: String, + tree: String, + parents: Vec, +} + +#[derive(Serialize)] +struct CreateRefRequest { + #[serde(rename = "ref")] + ref_name: String, + sha: String, +} + +#[derive(Serialize)] +struct CreatePrRequest { + title: String, + body: String, + head: String, + base: String, +} + +#[derive(PartialEq, PartialOrd)] +pub enum Permission { + None, + Read, + Write, + Admin, +} + +impl GitHubClient { + pub fn new(token: &str) -> Self { + Self { + token: token.to_string(), + client: Client::new(), + } + } + + fn get(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client + .get(format!("{GITHUB_API}{path}")) + .header(AUTHORIZATION, format!("Bearer {}", self.token)) + .header(USER_AGENT, "czdev/0.1") + .header(ACCEPT, "application/vnd.github+json") + } + + fn post(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client + .post(format!("{GITHUB_API}{path}")) + .header(AUTHORIZATION, format!("Bearer {}", self.token)) + .header(USER_AGENT, "czdev/0.1") + .header(ACCEPT, "application/vnd.github+json") + } + + pub fn get_user(&self) -> Result { + self.get("/user") + .send() + .context("GET /user")? + .error_for_status() + .context("GET /user status")? + .json() + .context("parsing user") + } + + pub fn get_user_emails(&self) -> Result> { + self.get("/user/emails") + .send() + .context("GET /user/emails")? + .error_for_status() + .context("GET /user/emails status")? + .json() + .context("parsing emails") + } + + pub fn get_verified_emails(&self) -> Result> { + let emails = self.get_user_emails()?; + Ok(emails + .into_iter() + .filter(|e| e.verified) + .map(|e| e.email) + .collect()) + } + + pub fn check_permission(&self, owner: &str, repo: &str, username: &str) -> Result { + let resp = self + .get(&format!("/repos/{owner}/{repo}/collaborators/{username}/permission")) + .send() + .context("checking permission")?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(Permission::None); + } + + let pr: PermissionResponse = resp + .error_for_status() + .context("permission status")? + .json() + .context("parsing permission")?; + + Ok(match pr.permission.as_str() { + "admin" => Permission::Admin, + "maintain" | "write" => Permission::Write, + "read" | "triage" => Permission::Read, + _ => Permission::None, + }) + } + + pub fn fork_repo(&self, owner: &str, repo: &str) -> Result { + #[derive(Deserialize)] + struct ForkResp { + full_name: String, + } + let resp: ForkResp = self + .post(&format!("/repos/{owner}/{repo}/forks")) + .json(&serde_json::json!({})) + .send() + .context("forking repo")? + .error_for_status() + .context("fork status")? + .json() + .context("parsing fork")?; + Ok(resp.full_name) + } + + pub fn get_ref_sha(&self, owner: &str, repo: &str, ref_name: &str) -> Result { + let resp: RefResponse = self + .get(&format!("/repos/{owner}/{repo}/git/ref/{ref_name}")) + .send() + .context("GET ref")? + .error_for_status() + .context("GET ref status")? + .json() + .context("parsing ref")?; + Ok(resp.object.sha) + } + + pub fn get_commit(&self, owner: &str, repo: &str, sha: &str) -> Result<(String, String)> { + let resp: CommitResponse = self + .get(&format!("/repos/{owner}/{repo}/git/commits/{sha}")) + .send() + .context("GET commit")? + .error_for_status() + .context("GET commit status")? + .json() + .context("parsing commit")?; + Ok((resp.sha, resp.tree.sha)) + } + + pub fn create_blob(&self, owner: &str, repo: &str, content_base64: &str) -> Result { + let resp: BlobResponse = self + .post(&format!("/repos/{owner}/{repo}/git/blobs")) + .json(&BlobRequest { + content: content_base64.to_string(), + encoding: "base64".to_string(), + }) + .send() + .context("creating blob")? + .error_for_status() + .context("create blob status")? + .json() + .context("parsing blob")?; + Ok(resp.sha) + } + + pub fn create_tree( + &self, + owner: &str, + repo: &str, + base_tree: &str, + path: &str, + blob_sha: Option<&str>, + ) -> Result { + let entry = TreeEntry { + path: path.to_string(), + mode: "100644".to_string(), + entry_type: "blob".to_string(), + sha: blob_sha.map(|s| s.to_string()), + }; + let resp: TreeResponse = self + .post(&format!("/repos/{owner}/{repo}/git/trees")) + .json(&CreateTreeRequest { + base_tree: base_tree.to_string(), + tree: vec![entry], + }) + .send() + .context("creating tree")? + .error_for_status() + .context("create tree status")? + .json() + .context("parsing tree")?; + Ok(resp.sha) + } + + pub fn create_commit( + &self, + owner: &str, + repo: &str, + message: &str, + tree_sha: &str, + parent_sha: &str, + ) -> Result { + let resp: CreateCommitResponse = self + .post(&format!("/repos/{owner}/{repo}/git/commits")) + .json(&CreateCommitRequest { + message: message.to_string(), + tree: tree_sha.to_string(), + parents: vec![parent_sha.to_string()], + }) + .send() + .context("creating commit")? + .error_for_status() + .context("create commit status")? + .json() + .context("parsing commit")?; + Ok(resp.sha) + } + + pub fn create_ref(&self, owner: &str, repo: &str, ref_name: &str, sha: &str) -> Result<()> { + self.post(&format!("/repos/{owner}/{repo}/git/refs")) + .json(&CreateRefRequest { + ref_name: format!("refs/heads/{ref_name}"), + sha: sha.to_string(), + }) + .send() + .context("creating ref")? + .error_for_status() + .context("create ref status")?; + Ok(()) + } + + pub fn create_pull_request( + &self, + owner: &str, + repo: &str, + title: &str, + body: &str, + head: &str, + base: &str, + ) -> Result { + self.post(&format!("/repos/{owner}/{repo}/pulls")) + .json(&CreatePrRequest { + title: title.to_string(), + body: body.to_string(), + head: head.to_string(), + base: base.to_string(), + }) + .send() + .context("creating PR")? + .error_for_status() + .context("create PR status")? + .json() + .context("parsing PR") + } + + pub fn get_file_content(&self, owner: &str, repo: &str, path: &str) -> Result> { + let resp = self + .get(&format!("/repos/{owner}/{repo}/contents/{path}")) + .header(ACCEPT, "application/vnd.github.raw+json") + .send() + .context("GET file content")?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(anyhow!("file not found: {path}")); + } + + Ok(resp + .error_for_status() + .context("GET file status")? + .bytes() + .context("reading bytes")? + .to_vec()) + } +} diff --git a/crates/czdev/src/main.rs b/crates/czdev/src/main.rs index 5b6c3a5..92c57ef 100644 --- a/crates/czdev/src/main.rs +++ b/crates/czdev/src/main.rs @@ -1,10 +1,14 @@ +mod auth; mod build; mod deploy; mod doctor; +mod github; mod list; mod manifest; mod paths; +mod publish; mod run; +mod unpublish; mod watch; use anyhow::Result; @@ -62,6 +66,31 @@ enum Command { #[arg(long)] deb: Option, }, + + /// Authenticate with GitHub (device flow). + Login, + + /// Remove stored GitHub credentials. + Logout, + + /// Publish a .deb package to the CardputerZero app store. + Publish { + /// Path to the .deb file. If omitted, searches ./build/*.deb + #[arg(long)] + deb: Option, + }, + + /// Create a PR to remove a published package (you can only remove your own). + Unpublish { + /// Package name to remove + package: String, + /// Version to remove + #[arg(long)] + version: String, + /// Architecture (default: arm64) + #[arg(long, default_value = "arm64")] + arch: String, + }, } fn main() -> Result<()> { @@ -76,5 +105,13 @@ fn main() -> Result<()> { Command::Run { path } => run::run_app(&path), Command::Watch { path } => watch::run(&path), Command::Deploy { path, host, deb } => deploy::run(&path, host.as_deref(), deb.as_deref()), + Command::Login => auth::login(), + Command::Logout => auth::logout(), + Command::Publish { deb } => publish::run(deb.as_deref()), + Command::Unpublish { + package, + version, + arch, + } => unpublish::run(&package, &version, &arch), } } diff --git a/crates/czdev/src/publish.rs b/crates/czdev/src/publish.rs new file mode 100644 index 0000000..36de74c --- /dev/null +++ b/crates/czdev/src/publish.rs @@ -0,0 +1,340 @@ +use anyhow::{anyhow, Context, Result}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::auth; +use crate::github::{GitHubClient, Permission}; + +const TARGET_OWNER: &str = "CardputerZero"; +const TARGET_REPO: &str = "packages"; + +struct DebMetadata { + package: String, + version: String, + architecture: String, + maintainer: String, + maintainer_email: String, +} + +pub fn run(deb: Option<&Path>) -> Result<()> { + let deb_path = resolve_deb(deb)?; + println!("Package: {}", deb_path.display()); + println!(); + + let token = auth::load_token()?; + let gh = GitHubClient::new(&token); + + let user = gh.get_user().context("fetching user info")?; + let verified_emails = gh.get_verified_emails()?; + + // Also include the noreply email + let noreply = format!("{}@users.noreply.github.com", user.login); + let mut all_emails = verified_emails.clone(); + if !all_emails.contains(&noreply) { + all_emails.push(noreply); + } + + println!("Preflight checks:"); + + // 1. Check .desktop file exists + let has_desktop = check_desktop(&deb_path)?; + if !has_desktop { + return Err(anyhow!( + "deb does not contain a .desktop file. All CardputerZero apps must include one." + )); + } + println!(" ✓ .desktop file found"); + + // 2. Extract metadata and check email + let meta = extract_metadata(&deb_path)?; + if !all_emails.iter().any(|e| e.eq_ignore_ascii_case(&meta.maintainer_email)) { + return Err(anyhow!( + "Maintainer email '{}' does not match any of your GitHub verified emails.\n \ + Your emails: {:?}\n \ + The deb Maintainer field must use your GitHub email.", + meta.maintainer_email, + all_emails + )); + } + println!(" ✓ Maintainer email matches GitHub account"); + + // 3. Package name validation + if !is_valid_package_name(&meta.package) { + return Err(anyhow!( + "Invalid package name '{}'. Must match [a-z0-9][a-z0-9.+-]+", + meta.package + )); + } + println!(" ✓ Package name \"{}\" is valid", meta.package); + + // 4. Show summary + let file_size = std::fs::metadata(&deb_path)?.len(); + let size_mb = file_size as f64 / 1_048_576.0; + println!( + " ✓ Version: {}, Arch: {}, Size: {:.1} MB", + meta.version, meta.architecture, size_mb + ); + println!(); + + if file_size > 100 * 1024 * 1024 { + return Err(anyhow!( + "File too large ({:.1} MB). GitHub blob API limit is 100 MB.", + size_mb + )); + } + + // 5. Check version is newer than existing + check_version_newer(&gh, &meta)?; + + // Determine target: direct push or fork + let perm = gh.check_permission(TARGET_OWNER, TARGET_REPO, &user.login)?; + let (push_owner, push_repo, pr_head) = if perm >= Permission::Write { + (TARGET_OWNER.to_string(), TARGET_REPO.to_string(), None) + } else { + println!("You don't have write access to {TARGET_OWNER}/{TARGET_REPO}."); + print!(" → Forking to your account... "); + let fork_name = gh.fork_repo(TARGET_OWNER, TARGET_REPO)?; + println!("done ({fork_name})"); + let parts: Vec<&str> = fork_name.split('/').collect(); + ( + parts[0].to_string(), + parts[1].to_string(), + Some(format!("{}:{}", user.login, branch_name(&meta))), + ) + }; + + println!("Uploading to {TARGET_OWNER}/{TARGET_REPO}..."); + + // Get base ref + let base_sha = gh.get_ref_sha(&push_owner, &push_repo, "heads/main")?; + let (_, base_tree_sha) = gh.get_commit(&push_owner, &push_repo, &base_sha)?; + + // Upload blob + print!(" → Uploading blob ({:.1} MB)... ", size_mb); + let file_bytes = std::fs::read(&deb_path).context("reading deb file")?; + let sha256_hash = hex_sha256(&file_bytes); + let content_b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes); + let blob_sha = gh.create_blob(&push_owner, &push_repo, &content_b64)?; + println!("done (sha: {})", &blob_sha[..8]); + + // Create tree + let file_path_in_repo = format!( + "pool/main/{}/{}_{}_{}.deb", + meta.package, meta.package, meta.version, meta.architecture + ); + print!(" → Creating tree... "); + let tree_sha = + gh.create_tree(&push_owner, &push_repo, &base_tree_sha, &file_path_in_repo, Some(&blob_sha))?; + println!("done"); + + // Create commit + let commit_msg = format!("publish: {} {} ({})", meta.package, meta.version, meta.architecture); + print!(" → Creating commit... "); + let commit_sha = gh.create_commit(&push_owner, &push_repo, &commit_msg, &tree_sha, &base_sha)?; + println!("done"); + + // Create branch + let branch = branch_name(&meta); + print!(" → Creating branch {branch}... "); + gh.create_ref(&push_owner, &push_repo, &branch, &commit_sha)?; + println!("done"); + + // Create PR + let head = pr_head.unwrap_or_else(|| branch.clone()); + let pr_body = format!( + "## Package: `{}`\n\n\ + | Field | Value |\n\ + |-------|-------|\n\ + | Version | {} |\n\ + | Architecture | {} |\n\ + | Maintainer | {} |\n\ + | Size | {:.1} MB |\n\ + | SHA-256 | `{}` |\n\ + | File | `{}` |\n\n\ + Submitted via `czdev publish`.", + meta.package, + meta.version, + meta.architecture, + meta.maintainer, + size_mb, + sha256_hash, + file_path_in_repo, + ); + print!(" → Creating pull request... "); + let pr = gh.create_pull_request( + TARGET_OWNER, + TARGET_REPO, + &format!("publish: {} {}", meta.package, meta.version), + &pr_body, + &head, + "main", + )?; + println!("done"); + + println!(); + println!("✓ Pull request created:"); + println!(" {}", pr.html_url); + println!(); + println!(" The PR will be validated by CI. A maintainer will review and merge it."); + Ok(()) +} + +fn resolve_deb(deb: Option<&Path>) -> Result { + if let Some(p) = deb { + if !p.is_file() { + return Err(anyhow!("file not found: {}", p.display())); + } + return Ok(p.to_path_buf()); + } + // Search build/ for a .deb + let build_dir = Path::new("build"); + if build_dir.is_dir() { + let mut debs: Vec<_> = std::fs::read_dir(build_dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "deb").unwrap_or(false)) + .collect(); + if debs.len() == 1 { + return Ok(debs.remove(0).path()); + } + if debs.len() > 1 { + return Err(anyhow!( + "multiple .deb files in build/. Specify one with --deb " + )); + } + } + Err(anyhow!("no .deb file found. Specify with --deb ")) +} + +fn check_desktop(deb: &Path) -> Result { + let output = Command::new("dpkg-deb") + .arg("-c") + .arg(deb) + .output() + .context("running dpkg-deb -c (is dpkg-deb installed?)")?; + let listing = String::from_utf8_lossy(&output.stdout); + Ok(listing.lines().any(|l| l.ends_with(".desktop"))) +} + +fn extract_metadata(deb: &Path) -> Result { + let fields = &["Package", "Version", "Architecture", "Maintainer"]; + let mut values = std::collections::HashMap::new(); + + for field in fields { + let output = Command::new("dpkg-deb") + .args(["-f", &deb.to_string_lossy(), field]) + .output() + .with_context(|| format!("dpkg-deb -f {field}"))?; + let val = String::from_utf8_lossy(&output.stdout).trim().to_string(); + values.insert(*field, val); + } + + let maintainer = values.get("Maintainer").cloned().unwrap_or_default(); + let email = extract_email(&maintainer); + + Ok(DebMetadata { + package: values.get("Package").cloned().unwrap_or_default(), + version: values.get("Version").cloned().unwrap_or_default(), + architecture: values.get("Architecture").cloned().unwrap_or_default(), + maintainer, + maintainer_email: email, + }) +} + +fn extract_email(maintainer: &str) -> String { + if let Some(start) = maintainer.find('<') { + if let Some(end) = maintainer.find('>') { + return maintainer[start + 1..end].to_string(); + } + } + maintainer.to_string() +} + +fn is_valid_package_name(name: &str) -> bool { + if name.len() < 2 { + return false; + } + let bytes = name.as_bytes(); + (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && bytes.iter().all(|&b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'+' || b == b'-' + }) +} + +fn branch_name(meta: &DebMetadata) -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("publish/{}-{}-{}", meta.package, meta.version, ts) +} + +fn hex_sha256(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} + +fn check_version_newer(_gh: &GitHubClient, meta: &DebMetadata) -> Result<()> { + // Try to fetch the Packages index to see if this package already exists + let packages_url = format!( + "https://cardputerzero.github.io/packages/dists/stable/main/binary-arm64/Packages" + ); + let resp = reqwest::blocking::get(&packages_url); + let content = match resp { + Ok(r) if r.status().is_success() => r.text().unwrap_or_default(), + _ => return Ok(()), // Can't check, skip (repo might be empty) + }; + + // Parse existing version for this package + let mut in_our_package = false; + let mut existing_version: Option = None; + for line in content.lines() { + if line.starts_with("Package: ") { + in_our_package = line.trim_start_matches("Package: ") == meta.package; + } + if in_our_package && line.starts_with("Version: ") { + let ver = line.trim_start_matches("Version: ").to_string(); + // Keep the highest version found + match &existing_version { + Some(ev) if compare_versions(&ver, ev) == std::cmp::Ordering::Greater => { + existing_version = Some(ver); + } + None => existing_version = Some(ver), + _ => {} + } + } + if line.is_empty() { + in_our_package = false; + } + } + + if let Some(existing) = existing_version { + if compare_versions(&meta.version, &existing) != std::cmp::Ordering::Greater { + return Err(anyhow!( + "Version {} is not newer than existing version {}.\n \ + Bump the version in your package before publishing.", + meta.version, + existing + )); + } + println!(" ✓ Version {} is newer than existing {}", meta.version, existing); + } else { + println!(" ✓ New package (no existing version found)"); + } + Ok(()) +} + +fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { + // Simple version comparison: split by '.', '-', '~' and compare segments + let parse = |v: &str| -> Vec { + v.split(|c: char| c == '.' || c == '-' || c == '~') + .filter_map(|s| s.parse::().ok()) + .collect() + }; + let va = parse(a); + let vb = parse(b); + va.cmp(&vb) +} diff --git a/crates/czdev/src/unpublish.rs b/crates/czdev/src/unpublish.rs new file mode 100644 index 0000000..ff498fd --- /dev/null +++ b/crates/czdev/src/unpublish.rs @@ -0,0 +1,124 @@ +use anyhow::{anyhow, Context, Result}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::auth; +use crate::github::{GitHubClient, Permission}; + +const TARGET_OWNER: &str = "CardputerZero"; +const TARGET_REPO: &str = "packages"; + +pub fn run(package: &str, version: &str, arch: &str) -> Result<()> { + let token = auth::load_token()?; + let gh = GitHubClient::new(&token); + let user = gh.get_user()?; + let verified_emails = gh.get_verified_emails()?; + + let noreply = format!("{}@users.noreply.github.com", user.login); + let mut all_emails = verified_emails; + if !all_emails.contains(&noreply) { + all_emails.push(noreply); + } + + let file_path = format!("pool/main/{}/{}_{}_{}. deb", package, package, version, arch); + let file_path = file_path.replace(". deb", ".deb"); + + // Verify the file exists and belongs to this user + println!("Checking ownership of {package} {version}..."); + let deb_bytes = gh + .get_file_content(TARGET_OWNER, TARGET_REPO, &file_path) + .context("package not found in repository")?; + + // Write to temp file to inspect maintainer + let tmp = std::env::temp_dir().join(format!("{package}_{version}_{arch}.deb")); + std::fs::write(&tmp, &deb_bytes).context("writing temp deb")?; + + let output = Command::new("dpkg-deb") + .args(["-f", &tmp.to_string_lossy(), "Maintainer"]) + .output() + .context("running dpkg-deb")?; + let maintainer = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let _ = std::fs::remove_file(&tmp); + + let maint_email = extract_email(&maintainer); + if !all_emails.iter().any(|e| e.eq_ignore_ascii_case(&maint_email)) { + return Err(anyhow!( + "Cannot unpublish: package maintainer '{}' does not match your account.\n \ + You can only remove packages you own.", + maintainer + )); + } + println!(" ✓ Ownership verified ({})", maint_email); + + // Determine push target + let perm = gh.check_permission(TARGET_OWNER, TARGET_REPO, &user.login)?; + let (push_owner, push_repo, pr_head) = if perm >= Permission::Write { + (TARGET_OWNER.to_string(), TARGET_REPO.to_string(), None) + } else { + let fork_name = gh.fork_repo(TARGET_OWNER, TARGET_REPO)?; + let parts: Vec<&str> = fork_name.split('/').collect(); + let branch = branch_name(package, version); + ( + parts[0].to_string(), + parts[1].to_string(), + Some(format!("{}:{}", user.login, branch)), + ) + }; + + println!("Creating removal PR..."); + + // Get base + let base_sha = gh.get_ref_sha(&push_owner, &push_repo, "heads/main")?; + let (_, base_tree_sha) = gh.get_commit(&push_owner, &push_repo, &base_sha)?; + + // Create tree with file removed (sha: null deletes the entry) + let tree_sha = gh.create_tree(&push_owner, &push_repo, &base_tree_sha, &file_path, None)?; + + // Commit + let commit_msg = format!("unpublish: {} {}", package, version); + let commit_sha = gh.create_commit(&push_owner, &push_repo, &commit_msg, &tree_sha, &base_sha)?; + + // Branch + let branch = branch_name(package, version); + gh.create_ref(&push_owner, &push_repo, &branch, &commit_sha)?; + + // PR + let head = pr_head.unwrap_or_else(|| branch.clone()); + let pr_body = format!( + "## Remove package: `{package}` v{version}\n\n\ + Requested by @{} (maintainer email: {}).\n\n\ + File: `{}`\n\n\ + Submitted via `czdev unpublish`.", + user.login, maint_email, file_path + ); + let pr = gh.create_pull_request( + TARGET_OWNER, + TARGET_REPO, + &format!("unpublish: {} {}", package, version), + &pr_body, + &head, + "main", + )?; + + println!(); + println!("✓ Removal PR created:"); + println!(" {}", pr.html_url); + Ok(()) +} + +fn branch_name(package: &str, version: &str) -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("unpublish/{}-{}-{}", package, version, ts) +} + +fn extract_email(maintainer: &str) -> String { + if let Some(start) = maintainer.find('<') { + if let Some(end) = maintainer.find('>') { + return maintainer[start + 1..end].to_string(); + } + } + maintainer.to_string() +}