diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/AI_AGENT_REPORT.md b/AI_AGENT_REPORT.md new file mode 100644 index 0000000..0897059 --- /dev/null +++ b/AI_AGENT_REPORT.md @@ -0,0 +1,46 @@ +# AI Agent Report — uberspace-cli + +## Status + +Phase 1 + 2 of the Rust rewrite are complete. The `uc` binary compiles and all 24 unit tests pass. + +## What was done + +- Created Rust project (`Cargo.toml`, binary name `uc`, CalVer `2026.3.3`) +- Implemented all Phase 1 + 2 modules: + - `registry.rs` — TOML-based asteroid CRUD (`~/.config/uc/registry.toml`) + - `ssh.rs` — passthrough (`ssh -t`) and capture (for status collection) + - `cache.rs` — per-asteroid TOML status cache (`~/.config/uc/cache/*.toml`) + - `translate.rs` — v7/v8 command translation (mail, port, tools, web backend) + - `status.rs` — remote status collection via SSH, colored display, age/staleness + - `cli.rs` — clap-derived CLI with fallback to asteroid passthrough +- `uc import ` migrates legacy `asteroids.list` + cache files + +## CLI command tree + +``` +uc add # register asteroid +uc list # list registered +uc remove # deregister +uc status [--refresh] # aggregate from cache (or refresh all) +uc status # refresh + show one asteroid +uc # passthrough → SSH with v7/v8 translation +uc import # migrate from asteroids.list +``` + +## Architecture notes + +- Clap `try_parse()` + manual fallback handles the `uc ` passthrough pattern +- SSH passthrough preserves the user's SSH config/keys — no Rust SSH library needed +- Remote status script is the same bash heredoc from the original, embedded as a const +- v8 Rich-table box-drawing chrome is stripped by the remote script before parsing + +## What's next (Phase 3) + +- Dashboard HTTP API: `reqwest` + `serde_json` for login, create-asteroid, add-ssh-key, delete +- Session management: cookies + CSRF token in `~/.config/uc/session.toml` + +## Known considerations + +- The `cache::load()` function exists but is currently only used transitively via `load_all()` — produces a dead-code warning +- Edition 2024 requires Rust 1.85+; current toolchain is 1.93 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dc3118a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,928 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[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", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "uc" +version = "2026.3.3" +dependencies = [ + "chrono", + "clap", + "colored", + "dirs", + "serde", + "tempfile", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2ffb080 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "uc" +version = "2026.3.3" +edition = "2024" + +[[bin]] +name = "uc" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +dirs = "6" +colored = "3" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..f4724fe --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct CachedStatus { + pub updated: String, + pub name: String, + pub server: String, + pub version: u8, + #[serde(default)] + pub ports: Vec, + #[serde(default)] + pub web_domains: Vec, + #[serde(default)] + pub mail_domains: Vec, + #[serde(default)] + pub mail_users: Vec, +} + +pub fn cache_dir() -> Result { + let config_dir = + dirs::config_dir().ok_or_else(|| "cannot determine config directory".to_string())?; + Ok(config_dir.join("uc").join("cache")) +} + +pub fn save(status: &CachedStatus) -> Result<(), String> { + let dir = cache_dir()?; + fs::create_dir_all(&dir).map_err(|e| format!("failed to create cache directory: {e}"))?; + let path = dir.join(format!("{}.toml", status.name)); + let content = + toml::to_string_pretty(status).map_err(|e| format!("failed to serialize cache: {e}"))?; + fs::write(&path, content).map_err(|e| format!("failed to write cache: {e}")) +} + +pub fn load(name: &str) -> Result, String> { + let path = cache_dir()?.join(format!("{name}.toml")); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|e| format!("failed to read cache: {e}"))?; + let status: CachedStatus = + toml::from_str(&content).map_err(|e| format!("failed to parse cache: {e}"))?; + Ok(Some(status)) +} + +pub fn load_all() -> Result, String> { + let dir = cache_dir()?; + if !dir.exists() { + return Ok(Vec::new()); + } + let mut results = Vec::new(); + let entries = fs::read_dir(&dir).map_err(|e| format!("failed to read cache directory: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to read cache entry: {e}"))?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + let content = + fs::read_to_string(&path).map_err(|e| format!("failed to read cache file: {e}"))?; + match toml::from_str::(&content) { + Ok(status) => results.push(status), + Err(e) => eprintln!("warning: skipping {}: {e}", path.display()), + } + } + } + results.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(results) +} + +pub fn remove(name: &str) -> Result<(), String> { + let path = cache_dir()?.join(format!("{name}.toml")); + if path.exists() { + fs::remove_file(&path).map_err(|e| format!("failed to remove cache: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_serialize() { + let status = CachedStatus { + updated: "2026-02-25T17:31:06".into(), + name: "danger".into(), + server: "cetus.uberspace.de".into(), + version: 7, + ports: vec![], + web_domains: vec!["danger.uber.space".into(), "rhqq2.de".into()], + mail_domains: vec!["danger.uber.space".into()], + mail_users: vec![], + }; + let serialized = toml::to_string_pretty(&status).unwrap(); + let deserialized: CachedStatus = toml::from_str(&serialized).unwrap(); + assert_eq!(status, deserialized); + } + + #[test] + fn deserialize_empty_lists() { + let toml_str = r#" +updated = "2026-01-01T00:00:00" +name = "test" +server = "test.uberspace.de" +version = 7 +"#; + let status: CachedStatus = toml::from_str(toml_str).unwrap(); + assert!(status.ports.is_empty()); + assert!(status.web_domains.is_empty()); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..2475a0e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,310 @@ +use std::fs; +use std::process; + +use clap::{Parser, Subcommand}; +use colored::Colorize; + +use crate::cache; +use crate::registry::{self, Asteroid, Registry}; +use crate::ssh; +use crate::status; +use crate::translate; + +#[derive(Parser)] +#[command(name = "uc", about = "uberspace account manager")] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Asteroid name (for passthrough commands) + #[arg(num_args = 1..)] + args: Vec, +} + +#[derive(Subcommand)] +enum Commands { + /// Register an asteroid + Add { + /// Account name + name: String, + /// Server hostname (e.g. cetus.uberspace.de) + server: String, + /// Uberspace version (7 or 8) + version: u8, + }, + + /// List registered asteroids + List, + + /// Deregister an asteroid + Remove { + /// Account name + name: String, + }, + + /// Show aggregate status from cache (or refresh all via SSH) + Status { + /// Refresh all accounts via SSH before showing + #[arg(long)] + refresh: bool, + }, + + /// Import asteroids from the legacy asteroids.list file + Import { + /// Path to the legacy asteroids.list file + path: String, + }, +} + +pub fn run() { + // clap's derive approach doesn't easily support `uc ` alongside + // named subcommands. We handle this by trying clap first, and falling back to + // manual parsing for asteroid passthrough. + let result = match Cli::try_parse() { + Ok(cli) => dispatch(cli), + Err(e) => { + // If clap fails because args don't match a subcommand, try passthrough + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + e.exit(); + } + // Check if first arg is a registered asteroid + let reg_path = match registry::registry_path() { + Ok(p) => p, + Err(_) => e.exit(), + }; + let reg = match Registry::load(®_path) { + Ok(r) => r, + Err(_) => e.exit(), + }; + if reg.lookup(&args[0]).is_some() { + handle_passthrough(&args[0], &args[1..]) + } else { + e.exit() + } + } + }; + + if let Err(msg) = result { + eprintln!("{} {msg}", "[error]".red()); + process::exit(1); + } +} + +fn dispatch(cli: Cli) -> Result<(), String> { + match cli.command { + Some(Commands::Add { + name, + server, + version, + }) => cmd_add(&name, &server, version), + Some(Commands::List) => cmd_list(), + Some(Commands::Remove { name }) => cmd_remove(&name), + Some(Commands::Status { refresh }) => cmd_status(refresh), + Some(Commands::Import { path }) => cmd_import(&path), + None => { + // Fall through to passthrough if args present + if cli.args.is_empty() { + Cli::parse_from(["uc", "--help"]); + Ok(()) + } else { + handle_passthrough(&cli.args[0], &cli.args[1..]) + } + } + } +} + +fn cmd_add(name: &str, server: &str, version: u8) -> Result<(), String> { + if version != 7 && version != 8 { + return Err(format!("version must be 7 or 8, got {version}")); + } + let path = registry::registry_path()?; + let mut reg = Registry::load(&path)?; + reg.add(Asteroid { + name: name.to_string(), + server: server.to_string(), + version, + }); + reg.save(&path)?; + let ver_info = format!("u{version}"); + eprintln!( + "{} registered: {name} @ {server} ({ver_info})", + "[ok]".green() + ); + Ok(()) +} + +fn cmd_list() -> Result<(), String> { + let path = registry::registry_path()?; + let reg = Registry::load(&path)?; + if reg.asteroid.is_empty() { + eprintln!( + "{} no accounts registered. Use: uc add ", + "[warn]".yellow() + ); + return Ok(()); + } + println!( + " {}", + format!("{:<12} {:<28} {}", "NAME", "SERVER", "VER").bold() + ); + println!("{}", "\u{2500}".repeat(49).dimmed()); + for a in ®.asteroid { + println!( + " {:<12} {:<28} u{}", + a.name, a.server, a.version + ); + } + Ok(()) +} + +fn cmd_remove(name: &str) -> Result<(), String> { + let path = registry::registry_path()?; + let mut reg = Registry::load(&path)?; + if !reg.remove(name) { + return Err(format!("'{name}' not found in registry")); + } + reg.save(&path)?; + cache::remove(name)?; + eprintln!("{} removed: {name}", "[ok]".green()); + Ok(()) +} + +fn cmd_status(refresh: bool) -> Result<(), String> { + let path = registry::registry_path()?; + let reg = Registry::load(&path)?; + if reg.asteroid.is_empty() { + eprintln!( + "{} no accounts registered. Use: uc add ", + "[warn]".yellow() + ); + return Ok(()); + } + status::show_all(refresh, ®.asteroid) +} + +fn cmd_import(legacy_path: &str) -> Result<(), String> { + let content = + fs::read_to_string(legacy_path).map_err(|e| format!("failed to read {legacy_path}: {e}"))?; + + let reg_path = registry::registry_path()?; + let mut reg = Registry::load(®_path)?; + let mut count = 0; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + eprintln!( + "{} skipping malformed line: {line}", + "[warn]".yellow() + ); + continue; + } + let name = parts[0]; + let server = parts[1]; + let version: u8 = parts + .get(2) + .and_then(|v| v.parse().ok()) + .unwrap_or(7); + + reg.add(Asteroid { + name: name.to_string(), + server: server.to_string(), + version, + }); + count += 1; + + // Try to import the corresponding cache file + import_cache_file(legacy_path, name)?; + } + + reg.save(®_path)?; + eprintln!( + "{} imported {count} asteroid(s) to {}", + "[ok]".green(), + reg_path.display() + ); + Ok(()) +} + +/// Import a single cache file from the legacy format (KEY=VALUE) to TOML. +fn import_cache_file(legacy_list_path: &str, name: &str) -> Result<(), String> { + use std::path::Path; + + let legacy_dir = Path::new(legacy_list_path) + .parent() + .ok_or_else(|| "cannot determine legacy directory".to_string())?; + let cache_path = legacy_dir.join("cache").join(name); + + if !cache_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&cache_path) + .map_err(|e| format!("failed to read cache for {name}: {e}"))?; + + let mut status = cache::CachedStatus::default(); + status.name = name.to_string(); + + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if let Some(val) = line.strip_prefix("UPDATED=") { + status.updated = val.to_string(); + } else if let Some(val) = line.strip_prefix("SERVER=") { + status.server = val.to_string(); + } else if let Some(val) = line.strip_prefix("VERSION=") { + status.version = val.parse().unwrap_or(7); + } else if let Some(val) = line.strip_prefix("PORTS=") { + status.ports = parse_legacy_csv(val); + } else if let Some(val) = line.strip_prefix("WEB_DOMAINS=") { + status.web_domains = parse_legacy_csv(val); + } else if let Some(val) = line.strip_prefix("MAIL_DOMAINS=") { + status.mail_domains = parse_legacy_csv(val); + } else if let Some(val) = line.strip_prefix("MAIL_USERS=") { + status.mail_users = parse_legacy_csv(val); + } + } + + cache::save(&status)?; + Ok(()) +} + +fn parse_legacy_csv(s: &str) -> Vec { + if s.is_empty() { + return Vec::new(); + } + s.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && s != "No mailboxes found.") + .collect() +} + +fn handle_passthrough(name: &str, rest: &[String]) -> Result<(), String> { + let reg_path = registry::registry_path()?; + let reg = Registry::load(®_path)?; + let asteroid = reg + .lookup(name) + .ok_or_else(|| format!("'{name}' not found in registry"))?; + + // Special case: `uc status` + if rest.first().is_some_and(|s| s == "status") { + let s = status::refresh_one(asteroid)?; + println!(); + let age = "just now".to_string(); + status::print_status(&s, &age); + return Ok(()); + } + + // Translate v7/v8 and pass through to SSH + let args: Vec = rest.to_vec(); + let translated = translate::translate(asteroid.version, &args)?; + let code = ssh::passthrough(asteroid, &translated)?; + if code != 0 { + process::exit(code); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..41c48c5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,10 @@ +mod cache; +mod cli; +mod registry; +mod ssh; +mod status; +mod translate; + +fn main() { + cli::run(); +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..903ec56 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,167 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Asteroid { + pub name: String, + pub server: String, + pub version: u8, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Registry { + #[serde(default)] + pub asteroid: Vec, +} + +impl Registry { + pub fn load(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(Registry::default()); + } + let content = + fs::read_to_string(path).map_err(|e| format!("failed to read registry: {e}"))?; + toml::from_str(&content).map_err(|e| format!("failed to parse registry: {e}")) + } + + pub fn save(&self, path: &PathBuf) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create config directory: {e}"))?; + } + let content = + toml::to_string_pretty(self).map_err(|e| format!("failed to serialize registry: {e}"))?; + fs::write(path, content).map_err(|e| format!("failed to write registry: {e}")) + } + + pub fn add(&mut self, asteroid: Asteroid) { + self.asteroid.retain(|a| a.name != asteroid.name); + self.asteroid.push(asteroid); + } + + pub fn remove(&mut self, name: &str) -> bool { + let before = self.asteroid.len(); + self.asteroid.retain(|a| a.name != name); + self.asteroid.len() < before + } + + pub fn lookup(&self, name: &str) -> Option<&Asteroid> { + self.asteroid.iter().find(|a| a.name == name) + } +} + +pub fn registry_path() -> Result { + let config_dir = + dirs::config_dir().ok_or_else(|| "cannot determine config directory".to_string())?; + Ok(config_dir.join("uc").join("registry.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn temp_path() -> (NamedTempFile, PathBuf) { + let f = NamedTempFile::new().unwrap(); + let p = f.path().to_path_buf(); + (f, p) + } + + #[test] + fn add_and_lookup() { + let mut reg = Registry::default(); + reg.add(Asteroid { + name: "danger".into(), + server: "cetus.uberspace.de".into(), + version: 7, + }); + let a = reg.lookup("danger").unwrap(); + assert_eq!(a.server, "cetus.uberspace.de"); + assert_eq!(a.version, 7); + } + + #[test] + fn add_replaces_existing() { + let mut reg = Registry::default(); + reg.add(Asteroid { + name: "danger".into(), + server: "old.uberspace.de".into(), + version: 7, + }); + reg.add(Asteroid { + name: "danger".into(), + server: "new.uberspace.de".into(), + version: 8, + }); + assert_eq!(reg.asteroid.len(), 1); + assert_eq!(reg.lookup("danger").unwrap().server, "new.uberspace.de"); + } + + #[test] + fn remove_existing() { + let mut reg = Registry::default(); + reg.add(Asteroid { + name: "danger".into(), + server: "cetus.uberspace.de".into(), + version: 7, + }); + assert!(reg.remove("danger")); + assert!(reg.lookup("danger").is_none()); + } + + #[test] + fn remove_nonexistent() { + let mut reg = Registry::default(); + assert!(!reg.remove("nope")); + } + + #[test] + fn roundtrip_save_load() { + let (_f, path) = temp_path(); + let mut reg = Registry::default(); + reg.add(Asteroid { + name: "danger".into(), + server: "cetus.uberspace.de".into(), + version: 7, + }); + reg.add(Asteroid { + name: "impstr".into(), + server: "pandora.uberspace.de".into(), + version: 8, + }); + reg.save(&path).unwrap(); + let loaded = Registry::load(&path).unwrap(); + assert_eq!(loaded.asteroid.len(), 2); + assert_eq!(loaded.lookup("danger").unwrap().version, 7); + assert_eq!(loaded.lookup("impstr").unwrap().version, 8); + } + + #[test] + fn load_nonexistent_returns_empty() { + let path = PathBuf::from("/tmp/uc-test-nonexistent-registry.toml"); + let reg = Registry::load(&path).unwrap(); + assert!(reg.asteroid.is_empty()); + } + + #[test] + fn parse_toml_format() { + let toml_str = r#" +[[asteroid]] +name = "danger" +server = "cetus.uberspace.de" +version = 7 + +[[asteroid]] +name = "impstr" +server = "pandora.uberspace.de" +version = 8 +"#; + let (_f, path) = temp_path(); + let mut file = fs::File::create(&path).unwrap(); + file.write_all(toml_str.as_bytes()).unwrap(); + let reg = Registry::load(&path).unwrap(); + assert_eq!(reg.asteroid.len(), 2); + } +} diff --git a/src/ssh.rs b/src/ssh.rs new file mode 100644 index 0000000..f8a8a3b --- /dev/null +++ b/src/ssh.rs @@ -0,0 +1,58 @@ +use std::process::Command; + +use crate::registry::Asteroid; + +/// Run `ssh -t @ uberspace ` and inherit stdio. +/// Returns the exit code from ssh. +pub fn passthrough(asteroid: &Asteroid, args: &[String]) -> Result { + let host = format!("{}@{}", asteroid.name, asteroid.server); + + let mut cmd = Command::new("ssh"); + cmd.arg("-t").arg("-o").arg("BatchMode=no").arg(&host); + cmd.arg("uberspace"); + cmd.args(args); + + let status = cmd + .status() + .map_err(|e| format!("failed to execute ssh: {e}"))?; + + Ok(status.code().unwrap_or(1)) +} + +/// Run a command on the remote via SSH and capture stdout. +/// Used for status collection where we need to parse output. +pub fn capture(asteroid: &Asteroid, remote_script: &str) -> Result { + let host = format!("{}@{}", asteroid.name, asteroid.server); + + let output = Command::new("ssh") + .arg("-o") + .arg("BatchMode=no") + .arg(&host) + .arg("bash") + .arg("-s") + .arg(asteroid.version.to_string()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(remote_script.as_bytes())?; + } + child.wait_with_output() + }) + .map_err(|e| format!("ssh to {} failed: {e}", asteroid.name))?; + + if !output.status.success() { + return Err(format!( + "ssh to {}@{} failed with exit code {}", + asteroid.name, + asteroid.server, + output.status.code().unwrap_or(1) + )); + } + + String::from_utf8(output.stdout) + .map_err(|e| format!("invalid utf-8 from ssh output: {e}")) +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..8a3159d --- /dev/null +++ b/src/status.rs @@ -0,0 +1,290 @@ +use chrono::Utc; +use colored::Colorize; + +use crate::cache::{self, CachedStatus}; +use crate::registry::Asteroid; +use crate::ssh; + +/// Remote script sent to the asteroid via SSH to collect status data. +/// Mirrors the bash heredoc from the original `asteroids` script. +const REMOTE_STATUS_SCRIPT: &str = r#" +version="$1" +set +e +u +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$HOME/bin:$HOME/.local/bin:$PATH" + +if [ "$version" = "8" ]; then + uberspace web backend list "$tmpdir/ports" 2>"$tmpdir/e.ports" & +else + uberspace port list "$tmpdir/ports" 2>"$tmpdir/e.ports" & +fi +uberspace web domain list "$tmpdir/web" 2>"$tmpdir/e.web" & +uberspace mail domain list "$tmpdir/mdom" 2>"$tmpdir/e.mdom" & +if [ "$version" = "8" ]; then + uberspace mail address list "$tmpdir/musr" 2>"$tmpdir/e.musr" & +else + uberspace mail user list "$tmpdir/musr" 2>"$tmpdir/e.musr" & +fi +wait + +for _ef in "$tmpdir"/e.*; do + [ -s "$_ef" ] && cat "$_ef" >&2 +done + +parse_list() { + local f="$1" + [ -s "$f" ] || return 0 + if grep -q $'\xe2\x94' "$f" 2>/dev/null; then + grep -v $'\xe2\x94' "$f" \ + | awk 'NR==1{next} /^[[:space:]]*$/{next} {gsub(/^[[:space:]]+|[[:space:]]+$/,""); print}' + else + grep -v '^[[:space:]]*$' "$f" + fi +} + +ports=$(parse_list "$tmpdir/ports" | tr '\n' ',' | sed 's/,$//') +web=$(parse_list "$tmpdir/web" | tr '\n' ',' | sed 's/,$//') +mdom=$(parse_list "$tmpdir/mdom" | tr '\n' ',' | sed 's/,$//') +musr=$(parse_list "$tmpdir/musr" | tr '\n' ',' | sed 's/,$//') + +printf 'PORTS=%s\n' "$ports" +printf 'WEB=%s\n' "$web" +printf 'MDOM=%s\n' "$mdom" +printf 'MUSR=%s\n' "$musr" +"#; + +/// Refresh status for a single asteroid via SSH, cache the result, and return it. +pub fn refresh_one(asteroid: &Asteroid) -> Result { + eprintln!( + "{} refreshing status for {} @ {} ...", + "==>".cyan(), + asteroid.name, + asteroid.server + ); + + let raw = ssh::capture(asteroid, REMOTE_STATUS_SCRIPT)?; + let updated = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + + let mut ports = Vec::new(); + let mut web = Vec::new(); + let mut mdom = Vec::new(); + let mut musr = Vec::new(); + + for line in raw.lines() { + let line = line.trim_end_matches('\r'); + if let Some(val) = line.strip_prefix("PORTS=") { + ports = parse_csv(val); + } else if let Some(val) = line.strip_prefix("WEB=") { + web = parse_csv(val); + } else if let Some(val) = line.strip_prefix("MDOM=") { + mdom = parse_csv(val); + } else if let Some(val) = line.strip_prefix("MUSR=") { + musr = parse_csv(val); + } + } + + if ports.is_empty() && web.is_empty() && mdom.is_empty() && musr.is_empty() { + eprintln!( + "{} no data from {} — check stderr above", + "[warn]".yellow(), + asteroid.name + ); + } + + let status = CachedStatus { + updated, + name: asteroid.name.clone(), + server: asteroid.server.clone(), + version: asteroid.version, + ports, + web_domains: web, + mail_domains: mdom, + mail_users: musr, + }; + + cache::save(&status)?; + Ok(status) +} + +/// Display a single status block with colored formatting. +pub fn print_status(status: &CachedStatus, age_display: &str) { + let ver = format!("u{}", status.version); + let header_right = format!("{} {}", status.server, ver); + + // header line + println!( + " {} {}{}", + status.name.bold(), + header_right.dimmed(), + format_age_suffix(age_display), + ); + + let (ports_label, users_label) = if status.version == 8 { + ("backends", "addrs") + } else { + ("ports", "users") + }; + + print_field(ports_label, &status.ports); + print_field("web", &status.web_domains); + print_field("mail", &status.mail_domains); + print_field(users_label, &status.mail_users); +} + +/// Show aggregate status from cache for all asteroids. +pub fn show_all(refresh: bool, asteroids: &[Asteroid]) -> Result<(), String> { + if refresh { + for asteroid in asteroids { + match refresh_one(asteroid) { + Ok(status) => { + println!(); + let age = compute_age(&status.updated); + print_status(&status, &age); + } + Err(e) => { + eprintln!("{} {}: {e}", "[error]".red(), asteroid.name); + } + } + } + return Ok(()); + } + + let cached = cache::load_all()?; + if cached.is_empty() { + eprintln!( + "{} no cached status — run: {} {}", + "[warn]".yellow(), + "uc".blue(), + "status --refresh".blue() + ); + return Ok(()); + } + + let mut stale_count = 0; + let mut first = true; + + for status in &cached { + if !first { + println!(); + } + first = false; + + let age = compute_age(&status.updated); + if is_stale(&status.updated) { + stale_count += 1; + } + print_status(status, &age); + } + + if stale_count > 0 { + println!(); + eprintln!( + "{} {stale_count} account(s) have stale cache (>24h) — run: uc status --refresh", + "[warn]".yellow() + ); + } + + Ok(()) +} + +fn print_field(label: &str, values: &[String]) { + if values.is_empty() { + println!(" {} (none)", format!("{label:<9}").dimmed()); + } else if values.len() == 1 { + println!(" {} {}", format!("{label:<9}").dimmed(), values[0]); + } else { + println!( + " {} \u{251c}\u{2500}\u{2500} {}", + format!("{label:<9}").dimmed(), + values[0] + ); + for v in &values[1..values.len() - 1] { + println!(" {} \u{2502} {v}", " ".repeat(9).dimmed()); + } + println!( + " {} \u{2514}\u{2500}\u{2500} {}", + " ".repeat(9).dimmed(), + values[values.len() - 1] + ); + } +} + +fn parse_csv(s: &str) -> Vec { + if s.is_empty() { + return Vec::new(); + } + s.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn compute_age(updated: &str) -> String { + let Ok(ts) = chrono::NaiveDateTime::parse_from_str(updated, "%Y-%m-%dT%H:%M:%S") else { + return updated.to_string(); + }; + let now = Utc::now().naive_utc(); + let age = now.signed_duration_since(ts); + let secs = age.num_seconds(); + + if secs < 0 { + return "just now".into(); + } else if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} + +fn is_stale(updated: &str) -> bool { + let Ok(ts) = chrono::NaiveDateTime::parse_from_str(updated, "%Y-%m-%dT%H:%M:%S") else { + return true; + }; + let now = Utc::now().naive_utc(); + let age = now.signed_duration_since(ts); + age.num_seconds() >= 86400 +} + +fn format_age_suffix(age: &str) -> String { + if age.ends_with("d ago") { + format!(" {} {}", age.dimmed(), "!".yellow()) + } else { + format!(" {}", age.dimmed()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_csv_basic() { + let result = parse_csv("a.de,b.de,c.de"); + assert_eq!(result, vec!["a.de", "b.de", "c.de"]); + } + + #[test] + fn parse_csv_empty() { + assert!(parse_csv("").is_empty()); + } + + #[test] + fn age_format() { + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + let age = compute_age(&now); + assert!(age.contains("s ago") || age == "just now"); + } + + #[test] + fn stale_detection() { + assert!(is_stale("2020-01-01T00:00:00")); + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + assert!(!is_stale(&now)); + } +} diff --git a/src/translate.rs b/src/translate.rs new file mode 100644 index 0000000..68052ed --- /dev/null +++ b/src/translate.rs @@ -0,0 +1,144 @@ +/// Translates canonical (unified) CLI args to version-specific `uberspace` args. +/// +/// The canonical form uses v7-style commands. For v8 asteroids, certain commands +/// are rewritten to the v8 equivalents. For v7, args pass through unchanged. +pub fn translate(version: u8, args: &[String]) -> Result, String> { + if version == 7 || args.is_empty() { + return Ok(args.to_vec()); + } + if version != 8 { + return Err(format!("unsupported uberspace version: {version}")); + } + + let joined = args.iter().map(|s| s.as_str()).collect::>(); + + match joined.as_slice() { + // mail user list → mail address list + ["mail", "user", "list"] => Ok(words("mail address list")), + + // mail user add → mail address add + ["mail", "user", "add", addr] => Ok(words_with("mail address add", addr)), + + // mail user del → mail address del + ["mail", "user", "del", addr] => Ok(words_with("mail address del", addr)), + + // port list → web backend list + ["port", "list"] => Ok(words("web backend list")), + + // tools version use → tool version set + ["tools", "version", "use", tool, ver] => { + Ok(vec![ + "tool".into(), + "version".into(), + "set".into(), + (*tool).into(), + (*ver).into(), + ]) + } + + // tools restart → web reload + ["tools", "restart", tool] => { + Ok(vec!["web".into(), (*tool).into(), "reload".into()]) + } + + // web backend set --http --port → web backend add port + ["web", "backend", "set", path, "--http", "--port", port] => { + Ok(vec![ + "web".into(), + "backend".into(), + "add".into(), + (*path).into(), + "port".into(), + (*port).into(), + ]) + } + + // Everything else passes through unchanged + _ => Ok(args.to_vec()), + } +} + +fn words(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() +} + +fn words_with(prefix: &str, suffix: &str) -> Vec { + let mut v = words(prefix); + v.push(suffix.to_string()); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() + } + + #[test] + fn v7_passthrough() { + let input = args("mail user list"); + assert_eq!(translate(7, &input).unwrap(), input); + } + + #[test] + fn v8_mail_user_list() { + let result = translate(8, &args("mail user list")).unwrap(); + assert_eq!(result, args("mail address list")); + } + + #[test] + fn v8_mail_user_add() { + let result = translate(8, &args("mail user add foo@bar.de")).unwrap(); + assert_eq!(result, args("mail address add foo@bar.de")); + } + + #[test] + fn v8_mail_user_del() { + let result = translate(8, &args("mail user del foo@bar.de")).unwrap(); + assert_eq!(result, args("mail address del foo@bar.de")); + } + + #[test] + fn v8_port_list() { + let result = translate(8, &args("port list")).unwrap(); + assert_eq!(result, args("web backend list")); + } + + #[test] + fn v8_tools_version_use() { + let result = translate(8, &args("tools version use php 8.2")).unwrap(); + assert_eq!(result, args("tool version set php 8.2")); + } + + #[test] + fn v8_tools_restart() { + let result = translate(8, &args("tools restart php")).unwrap(); + assert_eq!(result, args("web php reload")); + } + + #[test] + fn v8_web_backend_set() { + let result = translate(8, &args("web backend set / --http --port 8080")).unwrap(); + assert_eq!(result, args("web backend add / port 8080")); + } + + #[test] + fn v8_unknown_passthrough() { + let input = args("web domain list"); + assert_eq!(translate(8, &input).unwrap(), input); + } + + #[test] + fn empty_args() { + let result = translate(8, &[]).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn unsupported_version() { + let result = translate(9, &args("port list")); + assert!(result.is_err()); + } +}