oxdock_cli/
lib.rs

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