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 let script = match &opts.script {
141 ScriptSource::Path(path) => {
142 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 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 let mut final_cwd = temp_root.clone();
177 if !script.trim().is_empty() {
178 let steps = parse_script(&script)?;
179 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 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 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 #[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 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 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 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#[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}