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 if prepare_snapshot {
99 copy_workspace_to(&workspace_root, &temp_root).context("failed to snapshot workspace")?;
100 }
101
102 let script = match &opts.script {
104 ScriptSource::Path(path) => {
105 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 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 let mut final_cwd = temp_root.clone();
140 if !script.trim().is_empty() {
141 let steps = parse_script(&script)?;
142 final_cwd = run_steps_with_context_result(&temp_root, &workspace_root, &steps)?;
146 }
147
148 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 #[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 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 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 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 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 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
316fn 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 let script = format!("printf '%s\\n' \"{}\"; exec {}", banner, shell_program());
327 cmd.arg("-c").arg(script);
328
329 #[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 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 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#[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 #[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 #[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}