mirror of
https://github.com/robcholz/vibebox.git
synced 2026-07-05 12:37:49 +02:00
feat: added list and clean cli cmd
This commit is contained in:
+155
-6
@@ -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<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[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<OsString> = 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<OsString> = 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<tui::SessionListRow> = 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();
|
||||
|
||||
+60
-4
@@ -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<PathBuf, io::Error> {
|
||||
let path = config_path(project_root);
|
||||
pub fn ensure_config_file(
|
||||
project_root: &Path,
|
||||
override_path: Option<&Path>,
|
||||
) -> Result<PathBuf, io::Error> {
|
||||
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<PathBuf, io::Error> {
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let mut errors = Vec::new();
|
||||
let root = match value.as_table() {
|
||||
|
||||
@@ -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<String>,
|
||||
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<CleanSummary, SessionError> {
|
||||
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<PathBuf, SessionError> {
|
||||
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<usize, SessionError> {
|
||||
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<u32> {
|
||||
let content = fs::read_to_string(path).ok()?;
|
||||
content.trim().parse::<u32>().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<SessionEntry, SessionError> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
let record: SessionEntry = toml::from_str(&raw)?;
|
||||
|
||||
+62
-1
@@ -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<Mutex<AppState>>,
|
||||
output_monitor: Arc<vm::OutputMonitor>,
|
||||
|
||||
+50
-3
@@ -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<UnixStream, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<PidFileGuard, Box<dyn std::error::Error>> {
|
||||
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::<u32>() {
|
||||
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::<u32>() {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user