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/docs/tasks.md b/docs/tasks.md index 5f84524..544d4c0 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -42,8 +42,12 @@ 5. [x] wire up SessionManager. 6. [x] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself). 7. [x] setup vibebox commands -8. [ ] setup cli commands. -9. [ ] fix ui overlap. +8. [x] setup cli commands. + 1. [x] Organize all the params. + 2. [x] Remove old cli. + 3. [x] add an actual config file. + 4. [x] set up the cli. +9. [ ] fix ui overlap, and consistency issue. 10. [ ] intensive integration test. ## Publish diff --git a/src/bin/vibebox-cli.rs b/src/bin/vibebox-cli.rs index 6e35e3c..377b062 100644 --- a/src/bin/vibebox-cli.rs +++ b/src/bin/vibebox-cli.rs @@ -2,30 +2,64 @@ use std::{ env, ffi::OsString, io::{self, IsTerminal, Write}, + path::{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}; -const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 30000; +#[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, + /// Explain mounts and mappings + Explain, +} fn main() -> Result<()> { init_tracing(); color_eyre::install()?; + 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"); + if let Some(command) = cli.command { + return handle_command(command, &cwd, cli.config.as_deref()); + } + + 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"); - let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS); + let args = vm::VmArg { + cpu_count: config.box_cfg.cpu_count, + ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024), + no_default_mounts: false, + mounts: config.box_cfg.mounts.clone(), + }; + let auto_shutdown_ms = config.supervisor.auto_shutdown_ms; tracing::info!(auto_shutdown_ms, "vm manager config"); if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) { tracing::error!(error = %err, "vm manager exited"); @@ -34,33 +68,24 @@ fn main() -> Result<()> { return Ok(()); } - let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - tracing::debug!("parsed cli args"); - if args.version() { - vm::print_version(); - return Ok(()); - } - if args.help() { - vm::print_help(); - return Ok(()); - } - vm::ensure_signed(); + let vm_args = vm::VmArg { + cpu_count: config.box_cfg.cpu_count, + ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024), + no_default_mounts: false, + mounts: config.box_cfg.mounts.clone(), + }; + let vm_info = VmInfo { version: env!("CARGO_PKG_VERSION").to_string(), - max_memory_mb: args.ram_mb(), - cpu_cores: args.cpu_count(), + max_memory_mb: vm_args.ram_bytes / (1024 * 1024), + cpu_cores: vm_args.cpu_count, }; - let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - tracing::info!(cwd = %cwd.display(), "starting vibebox cli"); - let auto_shutdown_ms = config::load_config(&cwd) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? - .auto_shutdown_ms - .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS); + let auto_shutdown_ms = config.supervisor.auto_shutdown_ms; if let Ok(manager) = SessionManager::new() { if let Err(err) = manager.update_global_sessions(&cwd) { - tracing::warn!(error = %err, "failed to update global session list"); + tracing::warn!(error = %err, "failed to update a global session list"); } } else { tracing::warn!("failed to initialize session manager"); @@ -79,14 +104,260 @@ 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, config_override: Option<&Path>) -> 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(()) + } + Command::Explain => { + let config = config::load_config_with_path(cwd, config_override); + let mounts = build_mount_rows(cwd, &config)?; + let networks = build_network_rows(cwd)?; + if mounts.is_empty() && networks.is_empty() { + println!("No mounts or network info available."); + return Ok(()); + } + tui::render_explain_tables(&mounts, &networks)?; + 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 build_mount_rows(cwd: &Path, config: &config::Config) -> Result> { + let mut rows = Vec::new(); + rows.extend(default_mounts(cwd)?); + for spec in &config.box_cfg.mounts { + rows.push(parse_mount_spec(cwd, spec, false)?); + } + Ok(rows) +} + +fn build_network_rows(cwd: &Path) -> Result> { + let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME); + let mut vm_ip = "-".to_string(); + if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) { + vm_ip = ip; + } + let host_to_vm = if vm_ip == "-" { + "ssh: :22".to_string() + } else { + format!("ssh: {vm_ip}:22") + }; + let row = tui::NetworkListRow { + network_type: "NAT".to_string(), + vm_ip: vm_ip.clone(), + host_to_vm, + vm_to_host: "none".to_string(), + }; + Ok(vec![row]) +} + +fn default_mounts(cwd: &Path) -> Result> { + let project_name = cwd + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("project"); + let project_guest = format!("/root/{project_name}"); + let project_host = relative_to_home(&cwd.to_path_buf()); + let mut rows = vec![tui::MountListRow { + host: project_host, + guest: project_guest, + mode: "read-write".to_string(), + default_mount: "yes".to_string(), + }]; + + let home = env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/")); + let cache_home = env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".cache")); + let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME); + let guest_mise_cache = cache_dir.join(".guest-mise-cache"); + rows.push(tui::MountListRow { + host: relative_to_home(&guest_mise_cache), + guest: "/root/.local/share/mise".to_string(), + mode: "read-write".to_string(), + default_mount: "yes".to_string(), + }); + Ok(rows) +} + +fn parse_mount_spec(cwd: &Path, spec: &str, default_mount: bool) -> Result { + let parts: Vec<&str> = spec.split(':').collect(); + if parts.len() < 2 || parts.len() > 3 { + return Err(color_eyre::eyre::eyre!("invalid mount spec: {spec}")); + } + let host_part = parts[0]; + let guest_part = parts[1]; + let mode = if parts.len() == 3 { + match parts[2] { + "read-only" => "read-only", + "read-write" => "read-write", + other => { + return Err(color_eyre::eyre::eyre!( + "invalid mount mode '{}'; expected read-only or read-write", + other + )); + } + } + } else { + "read-write" + }; + let host_path = resolve_host_path(cwd, host_part); + let host_display = relative_to_home(&host_path); + let guest_display = if Path::new(guest_part).is_absolute() { + guest_part.to_string() + } else { + format!("/root/{guest_part}") + }; + Ok(tui::MountListRow { + host: host_display, + guest: guest_display, + mode: mode.to_string(), + default_mount: if default_mount { "yes" } else { "no" }.to_string(), + }) +} + +fn resolve_host_path(cwd: &Path, host: &str) -> PathBuf { + if let Some(stripped) = host.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home).join(stripped); + } + } else if host == "~" { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home); + } + } + let host_path = PathBuf::from(host); + if host_path.is_absolute() { + host_path + } else { + cwd.join(host_path) + } +} + 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/bin/vibebox-supervisor.rs b/src/bin/vibebox-supervisor.rs index 1665315..25464c1 100644 --- a/src/bin/vibebox-supervisor.rs +++ b/src/bin/vibebox-supervisor.rs @@ -6,9 +6,7 @@ use std::{ use color_eyre::Result; use tracing_subscriber::EnvFilter; -use vibebox::{instance, vm, vm_manager}; - -const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000; +use vibebox::{config, instance, vm, vm_manager}; fn main() -> Result<()> { init_tracing(); @@ -16,14 +14,17 @@ fn main() -> Result<()> { tracing::info!("starting vm supervisor"); let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let config = config::load_config(&cwd); let instance_dir = instance::ensure_instance_dir(&cwd) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; let _ = instance::touch_last_active(&instance_dir); - let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS); + let args = vm::VmArg { + cpu_count: config.box_cfg.cpu_count, + ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024), + no_default_mounts: false, + mounts: config.box_cfg.mounts.clone(), + }; + let auto_shutdown_ms = config.supervisor.auto_shutdown_ms; tracing::info!(auto_shutdown_ms, "vm supervisor config"); let result = vm_manager::run_manager(args, auto_shutdown_ms); diff --git a/src/config.rs b/src/config.rs index 2c6a88e..9455a3e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,38 +1,298 @@ use std::{ - fs, io, + env, fs, io, path::{Path, PathBuf}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; + +use crate::vm::DirectoryShare; pub const CONFIG_FILENAME: &str = "vibebox.toml"; +pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH"; -#[derive(Debug, Default, Deserialize)] -pub struct ProjectConfig { - pub auto_shutdown_ms: Option, +const DEFAULT_CPU_COUNT: usize = 2; +const DEFAULT_RAM_MB: u64 = 2048; +const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(rename = "box")] + pub box_cfg: BoxConfig, + pub supervisor: SupervisorConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + box_cfg: BoxConfig::default(), + supervisor: SupervisorConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoxConfig { + pub cpu_count: usize, + pub ram_mb: u64, + pub mounts: Vec, +} + +impl Default for BoxConfig { + fn default() -> Self { + Self { + cpu_count: default_cpu_count(), + ram_mb: default_ram_mb(), + mounts: default_mounts(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupervisorConfig { + pub auto_shutdown_ms: u64, +} + +impl Default for SupervisorConfig { + fn default() -> Self { + Self { + auto_shutdown_ms: default_auto_shutdown_ms(), + } + } +} + +fn default_cpu_count() -> usize { + DEFAULT_CPU_COUNT +} + +fn default_ram_mb() -> u64 { + DEFAULT_RAM_MB +} + +fn default_auto_shutdown_ms() -> u64 { + DEFAULT_AUTO_SHUTDOWN_MS +} + +fn default_mounts() -> Vec { + let home = match std::env::var("HOME") { + Ok(home) => PathBuf::from(home), + Err(_) => return Vec::new(), + }; + + let mut mounts = Vec::new(); + let codex_host = home.join(".codex"); + if codex_host.exists() { + mounts.push("~/.codex:/usr/local/codex:read-write".to_string()); + } + let claude_host = home.join(".claude"); + if claude_host.exists() { + mounts.push("~/.claude:/usr/local/claude:read-write".to_string()); + } + mounts } 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() { - fs::write(&path, "")?; + let default_config = Config::default(); + let contents = toml::to_string_pretty(&default_config).unwrap_or_default(); + fs::write(&path, contents)?; tracing::info!(path = %path.display(), "created vibebox config"); } Ok(path) } -pub fn load_config( - project_root: &Path, -) -> Result> { - let path = ensure_config_file(project_root)?; - let raw = fs::read_to_string(&path)?; - tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config"); - if raw.trim().is_empty() { - return Ok(ProjectConfig::default()); - } - Ok(toml::from_str::(&raw)?) +pub fn load_config(project_root: &Path) -> Config { + 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}")), + }; + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) => die(&format!("failed to read config: {err}")), + }; + let trimmed = raw.trim(); + tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config"); + if trimmed.is_empty() { + die(&format!( + "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)", + path.display() + )); + } + + let value: toml::Value = match toml::from_str(trimmed) { + Ok(value) => value, + Err(err) => die(&format!("invalid config: {err}")), + }; + let schema_errors = validate_schema(&value); + if !schema_errors.is_empty() { + let message = format!( + "config file ({}) is missing or invalid fields:\n- {}", + path.display(), + schema_errors.join("\n- ") + ); + die(&message); + } + + let config: Config = match toml::from_str(trimmed) { + Ok(config) => config, + Err(err) => die(&format!("invalid config: {err}")), + }; + validate_or_exit(&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() { + Some(table) => table, + None => { + errors.push("config must be a table".to_string()); + return errors; + } + }; + + match root.get("box") { + None => errors.push("missing [box] table".to_string()), + Some(value) => match value.as_table() { + Some(table) => { + validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors); + validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors); + validate_string_array( + table, + "mounts", + "[box].mounts (array of strings)", + &mut errors, + ); + } + None => errors.push("[box] must be a table".to_string()), + }, + } + + match root.get("supervisor") { + None => errors.push("missing [supervisor] table".to_string()), + Some(value) => match value.as_table() { + Some(table) => { + validate_int( + table, + "auto_shutdown_ms", + "[supervisor].auto_shutdown_ms (integer)", + &mut errors, + ); + } + None => errors.push("[supervisor] must be a table".to_string()), + }, + } + + errors +} + +fn validate_int(table: &toml::value::Table, key: &str, label: &str, errors: &mut Vec) { + match table.get(key) { + None => errors.push(format!("missing {label}")), + Some(value) => { + if value.as_integer().is_none() { + errors.push(format!("invalid {label}: expected integer")); + } + } + } +} + +fn validate_string_array( + table: &toml::value::Table, + key: &str, + label: &str, + errors: &mut Vec, +) { + match table.get(key) { + None => errors.push(format!("missing {label}")), + Some(value) => match value.as_array() { + Some(values) => { + if values.iter().any(|value| !value.is_str()) { + errors.push(format!("invalid {label}: expected array of strings")); + } + } + None => errors.push(format!("invalid {label}: expected array of strings")), + }, + } +} + +fn validate_or_exit(config: &Config) { + if config.box_cfg.cpu_count == 0 { + die("box.cpu_count must be >= 1"); + } + if config.box_cfg.ram_mb == 0 { + die("box.ram_mb must be >= 1"); + } + if config.supervisor.auto_shutdown_ms == 0 { + die("supervisor.auto_shutdown_ms must be >= 1"); + } + for spec in &config.box_cfg.mounts { + if let Err(err) = DirectoryShare::from_mount_spec(spec) { + die(&format!("invalid mount spec '{spec}': {err}")); + } + } +} + +fn die(message: &str) -> ! { + eprintln!("[vibebox] {message}"); + std::process::exit(1); } diff --git a/src/instance.rs b/src/instance.rs index 4773a59..b578830 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -20,14 +20,13 @@ use uuid::Uuid; use crate::{ commands, - session_manager::{INSTANCE_DIR_NAME, INSTANCE_TOML_FILENAME}, + session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME}, tui::AppState, vm::{self, LoginAction, VmInput}, }; const SSH_KEY_NAME: &str = "ssh_key"; -#[allow(dead_code)] -const SERIAL_LOG_NAME: &str = "serial.log"; +pub(crate) const SERIAL_LOG_NAME: &str = "serial.log"; const DEFAULT_SSH_USER: &str = "vibecoder"; const SSH_CONNECT_RETRIES: usize = 30; const SSH_CONNECT_DELAY_MS: u64 = 500; @@ -126,7 +125,7 @@ pub(crate) fn ensure_ssh_keypair( pub(crate) fn load_or_create_instance_config( instance_dir: &Path, ) -> Result> { - let config_path = instance_dir.join(INSTANCE_TOML_FILENAME); + let config_path = instance_dir.join(INSTANCE_FILENAME); let mut config = if config_path.exists() { let raw = fs::read_to_string(&config_path)?; toml::from_str::(&raw)? @@ -163,11 +162,30 @@ pub(crate) fn load_or_create_instance_config( Ok(config) } +fn read_instance_config( + instance_dir: &Path, +) -> Result, Box> { + let config_path = instance_dir.join(INSTANCE_FILENAME); + if !config_path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&config_path)?; + let config = toml::from_str::(&raw)?; + Ok(Some(config)) +} + +pub fn read_instance_vm_ip( + instance_dir: &Path, +) -> Result, Box> { + let config = read_instance_config(instance_dir)?; + Ok(config.and_then(|cfg| cfg.vm_ipv4)) +} + pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box> { let mut config = load_or_create_instance_config(instance_dir)?; let now = OffsetDateTime::now_utc().format(&Rfc3339)?; config.last_active = Some(now); - write_instance_config(&instance_dir.join(INSTANCE_TOML_FILENAME), &config)?; + write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?; Ok(()) } @@ -353,7 +371,6 @@ pub(crate) fn build_ssh_login_actions( vec![LoginAction::Send(setup)] } -#[allow(dead_code)] fn spawn_ssh_io( app: Arc>, config: Arc>, @@ -379,7 +396,7 @@ fn spawn_ssh_io( let ssh_ready = Arc::new(AtomicBool::new(false)); let input_tx_holder: Arc>>> = Arc::new(Mutex::new(None)); - let instance_path = instance_dir.join(INSTANCE_TOML_FILENAME); + let instance_path = instance_dir.join(INSTANCE_FILENAME); let config_for_output = config.clone(); let log_for_output = log_file.clone(); let ssh_connected_for_output = ssh_connected.clone(); diff --git a/src/session_manager.rs b/src/session_manager.rs index d840611..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}, }; @@ -11,9 +12,10 @@ use crate::config::CONFIG_FILENAME; pub const INSTANCE_DIR_NAME: &str = ".vibebox"; pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox"; pub const GLOBAL_DIR_NAME: &str = ".vibebox"; -pub const INSTANCE_TOML_FILENAME: &str = "instance.toml"; -pub const SESSION_TEMP_PREFIX: &str = "sessions"; +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)] @@ -21,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)] @@ -42,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")] @@ -100,7 +110,8 @@ impl SessionManager { } else { tracing::warn!( directory = %directory.display(), - "missing session id in instance.toml" + file = INSTANCE_FILENAME, + "missing session id in instance file" ); } } @@ -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())); @@ -158,7 +187,7 @@ impl SessionManager { } fn session_path_for(&self, id: &str) -> PathBuf { - let filename = format!("{id}.toml"); + let filename = format!("{id}{SESSION_TOML_SUFFIX}"); self.sessions_dir.join(filename) } @@ -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)?; @@ -225,9 +321,7 @@ fn read_session_file(path: &Path) -> Result { } fn read_instance_metadata(directory: &Path) -> Result { - let instance_path = directory - .join(INSTANCE_DIR_NAME) - .join(INSTANCE_TOML_FILENAME); + let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME); if !instance_path.exists() { return Ok(InstanceMetadata::default()); } @@ -256,7 +350,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> { fs::create_dir_all(parent)?; let mut temp = tempfile::Builder::new() - .prefix(SESSION_TEMP_PREFIX) + .prefix(SESSIONS_DIR_NAME) .suffix(SESSION_TOML_SUFFIX) .tempfile_in(parent)?; temp.write_all(content)?; @@ -285,7 +379,7 @@ mod tests { let instance_dir = project_dir.join(INSTANCE_DIR_NAME); fs::create_dir_all(&instance_dir).unwrap(); let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n"); - fs::write(instance_dir.join(INSTANCE_TOML_FILENAME), content).unwrap(); + fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap(); } #[test] @@ -306,9 +400,10 @@ mod tests { assert_eq!(dirs[0], project_dir.canonicalize().unwrap()); assert!(mgr.index_path().exists()); - let session_path = mgr - .index_path() - .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml"); + let session_path = mgr.index_path().join(format!( + "019bf290-cccc-7c23-ba1d-dce7e6d40693{}", + SESSION_TOML_SUFFIX + )); assert!(session_path.exists()); } @@ -329,9 +424,10 @@ mod tests { let sessions = mgr.list_sessions().unwrap(); assert!(sessions.is_empty()); - let session_path = mgr - .index_path() - .join("019bf290-cccc-7c23-ba1d-dce7e6d40693.toml"); + let session_path = mgr.index_path().join(format!( + "019bf290-cccc-7c23-ba1d-dce7e6d40693{}", + SESSION_TOML_SUFFIX + )); assert!(!session_path.exists()); } @@ -340,7 +436,11 @@ mod tests { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); fs::create_dir_all(mgr.index_path()).unwrap(); - fs::write(mgr.index_path().join("bad.toml"), "not toml").unwrap(); + fs::write( + mgr.index_path().join(format!("bad{SESSION_TOML_SUFFIX}")), + "not toml", + ) + .unwrap(); let err = mgr.list_sessions().unwrap_err(); assert!(matches!(err, SessionError::TomlDe(_))); diff --git a/src/tui.rs b/src/tui.rs index 28cc430..1a4371c 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,31 @@ 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)] +pub struct MountListRow { + pub host: String, + pub guest: String, + pub mode: String, + pub default_mount: String, +} + +#[derive(Debug, Clone)] +pub struct NetworkListRow { + pub network_type: String, + pub vm_ip: String, + pub host_to_vm: String, + pub vm_to_host: String, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct PageLayout { header: Rect, @@ -174,6 +199,197 @@ 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 render_mounts_table(rows: &[MountListRow]) -> 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); + + render_mounts_table_into(rows, 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 render_explain_tables(mounts: &[MountListRow], networks: &[NetworkListRow]) -> Result<()> { + let (width, _) = crossterm::terminal::size()?; + if width == 0 { + return Ok(()); + } + + let mounts_height = if mounts.is_empty() { + 0 + } else { + (mounts.len() as u16).saturating_add(3) + }; + let networks_height = if networks.is_empty() { + 0 + } else { + (networks.len() as u16).saturating_add(3) + }; + let gap = if mounts_height > 0 && networks_height > 0 { + 1 + } else { + 0 + }; + let total_height = mounts_height + .saturating_add(gap) + .saturating_add(networks_height); + if total_height == 0 { + return Ok(()); + } + + let mut buffer = Buffer::empty(Rect::new(0, 0, width, total_height)); + let mut y = 0u16; + + if mounts_height > 0 { + let area = Rect::new(0, y, width, mounts_height); + render_mounts_table_into(mounts, area, &mut buffer); + y = y.saturating_add(mounts_height).saturating_add(gap); + } + + if networks_height > 0 { + let area = Rect::new(0, y, width, networks_height); + render_networks_table_into(networks, 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(()) +} + +fn render_mounts_table_into(rows: &[MountListRow], area: Rect, buffer: &mut Buffer) { + let header = Row::new(vec![ + Cell::from("Host"), + Cell::from("Guest"), + Cell::from("Mode"), + Cell::from(""), + Cell::from("Default"), + ]) + .style(Style::default().fg(Color::Cyan)); + + let table_rows = rows.iter().map(|row| { + Row::new(vec![ + Cell::from(row.host.clone()), + Cell::from(row.guest.clone()), + Cell::from(row.mode.clone()), + Cell::from(""), + Cell::from(row.default_mount.clone()), + ]) + }); + + let table = Table::new( + table_rows, + [ + Constraint::Min(24), + Constraint::Min(24), + Constraint::Length(10), + Constraint::Length(1), + Constraint::Length(8), + ], + ) + .header(header) + .block(Block::default().title("Mounts").borders(Borders::ALL)) + .column_spacing(1); + + table.render(area, buffer); +} + +fn render_networks_table_into(rows: &[NetworkListRow], area: Rect, buffer: &mut Buffer) { + let header = Row::new(vec![ + Cell::from("Type"), + Cell::from("VM IP"), + Cell::from("Host \u{2192} VM"), + Cell::from("VM \u{2192} Host"), + ]) + .style(Style::default().fg(Color::Cyan)); + + let table_rows = rows.iter().map(|row| { + Row::new(vec![ + Cell::from(row.network_type.clone()), + Cell::from(row.vm_ip.clone()), + Cell::from(row.host_to_vm.clone()), + Cell::from(row.vm_to_host.clone()), + ]) + }); + + let table = Table::new( + table_rows, + [ + Constraint::Length(8), + Constraint::Length(16), + Constraint::Min(24), + Constraint::Min(20), + ], + ) + .header(header) + .block(Block::default().title("Network").borders(Borders::ALL)) + .column_spacing(1); + + table.render(area, buffer); +} + pub fn passthrough_vm_io( app: Arc>, output_monitor: Arc, diff --git a/src/vm.rs b/src/vm.rs index 347d588..43207d3 100644 --- a/src/vm.rs +++ b/src/vm.rs @@ -1,8 +1,6 @@ use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME}; use std::{ - env, - ffi::OsString, - fs, + env, fs, io::{self, Write}, os::{ fd::RawFd, @@ -25,7 +23,6 @@ use std::{ use block2::RcBlock; use dispatch2::DispatchQueue; -use lexopt::prelude::*; use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject}; use objc2_foundation::*; use objc2_virtualization::*; @@ -40,9 +37,12 @@ const DEFAULT_CPU_COUNT: usize = 2; const DEFAULT_RAM_MB: u64 = 2048; const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB; const START_TIMEOUT: Duration = Duration::from_secs(60); -const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30); const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120); const PROVISION_SCRIPT: &str = include_str!("provision.sh"); +const PROVISION_SCRIPT_NAME: &str = "provision.sh"; +const DEFAULT_RAW_NAME: &str = "default.raw"; +const INSTANCE_RAW_NAME: &str = "instance.raw"; +const BASE_DISK_RAW_NAME: &str = "disk.raw"; #[derive(Clone)] pub(crate) enum LoginAction { @@ -83,7 +83,7 @@ impl DirectoryShare { if parts.len() < 2 || parts.len() > 3 { return Err(format!("Invalid mount spec: {spec}").into()); } - let host = PathBuf::from(parts[0]); + let host = expand_tilde_path(parts[0]); let guest = PathBuf::from(parts[1]); let read_only = if parts.len() == 3 { match parts[2] { @@ -117,7 +117,27 @@ impl DirectoryShare { } } -pub fn run_with_args(args: CliArgs, io_handler: F) -> Result<(), Box> +fn expand_tilde_path(value: &str) -> PathBuf { + if let Some(stripped) = value.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home).join(stripped); + } + } else if value == "~" { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home); + } + } + PathBuf::from(value) +} + +pub struct VmArg { + pub cpu_count: usize, + pub ram_bytes: u64, + pub no_default_mounts: bool, + pub mounts: Vec, +} + +pub fn run_with_args(args: VmArg, io_handler: F) -> Result<(), Box> where F: FnOnce(Arc, OwnedFd, OwnedFd) -> IoContext, { @@ -125,7 +145,7 @@ where } pub(crate) fn run_with_args_and_extras( - args: CliArgs, + args: VmArg, io_handler: F, extra_login_actions: Vec, extra_directory_shares: Vec, @@ -158,8 +178,8 @@ where basename_compressed.trim_end_matches(".tar.xz") )); - let default_raw = cache_dir.join("default.raw"); - let instance_raw = instance_dir.join("instance.raw"); + let default_raw = cache_dir.join(DEFAULT_RAW_NAME); + let instance_raw = instance_dir.join(INSTANCE_RAW_NAME); // Prepare system-wide directories fs::create_dir_all(&cache_dir)?; @@ -168,22 +188,14 @@ where let mise_directory_share = DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?; - let disk_path = if let Some(path) = args.disk { - if !path.exists() { - return Err(format!("Disk image does not exist: {}", path.display()).into()); - } - path - } else { - ensure_default_image( - &base_raw, - &base_compressed, - &default_raw, - std::slice::from_ref(&mise_directory_share), - )?; - ensure_instance_disk(&instance_raw, &default_raw)?; - - instance_raw - }; + ensure_default_image( + &base_raw, + &base_compressed, + &default_raw, + std::slice::from_ref(&mise_directory_share), + )?; + ensure_instance_disk(&instance_raw, &default_raw)?; + let disk_path = instance_raw; let mut login_actions = Vec::new(); let mut directory_shares = Vec::new(); @@ -207,22 +219,6 @@ where ); directory_shares.push(mise_directory_share); - - // Add default shares, if they exist - for share in [ - DirectoryShare::new(home.join(".codex"), "/usr/local/codex".into(), false), - DirectoryShare::new(home.join(".claude"), "/usr/local/claude".into(), false), - DirectoryShare::new( - "/Users/zhangjie/Documents/Code/CompletePrograms/vibebox/.ssh".into(), - "/usr/local/ssh".into(), - true, - ), - ] - .into_iter() - .flatten() - { - directory_shares.push(share) - } } directory_shares.extend(extra_directory_shares); @@ -237,9 +233,6 @@ where login_actions.extend(extra_login_actions); - // Any user-provided login actions must come after our system ones - login_actions.extend(args.login_actions); - run_vm_with_io( &disk_path, &login_actions, @@ -250,156 +243,6 @@ where ) } -pub fn print_help() { - println!( - "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents. - -vibe [OPTIONS] [disk-image.raw] - -Options - - --help Print this help message. - --version Print the version (commit SHA). - --no-default-mounts Disable all default mounts. - --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`. - Defaults to read-write. - Errors if host-path does not exist. - --cpus Number of virtual CPUs (default {DEFAULT_CPU_COUNT}). - --ram RAM size in megabytes (default {DEFAULT_RAM_MB}). - --script Run script in VM. - --send Type `some-command` followed by newline into the VM. - --expect [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`. - If `string` does not appear within timeout (default 30 seconds), shutdown VM with error. -" - ); -} - -pub fn print_version() { - println!("Vibe"); - println!("https://github.com/lynaghk/vibe/"); - println!("Git SHA: {}", env!("GIT_SHA")); -} - -pub struct CliArgs { - disk: Option, - version: bool, - help: bool, - no_default_mounts: bool, - mounts: Vec, - login_actions: Vec, - cpu_count: usize, - ram_bytes: u64, -} - -impl CliArgs { - pub fn version(&self) -> bool { - self.version - } - - pub fn help(&self) -> bool { - self.help - } - - pub fn cpu_count(&self) -> usize { - self.cpu_count - } - - pub fn ram_bytes(&self) -> u64 { - self.ram_bytes - } - - pub fn ram_mb(&self) -> u64 { - self.ram_bytes / BYTES_PER_MB - } -} - -pub fn parse_cli() -> Result> { - parse_cli_from(env::args_os()) -} - -pub fn parse_cli_from(args: I) -> Result> -where - I: IntoIterator, -{ - fn os_to_string(value: OsString, flag: &str) -> Result> { - value - .into_string() - .map_err(|_| format!("{flag} expects valid UTF-8").into()) - } - - let mut parser = lexopt::Parser::from_iter(args); - let mut disk = None; - let mut version = false; - let mut help = false; - let mut no_default_mounts = false; - let mut mounts = Vec::new(); - let mut login_actions = Vec::new(); - let mut script_index = 0; - let mut cpu_count = DEFAULT_CPU_COUNT; - let mut ram_bytes = DEFAULT_RAM_BYTES; - - while let Some(arg) = parser.next()? { - match arg { - Long("version") => version = true, - Long("help") | Short('h') => help = true, - Long("no-default-mounts") => no_default_mounts = true, - Long("cpus") => { - let value = os_to_string(parser.value()?, "--cpus")?.parse()?; - if value == 0 { - return Err("--cpus must be >= 1".into()); - } - cpu_count = value; - } - Long("ram") => { - let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?; - if value == 0 { - return Err("--ram must be >= 1".into()); - } - ram_bytes = value * BYTES_PER_MB; - } - Long("mount") => { - mounts.push(os_to_string(parser.value()?, "--mount")?); - } - Long("script") => { - login_actions.push(Script { - path: os_to_string(parser.value()?, "--script")?.into(), - index: script_index, - }); - script_index += 1; - } - Long("send") => { - login_actions.push(Send(os_to_string(parser.value()?, "--send")?)); - } - Long("expect") => { - let text = os_to_string(parser.value()?, "--expect")?; - let timeout = match parser.optional_value() { - Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?), - None => DEFAULT_EXPECT_TIMEOUT, - }; - login_actions.push(Expect { text, timeout }); - } - Value(value) => { - if disk.is_some() { - return Err("Only one disk path may be provided".into()); - } - disk = Some(PathBuf::from(value)); - } - _ => return Err(arg.unexpected().into()), - } - } - - Ok(CliArgs { - disk, - version, - help, - no_default_mounts, - mounts, - login_actions, - cpu_count, - ram_bytes, - }) -} - fn script_command_from_path( path: &Path, index: usize, @@ -629,7 +472,11 @@ fn ensure_base_image( println!("Decompressing base image..."); let status = Command::new("tar") - .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"]) + .args([ + "-xOf", + &base_compressed.to_string_lossy(), + BASE_DISK_RAW_NAME, + ]) .stdout(std::fs::File::create(base_raw)?) .status()?; @@ -655,7 +502,7 @@ fn ensure_default_image( println!("Configuring base image..."); fs::copy(base_raw, default_raw)?; - let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?; + let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?; run_vm( default_raw, &[Send(provision_command)], diff --git a/src/vm_manager.rs b/src/vm_manager.rs index 809394c..b6f5fb3 100644 --- a/src/vm_manager.rs +++ b/src/vm_manager.rs @@ -15,24 +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, + 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) { @@ -45,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(), @@ -89,11 +95,12 @@ pub fn ensure_manager( } pub fn run_manager( - args: vm::CliArgs, + args: vm::VmArg, auto_shutdown_ms: u64, ) -> 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, @@ -111,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(); @@ -131,8 +139,11 @@ 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"); + let log_path = instance_dir.join(VM_MANAGER_LOG_NAME); let log_file = fs::OpenOptions::new() .create(true) .write(true) @@ -153,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(); @@ -280,7 +329,7 @@ fn spawn_manager_io( vm_output_fd: std::os::unix::io::OwnedFd, vm_input_fd: std::os::unix::io::OwnedFd, ) -> vm::IoContext { - let log_path = instance_dir.join("serial.log"); + let log_path = instance_dir.join(SERIAL_LOG_NAME); let log_file = fs::OpenOptions::new() .create(true) .write(true) @@ -289,7 +338,7 @@ fn spawn_manager_io( .ok() .map(|file| Arc::new(Mutex::new(file))); - let instance_path = instance_dir.join("instance.toml"); + let instance_path = instance_dir.join(INSTANCE_FILENAME); let config_for_output = config.clone(); let log_for_output = log_file.clone(); let mut line_buf = String::new(); @@ -390,7 +439,7 @@ struct ManagerOptions { trait VmExecutor { fn run_vm( &self, - args: vm::CliArgs, + args: vm::VmArg, extra_login_actions: Vec, extra_shares: Vec, config: Arc>, @@ -404,7 +453,7 @@ struct RealVmExecutor; impl VmExecutor for RealVmExecutor { fn run_vm( &self, - args: vm::CliArgs, + args: vm::VmArg, extra_login_actions: Vec, extra_shares: Vec, config: Arc>, @@ -433,7 +482,7 @@ impl VmExecutor for RealVmExecutor { fn run_manager_with( project_root: &Path, - args: vm::CliArgs, + args: vm::VmArg, auto_shutdown_ms: u64, executor: &dyn VmExecutor, options: ManagerOptions, @@ -465,7 +514,7 @@ fn run_manager_with( let mut config = load_or_create_instance_config(&instance_dir)?; if config.vm_ipv4.is_some() { config.vm_ipv4 = None; - write_instance_config(&instance_dir.join("instance.toml"), &config)?; + write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?; } let config = Arc::new(Mutex::new(config)); diff --git a/vibebox.toml b/vibebox.toml index e69de29..2371ee3 100644 --- a/vibebox.toml +++ b/vibebox.toml @@ -0,0 +1,10 @@ +[box] +cpu_count = 2 +ram_mb = 2048 +mounts = [ + "~/.codex:/usr/local/codex:read-write", + "~/.claude:/usr/local/claude:read-write", +] + +[supervisor] +auto_shutdown_ms = 20000