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::{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    init_temp_gc();
17    let workspace_root = discover_workspace_root().context("guard workspace root")?;
18
19    let mut args = std::env::args().skip(1);
20    let opts = Options::parse(&mut args, &workspace_root)?;
21    execute(opts, workspace_root)
22}
23
24#[derive(Debug, Clone)]
25pub enum ScriptSource {
26    Path(GuardedPath),
27    Stdin,
28}
29
30#[derive(Debug, Clone)]
31pub struct Options {
32    pub script: ScriptSource,
33    pub shell: bool,
34}
35
36impl Options {
37    pub fn parse(
38        args: &mut impl Iterator<Item = String>,
39        workspace_root: &GuardedPath,
40    ) -> Result<Self> {
41        let mut script: Option<ScriptSource> = None;
42        let mut shell = false;
43        while let Some(arg) = args.next() {
44            if arg.is_empty() {
45                continue;
46            }
47            match arg.as_str() {
48                "--script" => {
49                    let p = args
50                        .next()
51                        .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
52                    if p == "-" {
53                        script = Some(ScriptSource::Stdin);
54                    } else {
55                        script = Some(ScriptSource::Path(
56                            workspace_root
57                                .join(&p)
58                                .with_context(|| format!("guard script path {p}"))?,
59                        ));
60                    }
61                }
62                "--shell" => {
63                    shell = true;
64                }
65                other => bail!("unexpected flag: {}", other),
66            }
67        }
68
69        let script = script.unwrap_or(ScriptSource::Stdin);
70
71        Ok(Self { script, shell })
72    }
73}
74
75pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
76    init_temp_gc();
77    execute_with_shell_runner(opts, workspace_root, run_shell, true)
78}
79
80pub struct ExecutionResult {
81    pub tempdir: GuardedTempDir,
82    pub final_cwd: GuardedPath,
83}
84
85pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
86    if opts.shell {
87        bail!("execute_with_result does not support --shell");
88    }
89
90    let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
91    let temp_root = tempdir.as_guarded_path().clone();
92
93    let script = match &opts.script {
94        ScriptSource::Path(path) => {
95            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
96            resolver
97                .read_to_string(path)
98                .with_context(|| format!("failed to read script at {}", path.display()))?
99        }
100        ScriptSource::Stdin => {
101            let mut buf = String::new();
102            io::stdin()
103                .lock()
104                .read_to_string(&mut buf)
105                .context("failed to read script from stdin")?;
106            buf
107        }
108    };
109
110    let mut final_cwd = temp_root.clone();
111    if !script.trim().is_empty() {
112        let steps = parse_script(&script)?;
113        final_cwd = run_steps_with_context_result_with_io(
114            &temp_root,
115            &workspace_root,
116            &steps,
117            ExecIo::new(),
118        )?;
119    }
120
121    Ok(ExecutionResult { tempdir, final_cwd })
122}
123
124fn execute_with_shell_runner<F>(
125    opts: Options,
126    workspace_root: GuardedPath,
127    shell_runner: F,
128    require_tty: bool,
129) -> Result<()>
130where
131    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
132{
133    #[cfg(windows)]
134    maybe_reexec_shell_to_temp(&opts)?;
135
136    let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
137    let temp_root = tempdir.as_guarded_path().clone();
138
139    // Interpret a tiny Dockerfile-ish script
140    let script = match &opts.script {
141        ScriptSource::Path(path) => {
142            // Read script path via PathResolver rooted at the workspace so
143            // script files are validated to live under the workspace.
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 stdin = io::stdin();
151            if stdin.is_terminal() {
152                // No piped script provided. If the caller requested `--shell`
153                // allow running with an initially-empty script so we can either
154                // drop into the interactive shell or open the editor later.
155                // Otherwise, require a script on stdin.
156                if opts.shell {
157                    String::new()
158                } else {
159                    bail!(
160                        "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
161                    );
162                }
163            } else {
164                let mut buf = String::new();
165                stdin
166                    .lock()
167                    .read_to_string(&mut buf)
168                    .context("failed to read script from stdin")?;
169                buf
170            }
171        }
172    };
173
174    // Parse and run steps if we have a non-empty script. Empty scripts are
175    // valid when `--shell` is requested and the caller didn't pipe a script.
176    let mut final_cwd = temp_root.clone();
177    if !script.trim().is_empty() {
178        let steps = parse_script(&script)?;
179        // Use the caller's workspace as the build context so WORKSPACE LOCAL can hop back and so COPY
180        // can source from the original tree if needed. Capture the final working directory so shells
181        // inherit whatever WORKDIR the script ended on.
182
183        // If we are running a script from a file, we might have stdin available for the script itself.
184        // If we read the script from stdin, then stdin is consumed.
185        // But if opts.script is ScriptSource::Path, stdin is still available.
186
187        let mut stdin_handle: Option<SharedInput> = None;
188        if let ScriptSource::Path(_) = opts.script {
189            let stdin = io::stdin();
190            if !stdin.is_terminal() {
191                // Wrap stdin in SharedInput (Arc<Mutex<dyn Read + Send>>)
192                // Note: std::io::Stdin is a handle, but we need an owned Read + Send.
193                // std::io::stdin() returns Stdin, which implements Read + Send.
194                // However, we need to be careful about locking.
195                // We can wrap the Stdin struct directly.
196                stdin_handle = Some(Arc::new(Mutex::new(stdin)));
197            }
198        }
199
200        let mut io_cfg = ExecIo::new();
201        io_cfg.set_stdin(stdin_handle);
202        final_cwd =
203            run_steps_with_context_result_with_io(&temp_root, &workspace_root, &steps, io_cfg)?;
204    }
205
206    // If requested, drop into an interactive shell after running the script.
207    if opts.shell {
208        if require_tty && !has_controlling_tty() {
209            bail!("--shell requires a tty (no controlling tty available)");
210        }
211        return shell_runner(&final_cwd, &workspace_root);
212    }
213
214    Ok(())
215}
216
217#[cfg(test)]
218fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
219where
220    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
221{
222    execute_with_shell_runner(opts, workspace_root, shell_runner, false)
223}
224
225fn has_controlling_tty() -> bool {
226    // Prefer checking whether stdin or stderr is a terminal. This avoids
227    // directly opening device files via `std::fs` while still detecting
228    // whether an interactive tty is available in the common cases.
229    #[cfg(unix)]
230    {
231        io::stdin().is_terminal() || io::stderr().is_terminal()
232    }
233
234    #[cfg(windows)]
235    {
236        io::stdin().is_terminal() || io::stderr().is_terminal()
237    }
238
239    #[cfg(not(any(unix, windows)))]
240    {
241        false
242    }
243}
244
245#[cfg(windows)]
246fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
247    // Only used for interactive shells. Copy the binary to a temp path and run it there so the
248    // original target exe is free for rebuilding while the shell stays open.
249    if !opts.shell {
250        return Ok(());
251    }
252    if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
253        return Ok(());
254    }
255
256    let self_path = std::env::current_exe().context("determine current executable")?;
257    let base_temp =
258        GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
259    let ts = std::time::SystemTime::now()
260        .duration_since(std::time::UNIX_EPOCH)
261        .unwrap_or_default()
262        .as_millis();
263    let temp_file = base_temp
264        .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
265        .context("construct temp shell path")?;
266
267    // Copy the current executable into the temporary location via a
268    // resolver whose root is the temp directory. The source may live
269    // outside the temp dir, so use `copy_file_from_external`.
270    let temp_root_guard = temp_file
271        .parent()
272        .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
273    let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
274    let dest = temp_file;
275    #[allow(clippy::disallowed_types)]
276    let source = oxdock_fs::UnguardedPath::external(self_path);
277    resolver_temp
278        .copy_file_from_unguarded(&source, &dest)
279        .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
280
281    let mut cmd = CommandBuilder::new(dest.as_path());
282    cmd.args(std::env::args_os().skip(1));
283    cmd.env("OXDOCK_SHELL_REEXEC", "1");
284    cmd.spawn()
285        .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
286
287    // Exit immediately so the original binary can be rebuilt while the shell child stays running.
288    std::process::exit(0);
289}
290
291pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
292    run_steps_with_context(workspace_root, workspace_root, steps)
293}
294
295fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
296    #[cfg(windows)]
297    let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
298    #[cfg(windows)]
299    let workspace_disp = oxdock_fs::command_path(workspace_root)
300        .as_ref()
301        .display()
302        .to_string();
303
304    #[cfg(not(windows))]
305    let cwd_disp = cwd.display().to_string();
306    #[cfg(not(windows))]
307    let workspace_disp = workspace_root.display().to_string();
308
309    let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
310    indoc::formatdoc! {"
311        {pkg} shell workspace
312          cwd: {cwd_disp}
313          source: workspace root at {workspace_disp}
314          lifetime: temporary directory created for this shell session; it disappears when you exit
315          creation: temp workspace starts empty unless your script copies files into it
316
317          WARNING: This shell still runs on your host filesystem and is **not** isolated!
318    "}
319}
320
321fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
322    oxdock_process::spawn_interactive_shell(cwd, workspace_root, &shell_banner(cwd, workspace_root))
323}
324
325// `command_path` now lives in `oxdock-fs` to centralize Path usage.
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use indoc::indoc;
331    use oxdock_fs::PathResolver;
332    use std::cell::{Cell, RefCell};
333
334    #[cfg_attr(
335        miri,
336        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
337    )]
338    #[test]
339    fn shell_runner_receives_final_workdir() -> Result<()> {
340        let workspace = GuardedPath::tempdir()?;
341        let workspace_root = workspace.as_guarded_path().clone();
342        let script_path = workspace_root.join("script.ox")?;
343        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
344        let script = indoc! {"
345            WRITE temp.txt 123
346            WORKDIR sub
347        "};
348        resolver.write_file(&script_path, script.as_bytes())?;
349
350        let opts = Options {
351            script: ScriptSource::Path(script_path),
352            shell: true,
353        };
354
355        let observed = Cell::new(false);
356        execute_for_test(opts, workspace_root.clone(), |cwd, _| {
357            assert!(
358                cwd.as_path().ends_with("sub"),
359                "final cwd should end in WORKDIR target, got {}",
360                cwd.display()
361            );
362
363            let temp_root = GuardedPath::new_root(cwd.root())
364                .context("construct guard for temp workspace root")?;
365            let sub_dir = temp_root.join("sub")?;
366            assert_eq!(
367                cwd.as_path(),
368                sub_dir.as_path(),
369                "shell runner cwd should match guarded sub dir"
370            );
371            let temp_file = temp_root.join("temp.txt")?;
372            let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
373            let contents = temp_resolver.read_to_string(&temp_file)?;
374            assert!(
375                contents.contains("123"),
376                "expected WRITE command to materialize temp file"
377            );
378            observed.set(true);
379            Ok(())
380        })?;
381
382        assert!(
383            observed.into_inner(),
384            "shell runner closure should have been invoked"
385        );
386        Ok(())
387    }
388
389    #[cfg_attr(
390        miri,
391        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
392    )]
393    #[test]
394    fn options_parse_requires_script_path_value() {
395        let workspace = GuardedPath::tempdir().expect("tempdir");
396        let mut args = vec!["--script".to_string()].into_iter();
397        let err = Options::parse(&mut args, workspace.as_guarded_path())
398            .expect_err("expected missing path error");
399        assert!(err.to_string().contains("--script requires a path"));
400    }
401
402    #[cfg_attr(
403        miri,
404        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
405    )]
406    #[test]
407    fn options_parse_script_path_and_shell() {
408        let workspace = GuardedPath::tempdir().expect("tempdir");
409        let workspace_root = workspace.as_guarded_path().clone();
410        let script_path = workspace_root.join("script.txt").expect("script path");
411        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
412            .expect("resolver");
413        resolver
414            .write_file(&script_path, b"WRITE out.txt hi")
415            .expect("write script");
416        let mut args = vec![
417            "--script".to_string(),
418            "script.txt".to_string(),
419            "--shell".to_string(),
420        ]
421        .into_iter();
422        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
423        assert!(opts.shell);
424        match opts.script {
425            ScriptSource::Path(path) => assert_eq!(path, script_path),
426            ScriptSource::Stdin => panic!("expected path script"),
427        }
428    }
429
430    #[cfg_attr(
431        miri,
432        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
433    )]
434    #[test]
435    fn execute_with_result_runs_script() {
436        let workspace = GuardedPath::tempdir().expect("tempdir");
437        let workspace_root = workspace.as_guarded_path().clone();
438        let script_path = workspace_root.join("script.txt").expect("script path");
439        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
440            .expect("resolver");
441        resolver
442            .write_file(&script_path, b"WRITE out.txt hi")
443            .expect("write script");
444        let opts = Options {
445            script: ScriptSource::Path(script_path),
446            shell: false,
447        };
448        let ExecutionResult { tempdir, final_cwd } =
449            execute_with_result(opts, workspace_root).expect("execute");
450        assert_eq!(tempdir.as_guarded_path(), &final_cwd);
451        let temp_resolver = PathResolver::new(
452            tempdir.as_guarded_path().root(),
453            tempdir.as_guarded_path().root(),
454        )
455        .expect("resolver");
456        let out = tempdir.as_guarded_path().join("out.txt").expect("out path");
457        let contents = temp_resolver.read_to_string(&out).expect("read out");
458        assert_eq!(contents.trim(), "hi");
459    }
460
461    #[cfg_attr(
462        miri,
463        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
464    )]
465    #[test]
466    fn execute_for_test_invokes_shell_runner() -> Result<()> {
467        let workspace = GuardedPath::tempdir()?;
468        let workspace_root = workspace.as_guarded_path().clone();
469        let script_path = workspace_root.join("empty.txt")?;
470        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
471        resolver.write_file(&script_path, b"")?;
472        let opts = Options {
473            script: ScriptSource::Path(script_path),
474            shell: true,
475        };
476        let called = RefCell::new(None::<(String, String)>);
477        execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
478            called.replace(Some((cwd.display(), workspace.display())));
479            Ok(())
480        })?;
481        let seen = called.borrow().clone().expect("shell runner called");
482        assert_eq!(seen.1, workspace_root.display());
483        Ok(())
484    }
485}
486
487#[cfg(all(test, windows))]
488mod windows_shell_tests {
489    use super::*;
490
491    #[test]
492    fn command_path_strips_verbatim_prefix() -> Result<()> {
493        let temp = GuardedPath::tempdir()?;
494        let converted = oxdock_fs::command_path(temp.as_guarded_path());
495        let as_str = converted.as_ref().display().to_string();
496        assert!(
497            !as_str.starts_with(r"\\?\"),
498            "expected non-verbatim path, got {as_str}"
499        );
500        Ok(())
501    }
502}