use anyhow::Result;
use fs2::FileExt;
pub fn remote_pid_path() -> std::path::PathBuf {
crate::config::collet_home(None).join("remote.pid")
}
pub fn remote_lock_path() -> std::path::PathBuf {
crate::config::collet_home(None).join("remote.lock")
}
pub fn remote_log_path() -> std::path::PathBuf {
crate::config::logs_dir().join("remote.log")
}
pub fn remote_add(platform: Option<&str>) -> Result<()> {
use crate::config::wizard_style as s;
let platforms = ["telegram", "slack", "discord"];
let choice = if let Some(p) = platform {
if !platforms.contains(&p) {
eprintln!("Unknown platform: {p}");
eprintln!("Available: {}", platforms.join(", "));
std::process::exit(1);
}
p.to_string()
} else {
eprintln!("{}Select platform:{}", s::BOLD, s::RESET);
for (i, p) in platforms.iter().enumerate() {
eprintln!(" {}[{}]{} {}", s::CYAN, i + 1, s::RESET, p);
}
eprint!(" {}Choice [1]: {}", s::DIM, s::RESET);
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let idx: usize = line.trim().parse().unwrap_or(1);
if idx == 0 || idx > platforms.len() {
eprintln!("Invalid choice.");
std::process::exit(1);
}
platforms[idx - 1].to_string()
};
let path = crate::config::config_file_path();
let mut cf = crate::config::load_config_file().unwrap_or_default();
let mut patches: Vec<(String, String, String)> = Vec::new();
match choice.as_str() {
"telegram" => {
eprintln!();
eprintln!("{}Telegram Setup{}", s::BOLD, s::RESET);
eprintln!(
" Get a bot token from {}@BotFather{} on Telegram.",
s::CYAN,
s::RESET
);
eprintln!();
eprint!(" {}Bot token: {}", s::BOLD, s::RESET);
let token = super::util::read_password_line()?;
if token.is_empty() {
eprintln!("Token is required.");
std::process::exit(1);
}
cf.telegram.token_enc = Some(crate::config::encrypt_key(&token)?);
cf.telegram.token = None;
eprintln!(
" {}Tip:{} Send a message to {}@userinfobot{} on Telegram to find your numeric user ID.",
s::DIM,
s::RESET,
s::CYAN,
s::RESET
);
eprint!(
" {}Allowed user IDs (comma-separated, empty=none): {}",
s::DIM,
s::RESET
);
let mut users_line = String::new();
std::io::stdin().read_line(&mut users_line)?;
let raw_parts: Vec<&str> = users_line
.trim()
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let mut users: Vec<i64> = Vec::new();
for part in &raw_parts {
match part.parse::<i64>() {
Ok(id) => users.push(id),
Err(_) => {
eprintln!(
" {}⚠ Skipping '{}' — Telegram user IDs must be numeric.{}",
s::YELLOW,
part,
s::RESET
);
}
}
}
if !users.is_empty() {
let ids = users
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(", ");
patches.push((
"telegram".into(),
"allowed_users".into(),
format!("[{ids}]"),
));
} else if !raw_parts.is_empty() {
eprintln!(
" {}⚠ No valid user IDs provided. All users will be denied.{}",
s::YELLOW,
s::RESET
);
}
eprintln!(" {}✓ Telegram configured{}", s::GREEN, s::RESET);
}
"slack" => {
eprintln!();
eprintln!("{}Slack Setup{}", s::BOLD, s::RESET);
eprintln!(" You need a Bot User OAuth Token (xoxb-...) and");
eprintln!(" a Socket Mode App Token (xapp-...).");
eprintln!();
eprint!(" {}Bot token (xoxb-...): {}", s::BOLD, s::RESET);
let bot = super::util::read_password_line()?;
eprint!(" {}App token (xapp-...): {}", s::BOLD, s::RESET);
let app = super::util::read_password_line()?;
if bot.is_empty() || app.is_empty() {
eprintln!("Both tokens are required.");
std::process::exit(1);
}
cf.slack.bot_token_enc = Some(crate::config::encrypt_key(&bot)?);
cf.slack.app_token_enc = Some(crate::config::encrypt_key(&app)?);
cf.slack.bot_token = None;
cf.slack.app_token = None;
eprint!(
" {}Allowed user IDs (comma-separated, empty=none): {}",
s::DIM,
s::RESET
);
let mut users_line = String::new();
std::io::stdin().read_line(&mut users_line)?;
let users: Vec<String> = users_line
.trim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !users.is_empty() {
let ids = users
.iter()
.map(|u| format!("{u:?}"))
.collect::<Vec<_>>()
.join(", ");
patches.push(("slack".into(), "allowed_users".into(), format!("[{ids}]")));
}
eprintln!(" {}✓ Slack configured{}", s::GREEN, s::RESET);
}
"discord" => {
eprintln!();
eprintln!("{}Discord Setup{}", s::BOLD, s::RESET);
eprintln!(" Get a bot token from the Discord Developer Portal.");
eprintln!();
eprint!(" {}Bot token: {}", s::BOLD, s::RESET);
let token = super::util::read_password_line()?;
if token.is_empty() {
eprintln!("Token is required.");
std::process::exit(1);
}
cf.discord.token_enc = Some(crate::config::encrypt_key(&token)?);
cf.discord.token = None;
eprint!(
" {}Allowed user IDs (comma-separated, empty=none): {}",
s::DIM,
s::RESET
);
let mut users_line = String::new();
std::io::stdin().read_line(&mut users_line)?;
let users: Vec<u64> = users_line
.trim()
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if !users.is_empty() {
let ids = users
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(", ");
patches.push(("discord".into(), "allowed_users".into(), format!("[{ids}]")));
}
eprint!(
" {}Guild IDs (comma-separated, empty=all): {}",
s::DIM,
s::RESET
);
let mut guilds_line = String::new();
std::io::stdin().read_line(&mut guilds_line)?;
let guilds: Vec<u64> = guilds_line
.trim()
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if !guilds.is_empty() {
let ids = guilds
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(", ");
patches.push(("discord".into(), "guild_ids".into(), format!("[{ids}]")));
}
eprintln!(" {}✓ Discord configured{}", s::GREEN, s::RESET);
}
_ => unreachable!(),
}
let existing_remote_enabled = crate::config::load_config_file()
.ok()
.and_then(|c| c.remote.enabled)
.unwrap_or(false);
if !existing_remote_enabled {
patches.push(("remote".into(), "enabled".into(), "true".into()));
eprintln!(" {}[remote] enabled = true{}", s::DIM, s::RESET);
}
crate::config::save_config_secrets(&cf)?;
let patch_refs: Vec<(&str, &str, &str)> = patches
.iter()
.map(|(s, k, v)| (s.as_str(), k.as_str(), v.as_str()))
.collect();
crate::config::patch_config_toml(&path, &patch_refs)?;
eprintln!();
eprintln!("Saved to {}", path.display());
Ok(())
}
pub fn remote_rm(platform: Option<&str>) -> Result<()> {
let platform = platform.unwrap_or_else(|| {
eprintln!("Usage: collet remote rm <telegram|slack|discord>");
std::process::exit(1);
});
let path = crate::config::config_file_path();
let mut cf = crate::config::load_config_file().unwrap_or_default();
match platform {
"telegram" => {
cf.telegram = crate::config::TelegramSection::default();
eprintln!("Removed Telegram configuration.");
}
"slack" => {
cf.slack = crate::config::SlackSection::default();
eprintln!("Removed Slack configuration.");
}
"discord" => {
cf.discord = crate::config::DiscordSection::default();
eprintln!("Removed Discord configuration.");
}
other => {
eprintln!("Unknown platform: {other}");
std::process::exit(1);
}
}
crate::config::write_config_public(&path, &cf)?;
Ok(())
}
pub fn remote_ls() -> Result<()> {
let file = crate::config::load_config_file().unwrap_or_default();
let enabled = file.remote.enabled.unwrap_or(false);
eprintln!(
"Remote gateway: {}",
if enabled { "enabled" } else { "disabled" }
);
eprintln!();
let running = is_remote_running();
if running {
eprintln!(
"Status: running (PID {})",
std::fs::read_to_string(remote_pid_path())
.unwrap_or_default()
.trim()
);
} else {
eprintln!("Status: stopped");
}
eprintln!();
let tg_configured = file.telegram.token.is_some()
|| file.telegram.token_enc.is_some()
|| std::env::var("COLLET_TELEGRAM_TOKEN").is_ok();
eprintln!(
" {} telegram users: {}",
if tg_configured { "✓" } else { "·" },
if file.telegram.allowed_users.is_empty() {
"(none)".to_string()
} else {
format!("{:?}", file.telegram.allowed_users)
},
);
let sl_configured = file.slack.bot_token.is_some()
|| file.slack.bot_token_enc.is_some()
|| std::env::var("COLLET_SLACK_BOT_TOKEN").is_ok();
eprintln!(
" {} slack users: {}",
if sl_configured { "✓" } else { "·" },
if file.slack.allowed_users.is_empty() {
"(none)".to_string()
} else {
format!("{:?}", file.slack.allowed_users)
},
);
let dc_configured = file.discord.token.is_some()
|| file.discord.token_enc.is_some()
|| std::env::var("COLLET_DISCORD_TOKEN").is_ok();
eprintln!(
" {} discord users: {} guilds: {}",
if dc_configured { "✓" } else { "·" },
if file.discord.allowed_users.is_empty() {
"(none)".to_string()
} else {
format!("{:?}", file.discord.allowed_users)
},
if file.discord.guild_ids.is_empty() {
"(all)".to_string()
} else {
format!("{:?}", file.discord.guild_ids)
},
);
if !file.channel_map.is_empty() {
eprintln!();
eprintln!("Channel mappings:");
for m in &file.channel_map {
eprintln!(
" {} #{} → {} ({})",
m.platform,
m.channel,
m.project.as_deref().unwrap_or("(default)"),
if m.name.is_empty() { "-" } else { &m.name }
);
}
}
Ok(())
}
pub async fn remote_start() -> Result<()> {
use crate::remote::adapter::{StreamingLevel, WorkspaceScope};
use std::sync::Arc;
let config = crate::config::Config::load()?;
let file = crate::config::load_config_file().unwrap_or_default();
if !file.remote.enabled.unwrap_or(false) {
eprintln!("Remote gateway is disabled. Enable it first:");
eprintln!(" collet remote add <platform>");
eprintln!(" or set [remote] enabled = true in config.toml");
std::process::exit(1);
}
let auth = crate::remote::auth::AuthConfig::new(
file.telegram.allowed_users.clone(),
file.slack.allowed_users.clone(),
file.discord.allowed_users.clone(),
);
let channel_map = crate::remote::channel_map::ChannelMap::new(file.channel_map.clone());
let default_streaming = file
.remote
.default_streaming
.as_deref()
.and_then(StreamingLevel::parse)
.unwrap_or(StreamingLevel::Compact);
let default_workspace = file
.remote
.default_workspace
.as_deref()
.and_then(WorkspaceScope::parse)
.unwrap_or(WorkspaceScope::Project);
let default_workspace_dir = file.remote.workspace.clone();
let approval_mode = file.remote.approval_mode.clone();
let permissions = file.remote.permissions.clone();
let mut adapters: Vec<Arc<dyn crate::remote::adapter::PlatformAdapter>> = Vec::new();
#[cfg(feature = "telegram")]
{
let token = file
.telegram
.token
.clone()
.or_else(|| {
file.telegram
.token_enc
.as_ref()
.and_then(|e| crate::config::decrypt_key(e).ok())
})
.or_else(|| std::env::var("COLLET_TELEGRAM_TOKEN").ok());
if let Some(token) = token {
adapters.push(Arc::new(crate::remote::telegram::TelegramAdapter::new(
token,
)));
eprintln!(" ✓ Telegram adapter enabled");
}
}
#[cfg(feature = "slack")]
{
let bot_token = file
.slack
.bot_token
.clone()
.or_else(|| {
file.slack
.bot_token_enc
.as_ref()
.and_then(|e| crate::config::decrypt_key(e).ok())
})
.or_else(|| std::env::var("COLLET_SLACK_BOT_TOKEN").ok());
let app_token = file
.slack
.app_token
.clone()
.or_else(|| {
file.slack
.app_token_enc
.as_ref()
.and_then(|e| crate::config::decrypt_key(e).ok())
})
.or_else(|| std::env::var("COLLET_SLACK_APP_TOKEN").ok());
if let (Some(bot), Some(app)) = (bot_token, app_token) {
adapters.push(Arc::new(crate::remote::slack::SlackAdapter::new(bot, app)));
eprintln!(" ✓ Slack adapter enabled");
}
}
#[cfg(feature = "discord")]
{
let token = file
.discord
.token
.clone()
.or_else(|| {
file.discord
.token_enc
.as_ref()
.and_then(|e| crate::config::decrypt_key(e).ok())
})
.or_else(|| std::env::var("COLLET_DISCORD_TOKEN").ok());
if let Some(token) = token {
adapters.push(Arc::new(crate::remote::discord::DiscordAdapter::new(token)));
eprintln!(" ✓ Discord adapter enabled");
}
}
if adapters.is_empty() {
eprintln!("No platform adapters configured.");
eprintln!(" Run: collet remote add");
std::process::exit(1);
}
let pid_path = remote_pid_path();
let _ = tokio::fs::create_dir_all(pid_path.parent().unwrap()).await;
let _ = tokio::fs::write(&pid_path, std::process::id().to_string()).await;
eprintln!(
"Starting remote gateway with {} adapter(s)...",
adapters.len()
);
let gateway = crate::remote::gateway::RemoteGateway::new(
config,
channel_map,
auth,
adapters,
default_streaming,
default_workspace,
default_workspace_dir,
approval_mode,
permissions,
);
let result = tokio::select! {
res = gateway.run() => res,
_ = tokio::signal::ctrl_c() => {
eprintln!("\nShutting down remote gateway...");
Ok(())
}
};
let _ = std::fs::remove_file(&pid_path);
result
}
pub fn remote_start_daemon() -> Result<()> {
#[cfg(target_os = "macos")]
{
let plist_path = super::util::launchd_plist_path();
if plist_path.exists() {
let uid = get_uid();
let status = std::process::Command::new("launchctl")
.args([
"bootstrap",
&format!("gui/{uid}"),
&plist_path.to_string_lossy(),
])
.status();
match status {
Ok(s) if s.success() => eprintln!("Started gateway (launchd)."),
_ => eprintln!("Failed to start gateway via launchd. Is it already running?"),
}
return Ok(());
}
}
#[cfg(target_os = "linux")]
{
let unit_path = super::util::systemd_unit_path();
if unit_path.exists() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "start", "collet-remote"])
.status();
eprintln!("Started gateway (systemd).");
return Ok(());
}
}
remote_start_daemon_direct()
}
fn remote_start_daemon_direct() -> Result<()> {
let lock_path = remote_lock_path();
let _ = std::fs::create_dir_all(lock_path.parent().unwrap_or(std::path::Path::new(".")));
let lock_file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.map_err(|e| anyhow::anyhow!("cannot open daemon lock file: {e}"))?;
if lock_file.try_lock_exclusive().is_err() {
eprintln!("Remote gateway is already running (or another start is in progress).");
eprintln!(" Use: collet remote stop");
return Ok(());
}
let pid_path = remote_pid_path();
if let Ok(pid_str) = std::fs::read_to_string(&pid_path) {
let pid: u32 = pid_str.trim().parse().unwrap_or(0);
if pid > 0 && is_process_running(pid) {
eprintln!("Remote gateway is already running (PID {pid}).");
eprintln!(" Use: collet remote stop");
return Ok(());
}
}
let exe = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("cannot find current executable: {e}"))?;
let log_dir = crate::config::collet_home(None).join("logs");
let _ = std::fs::create_dir_all(&log_dir);
let log_path = crate::config::dated_log_path(&log_dir, "remote", 7);
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|e| anyhow::anyhow!("cannot open log file: {e}"))?;
let log_err = log_file
.try_clone()
.map_err(|e| anyhow::anyhow!("cannot clone log file handle: {e}"))?;
let child = std::process::Command::new(exe)
.args(["remote", "start", "--fg"])
.stdout(log_file)
.stderr(log_err)
.stdin(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn daemon: {e}"))?;
eprintln!("Remote gateway started (PID {}).", child.id());
eprintln!(" Logs: {}", log_path.display());
eprintln!(" Stop: collet remote stop");
Ok(())
}
fn is_process_running(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(not(unix))]
{
let _ = pid;
false
}
}
#[cfg(target_os = "macos")]
fn get_uid() -> String {
std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "501".to_string())
}
#[cfg(target_os = "macos")]
fn launchd_target() -> String {
format!("gui/{}/com.collet.remote", get_uid())
}
pub fn remote_stop() -> Result<()> {
#[cfg(target_os = "macos")]
{
let plist_path = super::util::launchd_plist_path();
if plist_path.exists() {
let target = launchd_target();
let status = std::process::Command::new("launchctl")
.args(["bootout", &target])
.status();
match status {
Ok(s) if s.success() => eprintln!("Stopped gateway (launchd)."),
_ => eprintln!("Gateway may not be running (launchd)."),
}
return Ok(());
}
}
#[cfg(target_os = "linux")]
{
let unit_path = super::util::systemd_unit_path();
if unit_path.exists() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "stop", "collet-remote"])
.status();
eprintln!("Stopped gateway (systemd).");
return Ok(());
}
}
remote_stop_by_pid()
}
fn remote_stop_by_pid() -> Result<()> {
let pid_path = remote_pid_path();
let pid_str = match std::fs::read_to_string(&pid_path) {
Ok(s) => s,
Err(_) => {
eprintln!("No running gateway found (no PID file).");
return Ok(());
}
};
let pid: u32 = pid_str.trim().parse().unwrap_or(0);
if pid == 0 {
eprintln!("Invalid PID file. Removing.");
let _ = std::fs::remove_file(&pid_path);
return Ok(());
}
#[cfg(unix)]
{
let status = std::process::Command::new("kill")
.arg(pid.to_string())
.status();
match status {
Ok(s) if s.success() => {
eprintln!("Stopped gateway (PID {pid}).");
let _ = std::fs::remove_file(&pid_path);
}
_ => {
eprintln!("Failed to stop PID {pid}. Process may have already exited.");
let _ = std::fs::remove_file(&pid_path);
}
}
}
#[cfg(not(unix))]
{
eprintln!("Stop is not supported on this platform. Kill PID {pid} manually.");
}
Ok(())
}
pub fn remote_restart() -> Result<()> {
#[cfg(target_os = "macos")]
{
let plist_path = super::util::launchd_plist_path();
if plist_path.exists() {
let target = launchd_target();
let status = std::process::Command::new("launchctl")
.args(["kickstart", "-k", &target])
.status();
if status.map(|s| s.success()).unwrap_or(false) {
eprintln!("Restarted gateway (launchd).");
return Ok(());
}
let _ = std::process::Command::new("launchctl")
.args(["bootout", &target])
.status();
std::thread::sleep(std::time::Duration::from_secs(1));
let uid = get_uid();
let _ = std::process::Command::new("launchctl")
.args([
"bootstrap",
&format!("gui/{uid}"),
&plist_path.to_string_lossy(),
])
.status();
eprintln!("Restarted gateway (launchd).");
return Ok(());
}
}
#[cfg(target_os = "linux")]
{
let unit_path = super::util::systemd_unit_path();
if unit_path.exists() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "restart", "collet-remote"])
.status();
eprintln!("Restarted gateway (systemd).");
return Ok(());
}
}
let _ = remote_stop_by_pid();
std::thread::sleep(std::time::Duration::from_secs(1));
remote_start_daemon()
}
fn is_remote_running() -> bool {
let pid_path = remote_pid_path();
let pid_str = match std::fs::read_to_string(&pid_path) {
Ok(s) => s,
Err(_) => return false,
};
let pid: u32 = pid_str.trim().parse().unwrap_or(0);
if pid == 0 {
return false;
}
#[cfg(unix)]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
{
false
}
}
pub fn remote_status() -> Result<()> {
let file = crate::config::load_config_file().unwrap_or_default();
let enabled = file.remote.enabled.unwrap_or(false);
eprintln!(
"Remote gateway: {}",
if enabled { "enabled" } else { "disabled" }
);
if is_remote_running() {
let pid = std::fs::read_to_string(remote_pid_path()).unwrap_or_default();
eprintln!("Status: running (PID {})", pid.trim());
} else {
eprintln!("Status: stopped");
}
let plist = super::util::launchd_plist_path();
if plist.exists() {
eprintln!("Auto-start: enabled ({})", plist.display());
} else {
eprintln!("Auto-start: disabled");
}
Ok(())
}
pub fn remote_enable() -> Result<()> {
#[cfg(target_os = "macos")]
{
let plist_path = super::util::launchd_plist_path();
if plist_path.exists() {
eprintln!("Already enabled: {}", plist_path.display());
return Ok(());
}
let exe = std::env::current_exe().map_err(|e| {
crate::common::AgentError::Config(format!("Cannot find executable: {e}"))
})?;
let log = remote_log_path();
let _ = std::fs::create_dir_all(log.parent().unwrap());
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.collet.remote</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>remote</string>
<string>start</string>
<string>--fg</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{}</string>
<key>StandardErrorPath</key>
<string>{}</string>
</dict>
</plist>
"#,
exe.display(),
log.display(),
log.display()
);
let _ = std::fs::create_dir_all(plist_path.parent().unwrap());
std::fs::write(&plist_path, plist).map_err(|e| {
crate::common::AgentError::Config(format!("Failed to write plist: {e}"))
})?;
let _ = std::process::Command::new("launchctl")
.args(["load", &plist_path.to_string_lossy()])
.status();
eprintln!("Enabled auto-start: {}", plist_path.display());
eprintln!("Logs: {}", log.display());
}
#[cfg(target_os = "linux")]
{
let unit_path = super::util::systemd_unit_path();
if unit_path.exists() {
eprintln!("Already enabled: {}", unit_path.display());
return Ok(());
}
let exe = std::env::current_exe().map_err(|e| {
crate::common::AgentError::Config(format!("Cannot find executable: {e}"))
})?;
let unit = format!(
r#"[Unit]
Description=Collet Remote Gateway
After=network.target
[Service]
ExecStart={} remote start --fg
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
"#,
exe.display()
);
let _ = std::fs::create_dir_all(unit_path.parent().unwrap());
std::fs::write(&unit_path, unit).map_err(|e| {
crate::common::AgentError::Config(format!("Failed to write systemd unit: {e}"))
})?;
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
let _ = std::process::Command::new("systemctl")
.args(["--user", "enable", "collet-remote"])
.status();
eprintln!("Enabled auto-start: {}", unit_path.display());
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
eprintln!("Auto-start is not supported on this platform.");
}
Ok(())
}
pub fn remote_disable() -> Result<()> {
#[cfg(target_os = "macos")]
{
let plist_path = super::util::launchd_plist_path();
if !plist_path.exists() {
eprintln!("Not enabled.");
return Ok(());
}
let _ = std::process::Command::new("launchctl")
.args(["unload", &plist_path.to_string_lossy()])
.status();
let _ = std::fs::remove_file(&plist_path);
eprintln!("Disabled auto-start.");
}
#[cfg(target_os = "linux")]
{
let unit_path = super::util::systemd_unit_path();
if !unit_path.exists() {
eprintln!("Not enabled.");
return Ok(());
}
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "collet-remote"])
.status();
let _ = std::fs::remove_file(&unit_path);
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
eprintln!("Disabled auto-start.");
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
eprintln!("Auto-start is not supported on this platform.");
}
Ok(())
}
fn find_latest_log(dir: &std::path::Path, prefix: &str) -> Option<std::path::PathBuf> {
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&format!("{prefix}."))
&& name.ends_with(".log")
&& let Ok(modified) = entry.metadata().and_then(|m| m.modified())
&& best.as_ref().map(|(t, _)| modified > *t).unwrap_or(true)
{
best = Some((modified, entry.path()));
}
}
}
best.map(|(_, p)| p)
}
pub fn remote_logs(follow: bool) -> Result<()> {
let log_dir = crate::config::logs_dir();
let log = find_latest_log(&log_dir, "remote").unwrap_or_else(remote_log_path);
if !log.exists() {
eprintln!("No log file found.");
return Ok(());
}
if follow {
let status = std::process::Command::new("tail")
.args(["-f", &log.to_string_lossy()])
.status()
.map_err(|e| crate::common::AgentError::Config(format!("Failed to tail log: {e}")))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
} else {
let output = std::process::Command::new("tail")
.args(["-50", &log.to_string_lossy()])
.output()
.map_err(|e| crate::common::AgentError::Config(format!("Failed to read log: {e}")))?;
eprint!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
Ok(())
}