diff --git a/Cargo.lock b/Cargo.lock index eb00dea..2e5ab1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -68,12 +118,6 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - [[package]] name = "cassowary" version = "0.3.0" @@ -95,6 +139,46 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "color-eyre" version = "0.6.5" @@ -122,6 +206,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "compact_str" version = "0.8.1" @@ -136,6 +226,19 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.61.2", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -206,6 +309,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -224,6 +339,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -262,95 +383,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -436,6 +474,12 @@ dependencies = [ "syn", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -467,12 +511,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lexopt" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" - [[package]] name = "libc" version = "0.2.180" @@ -557,7 +595,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]] @@ -646,6 +684,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "owo-colors" version = "4.2.3" @@ -687,12 +731,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "powerfmt" version = "0.2.0" @@ -868,6 +906,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "signal-hook" version = "0.3.18" @@ -899,28 +943,12 @@ dependencies = [ "libc", ] -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -1039,34 +1067,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "toml" version = "0.9.11+spec-1.1.0" @@ -1177,16 +1177,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tui-textarea" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" -dependencies = [ - "ratatui", - "unicode-width 0.2.0", -] - [[package]] name = "unicode-ident" version = "1.0.22" @@ -1222,6 +1212,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.20.0" @@ -1245,11 +1241,11 @@ name = "vibebox" version = "0.1.0" dependencies = [ "block2", + "clap", "color-eyre", "crossterm", + "dialoguer", "dispatch2", - "futures", - "lexopt", "libc", "objc2", "objc2-foundation", @@ -1259,12 +1255,9 @@ dependencies = [ "tempfile", "thiserror", "time", - "tokio", "toml", "tracing", "tracing-subscriber", - "tui-textarea", - "unicode-width 0.2.0", "uuid", ] @@ -1362,16 +1355,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -1389,31 +1373,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -1422,96 +1389,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.14" @@ -1523,3 +1442,9 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index f0e8f3a..b940f08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ objc2-virtualization = "*" block2 = "*" dispatch2 = "*" libc = "*" -lexopt = "0.3" +clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } tempfile = "3" thiserror = "2.0.18" @@ -29,10 +29,7 @@ toml = "0.9.8" uuid = { version = "1", features = ["v7", "serde"] } color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["event-stream"] } -futures = "0.3.31" ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] } -tokio = { version = "1.40.0", features = ["full"] } -tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] } -unicode-width = "0.2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dialoguer = "0.12.0" diff --git a/src/bin/vibebox-cli.rs b/src/bin/vibebox-cli.rs index 1d78f31..562135b 100644 --- a/src/bin/vibebox-cli.rs +++ b/src/bin/vibebox-cli.rs @@ -2,24 +2,52 @@ use std::{ env, ffi::OsString, io::{self, IsTerminal, Write}, + path::PathBuf, sync::{Arc, Mutex}, }; +use clap::Parser; use color_eyre::Result; +use dialoguer::Confirm; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use tracing_subscriber::EnvFilter; use vibebox::tui::{AppState, VmInfo}; -use vibebox::{SessionManager, commands, config, instance, tui, vm, vm_manager}; +use vibebox::{SessionManager, commands, config, instance, session_manager, tui, vm, vm_manager}; + +#[derive(Debug, Parser)] +#[command(name = "vibebox", version, about = "Vibebox CLI")] +struct Cli { + /// Path to vibebox.toml (relative to the current directory) + #[arg(short = 'c', long = "config", value_name = "PATH", global = true)] + config: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, clap::Subcommand)] +enum Command { + /// List all sessions + List, + /// Delete the current project's .vibebox directory + Clean, +} fn main() -> Result<()> { init_tracing(); color_eyre::install()?; - let raw_args: Vec = env::args_os().collect(); - + let cli = Cli::parse(); let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; tracing::info!(cwd = %cwd.display(), "starting vibebox cli"); - let config = config::load_config(&cwd); + if let Some(command) = cli.command { + return handle_command(command, &cwd); + } + + let config_override = cli.config.clone(); + let raw_args: Vec = env::args_os().collect(); + let config = config::load_config_with_path(&cwd, config_override.as_deref()); if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") { tracing::info!("starting vm manager mode"); @@ -74,14 +102,135 @@ fn main() -> Result<()> { } tracing::info!(auto_shutdown_ms, "auto shutdown config"); - let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let manager_conn = + vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; Ok(()) } +fn handle_command(command: Command, cwd: &PathBuf) -> Result<()> { + match command { + Command::List => { + let manager = SessionManager::new()?; + let sessions = manager.list_sessions()?; + if sessions.is_empty() { + println!("No sessions were found."); + return Ok(()); + } + let rows: Vec = sessions + .into_iter() + .map(|session| tui::SessionListRow { + name: project_name(&session.directory), + id: session.id, + directory: relative_to_home(&session.directory), + last_active: format_last_active(session.last_active.as_deref()), + active: if session.active { + "yes".to_string() + } else { + "no".to_string() + }, + }) + .collect(); + tui::render_sessions_table(&rows)?; + Ok(()) + } + Command::Clean => { + let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME); + if !instance_dir.exists() { + println!("No .vibebox directory found at {}", instance_dir.display()); + return Ok(()); + } + let confirmed = Confirm::new() + .with_prompt(format!( + "Delete {} and all its contents?", + instance_dir.display() + )) + .default(false) + .interact()?; + if !confirmed { + println!("Cancelled."); + return Ok(()); + } + let manager = SessionManager::new()?; + let summary = manager.clean_project(cwd)?; + println!( + "Deleted {} (removed={}, session_records_removed={})", + summary.instance_dir.display(), + summary.removed_instance_dir, + summary.removed_sessions + ); + Ok(()) + } + } +} + +fn project_name(directory: &PathBuf) -> String { + directory + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("-") + .to_string() +} + +fn relative_to_home(directory: &PathBuf) -> String { + let Ok(home) = env::var("HOME") else { + return directory.display().to_string(); + }; + let home_path = PathBuf::from(home); + if let Ok(stripped) = directory.strip_prefix(&home_path) { + if stripped.components().next().is_none() { + return "~".to_string(); + } + return format!("~/{}", stripped.display()); + } + directory.display().to_string() +} + +fn format_last_active(value: Option<&str>) -> String { + let Some(raw) = value else { + return "-".to_string(); + }; + let parsed = OffsetDateTime::parse(raw, &Rfc3339); + let Ok(timestamp) = parsed else { + return raw.to_string(); + }; + let now = OffsetDateTime::now_utc(); + let mut seconds = (now - timestamp).whole_seconds(); + if seconds < 0 { + seconds = 0; + } + let seconds = seconds as i64; + let week_seconds = 7 * 24 * 60 * 60; + if seconds >= week_seconds { + let formatted = + match time::format_description::parse("[year]-[month]-[day] [hour]:[minute]Z") { + Ok(format) => timestamp + .format(&format) + .unwrap_or_else(|_| raw.to_string()), + Err(_) => timestamp + .format(&Rfc3339) + .unwrap_or_else(|_| raw.to_string()), + }; + return formatted; + } + if seconds < 60 { + return "just now".to_string(); + } + if seconds < 60 * 60 { + let mins = seconds / 60; + return format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" }); + } + if seconds < 60 * 60 * 24 { + let hours = seconds / (60 * 60); + return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }); + } + let days = seconds / (60 * 60 * 24); + format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) +} + fn init_tracing() { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err(); diff --git a/src/config.rs b/src/config.rs index a2f849e..9455a3e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use std::{ - fs, io, + env, fs, io, path::{Path, PathBuf}, }; @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::vm::DirectoryShare; pub const CONFIG_FILENAME: &str = "vibebox.toml"; +pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH"; const DEFAULT_CPU_COUNT: usize = 2; const DEFAULT_RAM_MB: u64 = 2048; @@ -93,8 +94,11 @@ pub fn config_path(project_root: &Path) -> PathBuf { project_root.join(CONFIG_FILENAME) } -pub fn ensure_config_file(project_root: &Path) -> Result { - let path = config_path(project_root); +pub fn ensure_config_file( + project_root: &Path, + override_path: Option<&Path>, +) -> Result { + let path = resolve_config_path(project_root, override_path); if !path.exists() { let default_config = Config::default(); let contents = toml::to_string_pretty(&default_config).unwrap_or_default(); @@ -105,7 +109,11 @@ pub fn ensure_config_file(project_root: &Path) -> Result { } pub fn load_config(project_root: &Path) -> Config { - let path = match ensure_config_file(project_root) { + load_config_with_path(project_root, None) +} + +pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config { + let path = match ensure_config_file(project_root, override_path) { Ok(path) => path, Err(err) => die(&format!("failed to create config: {err}")), }; @@ -144,6 +152,54 @@ pub fn load_config(project_root: &Path) -> Config { config } +fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf { + let root = match fs::canonicalize(project_root) { + Ok(root) => root, + Err(err) => die(&format!("failed to resolve project root: {err}")), + }; + + let override_path = override_path + .map(PathBuf::from) + .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from)); + let raw_path = if let Some(path) = override_path { + if path.is_absolute() { + path + } else { + project_root.join(path) + } + } else { + config_path(project_root) + }; + + let normalized = normalize_path(&raw_path); + if !normalized.starts_with(&root) { + die(&format!( + "config path must be within {}: {}", + root.display(), + normalized.display() + )); + } + normalized +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + std::path::Component::RootDir => { + normalized.push(std::path::MAIN_SEPARATOR.to_string()); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + let _ = normalized.pop(); + } + std::path::Component::Normal(part) => normalized.push(part), + } + } + normalized +} + fn validate_schema(value: &toml::Value) -> Vec { let mut errors = Vec::new(); let root = match value.as_table() { diff --git a/src/session_manager.rs b/src/session_manager.rs index 1900529..6359fe5 100644 --- a/src/session_manager.rs +++ b/src/session_manager.rs @@ -1,6 +1,7 @@ use std::{ env, fs, io::{self, Write}, + os::unix::fs::FileTypeExt, path::{Path, PathBuf}, }; @@ -13,6 +14,8 @@ pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox"; pub const GLOBAL_DIR_NAME: &str = ".vibebox"; pub const INSTANCE_FILENAME: &str = "instance.toml"; pub const SESSION_TOML_SUFFIX: &str = ".toml"; +pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock"; +pub const VM_MANAGER_PID_NAME: &str = "vm.pid"; const SESSIONS_DIR_NAME: &str = "sessions"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,6 +23,7 @@ pub struct SessionRecord { pub directory: PathBuf, pub id: String, pub last_active: Option, + pub active: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -41,6 +45,13 @@ pub struct SessionManager { sessions_dir: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CleanSummary { + pub instance_dir: PathBuf, + pub removed_instance_dir: bool, + pub removed_sessions: usize, +} + #[derive(Debug, thiserror::Error)] pub enum SessionError { #[error("HOME environment variable is not set")] @@ -138,15 +149,33 @@ impl SessionManager { let mut records = Vec::with_capacity(sessions.len()); for session in sessions { let meta = read_instance_metadata(&session.directory)?; + let active = is_session_active(&session.directory); records.push(SessionRecord { directory: session.directory, id: session.id, last_active: meta.last_active, + active, }); } Ok(records) } + pub fn clean_project(&self, directory: &Path) -> Result { + let directory = self.normalize_directory(directory)?; + let instance_dir = directory.join(INSTANCE_DIR_NAME); + let mut removed_instance_dir = false; + if instance_dir.exists() { + fs::remove_dir_all(&instance_dir)?; + removed_instance_dir = true; + } + let removed_sessions = self.remove_session_records_for_directory(&directory)?; + Ok(CleanSummary { + instance_dir, + removed_instance_dir, + removed_sessions, + }) + } + fn normalize_directory(&self, directory: &Path) -> Result { if !directory.is_absolute() { return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf())); @@ -206,6 +235,36 @@ impl SessionManager { Ok((sessions, removed)) } + + fn remove_session_records_for_directory( + &self, + directory: &Path, + ) -> Result { + if !self.sessions_dir.exists() { + return Ok(0); + } + let mut removed = 0usize; + for entry in fs::read_dir(&self.sessions_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let record = read_session_file(&path)?; + if record.directory == directory { + fs::remove_file(&path)?; + removed += 1; + } + } + if removed > 0 { + tracing::info!( + directory = %directory.display(), + removed, + "removed session records" + ); + } + Ok(removed) + } } fn is_vibebox_dir(directory: &Path) -> bool { @@ -215,6 +274,43 @@ fn is_vibebox_dir(directory: &Path) -> bool { directory.join(CONFIG_FILENAME).is_file() } +fn is_session_active(directory: &Path) -> bool { + let instance_dir = directory.join(INSTANCE_DIR_NAME); + let pid_path = instance_dir.join(VM_MANAGER_PID_NAME); + let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME); + + let pid = read_pid(&pid_path); + let is_alive = pid.map(pid_is_alive).unwrap_or(false); + if !is_alive { + let _ = fs::remove_file(&pid_path); + return false; + } + + if let Ok(metadata) = fs::metadata(&socket_path) { + return metadata.file_type().is_socket(); + } + + true +} + +fn read_pid(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + content.trim().parse::().ok() +} + +fn pid_is_alive(pid: u32) -> bool { + let pid = pid as libc::pid_t; + let result = unsafe { libc::kill(pid, 0) }; + if result == 0 { + return true; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) if code == libc::EPERM => true, + Some(code) if code == libc::ESRCH => false, + _ => false, + } +} + fn read_session_file(path: &Path) -> Result { let raw = fs::read_to_string(path)?; let record: SessionEntry = toml::from_str(&raw)?; diff --git a/src/tui.rs b/src/tui.rs index 28cc430..ecca93f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -20,7 +20,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, List, ListItem, Paragraph, Widget}, + widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget}, }; use crate::vm; @@ -98,6 +98,15 @@ pub struct VibeboxCommand { pub description: String, } +#[derive(Debug, Clone)] +pub struct SessionListRow { + pub name: String, + pub directory: String, + pub last_active: String, + pub active: String, + pub id: String, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct PageLayout { header: Rect, @@ -174,6 +183,58 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> { Ok(()) } +pub fn render_sessions_table(rows: &[SessionListRow]) -> Result<()> { + let (width, _) = crossterm::terminal::size()?; + if width == 0 { + return Ok(()); + } + + let height = (rows.len() as u16).saturating_add(3); + let mut buffer = Buffer::empty(Rect::new(0, 0, width, height)); + let area = Rect::new(0, 0, width, height); + + let header = Row::new(vec![ + Cell::from("Name"), + Cell::from("Last Active"), + Cell::from("Active"), + Cell::from("ID"), + Cell::from("Directory"), + ]) + .style(Style::default().fg(Color::Cyan)); + + let table_rows = rows.iter().map(|row| { + Row::new(vec![ + Cell::from(row.name.clone()), + Cell::from(row.last_active.clone()), + Cell::from(row.active.clone()), + Cell::from(row.id.clone()), + Cell::from(row.directory.clone()), + ]) + }); + + let table = Table::new( + table_rows, + [ + Constraint::Length(16), + Constraint::Length(14), + Constraint::Length(8), + Constraint::Length(36), + Constraint::Min(24), + ], + ) + .header(header) + .block(Block::default().title("Sessions").borders(Borders::ALL)) + .column_spacing(2); + + table.render(area, &mut buffer); + + let mut stdout = io::stdout(); + execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?; + write_buffer_with_style(&buffer, &mut stdout)?; + stdout.flush()?; + Ok(()) +} + pub fn passthrough_vm_io( app: Arc>, output_monitor: Arc, diff --git a/src/vm_manager.rs b/src/vm_manager.rs index ebbe423..b6f5fb3 100644 --- a/src/vm_manager.rs +++ b/src/vm_manager.rs @@ -15,26 +15,30 @@ use std::{ }; use crate::{ + config::CONFIG_PATH_ENV, instance::SERIAL_LOG_NAME, instance::{ InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config, }, - session_manager::{GLOBAL_DIR_NAME, INSTANCE_FILENAME}, + session_manager::{ + GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME, + }, vm::{self, DirectoryShare, LoginAction, VmInput}, }; -const VM_MANAGER_SOCKET_NAME: &str = "vm.sock"; const VM_MANAGER_LOCK_NAME: &str = "vm.lock"; const VM_MANAGER_LOG_NAME: &str = "vm_manager.log"; pub fn ensure_manager( raw_args: &[std::ffi::OsString], auto_shutdown_ms: u64, + config_path: Option<&Path>, ) -> Result> { let project_root = env::current_dir()?; tracing::debug!(root = %project_root.display(), "ensure vm manager"); let instance_dir = ensure_instance_dir(&project_root)?; + cleanup_stale_manager(&instance_dir); let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME); if let Ok(stream) = UnixStream::connect(&socket_path) { @@ -47,7 +51,7 @@ pub fn ensure_manager( let mut lock_file = acquire_spawn_lock(&lock_path)?; if lock_file.is_some() { tracing::info!(path = %socket_path.display(), "spawning vm manager"); - spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?; + spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?; } else { tracing::info!( path = %socket_path.display(), @@ -96,6 +100,7 @@ pub fn run_manager( ) -> Result<(), Box> { let project_root = env::current_dir()?; tracing::info!(root = %project_root.display(), "vm manager starting"); + let _pid_guard = ensure_pid_file(&project_root)?; run_manager_with( &project_root, args, @@ -113,6 +118,7 @@ fn spawn_manager_process( raw_args: &[std::ffi::OsString], auto_shutdown_ms: u64, instance_dir: &Path, + config_path: Option<&Path>, ) -> Result<(), Box> { let exe = env::current_exe()?; let mut supervisor_exe = exe.clone(); @@ -133,6 +139,9 @@ fn spawn_manager_process( } cmd.env("VIBEBOX_LOG_NO_COLOR", "1"); cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string()); + if let Some(path) = config_path { + cmd.env(CONFIG_PATH_ENV, path); + } tracing::info!(auto_shutdown_ms, "vm manager process spawn requested"); let log_path = instance_dir.join(VM_MANAGER_LOG_NAME); let log_file = fs::OpenOptions::new() @@ -155,6 +164,44 @@ fn spawn_manager_process( Ok(()) } +fn ensure_pid_file(project_root: &Path) -> Result> { + let instance_dir = ensure_instance_dir(project_root)?; + let pid_path = instance_dir.join(VM_MANAGER_PID_NAME); + if let Ok(content) = fs::read_to_string(&pid_path) { + if let Ok(pid) = content.trim().parse::() { + if pid_is_alive(pid) { + return Err(format!("vm manager already running (pid {pid})").into()); + } + } + let _ = fs::remove_file(&pid_path); + } + fs::write(&pid_path, format!("{}\n", std::process::id()))?; + let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600)); + Ok(PidFileGuard { path: pid_path }) +} + +fn cleanup_stale_manager(instance_dir: &Path) { + let pid_path = instance_dir.join(VM_MANAGER_PID_NAME); + if let Ok(content) = fs::read_to_string(&pid_path) { + if let Ok(pid) = content.trim().parse::() { + if pid_is_alive(pid) { + return; + } + } + } + let _ = fs::remove_file(&pid_path); +} + +struct PidFileGuard { + path: PathBuf, +} + +impl Drop for PidFileGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + fn detach_from_terminal() { unsafe { libc::setsid();