cli.rs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. use futures::{FutureExt, Stream, StreamExt, future};
  2. use process_wrap::tokio::CommandWrap;
  3. #[cfg(unix)]
  4. use process_wrap::tokio::ProcessGroup;
  5. #[cfg(windows)]
  6. use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
  7. use std::collections::HashMap;
  8. #[cfg(unix)]
  9. use std::os::unix::process::ExitStatusExt;
  10. use std::path::Path;
  11. use std::process::Stdio;
  12. use std::sync::Arc;
  13. use std::time::{Duration, Instant};
  14. use tauri::{AppHandle, Manager, path::BaseDirectory};
  15. use tauri_specta::Event;
  16. use tokio::{
  17. io::{AsyncBufRead, AsyncBufReadExt, BufReader},
  18. process::Command,
  19. sync::{mpsc, oneshot},
  20. task::JoinHandle,
  21. };
  22. use tokio_stream::wrappers::ReceiverStream;
  23. use tracing::Instrument;
  24. #[cfg(windows)]
  25. use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
  26. use crate::server::get_wsl_config;
  27. #[cfg(windows)]
  28. #[derive(Clone, Copy, Debug)]
  29. // Keep this as a custom wrapper instead of process_wrap::CreationFlags.
  30. // JobObject pre_spawn rewrites creation flags, so this must run after it.
  31. struct WinCreationFlags;
  32. #[cfg(windows)]
  33. impl CommandWrapper for WinCreationFlags {
  34. fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
  35. command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
  36. Ok(())
  37. }
  38. }
  39. const CLI_INSTALL_DIR: &str = ".opencode/bin";
  40. const CLI_BINARY_NAME: &str = "opencode";
  41. const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
  42. #[derive(serde::Deserialize, Debug)]
  43. pub struct ServerConfig {
  44. pub hostname: Option<String>,
  45. pub port: Option<u32>,
  46. }
  47. #[derive(serde::Deserialize, Debug)]
  48. pub struct Config {
  49. pub server: Option<ServerConfig>,
  50. }
  51. #[derive(Clone, Debug)]
  52. pub enum CommandEvent {
  53. Stdout(String),
  54. Stderr(String),
  55. Error(String),
  56. Terminated(TerminatedPayload),
  57. }
  58. #[derive(Clone, Copy, Debug)]
  59. pub struct TerminatedPayload {
  60. pub code: Option<i32>,
  61. pub signal: Option<i32>,
  62. }
  63. #[derive(Clone, Debug)]
  64. pub struct CommandChild {
  65. kill: mpsc::Sender<()>,
  66. }
  67. impl CommandChild {
  68. pub fn kill(&self) -> std::io::Result<()> {
  69. self.kill
  70. .try_send(())
  71. .map_err(|e| std::io::Error::other(e.to_string()))
  72. }
  73. }
  74. pub async fn get_config(app: &AppHandle) -> Option<Config> {
  75. let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
  76. events
  77. .fold(String::new(), async |mut config_str, event| {
  78. if let CommandEvent::Stdout(s) = &event {
  79. config_str += s.as_str()
  80. }
  81. if let CommandEvent::Stderr(s) = &event {
  82. config_str += s.as_str()
  83. }
  84. config_str
  85. })
  86. .map(|v| serde_json::from_str::<Config>(&v))
  87. .await
  88. .ok()
  89. }
  90. fn get_cli_install_path() -> Option<std::path::PathBuf> {
  91. std::env::var("HOME").ok().map(|home| {
  92. std::path::PathBuf::from(home)
  93. .join(CLI_INSTALL_DIR)
  94. .join(CLI_BINARY_NAME)
  95. })
  96. }
  97. pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
  98. // Get binary with symlinks support
  99. tauri::process::current_binary(&app.env())
  100. .expect("Failed to get current binary")
  101. .parent()
  102. .expect("Failed to get parent dir")
  103. .join("opencode-cli")
  104. }
  105. fn is_cli_installed() -> bool {
  106. get_cli_install_path()
  107. .map(|path| path.exists())
  108. .unwrap_or(false)
  109. }
  110. const INSTALL_SCRIPT: &str = include_str!("../../../../install");
  111. #[tauri::command]
  112. #[specta::specta]
  113. pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
  114. if cfg!(not(unix)) {
  115. return Err("CLI installation is only supported on macOS & Linux".to_string());
  116. }
  117. let sidecar = get_sidecar_path(&app);
  118. if !sidecar.exists() {
  119. return Err("Sidecar binary not found".to_string());
  120. }
  121. let temp_script = std::env::temp_dir().join("opencode-install.sh");
  122. std::fs::write(&temp_script, INSTALL_SCRIPT)
  123. .map_err(|e| format!("Failed to write install script: {}", e))?;
  124. #[cfg(unix)]
  125. {
  126. use std::os::unix::fs::PermissionsExt;
  127. std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
  128. .map_err(|e| format!("Failed to set script permissions: {}", e))?;
  129. }
  130. let output = std::process::Command::new(&temp_script)
  131. .arg("--binary")
  132. .arg(&sidecar)
  133. .output()
  134. .map_err(|e| format!("Failed to run install script: {}", e))?;
  135. let _ = std::fs::remove_file(&temp_script);
  136. if !output.status.success() {
  137. let stderr = String::from_utf8_lossy(&output.stderr);
  138. return Err(format!("Install script failed: {}", stderr));
  139. }
  140. let install_path =
  141. get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
  142. Ok(install_path.to_string_lossy().to_string())
  143. }
  144. pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
  145. if cfg!(debug_assertions) {
  146. tracing::debug!("Skipping CLI sync for debug build");
  147. return Ok(());
  148. }
  149. if !is_cli_installed() {
  150. tracing::info!("No CLI installation found, skipping sync");
  151. return Ok(());
  152. }
  153. let cli_path =
  154. get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
  155. let output = std::process::Command::new(&cli_path)
  156. .arg("--version")
  157. .output()
  158. .map_err(|e| format!("Failed to get CLI version: {}", e))?;
  159. if !output.status.success() {
  160. return Err("Failed to get CLI version".to_string());
  161. }
  162. let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
  163. let cli_version = semver::Version::parse(&cli_version_str)
  164. .map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
  165. let app_version = app.package_info().version.clone();
  166. if cli_version >= app_version {
  167. tracing::info!(
  168. %cli_version, %app_version,
  169. "CLI is up to date, skipping sync"
  170. );
  171. return Ok(());
  172. }
  173. tracing::info!(
  174. %cli_version, %app_version,
  175. "CLI is older than app version, syncing"
  176. );
  177. install_cli(app)?;
  178. tracing::info!("Synced installed CLI");
  179. Ok(())
  180. }
  181. fn get_user_shell() -> String {
  182. std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
  183. }
  184. fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
  185. get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
  186. }
  187. fn shell_escape(input: &str) -> String {
  188. if input.is_empty() {
  189. return "''".to_string();
  190. }
  191. let mut escaped = String::from("'");
  192. escaped.push_str(&input.replace("'", "'\"'\"'"));
  193. escaped.push('\'');
  194. escaped
  195. }
  196. fn parse_shell_env(stdout: &[u8]) -> HashMap<String, String> {
  197. String::from_utf8_lossy(stdout)
  198. .split('\0')
  199. .filter_map(|line| {
  200. if line.is_empty() {
  201. return None;
  202. }
  203. let (key, value) = line.split_once('=')?;
  204. if key.is_empty() {
  205. return None;
  206. }
  207. Some((key.to_string(), value.to_string()))
  208. })
  209. .collect()
  210. }
  211. fn command_output_with_timeout(
  212. mut cmd: std::process::Command,
  213. timeout: Duration,
  214. ) -> std::io::Result<Option<std::process::Output>> {
  215. let mut child = cmd.spawn()?;
  216. let start = Instant::now();
  217. loop {
  218. if child.try_wait()?.is_some() {
  219. return child.wait_with_output().map(Some);
  220. }
  221. if start.elapsed() >= timeout {
  222. let _ = child.kill();
  223. let _ = child.wait();
  224. return Ok(None);
  225. }
  226. std::thread::sleep(Duration::from_millis(25));
  227. }
  228. }
  229. enum ShellEnvProbe {
  230. Loaded(HashMap<String, String>),
  231. Timeout,
  232. Unavailable,
  233. }
  234. fn probe_shell_env(shell: &str, mode: &str) -> ShellEnvProbe {
  235. let mut cmd = std::process::Command::new(shell);
  236. cmd.args([mode, "-c", "env -0"]);
  237. cmd.stdin(Stdio::null());
  238. cmd.stdout(Stdio::piped());
  239. cmd.stderr(Stdio::null());
  240. let output = match command_output_with_timeout(cmd, SHELL_ENV_TIMEOUT) {
  241. Ok(Some(output)) => output,
  242. Ok(None) => return ShellEnvProbe::Timeout,
  243. Err(error) => {
  244. tracing::debug!(shell, mode, ?error, "Shell env probe failed");
  245. return ShellEnvProbe::Unavailable;
  246. }
  247. };
  248. if !output.status.success() {
  249. tracing::debug!(shell, mode, "Shell env probe exited with non-zero status");
  250. return ShellEnvProbe::Unavailable;
  251. }
  252. let env = parse_shell_env(&output.stdout);
  253. if env.is_empty() {
  254. tracing::debug!(shell, mode, "Shell env probe returned empty env");
  255. return ShellEnvProbe::Unavailable;
  256. }
  257. ShellEnvProbe::Loaded(env)
  258. }
  259. fn is_nushell(shell: &str) -> bool {
  260. let shell_name = Path::new(shell)
  261. .file_name()
  262. .and_then(|name| name.to_str())
  263. .unwrap_or(shell)
  264. .to_ascii_lowercase();
  265. shell_name == "nu" || shell_name == "nu.exe" || shell.to_ascii_lowercase().ends_with("\\nu.exe")
  266. }
  267. fn load_shell_env(shell: &str) -> Option<HashMap<String, String>> {
  268. if is_nushell(shell) {
  269. tracing::debug!(shell, "Skipping shell env probe for nushell");
  270. return None;
  271. }
  272. match probe_shell_env(shell, "-il") {
  273. ShellEnvProbe::Loaded(env) => {
  274. tracing::info!(
  275. shell,
  276. env_count = env.len(),
  277. "Loaded shell environment with -il"
  278. );
  279. return Some(env);
  280. }
  281. ShellEnvProbe::Timeout => {
  282. tracing::warn!(shell, "Interactive shell env probe timed out");
  283. return None;
  284. }
  285. ShellEnvProbe::Unavailable => {}
  286. }
  287. if let ShellEnvProbe::Loaded(env) = probe_shell_env(shell, "-l") {
  288. tracing::info!(
  289. shell,
  290. env_count = env.len(),
  291. "Loaded shell environment with -l"
  292. );
  293. return Some(env);
  294. }
  295. tracing::warn!(shell, "Falling back to app environment");
  296. None
  297. }
  298. fn merge_shell_env(
  299. shell_env: Option<HashMap<String, String>>,
  300. envs: Vec<(String, String)>,
  301. ) -> Vec<(String, String)> {
  302. let mut merged = shell_env.unwrap_or_default();
  303. for (key, value) in envs {
  304. merged.insert(key, value);
  305. }
  306. merged.into_iter().collect()
  307. }
  308. pub fn spawn_command(
  309. app: &tauri::AppHandle,
  310. args: &str,
  311. extra_env: &[(&str, String)],
  312. ) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), std::io::Error> {
  313. let state_dir = app
  314. .path()
  315. .resolve("", BaseDirectory::AppLocalData)
  316. .expect("Failed to resolve app local data dir");
  317. let mut envs = vec![
  318. (
  319. "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
  320. "true".to_string(),
  321. ),
  322. (
  323. "OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
  324. "true".to_string(),
  325. ),
  326. ("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
  327. (
  328. "XDG_STATE_HOME".to_string(),
  329. state_dir.to_string_lossy().to_string(),
  330. ),
  331. ];
  332. envs.extend(
  333. extra_env
  334. .iter()
  335. .map(|(key, value)| (key.to_string(), value.clone())),
  336. );
  337. let mut cmd = if cfg!(windows) {
  338. if is_wsl_enabled(app) {
  339. tracing::info!("WSL is enabled, spawning CLI server in WSL");
  340. let version = app.package_info().version.to_string();
  341. let mut script = vec![
  342. "set -e".to_string(),
  343. "BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
  344. "if [ ! -x \"$BIN\" ]; then".to_string(),
  345. format!(
  346. " curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
  347. shell_escape(&version)
  348. ),
  349. "fi".to_string(),
  350. ];
  351. let mut env_prefix = vec![
  352. "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
  353. "OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
  354. "OPENCODE_CLIENT=desktop".to_string(),
  355. "XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
  356. ];
  357. env_prefix.extend(
  358. envs.iter()
  359. .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
  360. .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
  361. .filter(|(key, _)| key != "OPENCODE_CLIENT")
  362. .filter(|(key, _)| key != "XDG_STATE_HOME")
  363. .map(|(key, value)| format!("{}={}", key, shell_escape(value))),
  364. );
  365. script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
  366. let mut cmd = Command::new("wsl");
  367. cmd.args(["-e", "bash", "-lc", &script.join("\n")]);
  368. cmd
  369. } else {
  370. let sidecar = get_sidecar_path(app);
  371. let mut cmd = Command::new(sidecar);
  372. cmd.args(args.split_whitespace());
  373. for (key, value) in envs {
  374. cmd.env(key, value);
  375. }
  376. cmd
  377. }
  378. } else {
  379. let sidecar = get_sidecar_path(app);
  380. let shell = get_user_shell();
  381. let envs = merge_shell_env(load_shell_env(&shell), envs);
  382. let line = if shell.ends_with("/nu") {
  383. format!("^\"{}\" {}", sidecar.display(), args)
  384. } else {
  385. format!("\"{}\" {}", sidecar.display(), args)
  386. };
  387. let mut cmd = Command::new(shell);
  388. cmd.args(["-l", "-c", &line]);
  389. for (key, value) in envs {
  390. cmd.env(key, value);
  391. }
  392. cmd
  393. };
  394. cmd.stdout(Stdio::piped());
  395. cmd.stderr(Stdio::piped());
  396. cmd.stdin(Stdio::null());
  397. let mut wrap = CommandWrap::from(cmd);
  398. #[cfg(unix)]
  399. {
  400. wrap.wrap(ProcessGroup::leader());
  401. }
  402. #[cfg(windows)]
  403. {
  404. wrap.wrap(JobObject).wrap(WinCreationFlags).wrap(KillOnDrop);
  405. }
  406. let mut child = wrap.spawn()?;
  407. let guard = Arc::new(tokio::sync::RwLock::new(()));
  408. let (tx, rx) = mpsc::channel(256);
  409. let (kill_tx, mut kill_rx) = mpsc::channel(1);
  410. let stdout = spawn_pipe_reader(
  411. tx.clone(),
  412. guard.clone(),
  413. BufReader::new(child.stdout().take().unwrap()),
  414. CommandEvent::Stdout,
  415. );
  416. let stderr = spawn_pipe_reader(
  417. tx.clone(),
  418. guard.clone(),
  419. BufReader::new(child.stderr().take().unwrap()),
  420. CommandEvent::Stderr,
  421. );
  422. tokio::task::spawn(async move {
  423. let mut kill_open = true;
  424. let status = loop {
  425. match child.try_wait() {
  426. Ok(Some(status)) => break Ok(status),
  427. Ok(None) => {}
  428. Err(err) => break Err(err),
  429. }
  430. tokio::select! {
  431. msg = kill_rx.recv(), if kill_open => {
  432. if msg.is_some() {
  433. let _ = child.start_kill();
  434. }
  435. kill_open = false;
  436. }
  437. _ = tokio::time::sleep(Duration::from_millis(100)) => {}
  438. }
  439. };
  440. match status {
  441. Ok(status) => {
  442. let payload = TerminatedPayload {
  443. code: status.code(),
  444. signal: signal_from_status(status),
  445. };
  446. let _ = tx.send(CommandEvent::Terminated(payload)).await;
  447. }
  448. Err(err) => {
  449. let _ = tx.send(CommandEvent::Error(err.to_string())).await;
  450. }
  451. }
  452. stdout.abort();
  453. stderr.abort();
  454. });
  455. let event_stream = ReceiverStream::new(rx);
  456. let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
  457. Ok((event_stream, CommandChild { kill: kill_tx }))
  458. }
  459. fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
  460. #[cfg(unix)]
  461. return status.signal();
  462. #[cfg(not(unix))]
  463. {
  464. let _ = status;
  465. None
  466. }
  467. }
  468. pub fn serve(
  469. app: &AppHandle,
  470. hostname: &str,
  471. port: u32,
  472. password: &str,
  473. ) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
  474. let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
  475. tracing::info!(port, "Spawning sidecar");
  476. let envs = [
  477. ("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
  478. ("OPENCODE_SERVER_PASSWORD", password.to_string()),
  479. ];
  480. let (events, child) = spawn_command(
  481. app,
  482. format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
  483. &envs,
  484. )
  485. .expect("Failed to spawn opencode");
  486. let mut exit_tx = Some(exit_tx);
  487. tokio::spawn(
  488. events
  489. .for_each(move |event| {
  490. match event {
  491. CommandEvent::Stdout(line) => {
  492. tracing::info!("{line}");
  493. }
  494. CommandEvent::Stderr(line) => {
  495. tracing::info!("{line}");
  496. }
  497. CommandEvent::Error(err) => {
  498. tracing::error!("{err}");
  499. }
  500. CommandEvent::Terminated(payload) => {
  501. tracing::info!(
  502. code = ?payload.code,
  503. signal = ?payload.signal,
  504. "Sidecar terminated"
  505. );
  506. if let Some(tx) = exit_tx.take() {
  507. let _ = tx.send(payload);
  508. }
  509. }
  510. }
  511. future::ready(())
  512. })
  513. .instrument(tracing::info_span!("sidecar")),
  514. );
  515. (child, exit_rx)
  516. }
  517. pub mod sqlite_migration {
  518. use super::*;
  519. #[derive(
  520. tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type,
  521. )]
  522. #[serde(tag = "type", content = "value")]
  523. pub enum SqliteMigrationProgress {
  524. InProgress(u8),
  525. Done,
  526. }
  527. pub(super) fn logs_middleware(
  528. app: AppHandle,
  529. stream: impl Stream<Item = CommandEvent>,
  530. ) -> impl Stream<Item = CommandEvent> {
  531. let app = app.clone();
  532. let mut done = false;
  533. stream.filter_map(move |event| {
  534. if done {
  535. return future::ready(Some(event));
  536. }
  537. future::ready(match &event {
  538. CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
  539. if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
  540. if let Ok(progress) = s.parse::<u8>() {
  541. let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
  542. } else if s == "done" {
  543. done = true;
  544. let _ = SqliteMigrationProgress::Done.emit(&app);
  545. }
  546. None
  547. } else {
  548. Some(event)
  549. }
  550. }
  551. _ => Some(event),
  552. })
  553. })
  554. }
  555. }
  556. fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
  557. tx: mpsc::Sender<CommandEvent>,
  558. guard: Arc<tokio::sync::RwLock<()>>,
  559. pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
  560. wrapper: F,
  561. ) -> JoinHandle<()> {
  562. tokio::spawn(async move {
  563. let _lock = guard.read().await;
  564. let reader = BufReader::new(pipe_reader);
  565. read_line(reader, tx, wrapper).await;
  566. })
  567. }
  568. async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
  569. reader: BufReader<impl AsyncBufRead + Unpin>,
  570. tx: mpsc::Sender<CommandEvent>,
  571. wrapper: F,
  572. ) {
  573. let mut lines = reader.lines();
  574. loop {
  575. let line = lines.next_line().await;
  576. match line {
  577. Ok(s) => {
  578. if let Some(s) = s {
  579. let _ = tx.clone().send(wrapper(s)).await;
  580. }
  581. }
  582. Err(e) => {
  583. let tx_ = tx.clone();
  584. let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
  585. break;
  586. }
  587. }
  588. }
  589. }
  590. #[cfg(test)]
  591. mod tests {
  592. use super::*;
  593. use std::collections::HashMap;
  594. #[test]
  595. fn parse_shell_env_supports_null_delimited_pairs() {
  596. let env = parse_shell_env(b"PATH=/usr/bin:/bin\0FOO=bar=baz\0\0");
  597. assert_eq!(env.get("PATH"), Some(&"/usr/bin:/bin".to_string()));
  598. assert_eq!(env.get("FOO"), Some(&"bar=baz".to_string()));
  599. }
  600. #[test]
  601. fn parse_shell_env_ignores_invalid_entries() {
  602. let env = parse_shell_env(b"INVALID\0=empty\0OK=1\0");
  603. assert_eq!(env.len(), 1);
  604. assert_eq!(env.get("OK"), Some(&"1".to_string()));
  605. }
  606. #[test]
  607. fn merge_shell_env_keeps_explicit_overrides() {
  608. let mut shell_env = HashMap::new();
  609. shell_env.insert("PATH".to_string(), "/shell/path".to_string());
  610. shell_env.insert("HOME".to_string(), "/tmp/home".to_string());
  611. let merged = merge_shell_env(
  612. Some(shell_env),
  613. vec![
  614. ("PATH".to_string(), "/desktop/path".to_string()),
  615. ("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
  616. ],
  617. )
  618. .into_iter()
  619. .collect::<HashMap<_, _>>();
  620. assert_eq!(merged.get("PATH"), Some(&"/desktop/path".to_string()));
  621. assert_eq!(merged.get("HOME"), Some(&"/tmp/home".to_string()));
  622. assert_eq!(merged.get("OPENCODE_CLIENT"), Some(&"desktop".to_string()));
  623. }
  624. #[test]
  625. fn is_nushell_handles_path_and_binary_name() {
  626. assert!(is_nushell("nu"));
  627. assert!(is_nushell("/opt/homebrew/bin/nu"));
  628. assert!(is_nushell("C:\\Program Files\\nu.exe"));
  629. assert!(!is_nushell("/bin/zsh"));
  630. }
  631. }