use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::VecDeque; use std::env; use std::ffi::OsStr; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; #[cfg(unix)] use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; #[cfg(windows)] use std::os::windows::process::CommandExt; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; fn log_line(message: &str) { println!("[tauri-cli] {message}"); } #[cfg(windows)] fn configure_spawn(command: &mut Command) { command.creation_flags(CREATE_NO_WINDOW); } #[cfg(not(windows))] fn configure_spawn(_command: &mut Command) {} fn workspace_root() -> Option { std::env::current_dir().ok().and_then(|mut dir| { for _ in 0..3 { if let Some(parent) = dir.parent() { dir = parent.to_path_buf(); } } Some(dir) }) } const SESSION_COOKIE_NAME: &str = "codenomad_session"; const CLI_STOP_GRACE_SECS: u64 = 30; #[cfg(windows)] const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000; #[cfg(unix)] fn configure_posix_process_group(command: &mut Command) { // Ensure the CLI runs in its own process group so we can terminate wrapper // processes (login shell/tsx) without leaving the server orphaned. unsafe { command.pre_exec(|| { if libc::setpgid(0, 0) != 0 { return Err(std::io::Error::last_os_error()); } Ok(()) }); } } #[cfg(windows)] fn kill_process_tree_windows(pid: u32, force: bool) -> bool { let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()]; if force { args.push("/F".to_string()); } let mut command = Command::new("taskkill"); command.args(&args); configure_spawn(&mut command); match command.output() { Ok(output) => { if output.status.success() { return true; } // If the PID is already gone, treat it as success. let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase(); let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); let combined = format!("{stdout}\n{stderr}"); combined.contains("not found") || combined.contains("no running instance") } Err(_) => false, } } fn navigate_main(app: &AppHandle, url: &str) { if let Some(win) = app.webview_windows().get("main") { let mut display = url.to_string(); if let Some(hash_index) = display.find('#') { display.replace_range(hash_index + 1.., "[REDACTED]"); } log_line(&format!("navigating main to {display}")); if let Ok(parsed) = Url::parse(url) { let _ = win.navigate(parsed); } else { log_line("failed to parse URL for navigation"); } } else { log_line("main window not found for navigation"); } } fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { let prefix = format!("{name}="); let cookie_kv = set_cookie.split(';').next()?.trim(); if !cookie_kv.starts_with(&prefix) { return None; } let value = cookie_kv.trim_start_matches(&prefix).trim(); if value.is_empty() { return None; } Some(value.to_string()) } fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result> { let parsed = Url::parse(base_url)?; let host = parsed.host_str().unwrap_or("127.0.0.1"); let port = parsed.port_or_known_default().unwrap_or(80); // This is only used for local bootstrap; we assume plain HTTP. let mut stream = TcpStream::connect((host, port))?; let body = format!("{{\"token\":\"{}\"}}", token); let request = format!( "POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.as_bytes().len(), body ); stream.write_all(request.as_bytes())?; stream.flush()?; let mut response = String::new(); stream.read_to_string(&mut response)?; let (raw_headers, _rest) = response .split_once("\r\n\r\n") .or_else(|| response.split_once("\n\n")) .unwrap_or((response.as_str(), "")); let mut lines = raw_headers.lines(); let status_line = lines.next().unwrap_or(""); if !status_line.contains(" 200 ") { return Ok(None); } for line in lines { // handle case-insensitive header name if let Some(value) = line.strip_prefix("Set-Cookie:") { if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) { return Ok(Some(session_id)); } } else if let Some(value) = line.strip_prefix("set-cookie:") { if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) { return Ok(Some(session_id)); } } } Ok(None) } fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> { let parsed = Url::parse(base_url)?; let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string(); let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id)) .domain(domain) .path("/") .http_only(true) .same_site(tauri::webview::cookie::SameSite::Lax) .build(); if let Some(win) = app.webview_windows().get("main") { win.set_cookie(cookie)?; } Ok(()) } const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] struct PreferencesConfig { #[serde(rename = "listeningMode")] listening_mode: Option, } #[derive(Debug, Deserialize)] struct ServerConfig { #[serde(rename = "listeningMode")] listening_mode: Option, } #[derive(Debug, Deserialize)] struct AppConfig { preferences: Option, server: Option, } fn resolve_config_locations() -> (PathBuf, PathBuf) { let raw = env::var("CLI_CONFIG") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); let expanded = expand_home(&raw); let lower = raw.trim().to_lowercase(); if lower.ends_with(".yaml") || lower.ends_with(".yml") { let base = expanded .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| expanded.clone()); return (expanded, base.join("config.json")); } if lower.ends_with(".json") { let base = expanded .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| expanded.clone()); return (base.join("config.yaml"), expanded); } // Treat as directory. (expanded.join("config.yaml"), expanded.join("config.json")) } fn expand_home(path: &str) -> PathBuf { if path.starts_with("~/") { if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) { return home.join(path.trim_start_matches("~/")); } } PathBuf::from(path) } fn resolve_listening_mode() -> String { let (yaml_path, json_path) = resolve_config_locations(); if let Ok(content) = fs::read_to_string(&yaml_path) { if let Ok(config) = serde_yaml::from_str::(&content) { let mode = config .server .as_ref() .and_then(|srv| srv.listening_mode.as_ref()) .or_else(|| { config .preferences .as_ref() .and_then(|prefs| prefs.listening_mode.as_ref()) }); if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } if mode == "all" { return "all".to_string(); } } } } // Legacy fallback. if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(config) = serde_json::from_str::(&content) { let mode = config .server .as_ref() .and_then(|srv| srv.listening_mode.as_ref()) .or_else(|| { config .preferences .as_ref() .and_then(|prefs| prefs.listening_mode.as_ref()) }); if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } if mode == "all" { return "all".to_string(); } } } } "local".to_string() } fn resolve_listening_host() -> String { let mode = resolve_listening_mode(); if mode == "local" { "127.0.0.1".to_string() } else { "0.0.0.0".to_string() } } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CliState { Starting, Ready, Error, Stopped, } #[derive(Debug, Clone, Serialize)] pub struct CliStatus { pub state: CliState, pub pid: Option, pub port: Option, pub url: Option, pub error: Option, } impl Default for CliStatus { fn default() -> Self { Self { state: CliState::Stopped, pid: None, port: None, url: None, error: None, } } } #[derive(Debug, Clone)] pub struct CliProcessManager { status: Arc>, child: Arc>>, ready: Arc, bootstrap_token: Arc>>, } impl CliProcessManager { pub fn new() -> Self { Self { status: Arc::new(Mutex::new(CliStatus::default())), child: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), } } pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> { log_line(&format!("start requested (dev={dev})")); self.stop()?; self.ready.store(false, Ordering::SeqCst); *self.bootstrap_token.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; status.port = None; status.url = None; status.error = None; status.pid = None; } Self::emit_status(&app, &self.status.lock()); let status_arc = self.status.clone(); let child_arc = self.child.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); thread::spawn(move || { if let Err(err) = Self::spawn_cli( app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev, ) { log_line(&format!("cli spawn failed: {err}")); let mut locked = status_arc.lock(); locked.state = CliState::Error; locked.error = Some(err.to_string()); let snapshot = locked.clone(); drop(locked); let _ = app.emit("cli:error", json!({"message": err.to_string()})); let _ = app.emit("cli:status", snapshot); } }); Ok(()) } pub fn stop(&self) -> anyhow::Result<()> { let mut child_opt = self.child.lock(); if let Some(mut child) = child_opt.take() { log_line(&format!("stopping CLI pid={}", child.id())); #[cfg(windows)] let mut forced_tree_shutdown = false; #[cfg(unix)] unsafe { let pid = child.id() as i32; // Prefer signaling the process group to avoid orphaning children // when the CLI was launched via a wrapper shell. let group_res = libc::kill(-pid, libc::SIGTERM); if group_res != 0 { let _ = libc::kill(pid, libc::SIGTERM); } } #[cfg(windows)] { let _ = kill_process_tree_windows(child.id(), false); } let start = Instant::now(); loop { match child.try_wait() { Ok(Some(_)) => break, Ok(None) => { #[cfg(windows)] if !forced_tree_shutdown && start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) { log_line(&format!( "regular Windows shutdown still running after {}ms; escalating pid={}", CLI_WINDOWS_FORCE_GRACE_MS, child.id() )); forced_tree_shutdown = true; if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } } if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { log_line(&format!( "stop timed out after {}s; sending SIGKILL pid={}", CLI_STOP_GRACE_SECS, child.id() )); #[cfg(unix)] unsafe { let pid = child.id() as i32; let group_res = libc::kill(-pid, libc::SIGKILL); if group_res != 0 { let _ = libc::kill(pid, libc::SIGKILL); } } #[cfg(windows)] { if !forced_tree_shutdown && !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } else if forced_tree_shutdown { let _ = child.kill(); } } break; } thread::sleep(Duration::from_millis(50)); } Err(_) => break, } } } let mut status = self.status.lock(); status.state = CliState::Stopped; status.pid = None; status.port = None; status.url = None; status.error = None; Ok(()) } pub fn status(&self) -> CliStatus { self.status.lock().clone() } fn spawn_cli( app: AppHandle, status: Arc>, child_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, dev: bool, ) -> anyhow::Result<()> { log_line("resolving CLI entry"); let resolution = CliEntry::resolve(&app, dev)?; let host = resolve_listening_host(); log_line(&format!( "resolved CLI entry runner={:?} entry={} host={}", resolution.runner, resolution.entry, host )); let args = resolution.build_args(dev, &host); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); } let cwd = workspace_root(); if let Some(ref c) = cwd { log_line(&format!("using cwd={}", c.display())); } let command_info = if supports_user_shell() { log_line("spawning via user shell"); ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) } else { log_line("spawning directly with node"); ShellCommandType::Direct(DirectCommand { program: resolution.node_binary.clone(), args: resolution.runner_args(&args), }) }; if !supports_user_shell() { if which::which(&resolution.node_binary).is_err() { return Err(anyhow::anyhow!( "Node binary not found. Make sure Node.js is installed." )); } } let child = match &command_info { ShellCommandType::UserShell(cmd) => { log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args)); let mut c = Command::new(&cmd.shell); c.args(&cmd.args) .env("ELECTRON_RUN_AS_NODE", "1") .stdout(Stdio::piped()) .stderr(Stdio::piped()); configure_spawn(&mut c); if let Some(ref cwd) = cwd { c.current_dir(cwd); } #[cfg(unix)] configure_posix_process_group(&mut c); c.spawn()? } ShellCommandType::Direct(cmd) => { log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args)); let mut c = Command::new(&cmd.program); c.args(&cmd.args) .env("ELECTRON_RUN_AS_NODE", "1") .stdout(Stdio::piped()) .stderr(Stdio::piped()); configure_spawn(&mut c); if let Some(ref cwd) = cwd { c.current_dir(cwd); } #[cfg(unix)] configure_posix_process_group(&mut c); c.spawn()? } }; let pid = child.id(); log_line(&format!("spawned pid={pid}")); { let mut locked = status.lock(); locked.pid = Some(pid); } Self::emit_status(&app, &status.lock()); { let mut holder = child_holder.lock(); *holder = Some(child); } let child_clone = child_holder.clone(); let status_clone = status.clone(); let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); thread::spawn(move || { let stdout = child_clone .lock() .as_mut() .and_then(|c| c.stdout.take()) .map(BufReader::new); let stderr = child_clone .lock() .as_mut() .and_then(|c| c.stderr.take()) .map(BufReader::new); if let Some(reader) = stdout { Self::process_stream( reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone, ); } if let Some(reader) = stderr { Self::process_stream( reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone, ); } }); let app_clone = app.clone(); let status_clone = status.clone(); let ready_clone = ready.clone(); let child_holder_clone = child_holder.clone(); thread::spawn(move || { let timeout = Duration::from_secs(60); thread::sleep(timeout); if ready_clone.load(Ordering::SeqCst) { return; } let mut locked = status_clone.lock(); locked.state = CliState::Error; locked.error = Some("CLI did not start in time".to_string()); log_line("timeout waiting for CLI readiness"); if let Some(child) = child_holder_clone.lock().as_mut() { #[cfg(unix)] unsafe { let pid = child.id() as i32; let group_res = libc::kill(-pid, libc::SIGKILL); if group_res != 0 { let _ = libc::kill(pid, libc::SIGKILL); } } #[cfg(windows)] { if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } } #[cfg(not(any(unix, windows)))] { let _ = child.kill(); } } let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"})); Self::emit_status(&app_clone, &locked); }); let status_clone = status.clone(); let app_clone = app.clone(); thread::spawn(move || { // Do not hold the child mutex while waiting for process exit. // Holding the lock across `wait()` deadlocks `stop()`, which needs the // same lock to send SIGTERM/SIGKILL when the user quits the app. let code = loop { let maybe_exited = { let mut guard = child_holder.lock(); if guard.is_none() { return; } match guard .as_mut() .and_then(|child| child.try_wait().ok().flatten()) { Some(status) => { // Drop the handle after the process exits so other callers // don't attempt to stop/kill a finished process. *guard = None; Some(status) } None => None, } }; if let Some(status) = maybe_exited { break Some(status); } thread::sleep(Duration::from_millis(100)); }; let mut locked = status_clone.lock(); let failed = locked.state != CliState::Ready; let err_msg = if failed { Some(match code { Some(status) => format!("CLI exited early: {status}"), None => "CLI exited early".to_string(), }) } else { None }; if failed { locked.state = CliState::Error; if locked.error.is_none() { locked.error = err_msg.clone(); } log_line(&format!( "cli process exited before ready: {:?}", locked.error )); let _ = app_clone.emit( "cli:error", json!({"message": locked.error.clone().unwrap_or_default()}), ); } else { locked.state = CliState::Stopped; log_line("cli process stopped cleanly"); } Self::emit_status(&app_clone, &locked); }); Ok(()) } fn process_stream( mut reader: R, stream: &str, app: &AppHandle, status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, ) { let mut buffer = String::new(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok(); let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; loop { buffer.clear(); match reader.read_line(&mut buffer) { Ok(0) => break, Ok(_) => { let line = buffer.trim_end(); if !line.is_empty() { if line.starts_with(token_prefix) { let token = line.trim_start_matches(token_prefix).trim(); if !token.is_empty() { let mut guard = bootstrap_token.lock(); if guard.is_none() { *guard = Some(token.to_string()); } } continue; } log_line(&format!("[cli][{}] {}", stream, line)); if ready.load(Ordering::SeqCst) { continue; } if let Some(url) = local_url_regex .as_ref() .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .map(|m| m.as_str().to_string()) { Self::mark_ready(app, status, ready, bootstrap_token, url); continue; } if line.to_lowercase().contains("http server listening") { if let Some(port) = http_regex .as_ref() .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|m| m.as_str().parse::().ok()) { Self::mark_ready( app, status, ready, bootstrap_token, format!("http://localhost:{port}"), ); continue; } if let Ok(value) = serde_json::from_str::(line) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { Self::mark_ready( app, status, ready, bootstrap_token, format!("http://localhost:{}", port), ); continue; } } } } } Err(_) => break, } } } fn mark_ready( app: &AppHandle, status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, base_url: String, ) { ready.store(true, Ordering::SeqCst); let port = Url::parse(&base_url) .ok() .and_then(|u| u.port_or_known_default()) .map(|p| p as u16); let mut locked = status.lock(); locked.port = port; locked.url = Some(base_url.clone()); locked.state = CliState::Ready; locked.error = None; log_line(&format!("cli ready on {base_url}")); let token = bootstrap_token.lock().take(); if let Some(token) = token { // Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS, // skip the exchange and let the user authenticate normally. let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string()); if scheme.as_deref() != Some("http") { navigate_main(app, &base_url); } else { match exchange_bootstrap_token(&base_url, &token) { Ok(Some(session_id)) => { if let Err(err) = set_session_cookie(app, &base_url, &session_id) { log_line(&format!("failed to set session cookie: {err}")); navigate_main(app, &format!("{base_url}/login")); } else { navigate_main(app, &base_url); } } Ok(None) => { log_line("bootstrap token exchange failed (invalid token)"); navigate_main(app, &format!("{base_url}/login")); } Err(err) => { log_line(&format!("bootstrap token exchange failed: {err}")); navigate_main(app, &format!("{base_url}/login")); } } } } else { navigate_main(app, &base_url); } let _ = app.emit("cli:ready", locked.clone()); Self::emit_status(app, &locked); } fn emit_status(app: &AppHandle, status: &CliStatus) { let _ = app.emit("cli:status", status.clone()); } } fn supports_user_shell() -> bool { cfg!(unix) } #[derive(Debug)] struct ShellCommand { shell: String, args: Vec, } #[derive(Debug)] struct DirectCommand { program: String, args: Vec, } #[derive(Debug)] enum ShellCommandType { UserShell(ShellCommand), Direct(DirectCommand), } #[derive(Debug)] struct CliEntry { entry: String, runner: Runner, runner_path: Option, node_binary: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Runner { Node, Tsx, } impl CliEntry { fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result { let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string()); if dev { if let Some(tsx_path) = resolve_tsx(app) { if let Some(entry) = resolve_dev_entry(app) { return Ok(Self { entry, runner: Runner::Tsx, runner_path: Some(tsx_path), node_binary, }); } } } if let Some(entry) = resolve_dist_entry(app) { return Ok(Self { entry, runner: Runner::Node, runner_path: None, node_binary, }); } Err(anyhow::anyhow!( "Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad." )) } fn build_args(&self, dev: bool, host: &str) -> Vec { let mut args = vec![ "serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string(), ]; if dev { // Dev: plain HTTP + Vite dev server proxy. let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL") .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| { std::env::var("ELECTRON_RENDERER_URL") .ok() .filter(|value| !value.trim().is_empty()) }) .unwrap_or_else(|| "http://localhost:3000".to_string()); let log_level = std::env::var("CLI_LOG_LEVEL") .ok() .map(|value| value.trim().to_lowercase()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "info".to_string()); args.push("--https".to_string()); args.push("false".to_string()); args.push("--http".to_string()); args.push("true".to_string()); args.push("--http-port".to_string()); args.push("0".to_string()); args.push("--ui-dev-server".to_string()); args.push(ui_dev_server); args.push("--log-level".to_string()); args.push(log_level); } else { // Prod desktop: always keep loopback HTTP enabled. args.push("--https".to_string()); args.push("true".to_string()); args.push("--http".to_string()); args.push("true".to_string()); } args } fn runner_args(&self, cli_args: &[String]) -> Vec { let mut args = VecDeque::new(); if self.runner == Runner::Tsx { if let Some(path) = &self.runner_path { args.push_back(path.clone()); } } args.push_back(self.entry.clone()); for arg in cli_args { args.push_back(arg.clone()); } args.into_iter().collect() } } fn resolve_tsx(_app: &AppHandle) -> Option { let candidates = vec![ std::env::current_dir() .ok() .map(|p| p.join("node_modules/tsx/dist/cli.js")), std::env::current_exe().ok().and_then(|ex| { ex.parent() .map(|p| p.join("../node_modules/tsx/dist/cli.js")) }), ]; first_existing(candidates) } fn resolve_dev_entry(_app: &AppHandle) -> Option { let candidates = vec![ std::env::current_dir() .ok() .map(|p| p.join("packages/server/src/index.ts")), std::env::current_dir() .ok() .map(|p| p.join("../server/src/index.ts")), ]; first_existing(candidates) } fn resolve_dist_entry(_app: &AppHandle) -> Option { let base = workspace_root(); let mut candidates: Vec> = vec![ base.as_ref().map(|p| p.join("packages/server/dist/bin.js")), base.as_ref() .map(|p| p.join("packages/server/dist/index.js")), base.as_ref().map(|p| p.join("server/dist/bin.js")), base.as_ref().map(|p| p.join("server/dist/index.js")), ]; if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { candidates.push(Some(dir.join("resources/server/dist/bin.js"))); candidates.push(Some(dir.join("resources/server/dist/index.js"))); candidates.push(Some(dir.join("resources/server/dist/server/bin.js"))); candidates.push(Some(dir.join("resources/server/dist/server/index.js"))); let resources = dir.join("../Resources"); candidates.push(Some(resources.join("server/dist/bin.js"))); candidates.push(Some(resources.join("server/dist/index.js"))); candidates.push(Some(resources.join("server/dist/server/bin.js"))); candidates.push(Some(resources.join("server/dist/server/index.js"))); candidates.push(Some(resources.join("resources/server/dist/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/index.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); candidates.push(Some( resources.join("resources/server/dist/server/index.js"), )); let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; for root in linux_resource_roots { candidates.push(Some(root.join("server/dist/bin.js"))); candidates.push(Some(root.join("server/dist/index.js"))); candidates.push(Some(root.join("server/dist/server/bin.js"))); candidates.push(Some(root.join("server/dist/server/index.js"))); candidates.push(Some(root.join("resources/server/dist/bin.js"))); candidates.push(Some(root.join("resources/server/dist/index.js"))); candidates.push(Some(root.join("resources/server/dist/server/bin.js"))); candidates.push(Some(root.join("resources/server/dist/server/index.js"))); } } } first_existing(candidates) } fn build_shell_command_string( entry: &CliEntry, cli_args: &[String], ) -> anyhow::Result { let shell = default_shell(); let mut quoted: Vec = Vec::new(); quoted.push(shell_escape(&entry.node_binary)); for arg in entry.runner_args(cli_args) { quoted.push(shell_escape(&arg)); } let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" ")); let args = build_shell_args(&shell, &command); log_line(&format!("user shell command: {} {:?}", shell, args)); Ok(ShellCommand { shell, args }) } fn default_shell() -> String { if let Ok(shell) = std::env::var("SHELL") { if !shell.trim().is_empty() { return shell; } } if cfg!(target_os = "macos") { "/bin/zsh".to_string() } else { "/bin/bash".to_string() } } fn shell_escape(input: &str) -> String { if input.is_empty() { "''".to_string() } else if !input .chars() .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!')) { input.to_string() } else { let escaped = input.replace('\'', "'\\''"); format!("'{}'", escaped) } } fn build_shell_args(shell: &str, command: &str) -> Vec { let shell_name = std::path::Path::new(shell) .file_name() .and_then(OsStr::to_str) .unwrap_or("") .to_lowercase(); if shell_name.contains("zsh") { vec!["-l".into(), "-i".into(), "-c".into(), command.into()] } else { vec!["-l".into(), "-c".into(), command.into()] } } fn first_existing(paths: Vec>) -> Option { paths .into_iter() .flatten() .find(|p| p.exists()) .map(|p| normalize_path(p)) } fn normalize_path(path: PathBuf) -> String { let resolved = if let Ok(clean) = path.canonicalize() { clean } else { path }; let rendered = resolved.to_string_lossy().to_string(); if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") { format!("\\\\{}", stripped) } else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") { stripped.to_string() } else { rendered } }