oxdock_cli/
lib.rs

1use anyhow::{Context, Result, bail};
2use std::env;
3use std::fs;
4use std::io::{self, IsTerminal, Read};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8pub use oxdock_core::{
9    Guard, Step, StepKind, parse_script, run_steps, run_steps_with_context, shell_program,
10};
11
12pub fn run() -> Result<()> {
13    let mut args = std::env::args().skip(1);
14    let opts = Options::parse(&mut args)?;
15    execute(opts)
16}
17
18#[derive(Debug, Clone)]
19pub enum ScriptSource {
20    Path(PathBuf),
21    Stdin,
22}
23
24#[derive(Debug, Clone)]
25pub struct Options {
26    pub script: ScriptSource,
27    pub shell: bool,
28}
29
30impl Options {
31    pub fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
32        let mut script: Option<ScriptSource> = None;
33        let mut shell = false;
34        while let Some(arg) = args.next() {
35            if arg.is_empty() {
36                continue;
37            }
38            match arg.as_str() {
39                "--script" => {
40                    let p = args
41                        .next()
42                        .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
43                    if p == "-" {
44                        script = Some(ScriptSource::Stdin);
45                    } else {
46                        script = Some(ScriptSource::Path(PathBuf::from(p)));
47                    }
48                }
49                "--shell" => {
50                    shell = true;
51                }
52                other => bail!("unexpected flag: {}", other),
53            }
54        }
55
56        let script = script.unwrap_or(ScriptSource::Stdin);
57
58        Ok(Self { script, shell })
59    }
60}
61
62pub fn execute(opts: Options) -> Result<()> {
63    #[cfg(windows)]
64    maybe_reexec_shell_to_temp(&opts)?;
65
66    // Prefer the runtime env var when present (e.g., under `cargo run`), but fall back to the
67    // compile-time value so the binary can be invoked directly without CARGO_MANIFEST_DIR set.
68    let workspace_root = discover_workspace_root()?;
69
70    let temp = tempfile::tempdir().context("failed to create temp dir")?;
71    // Keep the workspace alive for interactive shells; otherwise the tempdir cleans up on drop.
72    let temp_path = if opts.shell {
73        temp.keep()
74    } else {
75        temp.path().to_path_buf()
76    };
77
78    // Materialize source tree without .git
79    archive_head(&workspace_root, &temp_path)?;
80
81    // Interpret a tiny Dockerfile-ish script
82    let script = match &opts.script {
83        ScriptSource::Path(path) => fs::read_to_string(path)
84            .with_context(|| format!("failed to read script at {}", path.display()))?,
85        ScriptSource::Stdin => {
86            let stdin = io::stdin();
87            if stdin.is_terminal() {
88                // No piped script provided. If the caller requested `--shell`,
89                // allow running with an empty script and drop into the interactive
90                // shell afterwards. Otherwise, require a script on stdin.
91                if opts.shell {
92                    String::new()
93                } else {
94                    bail!(
95                        "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
96                    );
97                }
98            } else {
99                let mut buf = String::new();
100                stdin
101                    .lock()
102                    .read_to_string(&mut buf)
103                    .context("failed to read script from stdin")?;
104                buf
105            }
106        }
107    };
108    // Parse and run steps if we have a non-empty script. Empty scripts are
109    // valid when `--shell` is requested and the caller didn't pipe a script.
110    if !script.trim().is_empty() {
111        let steps = parse_script(&script)?;
112        // Use the caller's workspace as the build context so WORKSPACE LOCAL can hop back and so COPY
113        // can source from the original tree if needed.
114        run_steps_with_context(&temp_path, &workspace_root, &steps)?;
115    }
116
117    // If requested, drop into an interactive shell after running the script.
118    if opts.shell {
119        if !has_controlling_tty() {
120            bail!("--shell requires a tty (no controlling tty available)");
121        }
122        return run_shell(&temp_path, &workspace_root);
123    }
124
125    Ok(())
126}
127
128fn has_controlling_tty() -> bool {
129    #[cfg(unix)]
130    {
131        std::fs::File::open("/dev/tty").is_ok()
132    }
133
134    #[cfg(windows)]
135    {
136        std::fs::File::open("CONIN$").is_ok()
137    }
138
139    #[cfg(not(any(unix, windows)))]
140    {
141        false
142    }
143}
144
145#[cfg(windows)]
146fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
147    // Only used for interactive shells. Copy the binary to a temp path and run it there so the
148    // original target exe is free for rebuilding while the shell stays open.
149    if !opts.shell {
150        return Ok(());
151    }
152    if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
153        return Ok(());
154    }
155
156    let self_path = std::env::current_exe().context("determine current executable")?;
157    let mut temp_path = std::env::temp_dir();
158    let ts = std::time::SystemTime::now()
159        .duration_since(std::time::UNIX_EPOCH)
160        .unwrap_or_default()
161        .as_millis();
162    temp_path.push(format!("oxdock-shell-{ts}-{}.exe", std::process::id()));
163
164    fs::copy(&self_path, &temp_path)
165        .with_context(|| format!("failed to copy shell runner to {}", temp_path.display()))?;
166
167    let mut cmd = Command::new(&temp_path);
168    cmd.args(std::env::args_os().skip(1));
169    cmd.env("OXDOCK_SHELL_REEXEC", "1");
170
171    cmd.spawn()
172        .with_context(|| format!("failed to spawn shell from {}", temp_path.display()))?;
173
174    // Exit immediately so the original binary can be rebuilt while the shell child stays running.
175    std::process::exit(0);
176}
177
178fn discover_workspace_root() -> Result<PathBuf> {
179    if let Ok(root) = std::env::var("OXDOCK_WORKSPACE_ROOT") {
180        return Ok(PathBuf::from(root));
181    }
182
183    if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR")
184        && let Some(parent) = PathBuf::from(manifest_dir).parent()
185    {
186        return Ok(parent.to_path_buf());
187    }
188
189    // Prefer the git repository root of the current working directory.
190    if let Ok(output) = Command::new("git")
191        .arg("rev-parse")
192        .arg("--show-toplevel")
193        .output()
194        && output.status.success()
195    {
196        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
197        if !path.is_empty() {
198            return Ok(PathBuf::from(path));
199        }
200    }
201
202    std::env::current_dir().context("failed to determine current directory for workspace root")
203}
204pub fn run_script(workspace_root: &Path, steps: &[Step]) -> Result<()> {
205    run_steps_with_context(workspace_root, workspace_root, steps)
206}
207
208fn shell_banner(cwd: &Path, workspace_root: &Path) -> String {
209    let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
210    format!(
211        "{} shell workspace: {} (materialized from git HEAD at {})",
212        pkg,
213        cwd.display(),
214        workspace_root.display()
215    )
216}
217
218#[cfg(windows)]
219fn escape_for_cmd(s: &str) -> String {
220    // Escape characters that would otherwise be interpreted by cmd when echoed.
221    s.replace('^', "^^")
222        .replace('&', "^&")
223        .replace('|', "^|")
224        .replace('>', "^>")
225        .replace('<', "^<")
226}
227
228fn run_shell(cwd: &Path, workspace_root: &Path) -> Result<()> {
229    let banner = shell_banner(cwd, workspace_root);
230
231    #[cfg(unix)]
232    {
233        let mut cmd = Command::new(shell_program());
234        cmd.current_dir(cwd);
235
236        // Print a single banner inside the subshell, then exec the user's shell to stay interactive.
237        let script = format!("printf '%s\\n' \"{}\"; exec {}", banner, shell_program());
238        cmd.arg("-c").arg(script);
239
240        // Reattach stdin to the controlling TTY so a piped-in script can still open an interactive shell.
241        if let Ok(tty) = fs::File::open("/dev/tty") {
242            cmd.stdin(tty);
243        }
244
245        let status = cmd.status()?;
246        if !status.success() {
247            bail!("shell exited with status {}", status);
248        }
249        Ok(())
250    }
251
252    #[cfg(windows)]
253    {
254        // Launch via `start` so Windows opens a real interactive console window rooted at the temp
255        // workspace. Using `start` avoids stdin/handle inheritance issues that can make the child
256        // non-interactive when CREATE_NEW_CONSOLE is set.
257        let mut cmd = Command::new("cmd");
258        cmd.arg("/C")
259            .arg("start")
260            .arg("oxdock shell")
261            .arg("/D")
262            .arg(cwd)
263            .arg("cmd")
264            .arg("/K")
265            .arg(format!("echo {} && cd /d .", escape_for_cmd(&banner)));
266
267        // Fire-and-forget so the parent console regains control immediately; the child window is
268        // fully interactive. If the launch fails, surface the error right away.
269        cmd.spawn()
270            .context("failed to start interactive shell window")?;
271        Ok(())
272    }
273
274    #[cfg(not(any(unix, windows)))]
275    {
276        let _ = cwd;
277        bail!("interactive shell unsupported on this platform");
278    }
279}
280fn run_cmd(cmd: &mut Command) -> Result<()> {
281    let status = cmd
282        .status()
283        .with_context(|| format!("failed to run {:?}", cmd))?;
284    if !status.success() {
285        bail!("command {:?} failed with status {}", cmd, status);
286    }
287    Ok(())
288}
289
290fn archive_head(workspace_root: &Path, temp_root: &Path) -> Result<()> {
291    let archive_path = temp_root.join("src.tar");
292    let archive_str = archive_path.to_string_lossy().to_string();
293    run_cmd(Command::new("git").current_dir(workspace_root).args([
294        "archive",
295        "--format=tar",
296        "--output",
297        &archive_str,
298        "HEAD",
299    ]))?;
300
301    run_cmd(
302        Command::new("tar")
303            .arg("-xf")
304            .arg(&archive_str)
305            .arg("-C")
306            .arg(temp_root),
307    )?;
308
309    // Drop the intermediate archive to keep the temp workspace clean.
310    fs::remove_file(&archive_path)
311        .with_context(|| format!("failed to remove {}", archive_path.display()))?;
312    Ok(())
313}