oxdock_cli/
lib.rs

1use anyhow::{Context, Result, bail};
2use oxdock_fs::{GuardedPath, PathResolver, discover_workspace_root};
3use oxdock_process::CommandBuilder;
4#[cfg(test)]
5use oxdock_process::CommandSnapshot;
6use std::env;
7use std::io::{self, IsTerminal, Read};
8#[cfg(test)]
9use std::sync::Mutex;
10
11pub use oxdock_core::{run_steps, run_steps_with_context, run_steps_with_context_result};
12pub use oxdock_parser::{Guard, Step, StepKind, parse_script};
13pub use oxdock_process::shell_program;
14
15pub fn run() -> Result<()> {
16    let workspace_root = discover_workspace_root().context("guard workspace root")?;
17
18    let mut args = std::env::args().skip(1);
19    let opts = Options::parse(&mut args, &workspace_root)?;
20    execute(opts, workspace_root)
21}
22
23#[derive(Debug, Clone)]
24pub enum ScriptSource {
25    Path(GuardedPath),
26    Stdin,
27}
28
29#[derive(Debug, Clone)]
30pub struct Options {
31    pub script: ScriptSource,
32    pub shell: bool,
33}
34
35impl Options {
36    pub fn parse(
37        args: &mut impl Iterator<Item = String>,
38        workspace_root: &GuardedPath,
39    ) -> Result<Self> {
40        let mut script: Option<ScriptSource> = None;
41        let mut shell = false;
42        while let Some(arg) = args.next() {
43            if arg.is_empty() {
44                continue;
45            }
46            match arg.as_str() {
47                "--script" => {
48                    let p = args
49                        .next()
50                        .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
51                    if p == "-" {
52                        script = Some(ScriptSource::Stdin);
53                    } else {
54                        script = Some(ScriptSource::Path(
55                            workspace_root
56                                .join(&p)
57                                .with_context(|| format!("guard script path {p}"))?,
58                        ));
59                    }
60                }
61                "--shell" => {
62                    shell = true;
63                }
64                other => bail!("unexpected flag: {}", other),
65            }
66        }
67
68        let script = script.unwrap_or(ScriptSource::Stdin);
69
70        Ok(Self { script, shell })
71    }
72}
73
74pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
75    execute_with_shell_runner(opts, workspace_root, run_shell, true)
76}
77
78fn execute_with_shell_runner<F>(
79    opts: Options,
80    workspace_root: GuardedPath,
81    shell_runner: F,
82    require_tty: bool,
83) -> Result<()>
84where
85    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
86{
87    #[cfg(windows)]
88    maybe_reexec_shell_to_temp(&opts)?;
89
90    let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
91    let temp_root = tempdir.as_guarded_path().clone();
92
93    // Interpret a tiny Dockerfile-ish script
94    let script = match &opts.script {
95        ScriptSource::Path(path) => {
96            // Read script path via PathResolver rooted at the workspace so
97            // script files are validated to live under the workspace.
98            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
99            resolver
100                .read_to_string(path)
101                .with_context(|| format!("failed to read script at {}", path.display()))?
102        }
103        ScriptSource::Stdin => {
104            let stdin = io::stdin();
105            if stdin.is_terminal() {
106                // No piped script provided. If the caller requested `--shell`
107                // allow running with an initially-empty script so we can either
108                // drop into the interactive shell or open the editor later.
109                // Otherwise, require a script on stdin.
110                if opts.shell {
111                    String::new()
112                } else {
113                    bail!(
114                        "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
115                    );
116                }
117            } else {
118                let mut buf = String::new();
119                stdin
120                    .lock()
121                    .read_to_string(&mut buf)
122                    .context("failed to read script from stdin")?;
123                buf
124            }
125        }
126    };
127
128    // Parse and run steps if we have a non-empty script. Empty scripts are
129    // valid when `--shell` is requested and the caller didn't pipe a script.
130    let mut final_cwd = temp_root.clone();
131    if !script.trim().is_empty() {
132        let steps = parse_script(&script)?;
133        // Use the caller's workspace as the build context so WORKSPACE LOCAL can hop back and so COPY
134        // can source from the original tree if needed. Capture the final working directory so shells
135        // inherit whatever WORKDIR the script ended on.
136        final_cwd = run_steps_with_context_result(&temp_root, &workspace_root, &steps)?;
137    }
138
139    // If requested, drop into an interactive shell after running the script.
140    if opts.shell {
141        if require_tty && !has_controlling_tty() {
142            bail!("--shell requires a tty (no controlling tty available)");
143        }
144        return shell_runner(&final_cwd, &workspace_root);
145    }
146
147    Ok(())
148}
149
150#[cfg(test)]
151fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
152where
153    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
154{
155    execute_with_shell_runner(opts, workspace_root, shell_runner, false)
156}
157
158fn has_controlling_tty() -> bool {
159    // Prefer checking whether stdin or stderr is a terminal. This avoids
160    // directly opening device files via `std::fs` while still detecting
161    // whether an interactive tty is available in the common cases.
162    #[cfg(unix)]
163    {
164        io::stdin().is_terminal() || io::stderr().is_terminal()
165    }
166
167    #[cfg(windows)]
168    {
169        io::stdin().is_terminal() || io::stderr().is_terminal()
170    }
171
172    #[cfg(not(any(unix, windows)))]
173    {
174        false
175    }
176}
177
178#[cfg(windows)]
179fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
180    // Only used for interactive shells. Copy the binary to a temp path and run it there so the
181    // original target exe is free for rebuilding while the shell stays open.
182    if !opts.shell {
183        return Ok(());
184    }
185    if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
186        return Ok(());
187    }
188
189    let self_path = std::env::current_exe().context("determine current executable")?;
190    let base_temp =
191        GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
192    let ts = std::time::SystemTime::now()
193        .duration_since(std::time::UNIX_EPOCH)
194        .unwrap_or_default()
195        .as_millis();
196    let temp_file = base_temp
197        .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
198        .context("construct temp shell path")?;
199
200    // Copy the current executable into the temporary location via a
201    // resolver whose root is the temp directory. The source may live
202    // outside the temp dir, so use `copy_file_from_external`.
203    let temp_root_guard = temp_file
204        .parent()
205        .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
206    let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
207    let dest = temp_file;
208    #[allow(clippy::disallowed_types)]
209    let source = oxdock_fs::UnguardedPath::new(self_path);
210    resolver_temp
211        .copy_file_from_unguarded(&source, &dest)
212        .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
213
214    let mut cmd = CommandBuilder::new(dest.as_path());
215    cmd.args(std::env::args_os().skip(1));
216    cmd.env("OXDOCK_SHELL_REEXEC", "1");
217    cmd.spawn()
218        .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
219
220    // Exit immediately so the original binary can be rebuilt while the shell child stays running.
221    std::process::exit(0);
222}
223
224pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
225    run_steps_with_context(workspace_root, workspace_root, steps)
226}
227
228fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
229    #[cfg(windows)]
230    let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
231    #[cfg(windows)]
232    let workspace_disp = oxdock_fs::command_path(workspace_root)
233        .as_ref()
234        .display()
235        .to_string();
236
237    #[cfg(not(windows))]
238    let cwd_disp = cwd.display().to_string();
239    #[cfg(not(windows))]
240    let workspace_disp = workspace_root.display().to_string();
241
242    let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
243    indoc::formatdoc! {"
244        {pkg} shell workspace
245          cwd: {cwd_disp}
246          source: workspace root at {workspace_disp}
247          lifetime: temporary directory created for this shell session; it disappears when you exit
248          creation: temp workspace starts empty unless your script copies files into it
249
250          WARNING: This shell still runs on your host filesystem and is **not** isolated!
251    "}
252}
253
254#[cfg(windows)]
255fn escape_for_cmd(s: &str) -> String {
256    // Escape characters that would otherwise be interpreted by cmd when echoed.
257    s.replace('^', "^^")
258        .replace('&', "^&")
259        .replace('|', "^|")
260        .replace('>', "^>")
261        .replace('<', "^<")
262}
263
264#[cfg(windows)]
265fn windows_banner_command(banner: &str, cwd: &GuardedPath) -> String {
266    let mut parts: Vec<String> = banner
267        .lines()
268        .map(|line| format!("echo {}", escape_for_cmd(line)))
269        .collect();
270    let cwd_path = oxdock_fs::command_path(cwd);
271    parts.push(format!(
272        "cd /d {}",
273        escape_for_cmd(&cwd_path.as_ref().display().to_string())
274    ));
275    parts.join(" && ")
276}
277
278// TODO: Migrate to oxdock-process crate so that Miri flags don't need to be handled here.
279fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
280    let banner = shell_banner(cwd, workspace_root);
281
282    #[cfg(unix)]
283    {
284        let mut cmd = CommandBuilder::new(shell_program());
285        cmd.current_dir(cwd.as_path());
286
287        // Print a single banner inside the subshell, then exec the user's shell to stay interactive.
288        let script = format!("printf '%s\\n' \"{}\"; exec {}", banner, shell_program());
289        cmd.arg("-c").arg(script);
290
291        // Reattach stdin to the controlling TTY so a piped-in script can still open an interactive shell.
292        // Use `PathResolver::open_external_file` to centralize raw `File::open` usage.
293        #[cfg(not(miri))]
294        {
295            #[allow(clippy::disallowed_types)]
296            let tty_path = oxdock_fs::UnguardedPath::new("/dev/tty");
297            if let Ok(resolver) =
298                PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
299                && let Ok(tty) = resolver.open_file_unguarded(&tty_path)
300            {
301                cmd.stdin_file(tty);
302            }
303        }
304
305        if try_shell_command_hook(&mut cmd)? {
306            return Ok(());
307        }
308
309        let status = cmd.status()?;
310        if !status.success() {
311            bail!("shell exited with status {}", status);
312        }
313        Ok(())
314    }
315
316    #[cfg(windows)]
317    {
318        // Launch via `start` so Windows opens a real interactive console window. Normalize the path
319        // and also set the parent process working directory to the temp workspace; this avoids
320        // start's `/D` parsing quirks on paths with spaces or verbatim prefixes.
321        let cwd_path = oxdock_fs::command_path(cwd);
322        let banner_cmd = windows_banner_command(&banner, cwd);
323        let mut cmd = CommandBuilder::new("cmd");
324        cmd.current_dir(cwd_path.as_ref())
325            .arg("/C")
326            .arg("start")
327            .arg("oxdock shell")
328            .arg("cmd")
329            .arg("/K")
330            .arg(banner_cmd);
331
332        if try_shell_command_hook(&mut cmd)? {
333            return Ok(());
334        }
335
336        // Fire-and-forget so the parent console regains control immediately; the child window is
337        // fully interactive. If the launch fails, surface the error right away.
338        cmd.spawn()
339            .context("failed to start interactive shell window")?;
340        Ok(())
341    }
342
343    #[cfg(not(any(unix, windows)))]
344    {
345        let _ = cwd;
346        bail!("interactive shell unsupported on this platform");
347    }
348}
349#[cfg(test)]
350type ShellCmdHook = dyn FnMut(&CommandSnapshot) -> Result<()> + Send;
351
352#[cfg(test)]
353thread_local! {
354    static SHELL_CMD_HOOK: std::cell::RefCell<Option<Box<ShellCmdHook>>> = std::cell::RefCell::new(None);
355}
356
357#[cfg(test)]
358fn set_shell_command_hook<F>(hook: F)
359where
360    F: FnMut(&CommandSnapshot) -> Result<()> + Send + 'static,
361{
362    SHELL_CMD_HOOK.with(|slot| {
363        *slot.borrow_mut() = Some(Box::new(hook));
364    });
365}
366
367#[cfg(test)]
368fn clear_shell_command_hook() {
369    SHELL_CMD_HOOK.with(|slot| {
370        *slot.borrow_mut() = None;
371    });
372}
373
374#[cfg(test)]
375fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
376    SHELL_CMD_HOOK.with(|slot| {
377        if let Some(hook) = slot.borrow_mut().as_mut() {
378            let snap = cmd.snapshot();
379            hook(&snap)?;
380            return Ok(true);
381        }
382        Ok(false)
383    })
384}
385
386#[cfg(not(test))]
387fn try_shell_command_hook(_cmd: &mut CommandBuilder) -> Result<bool> {
388    Ok(false)
389}
390
391// `command_path` now lives in `oxdock-fs` to centralize Path usage.
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use indoc::indoc;
397    use oxdock_fs::PathResolver;
398    use std::cell::Cell;
399
400    #[cfg_attr(
401        miri,
402        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
403    )]
404    #[test]
405    fn shell_runner_receives_final_workdir() -> Result<()> {
406        let workspace = GuardedPath::tempdir()?;
407        let workspace_root = workspace.as_guarded_path().clone();
408        let script_path = workspace_root.join("script.ox")?;
409        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
410        let script = indoc! {"
411            WRITE temp.txt 123
412            WORKDIR sub
413        "};
414        resolver.write_file(&script_path, script.as_bytes())?;
415
416        let opts = Options {
417            script: ScriptSource::Path(script_path),
418            shell: true,
419        };
420
421        let observed = Cell::new(false);
422        execute_for_test(opts, workspace_root.clone(), |cwd, _| {
423            assert!(
424                cwd.as_path().ends_with("sub"),
425                "final cwd should end in WORKDIR target, got {}",
426                cwd.display()
427            );
428
429            let temp_root = GuardedPath::new_root(cwd.root())
430                .context("construct guard for temp workspace root")?;
431            let sub_dir = temp_root.join("sub")?;
432            assert_eq!(
433                cwd.as_path(),
434                sub_dir.as_path(),
435                "shell runner cwd should match guarded sub dir"
436            );
437            let temp_file = temp_root.join("temp.txt")?;
438            let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
439            let contents = temp_resolver.read_to_string(&temp_file)?;
440            assert!(
441                contents.contains("123"),
442                "expected WRITE command to materialize temp file"
443            );
444            observed.set(true);
445            Ok(())
446        })?;
447
448        assert!(
449            observed.into_inner(),
450            "shell runner closure should have been invoked"
451        );
452        Ok(())
453    }
454
455    #[cfg(any(unix, windows))]
456    #[test]
457    fn run_shell_builds_command_for_platform() -> Result<()> {
458        let workspace = GuardedPath::tempdir()?;
459        let workspace_root = workspace.as_guarded_path().clone();
460        let cwd = workspace_root.join("subdir")?;
461        #[cfg(not(miri))]
462        {
463            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
464            resolver.create_dir_all(&cwd)?;
465        }
466
467        let captured = std::sync::Arc::new(Mutex::new(None::<CommandSnapshot>));
468        let guard = captured.clone();
469        set_shell_command_hook(move |cmd| {
470            *guard.lock().unwrap() = Some(cmd.clone());
471            Ok(())
472        });
473        run_shell(&cwd, &workspace_root)?;
474        clear_shell_command_hook();
475
476        let snap = captured
477            .lock()
478            .unwrap()
479            .clone()
480            .expect("hook should capture snapshot");
481        let cwd_path = snap.cwd.expect("cwd should be set");
482        assert!(
483            cwd_path.ends_with("subdir"),
484            "expected cwd to include subdir, got {}",
485            cwd_path.display()
486        );
487
488        #[cfg(unix)]
489        {
490            let program = snap.program.to_string_lossy();
491            assert_eq!(program, shell_program(), "expected shell program name");
492            let args: Vec<_> = snap
493                .args
494                .iter()
495                .map(|s| s.to_string_lossy().to_string())
496                .collect();
497            assert_eq!(
498                args.len(),
499                2,
500                "expected two args (-c script), got {:?}",
501                args
502            );
503            assert_eq!(args[0], "-c");
504            assert!(
505                args[1].contains("exec"),
506                "expected script to exec the shell, got {:?}",
507                args[1]
508            );
509        }
510
511        #[cfg(windows)]
512        {
513            let program = snap.program.to_string_lossy().to_string();
514            assert_eq!(program, "cmd", "expected cmd.exe launcher");
515            let args: Vec<_> = snap
516                .args
517                .iter()
518                .map(|s| s.to_string_lossy().to_string())
519                .collect();
520            let banner_cmd = windows_banner_command(&shell_banner(&cwd, &workspace_root), &cwd);
521            let expected = vec![
522                "/C".to_string(),
523                "start".to_string(),
524                "oxdock shell".to_string(),
525                "cmd".to_string(),
526                "/K".to_string(),
527                banner_cmd,
528            ];
529            assert_eq!(args, expected, "expected exact windows shell argv");
530        }
531
532        Ok(())
533    }
534}
535
536#[cfg(all(test, windows))]
537mod windows_shell_tests {
538    use super::*;
539    use oxdock_fs::PathResolver;
540
541    #[test]
542    fn command_path_strips_verbatim_prefix() -> Result<()> {
543        let temp = GuardedPath::tempdir()?;
544        let converted = oxdock_fs::command_path(temp.as_guarded_path());
545        let as_str = converted.as_ref().display().to_string();
546        assert!(
547            !as_str.starts_with(r"\\?\"),
548            "expected non-verbatim path, got {as_str}"
549        );
550        Ok(())
551    }
552
553    #[test]
554    fn windows_banner_command_emits_all_lines() {
555        let banner = "line1\nline2\nline3";
556        let workspace = GuardedPath::tempdir().expect("tempdir");
557        let cwd = workspace.as_guarded_path().clone();
558        let cmd = windows_banner_command(banner, &cwd);
559        assert!(cmd.contains("line1"));
560        assert!(cmd.contains("line2"));
561        assert!(cmd.contains("line3"));
562        assert!(cmd.contains("cd /d "));
563    }
564
565    #[test]
566    fn run_shell_builds_windows_command() -> Result<()> {
567        let workspace = GuardedPath::tempdir_with(|builder| {
568            builder.prefix("oxdock shell win ");
569        })?;
570        let workspace_root = workspace.as_guarded_path().clone();
571        let cwd = workspace_root.join("subdir")?;
572        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
573        resolver.create_dir_all(&cwd)?;
574
575        let captured = std::sync::Arc::new(Mutex::new(None::<CommandSnapshot>));
576        let guard = captured.clone();
577        set_shell_command_hook(move |cmd| {
578            *guard.lock().unwrap() = Some(cmd.clone());
579            Ok(())
580        });
581        run_shell(&cwd, &workspace_root)?;
582        clear_shell_command_hook();
583
584        let snap = captured
585            .lock()
586            .unwrap()
587            .clone()
588            .expect("hook should capture snapshot");
589        let program = snap.program.to_string_lossy().to_string();
590        assert_eq!(program, "cmd", "expected cmd.exe launcher");
591        let args: Vec<_> = snap
592            .args
593            .iter()
594            .map(|s| s.to_string_lossy().to_string())
595            .collect();
596        let banner_cmd = windows_banner_command(&shell_banner(&cwd, &workspace_root), &cwd);
597        let expected = vec![
598            "/C".to_string(),
599            "start".to_string(),
600            "oxdock shell".to_string(),
601            "cmd".to_string(),
602            "/K".to_string(),
603            banner_cmd,
604        ];
605        assert_eq!(args, expected, "expected exact windows shell argv");
606        let cwd_path = snap.cwd.expect("cwd should be set");
607        assert!(
608            cwd_path.ends_with("subdir"),
609            "expected cwd to include subdir, got {}",
610            cwd_path.display()
611        );
612        Ok(())
613    }
614}