diff --git a/Cargo.lock b/Cargo.lock index b184b2bdb..dbcb463cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ dependencies = [ "serde_json", "serde_with", "solana-datasets", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "toml", "tracing", @@ -76,7 +76,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "url", "urlencoding", @@ -264,7 +264,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -301,7 +301,7 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -343,7 +343,7 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -368,7 +368,7 @@ dependencies = [ "alloy-rlp", "borsh", "serde", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -391,7 +391,7 @@ dependencies = [ "serde", "serde_with", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -443,7 +443,7 @@ dependencies = [ "http 1.4.0", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", ] @@ -470,7 +470,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -503,7 +503,7 @@ dependencies = [ "rand 0.8.5", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "url", ] @@ -573,7 +573,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -704,7 +704,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -730,7 +730,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -746,7 +746,7 @@ dependencies = [ "async-trait", "k256", "rand 0.8.5", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -837,7 +837,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tower", "tracing", @@ -943,7 +943,7 @@ dependencies = [ "serde_json", "sqlx", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tonic 0.13.1", "tracing", @@ -964,7 +964,7 @@ dependencies = [ "metadata-db-postgres", "monitoring", "serde", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", ] @@ -983,7 +983,7 @@ dependencies = [ "rand 0.9.2", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "url", "uuid", ] @@ -1009,7 +1009,7 @@ dependencies = [ "rand 0.9.2", "serde", "solana-datasets", - "thiserror 2.0.18", + "thiserror 2.0.17", "toml", "tracing", "url", @@ -1024,7 +1024,7 @@ dependencies = [ "object_store", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", ] @@ -1036,7 +1036,7 @@ dependencies = [ "fs-err", "object_store", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.17", "url", ] @@ -1051,7 +1051,7 @@ dependencies = [ "object_store", "parking_lot", "serde", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "toml", "tracing", @@ -1062,16 +1062,24 @@ name = "ampcc" version = "0.1.0" dependencies = [ "admin-client", + "alloy", "anyhow", + "arboard", + "base64 0.22.1", + "chrono", "crossterm 0.29.0", "datasets-common", "directories", + "dirs", "figment", + "open", + "rand 0.9.2", "ratatui", "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "sha2", + "thiserror 2.0.17", "tokio", "url", "urlencoding", @@ -1098,7 +1106,7 @@ dependencies = [ "serde", "serde_json", "solana-datasets", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "toml", "tracing", @@ -1125,7 +1133,7 @@ dependencies = [ "monitoring", "server", "snmalloc-rs", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", "vergen-gitcl", @@ -1153,7 +1161,7 @@ dependencies = [ "pgtemp", "sqlparser", "sqlx", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -1253,6 +1261,26 @@ dependencies = [ "object", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.8.0" @@ -1718,7 +1746,7 @@ dependencies = [ "bytes", "enum_dispatch", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -1879,7 +1907,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tower", "tracing", @@ -2358,6 +2386,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -2449,7 +2483,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -2633,6 +2667,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.57" @@ -2724,7 +2767,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -2896,7 +2939,7 @@ dependencies = [ "opentelemetry-instrumentation-tower", "rand 0.9.2", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tower-http", "tracing", @@ -4031,7 +4074,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -4045,7 +4088,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -4090,7 +4133,7 @@ dependencies = [ "datasets-common", "futures", "monitoring", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -4295,6 +4338,15 @@ dependencies = [ "dirs-sys", ] +[[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" @@ -4304,7 +4356,17 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", ] [[package]] @@ -4406,7 +4468,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", @@ -4601,9 +4663,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -4660,7 +4728,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tower", "tracing", @@ -4727,6 +4795,35 @@ dependencies = [ "bytes", ] +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "feature-probe" version = "0.1.1" @@ -4803,7 +4900,7 @@ dependencies = [ "schemars 1.2.0", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tonic 0.13.1", "tonic-build", @@ -5226,6 +5323,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -5733,7 +5840,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -5873,6 +5980,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -6014,6 +6135,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -6106,7 +6246,7 @@ dependencies = [ "indoc", "num-traits", "num_cpus", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "v8", ] @@ -6180,7 +6320,7 @@ checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -6564,7 +6704,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", @@ -6643,12 +6783,22 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "serde", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "tracing-opentelemetry", "tracing-subscriber", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multibase" version = "0.9.2" @@ -6748,7 +6898,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6960,6 +7110,79 @@ dependencies = [ "smallvec", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.32.2" @@ -6998,7 +7221,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -7034,6 +7257,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openapiv3" version = "2.2.0" @@ -7105,7 +7339,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", ] @@ -7152,7 +7386,7 @@ dependencies = [ "opentelemetry_sdk", "prost 0.14.1", "reqwest", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tonic 0.14.2", "tracing", @@ -7189,7 +7423,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.2", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", ] @@ -7352,6 +7586,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.11.0" @@ -7642,6 +7882,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -7783,7 +8036,7 @@ dependencies = [ "sha2", "spki", "syn 2.0.114", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", "uuid", @@ -7885,7 +8138,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.114", - "thiserror 2.0.18", + "thiserror 2.0.17", "typify", "unicode-ident", ] @@ -8021,6 +8274,15 @@ dependencies = [ "cc", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + [[package]] name = "qstring" version = "0.7.2" @@ -8051,6 +8313,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -8074,8 +8342,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", - "thiserror 2.0.18", + "socket2 0.6.1", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -8098,7 +8366,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier", "slab", - "thiserror 2.0.18", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -8113,9 +8381,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8247,7 +8515,7 @@ dependencies = [ "kasuari", "lru", "strum 0.27.2", - "thiserror 2.0.18", + "thiserror 2.0.17", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -8385,7 +8653,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -8667,7 +8935,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8734,7 +9002,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8767,7 +9035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -9198,7 +9466,7 @@ dependencies = [ "prost 0.13.5", "rand 0.9.2", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tonic 0.13.1", "tower-http", @@ -9454,7 +9722,7 @@ dependencies = [ "spl-token-group-interface", "spl-token-interface", "spl-token-metadata-interface", - "thiserror 2.0.18", + "thiserror 2.0.17", "zstd", ] @@ -9618,7 +9886,7 @@ dependencies = [ "solana-transaction-error", "solana-transaction-status-client-types", "solana-udp-client", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-util", ] @@ -9712,7 +9980,7 @@ dependencies = [ "solana-metrics", "solana-time-utils", "solana-transaction-error", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", ] @@ -9741,7 +10009,7 @@ dependencies = [ "curve25519-dalek", "solana-define-syscall 3.0.0", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -9772,7 +10040,7 @@ dependencies = [ "solana-sdk", "solana-storage-proto", "solana-transaction-status-client-types", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -9879,7 +10147,7 @@ dependencies = [ "solana-pubkey 3.0.0", "solana-sdk-ids", "solana-system-interface", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10106,13 +10374,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd3143e9fb2bc093412f148c5a810cfd6f637d7ba829548a43191a3efaacdb3" dependencies = [ "crossbeam-channel", - "gethostname", + "gethostname 0.2.3", "log", "reqwest", "solana-cluster-type", "solana-sha256-hasher", "solana-time-utils", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10372,7 +10640,7 @@ dependencies = [ "solana-pubkey 3.0.0", "solana-rpc-client-types", "solana-signature", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-tungstenite 0.28.0", @@ -10406,7 +10674,7 @@ dependencies = [ "solana-streamer", "solana-tls-utils", "solana-transaction-error", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", ] @@ -10510,7 +10778,7 @@ dependencies = [ "solana-signer", "solana-transaction-error", "solana-transaction-status-client-types", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10527,7 +10795,7 @@ dependencies = [ "solana-pubkey 3.0.0", "solana-rpc-client", "solana-sdk-ids", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10554,7 +10822,7 @@ dependencies = [ "solana-transaction-status-client-types", "solana-version", "spl-generic-token", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10574,7 +10842,7 @@ dependencies = [ "hash32", "log", "rustc-demangle", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10612,7 +10880,7 @@ dependencies = [ "solana-time-utils", "solana-transaction", "solana-transaction-error", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10644,7 +10912,7 @@ checksum = "9de18cfdab99eeb940fbedd8c981fa130c0d76252da75d05446f22fae8b51932" dependencies = [ "k256", "solana-define-syscall 4.0.1", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -10867,7 +11135,7 @@ dependencies = [ "solana-tls-utils", "solana-transaction-error", "solana-transaction-metrics-tracker", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-util", "x509-parser", @@ -10996,7 +11264,7 @@ dependencies = [ "solana-signer", "solana-transaction", "solana-transaction-error", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", ] @@ -11107,7 +11375,7 @@ dependencies = [ "spl-token-group-interface", "spl-token-interface", "spl-token-metadata-interface", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11131,7 +11399,7 @@ dependencies = [ "solana-transaction", "solana-transaction-context", "solana-transaction-error", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11146,7 +11414,7 @@ dependencies = [ "solana-net-utils", "solana-streamer", "solana-transaction-error", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", ] @@ -11222,7 +11490,7 @@ dependencies = [ "solana-signature", "solana-signer", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.17", "wasm-bindgen", "zeroize", ] @@ -11338,7 +11606,7 @@ dependencies = [ "solana-program-option", "solana-pubkey 3.0.0", "solana-zk-sdk", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11366,7 +11634,7 @@ dependencies = [ "spl-token-group-interface", "spl-token-metadata-interface", "spl-type-length-value", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11386,7 +11654,7 @@ dependencies = [ "solana-sdk-ids", "solana-zk-sdk", "spl-pod", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11397,7 +11665,7 @@ checksum = "a0cd59fce3dc00f563c6fa364d67c3f200d278eae681f4dc250240afcfe044b1" dependencies = [ "curve25519-dalek", "solana-zk-sdk", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11415,7 +11683,7 @@ dependencies = [ "solana-pubkey 3.0.0", "spl-discriminator", "spl-pod", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11435,7 +11703,7 @@ dependencies = [ "solana-program-pack", "solana-pubkey 3.0.0", "solana-sdk-ids", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11454,7 +11722,7 @@ dependencies = [ "spl-discriminator", "spl-pod", "spl-type-length-value", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11472,7 +11740,7 @@ dependencies = [ "solana-program-error", "spl-discriminator", "spl-pod", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -11538,7 +11806,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -11621,7 +11889,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "whoami", ] @@ -11659,7 +11927,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "whoami", ] @@ -11684,7 +11952,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "url", ] @@ -11889,7 +12157,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -12007,7 +12275,7 @@ dependencies = [ "serde_yaml", "server", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "toml", "tonic 0.13.1", @@ -12027,11 +12295,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.17", ] [[package]] @@ -12047,9 +12315,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -12085,6 +12353,20 @@ dependencies = [ "ordered-float 2.10.1", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half 2.7.1", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.45" @@ -12419,9 +12701,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", @@ -12582,7 +12864,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.17", "utf-8", ] @@ -12601,7 +12883,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.17", "utf-8", "webpki-roots 0.26.11", ] @@ -12647,7 +12929,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.114", - "thiserror 2.0.18", + "thiserror 2.0.17", "unicode-ident", ] @@ -13164,6 +13446,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -13286,7 +13574,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13700,7 +13988,7 @@ dependencies = [ "monitoring", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -13725,7 +14013,7 @@ dependencies = [ "pharos", "rustc_version 0.4.1", "send_wrapper", - "thiserror 2.0.18", + "thiserror 2.0.17", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -13740,6 +14028,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname 1.1.0", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -13797,7 +14102,7 @@ dependencies = [ "indexmap 2.13.0", "multihash", "serde_cbor", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", ] @@ -13957,3 +14262,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/crates/bin/ampcc/Cargo.toml b/crates/bin/ampcc/Cargo.toml index afc6a4790..d290e4ae2 100644 --- a/crates/bin/ampcc/Cargo.toml +++ b/crates/bin/ampcc/Cargo.toml @@ -1,13 +1,22 @@ [package] name = "ampcc" +description = "TUI to interact with Amp Datasets. View published Datasets, fetch and view their manifest and schemas. Search for Datasets." edition.workspace = true version.workspace = true license-file.workspace = true [dependencies] +alloy.workspace = true anyhow.workspace = true +arboard = "3.6.1" +base64 = "0.22.1" +chrono.workspace = true crossterm = "0.29.0" +dirs = "6.0.0" +open = "5.3.3" +rand = "0.9.2" ratatui = "0.30" +sha2 = "0.10.9" tokio.workspace = true admin-client = { path = "../../clients/admin" } worker = { path = "../../services/worker" } diff --git a/crates/bin/ampcc/src/action.rs b/crates/bin/ampcc/src/action.rs new file mode 100644 index 000000000..a2e435fa4 --- /dev/null +++ b/crates/bin/ampcc/src/action.rs @@ -0,0 +1,193 @@ +//! Application actions for state management. +//! +//! Actions are the single source of truth for state mutations. +//! All state changes flow through the action handler. + +use admin_client::{ + jobs::JobInfo, + workers::{WorkerDetailResponse, WorkerInfo}, +}; +use worker::job::JobId; + +use crate::{app::DataSource, auth::AuthStorage}; + +/// Actions that can be dispatched to mutate application state. +#[derive(Debug)] +pub enum Action { + /// No-op action (used for events that don't need handling). + None, + + /// Quit the application. + Quit, + + // ======================================================================== + // Navigation Actions + // ======================================================================== + /// Navigate to next item in focused pane. + NavigateDown, + + /// Navigate to previous item in focused pane. + NavigateUp, + + /// Page down in focused pane. + PageDown(u16), + + /// Page up in focused pane. + PageUp(u16), + + /// Cycle to next pane. + NextPane, + + /// Cycle to previous pane. + PrevPane, + + /// Toggle expand/collapse on selected dataset. + ToggleExpand, + + /// Enter detail view for selected item. + EnterDetail, + + // ======================================================================== + // Source Switching + // ======================================================================== + /// Switch to local data source. + SwitchToLocal, + + /// Switch to registry data source. + SwitchToRegistry, + + /// Source switch completed. + SourceSwitched(Result), + + // ======================================================================== + // Search Actions + // ======================================================================== + /// Enter search mode. + EnterSearchMode, + + /// Exit search mode. + ExitSearchMode, + + /// Add character to search query. + SearchInput(char), + + /// Remove character from search query. + SearchBackspace, + + /// Submit search (exit search mode and refresh). + SearchSubmit, + + // ======================================================================== + // Dataset Actions + // ======================================================================== + /// Trigger dataset refresh. + RefreshDatasets, + + /// Datasets loaded from source. + DatasetsLoaded(Result, String>), + + /// Dataset versions loaded. + VersionsLoaded { + dataset_index: usize, + versions: Result, String>, + }, + + // ======================================================================== + // Manifest Actions + // ======================================================================== + /// Load manifest for selected dataset. + LoadManifest, + + /// Manifest loaded. + ManifestLoaded(Option), + + // ======================================================================== + // Jobs Actions (Local mode) + // ======================================================================== + /// Refresh jobs list. + RefreshJobs, + + /// Jobs loaded. + JobsLoaded(Vec), + + /// Stop a job. + StopJob(JobId), + + /// Job stopped result. + JobStopped(Result<(), String>), + + /// Delete a job. + DeleteJob(JobId), + + /// Job deleted result. + JobDeleted(Result<(), String>), + + // ======================================================================== + // Workers Actions (Local mode) + // ======================================================================== + /// Refresh workers list. + RefreshWorkers, + + /// Workers loaded. + WorkersLoaded(Vec), + + /// Load worker details. + LoadWorkerDetail(String), + + /// Worker detail loaded. + WorkerDetailLoaded(Option), + + // ======================================================================== + // Auth Actions + // ======================================================================== + /// Check auth state on startup. + AuthCheckOnStartup, + + /// Auth state loaded from disk. + AuthStateLoaded(Option), + + /// Start the login flow. + AuthLogin, + + /// Logout and clear credentials. + AuthLogout, + + /// Device flow initiated - waiting for user confirmation. + AuthDeviceFlowPending { + user_code: String, + verification_uri: String, + device_code: String, + code_verifier: String, + interval: i64, + }, + + /// User confirmed device flow - open browser and start polling. + AuthDeviceFlowConfirm, + + /// Poll for device token. + AuthDeviceFlowPoll { + device_code: String, + code_verifier: String, + interval: i64, + /// If true, poll immediately without delay (first poll after user confirms). + is_first_poll: bool, + }, + + /// Device flow completed successfully. + AuthDeviceFlowComplete(AuthStorage), + + /// Cancel device flow. + AuthDeviceFlowCancel, + + /// Auth error occurred. + AuthError(String), + + /// Token refresh completed. + AuthRefreshComplete(Result), + + // ======================================================================== + // Error Actions + // ======================================================================== + /// Display an error message. + Error(String), +} diff --git a/crates/bin/ampcc/src/app.rs b/crates/bin/ampcc/src/app.rs index 3cd1a1be9..c493c7e63 100644 --- a/crates/bin/ampcc/src/app.rs +++ b/crates/bin/ampcc/src/app.rs @@ -9,9 +9,11 @@ use admin_client::{ }; use anyhow::{Context, Result}; use ratatui::widgets::ScrollbarState; +use reqwest::Client as HttpClient; +use tokio::sync::mpsc::UnboundedSender; use url::Url; -use crate::{config::Config, registry::RegistryClient}; +use crate::{action::Action, auth::AuthStorage, config::Config, registry::RegistryClient}; /// Input mode for the application. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,6 +109,42 @@ impl DataSource { } } +/// Status of the device flow authentication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceFlowStatus { + /// Waiting for user to confirm (press Enter to open browser). + AwaitingConfirmation, + /// Browser opened, waiting for user to complete auth. + WaitingForBrowser, + /// Actively polling for token. + Polling, + /// Error occurred while opening the user's browser. + /// + /// Print the Auth URL so they can open it manually. + OpenBrowserFailure(String), + /// Error occurred during device flow. + Error(String), +} + +/// State for the device flow authentication process. +#[derive(Debug, Clone)] +pub struct DeviceFlowState { + /// User code to display/copy. + pub user_code: String, + /// URL where user authenticates. + pub verification_uri: String, + /// Device code for polling. + pub device_code: String, + /// PKCE code verifier. + pub code_verifier: String, + /// Polling interval in seconds. + pub interval: i64, + /// Current status. + pub status: DeviceFlowStatus, + /// If copy-to-clipboard threw an error, display in auth_screen + pub copy_to_clipboard_failed: bool, +} + /// A version entry for a dataset. #[derive(Debug, Clone)] pub struct VersionEntry { @@ -335,10 +373,20 @@ fn format_complex_type(type_name: &str, params: &serde_json::Value) -> String { pub struct App { pub config: Config, + // Action channel for async task communication + pub action_tx: UnboundedSender, + + // HTTP client (shared with auth clients) + pub http_client: HttpClient, + // Clients pub local_client: Arc, pub registry_client: RegistryClient, + // Auth state + pub auth_state: Option, + pub auth_device_flow: Option, + // Data source pub current_source: DataSource, @@ -402,7 +450,11 @@ pub struct App { impl App { /// Create a new application instance. - pub fn new(config: Config) -> Result { + pub fn new( + config: Config, + action_tx: UnboundedSender, + http_client: HttpClient, + ) -> Result { let admin_url = Url::parse(&config.local_admin_url).context("invalid admin URL")?; let local_client = Arc::new(Client::new(admin_url)); @@ -416,8 +468,12 @@ impl App { Ok(Self { config, + action_tx, + http_client, local_client, registry_client, + auth_state: None, + auth_device_flow: None, current_source: default_source, should_quit: false, input_mode: InputMode::Normal, @@ -455,11 +511,6 @@ impl App { /// Spinner frames for loading animation (braille pattern). pub const SPINNER_FRAMES: &'static [char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - /// Quit the application. - pub fn quit(&mut self) { - self.should_quit = true; - } - /// Advance the spinner animation frame. pub fn tick_spinner(&mut self) { if self.loading { @@ -621,33 +672,11 @@ impl App { } } - /// Fetch datasets from the current source. - pub async fn fetch_datasets(&mut self) -> Result<()> { - self.start_loading("Fetching datasets..."); - - let result = match self.current_source { - DataSource::Local => self.fetch_local_datasets().await, - DataSource::Registry => self.fetch_registry_datasets().await, - }; - - self.stop_loading(); - - match result { - Ok(datasets) => { - self.datasets = datasets; - self.update_search(); - Ok(()) - } - Err(e) => { - self.error_message = Some(e.to_string()); - Err(e) - } - } - } - /// Fetch datasets from local admin API. - async fn fetch_local_datasets(&self) -> Result> { - let response = self.local_client.datasets().list_all().await?; + /// + /// This is an associated function that can be called from spawn tasks. + pub async fn fetch_local_datasets(client: &admin_client::Client) -> Result> { + let response = client.datasets().list_all().await?; Ok(response .datasets .into_iter() @@ -663,12 +692,16 @@ impl App { } /// Fetch datasets from the registry. - async fn fetch_registry_datasets(&self) -> Result> { + /// + /// This is an associated function that can be called from spawn tasks. + pub async fn fetch_registry_datasets( + client: &crate::registry::RegistryClient, + ) -> Result> { let mut all_datasets = Vec::new(); let mut page = 1; loop { - let response = self.registry_client.list_datasets(page).await?; + let response = client.list_datasets(page).await?; for d in response.datasets { all_datasets.push(DatasetEntry { namespace: d.namespace, @@ -689,32 +722,25 @@ impl App { Ok(all_datasets) } - /// Switch to a different data source. - pub async fn switch_source(&mut self, source: DataSource) -> Result<()> { - if self.current_source == source { - return Ok(()); - } - - self.current_source = source; - self.search_query.clear(); - self.selected_index = 0; - self.selected_version_indices.clear(); - self.current_manifest = None; - - // Clear jobs/workers when switching away from Local - if source == DataSource::Registry { - self.jobs.clear(); - self.workers.clear(); - self.selected_job_index = 0; - self.selected_worker_index = 0; - self.content_view = ContentView::None; - // If currently on Jobs or Workers pane, switch to Datasets - if matches!(self.active_pane, ActivePane::Jobs | ActivePane::Workers) { - self.active_pane = ActivePane::Datasets; - } - } - - self.fetch_datasets().await + /// Fetch versions for a dataset from the registry. + /// + /// This is an associated function that can be called from spawn tasks. + pub async fn fetch_registry_versions( + client: &crate::registry::RegistryClient, + namespace: &str, + name: &str, + ) -> Result> { + let versions = client.get_versions(namespace, name).await?; + Ok(versions + .into_iter() + .enumerate() + .map(|(i, v)| VersionEntry { + version_tag: v.version_tag, + status: v.status, + created_at: v.created_at, + is_latest: i == 0, + }) + .collect()) } /// Check if the current source is Local. @@ -851,26 +877,6 @@ impl App { None } - /// Convert (dataset_index, version_index) to flat index. - fn position_to_index(&self, dataset_idx: usize, version_idx: Option) -> usize { - let mut index = 0; - for (i, dataset) in self.filtered_datasets.iter().enumerate() { - if i == dataset_idx { - if let Some(v_idx) = version_idx { - return index + 1 + v_idx; - } - return index; - } - index += 1; - if dataset.expanded - && let Some(versions) = &dataset.versions - { - index += versions.len(); - } - } - index - } - /// Select the next item. pub fn select_next(&mut self) { let total = self.total_items(); @@ -938,69 +944,6 @@ impl App { } } - /// Toggle expansion of the currently selected dataset. - pub async fn toggle_expand(&mut self) -> Result<()> { - let Some((dataset_idx, version_idx)) = self.index_to_position(self.selected_index) else { - return Ok(()); - }; - - // Only toggle if we're on a dataset, not a version - if version_idx.is_some() { - return Ok(()); - } - - let dataset = match self.filtered_datasets.get_mut(dataset_idx) { - Some(d) => d, - None => return Ok(()), - }; - - if dataset.expanded { - // Collapse - dataset.expanded = false; - // Recalculate selected_index to stay on this dataset - self.selected_index = self.position_to_index(dataset_idx, None); - } else { - // Expand - fetch versions if not already loaded - let needs_fetch = dataset.versions.is_none(); - let namespace = dataset.namespace.clone(); - let name = dataset.name.clone(); - - if needs_fetch { - let versions = self.fetch_versions(&namespace, &name).await?; - let dataset = self.filtered_datasets.get_mut(dataset_idx).unwrap(); - dataset.versions = Some(versions); - } - let dataset = self.filtered_datasets.get_mut(dataset_idx).unwrap(); - dataset.expanded = true; - } - - Ok(()) - } - - /// Fetch versions for a dataset. - async fn fetch_versions(&self, namespace: &str, name: &str) -> Result> { - match self.current_source { - DataSource::Local => { - // Local doesn't have version listing in admin-client currently - // Return just the latest version if available - Ok(Vec::new()) - } - DataSource::Registry => { - let versions = self.registry_client.get_versions(namespace, name).await?; - Ok(versions - .into_iter() - .enumerate() - .map(|(i, v)| VersionEntry { - version_tag: v.version_tag, - status: v.status, - created_at: v.created_at, - is_latest: i == 0, - }) - .collect()) - } - } - } - /// Fetch manifest for a specific dataset version. #[allow(dead_code)] pub async fn fetch_manifest( @@ -1032,4 +975,426 @@ impl App { } } } + + /// Check if the application is still running. + pub fn is_running(&self) -> bool { + !self.should_quit + } + + /// Send an action to the action channel. + pub fn send_action(&self, action: Action) { + let _ = self.action_tx.send(action); + } + + /// Handle an action and mutate state accordingly. + pub fn handle_action(&mut self, action: Action) { + match action { + Action::None => {} + + Action::Quit => self.should_quit = true, + + // Navigation + Action::NavigateDown => match self.active_pane { + ActivePane::Datasets => { + self.select_next(); + self.send_action(Action::LoadManifest); + } + ActivePane::Jobs => { + self.select_next_job(); + if let Some(job) = self.get_selected_job().cloned() { + self.content_view = ContentView::Job(job); + self.reset_scroll(); + } + } + ActivePane::Workers => { + self.select_next_worker(); + if let Some(node_id) = self.get_selected_worker().map(|w| w.node_id.clone()) { + self.send_action(Action::LoadWorkerDetail(node_id)); + } + } + _ => self.scroll_down(), + }, + + Action::NavigateUp => match self.active_pane { + ActivePane::Datasets => { + self.select_previous(); + self.send_action(Action::LoadManifest); + } + ActivePane::Jobs => { + self.select_previous_job(); + if let Some(job) = self.get_selected_job().cloned() { + self.content_view = ContentView::Job(job); + self.reset_scroll(); + } + } + ActivePane::Workers => { + self.select_previous_worker(); + if let Some(node_id) = self.get_selected_worker().map(|w| w.node_id.clone()) { + self.send_action(Action::LoadWorkerDetail(node_id)); + } + } + _ => self.scroll_up(), + }, + + Action::PageDown(size) => self.page_down(size), + Action::PageUp(size) => self.page_up(size), + + Action::NextPane => { + let is_local = self.is_local(); + self.active_pane = self.active_pane.next(is_local); + } + + Action::PrevPane => { + let is_local = self.is_local(); + self.active_pane = self.active_pane.prev(is_local); + } + + Action::ToggleExpand => { + // This needs async - will be handled by spawning a task + // For now, mark as needing implementation + } + + Action::EnterDetail => match self.active_pane { + ActivePane::Jobs => { + if let Some(job) = self.get_selected_job().cloned() { + self.content_view = ContentView::Job(job); + self.reset_scroll(); + self.active_pane = ActivePane::Detail; + } + } + ActivePane::Workers => { + if let Some(node_id) = self.get_selected_worker().map(|w| w.node_id.clone()) { + self.send_action(Action::LoadWorkerDetail(node_id)); + self.active_pane = ActivePane::Detail; + } + } + _ => {} + }, + + // Source switching + Action::SwitchToLocal => { + if self.current_source != DataSource::Local { + self.start_loading("Switching source..."); + // Spawns async task handled by handle_async_action + } + } + + Action::SwitchToRegistry => { + if self.current_source != DataSource::Registry { + self.start_loading("Switching source..."); + // Spawns async task handled by handle_async_action + } + } + + Action::SourceSwitched(result) => match result { + Ok(source) => { + self.current_source = source; + self.search_query.clear(); + self.selected_index = 0; + self.selected_version_indices.clear(); + self.current_manifest = None; + + if source == DataSource::Registry { + self.jobs.clear(); + self.workers.clear(); + self.selected_job_index = 0; + self.selected_worker_index = 0; + self.content_view = ContentView::None; + if matches!(self.active_pane, ActivePane::Jobs | ActivePane::Workers) { + self.active_pane = ActivePane::Datasets; + } + } + + self.send_action(Action::RefreshDatasets); + } + Err(e) => { + self.error_message = Some(e); + self.stop_loading(); + } + }, + + // Search + Action::EnterSearchMode => { + self.input_mode = InputMode::Search; + } + + Action::ExitSearchMode => { + self.input_mode = InputMode::Normal; + } + + Action::SearchInput(c) => { + self.search_query.push(c); + self.update_search(); + } + + Action::SearchBackspace => { + self.search_query.pop(); + self.update_search(); + } + + Action::SearchSubmit => { + self.input_mode = InputMode::Normal; + self.send_action(Action::LoadManifest); + } + + // Datasets + Action::RefreshDatasets => { + self.start_loading("Refreshing datasets..."); + // Spawns async task to refresh datasets handled by handle_async_action + } + + Action::DatasetsLoaded(result) => { + match result { + Ok(datasets) => { + self.datasets = datasets; + self.update_search(); + self.send_action(Action::LoadManifest); + } + Err(e) => { + self.error_message = Some(e); + } + } + self.stop_loading(); + } + + Action::VersionsLoaded { + dataset_index, + versions, + } => { + match versions { + Ok(v) => { + if let Some(dataset) = self.filtered_datasets.get_mut(dataset_index) { + dataset.versions = Some(v); + dataset.expanded = true; + } + } + Err(e) => { + self.error_message = Some(e); + } + } + self.stop_loading(); + } + + // Manifest + Action::LoadManifest => { + self.start_loading("Loading manifest..."); + // Spawns async task to load manifest handled by handle_async_action + } + + Action::ManifestLoaded(manifest) => { + self.reset_scroll(); + self.current_inspect = manifest.as_ref().and_then(InspectResult::from_manifest); + self.current_manifest = manifest; + self.content_view = ContentView::Dataset; + self.stop_loading(); + } + + // Jobs + Action::RefreshJobs => {} + + Action::JobsLoaded(jobs) => { + self.jobs = jobs; + if self.selected_job_index >= self.jobs.len() && !self.jobs.is_empty() { + self.selected_job_index = self.jobs.len() - 1; + } + } + + Action::StopJob(_job_id) => { + self.start_loading("Stopping job..."); + // Spawns async task to stop job handled by handle_async_action + } + + Action::JobStopped(result) => { + match result { + Ok(()) => self.send_action(Action::RefreshJobs), + Err(e) => self.error_message = Some(e), + } + self.stop_loading(); + } + + Action::DeleteJob(_job_id) => { + self.start_loading("Deleting job..."); + // Spawns async task to delete job handled by handle_async_action + } + + Action::JobDeleted(result) => { + match result { + Ok(()) => self.send_action(Action::RefreshJobs), + Err(e) => self.error_message = Some(e), + } + self.stop_loading(); + } + + // Workers + Action::RefreshWorkers => {} + + Action::WorkersLoaded(workers) => { + self.workers = workers; + if self.selected_worker_index >= self.workers.len() && !self.workers.is_empty() { + self.selected_worker_index = self.workers.len() - 1; + } + } + + Action::LoadWorkerDetail(_node_id) => { + self.start_loading("Loading worker details..."); + // Spawns async task to load worker details handled by handle_async_action + } + + Action::WorkerDetailLoaded(detail) => { + if let Some(worker_detail) = detail { + self.content_view = ContentView::Worker(worker_detail); + self.reset_scroll(); + } + self.stop_loading(); + } + + // Auth + Action::AuthCheckOnStartup => {} + + Action::AuthStateLoaded(auth) => { + // Update registry client with the loaded auth token + if let Some(ref a) = auth { + self.registry_client = RegistryClient::with_token( + self.config.registry_url.clone(), + Some(a.access_token.clone()), + ); + } + self.auth_state = auth; + } + + Action::AuthLogin => { + if self.auth_state.is_none() && self.auth_device_flow.is_none() { + self.start_loading("Logging in..."); + // Spawns async task to log user in with auth flow handled by handle_async_action + } + } + + Action::AuthLogout => { + let _ = AuthStorage::clear(); + self.auth_state = None; + self.auth_device_flow = None; + // Rebuild registry client without explicit token (falls back to env var) + self.registry_client = + RegistryClient::with_token(self.config.registry_url.clone(), None); + } + + Action::AuthDeviceFlowPending { + user_code, + verification_uri, + device_code, + code_verifier, + interval, + } => { + self.stop_loading(); + + let mut copy_to_clipboard_failed = false; + // Copy user code to clipboard + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if clipboard.set_text(&user_code).is_err() { + copy_to_clipboard_failed = true; + } + } else { + copy_to_clipboard_failed = true; + } + + self.auth_device_flow = Some(DeviceFlowState { + user_code, + verification_uri, + device_code, + code_verifier, + interval, + status: DeviceFlowStatus::AwaitingConfirmation, + copy_to_clipboard_failed, + }); + } + + Action::AuthDeviceFlowConfirm => { + if let Some(ref mut flow) = self.auth_device_flow { + if crate::auth::PkceDeviceFlowClient::open_browser(&flow.verification_uri) + .is_err() + { + // pass the auth URL to the error to print in the auth screen + flow.status = + DeviceFlowStatus::OpenBrowserFailure(self.config.auth_url.clone()); + } else { + flow.status = DeviceFlowStatus::WaitingForBrowser; + } + // Clone values before sending to avoid borrow conflict + let device_code = flow.device_code.clone(); + let code_verifier = flow.code_verifier.clone(); + let interval = flow.interval; + self.send_action(Action::AuthDeviceFlowPoll { + device_code, + code_verifier, + interval, + is_first_poll: true, + }); + } + } + + Action::AuthDeviceFlowPoll { + device_code, + code_verifier, + interval, + is_first_poll, + } => { + if let Some(ref mut flow) = self.auth_device_flow { + flow.status = DeviceFlowStatus::Polling; + } + let _ = (device_code, code_verifier, interval, is_first_poll); + } + + Action::AuthDeviceFlowComplete(auth) => { + let _ = auth.save(); + // Update registry client with new auth token + self.registry_client = RegistryClient::with_token( + self.config.registry_url.clone(), + Some(auth.access_token.clone()), + ); + self.auth_state = Some(auth); + self.auth_device_flow = None; + } + + Action::AuthDeviceFlowCancel => { + self.auth_device_flow = None; + } + + Action::AuthError(error) => { + if let Some(ref mut flow) = self.auth_device_flow { + flow.status = DeviceFlowStatus::Error(error); + } else { + // Error during initial login request (before device flow started) + self.stop_loading(); + self.error_message = Some(error); + } + } + + Action::AuthRefreshComplete(result) => match result { + Ok(auth) => { + let _ = auth.save(); + // Update registry client with refreshed token + self.registry_client = RegistryClient::with_token( + self.config.registry_url.clone(), + Some(auth.access_token.clone()), + ); + self.auth_state = Some(auth); + } + Err(_) => { + let _ = AuthStorage::clear(); + self.auth_state = None; + // Rebuild registry client without explicit token (falls back to env var) + self.registry_client = + RegistryClient::with_token(self.config.registry_url.clone(), None); + } + }, + + // Errors + Action::Error(msg) => { + self.error_message = Some(msg); + self.stop_loading(); + } + } + + self.needs_redraw = true; + } } diff --git a/crates/bin/ampcc/src/auth.rs b/crates/bin/ampcc/src/auth.rs new file mode 100644 index 000000000..1dde3aa0f --- /dev/null +++ b/crates/bin/ampcc/src/auth.rs @@ -0,0 +1,17 @@ +//! Authentication module for CLI auth state management. +//! +//! This module provides: +//! - [`AuthStorage`] - Credentials stored on disk +//! - [`AuthClient`] - Token refresh functionality +//! - [`PkceDeviceFlowClient`] - PKCE device flow authentication +//! - [`AuthError`] - Error types for auth operations + +mod client; +mod domain; +mod error; +mod pkce; + +pub use client::AuthClient; +pub use domain::AuthStorage; +pub use error::AuthError; +pub use pkce::PkceDeviceFlowClient; diff --git a/crates/bin/ampcc/src/auth/client.rs b/crates/bin/ampcc/src/auth/client.rs new file mode 100644 index 000000000..1396e8153 --- /dev/null +++ b/crates/bin/ampcc/src/auth/client.rs @@ -0,0 +1,184 @@ +//! Auth client for token refresh. + +use chrono::Utc; +use reqwest::Client; + +use super::{ + domain::{AuthStorage, RefreshTokenRequest, RefreshTokenResponse}, + error::AuthError, +}; + +/// Time before expiry to trigger refresh (5 minutes). +const REFRESH_THRESHOLD_SECS: i64 = 5 * 60; + +/// Authentication client for refreshing credentials. +pub struct AuthClient { + /// HTTP client for API requests + http_client: Client, + /// Auth web app base URL (for refresh endpoint) + auth_url: String, +} + +impl AuthClient { + /// Create a new AuthClient. + /// + /// # Arguments + /// * `http_client` - Shared reqwest client + /// * `auth_url` - Base URL for auth web app (e.g., `https://auth.amp.thegraph.com/api/v1/auth`) + pub fn new(http_client: Client, auth_url: String) -> Self { + Self { + http_client, + auth_url, + } + } + + /// Check if the auth token needs to be refreshed. + /// + /// Returns true if: + /// - No expiry is set (refresh to be safe) + /// - Token is expired + /// - Token expires within 5 minutes + pub fn needs_refresh(auth: &AuthStorage) -> bool { + match auth.expiry { + None => true, + Some(expiry) => { + let now = Utc::now().timestamp(); + expiry - now <= REFRESH_THRESHOLD_SECS + } + } + } + + /// Refresh an expired or expiring access token. + /// + /// Makes a POST request to the auth web app's refresh endpoint. + pub async fn refresh_token(&self, auth: &AuthStorage) -> Result { + let url = format!("{}/refresh", self.auth_url); + let request_body = RefreshTokenRequest::from_auth(auth); + + let response = self + .http_client + .post(&url) + .bearer_auth(&auth.access_token) + .json(&request_body) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await?; + + let status = response.status(); + + // Handle error responses + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(AuthError::TokenExpired); + } + + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()) + .unwrap_or(60); + return Err(AuthError::RateLimited { retry_after }); + } + + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "(failed to read error body)".to_string()); + return Err(AuthError::RefreshError(format!( + "HTTP {}: {}", + status, error_text + ))); + } + + let refresh_response: RefreshTokenResponse = response.json().await?; + + // Validate user ID matches + if refresh_response.user.id != auth.user_id { + return Err(AuthError::UserMismatch { + expected: auth.user_id.clone(), + received: refresh_response.user.id, + }); + } + + // Calculate new expiry + let now = Utc::now().timestamp(); + let expiry = now + refresh_response.expires_in; + + // Build updated auth storage + Ok(AuthStorage { + access_token: refresh_response.token, + refresh_token: refresh_response + .refresh_token + .unwrap_or_else(|| auth.refresh_token.clone()), + user_id: refresh_response.user.id, + accounts: refresh_response.user.accounts, + expiry: Some(expiry), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_auth_storage(expiry: Option) -> AuthStorage { + AuthStorage { + access_token: "test_access_token".to_string(), + refresh_token: "test_refresh_token".to_string(), + user_id: "test_user_id".to_string(), + accounts: Some(vec![ + "0x1234567890123456789012345678901234567890".to_string(), + ]), + expiry, + } + } + + #[test] + fn test_needs_refresh_no_expiry() { + // No expiry set should trigger refresh + let auth = make_auth_storage(None); + assert!(AuthClient::needs_refresh(&auth)); + } + + #[test] + fn test_needs_refresh_expired() { + // Token that expired 1 hour ago + let now = Utc::now().timestamp(); + let auth = make_auth_storage(Some(now - 3600)); + assert!(AuthClient::needs_refresh(&auth)); + } + + #[test] + fn test_needs_refresh_expiring_soon() { + // Token expiring in 2 minutes (within 5 minute threshold) + let now = Utc::now().timestamp(); + let auth = make_auth_storage(Some(now + 120)); + assert!(AuthClient::needs_refresh(&auth)); + } + + #[test] + fn test_needs_refresh_at_threshold() { + // Token expiring in exactly 5 minutes (at threshold boundary) + let now = Utc::now().timestamp(); + let auth = make_auth_storage(Some(now + REFRESH_THRESHOLD_SECS)); + assert!(AuthClient::needs_refresh(&auth)); + } + + #[test] + fn test_needs_refresh_valid_token() { + // Token expiring in 1 hour (well beyond threshold) + let now = Utc::now().timestamp(); + let auth = make_auth_storage(Some(now + 3600)); + assert!(!AuthClient::needs_refresh(&auth)); + } + + #[test] + fn test_needs_refresh_just_beyond_threshold() { + // Token expiring in 5 minutes + 1 second (just beyond threshold) + let now = Utc::now().timestamp(); + let auth = make_auth_storage(Some(now + REFRESH_THRESHOLD_SECS + 1)); + assert!(!AuthClient::needs_refresh(&auth)); + } +} diff --git a/crates/bin/ampcc/src/auth/domain.rs b/crates/bin/ampcc/src/auth/domain.rs new file mode 100644 index 000000000..0740a06c6 --- /dev/null +++ b/crates/bin/ampcc/src/auth/domain.rs @@ -0,0 +1,218 @@ +//! Auth domain for cli authentication with PKCE device flow + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Default storage path relative to home directory. +const AUTH_STORAGE_PATH: &str = ".amp/cache/amp_cli_auth"; + +/// Auth credentials stored on disk at ~/.amp/cache/amp_cli_auth +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthStorage { + pub access_token: String, + pub refresh_token: String, + pub user_id: String, + /// Optional list of accounts (can be user IDs or wallet addresses) + #[serde(skip_serializing_if = "Option::is_none")] + pub accounts: Option>, + /// Token expiry as Unix timestamp (seconds since epoch) + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, +} +impl AuthStorage { + /// Get the path to the auth storage file. + fn storage_path() -> Option { + dirs::home_dir().map(|home| home.join(AUTH_STORAGE_PATH)) + } + + /// Load auth credentials from disk. + /// + /// Returns `None` if the file doesn't exist or can't be parsed. + pub fn load() -> Option { + let path = Self::storage_path()?; + let contents = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&contents).ok() + } + + /// Save auth credentials to disk. + /// + /// Creates the parent directory if it doesn't exist. + /// Sets file permissions to 0600 (owner read/write only) on Unix. + pub fn save(&self) -> std::io::Result<()> { + let path = Self::storage_path() + .ok_or_else(|| std::io::Error::other("Could not determine home directory"))?; + + // Create parent directory if needed + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let contents = + serde_json::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?; + + std::fs::write(&path, &contents)?; + + // Set restrictive permissions on Unix (owner read/write only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&path, permissions)?; + } + + Ok(()) + } + + /// Clear auth credentials from disk. + /// + /// Returns `Ok(())` if the file was deleted or didn't exist. + pub fn clear() -> std::io::Result<()> { + let Some(path) = Self::storage_path() else { + return Ok(()); + }; + + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +/// Request body for POST /refresh endpoint. +#[derive(Debug, Serialize)] +pub struct RefreshTokenRequest { + pub user_id: String, + pub refresh_token: String, +} + +impl RefreshTokenRequest { + /// Create a refresh token request from cached auth storage. + pub fn from_auth(auth: &AuthStorage) -> Self { + Self { + user_id: auth.user_id.clone(), + refresh_token: auth.refresh_token.clone(), + } + } +} + +/// Response from POST /refresh endpoint. +#[derive(Debug, Deserialize)] +pub struct RefreshTokenResponse { + /// New access token + pub token: String, + /// New refresh token (if rotated) + pub refresh_token: Option, + /// Token lifetime in seconds + pub expires_in: i64, + /// User information + pub user: RefreshTokenUser, +} + +/// User info returned in refresh token response. +#[derive(Debug, Deserialize)] +pub struct RefreshTokenUser { + pub id: String, + pub accounts: Option>, +} + +// ============================================================================ +// Device Flow Types +// ============================================================================ + +/// Request body for POST /api/v1/device/authorize endpoint. +#[derive(Debug, Serialize)] +pub struct DeviceAuthorizationRequest { + pub code_challenge: String, + pub code_challenge_method: String, +} + +impl DeviceAuthorizationRequest { + /// Create a new device authorization request with S256 challenge method. + pub fn new(code_challenge: String) -> Self { + Self { + code_challenge, + code_challenge_method: "S256".to_string(), + } + } +} + +/// Response from POST /api/v1/device/authorize endpoint. +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceAuthorizationResponse { + /// Device verification code used for polling. + pub device_code: String, + /// User code to display for manual entry. + pub user_code: String, + /// URL where user enters the code. + pub verification_uri: String, + /// Time in seconds until device code expires. + /// Currently unused but part of the API response - could be used for countdown timer. + #[allow(dead_code)] + pub expires_in: i64, + /// Minimum polling interval in seconds. + pub interval: i64, +} + +/// Success response from GET /api/v1/device/token endpoint. +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceTokenResponse { + /// The access token for authenticated requests. + pub access_token: String, + /// The refresh token for renewing access. + pub refresh_token: String, + /// The authenticated user's ID. + pub user_id: String, + /// List of user accounts (wallet addresses, etc.). + pub user_accounts: Vec, + /// Seconds until the token expires from receipt. + pub expires_in: i64, +} + +impl DeviceTokenResponse { + /// Convert to AuthStorage for persisting to disk. + pub fn to_auth_storage(&self, expiry: i64) -> AuthStorage { + AuthStorage { + access_token: self.access_token.clone(), + refresh_token: self.refresh_token.clone(), + user_id: self.user_id.clone(), + accounts: Some(self.user_accounts.clone()), + expiry: Some(expiry), + } + } +} + +/// Polling response that can be either success, pending, or expired. +/// +/// NOTE: This uses `#[serde(untagged)]` which tries variants in order. +/// `Success` must come first because it has more required fields than `Error`. +/// If deserialization of `Success` fails (missing fields), it falls back to `Error`. +/// Do not reorder these variants. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum DeviceTokenPollingResponse { + /// Successfully received tokens (must be first - has more required fields). + Success(DeviceTokenResponse), + /// Error response (pending or expired). + Error(DeviceTokenErrorResponse), +} + +/// Error response from GET /api/v1/device/token endpoint. +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceTokenErrorResponse { + pub error: String, +} + +impl DeviceTokenErrorResponse { + /// Check if this is an "authorization_pending" response. + pub fn is_pending(&self) -> bool { + self.error == "authorization_pending" + } + + /// Check if this is an "expired_token" response. + pub fn is_expired(&self) -> bool { + self.error == "expired_token" + } +} diff --git a/crates/bin/ampcc/src/auth/error.rs b/crates/bin/ampcc/src/auth/error.rs new file mode 100644 index 000000000..8a41347b6 --- /dev/null +++ b/crates/bin/ampcc/src/auth/error.rs @@ -0,0 +1,72 @@ +//! Auth error types. + +use thiserror::Error; + +/// Errors that can occur during authentication operations. +#[derive(Debug, Error)] +pub enum AuthError { + /// Refresh token expired error + /// + /// This occurs when the refresh token stored on the users machine has past its expiry. + /// When this occurs, the user will need to re-authenticate. + #[error("Token expired")] + TokenExpired, + + /// Refresh token request error + /// + /// This is a catch-all error that is thrown if an error occurs when hitting the refresh endpoint. + /// Specifically if 400/5xx errors are returned in the response body. + #[error("Refresh failed: {0}")] + RefreshError(String), + /// User mismatch error. + /// + /// This occurs when the user id returned in the refresh token response does NOT match the user + /// id on the AuthStorage instance. + #[error("User ID mismatch: expected {expected}, got {received}")] + UserMismatch { expected: String, received: String }, + + /// PKCE Device Authorization error. + /// + /// Thrown if the /device/authorize HTTP request fails and returns a non-success (200) status. + #[error("Device authorization failed: {0}")] + DeviceAuthorizationError(String), + /// PKCE Device token polling Error. + /// + /// Thrown if the /device/token HTTP request fails and returns a non-success (200) status. + /// This is called while polling the endpoint to determine if the user has completed authentication + /// through the UI. + #[error("Device token polling failed: {0}")] + DeviceTokenPollingError(String), + /// PKCE Device request expired. + /// + /// This occurs if the user takes too long to authenticate through the browser and the request + /// cycle times out. + /// User will need to restart device flow. + #[error("Device token expired - please restart authentication")] + DeviceTokenExpired, + /// PKCE Device Flow browser open error. + /// + /// Thrown if an error occurs while trying to open the users browser to the auth UI. + #[error("Failed to open browser: {0}")] + OpenBrowserError(String), + + /// PKCE Device flow rate-limit error. + /// + /// This error is thrown if the user attempts to make too many refresh requests in the given + /// time-period established by the Auth UI. + /// Prevents DDoS of the Auth UI refresh endpoint and other potential attack vectors. + /// User can retry after the returned retry_after. + #[error("Rate limited, retry after {retry_after} seconds")] + RateLimited { retry_after: u64 }, + /// Generic HTTP Error occurred. + #[error("HTTP request failed: {0}")] + HttpError(#[from] reqwest::Error), + /// Generic IO Error occurred. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + /// Generic JSON Error Occurred. + /// + /// This would be when decoding the response JSON body from the device flow or refresh HTTP requests. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/bin/ampcc/src/auth/pkce.rs b/crates/bin/ampcc/src/auth/pkce.rs new file mode 100644 index 000000000..a9348d928 --- /dev/null +++ b/crates/bin/ampcc/src/auth/pkce.rs @@ -0,0 +1,209 @@ +//! PKCE (Proof Key for Code Exchange) helpers for OAuth 2.0 device flow. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::RngCore; +use reqwest::Client; +use sha2::{Digest, Sha256}; + +use super::{ + domain::{ + DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceTokenPollingResponse, + DeviceTokenResponse, + }, + error::AuthError, +}; + +/// Generate a cryptographically random code_verifier. +/// +/// The verifier is 43 characters long (32 random bytes, base64url encoded). +/// This meets the RFC 7636 requirement of 43-128 characters using +/// unreserved characters [A-Za-z0-9-._~]. +pub fn generate_code_verifier() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Generate code_challenge from code_verifier using S256 method. +/// +/// code_challenge = BASE64URL(SHA256(code_verifier)) +pub fn generate_code_challenge(verifier: &str) -> String { + let hash = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(hash) +} + +/// Device flow client for OAuth 2.0 PKCE authentication. +pub struct PkceDeviceFlowClient { + http_client: Client, + auth_url: String, +} + +impl PkceDeviceFlowClient { + /// Create a new DeviceFlowClient. + /// + /// # Arguments + /// * `http_client` - Shared reqwest client + /// * `auth_url` - Base URL for auth platform (e.g., `https://auth.amp.thegraph.com`) + pub fn new(http_client: Client, auth_url: String) -> Self { + Self { + http_client, + auth_url, + } + } + + /// Request device authorization. + /// + /// Generates PKCE code_verifier and code_challenge, then requests + /// device authorization from the auth server. + /// + /// Returns the authorization response and the code_verifier needed for polling. + pub async fn request_authorization(&self) -> Result { + // Generate PKCE parameters + let code_verifier = generate_code_verifier(); + let code_challenge = generate_code_challenge(&code_verifier); + + let url = format!("{}/api/v1/device/authorize", self.auth_url); + let request_body = DeviceAuthorizationRequest::new(code_challenge); + + let response = self + .http_client + .post(&url) + .json(&request_body) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await?; + + let status = response.status(); + + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "(failed to read error body)".to_string()); + return Err(AuthError::DeviceAuthorizationError(format!( + "HTTP {}: {}", + status, error_text + ))); + } + + let auth_response: DeviceAuthorizationResponse = response.json().await?; + + Ok(PkceDeviceAuthorizationResult { + response: auth_response, + code_verifier, + }) + } + + /// Poll for device token. + /// + /// Returns: + /// - `Ok(Some(token))` if authentication succeeded + /// - `Ok(None)` if still pending (authorization_pending) + /// - `Err(DeviceTokenExpired)` if the device code expired + /// - `Err(_)` for other errors + pub async fn poll_for_token( + &self, + device_code: &str, + code_verifier: &str, + ) -> Result, AuthError> { + let url = format!( + "{}/api/v1/device/token?device_code={}&code_verifier={}", + self.auth_url, device_code, code_verifier + ); + + let response = self + .http_client + .get(&url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await?; + + let status = response.status(); + + // The server returns 400 for "authorization_pending" and "expired_token", + // so we need to parse the JSON body even on non-success status codes. + // Only fail early on server errors (5xx) or other unexpected statuses. + if status.is_server_error() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "(failed to read error body)".to_string()); + return Err(AuthError::DeviceTokenPollingError(format!( + "HTTP {}: {}", + status, error_text + ))); + } + + let polling_response: DeviceTokenPollingResponse = response.json().await?; + + match polling_response { + DeviceTokenPollingResponse::Success(token) => Ok(Some(token)), + DeviceTokenPollingResponse::Error(err) => { + if err.is_pending() { + Ok(None) + } else if err.is_expired() { + Err(AuthError::DeviceTokenExpired) + } else { + Err(AuthError::DeviceTokenPollingError(format!( + "Unknown error: {}", + err.error + ))) + } + } + } + } + + /// Open the verification URL in the user's default browser. + pub fn open_browser(url: &str) -> Result<(), AuthError> { + open::that(url).map_err(|e| AuthError::OpenBrowserError(e.to_string())) + } +} + +/// Result of requesting device authorization. +pub struct PkceDeviceAuthorizationResult { + /// The authorization response from the server. + pub response: DeviceAuthorizationResponse, + /// The code verifier needed for token polling. + pub code_verifier: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_code_verifier_length() { + let verifier = generate_code_verifier(); + // 32 bytes base64url encoded = 43 characters + assert_eq!(verifier.len(), 43); + } + + #[test] + fn test_code_verifier_uniqueness() { + let v1 = generate_code_verifier(); + let v2 = generate_code_verifier(); + assert_ne!(v1, v2); + } + + #[test] + fn test_code_challenge_deterministic() { + let verifier = "test_verifier_string"; + let c1 = generate_code_challenge(verifier); + let c2 = generate_code_challenge(verifier); + assert_eq!(c1, c2); + } + + #[test] + fn test_code_challenge_format() { + let verifier = generate_code_verifier(); + let challenge = generate_code_challenge(&verifier); + // SHA256 = 32 bytes, base64url encoded = 43 characters + assert_eq!(challenge.len(), 43); + // Should only contain URL-safe base64 characters + assert!( + challenge + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + ); + } +} diff --git a/crates/bin/ampcc/src/config.rs b/crates/bin/ampcc/src/config.rs index b06fa1950..9ec5c2d54 100644 --- a/crates/bin/ampcc/src/config.rs +++ b/crates/bin/ampcc/src/config.rs @@ -16,6 +16,8 @@ pub struct Config { pub registry_url: String, #[serde(default = "default_source")] pub default_source: String, + #[serde(default = "default_auth_url")] + pub auth_url: String, } fn default_local_query_url() -> String { @@ -30,6 +32,9 @@ fn default_registry_url() -> String { fn default_source() -> String { "registry".into() } +fn default_auth_url() -> String { + "https://auth.amp.thegraph.com".into() +} impl Config { pub fn load() -> Result { diff --git a/crates/bin/ampcc/src/main.rs b/crates/bin/ampcc/src/main.rs index ecafd1dd6..89b7b2993 100644 --- a/crates/bin/ampcc/src/main.rs +++ b/crates/bin/ampcc/src/main.rs @@ -5,10 +5,6 @@ use std::{ time::{Duration, Instant}, }; -use admin_client::{ - jobs::JobInfo, - workers::{WorkerDetailResponse, WorkerInfo}, -}; use anyhow::Result; use crossterm::{ event::{ @@ -18,44 +14,39 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Terminal, backend::CrosstermBackend}; +use reqwest::Client as HttpClient; use tokio::sync::mpsc; -use worker::{job::JobId, node_id::NodeId}; +mod action; mod app; +mod auth; mod config; mod registry; mod ui; +mod util; -use app::{ActivePane, App, ContentView, DataSource, InputMode, InspectResult}; +use action::Action; +use app::{ActivePane, App, ContentView, DataSource, DeviceFlowStatus, InputMode}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; /// Auto-refresh interval for jobs/workers (10 seconds). const REFRESH_INTERVAL: Duration = Duration::from_secs(10); -/// Events that can be sent from async tasks. -enum AppEvent { - ManifestLoaded(Option), - JobsLoaded(Vec), - WorkersLoaded(Vec), - WorkerDetailLoaded(Option), - JobStopped(Result<(), String>), - JobDeleted(Result<(), String>), - Error(String), -} +/// Tick rate for UI updates. +const TICK_RATE: Duration = Duration::from_millis(100); #[tokio::main] async fn main() -> Result<()> { let config = config::Config::load()?; - let mut app = App::new(config)?; - // Fetch initial datasets - if let Err(e) = app.fetch_datasets().await { - eprintln!("Failed to fetch datasets: {}", e); - // Continue anyway to show UI - } + // Create shared HTTP client + let http_client = HttpClient::new(); - // If starting in Local mode, also fetch jobs and workers - // (will be handled in run_app after channel is set up) + // Create action channel + let (action_tx, action_rx) = mpsc::unbounded_channel::(); + + // Create app + let mut app = App::new(config, action_tx.clone(), http_client)?; // Setup terminal enable_raw_mode()?; @@ -65,7 +56,7 @@ async fn main() -> Result<()> { let mut terminal = Terminal::new(backend)?; // Run app - let res = run_app(&mut terminal, &mut app).await; + let res = run_app(&mut terminal, &mut app, action_tx, action_rx).await; // Restore terminal disable_raw_mode()?; @@ -86,23 +77,32 @@ async fn main() -> Result<()> { async fn run_app( terminal: &mut Terminal, app: &mut App, + action_tx: mpsc::UnboundedSender, + mut action_rx: mpsc::UnboundedReceiver, ) -> Result<()> where B::Error: Send + Sync + 'static, { - let (tx, mut rx) = mpsc::channel::(10); - let tick_rate = std::time::Duration::from_millis(100); - - // Initial manifest fetch - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx.clone()); + // Initial data loads + let _ = action_tx.send(Action::RefreshDatasets); + let _ = action_tx.send(Action::AuthCheckOnStartup); // If in Local mode, also fetch jobs and workers if app.is_local() { - spawn_fetch_jobs(app, tx.clone()); - spawn_fetch_workers(app, tx.clone()); + let _ = action_tx.send(Action::RefreshJobs); + let _ = action_tx.send(Action::RefreshWorkers); } + // Spawn async tasks for initial loads + spawn_fetch_datasets(app); + spawn_check_auth(app); + if app.is_local() { + spawn_fetch_jobs(app); + spawn_fetch_workers(app); + } + + let mut last_tick = Instant::now(); + loop { // Tick spinner animation (only when loading) if app.loading { @@ -112,465 +112,359 @@ where // Auto-refresh jobs/workers in Local mode if app.is_local() && app.last_refresh.elapsed() >= REFRESH_INTERVAL { - spawn_fetch_jobs(app, tx.clone()); - spawn_fetch_workers(app, tx.clone()); + spawn_fetch_jobs(app); + spawn_fetch_workers(app); app.last_refresh = Instant::now(); } - // Handle async updates - if let Ok(event) = rx.try_recv() { - match event { - AppEvent::ManifestLoaded(manifest) => { - app.reset_scroll(); - app.current_inspect = manifest.as_ref().and_then(InspectResult::from_manifest); - app.current_manifest = manifest; - app.content_view = ContentView::Dataset; - app.stop_loading(); - } - AppEvent::JobsLoaded(jobs) => { - app.jobs = jobs; - // Reset selection if out of bounds - if app.selected_job_index >= app.jobs.len() && !app.jobs.is_empty() { - app.selected_job_index = app.jobs.len() - 1; - } - } - AppEvent::WorkersLoaded(workers) => { - app.workers = workers; - // Reset selection if out of bounds - if app.selected_worker_index >= app.workers.len() && !app.workers.is_empty() { - app.selected_worker_index = app.workers.len() - 1; - } - } - AppEvent::WorkerDetailLoaded(detail) => { - if let Some(worker_detail) = detail { - app.content_view = ContentView::Worker(worker_detail); - app.reset_scroll(); - } - app.stop_loading(); - } - AppEvent::JobStopped(result) => { - match result { - Ok(()) => { - // Refresh jobs list after stopping - spawn_fetch_jobs(app, tx.clone()); - } - Err(msg) => { - app.error_message = Some(msg); - } - } - app.stop_loading(); - } - AppEvent::JobDeleted(result) => { - match result { - Ok(()) => { - // Refresh jobs list after deleting - spawn_fetch_jobs(app, tx.clone()); - } - Err(msg) => { - app.error_message = Some(msg); - } + // Draw if needed + if app.needs_redraw { + terminal.draw(|f| ui::draw(f, app))?; + app.needs_redraw = false; + } + + // Calculate timeout for event polling + let timeout = TICK_RATE + .checked_sub(last_tick.elapsed()) + .unwrap_or(Duration::ZERO); + + // Handle events using tokio::select! + tokio::select! { + // Check for terminal events + _ = tokio::time::sleep(timeout) => { + // Check for terminal events (non-blocking) + if event::poll(Duration::ZERO)? { + let action = handle_terminal_event(app, terminal, event::read()?)?; + if action_tx.send(action).is_err() { + break; } - app.stop_loading(); } - AppEvent::Error(msg) => { - app.error_message = Some(msg); - app.stop_loading(); + + // Tick + if last_tick.elapsed() >= TICK_RATE { + last_tick = Instant::now(); } } - app.needs_redraw = true; + + // Handle actions from channel + Some(action) = action_rx.recv() => { + // Handle actions that need to spawn async tasks + handle_async_action(app, &action); + + // Handle state mutations + app.handle_action(action); + } } - // Handle Input - if event::poll(tick_rate)? { - match event::read()? { - Event::Key(key) => { - match app.input_mode { - InputMode::Normal => { - match key.code { - KeyCode::Char('q') => app.quit(), - KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => { - app.quit() - } - - // Source switching - KeyCode::Char('1') => { - let tx = tx.clone(); - app.start_loading("Switching source..."); - if let Err(e) = app.switch_source(DataSource::Local).await { - let _ = tx.send(AppEvent::Error(e.to_string())).await; - } else { - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx.clone()); - // Also fetch jobs and workers in Local mode - spawn_fetch_jobs(app, tx.clone()); - spawn_fetch_workers(app, tx); - app.last_refresh = Instant::now(); - } - } - KeyCode::Char('2') => { - let tx = tx.clone(); - app.start_loading("Switching source..."); - if let Err(e) = app.switch_source(DataSource::Registry).await { - let _ = tx.send(AppEvent::Error(e.to_string())).await; - } else { - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx); - } - } - - // Search - KeyCode::Char('/') => { - app.input_mode = InputMode::Search; - } - - // Refresh - context sensitive - KeyCode::Char('r') => { - let tx = tx.clone(); - match app.active_pane { - ActivePane::Datasets => { - app.start_loading("Refreshing datasets..."); - if let Err(e) = app.fetch_datasets().await { - let _ = - tx.send(AppEvent::Error(e.to_string())).await; - } else { - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx); - } - } - ActivePane::Jobs => { - spawn_fetch_jobs(app, tx); - app.last_refresh = Instant::now(); - } - ActivePane::Workers => { - spawn_fetch_workers(app, tx); - app.last_refresh = Instant::now(); - } - _ => { - // Refresh everything - app.start_loading("Refreshing..."); - if let Err(e) = app.fetch_datasets().await { - let _ = - tx.send(AppEvent::Error(e.to_string())).await; - } else { - spawn_fetch_manifest(app, tx.clone()); - if app.is_local() { - spawn_fetch_jobs(app, tx.clone()); - spawn_fetch_workers(app, tx); - app.last_refresh = Instant::now(); - } - } - } - } - } - - // Stop job (s key) - KeyCode::Char('s') => { - if app.active_pane == ActivePane::Jobs - && let Some(job) = app.get_selected_job() - && App::can_stop_job(&job.status) - { - let job_id = job.id; - app.start_loading("Stopping job..."); - spawn_stop_job(app, job_id, tx.clone()); - } - } - - // Delete job (d key, only if not Ctrl+d) - KeyCode::Char('d') - if !key.modifiers.contains(KeyModifiers::CONTROL) => - { - if app.active_pane == ActivePane::Jobs - && let Some(job) = app.get_selected_job() - && App::is_job_terminal(&job.status) - { - let job_id = job.id; - app.start_loading("Deleting job..."); - spawn_delete_job(app, job_id, tx.clone()); - } - } - - // Navigation - KeyCode::Down | KeyCode::Char('j') => match app.active_pane { - ActivePane::Datasets => { - app.select_next(); - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx.clone()); - } - ActivePane::Jobs => { - app.select_next_job(); - // Show job details in content pane - if let Some(job) = app.get_selected_job().cloned() { - app.content_view = ContentView::Job(job); - app.reset_scroll(); - } - } - ActivePane::Workers => { - app.select_next_worker(); - // Fetch and show worker details - if let Some(node_id) = - app.get_selected_worker().map(|w| w.node_id.clone()) - { - app.start_loading("Loading worker details..."); - spawn_fetch_worker_detail(app, &node_id, tx.clone()); - } - } - _ => app.scroll_down(), - }, - KeyCode::Up | KeyCode::Char('k') => match app.active_pane { - ActivePane::Datasets => { - app.select_previous(); - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx.clone()); - } - ActivePane::Jobs => { - app.select_previous_job(); - // Show job details in content pane - if let Some(job) = app.get_selected_job().cloned() { - app.content_view = ContentView::Job(job); - app.reset_scroll(); - } - } - ActivePane::Workers => { - app.select_previous_worker(); - // Fetch and show worker details - if let Some(node_id) = - app.get_selected_worker().map(|w| w.node_id.clone()) - { - app.start_loading("Loading worker details..."); - spawn_fetch_worker_detail(app, &node_id, tx.clone()); - } - } - _ => app.scroll_up(), - }, - - // Page navigation (vim-style) - KeyCode::Char('u') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.page_up(10); - } - KeyCode::Char('d') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.page_down(10); - } - - // Expand/collapse or show details - KeyCode::Enter => { - let tx = tx.clone(); - match app.active_pane { - ActivePane::Datasets => { - app.start_loading("Expanding..."); - if let Err(e) = app.toggle_expand().await { - let _ = - tx.send(AppEvent::Error(e.to_string())).await; - } - app.stop_loading(); - } - ActivePane::Jobs => { - // Show job details in content pane and focus Detail - if let Some(job) = app.get_selected_job().cloned() { - app.content_view = ContentView::Job(job); - app.reset_scroll(); - app.active_pane = ActivePane::Detail; - } - } - ActivePane::Workers => { - // Fetch worker details and focus Detail - if let Some(node_id) = - app.get_selected_worker().map(|w| w.node_id.clone()) - { - app.start_loading("Loading worker details..."); - spawn_fetch_worker_detail(app, &node_id, tx); - app.active_pane = ActivePane::Detail; - } - } - _ => {} - } - } - - // Pane navigation - KeyCode::Tab => { - let is_local = app.is_local(); - app.active_pane = app.active_pane.next(is_local); - } - KeyCode::BackTab => { - let is_local = app.is_local(); - app.active_pane = app.active_pane.prev(is_local); - } - - _ => {} - } - } - InputMode::Search => match key.code { - KeyCode::Enter => { - app.input_mode = InputMode::Normal; - app.start_loading("Loading manifest..."); - spawn_fetch_manifest(app, tx.clone()); - } - KeyCode::Esc => { - app.input_mode = InputMode::Normal; - } - KeyCode::Char(c) => { - app.search_query.push(c); - app.update_search(); - } - KeyCode::Backspace => { - app.search_query.pop(); - app.update_search(); - } - _ => {} - }, + if !app.is_running() { + break; + } + } + + Ok(()) +} + +/// Handle terminal events and convert to actions. +fn handle_terminal_event( + app: &mut App, + terminal: &Terminal, + event: Event, +) -> Result +where + B::Error: Send + Sync + 'static, +{ + match event { + Event::Key(key) => Ok(handle_key_event(app, key)), + Event::Mouse(mouse) => Ok(handle_mouse_event(app, terminal, mouse)?), + _ => Ok(Action::None), + } +} + +/// Handle key events and return the corresponding action. +fn handle_key_event(app: &App, key: event::KeyEvent) -> Action { + // If device flow modal is active, handle it first + if let Some(ref flow) = app.auth_device_flow { + return handle_device_flow_key(flow, key); + } + + match app.input_mode { + InputMode::Normal => handle_normal_mode_key(app, key), + InputMode::Search => handle_search_mode_key(key), + } +} + +/// Handle key events when device flow modal is active. +fn handle_device_flow_key(flow: &app::DeviceFlowState, key: event::KeyEvent) -> Action { + match key.code { + KeyCode::Enter => { + // Only confirm if awaiting confirmation + if matches!(flow.status, DeviceFlowStatus::AwaitingConfirmation) { + Action::AuthDeviceFlowConfirm + } else { + Action::None + } + } + KeyCode::Esc => Action::AuthDeviceFlowCancel, + _ => Action::None, + } +} + +/// Handle key events in normal mode. +fn handle_normal_mode_key(app: &App, key: event::KeyEvent) -> Action { + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => Action::Quit, + + // Source switching + KeyCode::Char('1') => Action::SwitchToLocal, + KeyCode::Char('2') => Action::SwitchToRegistry, + + // Search + KeyCode::Char('/') => Action::EnterSearchMode, + + // Refresh + KeyCode::Char('r') => match app.active_pane { + ActivePane::Datasets => Action::RefreshDatasets, + ActivePane::Jobs => Action::RefreshJobs, + ActivePane::Workers => Action::RefreshWorkers, + _ => Action::RefreshDatasets, + }, + + // Stop job + KeyCode::Char('s') => { + if app.active_pane == ActivePane::Jobs + && let Some(job) = app.get_selected_job() + && App::can_stop_job(&job.status) + { + return Action::StopJob(job.id); + } + Action::None + } + + // Delete job + KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if app.active_pane == ActivePane::Jobs + && let Some(job) = app.get_selected_job() + && App::is_job_terminal(&job.status) + { + return Action::DeleteJob(job.id); + } + Action::None + } + + // Navigation + KeyCode::Down | KeyCode::Char('j') => Action::NavigateDown, + KeyCode::Up | KeyCode::Char('k') => Action::NavigateUp, + + // Page navigation + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::PageUp(10), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::PageDown(10), + + // Expand/collapse or enter detail + KeyCode::Enter => match app.active_pane { + ActivePane::Datasets => Action::ToggleExpand, + ActivePane::Jobs | ActivePane::Workers => Action::EnterDetail, + _ => Action::None, + }, + + // Pane navigation + KeyCode::Tab => Action::NextPane, + KeyCode::BackTab => Action::PrevPane, + + // Auth - Ctrl+L toggles login/logout based on current state + KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app.auth_state.is_some() { + Action::AuthLogout + } else { + Action::AuthLogin + } + } + + _ => Action::None, + } +} + +/// Handle key events in search mode. +fn handle_search_mode_key(key: event::KeyEvent) -> Action { + match key.code { + KeyCode::Enter => Action::SearchSubmit, + KeyCode::Esc => Action::ExitSearchMode, + KeyCode::Char(c) => Action::SearchInput(c), + KeyCode::Backspace => Action::SearchBackspace, + _ => Action::None, + } +} + +/// Handle mouse events and return the corresponding action. +fn handle_mouse_event( + app: &mut App, + terminal: &Terminal, + mouse: event::MouseEvent, +) -> Result +where + B::Error: Send + Sync + 'static, +{ + if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind { + let term_size = terminal.size()?; + let size = Rect::new(0, 0, term_size.width, term_size.height); + + // Top-level layout: Header (3) | Main | Footer (1) + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(size); + + let header_area = main_chunks[0]; + let main_area = main_chunks[1]; + + let x = mouse.column; + let y = mouse.row; + + // Check if header was clicked + if x >= header_area.x + && x < header_area.x + header_area.width + && y >= header_area.y + && y < header_area.y + header_area.height + { + app.active_pane = ActivePane::Header; + } else if app.active_pane == ActivePane::Header { + if y >= main_area.y && y < main_area.y + main_area.height { + app.active_pane = ActivePane::Datasets; + } + } else { + // Normal pane detection + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(main_area); + + let sidebar_area = content_chunks[0]; + let content_area = content_chunks[1]; + + if x >= sidebar_area.x + && x < sidebar_area.x + sidebar_area.width + && y >= sidebar_area.y + && y < sidebar_area.y + sidebar_area.height + { + // Clicked in sidebar + if app.is_local() { + let sidebar_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(sidebar_area); + + let datasets_area = sidebar_chunks[0]; + let jobs_area = sidebar_chunks[1]; + let workers_area = sidebar_chunks[2]; + + if y >= datasets_area.y && y < datasets_area.y + datasets_area.height { + app.active_pane = ActivePane::Datasets; + } else if y >= jobs_area.y && y < jobs_area.y + jobs_area.height { + app.active_pane = ActivePane::Jobs; + } else if y >= workers_area.y && y < workers_area.y + workers_area.height { + app.active_pane = ActivePane::Workers; } + } else { + app.active_pane = ActivePane::Datasets; } - Event::Mouse(mouse) => { - // TODO: refactor into components (test intersections) - if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind { - // Calculate pane areas to determine which pane was clicked - let term_size = terminal.size()?; - let size = Rect::new(0, 0, term_size.width, term_size.height); - - // Top-level layout: Header (3) | Main | Footer (1) - let main_chunks = Layout::default() + } else if x >= content_area.x + && x < content_area.x + content_area.width + && y >= content_area.y + && y < content_area.y + content_area.height + { + // Clicked in content area + match app.content_view { + ContentView::Dataset => { + let content_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(1), - ]) - .split(size); - - let header_area = main_chunks[0]; - let main_area = main_chunks[1]; - - // Check which pane was clicked - let x = mouse.column; - let y = mouse.row; - - // Check if header was clicked - if x >= header_area.x - && x < header_area.x + header_area.width - && y >= header_area.y - && y < header_area.y + header_area.height - { - app.active_pane = ActivePane::Header; - } else if app.active_pane == ActivePane::Header { - // If on splash and clicking main area, go to Datasets - if y >= main_area.y && y < main_area.y + main_area.height { - app.active_pane = ActivePane::Datasets; - } - } else { - // Normal pane detection - // Main layout: Sidebar (35%) | Content (65%) - let content_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(35), - Constraint::Percentage(65), - ]) - .split(main_area); - - let sidebar_area = content_chunks[0]; - let content_area = content_chunks[1]; - - if x >= sidebar_area.x - && x < sidebar_area.x + sidebar_area.width - && y >= sidebar_area.y - && y < sidebar_area.y + sidebar_area.height - { - // Clicked in sidebar - determine which section - if app.is_local() { - let sidebar_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(50), // Datasets - Constraint::Percentage(25), // Jobs - Constraint::Percentage(25), // Workers - ]) - .split(sidebar_area); - - let datasets_area = sidebar_chunks[0]; - let jobs_area = sidebar_chunks[1]; - let workers_area = sidebar_chunks[2]; - - if y >= datasets_area.y - && y < datasets_area.y + datasets_area.height - { - app.active_pane = ActivePane::Datasets; - } else if y >= jobs_area.y && y < jobs_area.y + jobs_area.height - { - app.active_pane = ActivePane::Jobs; - } else if y >= workers_area.y - && y < workers_area.y + workers_area.height - { - app.active_pane = ActivePane::Workers; - } - } else { - // Registry mode - only Datasets pane in sidebar - app.active_pane = ActivePane::Datasets; - } - } else if x >= content_area.x - && x < content_area.x + content_area.width - && y >= content_area.y - && y < content_area.y + content_area.height - { - // Clicked in content area - match app.content_view { - ContentView::Dataset => { - let content_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(60), // Manifest - Constraint::Percentage(40), // Schema - ]) - .split(content_area); - - let manifest_area = content_chunks[0]; - let schema_area = content_chunks[1]; - - if y >= manifest_area.y - && y < manifest_area.y + manifest_area.height - { - app.active_pane = ActivePane::Manifest; - } else if y >= schema_area.y - && y < schema_area.y + schema_area.height - { - app.active_pane = ActivePane::Schema; - } - } - ContentView::Job(_) - | ContentView::Worker(_) - | ContentView::None => { - app.active_pane = ActivePane::Detail; - } - } - } + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(content_area); + + let manifest_area = content_chunks[0]; + let schema_area = content_chunks[1]; + + if y >= manifest_area.y && y < manifest_area.y + manifest_area.height { + app.active_pane = ActivePane::Manifest; + } else if y >= schema_area.y && y < schema_area.y + schema_area.height { + app.active_pane = ActivePane::Schema; } } + ContentView::Job(_) | ContentView::Worker(_) | ContentView::None => { + app.active_pane = ActivePane::Detail; + } } - _ => {} } - app.needs_redraw = true; } + } - // Only draw when needed (reduces CPU usage from ~20% to near 0% when idle) - if app.needs_redraw { - terminal.draw(|f| ui::draw(f, app))?; - app.needs_redraw = false; - } + Ok(Action::None) +} - if app.should_quit { - return Ok(()); - } +/// Handle actions that need to spawn async tasks. +fn handle_async_action(app: &App, action: &Action) { + match action { + Action::RefreshDatasets => spawn_fetch_datasets(app), + Action::LoadManifest => spawn_fetch_manifest(app), + Action::RefreshJobs => spawn_fetch_jobs(app), + Action::RefreshWorkers => spawn_fetch_workers(app), + Action::StopJob(job_id) => spawn_stop_job(app, *job_id), + Action::DeleteJob(job_id) => spawn_delete_job(app, *job_id), + Action::LoadWorkerDetail(node_id) => spawn_fetch_worker_detail(app, node_id), + Action::SwitchToLocal => spawn_switch_source(app, DataSource::Local), + Action::SwitchToRegistry => spawn_switch_source(app, DataSource::Registry), + Action::ToggleExpand => spawn_toggle_expand(app), + Action::AuthCheckOnStartup => spawn_check_auth(app), + Action::AuthLogin => spawn_start_device_flow(app), + Action::AuthDeviceFlowPoll { + device_code, + code_verifier, + interval, + is_first_poll, + } => spawn_poll_device_token(app, device_code, code_verifier, *interval, *is_first_poll), + _ => {} } } -fn spawn_fetch_manifest(app: &App, tx: mpsc::Sender) { +// ============================================================================ +// Async task spawners +// ============================================================================ + +fn spawn_fetch_datasets(app: &App) { + let tx = app.action_tx.clone(); + let local_client = app.local_client.clone(); + let registry_client = app.registry_client.clone(); + let source = app.current_source; + + tokio::spawn(async move { + let result = match source { + DataSource::Local => App::fetch_local_datasets(&local_client) + .await + .map_err(|e| e.to_string()), + DataSource::Registry => App::fetch_registry_datasets(®istry_client) + .await + .map_err(|e| e.to_string()), + }; + let _ = tx.send(Action::DatasetsLoaded(result)); + }); +} + +fn spawn_fetch_manifest(app: &App) { let Some((namespace, name, version)) = app.get_selected_manifest_params() else { return; }; - match app.current_source { + let tx = app.action_tx.clone(); + let source = app.current_source; + + match source { DataSource::Local => { let client = app.local_client.clone(); tokio::spawn(async move { @@ -581,17 +475,15 @@ fn spawn_fetch_manifest(app: &App, tx: mpsc::Sender) { let reference = Reference::new(ns, n, rev); match client.datasets().get_manifest(&reference).await { Ok(manifest) => { - let _ = tx.send(AppEvent::ManifestLoaded(manifest)).await; + let _ = tx.send(Action::ManifestLoaded(manifest)); } Err(e) => { - let _ = tx - .send(AppEvent::Error(format!("Failed to fetch: {}", e))) - .await; + let _ = tx.send(Action::Error(format!("Failed to fetch: {}", e))); } } } _ => { - let _ = tx.send(AppEvent::ManifestLoaded(None)).await; + let _ = tx.send(Action::ManifestLoaded(None)); } } }); @@ -601,12 +493,10 @@ fn spawn_fetch_manifest(app: &App, tx: mpsc::Sender) { tokio::spawn(async move { match client.get_manifest(&namespace, &name, &version).await { Ok(manifest) => { - let _ = tx.send(AppEvent::ManifestLoaded(Some(manifest))).await; + let _ = tx.send(Action::ManifestLoaded(Some(manifest))); } Err(e) => { - let _ = tx - .send(AppEvent::Error(format!("Failed to fetch: {}", e))) - .await; + let _ = tx.send(Action::Error(format!("Failed to fetch: {}", e))); } } }); @@ -614,107 +504,306 @@ fn spawn_fetch_manifest(app: &App, tx: mpsc::Sender) { } } -fn spawn_fetch_jobs(app: &App, tx: mpsc::Sender) { +fn spawn_fetch_jobs(app: &App) { + let tx = app.action_tx.clone(); let client = app.local_client.clone(); + tokio::spawn(async move { match client.jobs().list(None, None, None).await { Ok(response) => { - let _ = tx.send(AppEvent::JobsLoaded(response.jobs)).await; + let _ = tx.send(Action::JobsLoaded(response.jobs)); } Err(e) => { - let _ = tx - .send(AppEvent::Error(format!("Failed to fetch jobs: {}", e))) - .await; + let _ = tx.send(Action::Error(format!("Failed to fetch jobs: {}", e))); } } }); } -fn spawn_fetch_workers(app: &App, tx: mpsc::Sender) { +fn spawn_fetch_workers(app: &App) { + let tx = app.action_tx.clone(); let client = app.local_client.clone(); + tokio::spawn(async move { match client.workers().list().await { Ok(response) => { - let _ = tx.send(AppEvent::WorkersLoaded(response.workers)).await; + let _ = tx.send(Action::WorkersLoaded(response.workers)); } Err(e) => { - let _ = tx - .send(AppEvent::Error(format!("Failed to fetch workers: {}", e))) - .await; + let _ = tx.send(Action::Error(format!("Failed to fetch workers: {}", e))); } } }); } -fn spawn_fetch_worker_detail(app: &App, node_id: &str, tx: mpsc::Sender) { +fn spawn_fetch_worker_detail(app: &App, node_id: &str) { + use worker::node_id::NodeId; + + let tx = app.action_tx.clone(); let client = app.local_client.clone(); let node_id: NodeId = match node_id.parse() { Ok(id) => id, Err(e) => { let tx_clone = tx.clone(); tokio::spawn(async move { - let _ = tx_clone - .send(AppEvent::Error(format!("Invalid node ID: {}", e))) - .await; + let _ = tx_clone.send(Action::Error(format!("Invalid node ID: {}", e))); }); return; } }; + tokio::spawn(async move { match client.workers().get(&node_id).await { Ok(Some(detail)) => { - let _ = tx.send(AppEvent::WorkerDetailLoaded(Some(detail))).await; + let _ = tx.send(Action::WorkerDetailLoaded(Some(detail))); } Ok(None) => { - let _ = tx - .send(AppEvent::Error("Worker not found".to_string())) - .await; + let _ = tx.send(Action::Error("Worker not found".to_string())); } Err(e) => { - let _ = tx - .send(AppEvent::Error(format!( - "Failed to fetch worker details: {}", - e - ))) - .await; + let _ = tx.send(Action::Error(format!( + "Failed to fetch worker details: {}", + e + ))); } } }); } -fn spawn_stop_job(app: &App, job_id: JobId, tx: mpsc::Sender) { +fn spawn_stop_job(app: &App, job_id: worker::job::JobId) { + let tx = app.action_tx.clone(); let client = app.local_client.clone(); + tokio::spawn(async move { match client.jobs().stop(&job_id).await { Ok(()) => { - let _ = tx.send(AppEvent::JobStopped(Ok(()))).await; + let _ = tx.send(Action::JobStopped(Ok(()))); } Err(e) => { - let _ = tx - .send(AppEvent::JobStopped(Err(format!( - "Failed to stop job: {}", - e - )))) - .await; + let _ = tx.send(Action::JobStopped(Err(format!( + "Failed to stop job: {}", + e + )))); } } }); } -fn spawn_delete_job(app: &App, job_id: JobId, tx: mpsc::Sender) { +fn spawn_delete_job(app: &App, job_id: worker::job::JobId) { + let tx = app.action_tx.clone(); let client = app.local_client.clone(); + tokio::spawn(async move { match client.jobs().delete_by_id(&job_id).await { Ok(()) => { - let _ = tx.send(AppEvent::JobDeleted(Ok(()))).await; + let _ = tx.send(Action::JobDeleted(Ok(()))); + } + Err(e) => { + let _ = tx.send(Action::JobDeleted(Err(format!( + "Failed to delete job: {}", + e + )))); + } + } + }); +} + +fn spawn_switch_source(app: &App, source: DataSource) { + let tx = app.action_tx.clone(); + + tokio::spawn(async move { + // Source switching is essentially just updating state + // The actual dataset fetch will happen via SourceSwitched -> RefreshDatasets + let _ = tx.send(Action::SourceSwitched(Ok(source))); + }); +} + +fn spawn_toggle_expand(app: &App) { + // Get the current selection info before spawning + let (dataset_idx, version_idx) = { + let mut current = 0usize; + let mut result = None; + for (idx, dataset) in app.filtered_datasets.iter().enumerate() { + if current == app.selected_index { + result = Some((idx, None::)); + break; + } + current += 1; + if dataset.expanded + && let Some(versions) = &dataset.versions + { + for v_idx in 0..versions.len() { + if current == app.selected_index { + result = Some((idx, Some(v_idx))); + break; + } + current += 1; + } + } + if result.is_some() { + break; + } + } + match result { + Some(r) => r, + None => return, + } + }; + + // Only toggle if we're on a dataset, not a version + if version_idx.is_some() { + return; + } + + let dataset = match app.filtered_datasets.get(dataset_idx) { + Some(d) => d, + None => return, + }; + + // If already expanded or has versions, just send action to toggle + if dataset.expanded || dataset.versions.is_some() { + // Toggle will be handled synchronously in handle_action + // For now, we don't need async for collapse + return; + } + + // Need to fetch versions + let tx = app.action_tx.clone(); + let registry_client = app.registry_client.clone(); + let namespace = dataset.namespace.clone(); + let name = dataset.name.clone(); + let source = app.current_source; + + tokio::spawn(async move { + let result = match source { + DataSource::Local => { + // Local doesn't have version listing + Ok(Vec::new()) + } + DataSource::Registry => { + App::fetch_registry_versions(®istry_client, &namespace, &name) + .await + .map_err(|e| e.to_string()) + } + }; + let _ = tx.send(Action::VersionsLoaded { + dataset_index: dataset_idx, + versions: result, + }); + }); +} + +// ============================================================================ +// Auth task spawners +// ============================================================================ + +fn spawn_check_auth(app: &App) { + use crate::auth::{AuthClient, AuthStorage}; + + let tx = app.action_tx.clone(); + let http_client = app.http_client.clone(); + let auth_url = app.config.auth_url.clone(); + + tokio::spawn(async move { + // Try to load auth from disk + let Some(auth) = AuthStorage::load() else { + let _ = tx.send(Action::AuthStateLoaded(None)); + return; + }; + + // Check if we need to refresh + if AuthClient::needs_refresh(&auth) { + let client = AuthClient::new(http_client, auth_url); + match client.refresh_token(&auth).await { + Ok(new_auth) => { + let _ = tx.send(Action::AuthRefreshComplete(Ok(new_auth))); + } + Err(e) => { + let _ = tx.send(Action::AuthRefreshComplete(Err(e.to_string()))); + } + } + } else { + let _ = tx.send(Action::AuthStateLoaded(Some(auth))); + } + }); +} + +fn spawn_start_device_flow(app: &App) { + use crate::auth::PkceDeviceFlowClient; + + let tx = app.action_tx.clone(); + let http_client = app.http_client.clone(); + let auth_url = app.config.auth_url.clone(); + + tokio::spawn(async move { + let client = PkceDeviceFlowClient::new(http_client, auth_url); + + match client.request_authorization().await { + Ok(result) => { + let _ = tx.send(Action::AuthDeviceFlowPending { + user_code: result.response.user_code, + verification_uri: result.response.verification_uri, + device_code: result.response.device_code, + code_verifier: result.code_verifier, + interval: result.response.interval, + }); + } + Err(e) => { + let _ = tx.send(Action::AuthError(e.to_string())); + } + } + }); +} + +fn spawn_poll_device_token( + app: &App, + device_code: &str, + code_verifier: &str, + interval: i64, + is_first_poll: bool, +) { + use chrono::Utc; + + use crate::auth::{AuthError, PkceDeviceFlowClient}; + + let tx = app.action_tx.clone(); + let http_client = app.http_client.clone(); + let auth_url = app.config.auth_url.clone(); + let device_code = device_code.to_string(); + let code_verifier = code_verifier.to_string(); + + tokio::spawn(async move { + // Only wait for the polling interval on retries, not the first poll + if !is_first_poll { + tokio::time::sleep(std::time::Duration::from_secs(interval as u64)).await; + } + + let client = PkceDeviceFlowClient::new(http_client, auth_url); + + match client.poll_for_token(&device_code, &code_verifier).await { + Ok(Some(token_response)) => { + // Success! Calculate expiry and send completion + let now = Utc::now().timestamp(); + let expiry = now + token_response.expires_in; + let auth = token_response.to_auth_storage(expiry); + let _ = tx.send(Action::AuthDeviceFlowComplete(auth)); + } + Ok(None) => { + // Still pending, poll again (with delay on next iteration) + let _ = tx.send(Action::AuthDeviceFlowPoll { + device_code, + code_verifier, + interval, + is_first_poll: false, + }); + } + Err(AuthError::DeviceTokenExpired) => { + let _ = tx.send(Action::AuthError( + "Authentication timed out. Please try again.".to_string(), + )); } Err(e) => { - let _ = tx - .send(AppEvent::JobDeleted(Err(format!( - "Failed to delete job: {}", - e - )))) - .await; + let _ = tx.send(Action::AuthError(e.to_string())); } } }); diff --git a/crates/bin/ampcc/src/registry.rs b/crates/bin/ampcc/src/registry.rs index 15925ef84..36835234d 100644 --- a/crates/bin/ampcc/src/registry.rs +++ b/crates/bin/ampcc/src/registry.rs @@ -1,10 +1,10 @@ //! Registry client for the public Amp dataset registry. -use std::path::PathBuf; - use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::auth::AuthStorage; + /// Errors that can occur when interacting with the registry. #[derive(Error, Debug)] pub enum RegistryError { @@ -74,62 +74,38 @@ pub struct RegistryClient { } impl RegistryClient { - /// Create a new registry client with optional auto-detected authentication. + /// Create a new registry client with auto-detected authentication. + /// + /// Loads auth token from: + /// 1. AMP_AUTH_TOKEN environment variable (highest priority) + /// 2. Auth storage file (~/.amp/cache/amp_cli_auth) pub fn new(base_url: String) -> Self { - let auth_token = Self::load_auth_token(); - // Use Client::new() like other working clients in the codebase - let http = reqwest::Client::new(); - - Self { - http, - base_url: base_url.trim_end_matches('/').to_string(), - auth_token, - } + // Load auth from AMP_AUTH_TOKEN env var. If not present, load from storage + let token = + Self::load_auth_from_env().or_else(|| AuthStorage::load().map(|a| a.access_token)); + Self::with_token(base_url, token) } - /// Create a new registry client with a specific auth token. - #[allow(dead_code)] - pub fn with_auth(base_url: String, token: String) -> Self { + /// Create a new registry client with an explicit auth token. + /// + /// Use this when the app's auth state changes (login, logout, refresh). + pub fn with_token(base_url: String, token: Option) -> Self { let http = reqwest::Client::new(); Self { http, base_url: base_url.trim_end_matches('/').to_string(), - auth_token: Some(token), + auth_token: token, } } - /// Load auth token from environment or auth file. + /// Load auth token from AMP_AUTH_TOKEN environment variable. /// - /// Priority: - /// 1. AMP_AUTH_TOKEN environment variable - /// 2. ~/.amp/cache/amp_cli_auth file - fn load_auth_token() -> Option { - // Check environment variable first - if let Ok(token) = std::env::var("AMP_AUTH_TOKEN") - && !token.is_empty() - { - return Some(token); - } - - // Try loading from auth file (~/.amp/cache/amp_cli_auth) - if let Some(home) = std::env::var_os("HOME") { - let auth_path: PathBuf = PathBuf::from(home) - .join(".amp") - .join("cache") - .join("amp_cli_auth"); - if let Ok(contents) = std::fs::read_to_string(&auth_path) { - // Parse JSON and extract accessToken - if let Ok(json) = serde_json::from_str::(&contents) - && let Some(token) = json.get("accessToken").and_then(|v| v.as_str()) - && !token.is_empty() - { - return Some(token.to_string()); - } - } - } - - None + /// This is used as a fallback for CI/automation scenarios. + fn load_auth_from_env() -> Option { + std::env::var("AMP_AUTH_TOKEN") + .ok() + .filter(|t| !t.is_empty()) } /// Build a request with optional auth header. diff --git a/crates/bin/ampcc/src/ui.rs b/crates/bin/ampcc/src/ui.rs index 660e2bc11..b89395413 100644 --- a/crates/bin/ampcc/src/ui.rs +++ b/crates/bin/ampcc/src/ui.rs @@ -2,6 +2,8 @@ //! //! This module uses The Graph's official color palette for consistent branding. +mod components; + use admin_client::{jobs::JobInfo, workers::WorkerDetailResponse}; use ratatui::{ Frame, @@ -13,7 +15,10 @@ use ratatui::{ }, }; -use crate::app::{ActivePane, App, ContentView, DataSource, InputMode, InspectResult}; +use crate::{ + app::{ActivePane, App, ContentView, DataSource, InputMode, InspectResult}, + util::get_account_display, +}; // ============================================================================ // The Graph Color Palette @@ -177,7 +182,7 @@ impl Theme { } /// ASCII art logo for splash screen (displayed when Header pane is focused). -const AMP_LOGO: &str = r#" +pub const AMP_LOGO: &str = r#" ▒█░ ▒███░ ▒█████░ @@ -203,6 +208,12 @@ The Graph /// Main draw function. pub fn draw(f: &mut Frame, app: &mut App) { + // If device flow is active, render full screen auth + if app.auth_device_flow.is_some() { + components::auth_screen::render(f, app); + return; + } + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -224,16 +235,35 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { DataSource::Registry => Theme::source_registry(), }; - let text = vec![Line::from(vec![ + // Build auth status spans + let auth_spans = if let Some(ref auth) = app.auth_state { + let account_display = get_account_display(auth); + vec![ + Span::raw(" | "), + Span::styled(account_display, Theme::status_success()), + Span::styled(" [Ctrl+L logout]", Theme::text_secondary()), + ] + } else { + vec![ + Span::raw(" | "), + Span::styled("Not logged in ", Theme::text_secondary()), + Span::styled("[Ctrl+L login]", Theme::text_secondary()), + ] + }; + + let mut spans = vec![ Span::styled("Amp CC", Theme::accent().add_modifier(Modifier::BOLD)), Span::raw(" | Source: "), Span::styled(app.current_source.as_str(), source_style), Span::raw(" | "), Span::styled( - truncate_url(app.current_source_url(), 50), + truncate_url(app.current_source_url(), 40), Theme::text_secondary(), ), - ])]; + ]; + spans.extend(auth_spans); + + let text = vec![Line::from(spans)]; let border_style = if app.active_pane == ActivePane::Header { Theme::border_focused() diff --git a/crates/bin/ampcc/src/ui/components.rs b/crates/bin/ampcc/src/ui/components.rs new file mode 100644 index 000000000..4bbcb9517 --- /dev/null +++ b/crates/bin/ampcc/src/ui/components.rs @@ -0,0 +1,3 @@ +//! UI components for the Amp CC TUI. + +pub mod auth_screen; diff --git a/crates/bin/ampcc/src/ui/components/auth_screen.rs b/crates/bin/ampcc/src/ui/components/auth_screen.rs new file mode 100644 index 000000000..043cfc9af --- /dev/null +++ b/crates/bin/ampcc/src/ui/components/auth_screen.rs @@ -0,0 +1,127 @@ +//! Auth screen component for device flow authentication. + +use std::borrow::Cow; + +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use super::super::{AMP_LOGO, Theme}; +use crate::app::{App, DeviceFlowStatus}; + +/// Render the full screen authentication screen. +pub fn render(f: &mut Frame, app: &App) { + let Some(ref flow) = app.auth_device_flow else { + return; + }; + + let area = f.area(); + + let block = Block::default() + .title(" Authentication ") + .borders(Borders::ALL) + .border_style(Theme::border_focused()); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Layout: top padding, logo, gap, code label, gap, user code, gap, status, bottom padding + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // Top padding (flexible) + Constraint::Length(20), // Logo + Constraint::Length(2), // Gap + Constraint::Length(1), // Code label + Constraint::Length(1), // Gap + Constraint::Length(1), // User code + Constraint::Length(2), // Gap + Constraint::Length(1), // Status + Constraint::Min(1), // Bottom padding (flexible) + ]) + .split(inner); + + // Render logo + render_logo(f, chunks[1]); + + // Render code label + render_code_label(f, chunks[3], flow.copy_to_clipboard_failed); + + // Render user code + render_user_code(f, chunks[5], &flow.user_code); + + // Render status + render_status(f, chunks[7], &flow.status); +} + +/// Render the ASCII logo. +fn render_logo(f: &mut Frame, area: Rect) { + let logo_lines: Vec<&str> = AMP_LOGO.lines().skip(1).take(18).collect(); + let lines: Vec = logo_lines + .iter() + .map(|line| { + Line::from(Span::styled( + line.to_string(), + Theme::accent().add_modifier(Modifier::BOLD), + )) + }) + .collect(); + + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + f.render_widget(paragraph, area); +} + +/// Render the code label. +fn render_code_label(f: &mut Frame, area: Rect, copy_failed: bool) { + let text = if copy_failed { + "Enter this code in your browser:" + } else { + "Enter this code in your browser (copied to clipboard):" + }; + let line = Line::from(Span::styled(text, Theme::text_secondary())); + let paragraph = Paragraph::new(line).alignment(Alignment::Center); + f.render_widget(paragraph, area); +} + +/// Render the user code prominently. +fn render_user_code(f: &mut Frame, area: Rect, user_code: &str) { + let text = Line::from(Span::styled( + user_code, + Style::default() + .fg(Theme::GALACTIC_AQUA) + .add_modifier(Modifier::BOLD), + )); + let paragraph = Paragraph::new(text).alignment(Alignment::Center); + f.render_widget(paragraph, area); +} + +/// Render the auth status message. +fn render_status(f: &mut Frame, area: Rect, status: &DeviceFlowStatus) { + let (message, style): (Cow<'_, str>, Style) = match status { + DeviceFlowStatus::AwaitingConfirmation => ( + "Press Enter to open browser, Esc to cancel".into(), + Theme::version_tag(), + ), + DeviceFlowStatus::WaitingForBrowser => ( + "Opening browser... Complete authentication to continue.".into(), + Theme::status_warning(), + ), + DeviceFlowStatus::Polling => ( + "Waiting for authentication...".into(), + Theme::status_warning(), + ), + DeviceFlowStatus::OpenBrowserFailure(url) => ( + format!("Failed to open browser. Go to: {url} to authenticate").into(), + Theme::status_warning(), + ), + DeviceFlowStatus::Error(err) => (err.as_str().into(), Theme::status_error()), + }; + + let text = Line::from(Span::styled(message, style)); + let paragraph = Paragraph::new(text).alignment(Alignment::Center); + f.render_widget(paragraph, area); +} diff --git a/crates/bin/ampcc/src/util.rs b/crates/bin/ampcc/src/util.rs new file mode 100644 index 000000000..3e96b84cc --- /dev/null +++ b/crates/bin/ampcc/src/util.rs @@ -0,0 +1,86 @@ +//! Common utility functions. + +use alloy::primitives::Address; + +use crate::auth::AuthStorage; + +/// Shorten a string for display, showing first 6 and last 6 characters. +/// +/// Strings <= 18 characters are returned as-is. +/// Longer strings become `first6...last6`. +/// +/// Handles UTF-8 safely by operating on characters, not bytes. +pub fn shorten(val: &str) -> String { + let char_count = val.chars().count(); + if char_count <= 18 { + val.to_string() + } else { + let first: String = val.chars().take(6).collect(); + let last: String = val.chars().skip(char_count - 6).collect(); + format!("{}...{}", first, last) + } +} + +/// Check if a string is a valid EVM wallet address. +/// +/// Uses alloy's Address type for proper validation including: +/// - Must start with "0x" prefix +/// - Correct length (42 characters with 0x prefix) +/// - Valid hexadecimal characters +pub fn is_evm_address(s: &str) -> bool { + s.starts_with("0x") && s.parse::
().is_ok() +} + +/// Get the display account from auth storage. +/// +/// Prefers the first valid EVM wallet address (0x...). +/// Falls back to user_id if no valid address is found. +/// Returns the shortened form for display. +pub fn get_account_display(auth: &AuthStorage) -> String { + let account = auth + .accounts + .as_ref() + .and_then(|accounts| accounts.iter().find(|a| is_evm_address(a))) + .map(|s| s.as_str()) + .unwrap_or(&auth.user_id); + shorten(account) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shorten_short_string() { + assert_eq!(shorten("hello"), "hello"); + assert_eq!(shorten("exactly18chars!!!"), "exactly18chars!!!"); + } + + #[test] + fn test_shorten_long_string() { + let long = "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE71"; + assert_eq!(shorten(long), "0x742d...f8fE71"); + } + + #[test] + fn test_is_evm_address_valid() { + // Valid lowercase address + assert!(is_evm_address("0x742d35cc6634c0532925a3b844bc9e7595f8fe71")); + // Valid checksummed address + assert!(is_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f8fE71")); + // Valid all zeros + assert!(is_evm_address("0x0000000000000000000000000000000000000000")); + } + + #[test] + fn test_is_evm_address_invalid() { + // Not an EVM address format + assert!(!is_evm_address("did:privy:cmfd6bf6u006vjx0b7xb2eybx")); + // Too short + assert!(!is_evm_address("0x123")); + // Missing 0x prefix + assert!(!is_evm_address("742d35Cc6634C0532925a3b844Bc9e7595f8fE71")); + // Empty string + assert!(!is_evm_address("")); + } +}