Skip to main content

cli_stream/
process.rs

1//! Generic streaming subprocess engine — the shared core behind every
2//! process-backed harness (bob, Claude Code, Codex, …).
3//!
4//! Spawns a child, pipes stdout/stderr line-by-line through a callback
5//! as [`ProcessEvent`]s, augments PATH so Node-based CLIs resolve even
6//! from a Finder-launched `.app`, and hands back a [`ProcessHandle`] for
7//! cancellation (SIGTERM → SIGKILL). No harness-trait or bob knowledge —
8//! purely subprocess streaming.
9//!
10//! Cancellation is the wrinkle: a run needs to be stoppable mid-stream
11//! when the user closes the tab or hits "stop". `ProcessHandle::cancel()`
12//! sends SIGTERM (with a SIGKILL fallback) and flips an atomic
13//! `cancelled` flag the reader threads use to short-circuit.
14
15use crate::error::StreamError;
16use serde::Serialize;
17use std::io::{BufRead, BufReader, Read};
18use std::path::{Path, PathBuf};
19use std::process::{Child, Command, Stdio};
20use std::sync::atomic::{AtomicBool, Ordering};
21#[cfg(unix)]
22use std::sync::mpsc;
23use std::sync::{Arc, Mutex, OnceLock};
24use std::thread;
25use std::time::Duration;
26
27/// Raw events emitted to the caller's callback during a streaming run.
28/// JSON-tagged so axum SSE and Tauri Channel render identical payloads
29/// on the wire. Harness-neutral: a process-backed adapter parses the
30/// `Stdout` lines into a normalized event vocabulary (e.g. `agent-harness`'s `RunEvent`).
31#[derive(Debug, Clone, Serialize)]
32#[serde(tag = "kind", rename_all = "camelCase")]
33// New lifecycle events can be added without breaking downstream matches —
34// consumers must carry a `_` arm. (Construction of existing variants is
35// unaffected, so it's still ergonomic to build them.)
36#[non_exhaustive]
37pub enum ProcessEvent {
38    /// First event. Sent before the child has produced any output so the
39    /// UI can show a "thinking…" state.
40    Started { run_id: String },
41    /// Raw stdout line. Process-backed CLIs emit one JSON object per line
42    /// in their streaming mode. The caller parses.
43    Stdout { run_id: String, line: String },
44    /// Raw stderr line. Warnings + the occasional error.
45    Stderr { run_id: String, line: String },
46    /// Spawn / IO failure. Terminal — followed by `Exited`.
47    Error { run_id: String, message: String },
48    /// Process exited. Always sent exactly once at the end.
49    Exited {
50        run_id: String,
51        exit_code: Option<i32>,
52        /// True iff `cancel()` was called before exit.
53        cancelled: bool,
54    },
55}
56
57/// Handle to an in-flight streaming run. Caller stores it (e.g. in a
58/// runId-keyed map) so a later `cancel()` can find it.
59///
60/// Dropping the handle does NOT cancel the run — the reader threads +
61/// wait thread continue independently. Use `cancel()` explicitly when
62/// the user closes the connection.
63#[derive(Clone, Debug)]
64pub struct ProcessHandle {
65    inner: Arc<HandleInner>,
66}
67
68#[derive(Debug)]
69struct HandleInner {
70    child: Mutex<Option<Child>>,
71    cancelled: AtomicBool,
72}
73
74impl ProcessHandle {
75    /// SIGTERM the process, then SIGKILL after 1.5s if it's still alive.
76    /// The CLI is supposed to flush a final result on SIGTERM but we
77    /// don't trust it to do so forever.
78    pub fn cancel(&self) -> Result<(), StreamError> {
79        self.inner.cancelled.store(true, Ordering::SeqCst);
80        let mut guard = self
81            .inner
82            .child
83            .lock()
84            .map_err(|_| StreamError::CancelLockPoisoned)?;
85        let Some(child) = guard.as_mut() else {
86            // Already exited.
87            return Ok(());
88        };
89        // Best-effort SIGTERM. On Unix, kill() sends SIGKILL by default;
90        // we use libc::kill for SIGTERM, falling back to child.kill() if
91        // the libc call fails. On Windows there's only TerminateProcess
92        // via .kill().
93        #[cfg(unix)]
94        {
95            let pid = child.id() as i32;
96            // SAFETY: pid is the child's PID owned by this Child; sending
97            // SIGTERM is well-defined.
98            unsafe { libc::kill(pid, libc::SIGTERM) };
99            // Spawn the SIGKILL fallback inline to avoid holding the mutex
100            // while sleeping.
101            let inner = Arc::clone(&self.inner);
102            thread::spawn(move || {
103                thread::sleep(Duration::from_millis(1500));
104                if let Ok(mut guard) = inner.child.lock() {
105                    if let Some(child) = guard.as_mut() {
106                        let _ = child.kill();
107                    }
108                }
109            });
110        }
111        #[cfg(not(unix))]
112        {
113            let _ = child.kill();
114        }
115        Ok(())
116    }
117
118    /// Whether `cancel()` was called. Tagged on the final `Exited` event.
119    pub fn was_cancelled(&self) -> bool {
120        self.inner.cancelled.load(Ordering::SeqCst)
121    }
122
123    /// The child's OS process id while it's alive, or `None` once it has been
124    /// reaped (the `Child` is taken on exit). Lets an embedder record the pid
125    /// so a child orphaned by a hard crash can be killed on the next launch.
126    pub fn pid(&self) -> Option<u32> {
127        self.inner
128            .child
129            .lock()
130            .ok()
131            .and_then(|guard| guard.as_ref().map(Child::id))
132    }
133}
134
135/// Spawn an arbitrary streaming child process — the generic engine behind
136/// every process-backed harness (bob, Claude Code, Codex).
137///
138/// Pipes stdout/stderr line-by-line through `callback` using the raw
139/// [`ProcessEvent`] vocabulary (Started / Stdout / Stderr / Error /
140/// Exited). `env` supplies per-harness secrets (each harness's API-key
141/// var, or none for self-authenticating CLIs). PATH is augmented so
142/// Node-based CLIs find `node`. Returns a [`ProcessHandle`] for
143/// cancellation.
144///
145/// `callback` is invoked from three threads (stdout reader, stderr
146/// reader, exit watcher); the `Clone` bound lets us hand a copy to each.
147/// `run_id` is opaque — the caller chooses it and uses it to correlate
148/// events with the handle.
149///
150/// ```no_run
151/// use cli_stream::{spawn_streaming, ProcessEvent};
152/// use std::path::PathBuf;
153///
154/// # fn main() -> Result<(), cli_stream::StreamError> {
155/// let handle = spawn_streaming(
156///     PathBuf::from("echo"),
157///     vec!["hello".to_owned()],
158///     Vec::new(),                      // extra env vars (key, value)
159///     std::env::current_dir().unwrap(),
160///     "run-1".to_owned(),              // your correlation id
161///     |event| match event {
162///         ProcessEvent::Stdout { line, .. } => println!("{line}"),
163///         ProcessEvent::Exited { exit_code, .. } => eprintln!("exit {exit_code:?}"),
164///         _ => {}
165///     },
166/// )?;
167/// // `handle.cancel()` stops it early; dropping the handle does not.
168/// let _ = handle;
169/// # Ok(())
170/// # }
171/// ```
172pub fn spawn_streaming<F>(
173    program: PathBuf,
174    args: Vec<String>,
175    env: Vec<(String, String)>,
176    cwd: PathBuf,
177    run_id: String,
178    callback: F,
179) -> Result<ProcessHandle, StreamError>
180where
181    F: FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
182{
183    // PATH augmentation: Node-based CLIs (bob, claude, codex) expect
184    // `node` (and often `npm`, `git`) on PATH. A desktop app launched
185    // from Finder/Launchpad inherits only the minimal launchd PATH
186    // (`/usr/bin:/bin:/usr/sbin:/sbin`), so an nvm-installed node is
187    // invisible and the child exits 127 ("command not found").
188    //
189    // Fix: prepend the program's parent dir (where node also lives in an
190    // nvm install) to the child's PATH. Added, not replaced, so a PATH
191    // the user explicitly set still wins on later lookups.
192    //
193    // A bare program name is resolved to its absolute path FIRST (see
194    // `resolve_program`), so the prepended dir is the program's real home —
195    // pairing a node CLI with the exact `node` it was installed under,
196    // regardless of which node version leads the inherited PATH.
197    let program = resolve_program(program);
198    let augmented_path = augment_path_for_node(&program);
199
200    let mut command = hidden_command(&program);
201    command
202        .args(&args)
203        .current_dir(&cwd)
204        .env("PATH", augmented_path)
205        .stdin(Stdio::null())
206        .stdout(Stdio::piped())
207        .stderr(Stdio::piped());
208    for (key, value) in &env {
209        command.env(key, value);
210    }
211    let mut child = command.spawn().map_err(|source| StreamError::Spawn {
212        program: program.display().to_string(),
213        source,
214    })?;
215
216    let stdout = child
217        .stdout
218        .take()
219        .ok_or(StreamError::PipeNotCaptured { stream: "stdout" })?;
220    let stderr = child
221        .stderr
222        .take()
223        .ok_or(StreamError::PipeNotCaptured { stream: "stderr" })?;
224
225    let inner = Arc::new(HandleInner {
226        child: Mutex::new(Some(child)),
227        cancelled: AtomicBool::new(false),
228    });
229    let handle = ProcessHandle {
230        inner: Arc::clone(&inner),
231    };
232
233    // Emit Started immediately so the caller doesn't wait on the first
234    // output line for a UI signal.
235    let mut started_cb = callback.clone();
236    started_cb(ProcessEvent::Started {
237        run_id: run_id.clone(),
238    });
239
240    // Reader threads. Each owns its own callback clone — the Clone bound
241    // is the whole point.
242    let stdout_cb = callback.clone();
243    let stdout_run_id = run_id.clone();
244    let stdout_handle = thread::spawn(move || {
245        pump_lines(stdout, stdout_run_id, true, stdout_cb);
246    });
247
248    let stderr_cb = callback.clone();
249    let stderr_run_id = run_id.clone();
250    let stderr_handle = thread::spawn(move || {
251        pump_lines(stderr, stderr_run_id, false, stderr_cb);
252    });
253
254    // Exit watcher — emits the terminal Exited event with the cancellation
255    // flag. It must NOT hold the child lock across a blocking `wait()`:
256    // `cancel()` needs that same lock to signal the child, so a held lock
257    // would block cancel until the process exited on its own (defeating it).
258    // Instead poll `try_wait()`, locking only for each non-blocking check and
259    // releasing between polls so `cancel()` can acquire the lock mid-run.
260    let exit_inner = Arc::clone(&inner);
261    let mut exit_cb = callback;
262    let exit_run_id = run_id;
263    thread::spawn(move || {
264        let wait_result = loop {
265            {
266                let mut guard = match exit_inner.child.lock() {
267                    Ok(guard) => guard,
268                    Err(_) => return, // poisoned — nothing safe to do
269                };
270                match guard.as_mut() {
271                    Some(child) => match child.try_wait() {
272                        Ok(Some(status)) => break Ok(status),
273                        Ok(None) => {} // still running; poll again
274                        Err(err) => break Err(err),
275                    },
276                    None => return, // already reaped
277                }
278            } // lock released before sleeping, so cancel() can acquire it
279            thread::sleep(Duration::from_millis(50));
280        };
281        let _ = stdout_handle.join();
282        let _ = stderr_handle.join();
283        let cancelled = exit_inner.cancelled.load(Ordering::SeqCst);
284
285        match wait_result {
286            Ok(status) => exit_cb(ProcessEvent::Exited {
287                run_id: exit_run_id.clone(),
288                exit_code: status.code(),
289                cancelled,
290            }),
291            Err(err) => exit_cb(ProcessEvent::Error {
292                run_id: exit_run_id.clone(),
293                message: format!("wait failed: {err}"),
294            }),
295        }
296
297        // Drop the child handle so subsequent cancel() calls
298        // short-circuit cleanly.
299        if let Ok(mut guard) = exit_inner.child.lock() {
300            *guard = None;
301        }
302    });
303
304    Ok(handle)
305}
306
307fn pump_lines<R, F>(reader: R, run_id: String, is_stdout: bool, mut callback: F)
308where
309    R: Read,
310    F: FnMut(ProcessEvent),
311{
312    let buffered = BufReader::new(reader);
313    for line in buffered.lines() {
314        match line {
315            Ok(text) => {
316                let event = if is_stdout {
317                    ProcessEvent::Stdout {
318                        run_id: run_id.clone(),
319                        line: text,
320                    }
321                } else {
322                    ProcessEvent::Stderr {
323                        run_id: run_id.clone(),
324                        line: text,
325                    }
326                };
327                callback(event);
328            }
329            Err(err) => {
330                callback(ProcessEvent::Error {
331                    run_id: run_id.clone(),
332                    message: format!("stream read failed: {err}"),
333                });
334                return;
335            }
336        }
337    }
338}
339
340/// Compose a PATH for the spawned process that always includes the
341/// directory containing the program — where `node`, `npm`, and friends
342/// usually live in an nvm install. The user's existing PATH stays as a
343/// fallback after our prepended directory.
344/// A [`Command`] that never opens a console window on Windows.
345///
346/// A GUI host (a Tauri app, an IDE) spawning a console-subsystem CLI gets a
347/// black console flashed on screen for every agent run and every `--version`
348/// probe. `CREATE_NO_WINDOW` suppresses it. Use this in place of
349/// `Command::new` for anything a desktop app spawns; it is a plain
350/// `Command::new` on every other platform, so call sites stay `cfg`-free.
351pub fn hidden_command(program: impl AsRef<std::ffi::OsStr>) -> Command {
352    #[allow(unused_mut)]
353    let mut command = Command::new(program);
354    #[cfg(windows)]
355    {
356        use std::os::windows::process::CommandExt;
357        // https://learn.microsoft.com/windows/win32/procthread/process-creation-flags
358        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
359        command.creation_flags(CREATE_NO_WINDOW);
360    }
361    command
362}
363
364fn augment_path_for_node(program: &Path) -> String {
365    prepend_program_dir(program, &augmented_node_path())
366}
367
368/// Resolve a bare program name (`bob`, `claude`) to its absolute path on the
369/// augmented PATH, so the spawn and the node pairing agree on *one* location.
370///
371/// Without this, a bare name splits the brain: the OS resolves the *program*
372/// against the parent process's PATH, while the child's `#!/usr/bin/env node`
373/// shebang resolves *node* against the PATH we set — and
374/// `prepend_program_dir` can't pair the program with its sibling node
375/// because a bare name has no parent dir. Concretely: an nvm-installed `bob`
376/// found under `v24/bin` could re-exec on a `v20` node that happened to lead
377/// the inherited PATH, and die on a v24-only flag ("exited with code 9").
378/// Resolving to the absolute path first means the program's own directory —
379/// holding the exact `node` it was installed with — is prepended and wins.
380///
381/// A program given with an explicit path is returned untouched; a bare name
382/// that can't be found is also returned untouched, so the spawn still fails
383/// with the clear "No such file" error rather than a synthetic one here.
384pub fn resolve_program(program: PathBuf) -> PathBuf {
385    if program.parent().is_some_and(|p| !p.as_os_str().is_empty()) {
386        return program; // explicit path — caller's choice wins
387    }
388    resolve_on_path(&program, &augmented_node_path()).unwrap_or(program)
389}
390
391/// Walk `path_env`'s entries for the first executable file named `name`.
392/// Pure with respect to env/spawn (filesystem only) so it's unit-testable.
393fn resolve_on_path(name: &Path, path_env: &str) -> Option<PathBuf> {
394    path_env
395        .split(':')
396        .filter(|dir| !dir.is_empty())
397        .map(|dir| Path::new(dir).join(name))
398        .find(|candidate| is_executable_file(candidate))
399}
400
401#[cfg(unix)]
402fn is_executable_file(path: &Path) -> bool {
403    use std::os::unix::fs::PermissionsExt;
404    std::fs::metadata(path)
405        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
406        .unwrap_or(false)
407}
408
409#[cfg(not(unix))]
410fn is_executable_file(path: &Path) -> bool {
411    path.is_file()
412}
413
414/// Prepend the directory containing `program` (where `node` also lives in an
415/// nvm install) to `base_path`, so the resolved binary's own dir is searched
416/// first. Pure (no env / no spawn) so it's unit-tested directly.
417fn prepend_program_dir(program: &Path, base_path: &str) -> String {
418    match program
419        .parent()
420        .map(|p| p.display().to_string())
421        .filter(|s| !s.is_empty())
422    {
423        Some(dir) => format!("{dir}:{base_path}"),
424        None => base_path.to_owned(),
425    }
426}
427
428/// A PATH that resolves Node-based CLIs (bob, claude, codex) even from a
429/// process launched by Finder/Launchpad, which inherits only the minimal
430/// launchd PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) rather than the user's
431/// shell PATH.
432///
433/// Strategy: keep the process's own PATH first (an explicit PATH still wins),
434/// then append the user's **real** PATH as resolved by their login shell —
435/// which sources their rc, so it knows where nvm / pnpm / volta / asdf / fnm /
436/// Homebrew put `node`, with no guessing. If the shell query is unavailable
437/// (no `$SHELL`, a timeout, a sandboxed app that can't spawn, …) we fall back
438/// to a hardcoded best-effort list, so we're never worse than before.
439///
440/// Used by the run path (which prepends the resolved binary's own dir on top
441/// of this) and by readiness probes that locate `claude`/`codex` via a bare
442/// `Command::new(name)`. Computed once and cached for the process — the
443/// (bounded) shell spawn happens at most once per launch, lazily on the first
444/// readiness/run/login, never at construction.
445pub fn augmented_node_path() -> String {
446    static CACHED: OnceLock<String> = OnceLock::new();
447    CACHED.get_or_init(compute_augmented_node_path).clone()
448}
449
450fn compute_augmented_node_path() -> String {
451    let mut parts: Vec<String> = Vec::new();
452    // The process's own PATH first — anything explicitly set still wins.
453    if let Ok(existing) = std::env::var("PATH") {
454        if !existing.is_empty() {
455            parts.push(existing);
456        }
457    }
458    // The user's real PATH (nvm/pnpm/volta/asdf/Homebrew) via their login
459    // shell; a hardcoded best-effort list if that's unavailable.
460    parts.push(login_shell_path().unwrap_or_else(hardcoded_node_dirs));
461    keep_absolute_entries(&parts.join(":"))
462}
463
464/// Keep only **absolute** PATH entries, dropping relative or empty ones (`.`,
465/// `""`, a direnv-style `node_modules/.bin`). Security: we spawn with
466/// `current_dir` set to the user's workspace — where the agent itself writes
467/// files and synced/downloaded content lands — so a relative/empty PATH entry
468/// (which resolves against that cwd) could run a planted `node`/`claude`. An
469/// empty entry is the classic implicit-cwd vector. Absolute dirs only.
470fn keep_absolute_entries(path: &str) -> String {
471    path.split(':')
472        .filter(|entry| entry.starts_with('/'))
473        .collect::<Vec<_>>()
474        .join(":")
475}
476
477/// Resolve PATH by asking the user's login + interactive shell — it sources
478/// their rc, so it knows wherever any node manager (nvm / pnpm / volta / asdf /
479/// fnm / Homebrew) put `node`, without us guessing. Bounded by a timeout so a
480/// slow or interactive rc can't hang us; returns `None` (→ hardcoded fallback)
481/// on any failure: no `$SHELL`, spawn refused (e.g. a sandboxed app), timeout,
482/// or no PATH in the output. Reads PATH from `env` (OS colon format,
483/// shell-agnostic — works for fish too) rather than expanding `$PATH`.
484///
485/// This *executes the user's shell rc*, exactly as opening a terminal does —
486/// their own shell, on their own machine. It is not a privilege/auth step: no
487/// "login session" is created; `-l`/`-i` only select which startup files are
488/// sourced (login profiles + the interactive rc where nvm usually lives).
489/// Printed on its own line right before `env`, so the parser can skip any
490/// shell-init chatter / terminal escape sequences (e.g. iTerm2 shell
491/// integration's `]1337;…` OSC codes) the interactive shell emits before our
492/// command runs — which would otherwise prepend to the `PATH=` line.
493#[cfg(unix)]
494const PATH_SENTINEL: &str = "__CLI_STREAM_PATH__";
495
496#[cfg(unix)]
497fn login_shell_path() -> Option<String> {
498    let shell = std::env::var("SHELL").ok().filter(|s| !s.is_empty())?;
499    // Print a sentinel line, then dump the environment. Reading PATH from `env`
500    // (not by expanding `$PATH`) keeps it OS colon format and shell-agnostic
501    // (fish stores PATH as a list); the sentinel lets the parser ignore
502    // anything the interactive shell prints at startup before `env` runs.
503    let script = format!("printf '\\n{PATH_SENTINEL}\\n'; env");
504    let mut child = Command::new(&shell)
505        .arg("-lic") // -l: login profiles, -i: interactive rc (nvm), -c: command
506        .arg(&script)
507        .stdin(Stdio::null())
508        .stdout(Stdio::piped())
509        .stderr(Stdio::null())
510        .spawn()
511        .ok()?;
512    // Read on a worker thread so the whole query can be bounded by a timeout —
513    // a misbehaving rc must not hang the app. Read bytes + lossy-decode (rather
514    // than `read_to_string`) so non-UTF-8 in the env dump degrades to
515    // replacement chars instead of discarding the whole output.
516    let mut stdout = child.stdout.take()?;
517    let (tx, rx) = mpsc::channel();
518    thread::spawn(move || {
519        let mut buf = Vec::new();
520        let _ = stdout.read_to_end(&mut buf);
521        let _ = tx.send(String::from_utf8_lossy(&buf).into_owned());
522    });
523    // 4s: generous enough for a heavy rc (oh-my-zsh + plugins + nvm lazy-load)
524    // to finish, since this is paid at most once (cached); on timeout we kill
525    // the shell and fall back to the hardcoded list.
526    let output = match rx.recv_timeout(Duration::from_secs(4)) {
527        Ok(buf) => buf,
528        Err(_) => {
529            let _ = child.kill();
530            let _ = child.wait();
531            return None;
532        }
533    };
534    let _ = child.wait();
535    parse_path_from_shell_output(&output)
536}
537
538#[cfg(not(unix))]
539fn login_shell_path() -> Option<String> {
540    None
541}
542
543/// Extract the `PATH=…` value from the shell's `printf <sentinel>; env` output.
544/// Everything up to (and including) the last sentinel is discarded — that's
545/// where shell-init chatter and terminal escape sequences live — then the
546/// `PATH=` line is read from the clean `env` dump that follows. `None` if the
547/// sentinel is missing (query misbehaved) or PATH is absent/empty.
548#[cfg(unix)]
549fn parse_path_from_shell_output(output: &str) -> Option<String> {
550    output
551        .rsplit_once(PATH_SENTINEL)?
552        .1
553        .lines()
554        .find_map(|line| line.strip_prefix("PATH="))
555        .map(str::trim)
556        .filter(|p| !p.is_empty())
557        .map(str::to_owned)
558}
559
560/// Hardcoded best-effort node locations — the fallback when the login-shell
561/// query is unavailable. Leans on the *universal* dirs every distro + macOS
562/// share: `/usr/bin` + `/usr/local/bin` are where apt/dnf/yum/pacman and the
563/// official Node tarball install, so the common Linux container case is covered
564/// without distro-specific guessing. Plus macOS Homebrew, the official-installer
565/// dir, and any nvm-managed node. Anything manager-specific (pnpm/volta/asdf,
566/// Linuxbrew, snap, …) is what the login-shell query is for — and a missing
567/// dir is just skipped, so this is never worse than the bare launchd PATH.
568fn hardcoded_node_dirs() -> String {
569    let mut parts: Vec<String> =
570        vec!["/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_owned()];
571    if let Ok(home) = std::env::var("HOME") {
572        if !home.is_empty() {
573            let home_path = Path::new(&home);
574            // Official-installer location for several agent CLIs.
575            parts.push(home_path.join(".local/bin").display().to_string());
576            // nvm: ~/.nvm/versions/node/<version>/bin — where npm-global
577            // CLIs (bob, claude, codex) live under an nvm-managed node.
578            if let Ok(entries) = std::fs::read_dir(home_path.join(".nvm/versions/node")) {
579                for entry in entries.flatten() {
580                    let bin = entry.path().join("bin");
581                    if bin.is_dir() {
582                        parts.push(bin.display().to_string());
583                    }
584                }
585            }
586        }
587    }
588    parts.join(":")
589}
590
591#[cfg(test)]
592mod tests {
593    /// Issue #35: a GUI host spawning a console-subsystem CLI flashed a console
594    /// window on Windows for every run and every `--version` probe. The flag is
595    /// Windows-only, so what's portable to assert is that the constructor is a
596    /// drop-in for `Command::new` — it still runs, and still captures output.
597    #[test]
598    fn hidden_command_runs_like_a_plain_command() {
599        let program = if cfg!(windows) { "cmd" } else { "echo" };
600        let args: &[&str] = if cfg!(windows) {
601            &["/C", "echo", "ok"]
602        } else {
603            &["ok"]
604        };
605        let out = hidden_command(program).args(args).output().unwrap();
606        assert!(out.status.success());
607        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
608    }
609
610    use super::*;
611
612    #[test]
613    fn hardcoded_fallback_includes_macos_defaults() {
614        // The fallback (used when the login-shell query is unavailable) must
615        // still carry Homebrew + the system bins, so a launchd-spawned `.app`
616        // resolves CLIs even without a usable shell — the original
617        // "not installed" fix.
618        let path = hardcoded_node_dirs();
619        assert!(
620            path.contains("/opt/homebrew/bin"),
621            "missing Apple-Silicon Homebrew bin"
622        );
623        assert!(
624            path.contains("/usr/local/bin"),
625            "missing Intel Homebrew / system bin"
626        );
627        assert!(path.contains("/usr/bin"), "missing system bin");
628    }
629
630    #[cfg(unix)]
631    #[test]
632    fn parse_path_from_shell_output_skips_chatter_before_the_sentinel() {
633        // Real-world shape: iTerm2 OSC escapes + a banner emitted at shell
634        // startup, BEFORE our sentinel + `env` dump. Only the post-sentinel
635        // PATH= line counts — note the pre-sentinel "PATH=/decoy" is ignored.
636        let output = "\u{1b}]1337;RemoteHost=x\u{7}welcome banner\nPATH=/decoy\n__CLI_STREAM_PATH__\nHOME=/Users/x\nPATH=/opt/homebrew/bin:/usr/bin\nLANG=en_US";
637        assert_eq!(
638            parse_path_from_shell_output(output).as_deref(),
639            Some("/opt/homebrew/bin:/usr/bin")
640        );
641        // No sentinel (query misbehaved) → None, so the caller falls back —
642        // even if a bare PATH= is present.
643        assert_eq!(parse_path_from_shell_output("PATH=/usr/bin"), None);
644        // Sentinel present but PATH absent/empty → None.
645        assert_eq!(
646            parse_path_from_shell_output("__CLI_STREAM_PATH__\nFOO=bar"),
647            None
648        );
649        assert_eq!(
650            parse_path_from_shell_output("__CLI_STREAM_PATH__\nPATH=\nFOO=bar"),
651            None
652        );
653    }
654
655    #[test]
656    fn keep_absolute_entries_drops_relative_and_empty() {
657        // Relative (`node_modules/.bin`, `.`) and empty entries — which resolve
658        // against the spawn cwd (the user's workspace) — are dropped; absolute
659        // dirs survive in order.
660        assert_eq!(
661            keep_absolute_entries("/opt/homebrew/bin:node_modules/.bin:/usr/bin:.::/bin"),
662            "/opt/homebrew/bin:/usr/bin:/bin"
663        );
664        assert_eq!(keep_absolute_entries("/usr/bin"), "/usr/bin");
665        // All-relative → empty (caller still has the process PATH ahead of it).
666        assert_eq!(keep_absolute_entries(".:rel:"), "");
667    }
668
669    #[test]
670    fn prepend_program_dir_puts_the_binary_dir_first() {
671        let combined = prepend_program_dir(
672            Path::new("/Users/x/.nvm/versions/node/v22/bin/bob"),
673            "/opt/homebrew/bin:/usr/bin",
674        );
675        assert!(combined.starts_with("/Users/x/.nvm/versions/node/v22/bin:"));
676        assert!(combined.contains("/opt/homebrew/bin"));
677        // A bare program name has no parent dir → base path unchanged.
678        assert_eq!(
679            prepend_program_dir(Path::new("bob"), "/usr/bin"),
680            "/usr/bin"
681        );
682    }
683
684    #[test]
685    fn augmented_node_path_is_nonempty_and_resolves_system_bin() {
686        // Exercises the cached public path once. `/usr/bin` is present whether
687        // the shell query succeeds (real PATH) or falls back (hardcoded), and
688        // is on the bare launchd PATH too — so this holds in any environment.
689        let path = augmented_node_path();
690        assert!(!path.is_empty());
691        assert!(path.contains("/usr/bin"), "system bin must always resolve");
692    }
693
694    #[test]
695    fn resolve_program_returns_explicit_paths_untouched() {
696        // A caller-supplied path is the caller's choice — no PATH lookup.
697        let explicit = PathBuf::from("/opt/somewhere/bob");
698        assert_eq!(resolve_program(explicit.clone()), explicit);
699        let relative = PathBuf::from("./bin/bob");
700        assert_eq!(resolve_program(relative.clone()), relative);
701    }
702
703    #[cfg(unix)]
704    #[test]
705    fn resolve_on_path_finds_the_first_executable_match() {
706        use std::os::unix::fs::PermissionsExt;
707        let root = tempfile::tempdir().expect("tempdir");
708        // dir_a holds a NON-executable `bob` (must be skipped); dir_b an
709        // executable one (must win even though dir_a comes first on PATH).
710        let dir_a = root.path().join("a");
711        let dir_b = root.path().join("b");
712        std::fs::create_dir_all(&dir_a).unwrap();
713        std::fs::create_dir_all(&dir_b).unwrap();
714        std::fs::write(dir_a.join("bob"), "#!/bin/sh\n").unwrap();
715        let exec = dir_b.join("bob");
716        std::fs::write(&exec, "#!/bin/sh\n").unwrap();
717        std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
718
719        let path_env = format!("{}:{}", dir_a.display(), dir_b.display());
720        assert_eq!(resolve_on_path(Path::new("bob"), &path_env), Some(exec));
721        // An unknown name resolves to nothing.
722        assert_eq!(
723            resolve_on_path(Path::new("definitely-missing"), &path_env),
724            None
725        );
726    }
727}
728
729/// End-to-end lifecycle tests that spawn real processes. Unix-only: they use
730/// `printf` / `sh` / `sleep`, and the cancel path is signal-based here.
731#[cfg(all(test, unix))]
732mod lifecycle {
733    use super::*;
734    use std::sync::Condvar;
735    use std::time::Instant;
736
737    type Done = Arc<(Mutex<bool>, Condvar)>;
738
739    /// A thread-safe event collector that signals `done` on the terminal
740    /// event. Returns the (cloneable) callback + the shared collections.
741    fn collector() -> (
742        impl FnMut(ProcessEvent) + Send + Sync + Clone + 'static,
743        Arc<Mutex<Vec<ProcessEvent>>>,
744        Done,
745    ) {
746        let events = Arc::new(Mutex::new(Vec::new()));
747        let done: Done = Arc::new((Mutex::new(false), Condvar::new()));
748        let cb = {
749            let events = Arc::clone(&events);
750            let done = Arc::clone(&done);
751            move |ev: ProcessEvent| {
752                let terminal =
753                    matches!(ev, ProcessEvent::Exited { .. } | ProcessEvent::Error { .. });
754                events.lock().unwrap().push(ev);
755                if terminal {
756                    let (lock, cvar) = &*done;
757                    *lock.lock().unwrap() = true;
758                    cvar.notify_all();
759                }
760            }
761        };
762        (cb, events, done)
763    }
764
765    /// Block until the terminal event fires, or panic after `secs`.
766    fn wait_done(done: &Done, secs: u64) {
767        let (lock, cvar) = &**done;
768        let mut finished = lock.lock().unwrap();
769        let deadline = Instant::now() + Duration::from_secs(secs);
770        while !*finished {
771            let now = Instant::now();
772            assert!(now < deadline, "process did not finish within {secs}s");
773            let (guard, _) = cvar.wait_timeout(finished, deadline - now).unwrap();
774            finished = guard;
775        }
776    }
777
778    /// Spawn `program args`, block until it exits, return every event.
779    fn run(program: &str, args: &[&str]) -> Vec<ProcessEvent> {
780        let (cb, events, done) = collector();
781        let _handle = spawn_streaming(
782            PathBuf::from(program),
783            args.iter().map(|s| (*s).to_owned()).collect(),
784            Vec::new(),
785            PathBuf::from("."),
786            "t".to_owned(),
787            cb,
788        )
789        .expect("spawn");
790        wait_done(&done, 10);
791        let events = events.lock().unwrap();
792        events.clone()
793    }
794
795    #[test]
796    fn streams_stdout_lines_then_exits_zero() {
797        let events = run("printf", &["%s\n", "alpha", "beta"]);
798        // Started leads, Exited(0, not cancelled) closes.
799        assert!(matches!(events.first(), Some(ProcessEvent::Started { .. })));
800        assert!(matches!(
801            events.last(),
802            Some(ProcessEvent::Exited {
803                exit_code: Some(0),
804                cancelled: false,
805                ..
806            })
807        ));
808        // Lines arrive in order, one event each.
809        let lines: Vec<&str> = events
810            .iter()
811            .filter_map(|e| match e {
812                ProcessEvent::Stdout { line, .. } => Some(line.as_str()),
813                _ => None,
814            })
815            .collect();
816        assert_eq!(lines, vec!["alpha", "beta"]);
817    }
818
819    #[test]
820    fn nonzero_exit_code_is_reported() {
821        let events = run("sh", &["-c", "exit 3"]);
822        assert!(matches!(
823            events.last(),
824            Some(ProcessEvent::Exited {
825                exit_code: Some(3),
826                cancelled: false,
827                ..
828            })
829        ));
830    }
831
832    #[test]
833    fn env_vars_are_passed_to_the_child() {
834        // The `env` argument must reach the child's environment — exercise it
835        // directly (the other lifecycle tests pass an empty env).
836        let (cb, events, done) = collector();
837        let _handle = spawn_streaming(
838            PathBuf::from("sh"),
839            vec![
840                "-c".to_owned(),
841                "printf '%s\\n' \"$CLI_STREAM_STUB\"".to_owned(),
842            ],
843            vec![("CLI_STREAM_STUB".to_owned(), "from-env".to_owned())],
844            PathBuf::from("."),
845            "t".to_owned(),
846            cb,
847        )
848        .expect("spawn");
849        wait_done(&done, 10);
850        let events = events.lock().unwrap();
851        assert!(
852            events
853                .iter()
854                .any(|e| matches!(e, ProcessEvent::Stdout { line, .. } if line == "from-env")),
855            "child should observe the injected env var, got {events:?}"
856        );
857    }
858
859    #[test]
860    fn stderr_is_streamed_and_not_misrouted_to_stdout() {
861        let events = run("sh", &["-c", "echo to-stderr 1>&2"]);
862        assert!(events
863            .iter()
864            .any(|e| matches!(e, ProcessEvent::Stderr { line, .. } if line == "to-stderr")));
865        assert!(!events
866            .iter()
867            .any(|e| matches!(e, ProcessEvent::Stdout { .. })));
868        assert!(events.iter().any(|e| matches!(
869            e,
870            ProcessEvent::Exited {
871                exit_code: Some(0),
872                ..
873            }
874        )));
875    }
876
877    #[test]
878    fn cancel_promptly_terminates_the_run_and_flags_it() {
879        // A 10s sleeper we cancel ~immediately; a working engine must kill it
880        // far sooner than 10s. `exec` so the process *is* sleep (no orphan).
881        let (cb, events, done) = collector();
882        let handle = spawn_streaming(
883            PathBuf::from("sh"),
884            vec!["-c".to_owned(), "exec sleep 10".to_owned()],
885            Vec::new(),
886            PathBuf::from("."),
887            "t".to_owned(),
888            cb,
889        )
890        .expect("spawn");
891
892        // cancel() may block until the child is reaped, so fire it off-thread.
893        let canceller = handle.clone();
894        thread::spawn(move || {
895            thread::sleep(Duration::from_millis(100));
896            let _ = canceller.cancel();
897        });
898
899        // Correct cancellation terminates the 10s sleep within a few seconds.
900        wait_done(&done, 4);
901        assert!(handle.was_cancelled());
902        let events = events.lock().unwrap();
903        assert!(
904            matches!(
905                events.last(),
906                Some(ProcessEvent::Exited {
907                    cancelled: true,
908                    ..
909                })
910            ),
911            "expected Exited(cancelled=true), got {:?}",
912            events.last()
913        );
914    }
915
916    #[test]
917    fn spawning_a_missing_binary_is_err() {
918        let result = spawn_streaming(
919            PathBuf::from("cli-stream-no-such-binary-zzz"),
920            Vec::new(),
921            Vec::new(),
922            PathBuf::from("."),
923            "t".to_owned(),
924            |_ev: ProcessEvent| {},
925        );
926        // Typed: a `Spawn` error carrying the OS `NotFound` io::Error as its
927        // source — the whole point of `StreamError` over a `String`. A caller
928        // can branch on `ErrorKind` to tell "not installed" (NotFound) from
929        // "permission denied", which a flattened string can't support.
930        match result {
931            Err(StreamError::Spawn { program, source }) => {
932                assert!(program.contains("cli-stream-no-such-binary-zzz"));
933                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
934            }
935            other => panic!("expected StreamError::Spawn, got {other:?}"),
936        }
937    }
938}