Skip to main content

oxdock_cli/
lib.rs

1use anyhow::{Context, Result, bail};
2use oxdock_fs::{GuardedPath, GuardedTempDir, PathResolver, discover_workspace_root, init_temp_gc};
3#[cfg(windows)]
4use oxdock_process::CommandBuilder;
5use oxdock_process::SharedInput;
6use std::env;
7use std::io::{self, IsTerminal, Read};
8use std::sync::{Arc, Mutex};
9
10use oxdock_core::{ExecIo, run_steps_with_context_result_with_io};
11pub use oxdock_core::{
12    parse_script, run_steps, run_steps_with_context, run_steps_with_context_result,
13};
14pub use oxdock_parser::{Guard, Step, StepKind};
15pub use oxdock_process::shell_program;
16
17pub fn run() -> Result<()> {
18    init_temp_gc();
19    let workspace_root = discover_workspace_root().context("guard workspace root")?;
20
21    let mut args = std::env::args().skip(1);
22    // `--help`/`-h` surfaces as the usage text in the parse error (parse must
23    // not exit the process itself: it is public library API). Print it and
24    // succeed so the binary exits 0.
25    let opts = match Options::parse(&mut args, &workspace_root) {
26        Ok(opts) => opts,
27        Err(err) if err.to_string() == usage() => {
28            print!("{err}");
29            return Ok(());
30        }
31        Err(err) => return Err(err),
32    };
33    execute(opts, workspace_root)
34}
35
36#[derive(Debug, Clone)]
37pub enum ScriptSource {
38    Path(GuardedPath),
39    Stdin,
40}
41
42#[derive(Debug, Clone)]
43pub struct Options {
44    pub script: ScriptSource,
45    pub shell: bool,
46}
47
48impl Options {
49    pub fn parse(
50        args: &mut impl Iterator<Item = String>,
51        workspace_root: &GuardedPath,
52    ) -> Result<Self> {
53        let mut script: Option<ScriptSource> = None;
54        let mut shell = false;
55        let mut set_script = |source: ScriptSource, origin: &str| -> Result<()> {
56            if script.is_some() {
57                bail!("script given multiple times ({origin})");
58            }
59            script = Some(source);
60            Ok(())
61        };
62        while let Some(arg) = args.next() {
63            if arg.is_empty() {
64                continue;
65            }
66            match arg.as_str() {
67                "--script" => {
68                    let p = args
69                        .next()
70                        .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
71                    if p == "-" {
72                        set_script(ScriptSource::Stdin, "--script -")?;
73                    } else {
74                        set_script(
75                            ScriptSource::Path(
76                                workspace_root
77                                    .join(&p)
78                                    .with_context(|| format!("guard script path {p}"))?,
79                            ),
80                            "--script",
81                        )?;
82                    }
83                }
84                "--shell" => {
85                    shell = true;
86                }
87                "--help" | "-h" => {
88                    bail!("{}", usage());
89                }
90                "-" => set_script(ScriptSource::Stdin, "positional `-`")?,
91                other if other.starts_with('-') => bail!("unexpected flag: {}", other),
92                other => set_script(
93                    ScriptSource::Path(
94                        workspace_root
95                            .join(other)
96                            .with_context(|| format!("guard script path {other}"))?,
97                    ),
98                    "positional argument",
99                )?,
100            }
101        }
102
103        let script = script.unwrap_or(ScriptSource::Stdin);
104
105        Ok(Self { script, shell })
106    }
107}
108
109/// Human-readable CLI usage, printed for `--help`/`-h`.
110pub fn usage() -> String {
111    let version = env!("CARGO_PKG_VERSION");
112    let description = env!("CARGO_PKG_DESCRIPTION");
113    indoc::formatdoc! {"
114        oxdock {version} — {description}
115        Usage: oxdock [OPTIONS] [SCRIPT]
116          SCRIPT             script file path (same as `--script <file>`); `-` reads stdin
117          --script <file|->  script file under the workspace root, or `-` for stdin
118          --shell            run the script, then drop into an interactive shell (requires a TTY)
119          --help, -h         print this help and exit
120        With no script given, reads the script from stdin (must be piped unless `--shell`).
121    "}
122}
123
124pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
125    init_temp_gc();
126    execute_with_shell_runner(opts, workspace_root, run_shell, true)
127}
128
129pub struct ExecutionResult {
130    pub tempdir: GuardedTempDir,
131    pub final_cwd: GuardedPath,
132}
133
134pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
135    if opts.shell {
136        bail!("execute_with_result does not support --shell");
137    }
138
139    let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
140    let temp_root = tempdir.as_guarded_path().clone();
141
142    let script = match &opts.script {
143        ScriptSource::Path(path) => {
144            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
145            resolver
146                .read_to_string(path)
147                .with_context(|| format!("failed to read script at {}", path.display()))?
148        }
149        ScriptSource::Stdin => {
150            let mut buf = String::new();
151            io::stdin()
152                .lock()
153                .read_to_string(&mut buf)
154                .context("failed to read script from stdin")?;
155            buf
156        }
157    };
158
159    let mut final_cwd = temp_root.clone();
160    if !script.trim().is_empty() {
161        let steps = parse_script(&script)?;
162        final_cwd = run_steps_with_context_result_with_io(
163            &temp_root,
164            &workspace_root,
165            &steps,
166            ExecIo::new(),
167        )?;
168    }
169
170    Ok(ExecutionResult { tempdir, final_cwd })
171}
172
173fn execute_with_shell_runner<F>(
174    opts: Options,
175    workspace_root: GuardedPath,
176    shell_runner: F,
177    require_tty: bool,
178) -> Result<()>
179where
180    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
181{
182    #[cfg(windows)]
183    maybe_reexec_shell_to_temp(&opts)?;
184
185    let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
186    let temp_root = tempdir.as_guarded_path().clone();
187
188    // Interpret a tiny Dockerfile-ish script
189    let script = match &opts.script {
190        ScriptSource::Path(path) => {
191            // Read script path via PathResolver rooted at the workspace so
192            // script files are validated to live under the workspace.
193            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
194            resolver
195                .read_to_string(path)
196                .with_context(|| format!("failed to read script at {}", path.display()))?
197        }
198        ScriptSource::Stdin => {
199            let stdin = io::stdin();
200            if stdin.is_terminal() {
201                // No piped script provided. If the caller requested `--shell`
202                // allow running with an initially-empty script so we can either
203                // drop into the interactive shell or open the editor later.
204                // Otherwise, require a script on stdin.
205                if opts.shell {
206                    String::new()
207                } else {
208                    bail!(
209                        "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
210                    );
211                }
212            } else {
213                let mut buf = String::new();
214                stdin
215                    .lock()
216                    .read_to_string(&mut buf)
217                    .context("failed to read script from stdin")?;
218                buf
219            }
220        }
221    };
222
223    // Parse and run steps if we have a non-empty script. Empty scripts are
224    // valid when `--shell` is requested and the caller didn't pipe a script.
225    let mut final_cwd = temp_root.clone();
226    if !script.trim().is_empty() {
227        let steps = parse_script(&script)?;
228        // Use the caller's workspace as the build context so WORKSPACE LOCAL can hop back and so COPY
229        // can source from the original tree if needed. Capture the final working directory so shells
230        // inherit whatever WORKDIR the script ended on.
231
232        // If we are running a script from a file, we might have stdin available for the script itself.
233        // If we read the script from stdin, then stdin is consumed.
234        // But if opts.script is ScriptSource::Path, stdin is still available.
235
236        let mut stdin_handle: Option<SharedInput> = None;
237        if let ScriptSource::Path(_) = opts.script {
238            let stdin = io::stdin();
239            if !stdin.is_terminal() {
240                // Wrap stdin in SharedInput (Arc<Mutex<dyn Read + Send>>)
241                // Note: std::io::Stdin is a handle, but we need an owned Read + Send.
242                // std::io::stdin() returns Stdin, which implements Read + Send.
243                // However, we need to be careful about locking.
244                // We can wrap the Stdin struct directly.
245                stdin_handle = Some(Arc::new(Mutex::new(stdin)));
246            }
247        }
248
249        let mut io_cfg = ExecIo::new();
250        io_cfg.set_stdin(stdin_handle);
251        final_cwd =
252            run_steps_with_context_result_with_io(&temp_root, &workspace_root, &steps, io_cfg)?;
253    }
254
255    // If requested, drop into an interactive shell after running the script.
256    if opts.shell {
257        if require_tty && !has_controlling_tty() {
258            bail!("--shell requires a tty (no controlling tty available)");
259        }
260        return shell_runner(&final_cwd, &workspace_root);
261    }
262
263    Ok(())
264}
265
266#[cfg(test)]
267fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
268where
269    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
270{
271    execute_with_shell_runner(opts, workspace_root, shell_runner, false)
272}
273
274fn has_controlling_tty() -> bool {
275    // Prefer checking whether stdin or stderr is a terminal. This avoids
276    // directly opening device files via `std::fs` while still detecting
277    // whether an interactive tty is available in the common cases.
278    #[cfg(unix)]
279    {
280        io::stdin().is_terminal() || io::stderr().is_terminal()
281    }
282
283    #[cfg(windows)]
284    {
285        io::stdin().is_terminal() || io::stderr().is_terminal()
286    }
287
288    #[cfg(not(any(unix, windows)))]
289    {
290        false
291    }
292}
293
294#[cfg(windows)]
295fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
296    // Only used for interactive shells. Copy the binary to a temp path and run it there so the
297    // original target exe is free for rebuilding while the shell stays open.
298    if !opts.shell {
299        return Ok(());
300    }
301    if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
302        return Ok(());
303    }
304
305    let self_path = std::env::current_exe().context("determine current executable")?;
306    let base_temp =
307        GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
308    let ts = std::time::SystemTime::now()
309        .duration_since(std::time::UNIX_EPOCH)
310        .unwrap_or_default()
311        .as_millis();
312    let temp_file = base_temp
313        .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
314        .context("construct temp shell path")?;
315
316    // Copy the current executable into the temporary location via a
317    // resolver whose root is the temp directory. The source may live
318    // outside the temp dir, so use `copy_file_from_external`.
319    let temp_root_guard = temp_file
320        .parent()
321        .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
322    let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
323    let dest = temp_file;
324    #[allow(clippy::disallowed_types)]
325    let source = oxdock_fs::UnguardedPath::external(self_path);
326    resolver_temp
327        .copy_file_from_unguarded(&source, &dest)
328        .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
329
330    let mut cmd = CommandBuilder::new(dest.as_path());
331    cmd.args(std::env::args_os().skip(1));
332    cmd.env("OXDOCK_SHELL_REEXEC", "1");
333    cmd.spawn()
334        .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
335
336    // Exit immediately so the original binary can be rebuilt while the shell child stays running.
337    std::process::exit(0);
338}
339
340pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
341    run_steps_with_context(workspace_root, workspace_root, steps)
342}
343
344fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
345    #[cfg(windows)]
346    let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
347    #[cfg(windows)]
348    let workspace_disp = oxdock_fs::command_path(workspace_root)
349        .as_ref()
350        .display()
351        .to_string();
352
353    #[cfg(not(windows))]
354    let cwd_disp = cwd.display().to_string();
355    #[cfg(not(windows))]
356    let workspace_disp = workspace_root.display().to_string();
357
358    let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
359    indoc::formatdoc! {"
360        {pkg} shell workspace
361          cwd: {cwd_disp}
362          source: workspace root at {workspace_disp}
363          lifetime: temporary directory created for this shell session; it disappears when you exit
364          creation: temp workspace starts empty unless your script copies files into it
365
366          WARNING: This shell still runs on your host filesystem and is **not** isolated!
367    "}
368}
369
370fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
371    oxdock_process::spawn_interactive_shell(cwd, workspace_root, &shell_banner(cwd, workspace_root))
372}
373
374// `command_path` now lives in `oxdock-fs` to centralize Path usage.
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use indoc::indoc;
380    use oxdock_fs::PathResolver;
381    use std::cell::{Cell, RefCell};
382
383    #[cfg_attr(
384        miri,
385        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
386    )]
387    #[test]
388    fn shell_runner_receives_final_workdir() -> Result<()> {
389        let workspace = GuardedPath::tempdir()?;
390        let workspace_root = workspace.as_guarded_path().clone();
391        let script_path = workspace_root.join("script.ox")?;
392        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
393        let script = indoc! {"
394            WRITE temp.txt 123
395            WORKDIR sub
396        "};
397        resolver.write_file(&script_path, script.as_bytes())?;
398
399        let opts = Options {
400            script: ScriptSource::Path(script_path),
401            shell: true,
402        };
403
404        let observed = Cell::new(false);
405        execute_for_test(opts, workspace_root.clone(), |cwd, _| {
406            assert!(
407                cwd.as_path().ends_with("sub"),
408                "final cwd should end in WORKDIR target, got {}",
409                cwd.display()
410            );
411
412            let temp_root = GuardedPath::new_root(cwd.root())
413                .context("construct guard for temp workspace root")?;
414            let sub_dir = temp_root.join("sub")?;
415            assert_eq!(
416                cwd.as_path(),
417                sub_dir.as_path(),
418                "shell runner cwd should match guarded sub dir"
419            );
420            let temp_file = temp_root.join("temp.txt")?;
421            let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
422            let contents = temp_resolver.read_to_string(&temp_file)?;
423            assert!(
424                contents.contains("123"),
425                "expected WRITE command to materialize temp file"
426            );
427            observed.set(true);
428            Ok(())
429        })?;
430
431        assert!(
432            observed.into_inner(),
433            "shell runner closure should have been invoked"
434        );
435        Ok(())
436    }
437
438    #[cfg_attr(
439        miri,
440        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
441    )]
442    #[test]
443    fn options_parse_requires_script_path_value() {
444        let workspace = GuardedPath::tempdir().expect("tempdir");
445        let mut args = vec!["--script".to_string()].into_iter();
446        let err = Options::parse(&mut args, workspace.as_guarded_path())
447            .expect_err("expected missing path error");
448        assert!(err.to_string().contains("--script requires a path"));
449    }
450
451    #[cfg_attr(
452        miri,
453        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
454    )]
455    #[test]
456    fn options_parse_script_path_and_shell() {
457        let workspace = GuardedPath::tempdir().expect("tempdir");
458        let workspace_root = workspace.as_guarded_path().clone();
459        let script_path = workspace_root.join("script.txt").expect("script path");
460        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
461            .expect("resolver");
462        resolver
463            .write_file(&script_path, b"WRITE out.txt hi")
464            .expect("write script");
465        let mut args = vec![
466            "--script".to_string(),
467            "script.txt".to_string(),
468            "--shell".to_string(),
469        ]
470        .into_iter();
471        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
472        assert!(opts.shell);
473        match opts.script {
474            ScriptSource::Path(path) => assert_eq!(path, script_path),
475            ScriptSource::Stdin => panic!("expected path script"),
476        }
477    }
478
479    #[cfg_attr(
480        miri,
481        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
482    )]
483    #[test]
484    fn options_parse_positional_script_path() {
485        let workspace = GuardedPath::tempdir().expect("tempdir");
486        let workspace_root = workspace.as_guarded_path().clone();
487        let mut args = vec!["script.txt".to_string()].into_iter();
488        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
489        assert!(!opts.shell);
490        match opts.script {
491            ScriptSource::Path(path) => assert_eq!(
492                path,
493                workspace_root.join("script.txt").expect("script path")
494            ),
495            ScriptSource::Stdin => panic!("expected path script"),
496        }
497    }
498
499    #[cfg_attr(
500        miri,
501        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
502    )]
503    #[test]
504    fn options_parse_positional_dash_reads_stdin() {
505        let workspace = GuardedPath::tempdir().expect("tempdir");
506        let mut args = vec!["-".to_string()].into_iter();
507        let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
508        assert!(matches!(opts.script, ScriptSource::Stdin));
509    }
510
511    #[cfg_attr(
512        miri,
513        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
514    )]
515    #[test]
516    fn options_parse_rejects_duplicate_script_sources() {
517        let workspace = GuardedPath::tempdir().expect("tempdir");
518        let workspace_root = workspace.as_guarded_path().clone();
519        let mut args = vec![
520            "a.ox".to_string(),
521            "--script".to_string(),
522            "b.ox".to_string(),
523        ]
524        .into_iter();
525        let err = Options::parse(&mut args, &workspace_root)
526            .expect_err("expected duplicate script error");
527        assert!(err.to_string().contains("multiple times"), "{err:?}");
528
529        let mut args = vec!["a.ox".to_string(), "b.ox".to_string()].into_iter();
530        let err = Options::parse(&mut args, &workspace_root)
531            .expect_err("expected duplicate script error");
532        assert!(err.to_string().contains("multiple times"), "{err:?}");
533    }
534
535    #[cfg_attr(
536        miri,
537        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
538    )]
539    #[test]
540    fn options_parse_rejects_unknown_flags() {
541        let workspace = GuardedPath::tempdir().expect("tempdir");
542        let mut args = vec!["--frobnicate".to_string()].into_iter();
543        let err = Options::parse(&mut args, workspace.as_guarded_path())
544            .expect_err("expected unknown flag error");
545        assert!(err.to_string().contains("unexpected flag"), "{err:?}");
546    }
547
548    #[test]
549    fn usage_describes_positional_script_and_help() {
550        let text = usage();
551        assert!(text.contains("Usage: oxdock"), "{text}");
552        assert!(text.contains("SCRIPT"), "{text}");
553        assert!(text.contains("--script"), "{text}");
554        assert!(text.contains("--help"), "{text}");
555        // Tagline is single-sourced from the package manifest, not hardcoded.
556        assert!(text.contains(env!("CARGO_PKG_DESCRIPTION")), "{text}");
557    }
558
559    #[cfg_attr(
560        miri,
561        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
562    )]
563    #[test]
564    fn options_parse_help_returns_usage_error_without_exiting() {
565        // Regression: parse is public library API and must return instead of
566        // terminating the process; `run()` turns this error into a clean exit 0.
567        let workspace = GuardedPath::tempdir().expect("tempdir");
568        for flag in ["--help", "-h"] {
569            let mut args = vec![flag.to_string()].into_iter();
570            let err = Options::parse(&mut args, workspace.as_guarded_path())
571                .expect_err("help flag must not parse as options");
572            assert_eq!(err.to_string(), usage());
573        }
574    }
575
576    #[cfg_attr(
577        miri,
578        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
579    )]
580    #[test]
581    fn execute_with_result_runs_script() {
582        let workspace = GuardedPath::tempdir().expect("tempdir");
583        let workspace_root = workspace.as_guarded_path().clone();
584        let script_path = workspace_root.join("script.txt").expect("script path");
585        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
586            .expect("resolver");
587        resolver
588            .write_file(&script_path, b"WRITE out.txt hi")
589            .expect("write script");
590        let opts = Options {
591            script: ScriptSource::Path(script_path),
592            shell: false,
593        };
594        let ExecutionResult { tempdir, final_cwd } =
595            execute_with_result(opts, workspace_root).expect("execute");
596        assert_eq!(tempdir.as_guarded_path(), &final_cwd);
597        let temp_resolver = PathResolver::new(
598            tempdir.as_guarded_path().root(),
599            tempdir.as_guarded_path().root(),
600        )
601        .expect("resolver");
602        let out = tempdir.as_guarded_path().join("out.txt").expect("out path");
603        let contents = temp_resolver.read_to_string(&out).expect("read out");
604        assert_eq!(contents.trim(), "hi");
605    }
606
607    #[cfg_attr(
608        miri,
609        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
610    )]
611    #[test]
612    fn execute_for_test_invokes_shell_runner() -> Result<()> {
613        let workspace = GuardedPath::tempdir()?;
614        let workspace_root = workspace.as_guarded_path().clone();
615        let script_path = workspace_root.join("empty.txt")?;
616        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
617        resolver.write_file(&script_path, b"")?;
618        let opts = Options {
619            script: ScriptSource::Path(script_path),
620            shell: true,
621        };
622        let called = RefCell::new(None::<(String, String)>);
623        execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
624            called.replace(Some((cwd.display(), workspace.display())));
625            Ok(())
626        })?;
627        let seen = called.borrow().clone().expect("shell runner called");
628        assert_eq!(seen.1, workspace_root.display());
629        Ok(())
630    }
631}
632
633#[cfg(all(test, windows))]
634mod windows_shell_tests {
635    use super::*;
636
637    #[test]
638    fn command_path_strips_verbatim_prefix() -> Result<()> {
639        let temp = GuardedPath::tempdir()?;
640        let converted = oxdock_fs::command_path(temp.as_guarded_path());
641        let as_str = converted.as_ref().display().to_string();
642        assert!(
643            !as_str.starts_with(r"\\?\"),
644            "expected non-verbatim path, got {as_str}"
645        );
646        Ok(())
647    }
648}