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 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
109pub 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 let script = match &opts.script {
190 ScriptSource::Path(path) => {
191 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 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 let mut final_cwd = temp_root.clone();
226 if !script.trim().is_empty() {
227 let steps = parse_script(&script)?;
228 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 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 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 #[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 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 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 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#[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 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 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}