1use anyhow::{Context, Result, bail};
2use oxdock_fs::{GuardedPath, PathResolver, discover_workspace_root};
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::{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 let workspace_root = discover_workspace_root().context("guard workspace root")?;
17
18 let mut args = std::env::args().skip(1);
19 let opts = Options::parse(&mut args, &workspace_root)?;
20 execute(opts, workspace_root)
21}
22
23#[derive(Debug, Clone)]
24pub enum ScriptSource {
25 Path(GuardedPath),
26 Stdin,
27}
28
29#[derive(Debug, Clone)]
30pub struct Options {
31 pub script: ScriptSource,
32 pub shell: bool,
33}
34
35impl Options {
36 pub fn parse(
37 args: &mut impl Iterator<Item = String>,
38 workspace_root: &GuardedPath,
39 ) -> Result<Self> {
40 let mut script: Option<ScriptSource> = None;
41 let mut shell = false;
42 while let Some(arg) = args.next() {
43 if arg.is_empty() {
44 continue;
45 }
46 match arg.as_str() {
47 "--script" => {
48 let p = args
49 .next()
50 .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
51 if p == "-" {
52 script = Some(ScriptSource::Stdin);
53 } else {
54 script = Some(ScriptSource::Path(
55 workspace_root
56 .join(&p)
57 .with_context(|| format!("guard script path {p}"))?,
58 ));
59 }
60 }
61 "--shell" => {
62 shell = true;
63 }
64 other => bail!("unexpected flag: {}", other),
65 }
66 }
67
68 let script = script.unwrap_or(ScriptSource::Stdin);
69
70 Ok(Self { script, shell })
71 }
72}
73
74pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
75 execute_with_shell_runner(opts, workspace_root, run_shell, true)
76}
77
78fn execute_with_shell_runner<F>(
79 opts: Options,
80 workspace_root: GuardedPath,
81 shell_runner: F,
82 require_tty: bool,
83) -> Result<()>
84where
85 F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
86{
87 #[cfg(windows)]
88 maybe_reexec_shell_to_temp(&opts)?;
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 {
95 ScriptSource::Path(path) => {
96 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
99 resolver
100 .read_to_string(path)
101 .with_context(|| format!("failed to read script at {}", path.display()))?
102 }
103 ScriptSource::Stdin => {
104 let stdin = io::stdin();
105 if stdin.is_terminal() {
106 if opts.shell {
111 String::new()
112 } else {
113 bail!(
114 "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
115 );
116 }
117 } else {
118 let mut buf = String::new();
119 stdin
120 .lock()
121 .read_to_string(&mut buf)
122 .context("failed to read script from stdin")?;
123 buf
124 }
125 }
126 };
127
128 let mut final_cwd = temp_root.clone();
131 if !script.trim().is_empty() {
132 let steps = parse_script(&script)?;
133 final_cwd = run_steps_with_context_result(&temp_root, &workspace_root, &steps)?;
137 }
138
139 if opts.shell {
141 if require_tty && !has_controlling_tty() {
142 bail!("--shell requires a tty (no controlling tty available)");
143 }
144 return shell_runner(&final_cwd, &workspace_root);
145 }
146
147 Ok(())
148}
149
150#[cfg(test)]
151fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
152where
153 F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
154{
155 execute_with_shell_runner(opts, workspace_root, shell_runner, false)
156}
157
158fn has_controlling_tty() -> bool {
159 #[cfg(unix)]
163 {
164 io::stdin().is_terminal() || io::stderr().is_terminal()
165 }
166
167 #[cfg(windows)]
168 {
169 io::stdin().is_terminal() || io::stderr().is_terminal()
170 }
171
172 #[cfg(not(any(unix, windows)))]
173 {
174 false
175 }
176}
177
178#[cfg(windows)]
179fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
180 if !opts.shell {
183 return Ok(());
184 }
185 if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
186 return Ok(());
187 }
188
189 let self_path = std::env::current_exe().context("determine current executable")?;
190 let base_temp =
191 GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
192 let ts = std::time::SystemTime::now()
193 .duration_since(std::time::UNIX_EPOCH)
194 .unwrap_or_default()
195 .as_millis();
196 let temp_file = base_temp
197 .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
198 .context("construct temp shell path")?;
199
200 let temp_root_guard = temp_file
204 .parent()
205 .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
206 let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
207 let dest = temp_file;
208 #[allow(clippy::disallowed_types)]
209 let source = oxdock_fs::UnguardedPath::new(self_path);
210 resolver_temp
211 .copy_file_from_unguarded(&source, &dest)
212 .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
213
214 let mut cmd = CommandBuilder::new(dest.as_path());
215 cmd.args(std::env::args_os().skip(1));
216 cmd.env("OXDOCK_SHELL_REEXEC", "1");
217 cmd.spawn()
218 .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
219
220 std::process::exit(0);
222}
223
224pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
225 run_steps_with_context(workspace_root, workspace_root, steps)
226}
227
228fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
229 #[cfg(windows)]
230 let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
231 #[cfg(windows)]
232 let workspace_disp = oxdock_fs::command_path(workspace_root)
233 .as_ref()
234 .display()
235 .to_string();
236
237 #[cfg(not(windows))]
238 let cwd_disp = cwd.display().to_string();
239 #[cfg(not(windows))]
240 let workspace_disp = workspace_root.display().to_string();
241
242 let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
243 indoc::formatdoc! {"
244 {pkg} shell workspace
245 cwd: {cwd_disp}
246 source: workspace root at {workspace_disp}
247 lifetime: temporary directory created for this shell session; it disappears when you exit
248 creation: temp workspace starts empty unless your script copies files into it
249
250 WARNING: This shell still runs on your host filesystem and is **not** isolated!
251 "}
252}
253
254#[cfg(windows)]
255fn escape_for_cmd(s: &str) -> String {
256 s.replace('^', "^^")
258 .replace('&', "^&")
259 .replace('|', "^|")
260 .replace('>', "^>")
261 .replace('<', "^<")
262}
263
264#[cfg(windows)]
265fn windows_banner_command(banner: &str, cwd: &GuardedPath) -> String {
266 let mut parts: Vec<String> = banner
267 .lines()
268 .map(|line| format!("echo {}", escape_for_cmd(line)))
269 .collect();
270 let cwd_path = oxdock_fs::command_path(cwd);
271 parts.push(format!(
272 "cd /d {}",
273 escape_for_cmd(&cwd_path.as_ref().display().to_string())
274 ));
275 parts.join(" && ")
276}
277
278fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
280 let banner = shell_banner(cwd, workspace_root);
281
282 #[cfg(unix)]
283 {
284 let mut cmd = CommandBuilder::new(shell_program());
285 cmd.current_dir(cwd.as_path());
286
287 let script = format!("printf '%s\\n' \"{}\"; exec {}", banner, shell_program());
289 cmd.arg("-c").arg(script);
290
291 #[cfg(not(miri))]
294 {
295 #[allow(clippy::disallowed_types)]
296 let tty_path = oxdock_fs::UnguardedPath::new("/dev/tty");
297 if let Ok(resolver) =
298 PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
299 && let Ok(tty) = resolver.open_file_unguarded(&tty_path)
300 {
301 cmd.stdin_file(tty);
302 }
303 }
304
305 if try_shell_command_hook(&mut cmd)? {
306 return Ok(());
307 }
308
309 let status = cmd.status()?;
310 if !status.success() {
311 bail!("shell exited with status {}", status);
312 }
313 Ok(())
314 }
315
316 #[cfg(windows)]
317 {
318 let cwd_path = oxdock_fs::command_path(cwd);
322 let banner_cmd = windows_banner_command(&banner, cwd);
323 let mut cmd = CommandBuilder::new("cmd");
324 cmd.current_dir(cwd_path.as_ref())
325 .arg("/C")
326 .arg("start")
327 .arg("oxdock shell")
328 .arg("cmd")
329 .arg("/K")
330 .arg(banner_cmd);
331
332 if try_shell_command_hook(&mut cmd)? {
333 return Ok(());
334 }
335
336 cmd.spawn()
339 .context("failed to start interactive shell window")?;
340 Ok(())
341 }
342
343 #[cfg(not(any(unix, windows)))]
344 {
345 let _ = cwd;
346 bail!("interactive shell unsupported on this platform");
347 }
348}
349#[cfg(test)]
350type ShellCmdHook = dyn FnMut(&CommandSnapshot) -> Result<()> + Send;
351
352#[cfg(test)]
353thread_local! {
354 static SHELL_CMD_HOOK: std::cell::RefCell<Option<Box<ShellCmdHook>>> = std::cell::RefCell::new(None);
355}
356
357#[cfg(test)]
358fn set_shell_command_hook<F>(hook: F)
359where
360 F: FnMut(&CommandSnapshot) -> Result<()> + Send + 'static,
361{
362 SHELL_CMD_HOOK.with(|slot| {
363 *slot.borrow_mut() = Some(Box::new(hook));
364 });
365}
366
367#[cfg(test)]
368fn clear_shell_command_hook() {
369 SHELL_CMD_HOOK.with(|slot| {
370 *slot.borrow_mut() = None;
371 });
372}
373
374#[cfg(test)]
375fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
376 SHELL_CMD_HOOK.with(|slot| {
377 if let Some(hook) = slot.borrow_mut().as_mut() {
378 let snap = cmd.snapshot();
379 hook(&snap)?;
380 return Ok(true);
381 }
382 Ok(false)
383 })
384}
385
386#[cfg(not(test))]
387fn try_shell_command_hook(_cmd: &mut CommandBuilder) -> Result<bool> {
388 Ok(false)
389}
390
391#[cfg(test)]
394mod tests {
395 use super::*;
396 use indoc::indoc;
397 use oxdock_fs::PathResolver;
398 use std::cell::Cell;
399
400 #[cfg_attr(
401 miri,
402 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
403 )]
404 #[test]
405 fn shell_runner_receives_final_workdir() -> Result<()> {
406 let workspace = GuardedPath::tempdir()?;
407 let workspace_root = workspace.as_guarded_path().clone();
408 let script_path = workspace_root.join("script.ox")?;
409 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
410 let script = indoc! {"
411 WRITE temp.txt 123
412 WORKDIR sub
413 "};
414 resolver.write_file(&script_path, script.as_bytes())?;
415
416 let opts = Options {
417 script: ScriptSource::Path(script_path),
418 shell: true,
419 };
420
421 let observed = Cell::new(false);
422 execute_for_test(opts, workspace_root.clone(), |cwd, _| {
423 assert!(
424 cwd.as_path().ends_with("sub"),
425 "final cwd should end in WORKDIR target, got {}",
426 cwd.display()
427 );
428
429 let temp_root = GuardedPath::new_root(cwd.root())
430 .context("construct guard for temp workspace root")?;
431 let sub_dir = temp_root.join("sub")?;
432 assert_eq!(
433 cwd.as_path(),
434 sub_dir.as_path(),
435 "shell runner cwd should match guarded sub dir"
436 );
437 let temp_file = temp_root.join("temp.txt")?;
438 let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
439 let contents = temp_resolver.read_to_string(&temp_file)?;
440 assert!(
441 contents.contains("123"),
442 "expected WRITE command to materialize temp file"
443 );
444 observed.set(true);
445 Ok(())
446 })?;
447
448 assert!(
449 observed.into_inner(),
450 "shell runner closure should have been invoked"
451 );
452 Ok(())
453 }
454
455 #[cfg(any(unix, windows))]
456 #[test]
457 fn run_shell_builds_command_for_platform() -> Result<()> {
458 let workspace = GuardedPath::tempdir()?;
459 let workspace_root = workspace.as_guarded_path().clone();
460 let cwd = workspace_root.join("subdir")?;
461 #[cfg(not(miri))]
462 {
463 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
464 resolver.create_dir_all(&cwd)?;
465 }
466
467 let captured = std::sync::Arc::new(Mutex::new(None::<CommandSnapshot>));
468 let guard = captured.clone();
469 set_shell_command_hook(move |cmd| {
470 *guard.lock().unwrap() = Some(cmd.clone());
471 Ok(())
472 });
473 run_shell(&cwd, &workspace_root)?;
474 clear_shell_command_hook();
475
476 let snap = captured
477 .lock()
478 .unwrap()
479 .clone()
480 .expect("hook should capture snapshot");
481 let cwd_path = snap.cwd.expect("cwd should be set");
482 assert!(
483 cwd_path.ends_with("subdir"),
484 "expected cwd to include subdir, got {}",
485 cwd_path.display()
486 );
487
488 #[cfg(unix)]
489 {
490 let program = snap.program.to_string_lossy();
491 assert_eq!(program, shell_program(), "expected shell program name");
492 let args: Vec<_> = snap
493 .args
494 .iter()
495 .map(|s| s.to_string_lossy().to_string())
496 .collect();
497 assert_eq!(
498 args.len(),
499 2,
500 "expected two args (-c script), got {:?}",
501 args
502 );
503 assert_eq!(args[0], "-c");
504 assert!(
505 args[1].contains("exec"),
506 "expected script to exec the shell, got {:?}",
507 args[1]
508 );
509 }
510
511 #[cfg(windows)]
512 {
513 let program = snap.program.to_string_lossy().to_string();
514 assert_eq!(program, "cmd", "expected cmd.exe launcher");
515 let args: Vec<_> = snap
516 .args
517 .iter()
518 .map(|s| s.to_string_lossy().to_string())
519 .collect();
520 let banner_cmd = windows_banner_command(&shell_banner(&cwd, &workspace_root), &cwd);
521 let expected = vec![
522 "/C".to_string(),
523 "start".to_string(),
524 "oxdock shell".to_string(),
525 "cmd".to_string(),
526 "/K".to_string(),
527 banner_cmd,
528 ];
529 assert_eq!(args, expected, "expected exact windows shell argv");
530 }
531
532 Ok(())
533 }
534}
535
536#[cfg(all(test, windows))]
537mod windows_shell_tests {
538 use super::*;
539 use oxdock_fs::PathResolver;
540
541 #[test]
542 fn command_path_strips_verbatim_prefix() -> Result<()> {
543 let temp = GuardedPath::tempdir()?;
544 let converted = oxdock_fs::command_path(temp.as_guarded_path());
545 let as_str = converted.as_ref().display().to_string();
546 assert!(
547 !as_str.starts_with(r"\\?\"),
548 "expected non-verbatim path, got {as_str}"
549 );
550 Ok(())
551 }
552
553 #[test]
554 fn windows_banner_command_emits_all_lines() {
555 let banner = "line1\nline2\nline3";
556 let workspace = GuardedPath::tempdir().expect("tempdir");
557 let cwd = workspace.as_guarded_path().clone();
558 let cmd = windows_banner_command(banner, &cwd);
559 assert!(cmd.contains("line1"));
560 assert!(cmd.contains("line2"));
561 assert!(cmd.contains("line3"));
562 assert!(cmd.contains("cd /d "));
563 }
564
565 #[test]
566 fn run_shell_builds_windows_command() -> Result<()> {
567 let workspace = GuardedPath::tempdir_with(|builder| {
568 builder.prefix("oxdock shell win ");
569 })?;
570 let workspace_root = workspace.as_guarded_path().clone();
571 let cwd = workspace_root.join("subdir")?;
572 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
573 resolver.create_dir_all(&cwd)?;
574
575 let captured = std::sync::Arc::new(Mutex::new(None::<CommandSnapshot>));
576 let guard = captured.clone();
577 set_shell_command_hook(move |cmd| {
578 *guard.lock().unwrap() = Some(cmd.clone());
579 Ok(())
580 });
581 run_shell(&cwd, &workspace_root)?;
582 clear_shell_command_hook();
583
584 let snap = captured
585 .lock()
586 .unwrap()
587 .clone()
588 .expect("hook should capture snapshot");
589 let program = snap.program.to_string_lossy().to_string();
590 assert_eq!(program, "cmd", "expected cmd.exe launcher");
591 let args: Vec<_> = snap
592 .args
593 .iter()
594 .map(|s| s.to_string_lossy().to_string())
595 .collect();
596 let banner_cmd = windows_banner_command(&shell_banner(&cwd, &workspace_root), &cwd);
597 let expected = vec![
598 "/C".to_string(),
599 "start".to_string(),
600 "oxdock shell".to_string(),
601 "cmd".to_string(),
602 "/K".to_string(),
603 banner_cmd,
604 ];
605 assert_eq!(args, expected, "expected exact windows shell argv");
606 let cwd_path = snap.cwd.expect("cwd should be set");
607 assert!(
608 cwd_path.ends_with("subdir"),
609 "expected cwd to include subdir, got {}",
610 cwd_path.display()
611 );
612 Ok(())
613 }
614}